Een dating-app op basis van brieven, niet van gezichten!

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

16.0 kB Ruw bestand
import { useState } from "react";
import { INK, RED, BLUE, PAPER, SKY, MUTE, FONT_MONO, FONT_SERIF } from "../../theme";
import { GENDER_LABEL, RELATIONSHIP_LABEL, genderLabel, AGE_MIN, AGE_MAX, BIO_MAX } from "../../constants";
import { fmtDate, agesToBirthRange } from "../../lib/dates";
import { currentLocation } from "../../lib/location";
import { locatieFout } from "../../lib/plugin-fouten";
import DataSection from "./DataSection";
import AccountSection from "./AccountSection";
import PostSection from "./PostSection";

/*
 * Profiel bewerken: leeftijden voor, geboortedata achter. onSave(profile) → Promise.
 * `onboarding`: stap 2 na registratie (andere kop en knoptekst, geen uitloggen/AVG-sectie).
 * `blocked`: [{ user, since }] — wie ik geblokkeerd heb; onUnblock(userId) → Promise. Alleen getoond als er iemand in staat.
 * `onUpdateAccount(changes)` → Promise<me>: naam/e-mail/geboortedatum/gender/wachtwoord (zie AccountSection).
 * `onLogoutOthers(currentPassword)` → Promise<{ loggedOut }>: overal elders uitloggen, dit toestel blijft.
 * `onExport()` → Promise<export> en `onDelete(password, farewell)` → Promise: de AVG-knoppen onderaan (zie DataSection).
 * `token` en `lastLetterId`: voor de meldingsinstelling (PostSection); in de browser doet die niets.
 */
export default function ProfileEditor({ me, onboarding = false, onSave, onLogout, blocked = [], onUnblock, onUpdateAccount, onLogoutOthers, onExport, onDelete, token, lastLetterId = "0" }) {
  const [unblocking, setUnblocking] = useState({ id: null, error: null });
  const unblock = async (userId) => {
    setUnblocking({ id: userId, error: null });
    try {
      await onUnblock(userId);
      setUnblocking({ id: null, error: null });
    } catch (e) {
      setUnblocking({ id: null, error: e.message });
    }
  };
  const [text, setText] = useState(me.text);
  const [hidden, setHidden] = useState(me.hidden ?? false);
  const [relationship, setRelationship] = useState(me.relationship);
  const [seekRelatie, setSeekRelatie] = useState(me.filter.seekRelatie);
  const [seekTrans, setSeekTrans] = useState(me.filter.seekTrans);
  const [aMin, setAMin] = useState(me.filter.ageMin);
  const [aMax, setAMax] = useState(me.filter.ageMax);
  const [genders, setGenders] = useState(me.filter.genders);
  const [lat, setLat] = useState(me.location?.lat ?? "");
  const [lon, setLon] = useState(me.location?.lon ?? "");
  const [useRadius, setUseRadius] = useState(me.filter.radiusKm != null);
  const [radiusKm, setRadiusKm] = useState(me.filter.radiusKm ?? 25);
  const [geoStatus, setGeoStatus] = useState(null); // null | "bezig" | de reden waarom het niet lukte
  const [saveState, setSaveState] = useState({ busy: false, saved: false, error: null });

  /* Een profiel zonder tekst verschijnt niet in Zoeken (zie MUTUAL_MATCH); dat hoort de schrijver te weten */
  const leeg = text.trim() === "";

  const range = agesToBirthRange(Math.min(aMin, aMax), Math.max(aMin, aMax));
  const toggle = (g) => setGenders((gs) => (gs.includes(g) ? gs.filter((x) => x !== g) : [...gs, g]));

  const hasLocation = lat !== "" && lon !== "" && Number.isFinite(+lat) && Number.isFinite(+lon);
  const location = hasLocation ? { lat: +lat, lon: +lon } : null;
  const radiusActive = useRadius && hasLocation && radiusKm > 0;

  const locate = async () => {
    setGeoStatus("bezig");
    try {
      const pos = await currentLocation();
      setLat(pos.lat);
      setLon(pos.lon);
      setGeoStatus(null);
    } catch (e) {
      setGeoStatus(locatieFout(e));
    }
  };

  const save = async () => {
    setSaveState({ busy: true, saved: false, error: null });
    try {
      await onSave({
        text,
        hidden,
        relationship,
        genders,
        seekRelatie,
        seekTrans,
        ageMin: Math.min(aMin, aMax),
        ageMax: Math.max(aMin, aMax),
        location,
        radiusKm: radiusActive ? Math.round(radiusKm) : null,
      });
      setSaveState({ busy: false, saved: true, error: null });
      setTimeout(() => setSaveState((s) => ({ ...s, saved: false })), 2000);
    } catch (e) {
      setSaveState({ busy: false, saved: false, error: e.message });
    }
  };
  const { busy, saved, error } = saveState;

  const numberInput = { width: 64, padding: 8, border: `2px solid ${INK}`, fontSize: 15 };
  const coordInput = { ...numberInput, width: 96 };
  /*
   * Een getalveld is nooit leeg: haal je het cijfer weg, dan staat er 0 (of de oude waarde) en moet
   * je die er eerst uit peuteren voor je kunt typen. Bij focus alles selecteren maakt van "wijzigen"
   * weer "intypen" — de eerste toets vervangt wat er stond.
   */
  const selectAll = (e) => e.target.select();
  const smallButton = { background: "#fff", color: INK, border: `2px solid ${INK}`, padding: "6px 10px", fontSize: 13, fontWeight: 600 };
  const chip = (active) => ({
    padding: "8px 14px", border: `2px solid ${INK}`, fontWeight: 600, fontSize: 14,
    background: active ? INK : "#fff", color: active ? "#fff" : INK,
  });

  return (
    <main className="app-main">
      <h2 style={{ margin: "0 0 4px", fontSize: 22 }}>{onboarding ? "Welkom, " + me.name : "Mijn profiel"}</h2>
      <p style={{ fontFamily: FONT_MONO, fontSize: 11, color: MUTE, margin: "0 0 14px" }}>
        {onboarding ? "stap 2 van 2 · " : ""}alleen tekst — je foto's verstuur je per brief, één per brief
      </p>
      {onboarding && (
        <p style={{ background: SKY, border: `1px solid ${INK}`, padding: "10px 12px", fontSize: 14, margin: "0 0 16px", lineHeight: 1.5 }}>
          Schrijf in een paar zinnen wie je bent en wat je zoekt, en stel in wie jij wilt zien.
          Op deze woorden word je gevonden — je profiel is tekst, foto's horen in brieven.
        </p>
      )}
      {/* ---------- Over mij: identiteit, relatiestatus, tekst, locatie ---------- */}
      <h3 style={{ margin: "0 0 6px", fontSize: 16 }}>Over mij</h3>
      {/* buiten onboarding staat deze regel bij Account (verderop), waar hij ook te wijzigen is */}
      {onboarding && (
        <p style={{ fontFamily: FONT_MONO, fontSize: 11, color: MUTE, margin: "0 0 10px" }}>
          {me.name} · {genderLabel(me)} · {me.age} jaar · {me.email}
        </p>
      )}
      <div style={{ display: "flex", gap: 8, marginBottom: 6, flexWrap: "wrap" }}>
        {Object.entries(RELATIONSHIP_LABEL).map(([r, label]) => (
          <button key={r} onClick={() => setRelationship(r)} aria-pressed={relationship === r} style={chip(relationship === r)}>
            {label}
          </button>
        ))}
      </div>
      <p style={{ fontSize: 13, color: MUTE, margin: "0 0 14px" }}>
        {relationship === "relatie"
          ? "Je bent alleen zichtbaar voor mensen die ook mensen in een relatie willen zien."
          : "Geef het aan als je al een relatie hebt — anderen kiezen zelf of ze je dan willen zien."}
      </p>
      <label style={{ fontWeight: 700, fontSize: 14 }}>
        Profieltekst
        <textarea
          value={text}
          onChange={(e) => setText(e.target.value)}
          rows={6}
          maxLength={BIO_MAX}
          autoFocus={onboarding}
          style={{
            width: "100%", marginTop: 6, padding: 12, fontSize: 15, fontFamily: FONT_SERIF,
            lineHeight: 1.6, border: `2px solid ${INK}`, background: PAPER, resize: "vertical",
          }}
        />
      </label>
      <p style={{ fontSize: 13, color: MUTE, margin: "6px 0 10px" }}>
        {leeg
          ? "Zonder profieltekst word je niet gevonden: een leeg profiel verschijnt niet in Zoeken."
          : "Wees concreet: op deze woorden word je gevonden."}
      </p>
      {/*
        * Verbergen hoort hier, onder de tekst waar het over gaat, en niet bij de AVG-knoppen onderaan:
        * dit is geen vertrek maar een pauze. Wat er verandert staat erbij, want "verbergen" alleen
        * laat in het midden of je post nog binnenkomt — en dat is precies de vraag die je hebt.
        */}
      <label style={{ display: "flex", gap: 8, alignItems: "center", fontSize: 14, marginBottom: 4 }}>
        <input type="checkbox" checked={hidden} onChange={(e) => setHidden(e.target.checked)} />
        Profiel tijdelijk verbergen
      </label>
      <p style={{ fontSize: 13, color: MUTE, margin: "0 0 14px" }}>
        Je verschijnt niet in Zoeken en niemand kan je een eerste brief sturen. Je lopende
        correspondenties blijven gewoon doorgaan.
      </p>

      <p style={{ fontWeight: 700, fontSize: 14, margin: "0 0 6px" }}>
        Locatie <span style={{ fontWeight: 400, color: MUTE }}>(optioneel)</span>
      </p>
      <div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap", marginBottom: 6 }}>
        <label style={{ fontSize: 14 }}>
          lat&nbsp;
          <input type="number" step="0.01" min="-90" max="90" value={lat} onChange={(e) => setLat(e.target.value)}
            onFocus={selectAll} style={coordInput} aria-label="Breedtegraad" />
        </label>
        <label style={{ fontSize: 14 }}>
          lon&nbsp;
          <input type="number" step="0.01" min="-180" max="180" value={lon} onChange={(e) => setLon(e.target.value)}
            onFocus={selectAll} style={coordInput} aria-label="Lengtegraad" />
        </label>
        <button onClick={locate} disabled={geoStatus === "bezig"} style={smallButton}>
          {geoStatus === "bezig" ? "…" : "◎ Mijn locatie"}
        </button>
        {hasLocation && (
          <button onClick={() => { setLat(""); setLon(""); setUseRadius(false); }} style={{ ...smallButton, border: "none", color: RED, padding: 0 }}>
            wissen
          </button>
        )}
      </div>
      {geoStatus !== null && geoStatus !== "bezig" && (
        <p role="alert" style={{ fontSize: 13, color: RED, margin: "0 0 8px" }}>
          {geoStatus} Je kunt de coördinaten ook zelf invullen.
        </p>
      )}
      <p style={{ fontSize: 13, color: MUTE, margin: "0 0 24px" }}>
        {hasLocation
          ? "Anderen zien de afstand tot jou; je locatie niet — en die bewaren we afgerond op ongeveer 1 km. Hieronder kun je er ook op filteren."
          : "Vul je locatie in om afstanden te zien en eventueel op afstand te filteren. We bewaren hem afgerond op ongeveer 1 km."}
      </p>

      {/* ---------- Wie ik wil zien: het filter (wederzijds) ---------- */}
      <h3 style={{ margin: "0 0 8px", fontSize: 16 }}>Wie ik wil zien</h3>
      <div style={{ display: "flex", gap: 8, marginBottom: 12, flexWrap: "wrap" }}>
        {Object.entries(GENDER_LABEL).map(([g, label]) => (
          <button key={g} onClick={() => toggle(g)} aria-pressed={genders.includes(g)} style={chip(genders.includes(g))}>
            {label}
          </button>
        ))}
      </div>
      <div style={{ display: "flex", gap: 12, alignItems: "center", marginBottom: 6 }}>
        <label style={{ fontSize: 14 }}>
          van&nbsp;
          <input type="number" min={AGE_MIN} max={AGE_MAX} value={aMin} onChange={(e) => setAMin(+e.target.value)}
            onFocus={selectAll} style={numberInput} aria-label="Minimumleeftijd" />
        </label>
        <label style={{ fontSize: 14 }}>
          tot en met&nbsp;
          <input type="number" min={AGE_MIN} max={AGE_MAX} value={aMax} onChange={(e) => setAMax(+e.target.value)}
            onFocus={selectAll} style={numberInput} aria-label="Maximumleeftijd" />
        </label>
        <span style={{ fontSize: 14 }}>jaar</span>
      </div>
      <p style={{ fontFamily: FONT_MONO, fontSize: 11, color: MUTE, margin: "0 0 12px" }}>
        opgeslagen als geboortedatumbereik: {fmtDate(range.minBirth)} t/m {fmtDate(range.maxBirth)}
      </p>
      <label style={{ display: "flex", gap: 8, alignItems: "center", fontSize: 14, marginBottom: 8 }}>
        <input type="checkbox" checked={seekRelatie} onChange={(e) => setSeekRelatie(e.target.checked)} />
        ook mensen die al in een relatie zijn
      </label>
      <label style={{ display: "flex", gap: 8, alignItems: "center", fontSize: 14, marginBottom: 8 }}>
        <input type="checkbox" checked={seekTrans} onChange={(e) => setSeekTrans(e.target.checked)} />
        ook trans personen
      </label>
      <label style={{ display: "flex", gap: 8, alignItems: "center", fontSize: 14, marginBottom: 6, opacity: hasLocation ? 1 : 0.5 }}>
        <input type="checkbox" checked={useRadius} disabled={!hasLocation} onChange={(e) => setUseRadius(e.target.checked)} />
        alleen binnen&nbsp;
        <input type="number" min="1" max="20000" value={radiusKm} disabled={!useRadius || !hasLocation}
          onChange={(e) => setRadiusKm(+e.target.value)} onFocus={selectAll} style={numberInput} aria-label="Straal in kilometers" />
        &nbsp;km van mij
      </label>
      {hasLocation && useRadius && (
        <p style={{ fontSize: 13, color: MUTE, margin: "0 0 6px" }}>
          Mét straal zie je alleen mensen die hun locatie hebben ingevuld — en zij jou alleen als jij binnen hún straal valt.
        </p>
      )}
      <p style={{ fontSize: 13, color: MUTE, margin: "8px 0 20px" }}>
        Je filter bepaalt ook wie jóu kan vinden: je verschijnt alleen bij mensen bij wie jullie filters wederzijds passen.
      </p>

      <button
        onClick={save}
        disabled={busy}
        style={{
          background: saved ? BLUE : RED, color: "#fff", border: "none", padding: "12px 22px", fontSize: 15, fontWeight: 700,
          opacity: busy ? 0.7 : 1, width: onboarding ? "100%" : undefined,
        }}
      >
        {busy ? "Opslaan…" : saved ? "✓ Opgeslagen" : onboarding ? "Opslaan en zoeken →" : "Profiel opslaan"}
      </button>
      {error && (
        <p role="alert" style={{ fontFamily: FONT_MONO, fontSize: 12, color: RED, margin: "10px 0 0" }}>fout: {error}</p>
      )}

      {!onboarding && onUpdateAccount && (
        <AccountSection key={me.email + me.name + me.gender + me.trans + me.age} me={me}
          onSave={onUpdateAccount} onLogoutOthers={onLogoutOthers} />
      )}

      {/* De melding zelf werkt alleen in de app op een toestel; het scherm staat er in de browser wel */}
      {!onboarding && token && <PostSection token={token} getSince={() => lastLetterId} />}

      {!onboarding && blocked.length > 0 && (
        <div style={{ marginTop: 36, paddingTop: 16, borderTop: `1px solid ${SKY}` }}>
          <h3 style={{ margin: "0 0 4px", fontSize: 16 }}>Geblokkeerd</h3>
          <p style={{ fontSize: 13, color: MUTE, margin: "0 0 10px" }}>
            Deze mensen kunnen je niet schrijven en zien je niet in Zoeken; hun correspondentie is verborgen, niet gewist.
            Deblokkeren brengt alles terug.
          </p>
          {blocked.map(({ user, since }) => (
            <div key={user.id} style={{ display: "flex", alignItems: "center", gap: 12, padding: "8px 0", borderBottom: `1px solid ${SKY}` }}>
              <div style={{ flex: 1, minWidth: 0 }}>
                <strong style={{ fontSize: 15 }}>{user.name}</strong>
                <span style={{ fontFamily: FONT_MONO, fontSize: 10, color: MUTE, marginLeft: 8 }}>sinds {fmtDate(since)}</span>
              </div>
              <button onClick={() => unblock(user.id)} disabled={unblocking.id === user.id} style={{ ...smallButton, opacity: unblocking.id === user.id ? 0.6 : 1 }}>
                deblokkeer
              </button>
            </div>
          ))}
          {unblocking.error && (
            <p role="alert" style={{ fontFamily: FONT_MONO, fontSize: 12, color: RED, margin: "8px 0 0" }}>fout: {unblocking.error}</p>
          )}
        </div>
      )}

      {!onboarding && onLogout && (
        <div style={{ marginTop: 36, paddingTop: 16, borderTop: `1px solid ${SKY}` }}>
          <button
            onClick={onLogout}
            style={{ background: "none", border: `2px solid ${MUTE}`, color: MUTE, padding: "8px 14px", fontSize: 13, fontWeight: 600 }}
          >
            Uitloggen
          </button>
          <p style={{ fontFamily: FONT_MONO, fontSize: 11, color: MUTE, margin: "8px 0 0" }}>
            ingelogd als {me.email} — op dit toestel
          </p>
        </div>
      )}

      {/* ---------- AVG: inzage en verwijderen — helemaal onderaan, na uitloggen ---------- */}
      {!onboarding && onExport && onDelete && <DataSection email={me.email} onExport={onExport} onDelete={onDelete} />}
    </main>
  );
}