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.

17.6 kB Ruw bestand
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 (
    <main className="app-main" style={{ paddingTop: 8 }}>
      <div style={{ textAlign: "center", margin: "6px 0 18px" }}>
        <Envelope width={120} id="auth" />
      </div>

      <div role="tablist" aria-label="Inloggen of account aanmaken" style={{ display: "flex", marginBottom: 20 }}>
        <button role="tab" aria-selected={!registering} onClick={() => switchMode("login")} style={{ ...tab(!registering), borderRight: "none" }}>
          Inloggen
        </button>
        <button role="tab" aria-selected={registering} onClick={() => switchMode("register")} style={tab(registering)}>
          Account aanmaken
        </button>
      </div>

      <form onSubmit={submit} noValidate>
        {registering && (
          <>
            <p style={{ fontSize: 14, color: MUTE, margin: "0 0 16px", lineHeight: 1.5 }}>
              Stap 1 van 2: je account. Daarna schrijf je je profieltekst en stel je in wie je zoekt.
            </p>

            {/*
              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 ? (
              <p style={{ fontSize: 14, border: `2px solid ${INK}`, background: "#fff", padding: "10px 12px", margin: "0 0 16px", lineHeight: 1.5 }}>
                Je betaling staat klaar. Vul je gegevens in, dan maken we je account af — je betaalt niet nog een keer.
              </p>
            ) : (
              paying && (
                <p style={{ fontSize: 14, border: `2px solid ${INK}`, background: "#fff", padding: "10px 12px", margin: "0 0 16px", lineHeight: 1.5 }}>
                  Een account aanmaken kost eenmalig {price ?? "een bedrag"}, via {canPay() ? "de store" : "de app"}. Je betaalt
                  één keer; het account blijft van jou.
                </p>
              )
            )}
            <label style={label}>
              Naam
              <input
                type="text" value={name} onChange={(e) => setName(e.target.value)} maxLength={NAME_MAX}
                autoComplete="given-name" placeholder="Zoals je aangeschreven wilt worden" style={input}
              />
            </label>
          </>
        )}

        {!registering && forgot && (
          <p style={{ fontSize: 14, color: MUTE, margin: "0 0 16px", lineHeight: 1.5 }}>
            Vul je e-mailadres in, dan sturen we je een brief met een link om een nieuw wachtwoord in te stellen.
          </p>
        )}

        <label style={label}>
          E-mailadres
          <input
            type="email" value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email"
            inputMode="email" autoCapitalize="none" placeholder="jij@voorbeeld.nl" style={input}
          />
        </label>

        {!forgot && (
          <label style={label}>
            Wachtwoord
            <input
              type="password" value={password} onChange={(e) => setPassword(e.target.value)}
              maxLength={PASSWORD_MAX}
              autoComplete={registering ? "new-password" : "current-password"}
              placeholder={registering ? `minimaal ${PASSWORD_MIN} tekens` : ""} style={input}
            />
          </label>
        )}

        {registering && (
          <>
            <p style={{ fontWeight: 700, fontSize: 14, margin: "0 0 6px" }}>Ik ben</p>
            <div style={{ display: "flex", gap: 8, marginBottom: 8, flexWrap: "wrap", alignItems: "center" }}>
              {Object.entries(GENDER_LABEL).map(([g, text]) => (
                <button type="button" key={g} onClick={() => setGender(g)} aria-pressed={gender === g} style={chip(gender === g)}>
                  {text}
                </button>
              ))}
              {/* streepje: 'trans' staat los van de keuze uit drie — "trans man", "trans vrouw", … */}
              <span aria-hidden="true" style={{ fontWeight: 700 }}>–</span>
              <button type="button" onClick={() => setTrans((t) => !t)} aria-pressed={trans} style={chip(trans)}>
                trans
              </button>
            </div>
            <p style={{ fontFamily: FONT_MONO, fontSize: 11, color: MUTE, margin: "0 0 14px" }}>
              {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</>}
            </p>

            <div style={label}>
              Geboortedatum
              <DateFields value="" onChange={setBirthdate} inputStyle={{ marginTop: 6 }} />
            </div>
            <p style={{ fontFamily: FONT_MONO, fontSize: 11, color: MUTE, margin: "-6px 0 16px" }}>
              dag – maand – jaar · je moet 18 jaar of ouder zijn · anderen zien alleen je leeftijd
            </p>

            {/* AVG art. 9: uitdrukkelijke toestemming — apart, niet vooraf aangevinkt, met de verklaring erbij */}
            <label style={{ display: "flex", gap: 10, alignItems: "flex-start", fontSize: 14, lineHeight: 1.5, marginBottom: 16, fontWeight: 400 }}>
              <input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} style={{ marginTop: 4, flexShrink: 0 }} />
              <span>
                Ik geef toestemming om mijn gender, trans-status, relatiestatus en wat ik zoek te verwerken om me te
                matchen — dat is gevoelige informatie, en zonder kan de app niet werken. Ik heb de{" "}
                <button type="button" onClick={() => setPrivacyOpen(true)} style={{ background: "none", border: "none", color: BLUE, fontWeight: 700, fontSize: 14, padding: 0, textDecoration: "underline" }}>
                  privacyverklaring
                </button>{" "}
                gelezen.
              </span>
            </label>
          </>
        )}

        {error && (
          <p role="alert" style={{ fontFamily: FONT_MONO, fontSize: 12, color: RED, margin: "0 0 12px" }}>fout: {error}</p>
        )}
        {/* Een afgebroken betaling is geen storing: wel melden, niet als fout */}
        {note && (
          <p role="status" style={{ fontFamily: FONT_MONO, fontSize: 12, color: MUTE, margin: "0 0 12px", lineHeight: 1.5 }}>{note}</p>
        )}

        <button
          type="submit" disabled={!canSubmit}
          style={{
            width: "100%", padding: "14px 0", fontSize: 16, fontWeight: 700, border: "none",
            background: canSubmit ? RED : SKY, color: canSubmit ? "#fff" : MUTE,
          }}
        >
          {busy
            ? "Even geduld…"
            : !registering
              ? forgot
                ? "Stuur me een herstel-link"
                : "Inloggen"
              : paying && !pending
                ? "Betalen en account aanmaken →"
                : "Account aanmaken →"}
        </button>
      </form>

      {!registering && (
        <p style={{ fontSize: 13, color: MUTE, textAlign: "center", margin: "18px 0 0" }}>
          {forgot ? (
            <button type="button" onClick={() => { setForgot(false); setError(null); }} style={{ background: "none", border: "none", color: BLUE, fontWeight: 700, fontSize: 13, padding: 0 }}>← Terug naar inloggen</button>
          ) : (
            <button type="button" onClick={() => { setForgot(true); setError(null); setNote(null); }} style={{ background: "none", border: "none", color: BLUE, fontWeight: 700, fontSize: 13, padding: 0 }}>Wachtwoord vergeten?</button>
          )}
        </p>
      )}
      <p style={{ fontSize: 13, color: MUTE, textAlign: "center", margin: registering ? "18px 0 0" : "10px 0 0" }}>
        {registering ? (
          <>Al een account?{" "}<button type="button" onClick={() => switchMode("login")} style={{ background: "none", border: "none", color: BLUE, fontWeight: 700, fontSize: 13, padding: 0 }}>Log in</button></>
        ) : (
          <>Nog geen account?{" "}<button type="button" onClick={() => switchMode("register")} style={{ background: "none", border: "none", color: BLUE, fontWeight: 700, fontSize: 13, padding: 0 }}>Maak er een aan</button></>
        )}
      </p>
      {!registering && (
        <p style={{ fontSize: 12, color: MUTE, textAlign: "center", margin: "8px 0 0" }}>
          <button type="button" onClick={() => setPrivacyOpen(true)} style={{ background: "none", border: "none", color: MUTE, fontSize: 12, padding: 0, textDecoration: "underline" }}>
            privacyverklaring
          </button>
        </p>
      )}
      {privacyOpen && <PrivacyDialog onClose={() => setPrivacyOpen(false)} />}
    </main>
  );
}