feat(slice-3): Sign-in re-skinned — code cells, cooldown countdown, honest §5.2 states

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 22:40:33 -07:00
parent 0b302ea1e1
commit ddc949615e
+143 -66
View File
@@ -1,9 +1,10 @@
// Sign in (SD-0001 §5.2) — the single email + one-time-code flow behind both doors. Step 1 // 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, // requests a code; step 2 verifies it. Honest copy for every §5.2 state; storefront routing
// after verify, whether the account was created or resumed (PUC-3). storefront routing is // is handled by App on success. Visuals per the ui/designs export (hf-signin).
// handled by App on success. import { useEffect, useState } from "react";
import { useState } from "react"; import { requestCode, verifyCode, type ApiError, type VerifyResult } from "../api";
import { requestCode, verifyCode, 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"; type Door = "signup" | "login";
@@ -13,99 +14,175 @@ interface Props {
onBack: () => void; 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) { export default function SignIn({ door, onAuthed, onBack }: Props) {
const [step, setStep] = useState<"email" | "code">("email"); const [step, setStep] = useState<"email" | "code">("email");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [fieldError, setFieldError] = useState<string | null>(null);
const [codeError, setCodeError] = useState<string | null>(null);
const [bannerError, setBannerError] = useState<ApiError | null>(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) { async function sendCode(): Promise<boolean> {
e.preventDefault();
setBusy(true); setBusy(true);
setError(null); setFieldError(null);
setBannerError(null);
const err = await requestCode(email); const err = await requestCode(email);
setBusy(false); setBusy(false);
if (err) { if (err) {
setError(err.message); applyError(err);
return; return err.code === "resend_cooldown"; // a cooldown still means a code is out there
} }
setCooldown(60);
return true;
}
async function submitEmail(e: React.FormEvent) {
e.preventDefault();
if (await sendCode()) {
setCode("");
setCodeError(null);
setStep("code"); setStep("code");
} }
}
async function resend() {
setCodeError(null);
setCode("");
await sendCode();
}
async function submitCode(e: React.FormEvent) { async function submitCode(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setBusy(true); setBusy(true);
setError(null); setCodeError(null);
setBannerError(null);
const res = await verifyCode(email, code); const res = await verifyCode(email, code);
setBusy(false); setBusy(false);
if (!res.ok) { if (!res.ok) {
setError(res.error.message); applyError(res.error);
return; return;
} }
onAuthed(res.result); onAuthed(res.result);
} }
if (step === "email") { const heading = door === "signup" ? "Create your storefront" : "Log in";
return ( const sub =
<main> door === "signup"
<header> ? "Enter your email to begin. There's no password to create."
<strong>ecomm</strong> : "Enter your email and we'll send a one-time code to sign in.";
<button type="button" onClick={onBack}>
Back
</button>
</header>
<form onSubmit={submitEmail}>
<h1>{heading}</h1>
<label>
Email
<input
type="email"
value={email}
autoFocus
required
onChange={(ev) => setEmail(ev.target.value)}
/>
</label>
<p>We'll email you a one-time code. That's all we need.</p>
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={busy}>
{busy ? "Sending…" : "Send code"}
</button>
</form>
</main>
);
}
return ( return (
<main> <Screen horizon>
<header> <TopBar
<strong>ecomm</strong> left={<Wordmark size={22} />}
<button type="button" onClick={() => setStep("email")}> right={
<button type="button" className="lk lk--mute" onClick={onBack}>
Back
</button>
}
/>
<main className="screen__main">
<AuthCard>
{step === "email" ? (
<form onSubmit={submitEmail} style={{ display: "contents" }}>
<div>
<h1>{heading}</h1>
<p className="card__sub">{sub}</p>
</div>
{bannerError && (
<Banner title="We couldn't send the code">
The email didn't go out. Try again in a moment — nothing was lost.
</Banner>
)}
<Field
label="Email"
error={fieldError}
inputProps={{
type: "email",
value: email,
placeholder: "you@example.com",
autoFocus: true,
required: true,
autoComplete: "email",
onChange: (ev) => setEmail(ev.target.value),
}}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<PrimaryButton busy={busy} disabled={cooldown > 0}>
{busy ? "Sending…" : cooldown > 0 ? `Resend in ${cooldown}s` : "Send code"}
</PrimaryButton>
<p className="note">
We'll email you a one-time code. That's all we need no password, ever.
</p>
</div>
</form>
) : (
<form onSubmit={submitCode} style={{ display: "contents" }}>
<div>
<h1>Check your email</h1>
<p className="card__sub">
We sent a 6-digit code to <span style={{ color: "var(--wv-starlight)" }}>{email}</span>.{" "}
<button type="button" className="lk" onClick={() => setStep("email")}>
Wrong address? Wrong address?
</button> </button>
</header> </p>
<form onSubmit={submitCode}> </div>
<h1>Enter your code</h1> {bannerError && (
<p>We sent a code to {email}.</p> <Banner title={bannerError.code === "code_exhausted" ? "Too many attempts" : "Something went wrong"}>
<label> {bannerError.code === "code_exhausted"
Code ? "That code is no longer valid. Request a fresh one to keep going."
<input : bannerError.message}
inputMode="numeric" </Banner>
autoComplete="one-time-code" )}
value={code} <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
autoFocus <CodeInput value={code} onChange={setCode} error={!!codeError} />
required {codeError && (
onChange={(ev) => setCode(ev.target.value)} <p className="note note--attn" role="alert">
/> {codeError}
</label> </p>
{error && <p role="alert">{error}</p>} )}
<button type="submit" disabled={busy}> </div>
{busy ? "Checking…" : "Continue"} <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<PrimaryButton busy={busy}>{busy ? "Checking…" : "Continue"}</PrimaryButton>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<p className="note">Good for 10 minutes.</p>
{cooldown > 0 ? (
<span className="note">Resend in {cooldown}s</span>
) : (
<button type="button" className="lk" onClick={resend}>
Resend code
</button> </button>
)}
</div>
</div>
</form> </form>
)}
</AuthCard>
</main> </main>
<Footer />
</Screen>
); );
} }