feat(slice-2): Landing + Sign-in screens, entry routing, API client (§5.1/5.2/6.5)
Two-step email + one-time-code sign-in behind both doors; pure entry-routing rule unit- tested with Vitest (§6.8); storefront routing reaches a SLICE-2 signed-in placeholder with sign-out (PUC-9). check.sh now runs the frontend unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+60
-6
@@ -1,10 +1,64 @@
|
||||
// SLICE-1 shell only — the Landing, Sign-in, Create-storefront, and Admin screens
|
||||
// (SD-0001 §5) land in SLICE-2/3. This proves the build and dev server work.
|
||||
// App shell — loads the session, applies the entry-routing rule (SD-0001 §6.5), and renders
|
||||
// the matching screen. SLICE-2 covers the unauthenticated doors (Landing/SignIn) and a
|
||||
// signed-in placeholder; the real create-storefront/admin screens land in SLICE-3.
|
||||
import { useEffect, useState } from "react";
|
||||
import { getMe, type VerifyResult } from "./api";
|
||||
import { routeFor, type SessionState } from "./routing";
|
||||
import Landing from "./screens/Landing";
|
||||
import SignIn from "./screens/SignIn";
|
||||
import SignedIn from "./screens/SignedIn";
|
||||
|
||||
type Door = "signup" | "login";
|
||||
|
||||
export default function App() {
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [door, setDoor] = useState<Door | null>(null);
|
||||
const [justCreated, setJustCreated] = useState(false);
|
||||
|
||||
async function reload() {
|
||||
setLoading(true);
|
||||
setSession(await getMe());
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
if (loading) return <main>Loading…</main>;
|
||||
|
||||
const route = routeFor(session ?? { account: null, storefront: null });
|
||||
|
||||
if (route === "landing") {
|
||||
if (door) {
|
||||
return (
|
||||
<SignIn
|
||||
door={door}
|
||||
onBack={() => {
|
||||
setDoor(null);
|
||||
}}
|
||||
onAuthed={(result: VerifyResult) => {
|
||||
setJustCreated(result.created);
|
||||
setDoor(null);
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Landing onGetStarted={() => setDoor("signup")} onLogIn={() => setDoor("login")} />;
|
||||
}
|
||||
|
||||
// route is "create-storefront" or "admin" — both are SLICE-3 screens; SLICE-2 shows the
|
||||
// signed-in placeholder so sign-out (PUC-9) is reachable.
|
||||
return (
|
||||
<main>
|
||||
<h1>ecomm</h1>
|
||||
<p>Honest commerce. Your storefront is yours.</p>
|
||||
</main>
|
||||
<SignedIn
|
||||
email={session!.account!.email}
|
||||
created={justCreated}
|
||||
onSignedOut={() => {
|
||||
setJustCreated(false);
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Typed fetch wrappers for the /api/auth/* surface (SD-0001 §6.4). Same-origin via the Vite
|
||||
// proxy; cookies carry the session. Errors return the §6.4 envelope; helpers normalize them.
|
||||
import type { SessionState } from "./routing";
|
||||
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
retry_after_s?: number;
|
||||
attempts_remaining?: number;
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
account: { email: string };
|
||||
storefront: { id: number; name: string } | null;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
async function errorOf(resp: Response): Promise<ApiError> {
|
||||
try {
|
||||
const body = await resp.json();
|
||||
if (body && body.error) return body.error as ApiError;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return { code: "unexpected", message: "Something went wrong. Please try again." };
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<SessionState | null> {
|
||||
const resp = await fetch("/api/auth/me", { credentials: "include" });
|
||||
if (resp.status === 401) return null;
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as SessionState;
|
||||
}
|
||||
|
||||
export async function requestCode(email: string): Promise<ApiError | null> {
|
||||
const resp = await fetch("/api/auth/request-code", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
return resp.ok ? null : await errorOf(resp);
|
||||
}
|
||||
|
||||
export async function verifyCode(
|
||||
email: string,
|
||||
code: string,
|
||||
): Promise<{ ok: true; result: VerifyResult } | { ok: false; error: ApiError }> {
|
||||
const resp = await fetch("/api/auth/verify", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email, code }),
|
||||
});
|
||||
if (resp.ok) return { ok: true, result: (await resp.json()) as VerifyResult };
|
||||
return { ok: false, error: await errorOf(resp) };
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { routeFor } from "./routing";
|
||||
|
||||
describe("entry routing (SD-0001 §6.5)", () => {
|
||||
it("no session -> landing", () => {
|
||||
expect(routeFor({ account: null, storefront: null })).toBe("landing");
|
||||
});
|
||||
|
||||
it("session without storefront -> create-storefront", () => {
|
||||
expect(routeFor({ account: { email: "m@example.com" }, storefront: null })).toBe(
|
||||
"create-storefront",
|
||||
);
|
||||
});
|
||||
|
||||
it("session with storefront -> admin", () => {
|
||||
expect(
|
||||
routeFor({ account: { email: "m@example.com" }, storefront: { id: 1, name: "Shop" } }),
|
||||
).toBe("admin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
// The single client-side entry-routing rule (SD-0001 §6.5). Exhaustive: every state lands
|
||||
// somewhere (BUC-4 — never stranded). Fed by one server answer (GET /api/auth/me, or the
|
||||
// verify response, which share this shape). storefront is always null in SLICE-2; SLICE-3
|
||||
// makes "admin" reachable.
|
||||
export interface SessionState {
|
||||
account: { email: string } | null;
|
||||
storefront: { id: number; name: string } | null;
|
||||
}
|
||||
|
||||
export type Screen = "landing" | "create-storefront" | "admin";
|
||||
|
||||
export function routeFor(s: SessionState): Screen {
|
||||
if (!s.account) return "landing";
|
||||
if (!s.storefront) return "create-storefront";
|
||||
return "admin";
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Landing (SD-0001 §5.1) — a Visitor learns what ecomm is and picks a door. Honest voice,
|
||||
// no trial/pricing/fee (corpus 14.01.0011). Both doors open the same sign-in flow (§5.2);
|
||||
// they differ only in framing.
|
||||
interface Props {
|
||||
onGetStarted: () => void;
|
||||
onLogIn: () => void;
|
||||
}
|
||||
|
||||
export default function Landing({ onGetStarted, onLogIn }: Props) {
|
||||
return (
|
||||
<main>
|
||||
<header>
|
||||
<strong>ecomm</strong>
|
||||
<button type="button" onClick={onLogIn}>
|
||||
Log in
|
||||
</button>
|
||||
</header>
|
||||
<section>
|
||||
<h1>Honest commerce. Your storefront is yours.</h1>
|
||||
<p>Sell online on a platform that treats you straight — no dark patterns, no lock-in.</p>
|
||||
<button type="button" onClick={onGetStarted}>
|
||||
Create your storefront
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// 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";
|
||||
|
||||
type Door = "signup" | "login";
|
||||
|
||||
interface Props {
|
||||
door: Door;
|
||||
onAuthed: (result: VerifyResult) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
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 heading = door === "signup" ? "Create your storefront" : "Log in";
|
||||
|
||||
async function submitEmail(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const err = await requestCode(email);
|
||||
setBusy(false);
|
||||
if (err) {
|
||||
setError(err.message);
|
||||
return;
|
||||
}
|
||||
setStep("code");
|
||||
}
|
||||
|
||||
async function submitCode(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const res = await verifyCode(email, code);
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
setError(res.error.message);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// SLICE-2 placeholder for the signed-in surfaces (create-storefront, admin) that land in
|
||||
// SLICE-3. It honestly states what's next and makes PUC-9 sign-out reachable now. No
|
||||
// fabricated capability (INV-9).
|
||||
import { logout } from "../api";
|
||||
|
||||
interface Props {
|
||||
email: string;
|
||||
created: boolean;
|
||||
onSignedOut: () => void;
|
||||
}
|
||||
|
||||
export default function SignedIn({ email, created, onSignedOut }: Props) {
|
||||
async function signOut() {
|
||||
await logout();
|
||||
onSignedOut();
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<header>
|
||||
<strong>ecomm</strong>
|
||||
<span>{email}</span>
|
||||
<button type="button" onClick={signOut}>
|
||||
Sign out
|
||||
</button>
|
||||
</header>
|
||||
<section>
|
||||
<h1>{created ? "Welcome to ecomm" : "Welcome back"}</h1>
|
||||
<p>You're signed in as {email}.</p>
|
||||
<p>Creating your storefront arrives in the next slice. Nothing else is needed yet.</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user