1985 lines
71 KiB
Markdown
1985 lines
71 KiB
Markdown
# SLICE-3 — Storefront Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Implement SLICE-3 of SD-0001 v0.3.0 — the `storefronts` domain (create + INV-4 guard + membership), the `POST /api/storefronts` endpoint wired into the `_storefront_for` seam, the Create-storefront + Admin-shell screens per the Claude Design export, complete entry routing, the whole-flow INV-1 bootstrap test, and re-skin the SLICE-1/2 screens (Landing, Sign-in) to the same design.
|
||
|
||
**Anchor:** Solution Design `wiggleverse-ecomm-content/specs/SD-0001-mvp-sign-up-and-single-storefront.md` §7.2 (SLICE-3), §6.5 (PUC-4/5/6/7/8), §6.4 (API), §5.1–5.4 (UX). Design export: `wiggleverse-ecomm-content/ui/designs/ecomm-login-and-create-storefront-designs/project/` (hf-kit/landing/signin/storefront/admin + `_ds` tokens).
|
||
|
||
**Architecture:** Backend adds a second domain package `app/domains/storefronts` (sibling of `accounts`, never imports it — the BFF passes `account_id`/`email` down). The INV-4 guard + insert are serialized per-account with a transaction-scoped advisory lock so two concurrent creates cannot both pass the guard. Frontend gains a design-system foundation (Wiggleverse tokens CSS + fonts + brand SVGs + an `app.css` implementing the hi-fi primitives) and a small `src/ui/` component kit; the four §5 screens render from it. No new migration: `storefront` + `storefront_membership` shipped in `0001_init.sql`.
|
||
|
||
**Tech Stack:** FastAPI + psycopg (no ORM), pytest against per-test Postgres databases; React 18 + Vite + vitest; plain CSS with Wiggleverse design tokens (no CSS framework).
|
||
|
||
**E2E browser tests:** deliberately deferred per SD-0001 §6.8 ("component/E2E browser tests deferred until there is UI beyond forms") — this slice's UI is still forms + text. Logged in the session transcript's Deferred decisions. The localhost walkthrough (PUC-10) is verified manually at Task 10.
|
||
|
||
---
|
||
|
||
## File structure
|
||
|
||
Backend (create):
|
||
- `backend/app/domains/storefronts/__init__.py` — package surface (`__all__`)
|
||
- `backend/app/domains/storefronts/errors.py` — `StorefrontsError`, `AlreadyOwnsStorefront`
|
||
- `backend/app/domains/storefronts/models.py` — `Storefront`
|
||
- `backend/app/domains/storefronts/service.py` — `create_storefront`, `storefront_for` (INV-4 guard)
|
||
- `backend/tests/test_storefronts_create.py` — service + endpoint scenario tests
|
||
- `backend/tests/test_storefronts_invariants.py` — INV-4 concurrency + many-capable schema shape
|
||
- `backend/tests/test_bootstrap.py` — INV-1 whole-flow
|
||
|
||
Backend (modify):
|
||
- `backend/app/main.py` — `_storefront_for` seam → storefronts domain; `POST /api/storefronts`; version 0.3
|
||
|
||
Frontend (create):
|
||
- `frontend/public/fonts/*.woff2` + `frontend/src/styles/fonts.css` — Space Grotesk 500/700, Inter 400/500/600 (copied from the design export `_ds`)
|
||
- `frontend/public/brand/mark-tile.svg`, `frontend/public/brand/mark-mono-gold.svg` — brand marks (copied from the design export `assets/`)
|
||
- `frontend/src/styles/tokens-colors.css`, `tokens-typography.css`, `tokens-spacing.css` — verbatim Wiggleverse token files
|
||
- `frontend/src/styles/app.css` — the hf-kit primitives as CSS classes (ground/starfield, topbar, card, field, button, banner, footer)
|
||
- `frontend/src/styles/index.css` — import manifest
|
||
- `frontend/src/ui/kit.tsx` — Screen, TopBar, Wordmark, Footer, AuthCard, Eyebrow, Banner, PrimaryButton, Field, AccountChip
|
||
- `frontend/src/ui/CodeInput.tsx` — 6-cell one-time-code input
|
||
- `frontend/src/screens/CreateStorefront.tsx` — PUC-4/5/7
|
||
- `frontend/src/screens/Admin.tsx` — PUC-8/9
|
||
|
||
Frontend (modify):
|
||
- `frontend/src/api.ts` — `createStorefront()`
|
||
- `frontend/src/App.tsx` — full entry routing, welcome banner state, verify result feeds session directly
|
||
- `frontend/src/screens/Landing.tsx` — rebuild per hf-landing
|
||
- `frontend/src/screens/SignIn.tsx` — rebuild per hf-signin (cooldown countdown, code cells, state banners)
|
||
- `frontend/src/main.tsx` — import styles
|
||
- `frontend/index.html` — theme color / background to avoid white flash
|
||
- `frontend/package.json` — version 0.3.0
|
||
|
||
Frontend (delete):
|
||
- `frontend/src/screens/SignedIn.tsx` — SLICE-2 placeholder, superseded by CreateStorefront/Admin
|
||
|
||
Docs (modify):
|
||
- `README.md` — status: storefront slice in place
|
||
|
||
Design-fidelity divergences (deliberate, honest-UI rule INV-9): the design's dead `#` links ("How it works", "Open Core", Privacy/Terms footer links) are NOT shipped — nothing links nowhere; the footer keeps the identity line only. The design's post-verify welcome banner renders on the destination screen (create-storefront/admin) since the SPA routes immediately after verify (PUC-3 honest copy preserved).
|
||
|
||
---
|
||
|
||
### Task 1: Branch + storefronts domain service (TDD)
|
||
|
||
**Files:**
|
||
- Create: `backend/app/domains/storefronts/{__init__,errors,models,service}.py`
|
||
- Test: `backend/tests/test_storefronts_create.py` (service-level tests first)
|
||
|
||
- [x] **Step 1: Branch**
|
||
|
||
```bash
|
||
git checkout -b slice-3-storefront
|
||
```
|
||
|
||
- [x] **Step 2: Write failing service-level tests**
|
||
|
||
`backend/tests/test_storefronts_create.py`:
|
||
|
||
```python
|
||
"""SLICE-3 storefront creation — service + endpoint scenario tests (SD-0001 §6.5 PUC-4/7)."""
|
||
import re
|
||
|
||
import psycopg
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.main import create_app
|
||
from app.domains import storefronts
|
||
from app.platform import db
|
||
|
||
|
||
@pytest.fixture()
|
||
def migrated_conn(fresh_db_url):
|
||
with psycopg.connect(fresh_db_url) as conn:
|
||
db.migrate(conn)
|
||
yield conn
|
||
|
||
|
||
def _account_id(conn, email="merchant@example.com"):
|
||
return conn.execute(
|
||
"INSERT INTO account (email) VALUES (%s) RETURNING id", (email,)
|
||
).fetchone()[0]
|
||
|
||
|
||
def test_create_storefront_with_name(migrated_conn):
|
||
acct = _account_id(migrated_conn)
|
||
sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "Ben's Bets")
|
||
assert sf.name == "Ben's Bets"
|
||
assert storefronts.storefront_for(migrated_conn, acct) == sf
|
||
|
||
|
||
def test_14_01_0026_blank_name_gets_generated_default(migrated_conn):
|
||
acct = _account_id(migrated_conn)
|
||
sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", None)
|
||
assert sf.name == "merchant's storefront"
|
||
|
||
|
||
def test_whitespace_name_treated_as_blank(migrated_conn):
|
||
acct = _account_id(migrated_conn)
|
||
sf = storefronts.create_storefront(migrated_conn, acct, "ben@bensbets.com", " ")
|
||
assert sf.name == "ben's storefront"
|
||
|
||
|
||
def test_second_create_refused_inv_4(migrated_conn):
|
||
acct = _account_id(migrated_conn)
|
||
storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "First")
|
||
with pytest.raises(storefronts.AlreadyOwnsStorefront):
|
||
storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "Second")
|
||
|
||
|
||
def test_membership_row_is_owner(migrated_conn):
|
||
acct = _account_id(migrated_conn)
|
||
sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", None)
|
||
role = migrated_conn.execute(
|
||
"SELECT role FROM storefront_membership WHERE account_id = %s AND storefront_id = %s",
|
||
(acct, sf.id),
|
||
).fetchone()[0]
|
||
assert role == "owner"
|
||
|
||
|
||
def test_storefront_for_none_when_absent(migrated_conn):
|
||
acct = _account_id(migrated_conn)
|
||
assert storefronts.storefront_for(migrated_conn, acct) is None
|
||
```
|
||
|
||
- [x] **Step 3: Run tests to verify they fail**
|
||
|
||
```bash
|
||
cd backend && .venv/bin/python -m pytest tests/test_storefronts_create.py -q
|
||
```
|
||
Expected: FAIL/ERROR — `app.domains.storefronts` does not exist.
|
||
|
||
- [x] **Step 4: Implement the domain**
|
||
|
||
`backend/app/domains/storefronts/models.py`:
|
||
|
||
```python
|
||
"""Storefront record — the §6.3 entity as the domain returns it."""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Storefront:
|
||
id: int
|
||
name: str
|
||
```
|
||
|
||
`backend/app/domains/storefronts/errors.py`:
|
||
|
||
```python
|
||
"""storefronts domain errors."""
|
||
from __future__ import annotations
|
||
|
||
|
||
class StorefrontsError(Exception):
|
||
"""Base for storefronts-domain errors."""
|
||
|
||
|
||
class AlreadyOwnsStorefront(StorefrontsError):
|
||
"""INV-4: the account already has its one storefront (PUC-7)."""
|
||
```
|
||
|
||
`backend/app/domains/storefronts/service.py`:
|
||
|
||
```python
|
||
"""storefronts — the storefront + membership service (SD-0001 §6.5).
|
||
|
||
Owns the storefront entity, the account<->storefront membership (INV-5), the entry-routing
|
||
answer (storefront_for), and INV-4's one-storefront guard — the single deletable check that
|
||
makes one-per-account an MVP rule, not a schema law. Never mints identity: the BFF passes
|
||
account_id and email down. Imports only app.platform-level concerns (none needed yet).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import psycopg
|
||
|
||
from .errors import AlreadyOwnsStorefront
|
||
from .models import Storefront
|
||
|
||
|
||
def _default_name(email: str) -> str:
|
||
"""§6.3: a blank name stores a generated default — never NULL (corpus 14.01.0026)."""
|
||
return f"{email.split('@', 1)[0]}'s storefront"
|
||
|
||
|
||
def storefront_for(conn: psycopg.Connection, account_id: int) -> Storefront | None:
|
||
"""The entry-routing answer (§6.5): which storefront, if any, this account has."""
|
||
row = conn.execute(
|
||
"SELECT s.id, s.name FROM storefront s"
|
||
" JOIN storefront_membership m ON m.storefront_id = s.id"
|
||
" WHERE m.account_id = %s ORDER BY m.created_at LIMIT 1",
|
||
(account_id,),
|
||
).fetchone()
|
||
return Storefront(id=row[0], name=row[1]) if row else None
|
||
|
||
|
||
def create_storefront(
|
||
conn: psycopg.Connection, account_id: int, email: str, name: str | None
|
||
) -> Storefront:
|
||
"""Create the account's one storefront + owner membership (PUC-4; INV-4, INV-5).
|
||
|
||
Guard + insert run as one atomic unit: a transaction-scoped advisory lock keyed by
|
||
account_id serializes concurrent creates for the same account, so the second of two
|
||
racing requests sees the first's committed membership and is refused (§6.5).
|
||
"""
|
||
conn.execute("SELECT pg_advisory_xact_lock(%s)", (account_id,))
|
||
existing = storefront_for(conn, account_id)
|
||
if existing is not None:
|
||
conn.rollback() # release the advisory lock; nothing was written
|
||
raise AlreadyOwnsStorefront()
|
||
final_name = (name or "").strip() or _default_name(email)
|
||
sf_id = conn.execute(
|
||
"INSERT INTO storefront (name) VALUES (%s) RETURNING id", (final_name,)
|
||
).fetchone()[0]
|
||
conn.execute(
|
||
"INSERT INTO storefront_membership (account_id, storefront_id, role)"
|
||
" VALUES (%s, %s, 'owner')",
|
||
(account_id, sf_id),
|
||
)
|
||
conn.commit()
|
||
return Storefront(id=sf_id, name=final_name)
|
||
```
|
||
|
||
`backend/app/domains/storefronts/__init__.py`:
|
||
|
||
```python
|
||
"""storefronts domain — storefront entity, membership, the one-storefront guard.
|
||
|
||
Owns INV-4 (one storefront per account is a service-layer rule) and INV-5 (tenant rows
|
||
carry storefront_id). Knows nothing about identity (that is the accounts domain). Imported
|
||
via this package surface only (§6.2).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from .errors import AlreadyOwnsStorefront, StorefrontsError
|
||
from .models import Storefront
|
||
from .service import create_storefront, storefront_for
|
||
|
||
__all__ = [
|
||
"Storefront",
|
||
"StorefrontsError",
|
||
"AlreadyOwnsStorefront",
|
||
"create_storefront",
|
||
"storefront_for",
|
||
]
|
||
```
|
||
|
||
- [x] **Step 5: Run tests to verify they pass**
|
||
|
||
```bash
|
||
cd backend && .venv/bin/python -m pytest tests/test_storefronts_create.py -q
|
||
```
|
||
Expected: all PASS.
|
||
|
||
- [x] **Step 6: Layering contract still green**
|
||
|
||
```bash
|
||
cd backend && .venv/bin/lint-imports
|
||
```
|
||
Expected: Contracts: 1 kept, 0 broken.
|
||
|
||
- [x] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add backend/app/domains/storefronts backend/tests/test_storefronts_create.py
|
||
git commit -m "feat(slice-3): storefronts domain — create + membership + INV-4 guard (§6.5)"
|
||
```
|
||
|
||
### Task 2: INV-4 invariant tests — concurrency + many-capable schema
|
||
|
||
**Files:**
|
||
- Test: `backend/tests/test_storefronts_invariants.py`
|
||
|
||
- [x] **Step 1: Write the tests**
|
||
|
||
```python
|
||
"""INV-4's sharp edges (SD-0001 §6.8): concurrent second-storefront refusal, and the
|
||
membership schema staying many-capable (the rule is a deletable guard, not a schema law)."""
|
||
import threading
|
||
|
||
import psycopg
|
||
import pytest
|
||
|
||
from app.domains import storefronts
|
||
from app.platform import db
|
||
|
||
|
||
@pytest.fixture()
|
||
def migrated_url(fresh_db_url):
|
||
with psycopg.connect(fresh_db_url) as conn:
|
||
db.migrate(conn)
|
||
return fresh_db_url
|
||
|
||
|
||
def test_inv_4_concurrent_creates_exactly_one_wins(migrated_url):
|
||
with psycopg.connect(migrated_url) as setup:
|
||
acct = setup.execute(
|
||
"INSERT INTO account (email) VALUES ('racer@example.com') RETURNING id"
|
||
).fetchone()[0]
|
||
setup.commit()
|
||
|
||
barrier = threading.Barrier(2)
|
||
results: list[str] = []
|
||
|
||
def racer():
|
||
with psycopg.connect(migrated_url) as conn:
|
||
barrier.wait()
|
||
try:
|
||
storefronts.create_storefront(conn, acct, "racer@example.com", None)
|
||
results.append("created")
|
||
except storefronts.AlreadyOwnsStorefront:
|
||
results.append("refused")
|
||
|
||
threads = [threading.Thread(target=racer) for _ in range(2)]
|
||
for t in threads:
|
||
t.start()
|
||
for t in threads:
|
||
t.join()
|
||
|
||
assert sorted(results) == ["created", "refused"]
|
||
with psycopg.connect(migrated_url) as check:
|
||
count = check.execute(
|
||
"SELECT count(*) FROM storefront_membership WHERE account_id = %s", (acct,)
|
||
).fetchone()[0]
|
||
assert count == 1
|
||
|
||
|
||
def test_inv_4_schema_stays_many_capable(migrated_url):
|
||
# No UNIQUE(account_id) anywhere in the storefront path: the only unique constraint on
|
||
# storefront_membership is its composite PK, so many-per-account stays a deletable
|
||
# service rule (R-5 mitigation).
|
||
with psycopg.connect(migrated_url) as conn:
|
||
uniques = conn.execute(
|
||
"SELECT i.indkey::text FROM pg_index i"
|
||
" JOIN pg_class c ON c.oid = i.indrelid"
|
||
" WHERE c.relname = 'storefront_membership' AND (i.indisunique OR i.indisprimary)"
|
||
).fetchall()
|
||
# exactly one unique index (the composite PK over two columns)
|
||
assert len(uniques) == 1
|
||
assert len(uniques[0][0].split()) == 2
|
||
```
|
||
|
||
- [x] **Step 2: Run them**
|
||
|
||
```bash
|
||
cd backend && .venv/bin/python -m pytest tests/test_storefronts_invariants.py -q
|
||
```
|
||
Expected: both PASS (Task 1 implementation already serializes via the advisory lock; if the concurrency test hangs or both create, the guard is broken — fix before proceeding).
|
||
|
||
- [x] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add backend/tests/test_storefronts_invariants.py
|
||
git commit -m "test(slice-3): INV-4 invariants — concurrent-create refusal + many-capable membership schema"
|
||
```
|
||
|
||
### Task 3: BFF — POST /api/storefronts + the _storefront_for seam (TDD)
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/main.py`
|
||
- Test: `backend/tests/test_storefronts_create.py` (append endpoint scenario tests)
|
||
|
||
- [x] **Step 1: Write failing endpoint scenario tests** (append to `backend/tests/test_storefronts_create.py`)
|
||
|
||
```python
|
||
# ── endpoint scenario tests (§6.4 POST /api/storefronts; corpus 14.01.*) ──────────
|
||
|
||
|
||
# (executed form: a @contextmanager wrapping `with TestClient(...)` — manually calling
|
||
# __enter__ then re-entering via `with` closes the lifespan pool early)
|
||
@contextmanager
|
||
def _signed_in_client(fresh_db_url, email="merchant@example.com"):
|
||
with TestClient(create_app(database_url=fresh_db_url)) as client:
|
||
client.post("/api/auth/request-code", json={"email": email})
|
||
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
|
||
client.post("/api/auth/verify", json={"email": email, "code": code})
|
||
yield client
|
||
|
||
|
||
def test_14_01_0013_create_storefront_with_name(fresh_db_url):
|
||
with _signed_in_client(fresh_db_url) as client:
|
||
resp = client.post("/api/storefronts", json={"name": "Ben's Bets"})
|
||
assert resp.status_code == 201
|
||
assert resp.json()["name"] == "Ben's Bets"
|
||
assert isinstance(resp.json()["id"], int)
|
||
|
||
|
||
def test_14_01_0015_0016_nothing_but_a_name_is_asked(fresh_db_url):
|
||
# No payment method, plan, or commitment: the request body needs nothing at all and
|
||
# the response carries only the storefront — no plan/trial/billing fields.
|
||
with _signed_in_client(fresh_db_url) as client:
|
||
resp = client.post("/api/storefronts", json={})
|
||
assert resp.status_code == 201
|
||
assert set(resp.json().keys()) == {"id", "name"}
|
||
|
||
|
||
def test_14_01_0025_create_lands_on_admin(fresh_db_url):
|
||
# After create, the entry-routing answer carries the storefront -> admin (PUC-6).
|
||
with _signed_in_client(fresh_db_url) as client:
|
||
created = client.post("/api/storefronts", json={"name": "Ben's Bets"}).json()
|
||
me = client.get("/api/auth/me").json()
|
||
assert me["storefront"] == created
|
||
|
||
|
||
def test_puc_05_returning_without_storefront_routes_to_create(fresh_db_url):
|
||
with _signed_in_client(fresh_db_url) as client:
|
||
me = client.get("/api/auth/me").json()
|
||
assert me["storefront"] is None
|
||
|
||
|
||
def test_14_01_0027_returning_with_storefront_straight_to_admin(fresh_db_url):
|
||
# PUC-6: a returning merchant's verify response already carries the storefront.
|
||
import psycopg
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
email = "returning@example.com"
|
||
with _signed_in_client(fresh_db_url, email) as client:
|
||
client.post("/api/storefronts", json={"name": "Ben's Bets"})
|
||
# fresh login: backdate the consumed code to clear the 60s resend cooldown
|
||
with psycopg.connect(fresh_db_url) as c:
|
||
c.execute(
|
||
"UPDATE auth_code SET created_at = %s WHERE email = %s",
|
||
(datetime.now(timezone.utc) - timedelta(minutes=2), email),
|
||
)
|
||
c.commit()
|
||
client.post("/api/auth/request-code", json={"email": email})
|
||
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
|
||
resp = client.post("/api/auth/verify", json={"email": email, "code": code})
|
||
assert resp.status_code == 200
|
||
assert resp.json()["storefront"]["name"] == "Ben's Bets"
|
||
assert resp.json()["created"] is False
|
||
|
||
|
||
def test_puc_07_second_create_refused_409(fresh_db_url):
|
||
with _signed_in_client(fresh_db_url) as client:
|
||
client.post("/api/storefronts", json={"name": "First"})
|
||
resp = client.post("/api/storefronts", json={"name": "Second"})
|
||
assert resp.status_code == 409
|
||
assert resp.json()["error"]["code"] == "already_owns_storefront"
|
||
|
||
|
||
def test_puc_08_admin_shell_answer(fresh_db_url):
|
||
# The shell renders from /me alone: storefront name + signed-in email (§6.5 PUC-8).
|
||
with _signed_in_client(fresh_db_url) as client:
|
||
client.post("/api/storefronts", json={"name": "Ben's Bets"})
|
||
me = client.get("/api/auth/me").json()
|
||
assert me["account"]["email"] == "merchant@example.com"
|
||
assert me["storefront"]["name"] == "Ben's Bets"
|
||
|
||
|
||
def test_create_storefront_requires_session(fresh_db_url):
|
||
with TestClient(create_app(database_url=fresh_db_url)) as client:
|
||
resp = client.post("/api/storefronts", json={"name": "Nope"})
|
||
assert resp.status_code == 401
|
||
assert resp.json()["error"]["code"] == "unauthenticated"
|
||
```
|
||
|
||
- [x] **Step 2: Run to verify the new tests fail**
|
||
|
||
```bash
|
||
cd backend && .venv/bin/python -m pytest tests/test_storefronts_create.py -q
|
||
```
|
||
Expected: the endpoint tests FAIL with 404/405 (`POST /api/storefronts` absent); Task-1 service tests still pass.
|
||
|
||
- [x] **Step 3: Wire the BFF**
|
||
|
||
In `backend/app/main.py`:
|
||
|
||
1. Module docstring: replace the last sentence (`storefront is hard-wired null this slice; the storefronts domain (SLICE-3) replaces the _storefront_for seam.`) with `SLICE-3 adds POST /api/storefronts and feeds the _storefront_for seam from the storefronts domain.`
|
||
2. Import the domain: `from app.domains import accounts, storefronts` (replacing the accounts-only import).
|
||
3. Add the request model next to `VerifyBody`:
|
||
|
||
```python
|
||
class CreateStorefrontBody(BaseModel):
|
||
name: str | None = None
|
||
```
|
||
|
||
4. Replace `_storefront_for`'s body (keep the signature):
|
||
|
||
```python
|
||
def _storefront_for(conn: psycopg.Connection, account: accounts.Account) -> dict | None:
|
||
"""The entry-routing answer: which storefront, if any, this account has (§6.5)."""
|
||
sf = storefronts.storefront_for(conn, account.id)
|
||
return {"id": sf.id, "name": sf.name} if sf else None
|
||
```
|
||
|
||
5. Bump `FastAPI(title="ecomm", version="0.2", ...)` to `version="0.3"`.
|
||
6. Add the endpoint after `logout`:
|
||
|
||
```python
|
||
@app.post("/api/storefronts")
|
||
def create_storefront(
|
||
body: CreateStorefrontBody,
|
||
conn: psycopg.Connection = Depends(get_conn),
|
||
sess: dict | None = Depends(get_session),
|
||
):
|
||
"""Create the account's one storefront (§6.4; PUC-4, INV-4/PUC-7 on refusal)."""
|
||
if sess is None:
|
||
return _error(401, "unauthenticated", "You are not signed in.")
|
||
account = accounts.get_account(conn, sess["account_id"])
|
||
if account is None:
|
||
return _error(401, "unauthenticated", "You are not signed in.")
|
||
try:
|
||
sf = storefronts.create_storefront(conn, account.id, account.email, body.name)
|
||
except storefronts.AlreadyOwnsStorefront:
|
||
return _error(
|
||
409, "already_owns_storefront",
|
||
"Your account already has its storefront — ecomm is one storefront per account today.",
|
||
)
|
||
return JSONResponse(status_code=201, content={"id": sf.id, "name": sf.name})
|
||
```
|
||
|
||
- [x] **Step 4: Run the whole backend suite + layering**
|
||
|
||
```bash
|
||
cd backend && .venv/bin/python -m pytest -q && .venv/bin/lint-imports
|
||
```
|
||
Expected: all tests PASS (incl. the SLICE-2 suites — the seam change keeps `storefront: null` for storefront-less accounts), contract kept.
|
||
|
||
- [x] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/app/main.py backend/tests/test_storefronts_create.py
|
||
git commit -m "feat(slice-3): POST /api/storefronts + storefronts-fed entry routing (§6.4/§6.5)"
|
||
```
|
||
|
||
### Task 4: INV-1 whole-flow bootstrap test
|
||
|
||
**Files:**
|
||
- Test: `backend/tests/test_bootstrap.py`
|
||
|
||
- [x] **Step 1: Write the test**
|
||
|
||
```python
|
||
"""INV-1's enforcement (SD-0001 §6.8): from an empty database, one test walks the whole
|
||
flow — request-code -> verify -> create-storefront -> /me — asserting no step needed
|
||
seeded state. Migration idempotence (the second INV-1 test) lives in test_migrations.py."""
|
||
import re
|
||
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.main import create_app
|
||
|
||
|
||
def test_inv_1_bootstrap_whole_flow_from_empty(fresh_db_url):
|
||
# fresh_db_url is a brand-new empty database; create_app() self-migrates (INV-7).
|
||
with TestClient(create_app(database_url=fresh_db_url)) as client:
|
||
# a fresh deployment serves healthz green before any row exists
|
||
assert client.get("/healthz").json() == {"status": "ok"}
|
||
|
||
# PUC-2: first visitor requests a code; it reaches them via the dev channel
|
||
assert client.post(
|
||
"/api/auth/request-code", json={"email": "first@example.com"}
|
||
).status_code == 204
|
||
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
|
||
|
||
# verify creates account #1 — the first row, through the product alone
|
||
verified = client.post(
|
||
"/api/auth/verify", json={"email": "first@example.com", "code": code}
|
||
)
|
||
assert verified.status_code == 200
|
||
assert verified.json()["created"] is True
|
||
assert verified.json()["storefront"] is None # -> create-storefront (PUC-5)
|
||
|
||
# PUC-4: create the storefront (blank name -> generated default)
|
||
created = client.post("/api/storefronts", json={})
|
||
assert created.status_code == 201
|
||
assert created.json()["name"] == "first's storefront"
|
||
|
||
# PUC-6/PUC-8: the admin answer — storefront + email from /me alone
|
||
me = client.get("/api/auth/me")
|
||
assert me.status_code == 200
|
||
assert me.json() == {
|
||
"account": {"email": "first@example.com"},
|
||
"storefront": created.json(),
|
||
}
|
||
```
|
||
|
||
- [x] **Step 2: Run it**
|
||
|
||
```bash
|
||
cd backend && .venv/bin/python -m pytest tests/test_bootstrap.py -q
|
||
```
|
||
Expected: PASS.
|
||
|
||
- [x] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add backend/tests/test_bootstrap.py
|
||
git commit -m "test(slice-3): INV-1 whole-flow bootstrap test — empty DB to admin answer (§6.8)"
|
||
```
|
||
|
||
### Task 5: Frontend design foundation — tokens, fonts, brand assets, app.css, UI kit
|
||
|
||
**Files:**
|
||
- Create: `frontend/public/fonts/` (5 woff2 + nothing else), `frontend/public/brand/` (2 svg), `frontend/src/styles/{fonts,tokens-colors,tokens-typography,tokens-spacing,app,index}.css`, `frontend/src/ui/kit.tsx`, `frontend/src/ui/CodeInput.tsx`
|
||
- Modify: `frontend/src/main.tsx`, `frontend/index.html`
|
||
|
||
- [x] **Step 1: Copy the design-system artifacts**
|
||
|
||
```bash
|
||
DS="/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content/ui/designs/ecomm-login-and-create-storefront-designs/project/_ds/wiggleverse-design-system-94cd8055-314c-4d7c-b347-3d24b698eff8"
|
||
PROJ="/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content/ui/designs/ecomm-login-and-create-storefront-designs/project"
|
||
mkdir -p frontend/public/fonts frontend/public/brand frontend/src/styles frontend/src/ui
|
||
cp "$DS"/assets/fonts/space-grotesk-v22-latin-500.woff2 "$DS"/assets/fonts/space-grotesk-v22-latin-700.woff2 \
|
||
"$DS"/assets/fonts/inter-v20-latin-regular.woff2 "$DS"/assets/fonts/inter-v20-latin-500.woff2 \
|
||
"$DS"/assets/fonts/inter-v20-latin-600.woff2 frontend/public/fonts/
|
||
cp "$PROJ"/assets/mark-tile.svg "$PROJ"/assets/mark-mono-gold.svg frontend/public/brand/
|
||
cp "$DS"/tokens/colors.css frontend/src/styles/tokens-colors.css
|
||
cp "$DS"/tokens/typography.css frontend/src/styles/tokens-typography.css
|
||
cp "$DS"/tokens/spacing.css frontend/src/styles/tokens-spacing.css
|
||
```
|
||
|
||
(Fraunces is not copied — no screen in this slice uses the human register; add it when one does, YAGNI.)
|
||
|
||
- [x] **Step 2: Write `frontend/src/styles/fonts.css`** (paths point at `/fonts/`; weights per the DS manifest)
|
||
|
||
```css
|
||
/* Wiggleverse webfonts (from the design-system export) — Space Grotesk (display) +
|
||
Inter (body/UI). Fraunces (human register) deliberately omitted: no screen uses it yet. */
|
||
@font-face {
|
||
font-family: "Space Grotesk";
|
||
font-style: normal;
|
||
font-weight: 500;
|
||
font-display: swap;
|
||
src: url("/fonts/space-grotesk-v22-latin-500.woff2") format("woff2");
|
||
}
|
||
@font-face {
|
||
font-family: "Space Grotesk";
|
||
font-style: normal;
|
||
font-weight: 700;
|
||
font-display: swap;
|
||
src: url("/fonts/space-grotesk-v22-latin-700.woff2") format("woff2");
|
||
}
|
||
@font-face {
|
||
font-family: "Inter";
|
||
font-style: normal;
|
||
font-weight: 400;
|
||
font-display: swap;
|
||
src: url("/fonts/inter-v20-latin-regular.woff2") format("woff2");
|
||
}
|
||
@font-face {
|
||
font-family: "Inter";
|
||
font-style: normal;
|
||
font-weight: 500;
|
||
font-display: swap;
|
||
src: url("/fonts/inter-v20-latin-500.woff2") format("woff2");
|
||
}
|
||
@font-face {
|
||
font-family: "Inter";
|
||
font-style: normal;
|
||
font-weight: 600;
|
||
font-display: swap;
|
||
src: url("/fonts/inter-v20-latin-600.woff2") format("woff2");
|
||
}
|
||
```
|
||
|
||
- [x] **Step 3: Write `frontend/src/styles/app.css`** — the hf-kit primitives as classes (key values from `hf-kit.jsx`: starfield/horizon ground, 68px glass topbar, raised auth card, 52px fields with gold focus ring, gold pill button, gold/lilac banners, hairline footer, 6-cell code input)
|
||
|
||
```css
|
||
/* ecomm app chrome — the hf-kit primitives (Direction A · Quiet centered) as CSS.
|
||
Depth = layered surfaces + hairlines; hover = small lift, no new shadows. */
|
||
|
||
* { box-sizing: border-box; }
|
||
|
||
html, body, #root { height: 100%; }
|
||
|
||
body {
|
||
margin: 0;
|
||
background: var(--wv-midnight);
|
||
color: var(--wv-starlight);
|
||
font-family: var(--wv-font-body);
|
||
-webkit-font-smoothing: antialiased;
|
||
}
|
||
|
||
/* ── screen ground: barely-there starfield + optional gold horizon ─────────── */
|
||
.screen {
|
||
min-height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: relative;
|
||
background:
|
||
radial-gradient(1.3px 1.3px at 16% 22%, rgba(237, 234, 255, .34), transparent),
|
||
radial-gradient(1.2px 1.2px at 78% 14%, rgba(237, 234, 255, .22), transparent),
|
||
radial-gradient(1.1px 1.1px at 88% 46%, rgba(155, 140, 255, .30), transparent),
|
||
radial-gradient(1.3px 1.3px at 30% 72%, rgba(237, 234, 255, .24), transparent),
|
||
radial-gradient(1.1px 1.1px at 62% 86%, rgba(237, 234, 255, .18), transparent),
|
||
radial-gradient(1.2px 1.2px at 7% 56%, rgba(155, 140, 255, .26), transparent),
|
||
var(--wv-midnight);
|
||
}
|
||
.screen--horizon {
|
||
background:
|
||
radial-gradient(120% 70% at 50% 142%, rgba(244, 199, 107, .16) 0%, rgba(244, 199, 107, .05) 38%, transparent 64%),
|
||
radial-gradient(1.3px 1.3px at 16% 22%, rgba(237, 234, 255, .34), transparent),
|
||
radial-gradient(1.2px 1.2px at 78% 14%, rgba(237, 234, 255, .22), transparent),
|
||
radial-gradient(1.1px 1.1px at 88% 46%, rgba(155, 140, 255, .30), transparent),
|
||
radial-gradient(1.3px 1.3px at 30% 72%, rgba(237, 234, 255, .24), transparent),
|
||
radial-gradient(1.1px 1.1px at 62% 86%, rgba(237, 234, 255, .18), transparent),
|
||
radial-gradient(1.2px 1.2px at 7% 56%, rgba(155, 140, 255, .26), transparent),
|
||
var(--wv-midnight);
|
||
}
|
||
.screen--plain { background: var(--wv-midnight); }
|
||
|
||
.screen__main {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 32px 24px;
|
||
}
|
||
|
||
/* ── top bar: glass chrome ──────────────────────────────────────────────────── */
|
||
.topbar {
|
||
flex: 0 0 auto;
|
||
height: 68px;
|
||
padding: 0 36px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
border-bottom: 1px solid var(--border-soft);
|
||
background: var(--glass-sky);
|
||
backdrop-filter: blur(var(--glass-blur));
|
||
position: relative;
|
||
z-index: 5;
|
||
}
|
||
.topbar__side { display: flex; align-items: center; gap: 16px; min-width: 0; }
|
||
|
||
/* ── wordmark ───────────────────────────────────────────────────────────────── */
|
||
.wordmark { display: inline-flex; align-items: center; gap: 10px; }
|
||
.wordmark img { display: block; border-radius: 6px; }
|
||
.wordmark__name {
|
||
font-family: var(--wv-font-display);
|
||
font-weight: var(--weight-bold);
|
||
font-size: 22px;
|
||
letter-spacing: var(--tracking-display);
|
||
color: var(--wv-starlight);
|
||
}
|
||
|
||
/* ── links & text buttons ───────────────────────────────────────────────────── */
|
||
.lk {
|
||
color: var(--wv-lilac);
|
||
text-decoration: none;
|
||
background: none;
|
||
border: none;
|
||
padding: 0;
|
||
font: inherit;
|
||
cursor: pointer;
|
||
transition: color var(--dur-fast) var(--ease);
|
||
}
|
||
.lk:hover { color: var(--wv-gold); }
|
||
.lk--mute { color: var(--text-on-dark-mute); }
|
||
.lk--nav {
|
||
font-family: var(--wv-font-display);
|
||
font-weight: var(--weight-medium);
|
||
font-size: 14px;
|
||
color: var(--wv-starlight);
|
||
}
|
||
|
||
.signout {
|
||
font-family: var(--wv-font-display);
|
||
font-weight: var(--weight-medium);
|
||
font-size: 14px;
|
||
color: var(--wv-starlight);
|
||
background: transparent;
|
||
border: none;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
transition: color var(--dur-fast) var(--ease);
|
||
}
|
||
.signout:hover { color: var(--wv-gold); }
|
||
|
||
/* ── account chip (top-bar right, signed in) ────────────────────────────────── */
|
||
.chip { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||
.chip__avatar {
|
||
width: 30px;
|
||
height: 30px;
|
||
border-radius: 15px;
|
||
background: var(--wv-lilac-16);
|
||
border: 1px solid var(--border-card);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-family: var(--wv-font-display);
|
||
font-weight: var(--weight-bold);
|
||
font-size: 13px;
|
||
flex: 0 0 auto;
|
||
}
|
||
.chip__email {
|
||
font-size: 14px;
|
||
color: var(--text-on-dark-soft);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.chip__divider { width: 1px; height: 18px; background: var(--border-soft); flex: 0 0 auto; }
|
||
|
||
/* ── eyebrow ────────────────────────────────────────────────────────────────── */
|
||
.eyebrow {
|
||
font-family: var(--wv-font-display);
|
||
font-weight: var(--weight-medium);
|
||
font-size: var(--text-eyebrow);
|
||
letter-spacing: var(--tracking-eyebrow);
|
||
text-transform: uppercase;
|
||
color: var(--wv-lilac);
|
||
margin: 0;
|
||
}
|
||
|
||
/* ── auth card — the one surface with a real shadow ─────────────────────────── */
|
||
.card {
|
||
width: 440px;
|
||
max-width: 100%;
|
||
background: var(--surface-raised);
|
||
border: 1px solid var(--border-card);
|
||
border-radius: var(--radius-card);
|
||
padding: 40px 40px 34px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 22px;
|
||
box-shadow: var(--shadow-soft);
|
||
position: relative;
|
||
z-index: 2;
|
||
}
|
||
.card h1 {
|
||
font-family: var(--wv-font-display);
|
||
font-weight: var(--weight-bold);
|
||
letter-spacing: var(--tracking-display);
|
||
font-size: 27px;
|
||
line-height: 1.08;
|
||
margin: 0 0 9px;
|
||
}
|
||
.card__sub { font-size: 14.5px; line-height: 1.55; color: var(--text-on-dark-soft); margin: 0; }
|
||
|
||
/* ── fields ─────────────────────────────────────────────────────────────────── */
|
||
.field { display: flex; flex-direction: column; gap: 8px; }
|
||
.field__label {
|
||
font-weight: var(--weight-medium);
|
||
font-size: 13.5px;
|
||
color: var(--text-on-dark-soft);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: baseline;
|
||
}
|
||
.field__optional { font-size: 12.5px; color: var(--text-on-dark-mute); font-weight: var(--weight-regular); }
|
||
.field__input {
|
||
height: 52px;
|
||
border-radius: 10px;
|
||
padding: 0 16px;
|
||
background: rgba(237, 234, 255, .035);
|
||
border: 1.5px solid var(--border-soft);
|
||
font-family: var(--wv-font-body);
|
||
font-size: 16px;
|
||
color: var(--wv-starlight);
|
||
width: 100%;
|
||
transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
|
||
}
|
||
.field__input::placeholder { color: var(--text-on-dark-mute); }
|
||
.field__input:hover { border-color: var(--border-strong); }
|
||
.field__input:focus {
|
||
outline: none;
|
||
border-color: var(--wv-gold);
|
||
box-shadow: 0 0 0 3px var(--wv-gold-28);
|
||
background: rgba(237, 234, 255, .06);
|
||
}
|
||
.field__input--error { border-color: var(--wv-gold); }
|
||
|
||
.note { font-size: 13px; line-height: 1.45; color: var(--text-on-dark-mute); margin: 0; }
|
||
.note--attn { color: var(--wv-gold); display: flex; gap: 7px; align-items: baseline; }
|
||
.note--attn::before { content: "◆"; font-size: 11px; }
|
||
|
||
/* ── primary button: gold pill ──────────────────────────────────────────────── */
|
||
.btn-primary {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: .4em;
|
||
width: 100%;
|
||
font-family: var(--wv-font-display);
|
||
font-weight: var(--weight-medium);
|
||
font-size: 15.5px;
|
||
line-height: 1;
|
||
padding: .85rem 1.4rem;
|
||
border-radius: var(--radius-pill);
|
||
border: var(--btn-border-w) solid transparent;
|
||
background: var(--cta);
|
||
color: var(--cta-text);
|
||
cursor: pointer;
|
||
transition: transform var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
|
||
}
|
||
.btn-primary:hover:not(:disabled) { background: var(--cta-hover); transform: var(--lift-1); }
|
||
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
||
.btn-primary--auto { width: auto; }
|
||
.btn-primary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
|
||
|
||
/* ── banner: gold attention / lilac info (no red in the palette) ────────────── */
|
||
.banner {
|
||
display: flex;
|
||
gap: 12px;
|
||
padding: 14px 16px;
|
||
border-radius: 12px;
|
||
align-items: flex-start;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
color: var(--text-on-dark-soft);
|
||
}
|
||
.banner--attn { background: rgba(244, 199, 107, .10); border: 1px solid var(--wv-gold-40); }
|
||
.banner--info { background: var(--wv-lilac-08); border: 1px solid var(--wv-lilac-18); }
|
||
.banner__icon { font-size: 13px; line-height: 22px; flex: 0 0 auto; }
|
||
.banner--attn .banner__icon { color: var(--wv-gold); }
|
||
.banner--info .banner__icon { color: var(--wv-lilac); }
|
||
.banner__title { color: var(--wv-starlight); font-weight: var(--weight-semibold); margin-bottom: 2px; }
|
||
|
||
/* ── code input: 6 cells over one invisible input ───────────────────────────── */
|
||
.code { position: relative; display: flex; gap: 11px; justify-content: center; }
|
||
.code__hidden {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
opacity: 0;
|
||
border: none;
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
}
|
||
.code__cell {
|
||
width: 52px;
|
||
height: 64px;
|
||
border-radius: 11px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(237, 234, 255, .035);
|
||
border: 1.5px solid var(--border-soft);
|
||
font-family: var(--wv-font-display);
|
||
font-weight: var(--weight-medium);
|
||
font-size: 26px;
|
||
transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
|
||
}
|
||
.code--focus .code__cell--active {
|
||
border-color: var(--wv-gold);
|
||
box-shadow: 0 0 0 3px var(--wv-gold-28);
|
||
}
|
||
.code--error .code__cell { border-color: var(--wv-gold); }
|
||
|
||
/* ── footer ─────────────────────────────────────────────────────────────────── */
|
||
.footer {
|
||
flex: 0 0 auto;
|
||
padding: 20px 36px;
|
||
border-top: 1px solid var(--border-soft);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 16px;
|
||
font-size: 12.5px;
|
||
color: var(--text-on-dark-mute);
|
||
background: rgba(9, 12, 34, .4);
|
||
}
|
||
.footer__id { display: inline-flex; align-items: center; gap: 9px; }
|
||
.footer__id img { opacity: .85; }
|
||
|
||
/* ── landing ────────────────────────────────────────────────────────────────── */
|
||
.hero { max-width: 680px; display: flex; flex-direction: column; align-items: center; text-align: center; }
|
||
.hero h1 {
|
||
font-family: var(--wv-font-display);
|
||
font-weight: var(--weight-bold);
|
||
letter-spacing: -0.02em;
|
||
line-height: 1.02;
|
||
font-size: clamp(36px, 6.5vw, 62px);
|
||
margin: 20px 0 22px;
|
||
}
|
||
.hero h1 em { font-style: normal; color: var(--wv-gold); }
|
||
.hero__lead {
|
||
font-size: clamp(15.5px, 1.8vw, 18.5px);
|
||
line-height: 1.6;
|
||
color: var(--text-on-dark-soft);
|
||
max-width: 520px;
|
||
margin: 0 0 32px;
|
||
text-wrap: pretty;
|
||
}
|
||
.hero__actions { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; justify-content: center; }
|
||
|
||
.promises {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
width: 100%;
|
||
margin-top: 44px;
|
||
border-top: 1px solid var(--border-soft);
|
||
padding-top: 24px;
|
||
text-align: left;
|
||
}
|
||
.promises > div { padding: 0 22px; }
|
||
.promises > div + div { border-left: 1px solid var(--border-soft); }
|
||
.promises__title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 5px;
|
||
font-family: var(--wv-font-display);
|
||
font-weight: var(--weight-medium);
|
||
font-size: 14.5px;
|
||
}
|
||
.promises__title::before { content: "◆"; color: var(--wv-gold); font-size: 10px; }
|
||
.promises p { font-size: 13px; line-height: 1.5; color: var(--text-on-dark-mute); margin: 0; }
|
||
|
||
/* ── admin shell ────────────────────────────────────────────────────────────── */
|
||
.storeid { display: inline-flex; align-items: center; gap: 11px; min-width: 0; }
|
||
.storeid img { display: block; border-radius: 7px; flex: 0 0 auto; }
|
||
.storeid__col { display: inline-flex; flex-direction: column; line-height: 1.12; min-width: 0; }
|
||
.storeid__name {
|
||
font-family: var(--wv-font-display);
|
||
font-weight: var(--weight-bold);
|
||
font-size: 17px;
|
||
letter-spacing: -0.01em;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.storeid__sub { font-size: 11px; color: var(--text-on-dark-mute); }
|
||
|
||
.empty { display: flex; flex-direction: column; align-items: center; text-align: center; max-width: 560px; }
|
||
.empty__seal {
|
||
width: 76px;
|
||
height: 76px;
|
||
border-radius: 50%;
|
||
border: 1px solid var(--border-card);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-bottom: 26px;
|
||
background: var(--wv-lilac-08);
|
||
}
|
||
.empty h1 {
|
||
font-family: var(--wv-font-display);
|
||
font-weight: var(--weight-bold);
|
||
letter-spacing: var(--tracking-display);
|
||
line-height: 1.04;
|
||
font-size: clamp(30px, 4.5vw, 42px);
|
||
margin: 12px 0 16px;
|
||
}
|
||
.empty__copy { font-size: 17px; line-height: 1.6; color: var(--text-on-dark-soft); max-width: 460px; margin: 0; text-wrap: pretty; }
|
||
|
||
/* ── small screens ──────────────────────────────────────────────────────────── */
|
||
@media (max-width: 720px) {
|
||
.topbar { padding: 0 20px; }
|
||
.footer { padding: 16px 20px; flex-direction: column; }
|
||
.card { padding: 28px 22px 24px; }
|
||
.promises { grid-template-columns: 1fr; gap: 14px; }
|
||
.promises > div { padding: 0; }
|
||
.promises > div + div { border-left: none; }
|
||
.code__cell { width: 44px; height: 56px; font-size: 22px; }
|
||
}
|
||
```
|
||
|
||
- [x] **Step 4: Write `frontend/src/styles/index.css`** (the one file the app imports)
|
||
|
||
```css
|
||
/* Global style entry point — Wiggleverse design-system tokens (copied from the
|
||
ui/designs export) + the ecomm app chrome built on them. */
|
||
@import "./fonts.css";
|
||
@import "./tokens-colors.css";
|
||
@import "./tokens-typography.css";
|
||
@import "./tokens-spacing.css";
|
||
@import "./app.css";
|
||
```
|
||
|
||
- [x] **Step 5: Import styles in `frontend/src/main.tsx`** (add as the first import)
|
||
|
||
```tsx
|
||
import "./styles/index.css";
|
||
```
|
||
|
||
- [x] **Step 6: Set the ground color in `frontend/index.html`** (avoid a white flash before CSS loads; head additions)
|
||
|
||
```html
|
||
<meta name="theme-color" content="#0E1230" />
|
||
<style>html { background: #0E1230; }</style>
|
||
<link rel="icon" type="image/svg+xml" href="/brand/mark-tile.svg" />
|
||
```
|
||
|
||
- [x] **Step 7: Write `frontend/src/ui/kit.tsx`** — the shared primitives
|
||
|
||
```tsx
|
||
// The ecomm UI kit — React faces of the app.css primitives, matching the Claude Design
|
||
// export (ui/designs/ecomm-login-and-create-storefront-designs, hf-kit). Presentation
|
||
// only: no business rules live here (§6.2 — the SPA renders what the BFF returns).
|
||
import type { ReactNode } from "react";
|
||
|
||
export function Screen({
|
||
children,
|
||
horizon = false,
|
||
plain = false,
|
||
}: {
|
||
children: ReactNode;
|
||
horizon?: boolean;
|
||
plain?: boolean;
|
||
}) {
|
||
const cls = plain ? "screen screen--plain" : horizon ? "screen screen--horizon" : "screen";
|
||
return <div className={cls}>{children}</div>;
|
||
}
|
||
|
||
export function TopBar({ left, right }: { left: ReactNode; right?: ReactNode }) {
|
||
return (
|
||
<header className="topbar">
|
||
<div className="topbar__side">{left}</div>
|
||
<div className="topbar__side">{right}</div>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
export function Wordmark({ size = 26 }: { size?: number }) {
|
||
return (
|
||
<span className="wordmark">
|
||
<img src="/brand/mark-tile.svg" width={size} height={size} alt="" />
|
||
<span className="wordmark__name" style={{ fontSize: size * 0.86 }}>
|
||
ecomm
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export function Footer() {
|
||
return (
|
||
<footer className="footer">
|
||
<span className="footer__id">
|
||
<img src="/brand/mark-mono-gold.svg" width={16} height={16} alt="" />
|
||
ecomm · a Wiggleverse line
|
||
</span>
|
||
</footer>
|
||
);
|
||
}
|
||
|
||
export function AuthCard({ children }: { children: ReactNode }) {
|
||
return <div className="card">{children}</div>;
|
||
}
|
||
|
||
export function Eyebrow({ children }: { children: ReactNode }) {
|
||
return <p className="eyebrow">{children}</p>;
|
||
}
|
||
|
||
export function Banner({
|
||
tone = "attn",
|
||
title,
|
||
children,
|
||
}: {
|
||
tone?: "attn" | "info";
|
||
title?: string;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<div className={`banner banner--${tone}`} role={tone === "attn" ? "alert" : "status"}>
|
||
<span className="banner__icon" aria-hidden="true">
|
||
{tone === "attn" ? "◆" : "◇"}
|
||
</span>
|
||
<div>
|
||
{title && <div className="banner__title">{title}</div>}
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function PrimaryButton({
|
||
children,
|
||
busy = false,
|
||
disabled = false,
|
||
type = "submit",
|
||
onClick,
|
||
}: {
|
||
children: ReactNode;
|
||
busy?: boolean;
|
||
disabled?: boolean;
|
||
type?: "submit" | "button";
|
||
onClick?: () => void;
|
||
}) {
|
||
return (
|
||
<button className="btn-primary" type={type} disabled={disabled || busy} onClick={onClick}>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export function Field({
|
||
label,
|
||
optional = false,
|
||
error,
|
||
helper,
|
||
inputProps,
|
||
}: {
|
||
label: string;
|
||
optional?: boolean;
|
||
error?: string | null;
|
||
helper?: string;
|
||
inputProps: React.InputHTMLAttributes<HTMLInputElement>;
|
||
}) {
|
||
return (
|
||
<div className="field">
|
||
<label className="field__label">
|
||
<span>{label}</span>
|
||
{optional && <span className="field__optional">optional</span>}
|
||
</label>
|
||
<input
|
||
className={`field__input${error ? " field__input--error" : ""}`}
|
||
aria-invalid={!!error}
|
||
{...inputProps}
|
||
/>
|
||
{error && (
|
||
<p className="note note--attn" role="alert">
|
||
{error}
|
||
</p>
|
||
)}
|
||
{helper && !error && <p className="note">{helper}</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AccountChip({ email, onSignOut }: { email: string; onSignOut: () => void }) {
|
||
return (
|
||
<div className="chip">
|
||
<span className="chip__avatar" aria-hidden="true">
|
||
{email[0]?.toUpperCase()}
|
||
</span>
|
||
<span className="chip__email">{email}</span>
|
||
<span className="chip__divider" aria-hidden="true" />
|
||
<button type="button" className="signout" onClick={onSignOut}>
|
||
Sign out
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [x] **Step 8: Write `frontend/src/ui/CodeInput.tsx`** — 6 cells over one invisible input (one-time-code semantics so password managers/OS autofill cooperate, §6.6)
|
||
|
||
```tsx
|
||
// Six-cell one-time-code input: one real <input> (invisible, full-bleed) carries the value
|
||
// and the OTC autofill semantics; the cells are presentation. Click anywhere to focus;
|
||
// paste works because the input is real.
|
||
import { useState } from "react";
|
||
|
||
const LENGTH = 6;
|
||
|
||
export default function CodeInput({
|
||
value,
|
||
onChange,
|
||
error = false,
|
||
}: {
|
||
value: string;
|
||
onChange: (code: string) => void;
|
||
error?: boolean;
|
||
}) {
|
||
const [focused, setFocused] = useState(false);
|
||
const digits = value.slice(0, LENGTH).split("");
|
||
const active = Math.min(digits.length, LENGTH - 1);
|
||
|
||
return (
|
||
<div className={`code${focused ? " code--focus" : ""}${error ? " code--error" : ""}`}>
|
||
<input
|
||
className="code__hidden"
|
||
value={value}
|
||
inputMode="numeric"
|
||
autoComplete="one-time-code"
|
||
aria-label="One-time code"
|
||
autoFocus
|
||
maxLength={LENGTH}
|
||
onFocus={() => setFocused(true)}
|
||
onBlur={() => setFocused(false)}
|
||
onChange={(ev) => onChange(ev.target.value.replace(/\D/g, "").slice(0, LENGTH))}
|
||
/>
|
||
{Array.from({ length: LENGTH }, (_, i) => (
|
||
<span
|
||
key={i}
|
||
aria-hidden="true"
|
||
className={`code__cell${i === active && focused ? " code__cell--active" : ""}`}
|
||
>
|
||
{digits[i] ?? ""}
|
||
</span>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [x] **Step 9: Typecheck + build to verify the foundation stands**
|
||
|
||
```bash
|
||
cd frontend && npm run build
|
||
```
|
||
Expected: clean build (the kit is not yet imported anywhere — that's fine; tsc checks it).
|
||
|
||
- [x] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add frontend/public frontend/src/styles frontend/src/ui frontend/src/main.tsx frontend/index.html
|
||
git commit -m "feat(slice-3): design-system foundation — Wiggleverse tokens, fonts, brand marks, UI kit (per ui/designs export)"
|
||
```
|
||
|
||
### Task 6: Landing rebuild (per hf-landing)
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/screens/Landing.tsx`
|
||
|
||
- [x] **Step 1: Rebuild the screen**
|
||
|
||
```tsx
|
||
// 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. Visuals per the ui/designs export (hf-landing, Direction A).
|
||
import { Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
|
||
|
||
interface Props {
|
||
onGetStarted: () => void;
|
||
onLogIn: () => void;
|
||
}
|
||
|
||
const PROMISES: Array<[string, string]> = [
|
||
["No trial clock", "Your storefront never expires or locks."],
|
||
["No plan wall", "We take only what it takes to run."],
|
||
["Your data, yours", "Nothing collected you didn't agree to give."],
|
||
];
|
||
|
||
export default function Landing({ onGetStarted, onLogIn }: Props) {
|
||
return (
|
||
<Screen horizon>
|
||
<TopBar
|
||
left={<Wordmark />}
|
||
right={
|
||
<button type="button" className="lk lk--nav" onClick={onLogIn}>
|
||
Log in
|
||
</button>
|
||
}
|
||
/>
|
||
<main className="screen__main">
|
||
<div className="hero">
|
||
<Eyebrow>One storefront, fully yours</Eyebrow>
|
||
<h1>
|
||
Sell online,
|
||
<br />
|
||
<em>honestly.</em>
|
||
</h1>
|
||
<p className="hero__lead">
|
||
Claim the one storefront that's yours on a platform that takes only what it takes
|
||
to run — no trial countdown, no plan wall, no data you didn't agree to give.
|
||
</p>
|
||
<div className="hero__actions">
|
||
<PrimaryButton type="button" onClick={onGetStarted}>
|
||
Create your storefront →
|
||
</PrimaryButton>
|
||
<button type="button" className="lk lk--mute" onClick={onLogIn}>
|
||
Already selling with us? <span style={{ color: "var(--wv-lilac)" }}>Log in</span>
|
||
</button>
|
||
</div>
|
||
<div className="promises">
|
||
{PROMISES.map(([title, copy]) => (
|
||
<div key={title}>
|
||
<div className="promises__title">{title}</div>
|
||
<p>{copy}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</main>
|
||
<Footer />
|
||
</Screen>
|
||
);
|
||
}
|
||
```
|
||
|
||
(add `Eyebrow` to the kit import list: `import { Eyebrow, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";`. The CTA renders auto-width inside `.hero__actions` — add `btn-primary--auto` via a wrapper if it stretches; check visually at Task 10.)
|
||
|
||
- [x] **Step 2: Build + tests**
|
||
|
||
```bash
|
||
cd frontend && npm run build && npm test
|
||
```
|
||
Expected: clean build, routing tests still green.
|
||
|
||
- [x] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/screens/Landing.tsx
|
||
git commit -m "feat(slice-3): Landing re-skinned to the Claude Design export (§5.1)"
|
||
```
|
||
|
||
### Task 7: Sign-in rebuild (per hf-signin) — cooldown countdown, code cells, honest states
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/screens/SignIn.tsx`
|
||
|
||
- [x] **Step 1: Rebuild the screen**
|
||
|
||
States per §5.2 + the design's states stack: invalid email → inline field error; sending → busy button; resend cooldown (PUC-2c) → countdown from `retry_after_s`; delivery failure (INV-9) → attn banner; wrong code (PUC-2a) → inline error with attempts remaining; expired (PUC-2b) → inline + resend offer; exhausted → attn banner; resend link cooldown-aware.
|
||
|
||
```tsx
|
||
// 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 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";
|
||
|
||
interface Props {
|
||
door: Door;
|
||
onAuthed: (result: VerifyResult) => void;
|
||
onBack: () => void;
|
||
}
|
||
|
||
function useCooldown(): [number, (s: number) => void] {
|
||
const [left, setLeft] = useState(0);
|
||
useEffect(() => {
|
||
if (left <= 0) return;
|
||
const t = setInterval(() => setLeft((v) => Math.max(0, v - 1)), 1000);
|
||
return () => clearInterval(t);
|
||
}, [left > 0]);
|
||
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 [fieldError, setFieldError] = useState<string | null>(null);
|
||
const [codeError, setCodeError] = useState<string | null>(null);
|
||
const [bannerError, setBannerError] = useState<ApiError | null>(null);
|
||
const [cooldown, setCooldown] = useCooldown();
|
||
|
||
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 sendCode(): Promise<boolean> {
|
||
setBusy(true);
|
||
setFieldError(null);
|
||
setBannerError(null);
|
||
const err = await requestCode(email);
|
||
setBusy(false);
|
||
if (err) {
|
||
applyError(err);
|
||
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");
|
||
}
|
||
}
|
||
|
||
async function resend() {
|
||
setCodeError(null);
|
||
setCode("");
|
||
await sendCode();
|
||
}
|
||
|
||
async function submitCode(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setBusy(true);
|
||
setCodeError(null);
|
||
setBannerError(null);
|
||
const res = await verifyCode(email, code);
|
||
setBusy(false);
|
||
if (!res.ok) {
|
||
applyError(res.error);
|
||
return;
|
||
}
|
||
onAuthed(res.result);
|
||
}
|
||
|
||
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 (
|
||
<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>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [x] **Step 2: Build + tests**
|
||
|
||
```bash
|
||
cd frontend && npm run build && npm test
|
||
```
|
||
Expected: green.
|
||
|
||
- [x] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/screens/SignIn.tsx
|
||
git commit -m "feat(slice-3): Sign-in re-skinned — code cells, cooldown countdown, honest §5.2 states"
|
||
```
|
||
|
||
### Task 8: Create-storefront screen + API helper
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/api.ts`
|
||
- Create: `frontend/src/screens/CreateStorefront.tsx`
|
||
|
||
- [x] **Step 1: Add the API helper** (append to `frontend/src/api.ts`)
|
||
|
||
```ts
|
||
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) };
|
||
}
|
||
```
|
||
|
||
- [x] **Step 2: Write the screen** (`frontend/src/screens/CreateStorefront.tsx`; per §5.3 + hf-storefront — name optional with helper, busy state, already-owns banner with admin link, honest error banner with retry; welcome banner carries PUC-3's honest copy after verify)
|
||
|
||
```tsx
|
||
// 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 { AuthCard, AccountChip, 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>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [x] **Step 3: Build**
|
||
|
||
```bash
|
||
cd frontend && npm run build
|
||
```
|
||
Expected: clean (screen not yet routed; tsc checks it).
|
||
|
||
- [x] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/api.ts frontend/src/screens/CreateStorefront.tsx
|
||
git commit -m "feat(slice-3): Create-storefront screen + API helper (§5.3, PUC-4/5/7)"
|
||
```
|
||
|
||
### Task 9: Admin shell + full entry routing in App
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/screens/Admin.tsx`
|
||
- Modify: `frontend/src/App.tsx`
|
||
- Delete: `frontend/src/screens/SignedIn.tsx`
|
||
|
||
- [x] **Step 1: Write the Admin screen** (per §5.4 + hf-admin — storefront name anchors the header, account chip + sign out right, honestly-empty centerpiece, no fabricated anything)
|
||
|
||
```tsx
|
||
// Admin shell (SD-0001 §5.4) — the storefront's stable home; honestly empty this release
|
||
// (PUC-8; PUC-9 sign-out). Renders from /me alone: storefront name + signed-in email. No
|
||
// zeroed metric tiles, no locked-feature teasers (OHM: Agency & Anti-Manipulation).
|
||
// Visuals per the ui/designs export (hf-admin).
|
||
import { logout } from "../api";
|
||
import { AccountChip, Banner, Eyebrow, Screen, TopBar } from "../ui/kit";
|
||
|
||
interface Props {
|
||
storefrontName: string;
|
||
email: string;
|
||
welcome: "new" | "back" | null;
|
||
onSignedOut: () => void;
|
||
}
|
||
|
||
export default function Admin({ storefrontName, email, welcome, onSignedOut }: Props) {
|
||
async function signOut() {
|
||
await logout();
|
||
onSignedOut();
|
||
}
|
||
|
||
return (
|
||
<Screen plain>
|
||
<TopBar
|
||
left={
|
||
<span className="storeid">
|
||
<img src="/brand/mark-tile.svg" width={24} height={24} alt="" />
|
||
<span className="storeid__col">
|
||
<span className="storeid__name">{storefrontName}</span>
|
||
<span className="storeid__sub">ecomm storefront</span>
|
||
</span>
|
||
</span>
|
||
}
|
||
right={<AccountChip email={email} onSignOut={signOut} />}
|
||
/>
|
||
<main className="screen__main">
|
||
<div className="empty">
|
||
{welcome && (
|
||
<div style={{ marginBottom: 24, width: "100%" }}>
|
||
<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>
|
||
</div>
|
||
)}
|
||
<div className="empty__seal" aria-hidden="true">
|
||
<img src="/brand/mark-mono-gold.svg" width={36} height={36} alt="" />
|
||
</div>
|
||
<Eyebrow>Your storefront</Eyebrow>
|
||
<h1>{storefrontName}</h1>
|
||
<p className="empty__copy">
|
||
There's nothing to manage yet — and that's a finished state, not a missing one.
|
||
Catalog, orders, and settings will appear here as ecomm grows.
|
||
</p>
|
||
</div>
|
||
</main>
|
||
</Screen>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [x] **Step 2: Complete the routing in `frontend/src/App.tsx`** (full §6.5 table: the verify response feeds the session directly — it carries the same shape as /me — and `welcome` carries the honest copy to the destination screen)
|
||
|
||
```tsx
|
||
// App shell — loads the session, applies the entry-routing rule (SD-0001 §6.5), and renders
|
||
// the matching screen. The rule is exhaustive: no state lands nowhere (BUC-4). The verify
|
||
// response carries the same shape as /me, so onAuthed feeds the session directly.
|
||
import { useEffect, useState } from "react";
|
||
import { getMe, type StorefrontResult, type VerifyResult } from "./api";
|
||
import { routeFor, type SessionState } from "./routing";
|
||
import Admin from "./screens/Admin";
|
||
import CreateStorefront from "./screens/CreateStorefront";
|
||
import Landing from "./screens/Landing";
|
||
import SignIn from "./screens/SignIn";
|
||
|
||
type Door = "signup" | "login";
|
||
type Welcome = "new" | "back" | null;
|
||
|
||
export default function App() {
|
||
const [session, setSession] = useState<SessionState | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [door, setDoor] = useState<Door | null>(null);
|
||
const [welcome, setWelcome] = useState<Welcome>(null);
|
||
|
||
async function reload() {
|
||
setLoading(true);
|
||
setSession(await getMe());
|
||
setLoading(false);
|
||
}
|
||
|
||
useEffect(() => {
|
||
void reload();
|
||
}, []);
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="screen screen--plain">
|
||
<main className="screen__main">
|
||
<p className="note">Loading…</p>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const route = routeFor(session ?? { account: null, storefront: null });
|
||
|
||
if (route === "landing") {
|
||
if (door) {
|
||
return (
|
||
<SignIn
|
||
door={door}
|
||
onBack={() => setDoor(null)}
|
||
onAuthed={(result: VerifyResult) => {
|
||
setWelcome(result.created ? "new" : "back");
|
||
setDoor(null);
|
||
setSession({ account: result.account, storefront: result.storefront });
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
return <Landing onGetStarted={() => setDoor("signup")} onLogIn={() => setDoor("login")} />;
|
||
}
|
||
|
||
const email = session!.account!.email;
|
||
|
||
function signedOut() {
|
||
setWelcome(null);
|
||
setDoor(null);
|
||
setSession(null);
|
||
}
|
||
|
||
if (route === "create-storefront") {
|
||
return (
|
||
<CreateStorefront
|
||
email={email}
|
||
welcome={welcome}
|
||
onCreated={(sf: StorefrontResult) => {
|
||
setWelcome(null);
|
||
setSession({ account: { email }, storefront: sf });
|
||
}}
|
||
onAlreadyOwns={() => void reload()}
|
||
onSignedOut={signedOut}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Admin
|
||
storefrontName={session!.storefront!.name}
|
||
email={email}
|
||
welcome={welcome}
|
||
onSignedOut={signedOut}
|
||
/>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [x] **Step 3: Delete the placeholder**
|
||
|
||
```bash
|
||
git rm frontend/src/screens/SignedIn.tsx
|
||
```
|
||
|
||
- [x] **Step 4: Build + tests**
|
||
|
||
```bash
|
||
cd frontend && npm run build && npm test
|
||
```
|
||
Expected: green — routing tests already assert all three routes (§6.5 table is unchanged; "admin" merely became reachable).
|
||
|
||
- [x] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/App.tsx frontend/src/screens/Admin.tsx
|
||
git commit -m "feat(slice-3): Admin shell + complete entry routing (§5.4/§6.5, PUC-6/8/9)"
|
||
```
|
||
|
||
### Task 10: Full gate + localhost walkthrough (PUC-10) + housekeeping
|
||
|
||
**Files:**
|
||
- Modify: `README.md`, `frontend/package.json`
|
||
|
||
- [x] **Step 1: Version bumps** — `frontend/package.json` `"version": "0.2.0"` → `"0.3.0"` (backend FastAPI version already bumped in Task 3).
|
||
|
||
- [x] **Step 2: README status** — update the status line/section that says the identity slice is in place to say SLICE-3 (storefront) is in place: storefronts domain, create + admin screens, full entry routing, INV-1 whole-flow green; SLICE-4 (deployed environments) is next.
|
||
|
||
- [x] **Step 3: Run the whole gate**
|
||
|
||
```bash
|
||
./scripts/check.sh
|
||
```
|
||
Expected: import contract kept; full backend suite green (SLICE-2's 43+3 plus the new storefronts/bootstrap tests); frontend typecheck + build + vitest green.
|
||
|
||
- [x] **Step 4: Localhost walkthrough (PUC-10, §7.2 definition of done)** — bring the app up and walk sign-up → create storefront → admin end to end against the real stack:
|
||
|
||
```bash
|
||
./scripts/dev.sh
|
||
```
|
||
|
||
Then drive the flow (browser or curl): request a code for a fresh email, read the 6-digit code from the backend log (`ecomm.mailer` INFO line), verify, create a storefront, confirm the admin answer. Screenshot or curl-transcript the result into the session transcript. Stop the dev stack after.
|
||
|
||
- [x] **Step 5: Commit + push + PR**
|
||
|
||
```bash
|
||
git add README.md frontend/package.json docs/superpowers/plans/2026-06-10-slice-3-storefront.md
|
||
git commit -m "chore(slice-3): version 0.3, README status — storefront slice in place"
|
||
git push -u origin slice-3-storefront
|
||
```
|
||
|
||
Open the PR against `main` titled `SLICE-3: storefront — create + INV-4 guard, Create/Admin screens, full entry routing (SD-0001 §7.2)`, body citing SD-0001 §7.2 SLICE-3 definition of done; merge per autonomous posture once green.
|
||
|
||
---
|
||
|
||
## Self-review notes
|
||
|
||
- **Spec coverage:** PUC-4 (Task 1/3/8), PUC-5 (Task 3 test + routing), PUC-6 (Task 3 test `14_01_0027` + Task 9), PUC-7 (Tasks 1–3 guard/409 + no second-create affordance anywhere in the UI), PUC-8 (Task 3 `puc_08` test + Task 9 Admin), corpus 0013/0015/0016/0025/0026/0027 (Task 3), INV-4 concurrent refusal (Task 2), INV-1 whole-flow (Task 4), §5.1–5.4 visuals incl. slices 1–2 re-skin (Tasks 5–9), localhost PUC-10 walkable (Task 10).
|
||
- **Deliberate divergences** (logged to Deferred decisions): no dead `#` links from the design; welcome banner moves to the destination screen; Fraunces not shipped (unused); E2E browser tests deferred per §6.8.
|
||
- **Type consistency:** `StorefrontResult {id, name}` (api.ts) = the 201 body = `SessionState.storefront` shape; `VerifyResult` already carries `storefront | null`; `welcome: "new" | "back" | null` threaded App → CreateStorefront/Admin.
|