Een dating-app op basis van brieven, niet van gezichten!
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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
<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
<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
<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
<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
<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" />
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>
);
}