Compare commits

..

10 Commits

Author SHA1 Message Date
ben.stull 2606fbf826 docs(products): operator guide (DOC-1) + domain notes (DOC-4); version 0.5.0
ci / check (push) Has been cancelled
ci / check (pull_request) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:00:13 -07:00
ben.stull ad738adbc0 test(e2e): SLICE-5 scenarios — preview/confirm, errors, rejection, cancel (§6.8)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:53:59 -07:00
ben.stull a58f42cf86 test(e2e): stand up Playwright — fresh-DB server harness + sign-up helper
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:50:59 -07:00
ben.stull d18a84e135 fix(products-ui): review pass — show-more race, keyboard history access, a11y glyphs/aria-live, label consistency
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:46:43 -07:00
ben.stull 87a56a66c4 feat(products-ui): import run detail (§5.5, PUC-4/5/8)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:35:49 -07:00
ben.stull 4141285b89 feat(products-ui): import preview — tiles, drill-in diffs, confirm/cancel (§5.4)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:34:33 -07:00
ben.stull 188494272a feat(products-ui): import upload screen (§5.3, PUC-2/5a)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:30:51 -07:00
ben.stull 26f84cb916 feat(products-ui): admin nav + Products page with import history (§5.2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:29:20 -07:00
ben.stull b5dac8886f feat(products-ui): typed products API client + admin hash routing
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:25:37 -07:00
ben.stull 4dacc2dafd feat(products): /api/products/* BFF endpoints + sample.csv (DOC-3)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:18:34 -07:00
33 changed files with 2011 additions and 30 deletions
+1 -1
View File
@@ -1 +1 @@
0.4.0
0.5.0
+6 -1
View File
@@ -6,6 +6,8 @@ drafts/runs. Storefront-scoped throughout (INV-14); upsert is the only mutation
"""
from __future__ import annotations
from pathlib import Path
from .errors import (
DraftExpired,
DraftNotFound,
@@ -27,10 +29,13 @@ from .service import (
summary,
)
# DOC-3: the downloadable worked-example CSV the BFF serves at /api/products/sample.csv.
SAMPLE_CSV_PATH = Path(__file__).parent / "sample.csv"
__all__ = [
"ProductsError", "FileRejected", "DraftNotFound", "DraftExpired",
"PreviewStale", "NothingToApply", "RunNotFound",
"MAX_DATA_ROWS", "MAX_FILE_BYTES",
"MAX_DATA_ROWS", "MAX_FILE_BYTES", "SAMPLE_CSV_PATH",
"import_validate", "get_draft", "get_draft_records", "discard_draft",
"confirm_draft", "list_runs", "get_run", "summary",
]
+6
View File
@@ -0,0 +1,6 @@
Handle,Title,Description,Vendor,Type,Google Product Category,Tags,Status,Published,Option1 Name,Option1 Value,Option2 Name,Option2 Value,Variant SKU,Variant Price,Variant Inventory Qty,Image Src,Image Position,Image Alt Text
moon-mug,Moon Mug,"<p>A ceramic mug glazed in moonlight grey.</p>",Wiggle Goods,standalone,Home & Garden > Kitchen & Dining,"kitchen, mugs",active,TRUE,,,,,WG-MUG-001,18.00,40,https://images.example.com/moon-mug.jpg,1,Moon Mug on a desk
star-tee,Star Tee,"<p>Soft cotton tee with a hand-printed star.</p>",Wiggle Goods,standalone,Apparel & Accessories > Clothing,"apparel, tees",active,TRUE,Size,S,Color,Indigo,WG-TEE-S,24.00,12,https://images.example.com/star-tee.jpg,1,Star Tee flat lay
star-tee,,,,,,,,,,M,,Indigo,WG-TEE-M,24.00,18,,,
star-tee,,,,,,,,,,L,,Indigo,WG-TEE-L,26.00,9,,,
star-tee,,,,,,,,,,,,,,,,https://images.example.com/star-tee-back.jpg,2,Star Tee back print
1 Handle Title Description Vendor Type Google Product Category Tags Status Published Option1 Name Option1 Value Option2 Name Option2 Value Variant SKU Variant Price Variant Inventory Qty Image Src Image Position Image Alt Text
2 moon-mug Moon Mug <p>A ceramic mug glazed in moonlight grey.</p> Wiggle Goods standalone Home & Garden > Kitchen & Dining kitchen, mugs active TRUE WG-MUG-001 18.00 40 https://images.example.com/moon-mug.jpg 1 Moon Mug on a desk
3 star-tee Star Tee <p>Soft cotton tee with a hand-printed star.</p> Wiggle Goods standalone Apparel & Accessories > Clothing apparel, tees active TRUE Size S Color Indigo WG-TEE-S 24.00 12 https://images.example.com/star-tee.jpg 1 Star Tee flat lay
4 star-tee M Indigo WG-TEE-M 24.00 18
5 star-tee L Indigo WG-TEE-L 26.00 9
6 star-tee https://images.example.com/star-tee-back.jpg 2 Star Tee back print
+179 -4
View File
@@ -4,7 +4,8 @@ SLICE-1 mounted /healthz; SLICE-2 adds the /api/auth/* identity endpoints (§6.4
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. SLICE-3 adds POST /api/storefronts and feeds the
_storefront_for seam from the storefronts domain.
_storefront_for seam from the storefronts domain. SLICE-5 adds the /api/products/* import
spine (SD-0002 §6.4): each endpoint is a gate + one products-domain call + error mapping.
"""
from __future__ import annotations
@@ -15,12 +16,12 @@ from pathlib import Path
from typing import Any
import psycopg
from fastapi import Depends, FastAPI, Response
from fastapi.responses import JSONResponse
from fastapi import Depends, FastAPI, File, Query, Response, UploadFile
from fastapi.responses import JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from app.domains import accounts, storefronts
from app.domains import accounts, products, 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
@@ -61,6 +62,26 @@ def _storefront_for(conn: psycopg.Connection, account: accounts.Account) -> dict
return {"id": sf.id, "name": sf.name} if sf else None
def _merchant_gate(
conn: psycopg.Connection, sess: dict | None
) -> JSONResponse | tuple[accounts.Account, storefronts.Storefront]:
"""The shared /api/products/* gate: a signed-in account that has its storefront.
Returns the (account, storefront) pair, or the ready-to-return error response —
401 with no session, 404 before the storefront exists (INV-14: every products
call is storefront-scoped, so there is nothing to address yet).
"""
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.")
sf = storefronts.storefront_for(conn, account.id)
if sf is None:
return _error(404, "no_storefront", "Create your storefront first.")
return account, sf
def _ensure_app_logging() -> None:
"""Surface the app's own `ecomm.*` INFO logs on stderr (idempotent).
@@ -208,6 +229,160 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None =
)
return JSONResponse(status_code=201, content={"id": sf.id, "name": sf.name})
@app.post("/api/products/imports")
async def import_upload(
file: UploadFile = File(...),
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""Upload a CSV → validated import draft (§6.4; PUC-2, PUC-5/5a on rejection)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
account, sf = gate
data = await file.read()
if len(data) > products.MAX_FILE_BYTES:
return _error(413, "file_too_large", "This file is larger than 10 MB.")
try:
draft = products.import_validate(conn, sf.id, account.id, file.filename or "upload.csv", data)
except products.FileRejected as exc:
return _error(400, exc.code, exc.message)
return JSONResponse(status_code=201, content=draft)
@app.get("/api/products/imports/drafts/{draft_id}")
def get_import_draft(
draft_id: int,
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""One draft's preview payload — summary, never the file bytes (§6.4; PUC-3)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
_account, sf = gate
try:
return products.get_draft(conn, sf.id, draft_id)
except products.DraftNotFound:
return _error(404, "not_found", "No such import preview.")
except products.DraftExpired:
return _error(410, "draft_expired", "This preview expired — upload the file again.")
@app.get("/api/products/imports/drafts/{draft_id}/records")
def get_import_draft_records(
draft_id: int,
kind: str | None = Query(default=None, pattern="^(add|update|unchanged|error)$"),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""The draft's per-product preview records, paged + kind-filtered (§6.4; PUC-3)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
_account, sf = gate
try:
records = products.get_draft_records(conn, sf.id, draft_id, kind, limit, offset)
except products.DraftNotFound:
return _error(404, "not_found", "No such import preview.")
except products.DraftExpired:
return _error(410, "draft_expired", "This preview expired — upload the file again.")
return {"records": records}
@app.post("/api/products/imports/drafts/{draft_id}/confirm")
def confirm_import_draft(
draft_id: int,
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""Apply the previewed diff as one import run (§6.4; PUC-4, INV-10/11)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
account, sf = gate
try:
run_id = products.confirm_draft(conn, sf.id, account.id, draft_id)
except products.DraftNotFound:
return _error(404, "not_found", "No such import preview.")
except products.DraftExpired:
return _error(410, "draft_expired", "This preview expired — upload the file again.")
except products.PreviewStale:
return _error(
409, "preview_stale",
"Your catalog changed since this preview — upload the file again.",
)
except products.NothingToApply:
return _error(
409, "nothing_to_apply",
"Nothing to change — your catalog already matches this file.",
)
return JSONResponse(status_code=201, content={"run_id": run_id})
@app.delete("/api/products/imports/drafts/{draft_id}")
def discard_import_draft(
draft_id: int,
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""Discard the draft, no trace kept; idempotent (§6.4; PUC-3a)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
_account, sf = gate
products.discard_draft(conn, sf.id, draft_id)
return Response(status_code=204)
@app.get("/api/products/imports/runs")
def list_import_runs(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""The storefront's import history, newest first (§6.4; PUC-8)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
_account, sf = gate
return {"runs": products.list_runs(conn, sf.id, limit, offset)}
@app.get("/api/products/imports/runs/{run_id}")
def get_import_run(
run_id: int,
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""One run's detail payload, errors included (§6.4; PUC-8)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
_account, sf = gate
try:
return products.get_run(conn, sf.id, run_id)
except products.RunNotFound:
return _error(404, "not_found", "No such import run.")
@app.get("/api/products/summary")
def products_summary(
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""The products dashboard counts (§6.4)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
_account, sf = gate
return products.summary(conn, sf.id)
@app.get("/api/products/sample.csv")
def products_sample_csv():
"""The DOC-3 worked-example CSV. Documentation, so no auth gate (§6.4)."""
return PlainTextResponse(
products.SAMPLE_CSV_PATH.read_text(),
media_type="text/csv",
headers={"content-disposition": 'attachment; filename="ecomm-products-sample.csv"'},
)
# Deployed topology (launch-app SPEC §2): nginx proxies everything here, so the
# backend serves the built SPA. Mounted LAST so /healthz and /api/* win. In dev the
# dist dir doesn't exist (Vite serves the frontend) and the mount is skipped.
+95
View File
@@ -0,0 +1,95 @@
"""§6.4 /api/products/* endpoint scenarios (PUC-2/3/3a/4/5/5a/8 + gates)."""
import io
import re
from contextlib import contextmanager
from fastapi.testclient import TestClient
from app.main import create_app
GOOD_CSV = b"Handle,Title,Vendor,Variant Price\nmoon-mug,Moon Mug,Acme,18.00\n"
@contextmanager
def _merchant_client(fresh_db_url, email="m@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})
client.post("/api/storefronts", json={})
yield client
def _upload(client, data=GOOD_CSV, name="cat.csv"):
return client.post("/api/products/imports", files={"file": (name, io.BytesIO(data), "text/csv")})
def test_upload_returns_201_draft(fresh_db_url):
with _merchant_client(fresh_db_url) as client:
resp = _upload(client)
assert resp.status_code == 201
body = resp.json()
assert body["summary"]["adds"] == 1 and body["dialect"] == "canonical"
def test_upload_rejections_carry_codes(fresh_db_url):
with _merchant_client(fresh_db_url) as client:
resp = _upload(client, b"Vendor\nAcme\n")
assert resp.status_code == 400
assert resp.json()["error"]["code"] == "missing_required_column"
resp = _upload(client, b"Handle,Title\n" + b"x" * (10 * 1024 * 1024 + 1))
assert resp.status_code == 413
def test_unauthenticated_401_and_no_storefront_404(fresh_db_url):
with TestClient(create_app(database_url=fresh_db_url)) as client:
assert _upload(client).status_code == 401
client.post("/api/auth/request-code", json={"email": "x@example.com"})
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
client.post("/api/auth/verify", json={"email": "x@example.com", "code": code})
assert _upload(client).status_code == 404
def test_preview_confirm_run_flow(fresh_db_url):
with _merchant_client(fresh_db_url) as client:
draft = _upload(client).json()
recs = client.get(f"/api/products/imports/drafts/{draft['id']}/records").json()["records"]
assert recs[0]["kind"] == "add"
run_id = client.post(f"/api/products/imports/drafts/{draft['id']}/confirm").json()["run_id"]
run = client.get(f"/api/products/imports/runs/{run_id}").json()
assert run["products_added"] == 1 and run["by"] == "m@example.com"
assert client.get("/api/products/summary").json()["product_count"] == 1
assert client.get("/api/products/imports/runs").json()["runs"][0]["id"] == run_id
def test_cancel_no_trace_puc3a(fresh_db_url):
with _merchant_client(fresh_db_url) as client:
draft = _upload(client).json()
assert client.delete(f"/api/products/imports/drafts/{draft['id']}").status_code == 204
assert client.get(f"/api/products/imports/drafts/{draft['id']}").status_code == 404
assert client.get("/api/products/imports/runs").json()["runs"] == []
def test_confirm_conflicts(fresh_db_url):
with _merchant_client(fresh_db_url) as client:
d1 = _upload(client).json()
client.post(f"/api/products/imports/drafts/{d1['id']}/confirm")
d2 = _upload(client).json()
resp = client.post(f"/api/products/imports/drafts/{d2['id']}/confirm")
assert resp.status_code == 409 and resp.json()["error"]["code"] == "nothing_to_apply"
def test_sample_csv_served(fresh_db_url):
with TestClient(create_app(database_url=fresh_db_url)) as client:
resp = client.get("/api/products/sample.csv")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/csv")
assert resp.text.startswith("Handle,Title,")
def test_sample_csv_imports_clean(fresh_db_url):
"""DOC-3 honesty: our own sample must validate with zero errors."""
with _merchant_client(fresh_db_url) as client:
sample = client.get("/api/products/sample.csv").content
body = _upload(client, sample, "sample.csv").json()
assert body["summary"]["errors"] == 0 and body["summary"]["adds"] == 2
+111
View File
@@ -0,0 +1,111 @@
# Operating ecomm
The framework-repo operator guide (SD-0002 DOC-1), started at SLICE-5. It covers
the app's operational surface — telemetry, runbooks, alert gestures, the E2E
gate. Per-deployment mechanics (deploy, secrets, VM access) live in the
deployment's flotilla docs and `deployment.toml`; environment bring-up is in
[`BOOTSTRAP.md`](./BOOTSTRAP.md).
## Products import/export ops (SD-0002, SLICE-5)
The §6.4 surface: a merchant uploads a catalog CSV (`POST
/api/products/imports`), the app validates it and stores an **import draft**
with a full preview (adds / updates / unchanged / errors), the merchant
confirms or cancels at the preview gate, and a confirm applies the previewed
diff as one **import run** recorded in the history (`/api/products/imports/runs`).
Caps and behavior to know (all enforced in code, not config):
- **Caps (INV-18):** ≤ 5,000 data rows and ≤ 10 MB per file —
`MAX_DATA_ROWS` / `MAX_FILE_BYTES` in `backend/app/domains/products/models.py`,
enforced in `backend/app/domains/products/codec.py` (file-level rejection)
plus a 413 `file_too_large` guard in the BFF (`backend/app/main.py`).
- **Draft expiry:** ~1 hour (`expires_at = now() + interval '1 hour'`). Cleanup
is a lazy sweep — expired drafts are deleted on the next upload and on any
access to an expired draft; there is no background job to babysit.
- **Upsert-only (INV-10):** an import adds and updates, never deletes. Catalog
products/variants/images absent from the file are untouched.
- **One-transaction apply (INV-11):** a confirm applies the whole previewed
diff in a single DB transaction — it lands completely or not at all.
### Telemetry
Structured JSON events on the `ecomm.telemetry` logger
(`backend/app/platform/telemetry.py`), one JSON object per line, emitted from
`backend/app/domains/products/service.py`. The app's `ecomm.*` log handler
writes to the process's stderr, which journald captures on the VM (and Cloud
Logging where the agent ships it). Events carry counts and durations only —
never file names, URLs, catalog content, or secret bytes.
| Event | Trigger | Payload fields |
| --- | --- | --- |
| TEL-1 `import_draft_created` | validation completes, draft stored | `storefront_id, dialect, row_count, adds, updates, unchanged, errors, unknown_columns_count, duration_ms` |
| TEL-2 `import_run_completed` | apply transaction commits | `run_id, storefront_id, added, updated, errored, duration_ms` |
| TEL-6 `import_apply_failed` | apply transaction aborts unexpectedly | `draft_id, storefront_id, error_class` |
### RB-2 — import apply failed
Triggered by ALR-2 (any TEL-6 event). The apply raised mid-transaction and
rolled back.
1. **Locate the failure.** On the VM, filter the journal for the event and note
the `draft_id`, `storefront_id`, and `error_class`:
```
journalctl -u ecomm.service | grep import_apply_failed
```
2. **Confirm the rollback held (INV-11).** The apply is one transaction, so a
failure leaves the catalog exactly as it was: the storefront's runs history
(`GET /api/products/imports/runs`) shows **no new run**, and the products
summary (`GET /api/products/summary`) shows an unchanged `product_count`.
3. **The merchant's draft is intact.** A failed apply does not consume the
draft — the merchant can retry confirm, or re-upload if the draft has since
expired (~1 h). Advise accordingly.
4. **File a bug** on `wiggleverse/wiggleverse-ecomm` with the `error_class`
and the surrounding log context (the traceback is in the app log next to
the event).
### ALR-2 — the log-based alert (one gesture per environment)
Run once per environment, at this slice's PPE deploy. This is an ad hoc op on
the existing GCP project (`wiggleverse-ecomm`), not a provisioning gesture.
```
# Select the deployment's gcloud config for this one process (handbook §8.4).
export CLOUDSDK_ACTIVE_CONFIG_NAME=wiggleverse-ecomm
# Log-based metric counting import-apply failures (TEL-6).
gcloud logging metrics create ecomm_import_apply_failed \
--description="ecomm TEL-6 import_apply_failed events (SD-0002 ALR-2)" \
--log-filter='resource.type="gce_instance" AND jsonPayload.message:"import_apply_failed" OR textPayload:"import_apply_failed"'
```
Then attach an alert policy to the metric — **operator email channel, threshold
any event > 0 in 5 minutes, severity notify-only** (pre-v1: no paging). The
policy is created in the Cloud Console or with
`gcloud alpha monitoring policies create`; the exact command depends on the
notification-channel id, so list channels first:
```
# Find the operator email channel's id for the policy.
gcloud beta monitoring channels list
```
### E2E browser suite
- Lives at `e2e/` — Playwright, Chromium, four SLICE-5 scenarios
(preview/confirm happy path, actionable errors, file rejection, cancel).
- Run with `bash scripts/e2e.sh`. The harness boots a **fresh `ecomm_e2e`
database** against the local compose Postgres and serves the built SPA from
the backend on **:8765** (the deployed topology), so it needs the dev
Postgres up (`scripts/dev.sh`).
- **Not in `scripts/check.sh` / CI yet** — the Gitea runner has no browsers
(the §10.6 machinery gap). Run it locally before merge, and against PPE per
the §9 pipeline (the PPE browser run is still manual this slice).
## Cross-references
- SLO and alert definitions: SD-0002 §9–§10 (content repo,
`wiggleverse-ecomm-content/specs/SD-0002-products-bulk-csv-import-export.md`).
- Import/diff engine internals: [`products-domain.md`](./products-domain.md).
+116
View File
@@ -0,0 +1,116 @@
# products domain — developer notes
DOC-4 (SD-0002 §11): the import/export spine as built in SLICE-5, extended by
SLICE-68. Operator-facing material is in [`OPERATIONS.md`](./OPERATIONS.md);
the spec is SD-0002 in the content repo.
## Layout and pipeline
`backend/app/domains/products/` is layered like the rest of the app
(main → domains → platform, enforced by import-linter):
- `models.py` — canonical row model + the column registry.
- `codec.py` — bytes → `ParsedFile`; file-level gates only.
- `validate.py` — rows → `CanonicalProduct` blocks + per-row errors.
- `diff.py` — catalog × canonical products → apply plan + preview records.
- `repo.py` — SQL only: catalog snapshot, draft/run CRUD, apply primitives.
Never commits or rolls back.
- `service.py` — use-case orchestration; owns every transaction boundary and
emits the TEL events via `backend/app/platform/telemetry.py`.
```mermaid
flowchart LR
subgraph validate_path [import_validate]
A[parse_csv] --> B[build_products] --> D[compute_diff] --> E[(import_draft)]
C[load_catalog] --> D
end
subgraph confirm_path [confirm_draft]
E --> F[re-derive: parse → build → diff] --> G{fingerprint match?}
G -- yes --> H[one-transaction apply<br/>run + products + delete draft]
G -- no --> I[PreviewStale<br/>draft kept]
end
```
Confirm re-derives everything from the draft's stored `file_bytes` against the
live catalog, then checks the fingerprint — so what lands is exactly what the
preview showed, or the confirm refuses (`preview_stale`). A confirm with no
adds and no updates refuses with `nothing_to_apply`.
## Canonical model and blank-vs-absent
`models.py` is the one model every dialect maps to (INV-17). `KNOWN_COLUMNS`
is the registry header detection, unknown-column warnings, and validation all
read; `CLEAR_DEFAULTS` holds the reset values for clearable fields
(`status`, `published`, `product_type`, `tags`).
The §6.5.1 cell semantics, as implemented:
- **Absent column** → the field never enters `fields{}` → untouched by diff
and apply (never a change).
- **Present-but-empty cell** → `fields[name] = None` (an explicit clear) →
resolved at diff time to its `CLEAR_DEFAULTS` entry, or NULL where none
exists.
- **The position exception:** a cleared `Variant Position` has no
`CLEAR_DEFAULTS` entry — `diff.resolved_variant_fields` resolves it to the
variant's 1-based file order within its product, never to NULL.
Option names live both in `CanonicalProduct.option_names` (the values) and in
`fields{}` (the file-presence marker the diff needs for the absent-vs-clear
distinction).
## Error granularity
`validate.py` never raises on a row problem: every violation is recorded as a
merchant-language `RowError` and **poisons its whole product block** — the
product previews as `kind="error"` and is excluded from apply, while parsing
continues so one pass yields a complete accounting (BUC-1a). On apply, error
rows are recorded per line in `import_run_error`; `rows_errored` on the run is
the **error-row count**, while the preview's errors tile counts error
**products** — the two numbers legitimately differ.
File-level problems (`not_csv`, `missing_required_column`, `too_many_rows`,
`file_too_large`) raise `FileRejected` in `codec.py` instead: no draft is
created. The BFF adds an early 413 for oversized uploads (`main.py`).
## INV-11 mechanics
`compute_diff` makes **one walk** that produces two views of the same
computation: the typed apply `plan` (resolved natives — `Decimal`, `bool`,
lists) that `confirm_draft` executes, and the JSON-safe preview `records`
stored as draft JSONB and served verbatim to the SPA. Because both derive from
the same walk they cannot diverge. The `fingerprint` is
`sha256(json.dumps(records, sort_keys=True, separators=(",", ":")))`; a
mismatch at confirm means the catalog drifted since preview → `PreviewStale`
(409 `preview_stale`, draft kept for re-validation). The whole apply — run row,
product/variant/image writes, error rows, draft delete — is one transaction;
any exception rolls it back and emits TEL-6.
## Named seams (what later slices replace)
- **`import_draft.file_bytes` → objectstore key (SLICE-7).** The upload
currently lives as BYTEA on the draft row (`0002_products.sql`); SLICE-7
moves the bytes to object storage and stores a key.
- **`codec.detect_dialect` → Shopify (SLICE-8).** Today it always returns
`"canonical"`; SLICE-8 recognizes Shopify's exact header set here (INV-17).
- **Run-status complete shortcut → `fetching_images` (SLICE-7).**
`confirm_draft` inserts the run with `status="complete"` directly; SLICE-7
inserts it as `fetching_images` and hands off to the image-fetch task.
Relatedly, image rows are created with the schema default
`status='pending'` **today and stay pending** — placeholder behavior until
SLICE-7's fetch phase (the run-detail payload already carries the stable
`image_progress` / `image_outcomes` shape, zeroed).
## Test map
| File | Covers |
| --- | --- |
| `backend/tests/test_products_codec.py` | file-level gates: parse, caps (INV-18), required columns, dialect |
| `backend/tests/test_products_validate.py` | every §6.5.1 row-error rule, one fixture each |
| `backend/tests/test_products_diff.py` | classification, blank-vs-absent, option matching, fingerprint |
| `backend/tests/test_products_service.py` | draft lifecycle: validate/preview/discard, expiry, TEL-1 |
| `backend/tests/test_products_invariants.py` | INV-10 (never deletes), INV-14 (storefront isolation), apply transactionality, TEL-6 |
| `backend/tests/test_products_endpoints.py` | §6.4 API scenarios + auth/storefront gates |
| `e2e/tests/import-preview-confirm.spec.ts` | happy path: upload → preview → confirm → history |
| `e2e/tests/import-errors.spec.ts` | actionable row errors at preview and on the run report |
| `e2e/tests/import-file-rejected.spec.ts` | file-level rejection, picker stays live, no trace |
| `e2e/tests/import-cancel.spec.ts` | cancel at preview leaves no trace |
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.backend.log
test-results/
playwright-report/
+4
View File
@@ -0,0 +1,4 @@
Handle,Title,Vendor,Tags,Status,Published,Option1 Name,Option1 Value,Variant SKU,Variant Price,Variant Inventory Qty
moon-mug,Moon Mug,Wiggle Goods,"kitchen, mugs",active,TRUE,,,WG-MUG-001,18.00,40
star-tee,Star Tee,Wiggle Goods,apparel,active,TRUE,Size,S,WG-TEE-S,24.00,12
star-tee,,,,,,,M,WG-TEE-M,24.00,18
1 Handle Title Vendor Tags Status Published Option1 Name Option1 Value Variant SKU Variant Price Variant Inventory Qty
2 moon-mug Moon Mug Wiggle Goods kitchen, mugs active TRUE WG-MUG-001 18.00 40
3 star-tee Star Tee Wiggle Goods apparel active TRUE Size S WG-TEE-S 24.00 12
4 star-tee M WG-TEE-M 24.00 18
+2
View File
@@ -0,0 +1,2 @@
Handle,Vendor
mug,Acme
1 Handle Vendor
2 mug Acme
+4
View File
@@ -0,0 +1,4 @@
Handle,Title,Variant Price
good-mug,Good Mug,10.00
bad-tee,Bad Tee,not-a-price
also-good,Also Good,5.00
1 Handle Title Variant Price
2 good-mug Good Mug 10.00
3 bad-tee Bad Tee not-a-price
4 also-good Also Good 5.00
+74
View File
@@ -0,0 +1,74 @@
// Shared E2E helpers — the sign-up journey (SD-0001 §5.1–§5.4) and products-page
// navigation (SD-0002 §5.2). Selectors are role/label-based against the real screens
// (Landing.tsx, SignIn.tsx, CreateStorefront.tsx, Admin.tsx, ProductsPage.tsx).
import { expect, type Page } from "@playwright/test";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
const LOG = join(__dirname, ".backend.log");
let seq = 0;
// The storefront name every helper-created merchant uses; tests assert against it.
export const STOREFRONT_NAME = "E2E Test Goods";
export function freshEmail(): string {
return `merchant${Date.now()}-${seq++}@example.com`;
}
// LogMailer's exact line (backend/app/platform/mailer.py):
// INFO:ecomm.mailer: LogMailer -> <to> | Your ecomm code: <6 digits>\n<body>
// The first 6-digit group after the marker is the code.
async function codeFor(email: string): Promise<string> {
for (let i = 0; i < 50; i++) {
const log = await readFile(LOG, "utf8").catch(() => "");
const at = log.lastIndexOf(`LogMailer -> ${email}`);
if (at >= 0) {
const m = log.slice(at).match(/\b(\d{6})\b/);
if (m) return m[1];
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error(`no code logged for ${email}`);
}
export async function signUpWithStorefront(page: Page, email = freshEmail()): Promise<string> {
// Landing → the sign-up door.
await page.goto("/");
await page.getByRole("button", { name: "Create your storefront →" }).click();
// Sign-in step 1: request the one-time code.
await page.getByLabel("Email").fill(email);
await page.getByRole("button", { name: "Send code" }).click();
// Sign-in step 2: read the code from the backend log and verify it.
await expect(page.getByRole("heading", { name: "Check your email" })).toBeVisible();
const code = await codeFor(email);
await page.getByLabel("One-time code").fill(code);
await page.getByRole("button", { name: "Continue" }).click();
// Create-storefront screen (a new account has none yet).
await expect(page.getByRole("heading", { name: "Create your storefront" })).toBeVisible();
await page.getByLabel("Storefront name").fill(STOREFRONT_NAME);
await page.getByRole("button", { name: "Create storefront", exact: true }).click();
// Admin shell: nav strip + the storefront identity in the topbar.
await expect(page.getByRole("navigation", { name: "Admin sections" })).toBeVisible();
await expect(page.locator(".storeid__name")).toHaveText(STOREFRONT_NAME);
return email;
}
export async function gotoProducts(page: Page) {
await page
.getByRole("navigation", { name: "Admin sections" })
.getByRole("link", { name: "Products" })
.click();
await expect(page.getByRole("heading", { level: 1, name: "Products" })).toBeVisible();
}
export async function uploadFixture(page: Page, fixture: string) {
// Two "Import products" links render on the empty products page (header + empty
// state) — same destination, so take the first.
await page.getByRole("link", { name: "Import products" }).first().click();
await expect(page.getByRole("heading", { name: "Import products" })).toBeVisible();
await page.locator('input[type="file"]').setInputFiles(join(__dirname, "fixtures", fixture));
}
+76
View File
@@ -0,0 +1,76 @@
{
"name": "wiggleverse-ecomm-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wiggleverse-ecomm-e2e",
"devDependencies": {
"@playwright/test": "^1.48.0"
}
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "wiggleverse-ecomm-e2e",
"private": true,
"scripts": { "test": "playwright test" },
"devDependencies": { "@playwright/test": "^1.48.0" }
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
timeout: 60_000,
retries: 0,
// One shared backend + log file; parallel sign-ins would interleave codes.
workers: 1,
use: { baseURL: "http://localhost:8765" },
webServer: {
command: "bash ./serve.sh",
url: "http://localhost:8765/healthz",
reuseExistingServer: false,
timeout: 120_000,
},
});
Executable
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# E2E server: fresh ecomm_e2e database, LogMailer (codes land in .backend.log),
# backend on :8765 serving the built SPA (the deployed topology, SD-0001 §6.2).
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [ ! -f "$repo_root/frontend/dist/index.html" ]; then
( cd "$repo_root/frontend" && npm run build )
fi
"$repo_root/.venv/bin/python" - <<'PY'
import psycopg
admin = psycopg.connect("postgresql://ecomm:ecomm@localhost:5432/postgres", autocommit=True)
admin.execute("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='ecomm_e2e' AND pid <> pg_backend_pid()")
admin.execute("DROP DATABASE IF EXISTS ecomm_e2e")
admin.execute("CREATE DATABASE ecomm_e2e")
PY
export ECOMM_DATABASE_URL="postgresql://ecomm:ecomm@localhost:5432/ecomm_e2e"
export ECOMM_MAILER=log
cd "$repo_root/backend"
exec "$repo_root/.venv/bin/python" -m uvicorn app.main:app --port 8765 \
> "$repo_root/e2e/.backend.log" 2>&1
+23
View File
@@ -0,0 +1,23 @@
// DoD scenario e2e_import_cancel_no_trace (SD-0002 §6.8): cancelling at the
// preview gate (PUC-3a) leaves no trace — no products, no run in the history.
import { expect, test } from "@playwright/test";
import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers";
test("e2e_import_cancel_no_trace", async ({ page }) => {
await signUpWithStorefront(page);
await gotoProducts(page);
await uploadFixture(page, "good.csv");
// The preview gate is up.
await expect(page.getByRole("heading", { name: "Import preview — good.csv" })).toBeVisible();
await expect(page.getByRole("button", { name: "2 to add" })).toBeVisible();
await page.getByRole("button", { name: "Cancel" }).click();
// Lands back on Products — still the empty state, no run recorded.
await expect(page.getByRole("heading", { level: 1, name: "Products" })).toBeVisible();
await expect(
page.getByText("No products yet. Bulk import is how product data gets in."),
).toBeVisible();
await expect(page.getByText("No imports yet.")).toBeVisible();
});
+41
View File
@@ -0,0 +1,41 @@
// DoD scenario e2e_import_errors_actionable (SD-0002 §6.8): row-level errors are
// actionable — line, column, message — at the preview gate AND on the run report
// card (PUC-5), while the good rows still import.
import { expect, test } from "@playwright/test";
import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers";
test("e2e_import_errors_actionable", async ({ page }) => {
await signUpWithStorefront(page);
await gotoProducts(page);
await uploadFixture(page, "mixed-errors.csv");
// Preview tiles: two good products, one errored row.
await expect(
page.getByRole("heading", { name: "Import preview — mixed-errors.csv" }),
).toBeVisible();
await expect(page.getByRole("button", { name: "2 to add" })).toBeVisible();
await page.getByRole("button", { name: "1 errors" }).click();
// The error table names the line, the column, and the problem (actionable, PUC-5).
const previewRow = page.locator(".errortable tbody tr");
await expect(previewRow).toHaveCount(1);
await expect(previewRow.locator("td").nth(0)).toHaveText("3");
await expect(previewRow.locator("td").nth(1)).toHaveText("Variant Price");
await expect(previewRow.locator("td").nth(2)).toContainText("is not a price");
// Good rows still import.
await page.getByRole("button", { name: "Import 2 products" }).click();
// Run detail: counts include the errored row, and the same error row persists.
await expect(page.getByRole("heading", { level: 1, name: "mixed-errors.csv" })).toBeVisible();
await expect(page.getByText("2 added · 0 updated · 1 rows in error")).toBeVisible();
const runRow = page.locator(".errortable tbody tr");
await expect(runRow).toHaveCount(1);
await expect(runRow.locator("td").nth(0)).toHaveText("3");
await expect(runRow.locator("td").nth(1)).toHaveText("Variant Price");
await expect(runRow.locator("td").nth(2)).toContainText("is not a price");
// The catalog gained the two good products.
await gotoProducts(page);
await expect(page.getByRole("heading", { level: 1, name: "Products · 2" })).toBeVisible();
});
+25
View File
@@ -0,0 +1,25 @@
// DoD scenario e2e_import_file_rejected (SD-0002 §6.8): a file-level rejection
// (PUC-5a) renders in place on the upload screen, the picker stays live for a
// retry, and nothing is recorded — no products, no run.
import { expect, test } from "@playwright/test";
import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers";
test("e2e_import_file_rejected", async ({ page }) => {
await signUpWithStorefront(page);
await gotoProducts(page);
await uploadFixture(page, "missing-title.csv");
// The rejection renders in place, naming the missing column.
await expect(page.getByText("That file can't be imported")).toBeVisible();
await expect(page.getByText("missing the required column 'Title'")).toBeVisible();
// The picker is live again for a retry.
await expect(page.locator('input[type="file"]')).toBeEnabled();
// No trace: still the empty catalog, and no run recorded.
await gotoProducts(page);
await expect(
page.getByText("No products yet. Bulk import is how product data gets in."),
).toBeVisible();
await expect(page.getByText("No imports yet.")).toBeVisible();
});
+49
View File
@@ -0,0 +1,49 @@
// DoD scenario e2e_import_preview_confirm (SD-0002 §6.8): the happy path end to
// end — sign up, empty catalog, upload good.csv, preview gate (PUC-2/3), confirm,
// run report card, and the catalog + history reflecting the import. Subsumes the
// Task-15 harness smoke (sign-up journey + products empty state).
import { expect, test } from "@playwright/test";
import { gotoProducts, signUpWithStorefront, STOREFRONT_NAME, uploadFixture } from "../helpers";
test("e2e_import_preview_confirm", async ({ page }) => {
await signUpWithStorefront(page);
// Admin topbar: storefront identity + the signed-in account chip (ex-smoke).
await expect(page.locator(".storeid__name")).toHaveText(STOREFRONT_NAME);
await expect(page.getByRole("button", { name: "Sign out" })).toBeVisible();
await gotoProducts(page);
// Empty state + the import affordances (SD-0002 §5.2, ex-smoke).
await expect(
page.getByText("No products yet. Bulk import is how product data gets in."),
).toBeVisible();
await expect(page.getByRole("link", { name: "Download sample CSV" })).toBeVisible();
await uploadFixture(page, "good.csv");
// Preview (§5.4): summary tiles + the file's name in the heading.
await expect(page.getByRole("heading", { name: "Import preview — good.csv" })).toBeVisible();
await expect(page.getByRole("button", { name: "2 to add" })).toBeVisible();
await expect(page.getByRole("button", { name: "0 errors" })).toBeVisible();
// Drill in: open star-tee's diff row and see field-level detail (the S-variant SKU).
const starTee = page.locator(".difflist__item", { hasText: "star-tee" });
await starTee.locator("summary").click();
await expect(starTee.getByText("WG-TEE-S")).toBeVisible();
// Consent gate (PUC-3): confirm the import.
await page.getByRole("button", { name: "Import 2 products" }).click();
// Run detail (§5.5): report card with the file name, counts, and status.
await expect(page.getByRole("heading", { level: 1, name: "good.csv" })).toBeVisible();
await expect(page.getByText("2 added · 0 updated · 0 rows in error")).toBeVisible();
await expect(page.getByText("Complete", { exact: true })).toBeVisible();
// Back on Products: the catalog count and exactly one history row for this run.
await gotoProducts(page);
await expect(page.getByRole("heading", { level: 1, name: "Products · 2" })).toBeVisible();
const rows = page.locator(".datatable tbody tr");
await expect(rows).toHaveCount(1);
await expect(rows.first().getByRole("link", { name: "good.csv" })).toBeVisible();
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "wiggleverse-ecomm-frontend",
"private": true,
"version": "0.4.0",
"version": "0.5.0",
"type": "module",
"scripts": {
"dev": "vite",
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { adminViewFor, hashFor, type AdminView } from "./adminRouting";
const VIEWS: AdminView[] = [
{ view: "home" }, { view: "products" }, { view: "import-upload" },
{ view: "import-preview", draftId: 7 }, { view: "run-detail", runId: 12 },
];
describe("adminRouting", () => {
it("round-trips every view", () => {
for (const v of VIEWS) expect(adminViewFor(hashFor(v))).toEqual(v);
});
it("defaults junk to home/products", () => {
expect(adminViewFor("")).toEqual({ view: "home" });
expect(adminViewFor("#/nonsense")).toEqual({ view: "home" });
expect(adminViewFor("#/products/imports/drafts/abc")).toEqual({ view: "products" });
});
});
+31
View File
@@ -0,0 +1,31 @@
// Hash routing for admin sections (SD-0002 §5). The URL is the durable handle on a
// section (PUC-8: run detail can be left and returned to); the SD-0001 entry-routing
// rule (routing.ts) still decides whether the admin renders at all.
export type AdminView =
| { view: "home" }
| { view: "products" }
| { view: "import-upload" }
| { view: "import-preview"; draftId: number }
| { view: "run-detail"; runId: number };
export function adminViewFor(hash: string): AdminView {
const parts = hash.replace(/^#\/?/, "").split("/").filter(Boolean);
if (parts[0] !== "products") return { view: "home" };
if (parts.length === 1) return { view: "products" };
if (parts[1] === "import" && parts.length === 2) return { view: "import-upload" };
if (parts[1] === "imports" && parts[2] === "drafts" && /^\d+$/.test(parts[3] ?? ""))
return { view: "import-preview", draftId: Number(parts[3]) };
if (parts[1] === "imports" && parts[2] === "runs" && /^\d+$/.test(parts[3] ?? ""))
return { view: "run-detail", runId: Number(parts[3]) };
return { view: "products" };
}
export function hashFor(v: AdminView): string {
switch (v.view) {
case "home": return "#/";
case "products": return "#/products";
case "import-upload": return "#/products/import";
case "import-preview": return `#/products/imports/drafts/${v.draftId}`;
case "run-detail": return `#/products/imports/runs/${v.runId}`;
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ export interface VerifyResult {
created: boolean;
}
async function errorOf(resp: Response): Promise<ApiError> {
export async function errorOf(resp: Response): Promise<ApiError> {
try {
const body = await resp.json();
if (body && body.error) return body.error as ApiError;
+98
View File
@@ -0,0 +1,98 @@
// Typed fetch wrappers for the /api/products/* surface (SD-0002 §6.4). Same
// conventions as api.ts: same-origin, cookie session, §6.4 error envelope.
import { errorOf, type ApiError } from "./api";
export interface DiffSummary { adds: number; updates: number; unchanged: number; errors: number; }
export interface Draft {
id: number; file_name: string; dialect: string;
summary: DiffSummary; unknown_columns: string[]; expires_at: string;
}
export type RecordKind = "add" | "update" | "unchanged" | "error";
export interface RowErrorDetail { line: number; column: string | null; message: string; }
export interface FieldChange { field: string; before: unknown; after: unknown; }
export interface VariantEntry {
options: (string | null)[]; kind?: string;
set?: Record<string, unknown>; changes?: FieldChange[];
}
export interface ImageEntry { src: string; kind?: string; position?: number; alt_text?: string | null; changes?: FieldChange[]; }
export interface DraftRecord {
handle: string; title: string; kind: RecordKind; variant_count: number;
detail: {
set?: Record<string, unknown>;
option_names?: (string | null)[];
changes?: FieldChange[];
variants?: VariantEntry[];
images?: ImageEntry[];
errors?: RowErrorDetail[];
};
}
export interface RunSummary {
id: number; file_name: string; dialect: string; created_at: string;
completed_at: string | null; status: string; by: string;
products_added: number; products_updated: number; rows_errored: number;
}
export interface RunDetail extends RunSummary {
errors: RowErrorDetail[];
image_progress: { done: number; total: number };
image_outcomes: unknown[];
}
export interface ProductsSummary {
product_count: number; image_problem_count: number; latest_run_id: number | null;
}
export type Result<T> = { ok: true; value: T } | { ok: false; error: ApiError; status: number };
// One label rule for CSV dialects, shared by Products history / preview / run detail.
export function dialectLabel(d: string): string {
return d === "canonical" ? "Canonical format" : d;
}
async function request<T>(path: string, init?: RequestInit): Promise<Result<T>> {
const resp = await fetch(path, { credentials: "include", ...init });
if (!resp.ok) return { ok: false, error: await errorOf(resp), status: resp.status };
if (resp.status === 204) return { ok: true, value: undefined as T };
return { ok: true, value: (await resp.json()) as T };
}
export function getProductsSummary(): Promise<Result<ProductsSummary>> {
return request("/api/products/summary");
}
export function uploadImport(file: File): Promise<Result<Draft>> {
const body = new FormData();
body.append("file", file);
// No content-type header: the browser sets the multipart boundary.
return request("/api/products/imports", { method: "POST", body });
}
export function getDraft(id: number): Promise<Result<Draft>> {
return request(`/api/products/imports/drafts/${id}`);
}
export async function getDraftRecords(
id: number, kind?: RecordKind, limit = 100, offset = 0,
): Promise<Result<DraftRecord[]>> {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
if (kind) params.set("kind", kind);
const resp = await request<{ records: DraftRecord[] }>(
`/api/products/imports/drafts/${id}/records?${params}`,
);
return resp.ok ? { ok: true, value: resp.value.records } : resp;
}
export function confirmDraft(id: number): Promise<Result<{ run_id: number }>> {
return request(`/api/products/imports/drafts/${id}/confirm`, { method: "POST" });
}
export function cancelDraft(id: number): Promise<Result<void>> {
return request(`/api/products/imports/drafts/${id}`, { method: "DELETE" });
}
export async function listRuns(): Promise<Result<RunSummary[]>> {
const resp = await request<{ runs: RunSummary[] }>("/api/products/imports/runs");
return resp.ok ? { ok: true, value: resp.value.runs } : resp;
}
export function getRun(id: number): Promise<Result<RunDetail>> {
return request(`/api/products/imports/runs/${id}`);
}
+51 -22
View File
@@ -1,9 +1,16 @@
// 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).
// Admin shell (SD-0001 §5.4) — the storefront's stable home; the home view is 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). SD-0002 §5 adds the
// admin nav strip + hash-routed products section (adminRouting.ts).
import { useEffect, useState } from "react";
import { adminViewFor, type AdminView } from "../adminRouting";
import { logout } from "../api";
import { AccountChip, Banner, Eyebrow, Screen, TopBar } from "../ui/kit";
import ImportPreview from "./products/ImportPreview";
import ImportUpload from "./products/ImportUpload";
import ProductsPage from "./products/ProductsPage";
import RunDetail from "./products/RunDetail";
interface Props {
storefrontName: string;
@@ -13,6 +20,14 @@ interface Props {
}
export default function Admin({ storefrontName, email, welcome, onSignedOut }: Props) {
const [view, setView] = useState<AdminView>(adminViewFor(window.location.hash));
useEffect(() => {
const onHashChange = () => setView(adminViewFor(window.location.hash));
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, []);
async function signOut() {
await logout();
onSignedOut();
@@ -32,27 +47,41 @@ export default function Admin({ storefrontName, email, welcome, onSignedOut }: P
}
right={<AccountChip email={email} onSignOut={signOut} />}
/>
<nav className="adminnav" aria-label="Admin sections">
<a className={`adminnav__item${view.view === "home" ? " adminnav__item--active" : ""}`} href="#/">
Overview
</a>
<a className={`adminnav__item${view.view !== "home" ? " adminnav__item--active" : ""}`} href="#/products">
Products
</a>
</nav>
<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>
{view.view === "home" && (
<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>
)}
<div className="empty__seal" aria-hidden="true">
<img src="/brand/mark-mono-gold.svg" width={36} height={36} alt="" />
<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>
<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>
)}
{view.view === "products" && <ProductsPage />}
{view.view === "import-upload" && <ImportUpload />}
{view.view === "import-preview" && <ImportPreview draftId={view.draftId} />}
{view.view === "run-detail" && <RunDetail runId={view.runId} />}
</main>
</Screen>
);
@@ -0,0 +1,393 @@
// Import preview (SD-0002 §5.4) — the consent gate. Summary tiles filter a
// drill-in diff list; the sticky footer carries confirm (PUC-3) / cancel (PUC-3a).
// Diff glyphs pair with color, never color alone (§6.6).
import { useEffect, useRef, useState } from "react";
import {
cancelDraft,
confirmDraft,
dialectLabel,
getDraft,
getDraftRecords,
type Draft,
type DraftRecord,
type FieldChange,
type RecordKind,
type VariantEntry,
} from "../../productsApi";
import { Banner } from "../../ui/kit";
const PAGE = 100;
function fmt(v: unknown): string {
if (v === null || v === undefined) return "—";
if (Array.isArray(v)) return v.length ? v.join(", ") : "—";
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
return String(v);
}
function KindChip({ kind }: { kind: RecordKind }) {
return <span className={`kindchip kindchip--${kind}`}>{kind}</span>;
}
function SetLine({ field, value }: { field: string; value: unknown }) {
return (
<div className="diffchange">
<span className="diffchange__glyph--add">+ </span>
{field}: {fmt(value)}
</div>
);
}
function ChangeRow({ change }: { change: FieldChange }) {
return (
<div className="diffchange">
{change.field}: <span className="diffchange__glyph--del"> {fmt(change.before)}</span> {" "}
<span className="diffchange__glyph--add">+ {fmt(change.after)}</span>
</div>
);
}
function variantLabel(v: VariantEntry): string {
const opts = v.options.filter((o): o is string => o != null);
return opts.length ? `Variant ${opts.join(" / ")}` : "Variant";
}
function RecordDetail({ record }: { record: DraftRecord }) {
const d = record.detail;
if (record.kind === "error") {
return (
<div>
{(d.errors ?? []).map((e, i) => (
<div className="diffchange" key={i}>
line {e.line}: {e.column != null && `'${e.column}' — `}
{e.message}
</div>
))}
</div>
);
}
return (
<div>
{Object.entries(d.set ?? {}).map(([field, value]) => (
<SetLine key={field} field={field} value={value} />
))}
{(d.changes ?? []).map((c, i) => (
<ChangeRow key={i} change={c} />
))}
{(d.variants ?? []).map((v, i) => (
<div key={i}>
<div className="diffchange diffchange--head">
{variantLabel(v)}
{v.kind ? ` (${v.kind})` : ""}
</div>
{Object.entries(v.set ?? {}).map(([field, value]) => (
<SetLine key={field} field={field} value={value} />
))}
{(v.changes ?? []).map((c, j) => (
<ChangeRow key={j} change={c} />
))}
</div>
))}
{(d.images ?? []).map((img, i) =>
img.kind && img.kind !== "add" ? (
<div key={i}>
<div className="diffchange diffchange--head">
image: {img.src} ({img.kind})
</div>
{(img.changes ?? []).map((c, j) => (
<ChangeRow key={j} change={c} />
))}
</div>
) : (
<div className="diffchange" key={i}>
<span className="diffchange__glyph--add">+ </span>image: {img.src}
</div>
),
)}
</div>
);
}
function ErrorTable({ records }: { records: DraftRecord[] }) {
const rows = records.flatMap((r) => r.detail.errors ?? []);
if (rows.length === 0) return null;
return (
<table className="errortable">
<thead>
<tr>
<th>Line</th>
<th>Column</th>
<th>Problem</th>
</tr>
</thead>
<tbody>
{rows.map((e, i) => (
<tr key={i}>
<td>{e.line}</td>
<td>{e.column ?? "—"}</td>
<td>{e.message}</td>
</tr>
))}
</tbody>
</table>
);
}
export default function ImportPreview({ draftId }: { draftId: number }) {
const [draft, setDraft] = useState<Draft | null>(null);
const [loadFail, setLoadFail] = useState<"gone" | "expired" | "failed" | null>(null);
const [filter, setFilter] = useState<RecordKind | null>(null);
const [records, setRecords] = useState<DraftRecord[] | null>(null);
const [recordsError, setRecordsError] = useState<"load" | "more" | null>(null);
const [hasMore, setHasMore] = useState(false);
const [moreBusy, setMoreBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
const [cancelling, setCancelling] = useState(false);
const [stale, setStale] = useState(false);
const [nothingNote, setNothingNote] = useState(false);
const [confirmError, setConfirmError] = useState<string | null>(null);
// Generation counter for the records list: bumped on every page-0 (re)load, so a
// page-0 or show-more response that resolves after a tile/filter (or draft) switch
// is recognized as stale and dropped instead of clobbering/appending to the new list.
const recordsGen = useRef(0);
async function loadDraft() {
setLoadFail(null);
const resp = await getDraft(draftId);
if (!resp.ok) {
setLoadFail(resp.status === 404 ? "gone" : resp.status === 410 ? "expired" : "failed");
return;
}
setDraft(resp.value);
}
useEffect(() => {
// A new draft means a fresh consent gate — reset everything the old one set.
setFilter(null);
setStale(false);
setConfirmError(null);
setNothingNote(false);
setRecords(null);
setRecordsError(null);
setHasMore(false);
void loadDraft();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draftId]);
function loadRecords(kind: RecordKind | null) {
recordsGen.current += 1;
const gen = recordsGen.current;
setRecords(null);
setRecordsError(null);
setHasMore(false);
void getDraftRecords(draftId, kind ?? undefined, PAGE, 0).then((resp) => {
if (gen !== recordsGen.current) return;
if (!resp.ok) {
setRecordsError("load");
return;
}
setRecords(resp.value);
setHasMore(resp.value.length === PAGE);
});
}
useEffect(() => {
if (!draft) return;
loadRecords(filter);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draft, draftId, filter]);
async function showMore() {
if (!records) return;
const gen = recordsGen.current;
setMoreBusy(true);
setRecordsError(null);
const resp = await getDraftRecords(draftId, filter ?? undefined, PAGE, records.length);
setMoreBusy(false);
// Filter/draft switched while this page was in flight — drop the stale page.
if (gen !== recordsGen.current) return;
if (!resp.ok) {
setRecordsError("more");
return;
}
setRecords((prev) => [...(prev ?? []), ...resp.value]);
setHasMore(resp.value.length === PAGE);
}
async function onConfirm() {
setConfirming(true);
setConfirmError(null);
const resp = await confirmDraft(draftId);
if (resp.ok) {
window.location.hash = `#/products/imports/runs/${resp.value.run_id}`;
return;
}
setConfirming(false);
if ((resp.status === 409 && resp.error.code === "preview_stale") || resp.status === 410) {
setStale(true);
} else if (resp.status === 409 && resp.error.code === "nothing_to_apply") {
setNothingNote(true);
} else {
setConfirmError(resp.error.message);
}
}
async function onCancel() {
setCancelling(true);
// PUC-3a — cancel even on draft-gone (404) still navigates home.
await cancelDraft(draftId);
window.location.hash = "#/products";
}
if (loadFail === "gone") {
return (
<Banner tone="attn" title="This preview is gone">
<a href="#/products">Back to Products</a>
</Banner>
);
}
if (loadFail === "expired") {
return (
<Banner tone="attn" title="This preview expired — upload the file again">
<a href="#/products/import">Upload the file again</a>
</Banner>
);
}
if (loadFail === "failed") {
return (
<Banner tone="attn" title="Couldn't load this preview">
Something went wrong on our side.{" "}
<button type="button" className="linklike" onClick={() => void loadDraft()}>
Retry
</button>
</Banner>
);
}
if (!draft) {
return (
<p className="note" role="status">
Loading
</p>
);
}
const { summary } = draft;
const tiles: { kind: RecordKind; num: number; label: string }[] = [
{ kind: "add", num: summary.adds, label: "to add" },
{ kind: "update", num: summary.updates, label: "to update" },
{ kind: "unchanged", num: summary.unchanged, label: "unchanged" },
{ kind: "error", num: summary.errors, label: "errors" },
];
const toApply = summary.adds + summary.updates;
return (
<div className="products">
<p className="note">
<a href="#/products"> Products</a>
</p>
<h1>Import preview {draft.file_name}</h1>
<p className="note">{dialectLabel(draft.dialect)}</p>
{draft.unknown_columns.length > 0 && (
<Banner tone="info" title="Columns not imported">
{draft.unknown_columns.length > 8 ? (
<details>
<summary>{draft.unknown_columns.length} columns not imported</summary>
{draft.unknown_columns.join(", ")}
</details>
) : (
draft.unknown_columns.join(", ")
)}
</Banner>
)}
<div className="tiles">
{tiles.map((t) => (
<button
key={t.kind}
type="button"
className={`tile tile--${t.kind}${filter === t.kind ? " tile--active" : ""}`}
aria-pressed={filter === t.kind}
onClick={() => setFilter(filter === t.kind ? null : t.kind)}
>
<span className="tile__num">{t.num.toLocaleString()}</span>
<span className="tile__label">{t.label}</span>
</button>
))}
</div>
{filter === "error" && records && <ErrorTable records={records} />}
{recordsError === "load" ? (
<p className="note note--attn" role="alert">
Couldn't load these records.{" "}
<button type="button" className="linklike" onClick={() => loadRecords(filter)}>
Retry
</button>
</p>
) : records === null ? (
<p className="note" role="status">
Loading…
</p>
) : records.length === 0 ? (
<p className="note">Nothing to show here.</p>
) : (
<div className="difflist">
{records.map((r, i) => (
<details className="difflist__item" key={`${r.handle}-${i}`}>
<summary>
<span className="difflist__handle">{r.handle}</span> · {r.title} ·{" "}
<KindChip kind={r.kind} /> · {r.variant_count}{" "}
{r.variant_count === 1 ? "variant" : "variants"}
</summary>
<RecordDetail record={r} />
</details>
))}
</div>
)}
{hasMore && (
<p>
<button type="button" className="btn-secondary" disabled={moreBusy} onClick={() => void showMore()}>
{moreBusy ? "Loading…" : "Show more"}
</button>
{recordsError === "more" && (
<span className="note note--attn" role="alert">
Couldn't load more records.{" "}
<button type="button" className="linklike" onClick={() => void showMore()}>
Retry
</button>
</span>
)}
</p>
)}
<div className="sticky-footer" aria-live="polite">
{stale ? (
<Banner tone="attn" title="Your catalog changed since this preview — upload the file again">
<a href="#/products/import">Upload the file again</a>
</Banner>
) : (
<>
<button
type="button"
className="btn-primary"
disabled={toApply === 0 || confirming || cancelling}
onClick={() => void onConfirm()}
>
{confirming ? "Importing…" : `Import ${toApply.toLocaleString()} products`}
</button>
<button
type="button"
className="btn-secondary"
disabled={confirming || cancelling}
onClick={() => void onCancel()}
>
Cancel
</button>
{(toApply === 0 || nothingNote) && (
<span className="note">Nothing to change your catalog already matches this file</span>
)}
{confirmError && (
<span className="note note--attn" role="alert">
{confirmError}
</span>
)}
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,60 @@
// Import — upload (SD-0002 §5.3). Selecting a file starts upload + validation
// immediately (PUC-2); file-level rejections (PUC-5a) render in place with the
// picker live for retry. No notifications — errors render here.
import { useRef, useState } from "react";
import { uploadImport } from "../../productsApi";
import { Banner } from "../../ui/kit";
export default function ImportUpload() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
async function onPick(files: FileList | null) {
const file = files?.[0];
if (!file) return;
setBusy(true);
setError(null);
const resp = await uploadImport(file);
setBusy(false);
if (inputRef.current) inputRef.current.value = "";
if (!resp.ok) {
setError(resp.error.message);
return;
}
window.location.hash = `#/products/imports/drafts/${resp.value.id}`;
}
return (
<div className="products products--narrow">
<p className="note">
<a href="#/products"> Products</a>
</p>
<h1>Import products</h1>
{error && (
<Banner tone="attn" title="That file can't be imported">
{error}
</Banner>
)}
<label className={`dropzone${busy ? " dropzone--busy" : ""}`}>
<input
ref={inputRef}
type="file"
accept=".csv,text/csv"
disabled={busy}
onChange={(e) => void onPick(e.target.files)}
/>
<span className="dropzone__title" aria-live="polite">
{busy ? "Validating…" : "Choose a CSV file"}
</span>
<span className="note">CSV, up to 5,000 rows</span>
</label>
<p className="note">
Works with the canonical format.{" "}
<a href="/api/products/sample.csv" download>
Download sample CSV
</a>
</p>
</div>
);
}
@@ -0,0 +1,132 @@
// Products page (SD-0002 §5.2) — the catalog's home: where imports start and history
// lives. SLICE-5: export is disabled (SLICE-6 ships it); the browsable list is #14's.
import { useEffect, useState } from "react";
import {
dialectLabel,
getProductsSummary,
listRuns,
type ProductsSummary,
type RunSummary,
} from "../../productsApi";
import { Banner } from "../../ui/kit";
const STATUS_LABELS: Record<string, string> = {
applying: "Importing…",
fetching_images: "Fetching images…",
complete: "Complete",
complete_with_problems: "Complete with problems",
};
export default function ProductsPage() {
const [summary, setSummary] = useState<ProductsSummary | null>(null);
const [runs, setRuns] = useState<RunSummary[] | null>(null);
const [failed, setFailed] = useState(false);
async function load() {
setFailed(false);
const [s, r] = await Promise.all([getProductsSummary(), listRuns()]);
if (!s.ok || !r.ok) {
setFailed(true);
return;
}
setSummary(s.value);
setRuns(r.value);
}
useEffect(() => {
void load();
}, []);
if (failed) {
return (
<Banner tone="attn" title="Couldn't load your products">
Something went wrong on our side.{" "}
<button type="button" className="linklike" onClick={() => void load()}>
Retry
</button>
</Banner>
);
}
if (!summary || !runs) {
return (
<p className="note" role="status">
Loading
</p>
);
}
const empty = summary.product_count === 0;
return (
<div className="products">
<header className="products__header">
<h1>
Products
{!empty && <span className="products__count"> · {summary.product_count.toLocaleString()}</span>}
</h1>
<div className="products__actions">
<div className="products__export">
<button type="button" className="btn-secondary" disabled title="Export arrives in a coming release">
Export
</button>
<span className="note">Export arrives in a coming release</span>
</div>
<a className="btn-primary" href="#/products/import">
Import products
</a>
</div>
</header>
{empty ? (
<div className="empty">
<p className="empty__copy">No products yet. Bulk import is how product data gets in.</p>
<a className="btn-primary" href="#/products/import">
Import products
</a>
<p className="note">
<a href="/api/products/sample.csv" download>
Download sample CSV
</a>
</p>
</div>
) : (
<p className="note">Your catalog is loaded. The browsable product list arrives with an upcoming release.</p>
)}
<section className="products__history">
<h2>Import history</h2>
{runs.length === 0 ? (
<p className="note">No imports yet.</p>
) : (
<table className="datatable">
<thead>
<tr>
<th>Date</th><th>File</th><th>Dialect</th><th>Added</th><th>Updated</th><th>Errors</th><th>Status</th>
</tr>
</thead>
<tbody>
{runs.map((r) => (
<tr
key={r.id}
className="datatable__rowlink"
onClick={() => {
window.location.hash = `#/products/imports/runs/${r.id}`;
}}
>
<td>{new Date(r.created_at).toLocaleString()}</td>
<td>
{/* Anchor = the keyboard/SR path (§6.6); the row onClick stays as a
mouse convenience. Both set the same hash, so the double fire on
an anchor click is idempotent. */}
<a href={`#/products/imports/runs/${r.id}`}>{r.file_name}</a>
</td>
<td>{dialectLabel(r.dialect)}</td>
<td>{r.products_added}</td>
<td>{r.products_updated}</td>
<td>{r.rows_errored}</td>
<td>{STATUS_LABELS[r.status] ?? r.status}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
);
}
@@ -0,0 +1,93 @@
// Run detail (SD-0002 §5.5) — report card for a completed or in-progress import run.
// NO images section this slice (SLICE-7). PUC-4/5/8.
import { useEffect, useState } from "react";
import { dialectLabel, getRun, type RunDetail as RunDetailType } from "../../productsApi";
import { Banner } from "../../ui/kit";
const STATUS_LABELS: Record<string, string> = {
applying: "Importing…",
fetching_images: "Fetching images…",
complete: "Complete",
complete_with_problems: "Complete with problems",
};
export default function RunDetail({ runId }: { runId: number }) {
const [run, setRun] = useState<RunDetailType | null>(null);
const [loadFail, setLoadFail] = useState<"gone" | "failed" | null>(null);
async function load() {
setLoadFail(null);
const resp = await getRun(runId);
if (!resp.ok) {
setLoadFail(resp.status === 404 ? "gone" : "failed");
return;
}
setRun(resp.value);
}
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [runId]);
if (loadFail === "gone") {
return (
<Banner tone="attn" title="No such import run">
<a href="#/products"> Products</a>
</Banner>
);
}
if (loadFail === "failed") {
return (
<Banner tone="attn" title="Couldn't load this import run">
Something went wrong on our side.{" "}
<button type="button" className="linklike" onClick={() => void load()}>
Retry
</button>
</Banner>
);
}
if (!run) {
return (
<p className="note" role="status">
Loading
</p>
);
}
return (
<div className="products">
<p className="note">
<a href="#/products"> Products</a>
</p>
<h1>{run.file_name}</h1>
<p className="note">
Imported {new Date(run.created_at).toLocaleString()} by {run.by} · {dialectLabel(run.dialect)}
</p>
<p className="note">
{run.products_added} added · {run.products_updated} updated · {run.rows_errored} rows in
error
</p>
<p className="note">{STATUS_LABELS[run.status] ?? run.status}</p>
{run.errors.length > 0 && (
<table className="errortable">
<thead>
<tr>
<th>Line</th>
<th>Column</th>
<th>Problem</th>
</tr>
</thead>
<tbody>
{run.errors.map((e, i) => (
<tr key={i}>
<td>{e.line}</td>
<td>{e.column ?? "—"}</td>
<td>{e.message}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
+1
View File
@@ -5,3 +5,4 @@
@import "./tokens-typography.css";
@import "./tokens-spacing.css";
@import "./app.css";
@import "./products.css";
+260
View File
@@ -0,0 +1,260 @@
/* Products section (SD-0002 §5) — admin nav strip, the catalog's home page, and the
import-flow primitives (dropzone, tiles, difflist — Tasks 1214 consume these).
Same language as app.css: dark ground, glass chrome, hairline borders, small lifts. */
/* Status accents (SD-0002 design bundle): add / update / error. */
:root {
--st-add: #1F8A5B;
--st-update: #B5830F;
--st-error: #C2513E;
}
/* ── admin nav: horizontal strip under the topbar ───────────────────────────── */
.adminnav {
flex: 0 0 auto;
display: flex;
gap: 26px;
padding: 0 36px;
border-bottom: 1px solid var(--border-soft);
background: rgba(9, 12, 34, .35);
}
.adminnav__item {
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: 14px;
color: var(--text-on-dark-mute);
text-decoration: none;
padding: 13px 2px 11px;
border-bottom: 2px solid transparent;
transition: color var(--dur-fast) var(--ease);
}
.adminnav__item:hover { color: var(--wv-starlight); }
.adminnav__item--active { color: var(--wv-starlight); border-bottom-color: var(--wv-gold); }
/* ── products page frame ────────────────────────────────────────────────────── */
/* margin-bottom auto pins the page to the top of the centered .screen__main. */
.products { width: 100%; max-width: 880px; margin-bottom: auto; }
.products--narrow { max-width: 560px; }
.products__header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
margin-bottom: 28px;
}
.products__header h1 {
font-family: var(--wv-font-display);
font-weight: var(--weight-bold);
letter-spacing: var(--tracking-display);
font-size: 28px;
line-height: 1.1;
margin: 0;
}
.products__count { color: var(--text-on-dark-mute); font-weight: var(--weight-medium); }
.products__actions { display: flex; gap: 12px; align-items: center; }
/* Disabled Export + its visible "coming release" caption, stacked. */
.products__export { display: flex; flex-direction: column; gap: 4px; align-items: center; }
.products__export .note { font-size: 11.5px; }
.products .btn-primary { width: auto; text-decoration: none; }
.products .empty { margin: 24px auto 0; }
.products__history { margin-top: 44px; }
.products__history h2 {
font-family: var(--wv-font-display);
font-weight: var(--weight-semibold);
font-size: 17px;
letter-spacing: var(--tracking-display);
margin: 0 0 14px;
}
/* ── secondary button: outline twin of .btn-primary ─────────────────────────── */
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: .4em;
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 var(--border-strong);
background: transparent;
color: var(--text-on-dark-soft);
cursor: pointer;
transition: border-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease),
transform var(--dur-fast) var(--ease);
}
.btn-secondary:hover:not(:disabled) { border-color: var(--wv-lilac); color: var(--wv-starlight); transform: var(--lift-1); }
.btn-secondary:disabled { opacity: .45; cursor: not-allowed; }
.btn-secondary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
/* ── button that reads as a link (inline retry etc.) ────────────────────────── */
.linklike {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--wv-lilac);
text-decoration: underline;
cursor: pointer;
transition: color var(--dur-fast) var(--ease);
}
.linklike:hover { color: var(--wv-gold); }
/* ── data tables (import history; errortable shares the bones) ──────────────── */
.datatable, .errortable {
width: 100%;
border-collapse: collapse;
font-size: 13.5px;
}
.datatable th, .errortable th {
text-align: left;
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: 12px;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--text-on-dark-mute);
padding: 8px 12px;
border-bottom: 1px solid var(--border-card);
}
.datatable td, .errortable td {
padding: 11px 12px;
border-bottom: 1px solid var(--border-soft);
color: var(--text-on-dark-soft);
}
.datatable__rowlink { cursor: pointer; transition: background var(--dur-fast) var(--ease); }
.datatable__rowlink:hover { background: var(--wv-lilac-08); }
.errortable td:last-child { color: var(--st-error); }
/* ── summary tiles (preview, Task 13) ───────────────────────────────────────── */
.tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
.tile {
background: var(--surface-raised);
border: 1px solid var(--border-card);
border-radius: var(--radius-panel);
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
transition: background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}
.tile:hover { background: var(--surface-raised-hi); }
.tile--active { border-color: var(--wv-gold); }
.tile__num {
font-family: var(--wv-font-display);
font-weight: var(--weight-bold);
font-size: 26px;
line-height: 1;
color: var(--text-on-dark-soft);
}
.tile__label { font-size: 12.5px; color: var(--text-on-dark-mute); }
.tile--add .tile__num { color: var(--st-add); }
.tile--update .tile__num { color: var(--st-update); }
.tile--error .tile__num { color: var(--st-error); }
/* ── diff list (preview records, Task 13) ───────────────────────────────────── */
.difflist { list-style: none; margin: 0; padding: 0; }
.difflist__item { padding: 12px 4px; border-bottom: 1px solid var(--border-soft); }
.difflist__item > summary {
cursor: pointer;
font-size: 14px;
color: var(--text-on-dark-soft);
transition: color var(--dur-fast) var(--ease);
}
.difflist__item > summary:hover { color: var(--wv-starlight); }
.difflist__item[open] > summary { margin-bottom: 8px; }
.difflist__handle { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; }
.diffchange {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12.5px;
line-height: 1.6;
color: var(--text-on-dark-soft);
}
.diffchange--head { color: var(--text-on-dark-mute); margin-top: 6px; }
.diffchange__glyph--add { color: var(--st-add); }
.diffchange__glyph--del { color: var(--st-error); }
/* ── kind chip (preview record summaries, Task 13) ──────────────────────────── */
.kindchip {
display: inline-block;
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: 11px;
letter-spacing: .06em;
text-transform: uppercase;
line-height: 1;
padding: 3px 9px 2px;
border-radius: var(--radius-pill);
border: 1px solid var(--border-strong);
color: var(--text-on-dark-mute);
}
.kindchip--add { color: var(--st-add); border-color: var(--st-add); }
.kindchip--update { color: var(--st-update); border-color: var(--st-update); }
.kindchip--error { color: var(--st-error); border-color: var(--st-error); }
/* preview layout rhythm: tiles + errortable sit between header and difflist */
.products .tiles { margin: 24px 0 18px; }
.products .errortable { margin: 0 0 18px; }
/* ── sticky confirm/cancel footer (preview, Task 13) ────────────────────────── */
.sticky-footer {
position: sticky;
bottom: 0;
display: flex;
gap: 12px;
align-items: center;
padding: 14px 0;
border-top: 1px solid var(--border-soft);
background: var(--glass-sky);
backdrop-filter: blur(var(--glass-blur));
}
/* ── upload dropzone (Task 12) ──────────────────────────────────────────────── */
.dropzone {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
text-align: center;
padding: 48px 24px;
border: 2px dashed var(--border-strong);
border-radius: var(--radius-card);
cursor: pointer;
transition: border-color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
}
.dropzone:hover { border-color: var(--wv-lilac); background: var(--wv-lilac-08); }
.dropzone--busy { opacity: .55; pointer-events: none; }
.dropzone input[type="file"] {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.dropzone__title {
display: block;
font-family: var(--wv-font-display);
font-weight: var(--weight-medium);
font-size: 17px;
color: var(--text-on-dark-soft);
margin-bottom: 2px;
}
/* ── small screens ──────────────────────────────────────────────────────────── */
@media (max-width: 720px) {
.adminnav { padding: 0 20px; }
.tiles { grid-template-columns: repeat(2, 1fr); }
.products__header { flex-wrap: wrap; }
}
Executable
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# E2E browser gate (SD-0002 §6.8) — Playwright against a fresh local stack.
# Not yet part of check.sh/CI: the CI runner has no browsers (§10.6 gap).
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root/e2e"
if [ ! -d node_modules ]; then
npm install
npx playwright install chromium
fi
npx playwright test "$@"