import { useEffect, useState } from "react"; import { INK, RED, BLUE, PAPER, SKY, MUTE, FONT_MONO } from "../../theme"; import { GENDER_LABEL, genderLabel, PASSWORD_MIN, PASSWORD_MAX, NAME_MAX } from "../../constants"; import { api } from "../../api/client"; import { canPay, buySlot, priceOf, pendingPurchase, clearPendingPurchase, purchaseIsStale } from "../../lib/billing"; import { log } from "../../lib/log"; import Envelope from "../../components/Envelope"; import DateFields from "../../components/DateFields"; import PrivacyDialog from "../privacy/PrivacyDialog"; /* * Inloggen of een account aanmaken. onDone(user, { isNew }) na succes; het * sessietoken bewaart de API-client zelf (lib/session.js). * Registreren vereist uitdrukkelijke toestemming voor de gevoelige gegevens (AVG art. 9): * een losse, niet vooraf aangevinkte checkbox met de privacyverklaring erbij. * * Een account kost een plek. Zolang er gratis plekken zijn merkt niemand daar iets van; zijn ze op, * dan gaat het formulier via de kassa van de store — eerst nakijken, dan betalen, dan aanmaken (zie * src/lib/billing.js voor waarom die volgorde er zo toe doet). Er is met opzet geen teller te zien: * wie te laat komt hoeft niet te weten wat hij gemist heeft, hij hoort gewoon wat het kost. */ export default function AuthView({ onDone }) { const [mode, setMode] = useState("login"); // "login" | "register" const [forgot, setForgot] = useState(false); // onderstand van "login": alleen een e-mailadres, voor een herstel-link const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [name, setName] = useState(""); const [gender, setGender] = useState(null); const [trans, setTrans] = useState(false); // onafhankelijke toggle bovenop het gender const [birthdate, setBirthdate] = useState(null); // 'YYYY-MM-DD' zodra de drie velden een echte datum vormen const [consent, setConsent] = useState(false); const [privacyOpen, setPrivacyOpen] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [note, setNote] = useState(null); // geen fout, wel iets om te weten (afgebroken betaling, bon die klaarstaat) /* null = nog niet opgevraagd; daarna { free, productId } van de server */ const [signup, setSignup] = useState(null); const [price, setPrice] = useState(null); // zoals de store hem schrijft ("€ 3,99"), of null const [pending, setPending] = useState(() => pendingPurchase()); const registering = mode === "register"; const paying = signup?.free === false; const canSubmit = !busy && email.trim() && (forgot || password.length >= (registering ? PASSWORD_MIN : 1)) && (!registering || (name.trim() && gender && birthdate && consent)); /* * Wat een plek nu kost, opgehaald zodra iemand aan registreren begint. Faalt dit (geen netwerk), * dan blijft `signup` null en gaat de app uit van gratis: de server zegt het anders wel met 402, * en dan schakelt submit() alsnog over. Beter dat dan een betaalscherm tonen op een gok. */ useEffect(() => { if (!registering || signup) return; let alive = true; api.signupStatus() .then(async (s) => { if (!alive) return; /* * Eerst de prijs ophalen, dán pas `signup` zetten. Andersom zet `setSignup` een nieuwe * waarde in de dependency van dit effect, waarop React de opruimfunctie hieronder draait * (`alive = false`) terwijl de store nog antwoordt — en dan valt de prijs op de grond en * staat er "een bedrag" terwijl de store gewoon "€ 5,00" teruggaf. */ const p = s.free === false && s.productId ? await priceOf(s.productId) : null; if (!alive) return; setPrice(p); setSignup(s); }) .catch((e) => log("✗ signupStatus", e?.message ?? String(e))); return () => { alive = false; }; }, [registering, signup]); const switchMode = (m) => { setMode(m); setForgot(false); setError(null); setNote(null); }; /* * De betaalde weg, in de enige volgorde die klopt: eerst het formulier laten nakijken (anders kan * iemand betalen voor een e-mailadres dat al bezet is), dan de kassa, dan pas aanmaken. Het * bonnetje blijft bewaard tot er een account op staat — zie src/lib/billing.js. */ const registerPaid = async (account, productId) => { await api.checkAccount(account); const purchase = await buySlot(productId); setPending(purchase); try { const user = await api.register({ ...account, purchase }); clearPendingPurchase(); setPending(null); return user; } catch (err) { /* * Weggooien mag alleen als het bonnetje zelf niets meer waard is: `purchase_used` (er staat al * een account op) of `purchase_dead` (Google heeft de betaling geannuleerd of teruggedraaid). * Bij "e-mailadres bezet" is er betaald en is hij nog wél wat waard, en bij een betaling die nog * loopt (`purchase_pending`) ook. * * `purchase_unknown` is het geval ertussenin: vlak na het betalen kent Google zijn eigen aankoop * soms nog niet, en dán weggooien vernietigt het bewijs van iemand die echt betaald heeft. Maar * een token dat nooit heeft bestaan blijft anders eeuwig opnieuw ingediend worden. Vandaar de * leeftijd: een etmaal krijgt hij, daarna niet meer (zie PURCHASE_STALE_MS). * * Dit is geen detail: een bon die nooit meer goed komt en tóch bewaard blijft, wordt bij elke * volgende poging opnieuw ingediend — en dan komt de gebruiker er nooit meer voorbij. Dat is * precies hoe "Deze betaling is niet doorgegaan" een toestel onbruikbaar maakte voor registreren. */ const onbekendEnOud = err.code === "purchase_unknown" && purchaseIsStale(purchase); if (err.code === "purchase_used" || err.code === "purchase_dead" || onbekendEnOud) { clearPendingPurchase(); setPending(null); } throw err; } }; const submit = async (e) => { e.preventDefault(); if (!canSubmit) return; setBusy(true); setError(null); setNote(null); try { /* * Wachtwoord vergeten: de server mailt een link naar /wachtwoord op de website; daar wordt * het nieuwe wachtwoord gezet. Het antwoord is altijd hetzelfde — of het adres bestaat is * niets dat dit scherm mag verklappen. */ if (!registering && forgot) { await api.forgot(email.trim()); setNote("Als dit adres bij ons bekend is, ligt er nu een brief in je mail. De link erin werkt een uur; daarna log je hier in met je nieuwe wachtwoord."); setForgot(false); setBusy(false); return; } if (!registering) { onDone(await api.login(email.trim(), password), { isNew: false }); return; } const account = { email: email.trim(), password, name: name.trim(), gender, trans, birthdate, consent }; let status = signup; /* * Gratis proberen zolang we geen reden hebben om iets anders te denken. Is de bak tussen het * opvragen en nu leeggeraakt, dan antwoordt de server 402 en gaan we alsnog langs de kassa — * dat scheelt de app een voorraad die toch nooit actueel is. */ if (status?.free !== false && !pending) { try { onDone(await api.register(account), { isNew: true }); return; } catch (err) { if (err.status !== 402) throw err; status = await api.signupStatus(); setSignup(status); setPrice(status.productId ? await priceOf(status.productId) : null); } } const productId = status?.productId ?? pending?.productId; if (!productId) throw new Error("De server kon niet zeggen wat een account kost. Probeer het zo nog eens."); onDone(await registerPaid(account, productId), { isNew: true }); } catch (err) { /* `zacht`: er is niets kapot, dus een mededeling en geen rode storing. De zin komt mee. */ if (err.zacht) setNote(err.message); else setError(err.message); setBusy(false); } }; const input = { width: "100%", padding: "10px 12px", fontSize: 15, border: `2px solid ${INK}`, background: PAPER, borderRadius: 0, marginTop: 6, }; const label = { display: "block", fontWeight: 700, fontSize: 14, marginBottom: 14 }; const tab = (active) => ({ flex: 1, padding: "10px 4px", border: `2px solid ${INK}`, fontSize: 14, fontWeight: 700, background: active ? INK : "#fff", color: active ? "#fff" : INK, }); const chip = (active) => ({ padding: "8px 14px", border: `2px solid ${INK}`, fontWeight: 600, fontSize: 14, background: active ? INK : "#fff", color: active ? "#fff" : INK, }); return (
{registering && ( <>

Stap 1 van 2: je account. Daarna schrijf je je profieltekst en stel je in wie je zoekt.

{/* Wat een plek kost. Alleen te zien als er echt betaald moet worden — zolang er gratis plekken zijn staat hier niets, en er staat nooit een teller: dat er ooit gratis plekken wáren is niets dat iemand hoeft te missen. Hier staat de prijs en verder niets. Waaróm een account geld kost is een keuze van de bouwer en geen mededeling aan de koper: wie uitlegt dat het tegen nepaccounts is, verontschuldigt zich voor de prijs én vertelt ondertussen wat de drempel moet doen. De redenering staat in src/constants.js, waar ze thuishoort. */} {pending ? (

Je betaling staat klaar. Vul je gegevens in, dan maken we je account af — je betaalt niet nog een keer.

) : ( paying && (

Een account aanmaken kost eenmalig {price ?? "een bedrag"}, via {canPay() ? "de store" : "de app"}. Je betaalt één keer; het account blijft van jou.

) )} )} {!registering && forgot && (

Vul je e-mailadres in, dan sturen we je een brief met een link om een nieuw wachtwoord in te stellen.

)} {!forgot && ( )} {registering && ( <>

Ik ben

{Object.entries(GENDER_LABEL).map(([g, text]) => ( ))} {/* streepje: 'trans' staat los van de keuze uit drie — "trans man", "trans vrouw", … */}

{gender ? `je staat als: ${genderLabel({ gender, trans })}` : "kies één van drie; 'trans' kan er altijd bij"} · later te wijzigen bij Mijn profiel {trans && <> · je verschijnt alleen bij mensen die openstaan voor trans personen}

Geboortedatum

dag – maand – jaar · je moet 18 jaar of ouder zijn · anderen zien alleen je leeftijd

{/* AVG art. 9: uitdrukkelijke toestemming — apart, niet vooraf aangevinkt, met de verklaring erbij */} )} {error && (

fout: {error}

)} {/* Een afgebroken betaling is geen storing: wel melden, niet als fout */} {note && (

{note}

)}
{!registering && (

{forgot ? ( ) : ( )}

)}

{registering ? ( <>Al een account?{" "} ) : ( <>Nog geen account?{" "} )}

{!registering && (

)} {privacyOpen && setPrivacyOpen(false)} />}
); }