diff --git a/frontend/src/screens/SignIn.tsx b/frontend/src/screens/SignIn.tsx index 88afd82..47712e0 100644 --- a/frontend/src/screens/SignIn.tsx +++ b/frontend/src/screens/SignIn.tsx @@ -1,9 +1,10 @@ // Sign in (SD-0001 §5.2) — the single email + one-time-code flow behind both doors. Step 1 -// requests a code; step 2 verifies it. Honest copy states which door framing applies and, -// after verify, whether the account was created or resumed (PUC-3). storefront routing is -// handled by App on success. -import { useState } from "react"; -import { requestCode, verifyCode, type VerifyResult } from "../api"; +// requests a code; step 2 verifies it. Honest copy for every §5.2 state; storefront routing +// is handled by App on success. Visuals per the ui/designs export (hf-signin). +import { useEffect, useState } from "react"; +import { requestCode, verifyCode, type ApiError, type VerifyResult } from "../api"; +import CodeInput from "../ui/CodeInput"; +import { AuthCard, Banner, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit"; type Door = "signup" | "login"; @@ -13,99 +14,175 @@ interface Props { onBack: () => void; } +function useCooldown(): [number, (s: number) => void] { + const [left, setLeft] = useState(0); + const ticking = left > 0; + useEffect(() => { + if (!ticking) return; + const t = setInterval(() => setLeft((v) => Math.max(0, v - 1)), 1000); + return () => clearInterval(t); + }, [ticking]); + return [left, setLeft]; +} + export default function SignIn({ door, onAuthed, onBack }: Props) { const [step, setStep] = useState<"email" | "code">("email"); const [email, setEmail] = useState(""); const [code, setCode] = useState(""); const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); + const [fieldError, setFieldError] = useState(null); + const [codeError, setCodeError] = useState(null); + const [bannerError, setBannerError] = useState(null); + const [cooldown, setCooldown] = useCooldown(); - const heading = door === "signup" ? "Create your storefront" : "Log in"; + function applyError(err: ApiError) { + setFieldError(null); + setCodeError(null); + setBannerError(null); + if (err.code === "invalid_email") setFieldError(err.message); + else if (err.code === "resend_cooldown") setCooldown(err.retry_after_s ?? 60); + else if (err.code === "code_mismatch" || err.code === "code_expired") setCodeError(err.message); + else setBannerError(err); // delivery_failed, code_exhausted, unexpected + } - async function submitEmail(e: React.FormEvent) { - e.preventDefault(); + async function sendCode(): Promise { setBusy(true); - setError(null); + setFieldError(null); + setBannerError(null); const err = await requestCode(email); setBusy(false); if (err) { - setError(err.message); - return; + applyError(err); + return err.code === "resend_cooldown"; // a cooldown still means a code is out there } - setStep("code"); + setCooldown(60); + return true; + } + + async function submitEmail(e: React.FormEvent) { + e.preventDefault(); + if (await sendCode()) { + setCode(""); + setCodeError(null); + setStep("code"); + } + } + + async function resend() { + setCodeError(null); + setCode(""); + await sendCode(); } async function submitCode(e: React.FormEvent) { e.preventDefault(); setBusy(true); - setError(null); + setCodeError(null); + setBannerError(null); const res = await verifyCode(email, code); setBusy(false); if (!res.ok) { - setError(res.error.message); + applyError(res.error); return; } onAuthed(res.result); } - if (step === "email") { - return ( -
-
- ecomm - -
-
-

{heading}

- -

We'll email you a one-time code. That's all we need.

- {error &&

{error}

} - -
-
- ); - } + const heading = door === "signup" ? "Create your storefront" : "Log in"; + const sub = + door === "signup" + ? "Enter your email to begin. There's no password to create." + : "Enter your email and we'll send a one-time code to sign in."; return ( -
-
- ecomm - -
-
-

Enter your code

-

We sent a code to {email}.

- - {error &&

{error}

} - -
-
+ + } + right={ + + } + /> +
+ + {step === "email" ? ( +
+
+

{heading}

+

{sub}

+
+ {bannerError && ( + + The email didn't go out. Try again in a moment — nothing was lost. + + )} + setEmail(ev.target.value), + }} + /> +
+ 0}> + {busy ? "Sending…" : cooldown > 0 ? `Resend in ${cooldown}s` : "Send code"} + +

+ We'll email you a one-time code. That's all we need — no password, ever. +

+
+ + ) : ( +
+
+

Check your email

+

+ We sent a 6-digit code to {email}.{" "} + +

+
+ {bannerError && ( + + {bannerError.code === "code_exhausted" + ? "That code is no longer valid. Request a fresh one to keep going." + : bannerError.message} + + )} +
+ + {codeError && ( +

+ {codeError} +

+ )} +
+
+ {busy ? "Checking…" : "Continue"} +
+

Good for 10 minutes.

+ {cooldown > 0 ? ( + Resend in {cooldown}s + ) : ( + + )} +
+
+
+ )} +
+
+