feat(slice-3): Create-storefront screen + API helper (§5.3, PUC-4/5/7)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -59,3 +59,21 @@ export async function verifyCode(
|
|||||||
export async function logout(): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StorefrontResult {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStorefront(
|
||||||
|
name: string,
|
||||||
|
): Promise<{ ok: true; storefront: StorefrontResult } | { ok: false; error: ApiError }> {
|
||||||
|
const resp = await fetch("/api/storefronts", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(name.trim() ? { name: name.trim() } : {}),
|
||||||
|
});
|
||||||
|
if (resp.ok) return { ok: true, storefront: (await resp.json()) as StorefrontResult };
|
||||||
|
return { ok: false, error: await errorOf(resp) };
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Create storefront (SD-0001 §5.3) — a storefront-less Merchant establishes their one
|
||||||
|
// storefront (PUC-4/5; PUC-7 defense-in-depth on 409). Visuals per the ui/designs export
|
||||||
|
// (hf-storefront). The welcome banner carries PUC-3's honest copy from the verify step.
|
||||||
|
import { useState } from "react";
|
||||||
|
import { createStorefront, logout, type StorefrontResult } from "../api";
|
||||||
|
import { AccountChip, AuthCard, Banner, Eyebrow, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
email: string;
|
||||||
|
welcome: "new" | "back" | null;
|
||||||
|
onCreated: (storefront: StorefrontResult) => void;
|
||||||
|
onAlreadyOwns: () => void;
|
||||||
|
onSignedOut: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateStorefront({ email, welcome, onCreated, onAlreadyOwns, onSignedOut }: Props) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [alreadyOwns, setAlreadyOwns] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
await logout();
|
||||||
|
onSignedOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await createStorefront(name);
|
||||||
|
setBusy(false);
|
||||||
|
if (res.ok) {
|
||||||
|
onCreated(res.storefront);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.error.code === "already_owns_storefront") setAlreadyOwns(true);
|
||||||
|
else setError(res.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen horizon>
|
||||||
|
<TopBar left={<Wordmark size={22} />} right={<AccountChip email={email} onSignOut={signOut} />} />
|
||||||
|
<main className="screen__main">
|
||||||
|
<AuthCard>
|
||||||
|
<form onSubmit={submit} style={{ display: "contents" }}>
|
||||||
|
<div>
|
||||||
|
<Eyebrow>One storefront, fully yours</Eyebrow>
|
||||||
|
<h1 style={{ marginTop: 12 }}>Create your storefront</h1>
|
||||||
|
<p className="card__sub">
|
||||||
|
This is the one thing to do right now. It costs nothing and commits you to nothing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{welcome && (
|
||||||
|
<Banner tone="info" title={welcome === "new" ? "Welcome to ecomm" : "Welcome back"}>
|
||||||
|
{welcome === "new"
|
||||||
|
? "A new account was created for this email."
|
||||||
|
: "Signed in to your existing account."}
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
{alreadyOwns && (
|
||||||
|
<Banner title="Your account already has its storefront">
|
||||||
|
ecomm is one storefront per account today.{" "}
|
||||||
|
<button type="button" className="lk" onClick={onAlreadyOwns}>
|
||||||
|
Go to your admin →
|
||||||
|
</button>
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
{error && <Banner title="That didn't work">{error} Try again.</Banner>}
|
||||||
|
<Field
|
||||||
|
label="Storefront name"
|
||||||
|
optional
|
||||||
|
helper="You can leave this blank — we'll pick a placeholder name you can change any time."
|
||||||
|
inputProps={{
|
||||||
|
type: "text",
|
||||||
|
value: name,
|
||||||
|
placeholder: "e.g. Ben's Bets",
|
||||||
|
autoFocus: true,
|
||||||
|
onChange: (ev) => setName(ev.target.value),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
<PrimaryButton busy={busy}>{busy ? "Creating…" : "Create storefront"}</PrimaryButton>
|
||||||
|
<p className="note">You can rename, configure, or close your storefront whenever you like.</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AuthCard>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user