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
+149 -72
View File
@@ -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<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) {
e.preventDefault();
async function sendCode(): Promise<boolean> {
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 (
<main>
<header>
<strong>ecomm</strong>
<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>
);
}
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 (
<main>
<header>
<strong>ecomm</strong>
<button type="button" onClick={() => setStep("email")}>
Wrong address?
</button>
</header>
<form onSubmit={submitCode}>
<h1>Enter your code</h1>
<p>We sent a code to {email}.</p>
<label>
Code
<input
inputMode="numeric"
autoComplete="one-time-code"
value={code}
autoFocus
required
onChange={(ev) => setCode(ev.target.value)}
/>
</label>
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={busy}>
{busy ? "Checking…" : "Continue"}
</button>
</form>
</main>
<Screen horizon>
<TopBar
left={<Wordmark size={22} />}
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?
</button>
</p>
</div>
{bannerError && (
<Banner title={bannerError.code === "code_exhausted" ? "Too many attempts" : "Something went wrong"}>
{bannerError.code === "code_exhausted"
? "That code is no longer valid. Request a fresh one to keep going."
: bannerError.message}
</Banner>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<CodeInput value={code} onChange={setCode} error={!!codeError} />
{codeError && (
<p className="note note--attn" role="alert">
{codeError}
</p>
)}
</div>
<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>
)}
</div>
</div>
</form>
)}
</AuthCard>
</main>
<Footer />
</Screen>
);
}