SLICE-3: storefront — create + INV-4 guard, Create/Admin screens, full entry routing (SD-0001 §7.2) #9
@@ -20,10 +20,12 @@ This app (One Name `ecomm`, see [`app.json`](./app.json)) is composed of:
|
||||
|
||||
## Status
|
||||
|
||||
SLICE-2 (identity) of SD-0001 is in place on top of the SLICE-1 skeleton: open,
|
||||
passwordless sign-up/log-in by email + one-time code (`accounts` domain, INV-2/INV-3),
|
||||
signed-cookie sessions, the `/api/auth/*` endpoints (`request-code`, `verify`, `me`,
|
||||
`logout`), the `platform/mailer` port with `LogMailer` (the dev code channel), and the
|
||||
Landing + Sign-in screens with the entry-routing rule (storefront hard-wired `null` until
|
||||
SLICE-3). See [`docs/BOOTSTRAP.md`](./docs/BOOTSTRAP.md) to run it locally. The storefront
|
||||
(SLICE-3) and deployed environments (SLICE-4) are next.
|
||||
SLICE-3 (storefront) of SD-0001 is in place on top of SLICE-1/2: the `storefronts`
|
||||
domain (create + owner membership, INV-4's one-storefront guard with concurrent-create
|
||||
refusal), `POST /api/storefronts`, the Create-storefront and Admin-shell screens, the
|
||||
complete entry-routing rule (landing / create-storefront / admin), and the INV-1
|
||||
whole-flow bootstrap test — all skinned to the Claude Design export
|
||||
(`wiggleverse-ecomm-content/ui/designs/ecomm-login-and-create-storefront-designs/`),
|
||||
which also re-skinned the SLICE-2 Landing + Sign-in screens. See
|
||||
[`docs/BOOTSTRAP.md`](./docs/BOOTSTRAP.md) to run it locally. Deployed environments
|
||||
(SLICE-4) are next.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
"""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)."""
|
||||
@@ -0,0 +1,10 @@
|
||||
"""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
|
||||
@@ -0,0 +1,56 @@
|
||||
"""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.
|
||||
"""
|
||||
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)
|
||||
+32
-10
@@ -3,8 +3,8 @@
|
||||
SLICE-1 mounted /healthz; SLICE-2 adds the /api/auth/* identity endpoints (§6.4). The BFF
|
||||
translates HTTP <-> domain calls and owns no business logic (INV-6): every rule lives in
|
||||
the accounts domain. create_app() opens the pool, self-migrates (INV-1, INV-7), and builds
|
||||
the configured mailer (INV-8) at startup. storefront is hard-wired null this slice; the
|
||||
storefronts domain (SLICE-3) replaces the _storefront_for seam.
|
||||
the configured mailer (INV-8) at startup. SLICE-3 adds POST /api/storefronts and feeds the
|
||||
_storefront_for seam from the storefronts domain.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -18,7 +18,7 @@ from fastapi import Depends, FastAPI, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.domains import accounts
|
||||
from app.domains import accounts, storefronts
|
||||
from app.platform import config, db
|
||||
from app.platform import mailer as mailer_mod
|
||||
from app.platform.deps import SESSION_COOKIE, get_conn, get_mailer, get_session
|
||||
@@ -35,18 +35,19 @@ class VerifyBody(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
class CreateStorefrontBody(BaseModel):
|
||||
name: str | None = None
|
||||
|
||||
|
||||
def _error(status: int, code: str, message: str, **extra: Any) -> JSONResponse:
|
||||
"""The shared §6.4 error envelope: {"error": {"code", "message", ...}}."""
|
||||
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message, **extra}})
|
||||
|
||||
|
||||
def _storefront_for(conn: psycopg.Connection, account: accounts.Account) -> dict | None:
|
||||
"""The entry-routing answer: which storefront, if any, this account has (§6.5).
|
||||
|
||||
Hard-wired null in SLICE-2 (identity only). SLICE-3 replaces this with a storefronts
|
||||
domain call; the verify/me responses already carry the field.
|
||||
"""
|
||||
return 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
|
||||
|
||||
|
||||
def _ensure_app_logging() -> None:
|
||||
@@ -93,7 +94,7 @@ def create_app(database_url: str | None = None) -> FastAPI:
|
||||
finally:
|
||||
app.state.pool.close()
|
||||
|
||||
app = FastAPI(title="ecomm", version="0.2", lifespan=lifespan)
|
||||
app = FastAPI(title="ecomm", version="0.3", lifespan=lifespan)
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz(response: Response, conn: psycopg.Connection = Depends(get_conn)):
|
||||
@@ -170,6 +171,27 @@ def create_app(database_url: str | None = None) -> FastAPI:
|
||||
resp.delete_cookie(SESSION_COOKIE, path="/")
|
||||
return resp
|
||||
|
||||
@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})
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""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(),
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"""SLICE-3 storefront creation — service + endpoint scenario tests (SD-0001 §6.5 PUC-4/7)."""
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ── endpoint scenario tests (§6.4 POST /api/storefronts; corpus 14.01.*) ──────────
|
||||
|
||||
|
||||
@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.
|
||||
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"
|
||||
@@ -0,0 +1,64 @@
|
||||
"""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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0E1230" />
|
||||
<style>html { background: #0E1230; }</style>
|
||||
<link rel="icon" type="image/svg+xml" href="/brand/mark-tile.svg" />
|
||||
<title>ecomm</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wiggleverse-ecomm-frontend",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
|
||||
<title>Wiggleverse — mono gold</title>
|
||||
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="#F4C76B" stroke-width="2.4" fill="none" stroke-linejoin="round"></path>
|
||||
<g fill="#F4C76B">
|
||||
<circle cx="98" cy="60" r="4.5"></circle><circle cx="79" cy="93" r="4.5"></circle><circle cx="41" cy="93" r="4.5"></circle>
|
||||
<circle cx="22" cy="60" r="4.5"></circle><circle cx="41" cy="27" r="4.5"></circle><circle cx="79" cy="27" r="4.5"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 648 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
|
||||
<title>Wiggleverse</title>
|
||||
<rect width="120" height="120" rx="26" fill="#0E1230"></rect>
|
||||
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="#F4C76B" stroke-width="5" fill="none" stroke-linejoin="round"></path>
|
||||
<g fill="#EDEAFF">
|
||||
<circle cx="98" cy="60" r="7"></circle><circle cx="79" cy="93" r="7"></circle><circle cx="41" cy="93" r="7"></circle>
|
||||
<circle cx="22" cy="60" r="7"></circle><circle cx="41" cy="27" r="7"></circle><circle cx="79" cy="27" r="7"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 684 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+47
-20
@@ -1,20 +1,22 @@
|
||||
// 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.
|
||||
// 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 VerifyResult } from "./api";
|
||||
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";
|
||||
import SignedIn from "./screens/SignedIn";
|
||||
|
||||
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 [justCreated, setJustCreated] = useState(false);
|
||||
const [welcome, setWelcome] = useState<Welcome>(null);
|
||||
|
||||
async function reload() {
|
||||
setLoading(true);
|
||||
@@ -26,7 +28,15 @@ export default function App() {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
if (loading) return <main>Loading…</main>;
|
||||
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 });
|
||||
|
||||
@@ -35,13 +45,11 @@ export default function App() {
|
||||
return (
|
||||
<SignIn
|
||||
door={door}
|
||||
onBack={() => {
|
||||
setDoor(null);
|
||||
}}
|
||||
onBack={() => setDoor(null)}
|
||||
onAuthed={(result: VerifyResult) => {
|
||||
setJustCreated(result.created);
|
||||
setWelcome(result.created ? "new" : "back");
|
||||
setDoor(null);
|
||||
void reload();
|
||||
setSession({ account: result.account, storefront: result.storefront });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -49,16 +57,35 @@ export default function App() {
|
||||
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.
|
||||
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 (
|
||||
<SignedIn
|
||||
email={session!.account!.email}
|
||||
created={justCreated}
|
||||
onSignedOut={() => {
|
||||
setJustCreated(false);
|
||||
void reload();
|
||||
}}
|
||||
<Admin
|
||||
storefrontName={session!.storefront!.name}
|
||||
email={email}
|
||||
welcome={welcome}
|
||||
onSignedOut={signedOut}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,3 +59,21 @@ export async function verifyCode(
|
||||
export async function logout(): Promise<void> {
|
||||
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) };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./styles/index.css";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,61 @@
|
||||
// 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.
|
||||
// they differ only in framing. Visuals per the ui/designs export (hf-landing, Direction A).
|
||||
import { Eyebrow, 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 (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
+149
-72
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/* 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);
|
||||
text-align: left;
|
||||
}
|
||||
.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; }
|
||||
.hero__actions .btn-primary { width: auto; }
|
||||
|
||||
.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; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/* 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");
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/* 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";
|
||||
@@ -0,0 +1,60 @@
|
||||
/* Wiggleverse — Color tokens
|
||||
Source of truth: wiggleverse-www/assets/tokens.css (brand BRAND.md §8–9).
|
||||
Dark "sky" is the primary ground; Paper is for long reading. No-center motif. */
|
||||
|
||||
:root {
|
||||
/* ---- Brand palette (base values) ---- */
|
||||
--wv-midnight: #0E1230; /* sky / ground — primary dark background */
|
||||
--wv-indigo: #1C2150; /* raised surfaces on dark (cards, mobile nav) */
|
||||
--wv-indigo-2: #232A63; /* hover state for raised surfaces */
|
||||
--wv-lilac: #9B8CFF; /* accent — "the bonds between us"; links, nodes */
|
||||
--wv-violet: #7C6FE0; /* secondary links / strokes (on light) */
|
||||
--wv-gold: #F4C76B; /* warmth / horizon / primary CTAs */
|
||||
--wv-gold-hi: #F7D488; /* gold hover */
|
||||
--wv-starlight:#EDEAFF; /* nodes / text on dark */
|
||||
--wv-paper: #F6F4FB; /* light-mode background */
|
||||
--wv-ink: #3B2F7A; /* text on light */
|
||||
--wv-night: #090C22; /* footer / deepest ground */
|
||||
|
||||
/* CTA text-on-gold (very dark gold-brown, not pure black) */
|
||||
--wv-gold-ink: #2A2003;
|
||||
--wv-gold-ink-soft: #6B4E10; /* "soon" tag text on gold tint */
|
||||
|
||||
/* ---- Alpha derivations (lilac / gold / starlight washes) ---- */
|
||||
--wv-lilac-08: rgba(155, 140, 255, .08);
|
||||
--wv-lilac-12: rgba(155, 140, 255, .12);
|
||||
--wv-lilac-16: rgba(155, 140, 255, .16);
|
||||
--wv-lilac-18: rgba(155, 140, 255, .18);
|
||||
--wv-lilac-32: rgba(155, 140, 255, .32);
|
||||
--wv-gold-28: rgba(244, 199, 107, .28);
|
||||
--wv-gold-40: rgba(244, 199, 107, .40);
|
||||
--wv-starlight-85: rgba(237, 234, 255, .85);
|
||||
--wv-starlight-78: rgba(237, 234, 255, .78);
|
||||
--wv-starlight-60: rgba(237, 234, 255, .60);
|
||||
--wv-starlight-55: rgba(237, 234, 255, .55);
|
||||
|
||||
/* ---- Semantic aliases ---- */
|
||||
--surface-sky: var(--wv-midnight); /* page ground (dark) */
|
||||
--surface-raised: var(--wv-indigo); /* cards / panels on dark */
|
||||
--surface-raised-hi: var(--wv-indigo-2); /* raised hover */
|
||||
--surface-paper: var(--wv-paper); /* long-reading light sections */
|
||||
--surface-card-light:#FFFFFF; /* build-cards on paper */
|
||||
--surface-footer: var(--wv-night);
|
||||
|
||||
--text-on-dark: var(--wv-starlight);
|
||||
--text-on-dark-soft: var(--wv-starlight-78);
|
||||
--text-on-dark-mute: var(--wv-starlight-60);
|
||||
--text-on-light: var(--wv-ink);
|
||||
--text-on-light-soft:#4B4170;
|
||||
|
||||
--accent: var(--wv-lilac); /* links + nodes on dark */
|
||||
--accent-on-light: var(--wv-violet); /* links + strokes on light */
|
||||
--cta: var(--wv-gold); /* primary action / horizon */
|
||||
--cta-hover: var(--wv-gold-hi);
|
||||
--cta-text: var(--wv-gold-ink);
|
||||
|
||||
--border-soft: var(--wv-lilac-16); /* hairlines on dark */
|
||||
--border-card: var(--wv-lilac-18);
|
||||
--border-strong: var(--wv-lilac-32);
|
||||
--focus-ring: var(--wv-gold);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/* Wiggleverse — Spacing, radius, shadow, layout & motion tokens
|
||||
Derived from the marketing-site CSS. The brand has almost no shadow system —
|
||||
depth is carried by surface color + hairline borders, not drop shadows. */
|
||||
|
||||
:root {
|
||||
/* ---- Spacing scale (rem) ---- */
|
||||
--space-0: 0;
|
||||
--space-1: .25rem;
|
||||
--space-2: .5rem;
|
||||
--space-3: .7rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.2rem;
|
||||
--space-6: 1.6rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
|
||||
/* Section rhythm — fluid vertical padding for page bands */
|
||||
--section-pad: clamp(3rem, 7vw, 5.5rem);
|
||||
--page-hero-pad: clamp(2.8rem, 6vw, 4.5rem);
|
||||
|
||||
/* ---- Layout ---- */
|
||||
--wrap-max: 1080px; /* content column */
|
||||
--wrap-gutter: 92vw; /* width: min(--wrap-max, --wrap-gutter) */
|
||||
--measure-prose: 68ch; /* reading measure for prose blocks */
|
||||
|
||||
/* ---- Radii ---- */
|
||||
--radius-card: 14px; /* path-cards, build-cards */
|
||||
--radius-panel: 12px; /* principle tiles */
|
||||
--radius-sm: 4px; /* focus ring rounding */
|
||||
--radius-pill: 999px; /* buttons, tags, notices */
|
||||
--radius-mark: 26px; /* favicon tile rounding */
|
||||
|
||||
/* ---- Borders ---- */
|
||||
--border-hair: 1px; /* default hairline */
|
||||
--border-card-w: 1px;
|
||||
--btn-border-w: 1.5px; /* ghost button / focus weight */
|
||||
|
||||
/* ---- Elevation ---- *
|
||||
* The system avoids drop shadows. "Lift" on hover is a -1 to -3px translateY,
|
||||
* not a shadow. These tokens exist for the rare card that needs real elevation. */
|
||||
--shadow-none: none;
|
||||
--shadow-soft: 0 8px 24px rgba(9, 12, 34, .28);
|
||||
--lift-1: translateY(-1px); /* @kind other */ /* buttons */
|
||||
--lift-3: translateY(-3px); /* @kind other */ /* cards */
|
||||
|
||||
/* ---- Glass (sticky header) ---- */
|
||||
--glass-sky: rgba(14, 18, 48, .82);
|
||||
--glass-blur: 10px;
|
||||
|
||||
/* ---- Motion ---- */
|
||||
--ease: ease; /* @kind other */
|
||||
--dur-fast: .15s; /* @kind other */ /* hover / press transitions */
|
||||
--dur-mid: .25s; /* @kind other */ /* mobile nav reveal */
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/* Wiggleverse — Typography tokens
|
||||
Two registers, one meaning (BRAND.md):
|
||||
• Machine register — Space Grotesk (display) + Inter (body/UI): precise, structural.
|
||||
• Human register — Fraunces, ITALIC ONLY: warm, literary, used sparingly
|
||||
for the lines that carry conscience ("the soul").
|
||||
@font-face rules live in assets/fonts/fonts.css. */
|
||||
|
||||
:root {
|
||||
/* ---- Families ---- */
|
||||
--wv-font-display: 'Space Grotesk', system-ui, sans-serif; /* headings, wordmark */
|
||||
--wv-font-body: 'Inter', system-ui, sans-serif; /* body / UI */
|
||||
--wv-font-human: 'Fraunces', Georgia, serif; /* pull-quotes — italic only */
|
||||
|
||||
/* semantic aliases */
|
||||
--font-display: var(--wv-font-display);
|
||||
--font-body: var(--wv-font-body);
|
||||
--font-soul: var(--wv-font-human);
|
||||
|
||||
/* ---- Weights ---- */
|
||||
--weight-regular: 400; /* Inter body */
|
||||
--weight-medium: 500; /* nav, eyebrows, buttons, Space Grotesk text */
|
||||
--weight-semibold:600; /* Inter emphasis, ledger totals */
|
||||
--weight-bold: 700; /* Space Grotesk headings, wordmark */
|
||||
--weight-soul: 500; /* Fraunces italic pull-quotes */
|
||||
|
||||
/* ---- Fluid display sizes (clamp: min, vw, max) ---- */
|
||||
--text-h1: clamp(2.1rem, 5.2vw, 3.6rem);
|
||||
--text-h2: clamp(1.6rem, 3.4vw, 2.4rem);
|
||||
--text-h3: 1.2rem;
|
||||
--text-lead: clamp(1.05rem, 1.8vw, 1.3rem); /* intro paragraph */
|
||||
--text-soul: clamp(1.2rem, 2.4vw, 1.7rem); /* hero pull-quote */
|
||||
|
||||
/* ---- Body / UI scale ---- */
|
||||
--text-body: 1rem; /* 16px base */
|
||||
--text-small: .95rem;
|
||||
--text-fine: .92rem;
|
||||
--text-eyebrow: .8rem; /* uppercase label */
|
||||
--text-tag: .72rem; /* pill tags */
|
||||
|
||||
/* ---- Line heights ---- */
|
||||
--leading-tight: 1.12; /* headings */
|
||||
--leading-body: 1.6; /* paragraphs */
|
||||
|
||||
/* ---- Letter spacing ---- */
|
||||
--tracking-display: -0.015em; /* headings + wordmark draw in slightly */
|
||||
--tracking-eyebrow: 0.12em; /* uppercase eyebrows open up */
|
||||
--tracking-tag: 0.08em;
|
||||
--tracking-notice: 0.06em;
|
||||
|
||||
/* ---- Measure ---- */
|
||||
--measure-lead: 60ch;
|
||||
--measure-prose:68ch;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// 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 { useId } from "react";
|
||||
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>;
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor={id}>
|
||||
<span>{label}</span>
|
||||
{optional && <span className="field__optional">optional</span>}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user