diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 4c4a2bc..348986e 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -1,8 +1,8 @@
# Server-side pre-merge gate. Calls the same scripts/check.sh that runs locally, so
# the local and server gates can never drift: import-linter (main > domains >
-# platform), then backend pytest against a Postgres service, then the frontend
-# typecheck + build. Requires a registered Gitea Actions runner advertising the
-# `ubuntu-latest` label.
+# platform), then backend pytest against a Postgres service. The network-seed reset
+# stripped the SPA frontend, so the gate is backend-only now. Requires a registered
+# Gitea Actions runner advertising the `ubuntu-latest` label.
name: ci
on:
@@ -35,21 +35,12 @@ jobs:
with:
python-version: "3.13"
- - name: Set up Node
- uses: actions/setup-node@v4
- with:
- node-version: "20"
-
- name: Install backend deps
run: |
python -m venv .venv
.venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install -r backend/requirements.txt
- - name: Install frontend deps
- run: |
- cd frontend && npm ci
-
- name: Run the pre-merge gate
env:
PYTHON: ${{ github.workspace }}/.venv/bin/python
diff --git a/CLAUDE.md b/CLAUDE.md
index d485189..bb22f88 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2,9 +2,12 @@
# wiggleverse-ecomm
-The clean implementation of Wiggleverse ecomm — a multi-tenant, OHM-grounded
-Shopify alternative. Greenfield: started fresh 2026-06-10, superseding the
-first-pass reference prototype (`wiggleverse-ecomm-prototype`).
+Wiggleverse ecomm, pivoted to a network-service seed — a minimal, OHM-grounded
+FastAPI service for a cross-maker verified referral network. Greenfield: started
+fresh 2026-06-10, superseding the first-pass reference prototype
+(`wiggleverse-ecomm-prototype`). The network-seed reset (v0.9.0) stripped the
+storefront/products HTTP spine and the SPA; the seed is `/healthz` + `/api/auth/*`
+(accounts) + the catalog-normalization library (no HTTP surface yet) + `platform/*`.
- **One Name:** `ecomm` (see `app.json`).
- **Content/corpus repo:** `wiggleverse-ecomm-content` (specs, plans, roadmap, pin).
diff --git a/README.md b/README.md
index 96feb82..284a5b3 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,11 @@
# wiggleverse-ecomm
-The clean implementation of **Wiggleverse ecomm** — a multi-tenant, OHM-grounded
-Shopify alternative.
+**Wiggleverse ecomm** is being pivoted to a **network-service seed**: a minimal,
+OHM-grounded FastAPI service for a cross-maker verified referral network. The
+storefront / products import-export spine and the SPA frontend were stripped in the
+network-seed reset (v0.9.0); what remains is `/healthz`, the `/api/auth/*` identity
+endpoints (the `accounts` domain), the catalog-normalization library kept as
+importable modules with no HTTP surface yet, and the generic `platform/*` infra.
This repo is greenfield. It was stood up fresh on 2026-06-10, superseding the
first-pass **reference prototype** at
diff --git a/VERSION b/VERSION
index a3df0a6..ac39a10 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.8.0
+0.9.0
diff --git a/app.json b/app.json
index 95892fd..b8c9311 100644
--- a/app.json
+++ b/app.json
@@ -1,7 +1,7 @@
{
"schemaVersion": "1.1",
"name": "ecomm",
- "title": "Wiggleverse ecomm — multi-tenant commerce",
+ "title": "Wiggleverse ecomm — cross-maker verified referral network (service)",
"giteaHost": "ssh://git@git.wiggleverse.org:2222",
"repos": [
{
diff --git a/backend/app/domains/__init__.py b/backend/app/domains/__init__.py
index ed76d05..540063a 100644
--- a/backend/app/domains/__init__.py
+++ b/backend/app/domains/__init__.py
@@ -1,6 +1,6 @@
-"""domains layer — bounded contexts (accounts, storefronts).
+"""domains layer — bounded contexts (accounts, products).
-Empty in SLICE-1; the accounts domain lands in SLICE-2 and storefronts in SLICE-3.
-The package exists now so the layer contract (.importlinter) has a target and later
-slices add to a stable seam. Domains import only from app.platform, never upward.
+The accounts domain is the network-service seed's identity context. The products
+package is the catalog-normalization library (no HTTP surface yet). Domains import
+only from app.platform, never upward.
"""
diff --git a/backend/app/domains/products/__init__.py b/backend/app/domains/products/__init__.py
index 0bea1f3..f5bfeb9 100644
--- a/backend/app/domains/products/__init__.py
+++ b/backend/app/domains/products/__init__.py
@@ -1,14 +1,27 @@
-"""products domain — catalog + bulk CSV import/export (SD-0002 §6.2).
+"""products domain — catalog-normalization library (no HTTP surface yet).
-Owns the canonical row model, codec, validation, diff engine, and import
-drafts/runs. Storefront-scoped throughout (INV-14); upsert is the only mutation
-(INV-10). Imported via this package surface only.
+The network-seed reset stripped the storefront-scoped import/export/image spine
+(service/repo/imagefetch and their endpoints). What remains is the pure, DB-free
+catalog-normalization library: the canonical row model, the CSV codec, the Shopify
+dialect adapter, validation, the diff engine, and serialization. These submodules are
+importable as a library; nothing here owns persistence or an HTTP route.
"""
from __future__ import annotations
from pathlib import Path
-from .imagefetch import recover_incomplete_runs, run_image_phase
+from .codec import detect_dialect, parse_csv
+from .diff import (
+ CatalogImage,
+ CatalogProduct,
+ CatalogVariant,
+ DiffResult,
+ ImagePlan,
+ ProductPlan,
+ VariantPlan,
+ compute_diff,
+)
+from .dialect_shopify import is_shopify_header, map_shopify_header
from .errors import (
DraftExpired,
DraftNotFound,
@@ -19,30 +32,37 @@ from .errors import (
ProductsError,
RunNotFound,
)
-from .models import MAX_DATA_ROWS, MAX_FILE_BYTES
-from .repo import image_for_serving
-from .service import (
- confirm_draft,
- discard_draft,
- export_catalog,
- get_draft,
- get_draft_records,
- get_run,
- import_validate,
- list_runs,
- summary,
+from .models import (
+ MAX_DATA_ROWS,
+ MAX_FILE_BYTES,
+ CanonicalImage,
+ CanonicalProduct,
+ CanonicalVariant,
+ ParsedFile,
+ Row,
+ RowError,
)
+from .serialize import catalog_to_csv
+from .validate import all_errors, build_products
-# DOC-3: the downloadable worked-example CSV the BFF serves at /api/products/sample.csv.
+# DOC-3: the downloadable worked-example CSV.
SAMPLE_CSV_PATH = Path(__file__).parent / "sample.csv"
-# DOC-2: the column reference the BFF serves at /api/products/columns.md.
+# DOC-2: the column reference.
COLUMNS_MD_PATH = Path(__file__).parent / "columns.md"
__all__ = [
+ # errors
"ProductsError", "FileRejected", "DraftNotFound", "DraftExpired",
"PreviewStale", "NothingToApply", "RunNotFound", "EmptyCatalog",
- "MAX_DATA_ROWS", "MAX_FILE_BYTES", "SAMPLE_CSV_PATH", "COLUMNS_MD_PATH",
- "import_validate", "get_draft", "get_draft_records", "discard_draft",
- "confirm_draft", "list_runs", "get_run", "summary", "export_catalog",
- "run_image_phase", "recover_incomplete_runs", "image_for_serving",
+ # models + limits
+ "MAX_DATA_ROWS", "MAX_FILE_BYTES", "Row", "RowError", "ParsedFile",
+ "CanonicalProduct", "CanonicalVariant", "CanonicalImage",
+ # codec + dialect
+ "parse_csv", "detect_dialect", "is_shopify_header", "map_shopify_header",
+ # validate + diff + serialize
+ "build_products", "all_errors", "compute_diff", "catalog_to_csv",
+ "CatalogProduct", "CatalogVariant", "CatalogImage",
+ "ProductPlan", "VariantPlan", "ImagePlan", "DiffResult",
+ # docs
+ "SAMPLE_CSV_PATH", "COLUMNS_MD_PATH",
]
diff --git a/backend/app/domains/products/imagefetch.py b/backend/app/domains/products/imagefetch.py
deleted file mode 100644
index 7b677f5..0000000
--- a/backend/app/domains/products/imagefetch.py
+++ /dev/null
@@ -1,146 +0,0 @@
-"""The post-commit image-fetch phase (SD-0002 §6.5.4, §6.9).
-
-In-process, bounded-concurrency, resumable. For a run's pending images: fetch
-each URL behind an SSRF guard with INV-18 bounds, run platform/images, store
-renditions via the objectstore, and move the image pending -> fetched |
-rejected_* | failed. Per-image idempotent (claim guard), so the phase can die
-and resume. Lives in the domains layer (imports repo + platform); main.py
-schedules it post-confirm and runs recovery at startup.
-"""
-from __future__ import annotations
-
-import ipaddress
-import socket
-import time
-from concurrent.futures import ThreadPoolExecutor
-from urllib.parse import urlsplit
-
-import httpx
-
-from app.platform import images as images_mod
-from app.platform import telemetry
-
-from . import repo
-
-MAX_FETCH_BYTES = 20 * 1024 * 1024 # INV-18
-FETCH_TIMEOUT_S = 30 # INV-18
-FETCH_CONCURRENCY = 4 # §6.6
-_CONTENT_PREFIX = "image/"
-
-
-class FetchBlocked(Exception):
- """SSRF guard or bounds refusal — maps to image status 'failed' with reason."""
-
-
-def _guard_host(url: str, allow_private: bool) -> None:
- parts = urlsplit(url)
- if parts.scheme not in {"http", "https"}:
- raise FetchBlocked(f"unsupported scheme: {parts.scheme!r}")
- host = parts.hostname
- if not host:
- raise FetchBlocked("no host")
- if allow_private:
- return
- try:
- infos = socket.getaddrinfo(host, parts.port or (443 if parts.scheme == "https" else 80))
- except socket.gaierror as exc:
- raise FetchBlocked(f"dns failure: {exc}") from exc
- for info in infos:
- ip = ipaddress.ip_address(info[4][0])
- if (ip.is_private or ip.is_loopback or ip.is_link_local
- or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
- raise FetchBlocked(f"non-public address: {ip}")
-
-
-def fetch_bytes(url: str, allow_private: bool) -> tuple[bytes, str]:
- """SSRF-guarded, bounded GET. Returns (bytes, content_type). Raises FetchBlocked."""
- _guard_host(url, allow_private)
- with httpx.Client(follow_redirects=False, timeout=FETCH_TIMEOUT_S) as client:
- with client.stream("GET", url) as resp:
- if resp.status_code != 200:
- raise FetchBlocked(f"http {resp.status_code}")
- ctype = resp.headers.get("content-type", "").split(";")[0].strip().lower()
- if not ctype.startswith(_CONTENT_PREFIX):
- raise FetchBlocked(f"not an image content-type: {ctype!r}")
- chunks: list[bytes] = []
- total = 0
- for chunk in resp.iter_bytes():
- total += len(chunk)
- if total > MAX_FETCH_BYTES:
- raise FetchBlocked("oversize")
- chunks.append(chunk)
- return b"".join(chunks), ctype
-
-
-def _key(storefront_id: int, image_id: int, name: str) -> str:
- return f"storefronts/{storefront_id}/product-images/{image_id}/{name}"
-
-
-def _process_one(pool, store, image: dict, allow_private: bool) -> None:
- with pool.connection() as conn:
- claimed = repo.claim_image_for_fetch(conn, image["id"])
- conn.commit()
- if not claimed:
- return
- image_id, sf = image["id"], image["storefront_id"]
- try:
- data, ctype = fetch_bytes(image["source_url"], allow_private)
- except (FetchBlocked, httpx.HTTPError) as exc:
- with pool.connection() as conn:
- repo.mark_image_failed(conn, image_id, str(exc)[:200]); conn.commit()
- return
- try:
- result = images_mod.process(data)
- if isinstance(result, images_mod.Rejected):
- reason = ("below the resolution bar" if result.reason == "rejected_low_res"
- else "not a supported image")
- with pool.connection() as conn:
- repo.mark_image_rejected(conn, image_id, result.reason, reason); conn.commit()
- return
- keys = {"original": _key(sf, image_id, "original"),
- "thumb": _key(sf, image_id, "thumb.webp"),
- "card": _key(sf, image_id, "card.webp"),
- "detail": _key(sf, image_id, "detail.webp")}
- store.put(keys["original"], data, ctype)
- store.put(keys["thumb"], result.renditions["thumb"], "image/webp")
- store.put(keys["card"], result.renditions["card"], "image/webp")
- store.put(keys["detail"], result.renditions["detail"], "image/webp")
- with pool.connection() as conn:
- repo.mark_image_fetched(conn, image_id, keys); conn.commit()
- except Exception as exc: # noqa: BLE001 — one bad image must never wedge the run (review fix)
- with pool.connection() as conn:
- repo.mark_image_failed(conn, image_id, f"processing error: {type(exc).__name__}")
- conn.commit()
-
-
-def run_image_phase(pool, store, run_id: int, allow_private: bool) -> None:
- """Fetch all pending images of one run, then move the run to a terminal status."""
- started = time.monotonic()
- with pool.connection() as conn:
- pending = repo.pending_images_for_run(conn, run_id)
- if pending:
- with ThreadPoolExecutor(max_workers=FETCH_CONCURRENCY) as pool_x:
- list(pool_x.map(lambda img: _process_one(pool, store, img, allow_private), pending))
- with pool.connection() as conn:
- counts = repo.run_image_counts(conn, run_id)
- status = ("complete_with_problems"
- if counts["rejected"] + counts["failed"] > 0 else "complete")
- repo.set_run_status(conn, run_id, status)
- conn.commit()
- telemetry.emit("image_phase_completed", run_id=run_id, fetched=counts["fetched"],
- rejected=counts["rejected"], failed=counts["failed"],
- duration_ms=int((time.monotonic() - started) * 1000))
-
-
-def recover_incomplete_runs(pool, store, allow_private: bool) -> list[int]:
- """Startup scan (§6.9): resume any run stuck in fetching_images. Returns the ids."""
- with pool.connection() as conn:
- runs = repo.incomplete_runs(conn)
- resumed: list[int] = []
- for run in runs:
- with pool.connection() as conn:
- pending = len(repo.pending_images_for_run(conn, run["id"]))
- telemetry.emit("image_phase_recovered", run_id=run["id"], pending_resumed=pending)
- run_image_phase(pool, store, run["id"], allow_private)
- resumed.append(run["id"])
- return resumed
diff --git a/backend/app/domains/products/repo.py b/backend/app/domains/products/repo.py
deleted file mode 100644
index 04bef57..0000000
--- a/backend/app/domains/products/repo.py
+++ /dev/null
@@ -1,616 +0,0 @@
-"""products repo — the SQL layer for the import spine (SD-0002 §6.3 data model).
-
-Owns SQL only: the catalog snapshot the diff engine reads, import draft/run
-CRUD, and the apply primitives the confirm transaction calls. Business rules
-live in service.py and diff.py — nothing here validates, diffs, commits, or
-rolls back (the confirm flow runs the apply primitives inside its own
-transaction). Every catalog/draft/run query is storefront-scoped (INV-14).
-
-Dict payload conventions: functions feeding §6.4 API payloads (insert_draft,
-list_runs, get_run) return datetimes as `.isoformat()` strings; get_draft_row
-returns raw datetimes for the service's expiry check. TEXT[] columns bind/load
-as Python lists and NUMERIC loads as Decimal natively under psycopg 3.
-"""
-from __future__ import annotations
-
-import psycopg
-from psycopg import sql
-from psycopg.types.json import Jsonb
-
-from .diff import CatalogImage, CatalogProduct, CatalogVariant
-
-# ---------------------------------------------------------------------------
-# Catalog snapshot (diff input) + dashboard counts
-# ---------------------------------------------------------------------------
-
-
-def load_catalog(conn: psycopg.Connection, storefront_id: int) -> dict[str, CatalogProduct]:
- """The storefront's full catalog, keyed by handle, in diff.py's snapshot shape."""
- catalog: dict[str, CatalogProduct] = {}
- by_id: dict[int, CatalogProduct] = {}
- for row in conn.execute(
- "SELECT id, handle, title, description_html, vendor, product_type,"
- " google_product_category, tags, status, published,"
- " option1_name, option2_name, option3_name"
- " FROM product WHERE storefront_id = %s",
- (storefront_id,),
- ):
- product = CatalogProduct(
- id=row[0],
- handle=row[1],
- title=row[2],
- option_names=(row[10], row[11], row[12]),
- fields={
- "title": row[2],
- "description_html": row[3],
- "vendor": row[4],
- "product_type": row[5],
- "google_product_category": row[6],
- "tags": row[7],
- "status": row[8],
- "published": row[9],
- },
- variants=[],
- images=[],
- )
- catalog[product.handle] = product
- by_id[product.id] = product
- for row in conn.execute(
- "SELECT v.product_id, v.id, v.position,"
- " v.option1_value, v.option2_value, v.option3_value,"
- " v.sku, v.barcode, v.price, v.cost, v.weight, v.weight_unit,"
- " v.volume, v.volume_unit, v.tax_id_1, v.tax_id_2,"
- " v.inventory_tracker, v.inventory_qty, i.source_url"
- " FROM variant v"
- " JOIN product p ON p.id = v.product_id"
- " LEFT JOIN product_image i ON i.id = v.image_id"
- " WHERE p.storefront_id = %s"
- " ORDER BY v.product_id, v.position, v.id",
- (storefront_id,),
- ):
- by_id[row[0]].variants.append(
- CatalogVariant(
- id=row[1],
- options=(row[3], row[4], row[5]),
- position=row[2],
- fields={
- "sku": row[6],
- "barcode": row[7],
- "price": row[8],
- "cost": row[9],
- "weight": row[10],
- "weight_unit": row[11],
- "volume": row[12],
- "volume_unit": row[13],
- "tax_id_1": row[14],
- "tax_id_2": row[15],
- "inventory_tracker": row[16],
- "inventory_qty": row[17],
- "variant_image": row[18],
- },
- )
- )
- for row in conn.execute(
- "SELECT i.product_id, i.id, i.source_url, i.position, i.alt_text, i.status"
- " FROM product_image i"
- " JOIN product p ON p.id = i.product_id"
- " WHERE p.storefront_id = %s"
- " ORDER BY i.product_id, i.position, i.id",
- (storefront_id,),
- ):
- by_id[row[0]].images.append(
- CatalogImage(id=row[1], source_url=row[2], position=row[3],
- alt_text=row[4], status=row[5])
- )
- return catalog
-
-
-_EXPORT_STATUSES = ("all", "active", "draft", "archived")
-
-
-def export_catalog(
- conn: psycopg.Connection, storefront_id: int, status_filter: str
-) -> list[CatalogProduct]:
- """The storefront's catalog as an ordered snapshot list, optionally filtered
- by product status (PUC-9). Reuses load_catalog's snapshot builder; the
- catalog fits in memory (≤5k rows, INV-18). Ordered by handle for a stable,
- deterministic export."""
- catalog = load_catalog(conn, storefront_id)
- products = sorted(catalog.values(), key=lambda p: p.handle)
- if status_filter and status_filter != "all":
- products = [p for p in products if p.fields.get("status") == status_filter]
- return products
-
-
-def product_count(conn: psycopg.Connection, storefront_id: int) -> int:
- return conn.execute(
- "SELECT count(*) FROM product WHERE storefront_id = %s", (storefront_id,)
- ).fetchone()[0]
-
-
-def image_problem_count(conn: psycopg.Connection, storefront_id: int) -> int:
- return conn.execute(
- "SELECT count(*) FROM product_image i"
- " JOIN product p ON p.id = i.product_id"
- " WHERE p.storefront_id = %s"
- " AND i.status IN ('rejected_low_res', 'rejected_not_image', 'failed')",
- (storefront_id,),
- ).fetchone()[0]
-
-
-def latest_run_id(conn: psycopg.Connection, storefront_id: int) -> int | None:
- row = conn.execute(
- "SELECT id FROM import_run WHERE storefront_id = %s"
- " ORDER BY created_at DESC, id DESC LIMIT 1",
- (storefront_id,),
- ).fetchone()
- return row[0] if row else None
-
-
-# ---------------------------------------------------------------------------
-# Import drafts (preview server side, INV-11)
-# ---------------------------------------------------------------------------
-
-
-def insert_draft(
- conn: psycopg.Connection,
- storefront_id: int,
- account_id: int,
- file_name: str,
- dialect: str,
- file_bytes: bytes,
- summary: dict,
- records: list,
- fingerprint: str,
- unknown_columns: list[str],
-) -> dict:
- """Create a draft (expires in 1 hour); returns the §6.4 draft payload."""
- row = conn.execute(
- "INSERT INTO import_draft"
- " (storefront_id, account_id, file_name, dialect, file_bytes,"
- " summary, records, fingerprint, unknown_columns, expires_at)"
- " VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, now() + interval '1 hour')"
- " RETURNING id, expires_at",
- (
- storefront_id,
- account_id,
- file_name,
- dialect,
- file_bytes,
- Jsonb(summary),
- Jsonb(records),
- fingerprint,
- unknown_columns,
- ),
- ).fetchone()
- return {
- "id": row[0],
- "file_name": file_name,
- "dialect": dialect,
- "summary": summary,
- "unknown_columns": unknown_columns,
- "expires_at": row[1].isoformat(),
- }
-
-
-def get_draft_row(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> dict | None:
- row = conn.execute(
- "SELECT id, storefront_id, account_id, file_name, dialect, file_bytes,"
- " summary, records, fingerprint, unknown_columns, expires_at, created_at"
- " FROM import_draft WHERE id = %s AND storefront_id = %s",
- (draft_id, storefront_id),
- ).fetchone()
- if row is None:
- return None
- columns = (
- "id",
- "storefront_id",
- "account_id",
- "file_name",
- "dialect",
- "file_bytes",
- "summary",
- "records",
- "fingerprint",
- "unknown_columns",
- "expires_at",
- "created_at",
- )
- record = dict(zip(columns, row))
- # BYTEA loads as memoryview; the service expects bytes.
- record["file_bytes"] = bytes(record["file_bytes"])
- return record
-
-
-def draft_records(
- conn: psycopg.Connection,
- storefront_id: int,
- draft_id: int,
- kind: str | None,
- limit: int,
- offset: int,
-) -> list[dict]:
- """The draft's preview records, order-preserving, optionally filtered by kind."""
- rows = conn.execute(
- "SELECT rec FROM import_draft d,"
- " jsonb_array_elements(d.records) WITH ORDINALITY AS r(rec, ord)"
- " WHERE d.id = %(draft_id)s AND d.storefront_id = %(storefront_id)s"
- " AND (%(kind)s::text IS NULL OR rec->>'kind' = %(kind)s)"
- " ORDER BY ord LIMIT %(limit)s OFFSET %(offset)s",
- {
- "draft_id": draft_id,
- "storefront_id": storefront_id,
- "kind": kind,
- "limit": limit,
- "offset": offset,
- },
- ).fetchall()
- return [row[0] for row in rows]
-
-
-def delete_draft(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> None:
- conn.execute(
- "DELETE FROM import_draft WHERE id = %s AND storefront_id = %s",
- (draft_id, storefront_id),
- )
-
-
-def sweep_expired_drafts(conn: psycopg.Connection) -> None:
- conn.execute("DELETE FROM import_draft WHERE expires_at < now()")
-
-
-# ---------------------------------------------------------------------------
-# Import runs (history, PUC-8)
-# ---------------------------------------------------------------------------
-
-_TERMINAL_RUN_STATUSES = ("complete", "complete_with_problems")
-
-
-def insert_run(
- conn: psycopg.Connection,
- storefront_id: int,
- account_id: int,
- file_name: str,
- dialect: str,
- added: int,
- updated: int,
- errored: int,
- status: str,
-) -> int:
- return conn.execute(
- "INSERT INTO import_run"
- " (storefront_id, account_id, file_name, dialect,"
- " products_added, products_updated, rows_errored, status, completed_at)"
- " VALUES (%s, %s, %s, %s, %s, %s, %s, %s, CASE WHEN %s THEN now() END)"
- " RETURNING id",
- (
- storefront_id,
- account_id,
- file_name,
- dialect,
- added,
- updated,
- errored,
- status,
- status in _TERMINAL_RUN_STATUSES,
- ),
- ).fetchone()[0]
-
-
-def insert_run_errors(conn: psycopg.Connection, run_id: int, errors: list[dict]) -> None:
- """Record per-row errors (RowError.as_json shape: line/column/message)."""
- if not errors:
- return
- with conn.cursor() as cur:
- cur.executemany(
- "INSERT INTO import_run_error (run_id, line_number, column_name, message)"
- " VALUES (%s, %s, %s, %s)",
- [(run_id, e["line"], e["column"], e["message"]) for e in errors],
- )
-
-
-_RUN_SELECT = (
- "SELECT r.id, r.file_name, r.dialect, r.created_at, r.completed_at, r.status,"
- " a.email, r.products_added, r.products_updated, r.rows_errored"
- " FROM import_run r JOIN account a ON a.id = r.account_id"
-)
-
-
-def _run_dict(row: tuple) -> dict:
- return {
- "id": row[0],
- "file_name": row[1],
- "dialect": row[2],
- "created_at": row[3].isoformat(),
- "completed_at": row[4].isoformat() if row[4] is not None else None,
- "status": row[5],
- "by": row[6],
- "products_added": row[7],
- "products_updated": row[8],
- "rows_errored": row[9],
- }
-
-
-def list_runs(conn: psycopg.Connection, storefront_id: int, limit: int, offset: int) -> list[dict]:
- rows = conn.execute(
- _RUN_SELECT + " WHERE r.storefront_id = %s ORDER BY r.created_at DESC, r.id DESC"
- " LIMIT %s OFFSET %s",
- (storefront_id, limit, offset),
- ).fetchall()
- runs = [_run_dict(row) for row in rows]
- if not runs:
- return runs
- ids = [r["id"] for r in runs]
- by_run = {rid: {"fetched": 0, "rejected": 0, "failed": 0, "total": 0} for rid in ids}
- for rid, fetched, rejected, failed, total in conn.execute(
- "SELECT import_run_id,"
- " count(*) FILTER (WHERE status='fetched'),"
- " count(*) FILTER (WHERE status IN ('rejected_low_res','rejected_not_image')),"
- " count(*) FILTER (WHERE status='failed'), count(*)"
- " FROM product_image WHERE import_run_id = ANY(%s) GROUP BY import_run_id",
- (ids,),
- ):
- by_run[rid] = {"fetched": fetched, "rejected": rejected, "failed": failed, "total": total}
- for r in runs:
- r["image_counts"] = by_run[r["id"]]
- return runs
-
-
-def get_run(conn: psycopg.Connection, storefront_id: int, run_id: int) -> dict | None:
- row = conn.execute(
- _RUN_SELECT + " WHERE r.storefront_id = %s AND r.id = %s",
- (storefront_id, run_id),
- ).fetchone()
- if row is None:
- return None
- run = _run_dict(row)
- run["errors"] = [
- {"line": line, "column": column, "message": message}
- for line, column, message in conn.execute(
- "SELECT line_number, column_name, message FROM import_run_error"
- " WHERE run_id = %s ORDER BY line_number, id",
- (run_id,),
- )
- ]
- counts = run_image_counts(conn, run_id)
- run["image_progress"] = {"done": counts["total"] - counts["pending"], "total": counts["total"]}
- run["image_counts"] = {"fetched": counts["fetched"], "rejected": counts["rejected"],
- "failed": counts["failed"]}
- run["image_outcomes"] = run_image_outcomes(conn, run_id)
- return run
-
-
-# ---------------------------------------------------------------------------
-# Image fetch phase (SLICE-7, SD-0002 §6.5.4) — claim/mark/count/outcomes.
-# ---------------------------------------------------------------------------
-
-
-def pending_images_for_run(conn: psycopg.Connection, run_id: int) -> list[dict]:
- rows = conn.execute(
- "SELECT i.id, i.source_url, p.storefront_id, i.product_id"
- " FROM product_image i JOIN product p ON p.id = i.product_id"
- " WHERE i.import_run_id = %s AND i.status = 'pending' ORDER BY i.id",
- (run_id,),
- ).fetchall()
- return [{"id": r[0], "source_url": r[1], "storefront_id": r[2], "product_id": r[3]} for r in rows]
-
-
-def claim_image_for_fetch(conn: psycopg.Connection, image_id: int) -> bool:
- row = conn.execute(
- "UPDATE product_image SET status = 'pending' WHERE id = %s AND status = 'pending' RETURNING id",
- (image_id,),
- ).fetchone()
- return row is not None
-
-
-def mark_image_fetched(conn: psycopg.Connection, image_id: int, keys: dict[str, str]) -> None:
- conn.execute(
- "UPDATE product_image SET status = 'fetched', failure_reason = NULL,"
- " key_original = %s, key_thumb = %s, key_card = %s, key_detail = %s,"
- " fetched_at = now() WHERE id = %s",
- (keys["original"], keys["thumb"], keys["card"], keys["detail"], image_id),
- )
-
-
-def mark_image_rejected(conn: psycopg.Connection, image_id: int, status: str, reason: str) -> None:
- conn.execute(
- "UPDATE product_image SET status = %s, failure_reason = %s, fetched_at = now() WHERE id = %s",
- (status, reason, image_id),
- )
-
-
-def mark_image_failed(conn: psycopg.Connection, image_id: int, reason: str) -> None:
- conn.execute(
- "UPDATE product_image SET status = 'failed', failure_reason = %s, fetched_at = now() WHERE id = %s",
- (reason, image_id),
- )
-
-
-def run_image_counts(conn: psycopg.Connection, run_id: int) -> dict:
- row = conn.execute(
- "SELECT count(*) FILTER (WHERE status = 'fetched'),"
- " count(*) FILTER (WHERE status IN ('rejected_low_res', 'rejected_not_image')),"
- " count(*) FILTER (WHERE status = 'failed'),"
- " count(*) FILTER (WHERE status = 'pending'), count(*)"
- " FROM product_image WHERE import_run_id = %s",
- (run_id,),
- ).fetchone()
- return {"fetched": row[0], "rejected": row[1], "failed": row[2], "pending": row[3], "total": row[4]}
-
-
-def set_run_status(conn: psycopg.Connection, run_id: int, status: str) -> None:
- conn.execute(
- "UPDATE import_run SET status = %s,"
- " completed_at = CASE WHEN %s THEN now() ELSE completed_at END WHERE id = %s",
- (status, status in _TERMINAL_RUN_STATUSES, run_id),
- )
-
-
-def incomplete_runs(conn: psycopg.Connection) -> list[dict]:
- rows = conn.execute(
- "SELECT id, storefront_id FROM import_run WHERE status = 'fetching_images' ORDER BY id",
- ).fetchall()
- return [{"id": r[0], "storefront_id": r[1]} for r in rows]
-
-
-def image_for_serving(conn: psycopg.Connection, storefront_id: int, image_id: int) -> dict | None:
- """Storefront-scoped lookup for the serving route: status + rendition keys (INV-14)."""
- row = conn.execute(
- "SELECT i.status, i.key_original, i.key_thumb, i.key_card, i.key_detail"
- " FROM product_image i JOIN product p ON p.id = i.product_id"
- " WHERE i.id = %s AND p.storefront_id = %s",
- (image_id, storefront_id),
- ).fetchone()
- if row is None:
- return None
- return {"status": row[0], "original": row[1], "thumb": row[2], "card": row[3], "detail": row[4]}
-
-
-def run_image_outcomes(conn: psycopg.Connection, run_id: int) -> list[dict]:
- rows = conn.execute(
- "SELECT p.handle, i.source_url, i.status, i.failure_reason,"
- " v.option1_value, v.option2_value, v.option3_value"
- " FROM product_image i JOIN product p ON p.id = i.product_id"
- " LEFT JOIN variant v ON v.image_id = i.id"
- " WHERE i.import_run_id = %s"
- " AND i.status IN ('rejected_low_res', 'rejected_not_image', 'failed')"
- " ORDER BY p.handle, i.position, i.id",
- (run_id,),
- ).fetchall()
- return [
- {"handle": r[0], "url": r[1], "outcome": r[2], "reason": r[3],
- "variant": " / ".join(x for x in (r[4], r[5], r[6]) if x) or None}
- for r in rows
- ]
-
-
-# ---------------------------------------------------------------------------
-# Apply primitives — called inside the confirm transaction (Task 8); no commits.
-# ---------------------------------------------------------------------------
-
-
-def insert_product(
- conn: psycopg.Connection,
- storefront_id: int,
- handle: str,
- resolved_fields: dict,
- option_names: tuple[str | None, str | None, str | None],
-) -> int:
- """INSERT with only the file-present fields; absent ones take column defaults."""
- columns = ["storefront_id", "handle", "option1_name", "option2_name", "option3_name"]
- values: list[object] = [storefront_id, handle, *option_names]
- for field_name, value in resolved_fields.items():
- columns.append(field_name)
- values.append(value)
- query = sql.SQL("INSERT INTO product ({}) VALUES ({}) RETURNING id").format(
- sql.SQL(", ").join(sql.Identifier(c) for c in columns),
- sql.SQL(", ").join(sql.Placeholder() for _ in columns),
- )
- return conn.execute(query, values).fetchone()[0]
-
-
-def update_product(conn: psycopg.Connection, product_id: int, changed_fields: dict) -> None:
- if not changed_fields:
- return
- assignments = [
- sql.SQL("{} = {}").format(sql.Identifier(f), sql.Placeholder())
- for f in changed_fields
- ]
- query = sql.SQL("UPDATE product SET {}, updated_at = now() WHERE id = {}").format(
- sql.SQL(", ").join(assignments), sql.Placeholder()
- )
- conn.execute(query, [*changed_fields.values(), product_id])
-
-
-def insert_variant(
- conn: psycopg.Connection,
- product_id: int,
- position: int,
- options: tuple[str | None, str | None, str | None],
- resolved_fields: dict,
- image_id: int | None,
-) -> int:
- # variant_image is not a column — the caller translates it to image_id; position
- # is the explicit param. Filter both defensively.
- fields = {
- k: v for k, v in resolved_fields.items() if k != "variant_image" and k != "position"
- }
- columns = [
- "product_id",
- "position",
- "option1_value",
- "option2_value",
- "option3_value",
- "image_id",
- ]
- values: list[object] = [product_id, position, *options, image_id]
- for field_name, value in fields.items():
- columns.append(field_name)
- values.append(value)
- query = sql.SQL("INSERT INTO variant ({}) VALUES ({}) RETURNING id").format(
- sql.SQL(", ").join(sql.Identifier(c) for c in columns),
- sql.SQL(", ").join(sql.Placeholder() for _ in columns),
- )
- return conn.execute(query, values).fetchone()[0]
-
-
-def update_variant(
- conn: psycopg.Connection,
- variant_id: int,
- changed_fields: dict,
- image_id: int | None | type(...) = ...,
-) -> None:
- """Dynamic UPDATE; image_id's Ellipsis default means "don't touch image_id"."""
- fields = {
- k: v for k, v in changed_fields.items() if k != "variant_image"
- }
- assignments = [
- sql.SQL("{} = {}").format(sql.Identifier(f), sql.Placeholder()) for f in fields
- ]
- values: list[object] = list(fields.values())
- if image_id is not ...:
- assignments.append(sql.SQL("image_id = {}").format(sql.Placeholder()))
- values.append(image_id)
- if not assignments:
- return
- query = sql.SQL("UPDATE variant SET {}, updated_at = now() WHERE id = {}").format(
- sql.SQL(", ").join(assignments), sql.Placeholder()
- )
- conn.execute(query, [*values, variant_id])
-
-
-def get_or_create_image(
- conn: psycopg.Connection,
- product_id: int,
- source_url: str,
- position: int,
- alt_text: str | None,
- run_id: int,
-) -> int:
- """Image identity within a product is source_url (§6.3); existing rows are
- returned untouched — diff emits explicit image update entries for position/alt."""
- row = conn.execute(
- "SELECT id FROM product_image WHERE product_id = %s AND source_url = %s",
- (product_id, source_url),
- ).fetchone()
- if row is not None:
- return row[0]
- return conn.execute(
- "INSERT INTO product_image (product_id, source_url, position, alt_text, import_run_id)"
- " VALUES (%s, %s, %s, %s, %s) RETURNING id",
- (product_id, source_url, position, alt_text, run_id),
- ).fetchone()[0]
-
-
-def update_image(conn: psycopg.Connection, image_id: int, changed_fields: dict) -> None:
- """Subset of {position, alt_text}."""
- if not changed_fields:
- return
- assignments = [
- sql.SQL("{} = {}").format(sql.Identifier(f), sql.Placeholder())
- for f in changed_fields
- ]
- query = sql.SQL("UPDATE product_image SET {} WHERE id = {}").format(
- sql.SQL(", ").join(assignments), sql.Placeholder()
- )
- conn.execute(query, [*changed_fields.values(), image_id])
diff --git a/backend/app/domains/products/service.py b/backend/app/domains/products/service.py
deleted file mode 100644
index b2dfa6c..0000000
--- a/backend/app/domains/products/service.py
+++ /dev/null
@@ -1,265 +0,0 @@
-"""products service — the import/export use-case orchestration (SD-0002 §6.5).
-
-Coordinates codec → validate → diff → repo; owns transaction boundaries (repo
-never commits). Preview is read-only against catalog tables (INV-11): validation
-writes exactly one row — the import_draft. TEL events per §9.1.
-"""
-from __future__ import annotations
-
-import time
-from collections.abc import Iterator
-from datetime import datetime, timezone
-
-import psycopg
-
-from app.platform import config, telemetry
-
-from . import codec, diff, repo, serialize, validate
-from .errors import (
- DraftExpired,
- DraftNotFound,
- EmptyCatalog,
- NothingToApply,
- PreviewStale,
- RunNotFound,
-)
-
-
-def import_validate(conn: psycopg.Connection, storefront_id: int, account_id: int,
- file_name: str, data: bytes) -> dict:
- """Upload → validate → diff → persist draft (PUC-2/3; INV-11). Raises FileRejected."""
- started = time.monotonic()
- # Commit the sweep before parsing: a FileRejected mid-parse must not roll
- # back expired-draft cleanup along with it.
- repo.sweep_expired_drafts(conn)
- conn.commit()
- parsed = codec.parse_csv(data)
- products = validate.build_products(parsed)
- catalog = repo.load_catalog(conn, storefront_id)
- diff_result = diff.compute_diff(catalog, products)
- draft = repo.insert_draft(
- conn, storefront_id, account_id, file_name, parsed.dialect, data,
- diff_result.summary, diff_result.records, diff_result.fingerprint,
- parsed.unknown_columns,
- )
- conn.commit()
- telemetry.emit(
- "import_draft_created",
- storefront_id=storefront_id,
- dialect=parsed.dialect,
- row_count=len(parsed.rows),
- adds=diff_result.summary["adds"],
- updates=diff_result.summary["updates"],
- unchanged=diff_result.summary["unchanged"],
- errors=diff_result.summary["errors"],
- unknown_columns_count=len(parsed.unknown_columns),
- duration_ms=int((time.monotonic() - started) * 1000),
- )
- return draft
-
-
-def _live_draft_row(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> dict:
- """The draft row if it exists and hasn't expired; expiry deletes lazily (§6.3)."""
- row = repo.get_draft_row(conn, storefront_id, draft_id)
- if row is None:
- raise DraftNotFound()
- if row["expires_at"] < datetime.now(timezone.utc):
- repo.delete_draft(conn, storefront_id, draft_id)
- conn.commit()
- raise DraftExpired()
- return row
-
-
-def get_draft(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> dict:
- """The §6.4 draft payload — never file_bytes or the full records list."""
- row = _live_draft_row(conn, storefront_id, draft_id)
- return {
- "id": row["id"],
- "file_name": row["file_name"],
- "dialect": row["dialect"],
- "summary": row["summary"],
- "unknown_columns": row["unknown_columns"],
- "expires_at": row["expires_at"].isoformat(),
- }
-
-
-def get_draft_records(conn: psycopg.Connection, storefront_id: int, draft_id: int,
- kind: str | None = None, limit: int = 100, offset: int = 0) -> list[dict]:
- """The draft's preview records, paged, optionally filtered by kind (PUC-3)."""
- _live_draft_row(conn, storefront_id, draft_id)
- return repo.draft_records(conn, storefront_id, draft_id, kind, limit, offset)
-
-
-def discard_draft(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> None:
- """Delete the draft, no trace kept; idempotent — an absent draft is fine (PUC-3a)."""
- repo.delete_draft(conn, storefront_id, draft_id)
- conn.commit()
-
-
-def confirm_draft(conn: psycopg.Connection, storefront_id: int, account_id: int,
- draft_id: int) -> int:
- """Apply the previewed diff in one transaction (PUC-4; INV-10/11).
-
- Everything is re-derived from the draft's stored file bytes against the live
- catalog; a fingerprint mismatch means the catalog drifted since preview
- (PreviewStale — the draft is kept so the merchant can re-validate). The apply
- executes the typed plan compute_diff built alongside the preview records, so
- what lands is exactly what the preview showed. rows_errored counts the
- import_run_error rows recorded (one per RowError), which is what the run
- detail's error table shows; the preview's errors tile counts error *products*.
- """
- started = time.monotonic()
- row = _live_draft_row(conn, storefront_id, draft_id)
- parsed = codec.parse_csv(row["file_bytes"])
- products = validate.build_products(parsed)
- catalog = repo.load_catalog(conn, storefront_id)
- diff_result = diff.compute_diff(catalog, products)
- if diff_result.fingerprint != row["fingerprint"]:
- # Release the read snapshot; nothing written.
- conn.rollback()
- raise PreviewStale()
- summary_counts = diff_result.summary
- if summary_counts["adds"] + summary_counts["updates"] == 0:
- # Release the read snapshot; nothing written.
- conn.rollback()
- raise NothingToApply()
- error_rows = [
- error.as_json()
- for plan in diff_result.plan if plan.kind == "error"
- for error in plan.canonical.errors
- ]
- try:
- run_id = repo.insert_run(
- conn, storefront_id, account_id, row["file_name"], row["dialect"],
- added=summary_counts["adds"], updated=summary_counts["updates"],
- errored=len(error_rows), status="applying",
- )
- for plan in diff_result.plan:
- _apply_product_plan(conn, storefront_id, plan, run_id)
- repo.insert_run_errors(conn, run_id, error_rows)
- pending = repo.run_image_counts(conn, run_id)["pending"]
- repo.set_run_status(conn, run_id, "fetching_images" if pending else "complete")
- repo.delete_draft(conn, storefront_id, draft_id)
- conn.commit()
- except Exception as exc:
- conn.rollback()
- telemetry.emit(
- "import_apply_failed",
- draft_id=draft_id,
- storefront_id=storefront_id,
- error_class=type(exc).__name__,
- )
- raise
- telemetry.emit(
- "import_run_completed",
- run_id=run_id,
- storefront_id=storefront_id,
- added=summary_counts["adds"],
- updated=summary_counts["updates"],
- errored=len(error_rows),
- duration_ms=int((time.monotonic() - started) * 1000),
- )
- return run_id
-
-
-def _apply_product_plan(conn: psycopg.Connection, storefront_id: int,
- plan: diff.ProductPlan, run_id: int) -> None:
- """Execute one product's plan inside the confirm transaction (no commits here)."""
- if plan.kind == "add":
- # Title is a canonical attribute, not a fields{} entry — non-error
- # products always carry one (validate guarantees it).
- product_fields = {"title": plan.canonical.title}
- product_fields.update(diff.resolved_product_fields(plan.canonical))
- product_id = repo.insert_product(
- conn, storefront_id, plan.canonical.handle, product_fields, plan.canonical.option_names
- )
- image_ids: dict[str, int] = {}
- elif plan.kind == "update":
- product_id = plan.catalog.id
- repo.update_product(conn, product_id, plan.product_changes)
- image_ids = {image.source_url: image.id for image in plan.catalog.images}
- else:
- return
-
- # Images first, so variants' variant_image URLs resolve to ids: validate puts
- # every variant_image URL into canonical.images, so each URL is in either the
- # catalog map (existing image) or the adds below.
- for image_plan in plan.image_plans:
- if image_plan.kind == "add":
- image_ids[image_plan.source_url] = repo.get_or_create_image(
- conn, product_id, image_plan.source_url, image_plan.position,
- image_plan.alt_text, run_id,
- )
- else:
- repo.update_image(conn, image_plan.image_id, image_plan.changes)
-
- for variant_plan in plan.variant_plans:
- if variant_plan.kind == "add":
- fields = diff.resolved_variant_fields(variant_plan.canonical, variant_plan.file_order)
- # diff time resolved any cleared position to file order; the
- # file_order fallback covers an absent position column.
- position = fields.get("position") or variant_plan.file_order
- url = fields.get("variant_image")
- image_id = image_ids[url] if url else None
- repo.insert_variant(
- conn, product_id, position, variant_plan.canonical.options, fields, image_id
- )
- elif "variant_image" in variant_plan.changes:
- url = variant_plan.changes["variant_image"]
- repo.update_variant(
- conn, variant_plan.catalog_id, variant_plan.changes,
- image_id=image_ids[url] if url else None,
- )
- else:
- repo.update_variant(conn, variant_plan.catalog_id, variant_plan.changes)
-
-
-def list_runs(conn: psycopg.Connection, storefront_id: int,
- limit: int = 50, offset: int = 0) -> list[dict]:
- """The storefront's import history, newest first (PUC-8)."""
- return repo.list_runs(conn, storefront_id, limit, offset)
-
-
-def get_run(conn: psycopg.Connection, storefront_id: int, run_id: int) -> dict:
- """One run's §6.4 detail payload, errors included."""
- run = repo.get_run(conn, storefront_id, run_id)
- if run is None:
- raise RunNotFound()
- return run
-
-
-def export_catalog(
- conn: psycopg.Connection, storefront_id: int, status_filter: str
-) -> Iterator[str]:
- """Stream the storefront's catalog as canonical CSV (PUC-9; INV-12 codec).
-
- Read-only: builds the snapshot, then streams the serializer over it. TEL-3
- is emitted once the stream is exhausted, with the product count and elapsed
- time. Raises EmptyCatalog before yielding anything if the (filtered) catalog
- is empty, so the BFF can answer 409 cleanly with no partial body.
- """
- started = time.monotonic()
- snapshot = repo.export_catalog(conn, storefront_id, status_filter)
- if not snapshot:
- raise EmptyCatalog()
-
- def _stream() -> Iterator[str]:
- yield from serialize.catalog_to_csv(snapshot, base_url=config.public_base_url())
- telemetry.emit(
- "catalog_exported",
- storefront_id=storefront_id,
- status_filter=status_filter,
- product_count=len(snapshot),
- duration_ms=int((time.monotonic() - started) * 1000),
- )
-
- return _stream()
-
-
-def summary(conn: psycopg.Connection, storefront_id: int) -> dict:
- """The products dashboard counts (§6.4)."""
- return {
- "product_count": repo.product_count(conn, storefront_id),
- "image_problem_count": repo.image_problem_count(conn, storefront_id),
- "latest_run_id": repo.latest_run_id(conn, storefront_id),
- }
diff --git a/backend/app/domains/storefronts/__init__.py b/backend/app/domains/storefronts/__init__.py
deleted file mode 100644
index d2db7f1..0000000
--- a/backend/app/domains/storefronts/__init__.py
+++ /dev/null
@@ -1,19 +0,0 @@
-"""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",
-]
diff --git a/backend/app/domains/storefronts/errors.py b/backend/app/domains/storefronts/errors.py
deleted file mode 100644
index 699f1a7..0000000
--- a/backend/app/domains/storefronts/errors.py
+++ /dev/null
@@ -1,10 +0,0 @@
-"""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)."""
diff --git a/backend/app/domains/storefronts/models.py b/backend/app/domains/storefronts/models.py
deleted file mode 100644
index 4bedb2f..0000000
--- a/backend/app/domains/storefronts/models.py
+++ /dev/null
@@ -1,10 +0,0 @@
-"""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
diff --git a/backend/app/domains/storefronts/service.py b/backend/app/domains/storefronts/service.py
deleted file mode 100644
index 4249dc2..0000000
--- a/backend/app/domains/storefronts/service.py
+++ /dev/null
@@ -1,56 +0,0 @@
-"""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)
diff --git a/backend/app/main.py b/backend/app/main.py
index 99f6583..780298a 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,32 +1,31 @@
-"""ecomm backend — FastAPI app factory + the REST BFF.
+"""ecomm backend — FastAPI app factory + the network-service seed BFF.
-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. SLICE-3 adds POST /api/storefronts and feeds the
-_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.
+This is the pivot to a network-service seed: a minimal FastAPI surface carrying only
+/healthz and the /api/auth/* identity endpoints (§6.4), backed by the accounts domain.
+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 — auth needs the mailer) at startup.
+
+The catalog-normalization library (app.domains.products: codec/dialect/models/serialize/
+validate/diff) is kept as importable modules but has no HTTP surface yet — the storefront
+and products import/export/image spine was stripped in the network-seed reset.
"""
from __future__ import annotations
import logging
import sys
-from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
import psycopg
-from fastapi import Depends, FastAPI, File, Query, Response, UploadFile
-from fastapi import Path as ApiPath
-from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
-from fastapi.staticfiles import StaticFiles
+from fastapi import Depends, FastAPI, Response
+from fastapi.responses import JSONResponse
from pydantic import BaseModel
-from app.domains import accounts, products, storefronts
+from app.domains import accounts
from app.platform import config, db
from app.platform import mailer as mailer_mod
-from app.platform import objectstore as objectstore_mod
from app.platform.deps import SESSION_COOKIE, get_conn, get_mailer, get_session
from app.platform.mailer import Mailer
from app.platform import session as session_mod
@@ -50,41 +49,11 @@ 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)."""
- sf = storefronts.storefront_for(conn, account.id)
- 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).
@@ -114,7 +83,7 @@ def _set_session_cookie(response: Response, account: accounts.Account) -> None:
)
-def create_app(database_url: str | None = None, static_dir: str | Path | None = None) -> FastAPI:
+def create_app(database_url: str | None = None) -> FastAPI:
_ensure_app_logging()
dsn = database_url or config.database_url()
@@ -124,18 +93,9 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None =
with app.state.pool.connection() as conn:
db.migrate(conn) # self-migrate at startup (INV-1, INV-7)
app.state.mailer = mailer_mod.build_mailer(config.mailer_kind()) # INV-8
- app.state.objectstore = objectstore_mod.build_objectstore(config.objectstore_kind())
- app.state.image_allow_private = config.image_fetch_allow_private()
- app.state.image_runner = ThreadPoolExecutor(max_workers=2, thread_name_prefix="img-run")
- # §6.9 startup recovery — resume runs stuck mid image-fetch, off the request path.
- app.state.image_runner.submit(
- products.recover_incomplete_runs, app.state.pool,
- app.state.objectstore, app.state.image_allow_private,
- )
try:
yield
finally:
- app.state.image_runner.shutdown(wait=False)
app.state.pool.close()
app = FastAPI(title="ecomm", version=_APP_VERSION, lifespan=lifespan)
@@ -180,7 +140,7 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None =
@app.post("/api/auth/verify")
def verify(body: VerifyBody, conn: psycopg.Connection = Depends(get_conn)):
- """Verify a code, start a session, and answer entry routing (§6.4/§6.5)."""
+ """Verify a code and start a session (§6.4)."""
try:
account, created = accounts.verify(conn, body.email, body.code)
except accounts.InvalidEmail:
@@ -196,7 +156,6 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None =
return _error(400, "code_exhausted", "Too many attempts — request a fresh code.")
payload = {
"account": {"email": account.email},
- "storefront": _storefront_for(conn, account),
"created": created,
}
resp = JSONResponse(status_code=200, content=payload)
@@ -205,13 +164,13 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None =
@app.get("/api/auth/me")
def me(conn: psycopg.Connection = Depends(get_conn), sess: dict | None = Depends(get_session)):
- """The signed-in account + entry-routing answer, or 401 (§6.4/§6.5)."""
+ """The signed-in account, or 401 (§6.4)."""
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.")
- return {"account": {"email": account.email}, "storefront": _storefront_for(conn, account)}
+ return {"account": {"email": account.email}}
@app.post("/api/auth/logout")
def logout():
@@ -220,251 +179,6 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None =
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})
-
- @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.",
- )
- app.state.image_runner.submit(
- products.run_image_phase, app.state.pool, app.state.objectstore,
- run_id, app.state.image_allow_private,
- )
- 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/export")
- def export_products(
- status: str = Query(default="all", pattern="^(all|active|draft|archived)$"),
- conn: psycopg.Connection = Depends(get_conn),
- sess: dict | None = Depends(get_session),
- ):
- """Stream the catalog as canonical CSV, optionally status-filtered (§6.4; PUC-9)."""
- gate = _merchant_gate(conn, sess)
- if isinstance(gate, JSONResponse):
- return gate
- _account, sf = gate
- try:
- stream = products.export_catalog(conn, sf.id, status)
- except products.EmptyCatalog:
- return _error(409, "empty_catalog", "There are no products to export.")
- return StreamingResponse(
- stream,
- media_type="text/csv",
- headers={"content-disposition": 'attachment; filename="ecomm-products-export.csv"'},
- )
-
- _RENDITION_CT = {"thumb": "image/webp", "card": "image/webp",
- "detail": "image/webp", "original": "application/octet-stream"}
-
- @app.get("/api/products/images/{image_id}/{rendition}")
- def serve_product_image(
- image_id: int,
- rendition: str = ApiPath(pattern="^(original|thumb|card|detail)$"),
- conn: psycopg.Connection = Depends(get_conn),
- sess: dict | None = Depends(get_session),
- ):
- """Serve a hosted image rendition, storefront-authorized + immutable cache (§6.4, INV-16)."""
- gate = _merchant_gate(conn, sess)
- if isinstance(gate, JSONResponse):
- return gate
- _account, sf = gate
- rec = products.image_for_serving(conn, sf.id, image_id)
- if rec is None:
- return _error(404, "not_found", "No such image.")
- if rec["status"] != "fetched":
- return _error(409, "not_fetched", "This image has not been fetched yet.")
- key = rec[rendition]
- if not key:
- return _error(404, "not_found", "No such rendition.")
- try:
- data = app.state.objectstore.get(key)
- except objectstore_mod.ObjectNotFound:
- return _error(404, "not_found", "No such image.")
- return Response(content=data, media_type=_RENDITION_CT[rendition],
- headers={"Cache-Control": "public, max-age=31536000, immutable"})
-
- @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"'},
- )
-
- @app.get("/api/products/columns.md")
- def products_columns_md():
- """The DOC-2 column reference. Documentation, so no auth gate (§6.4)."""
- return PlainTextResponse(
- products.COLUMNS_MD_PATH.read_text(),
- media_type="text/markdown; charset=utf-8",
- )
-
- # 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.
- spa_dir = Path(static_dir) if static_dir is not None else _REPO_ROOT / "frontend" / "dist"
- if (spa_dir / "index.html").is_file():
- app.mount("/", StaticFiles(directory=spa_dir, html=True), name="spa")
-
return app
diff --git a/backend/tests/test_auth_endpoints.py b/backend/tests/test_auth_endpoints.py
index aaea47c..3432c29 100644
--- a/backend/tests/test_auth_endpoints.py
+++ b/backend/tests/test_auth_endpoints.py
@@ -57,7 +57,7 @@ def test_verify_sets_cookie_and_returns_shape(fresh_db_url):
resp = client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": code})
assert resp.status_code == 200
data = resp.json()
- assert data == {"account": {"email": "merchant@example.com"}, "storefront": None, "created": True}
+ assert data == {"account": {"email": "merchant@example.com"}, "created": True}
assert "ecomm_session" in resp.cookies
@@ -115,7 +115,7 @@ def test_me_returns_account_with_session(fresh_db_url):
client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": _last_code(client)})
resp = client.get("/api/auth/me") # TestClient carries the cookie set by verify
assert resp.status_code == 200
- assert resp.json() == {"account": {"email": "merchant@example.com"}, "storefront": None}
+ assert resp.json() == {"account": {"email": "merchant@example.com"}}
def test_puc_09_logout_clears_session(fresh_db_url):
diff --git a/backend/tests/test_bootstrap.py b/backend/tests/test_bootstrap.py
index ec1db53..d338f69 100644
--- a/backend/tests/test_bootstrap.py
+++ b/backend/tests/test_bootstrap.py
@@ -1,6 +1,7 @@
"""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."""
+surviving flow — request-code -> verify -> /me — asserting no step needed seeded state.
+The network-seed reset removed the create-storefront step from this flow. Migration
+idempotence (the second INV-1 test) lives in test_migrations.py."""
import re
from fastapi.testclient import TestClient
@@ -26,17 +27,9 @@ def test_inv_1_bootstrap_whole_flow_from_empty(fresh_db_url):
)
assert verified.status_code == 200
assert verified.json()["created"] is True
- assert verified.json()["storefront"] is None # -> create-storefront (PUC-5)
+ assert verified.json()["account"] == {"email": "first@example.com"}
- # 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
+ # the admin answer — the signed-in account from /me alone (cookie set by verify)
me = client.get("/api/auth/me")
assert me.status_code == 200
- assert me.json() == {
- "account": {"email": "first@example.com"},
- "storefront": created.json(),
- }
+ assert me.json() == {"account": {"email": "first@example.com"}}
diff --git a/backend/tests/test_products_endpoints.py b/backend/tests/test_products_endpoints.py
deleted file mode 100644
index 66de184..0000000
--- a/backend/tests/test_products_endpoints.py
+++ /dev/null
@@ -1,146 +0,0 @@
-"""§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
-
-
-def test_columns_md_served(fresh_db_url):
- """DOC-2 column reference is app-served, unauthenticated documentation (§6.4)."""
- with TestClient(create_app(database_url=fresh_db_url)) as client:
- resp = client.get("/api/products/columns.md")
- assert resp.status_code == 200
- assert "text/markdown" in resp.headers["content-type"]
- body = resp.text
- assert "Body (HTML)" in body # Shopify dialect notes present
- assert "`Handle`" in body # canonical columns present
-
-
-def test_export_returns_canonical_csv(fresh_db_url):
- with _merchant_client(fresh_db_url) as client:
- draft = _upload(client).json()
- client.post(f"/api/products/imports/drafts/{draft['id']}/confirm")
- resp = client.get("/api/products/export")
- assert resp.status_code == 200
- assert resp.headers["content-type"].startswith("text/csv")
- assert "attachment" in resp.headers["content-disposition"]
- body = resp.text
- assert body.splitlines()[0].startswith("Handle,")
- assert "moon-mug" in body
-
-
-def test_export_status_filter_respected(fresh_db_url):
- with _merchant_client(fresh_db_url) as client:
- # GOOD_CSV's moon-mug has no Status column → defaults to active.
- draft = _upload(client).json()
- client.post(f"/api/products/imports/drafts/{draft['id']}/confirm")
- assert "moon-mug" in client.get("/api/products/export?status=active").text
- # No archived products → 409.
- assert client.get("/api/products/export?status=archived").status_code == 409
-
-
-def test_export_empty_catalog_409(fresh_db_url):
- with _merchant_client(fresh_db_url) as client:
- resp = client.get("/api/products/export")
- assert resp.status_code == 409
- assert resp.json()["error"]["code"] == "empty_catalog"
-
-
-def test_export_requires_merchant(fresh_db_url):
- with TestClient(create_app(database_url=fresh_db_url)) as client:
- assert client.get("/api/products/export").status_code == 401
-
-
-def test_export_bad_status_422(fresh_db_url):
- with _merchant_client(fresh_db_url) as client:
- assert client.get("/api/products/export?status=bogus").status_code == 422
diff --git a/backend/tests/test_products_export.py b/backend/tests/test_products_export.py
deleted file mode 100644
index 959073b..0000000
--- a/backend/tests/test_products_export.py
+++ /dev/null
@@ -1,100 +0,0 @@
-"""Export: status-filtered catalog snapshot + the streamed service (PUC-9, TEL-3)."""
-import csv
-import io
-import json
-import logging
-
-import psycopg
-import pytest
-
-from app.domains import products
-from app.domains.products import repo
-from app.platform import db
-
-CSV = (
- b"Handle,Title,Vendor,Status,Variant Price\n"
- b"active-mug,Active Mug,Acme,active,18.00\n"
- b"draft-tee,Draft Tee,Acme,draft,24.00\n"
-)
-
-
-@pytest.fixture()
-def migrated_conn(fresh_db_url):
- with psycopg.connect(fresh_db_url) as conn:
- db.migrate(conn)
- yield conn
-
-
-@pytest.fixture()
-def merchant(migrated_conn):
- acct = migrated_conn.execute(
- "INSERT INTO account (email) VALUES ('m@example.com') RETURNING id").fetchone()[0]
- sf = migrated_conn.execute(
- "INSERT INTO storefront (name) VALUES ('Shop') RETURNING id").fetchone()[0]
- migrated_conn.execute(
- "INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf))
- migrated_conn.commit()
- return {"account_id": acct, "storefront_id": sf}
-
-
-def _seed(conn, merchant):
- draft = products.import_validate(conn, merchant["storefront_id"], merchant["account_id"], "c.csv", CSV)
- products.confirm_draft(conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
-
-
-def test_export_all_returns_both(migrated_conn, merchant):
- _seed(migrated_conn, merchant)
- snap = repo.export_catalog(migrated_conn, merchant["storefront_id"], "all")
- assert {p.handle for p in snap} == {"active-mug", "draft-tee"}
-
-
-def test_export_status_filter(migrated_conn, merchant):
- _seed(migrated_conn, merchant)
- active = repo.export_catalog(migrated_conn, merchant["storefront_id"], "active")
- assert [p.handle for p in active] == ["active-mug"]
- draft = repo.export_catalog(migrated_conn, merchant["storefront_id"], "draft")
- assert [p.handle for p in draft] == ["draft-tee"]
-
-
-@pytest.fixture()
-def telemetry_propagation():
- """create_app() sets propagate=False on the parent "ecomm" logger
- (main._ensure_app_logging), which hides ecomm.telemetry records from caplog's
- root-logger handler whenever an API test ran first. Restore propagation here."""
- lg = logging.getLogger("ecomm")
- prior = lg.propagate
- lg.propagate = True
- yield
- lg.propagate = prior
-
-
-def test_export_streams_canonical_csv(migrated_conn, merchant):
- _seed(migrated_conn, merchant)
- text = "".join(products.export_catalog(migrated_conn, merchant["storefront_id"], "all"))
- rows = list(csv.DictReader(io.StringIO(text)))
- assert {r["Handle"] for r in rows} == {"active-mug", "draft-tee"}
- assert rows[0]["Handle"] == "active-mug" # sorted by handle
-
-
-def test_export_empty_raises(migrated_conn, merchant):
- # No catalog at all → empty.
- with pytest.raises(products.EmptyCatalog):
- list(products.export_catalog(migrated_conn, merchant["storefront_id"], "all"))
-
-
-def test_export_empty_after_filter_raises(migrated_conn, merchant):
- _seed(migrated_conn, merchant) # only active + draft exist
- with pytest.raises(products.EmptyCatalog):
- list(products.export_catalog(migrated_conn, merchant["storefront_id"], "archived"))
-
-
-def test_tel3_emitted(migrated_conn, merchant, caplog, telemetry_propagation):
- _seed(migrated_conn, merchant)
- with caplog.at_level(logging.INFO, logger="ecomm.telemetry"):
- list(products.export_catalog(migrated_conn, merchant["storefront_id"], "all"))
- events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"]
- exported = [e for e in events if e["event"] == "catalog_exported"]
- assert len(exported) == 1
- assert exported[0]["product_count"] == 2
- assert exported[0]["status_filter"] == "all"
- assert "duration_ms" in exported[0]
diff --git a/backend/tests/test_products_hosted.py b/backend/tests/test_products_hosted.py
deleted file mode 100644
index 9c145cb..0000000
--- a/backend/tests/test_products_hosted.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""hosted-image URL helpers — round-trip recognition (SD-0002 §6.3.1, INV-12)."""
-from app.domains.products import hosted
-
-
-def test_build_relative_when_no_base():
- assert hosted.image_url("", 42, "detail") == "/api/products/images/42/detail"
-
-
-def test_build_absolute_with_base():
- url = hosted.image_url("https://ecomm-ppe.wiggleverse.org", 42, "detail")
- assert url == "https://ecomm-ppe.wiggleverse.org/api/products/images/42/detail"
-
-
-def test_parse_absolute_hosted_url():
- url = "https://market.wiggleverse.org/api/products/images/7/detail"
- assert hosted.parse_image_id(url) == 7
-
-
-def test_parse_relative_hosted_url():
- assert hosted.parse_image_id("/api/products/images/7/card") == 7
-
-
-def test_parse_ignores_query_and_trailing():
- assert hosted.parse_image_id("/api/products/images/7/detail?v=1") == 7
-
-
-def test_parse_non_hosted_returns_none():
- assert hosted.parse_image_id("https://cdn.example.com/a/b.png") is None
- assert hosted.parse_image_id("/api/products/images/abc/detail") is None
- assert hosted.parse_image_id("/api/products/images/7") is None
diff --git a/backend/tests/test_products_image_serving.py b/backend/tests/test_products_image_serving.py
deleted file mode 100644
index fad07d6..0000000
--- a/backend/tests/test_products_image_serving.py
+++ /dev/null
@@ -1,78 +0,0 @@
-"""GET /api/products/images/{id}/{rendition} — authorize, stream, immutable cache (§6.4, INV-14/16)."""
-import psycopg
-from contextlib import contextmanager
-
-from fastapi.testclient import TestClient
-
-from app.main import create_app
-from app.domains import products # noqa: F401 (ensures package import path)
-
-from test_products_endpoints import _merchant_client # reuse the signed-in client
-
-
-def _seed_image(fresh_db_url, status="fetched", with_detail_bytes=None, store=None,
- email_storefront_only=True):
- """Insert a product + image for the (only) storefront; set keys; return (storefront_id, image_id, keys)."""
- with psycopg.connect(fresh_db_url) as conn:
- sf = conn.execute("SELECT id FROM storefront ORDER BY id LIMIT 1").fetchone()[0]
- pid = conn.execute(
- "INSERT INTO product (storefront_id, handle, title) VALUES (%s,'lamp','Lamp') RETURNING id",
- (sf,)).fetchone()[0]
- iid = conn.execute(
- "INSERT INTO product_image (product_id, source_url, position, status)"
- " VALUES (%s,'https://m/a.png',1,%s) RETURNING id", (pid, status)).fetchone()[0]
- keys = {r: f"storefronts/{sf}/product-images/{iid}/{r}" for r in ("original", "thumb", "card", "detail")}
- if status == "fetched":
- conn.execute(
- "UPDATE product_image SET key_original=%s, key_thumb=%s, key_card=%s, key_detail=%s WHERE id=%s",
- (keys["original"], keys["thumb"], keys["card"], keys["detail"], iid))
- conn.commit()
- if with_detail_bytes is not None and store is not None:
- store.put(keys["detail"], with_detail_bytes, "image/webp")
- return sf, iid, keys
-
-
-def test_serves_fetched_rendition_with_immutable_cache(fresh_db_url):
- with _merchant_client(fresh_db_url) as client:
- _sf, iid, _keys = _seed_image(fresh_db_url, status="fetched",
- with_detail_bytes=b"WEBPDATA", store=client.app.state.objectstore)
- resp = client.get(f"/api/products/images/{iid}/detail")
- assert resp.status_code == 200
- assert resp.headers["content-type"] == "image/webp"
- assert "immutable" in resp.headers.get("cache-control", "")
- assert resp.content == b"WEBPDATA"
-
-
-def test_not_fetched_returns_409(fresh_db_url):
- with _merchant_client(fresh_db_url) as client:
- _sf, iid, _keys = _seed_image(fresh_db_url, status="pending")
- resp = client.get(f"/api/products/images/{iid}/detail")
- assert resp.status_code == 409
- assert resp.json()["error"]["code"] == "not_fetched"
-
-
-def test_other_storefronts_image_is_404(fresh_db_url):
- # Sign in as merchant B first (this migrates the DB), then seed an image
- # belonging to a *different* storefront A — it must be invisible to B.
- with _merchant_client(fresh_db_url, email="b@example.com") as client:
- with psycopg.connect(fresh_db_url) as conn:
- a = conn.execute("INSERT INTO account (email) VALUES ('a@example.com') RETURNING id").fetchone()[0]
- sfa = conn.execute("INSERT INTO storefront (name) VALUES ('A') RETURNING id").fetchone()[0]
- conn.execute("INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (a, sfa))
- pid = conn.execute("INSERT INTO product (storefront_id, handle, title) VALUES (%s,'lamp','Lamp') RETURNING id", (sfa,)).fetchone()[0]
- iid = conn.execute("INSERT INTO product_image (product_id, source_url, position, status) VALUES (%s,'u',1,'fetched') RETURNING id", (pid,)).fetchone()[0]
- conn.commit()
- resp = client.get(f"/api/products/images/{iid}/detail")
- assert resp.status_code == 404
-
-
-def test_unauthenticated_is_401(fresh_db_url):
- with TestClient(create_app(database_url=fresh_db_url)) as client:
- assert client.get("/api/products/images/1/detail").status_code == 401
-
-
-def test_bad_rendition_is_422(fresh_db_url):
- with _merchant_client(fresh_db_url) as client:
- _sf, iid, _keys = _seed_image(fresh_db_url, status="fetched",
- with_detail_bytes=b"x", store=client.app.state.objectstore)
- assert client.get(f"/api/products/images/{iid}/huge").status_code == 422
diff --git a/backend/tests/test_products_imagefetch.py b/backend/tests/test_products_imagefetch.py
deleted file mode 100644
index ef9c3eb..0000000
--- a/backend/tests/test_products_imagefetch.py
+++ /dev/null
@@ -1,159 +0,0 @@
-"""image-fetch phase: SSRF guard, fetch+process+store, run completion, resume (§6.5.4/§6.9)."""
-import io
-import threading
-from http.server import BaseHTTPRequestHandler, HTTPServer
-
-import psycopg
-import pytest
-from PIL import Image
-
-from app.domains.products import imagefetch, repo
-from app.platform import db, objectstore
-
-
-def _png(w, h):
- buf = io.BytesIO(); Image.new("RGB", (w, h), (1, 2, 3)).save(buf, "PNG"); return buf.getvalue()
-
-
-class _Host(BaseHTTPRequestHandler):
- GOOD = _png(800, 800)
- TINY = _png(64, 64)
- def log_message(self, *a): pass
- def do_GET(self):
- if self.path.startswith("/good.png"):
- self.send_response(200); self.send_header("Content-Type", "image/png")
- self.end_headers(); self.wfile.write(self.GOOD)
- elif self.path.startswith("/tiny.png"):
- self.send_response(200); self.send_header("Content-Type", "image/png")
- self.end_headers(); self.wfile.write(self.TINY)
- else:
- self.send_response(404); self.end_headers()
-
-
-@pytest.fixture()
-def host():
- srv = HTTPServer(("127.0.0.1", 0), _Host)
- t = threading.Thread(target=srv.serve_forever, daemon=True); t.start()
- yield f"http://127.0.0.1:{srv.server_address[1]}"
- srv.shutdown()
-
-
-@pytest.fixture()
-def pool(fresh_db_url):
- with psycopg.connect(fresh_db_url) as conn:
- db.migrate(conn); conn.commit()
- p = db.open_pool(fresh_db_url, max_size=6)
- yield p
- p.close()
-
-
-def _seed(pool):
- """account+storefront+run; returns (storefront_id, account_id, run_id)."""
- with pool.connection() as conn:
- acct = conn.execute("INSERT INTO account (email) VALUES ('m@example.com') RETURNING id").fetchone()[0]
- sf = conn.execute("INSERT INTO storefront (name) VALUES ('Shop') RETURNING id").fetchone()[0]
- conn.execute("INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf))
- rid = conn.execute(
- "INSERT INTO import_run (storefront_id, account_id, file_name, dialect,"
- " products_added, products_updated, rows_errored, status)"
- " VALUES (%s,%s,'c.csv','canonical',0,0,0,'fetching_images') RETURNING id", (sf, acct)).fetchone()[0]
- conn.commit()
- return sf, acct, rid
-
-
-def _product(pool, sf, handle):
- with pool.connection() as conn:
- pid = conn.execute("INSERT INTO product (storefront_id, handle, title) VALUES (%s,%s,%s) RETURNING id",
- (sf, handle, handle.title())).fetchone()[0]
- conn.commit()
- return pid
-
-
-def _image(pool, pid, url, rid, position=1):
- with pool.connection() as conn:
- iid = conn.execute("INSERT INTO product_image (product_id, source_url, position, status, import_run_id)"
- " VALUES (%s,%s,%s,'pending',%s) RETURNING id", (pid, url, position, rid)).fetchone()[0]
- conn.commit()
- return iid
-
-
-def _img_row(pool, iid):
- with pool.connection() as conn:
- r = conn.execute("SELECT status, key_original, key_thumb, key_card, key_detail FROM product_image WHERE id=%s",
- (iid,)).fetchone()
- return {"status": r[0], "key_original": r[1], "key_thumb": r[2], "key_card": r[3], "key_detail": r[4]}
-
-
-def _run_status(pool, rid):
- with pool.connection() as conn:
- return conn.execute("SELECT status FROM import_run WHERE id=%s", (rid,)).fetchone()[0]
-
-
-def test_ssrf_guard_rejects_private_when_disallowed():
- with pytest.raises(imagefetch.FetchBlocked):
- imagefetch.fetch_bytes("http://127.0.0.1:1/x.png", allow_private=False)
- with pytest.raises(imagefetch.FetchBlocked):
- imagefetch.fetch_bytes("http://169.254.169.254/latest/meta-data", allow_private=False)
- with pytest.raises(imagefetch.FetchBlocked):
- imagefetch.fetch_bytes("ftp://example.com/x", allow_private=False)
-
-
-def test_ssrf_guard_allows_private_when_enabled(host):
- data, ctype = imagefetch.fetch_bytes(f"{host}/good.png", allow_private=True)
- assert ctype.startswith("image/") and len(data) > 0
-
-
-def test_run_phase_classifies_each_image(pool, host, tmp_path):
- store = objectstore.LocalObjectStore(str(tmp_path))
- sf, _acct, rid = _seed(pool)
- p = _product(pool, sf, "lamp")
- ok = _image(pool, p, f"{host}/good.png", rid, position=1)
- _image(pool, p, f"{host}/tiny.png", rid, position=2)
- _image(pool, p, f"{host}/missing.png", rid, position=3)
- imagefetch.run_image_phase(pool, store, rid, allow_private=True)
- with pool.connection() as conn:
- counts = repo.run_image_counts(conn, rid)
- assert counts["fetched"] == 1 and counts["rejected"] == 1 and counts["failed"] == 1
- row = _img_row(pool, ok)
- assert row["status"] == "fetched"
- for k in ("key_original", "key_thumb", "key_card", "key_detail"):
- assert row[k] and store.get(row[k])
- assert _run_status(pool, rid) == "complete_with_problems"
-
-
-def test_unexpected_processing_error_marks_image_failed_not_wedged(pool, host, tmp_path, monkeypatch):
- # If anything after the fetch raises unexpectedly (e.g. objectstore.put),
- # the image is marked failed and the run still reaches a terminal status —
- # never stranded in fetching_images.
- store = objectstore.LocalObjectStore(str(tmp_path))
- sf, _acct, rid = _seed(pool)
- p = _product(pool, sf, "lamp")
- iid = _image(pool, p, f"{host}/good.png", rid, position=1)
-
- def _boom(*a, **k):
- raise RuntimeError("storage down")
- monkeypatch.setattr(store, "put", _boom)
-
- imagefetch.run_image_phase(pool, store, rid, allow_private=True)
- with pool.connection() as conn:
- counts = repo.run_image_counts(conn, rid)
- assert counts["failed"] == 1 and counts["pending"] == 0
- assert _run_status(pool, rid) == "complete_with_problems"
- row = _img_row(pool, iid)
- assert row["status"] == "failed"
-
-
-def test_resume_after_kill_completes_remaining(pool, host, tmp_path):
- store = objectstore.LocalObjectStore(str(tmp_path))
- sf, _acct, rid = _seed(pool)
- p = _product(pool, sf, "lamp")
- a = _image(pool, p, f"{host}/good.png", rid, position=1)
- with pool.connection() as conn: # simulate crash: one already fetched, run still fetching_images
- repo.mark_image_fetched(conn, a, {"original": "o", "thumb": "t", "card": "c", "detail": "d"})
- conn.commit()
- _image(pool, p, f"{host}/good.png?2", rid, position=2) # still pending
- resumed = imagefetch.recover_incomplete_runs(pool, store, allow_private=True)
- assert rid in resumed
- with pool.connection() as conn:
- assert repo.run_image_counts(conn, rid)["pending"] == 0
- assert _run_status(pool, rid) == "complete"
diff --git a/backend/tests/test_products_invariants.py b/backend/tests/test_products_invariants.py
deleted file mode 100644
index 7e045cb..0000000
--- a/backend/tests/test_products_invariants.py
+++ /dev/null
@@ -1,101 +0,0 @@
-"""SD-0002 invariants: INV-10 (never deletes), INV-14 (two-storefront zero bleed),
-apply transactionality (§6.8), TEL-6."""
-import json
-import logging
-
-import psycopg
-import pytest
-
-from app.domains import products
-from app.domains.products import repo, service
-from app.platform import db
-
-CSV_A = b"Handle,Title,Variant Price\nmug,Mug,10.00\ntee,Tee,20.00\n"
-CSV_PARTIAL = b"Handle,Title,Variant Price\nmug,Mug,12.00\n"
-
-
-@pytest.fixture()
-def migrated_conn(fresh_db_url):
- with psycopg.connect(fresh_db_url) as conn:
- db.migrate(conn)
- yield conn
-
-
-def _merchant(conn, email="m@example.com", shop="Shop"):
- acct = conn.execute("INSERT INTO account (email) VALUES (%s) RETURNING id", (email,)).fetchone()[0]
- sf = conn.execute("INSERT INTO storefront (name) VALUES (%s) RETURNING id", (shop,)).fetchone()[0]
- conn.execute("INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf))
- conn.commit()
- return acct, sf
-
-
-def _import(conn, acct, sf, data):
- d = products.import_validate(conn, sf, acct, "f.csv", data)
- return products.confirm_draft(conn, sf, acct, d["id"])
-
-
-def test_inv10_partial_file_never_deletes(migrated_conn):
- acct, sf = _merchant(migrated_conn)
- _import(migrated_conn, acct, sf, CSV_A)
- before = migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0]
- _import(migrated_conn, acct, sf, CSV_PARTIAL)
- after = migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0]
- assert after >= before == 2
-
-
-def test_inv14_two_storefronts_zero_bleed(migrated_conn):
- acct1, sf1 = _merchant(migrated_conn)
- acct2, sf2 = _merchant(migrated_conn, "n@example.com", "Other")
- _import(migrated_conn, acct1, sf1, CSV_A)
- assert products.summary(migrated_conn, sf2)["product_count"] == 0
- assert products.list_runs(migrated_conn, sf2) == []
- _import(migrated_conn, acct2, sf2, CSV_A)
- assert products.summary(migrated_conn, sf2)["product_count"] == 2
- run1 = products.list_runs(migrated_conn, sf1)[0]
- with pytest.raises(products.RunNotFound):
- products.get_run(migrated_conn, sf2, run1["id"])
-
-
-def test_apply_failure_rolls_back_whole_transaction_tel6(migrated_conn, monkeypatch, caplog):
- acct, sf = _merchant(migrated_conn)
- d = products.import_validate(migrated_conn, sf, acct, "f.csv", CSV_A)
-
- def boom(*a, **k):
- raise RuntimeError("mid-apply crash")
- monkeypatch.setattr(service.repo, "insert_run_errors", boom)
- lg = logging.getLogger("ecomm")
- prior = lg.propagate
- lg.propagate = True
- try:
- with caplog.at_level(logging.INFO, logger="ecomm.telemetry"):
- with pytest.raises(RuntimeError):
- products.confirm_draft(migrated_conn, sf, acct, d["id"])
- finally:
- lg.propagate = prior
- assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 0
- assert migrated_conn.execute("SELECT count(*) FROM import_run").fetchone()[0] == 0
- assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 1
- events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"]
- assert any(e["event"] == "import_apply_failed" and e["error_class"] == "RuntimeError" for e in events)
-
-
-# A partial Shopify export (signature: Body (HTML) + Variant Grams) naming only one
-# of the two existing products — INV-10 must hold across the dialect boundary.
-SHOPIFY_PARTIAL = (
- b"Handle,Title,Body (HTML),Variant Price,Variant Grams\n"
- b"mug,Mug,
x
,12.00,180\n"
-)
-
-
-def test_inv10_shopify_partial_never_deletes(migrated_conn):
- acct, sf = _merchant(migrated_conn)
- _import(migrated_conn, acct, sf, CSV_A)
- before = migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0]
- d = products.import_validate(migrated_conn, sf, acct, "shopify.csv", SHOPIFY_PARTIAL)
- assert d["dialect"] == "shopify"
- products.confirm_draft(migrated_conn, sf, acct, d["id"])
- after = migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0]
- # INV-10: nothing deleted; the unmentioned 'tee' is untouched.
- assert after >= before == 2
- handles = {r[0] for r in migrated_conn.execute("SELECT handle FROM product").fetchall()}
- assert {"mug", "tee"} <= handles
diff --git a/backend/tests/test_products_repo_images.py b/backend/tests/test_products_repo_images.py
deleted file mode 100644
index 0f28ee0..0000000
--- a/backend/tests/test_products_repo_images.py
+++ /dev/null
@@ -1,93 +0,0 @@
-"""products repo — image-phase helpers (SD-0002 §6.5.4)."""
-import psycopg
-import pytest
-
-from app.domains.products import repo
-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
-
-
-@pytest.fixture()
-def merchant(migrated_conn):
- acct = migrated_conn.execute(
- "INSERT INTO account (email) VALUES ('m@example.com') RETURNING id").fetchone()[0]
- sf = migrated_conn.execute(
- "INSERT INTO storefront (name) VALUES ('Shop') RETURNING id").fetchone()[0]
- migrated_conn.execute(
- "INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf))
- migrated_conn.commit()
- return {"account_id": acct, "storefront_id": sf}
-
-
-def _run(conn, m, status="fetching_images"):
- return conn.execute(
- "INSERT INTO import_run (storefront_id, account_id, file_name, dialect,"
- " products_added, products_updated, rows_errored, status)"
- " VALUES (%s,%s,'c.csv','canonical',0,0,0,%s) RETURNING id",
- (m["storefront_id"], m["account_id"], status)).fetchone()[0]
-
-
-def _product(conn, m, handle):
- return conn.execute(
- "INSERT INTO product (storefront_id, handle, title) VALUES (%s,%s,%s) RETURNING id",
- (m["storefront_id"], handle, handle.title())).fetchone()[0]
-
-
-def _image(conn, product_id, url, run_id, status="pending", position=1):
- return conn.execute(
- "INSERT INTO product_image (product_id, source_url, position, status, import_run_id)"
- " VALUES (%s,%s,%s,%s,%s) RETURNING id",
- (product_id, url, position, status, run_id)).fetchone()[0]
-
-
-def test_pending_images_for_run_returns_only_pending(migrated_conn, merchant):
- rid = _run(migrated_conn, merchant)
- p = _product(migrated_conn, merchant, "lamp")
- a = _image(migrated_conn, p, "https://m/a.png", rid, status="pending", position=1)
- _image(migrated_conn, p, "https://m/b.png", rid, status="fetched", position=2)
- pend = repo.pending_images_for_run(migrated_conn, rid)
- assert [img["id"] for img in pend] == [a]
- assert pend[0]["source_url"] == "https://m/a.png"
- assert pend[0]["storefront_id"] == merchant["storefront_id"]
-
-
-def test_claim_image_for_fetch_is_idempotent(migrated_conn, merchant):
- rid = _run(migrated_conn, merchant)
- p = _product(migrated_conn, merchant, "lamp")
- img = _image(migrated_conn, p, "https://m/a.png", rid)
- assert repo.claim_image_for_fetch(migrated_conn, img) is True
- repo.mark_image_fetched(migrated_conn, img,
- {"original": "o", "thumb": "t", "card": "c", "detail": "d"})
- assert repo.claim_image_for_fetch(migrated_conn, img) is False
-
-
-def test_mark_image_outcomes_and_run_counts(migrated_conn, merchant):
- rid = _run(migrated_conn, merchant)
- p = _product(migrated_conn, merchant, "lamp")
- ok = _image(migrated_conn, p, "https://m/a.png", rid, position=1)
- bad = _image(migrated_conn, p, "https://m/b.png", rid, position=2)
- miss = _image(migrated_conn, p, "https://m/c.png", rid, position=3)
- repo.mark_image_fetched(migrated_conn, ok, {"original": "o", "thumb": "t", "card": "c", "detail": "d"})
- repo.mark_image_rejected(migrated_conn, bad, "rejected_low_res", "below the resolution bar")
- repo.mark_image_failed(migrated_conn, miss, "host unreachable")
- assert repo.run_image_counts(migrated_conn, rid) == {
- "fetched": 1, "rejected": 1, "failed": 1, "pending": 0, "total": 3}
- outcomes = repo.run_image_outcomes(migrated_conn, rid)
- assert {o["handle"] for o in outcomes} == {"lamp"}
- assert {o["outcome"] for o in outcomes} == {"rejected_low_res", "failed"} # fetched not listed
-
-
-def test_set_run_status_and_incomplete_runs(migrated_conn, merchant):
- rid = _run(migrated_conn, merchant, status="fetching_images")
- _run(migrated_conn, merchant, status="complete")
- assert rid in [r["id"] for r in repo.incomplete_runs(migrated_conn)]
- repo.set_run_status(migrated_conn, rid, "complete")
- assert rid not in [r["id"] for r in repo.incomplete_runs(migrated_conn)]
- row = migrated_conn.execute("SELECT status, completed_at FROM import_run WHERE id=%s", (rid,)).fetchone()
- assert row[0] == "complete" and row[1] is not None
diff --git a/backend/tests/test_products_service.py b/backend/tests/test_products_service.py
deleted file mode 100644
index 335d60e..0000000
--- a/backend/tests/test_products_service.py
+++ /dev/null
@@ -1,242 +0,0 @@
-"""products service — drafts: validate/preview/discard (PUC-2/3/3a/5a; INV-11)."""
-import json
-import logging
-
-import psycopg
-import pytest
-
-from app.domains import products
-from app.platform import db
-
-GOOD_CSV = b"Handle,Title,Vendor,Variant Price\nmoon-mug,Moon Mug,Acme,18.00\nstar-tee,Star Tee,Acme,24.00\n"
-
-
-@pytest.fixture()
-def migrated_conn(fresh_db_url):
- with psycopg.connect(fresh_db_url) as conn:
- db.migrate(conn)
- yield conn
-
-
-@pytest.fixture()
-def merchant(migrated_conn):
- acct = migrated_conn.execute(
- "INSERT INTO account (email) VALUES ('m@example.com') RETURNING id").fetchone()[0]
- sf = migrated_conn.execute(
- "INSERT INTO storefront (name) VALUES ('Shop') RETURNING id").fetchone()[0]
- migrated_conn.execute(
- "INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf))
- migrated_conn.commit()
- return {"account_id": acct, "storefront_id": sf}
-
-
-def test_import_validate_creates_draft_with_summary(migrated_conn, merchant):
- draft = products.import_validate(
- migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
- assert draft["dialect"] == "canonical"
- assert draft["summary"] == {"adds": 2, "updates": 0, "unchanged": 0, "errors": 0}
- assert draft["expires_at"]
-
-
-def test_validate_writes_nothing_to_catalog_inv11(migrated_conn, merchant):
- products.import_validate(
- migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
- assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 0
- assert migrated_conn.execute("SELECT count(*) FROM variant").fetchone()[0] == 0
-
-
-def test_file_rejection_leaves_no_draft(migrated_conn, merchant):
- with pytest.raises(products.FileRejected):
- products.import_validate(
- migrated_conn, merchant["storefront_id"], merchant["account_id"], "bad.csv",
- b"Vendor,Price\nAcme,1\n")
- assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0
-
-
-def test_records_paging_and_kind_filter(migrated_conn, merchant):
- draft = products.import_validate(
- migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
- recs = products.get_draft_records(migrated_conn, merchant["storefront_id"], draft["id"])
- assert [r["handle"] for r in recs] == ["moon-mug", "star-tee"]
- adds = products.get_draft_records(
- migrated_conn, merchant["storefront_id"], draft["id"], kind="add", limit=1)
- assert len(adds) == 1 and adds[0]["kind"] == "add"
-
-
-def test_discard_deletes_no_trace_puc3a(migrated_conn, merchant):
- draft = products.import_validate(
- migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
- products.discard_draft(migrated_conn, merchant["storefront_id"], draft["id"])
- assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0
- products.discard_draft(migrated_conn, merchant["storefront_id"], draft["id"]) # idempotent
-
-
-def test_draft_scoped_to_storefront_inv14(migrated_conn, merchant):
- draft = products.import_validate(
- migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
- other_sf = migrated_conn.execute(
- "INSERT INTO storefront (name) VALUES ('Other') RETURNING id").fetchone()[0]
- migrated_conn.commit()
- with pytest.raises(products.DraftNotFound):
- products.get_draft(migrated_conn, other_sf, draft["id"])
-
-
-def test_expired_draft_raises_and_lazily_deletes(migrated_conn, merchant):
- draft = products.import_validate(
- migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
- migrated_conn.execute(
- "UPDATE import_draft SET expires_at = now() - interval '1 minute' WHERE id = %s",
- (draft["id"],))
- migrated_conn.commit()
- with pytest.raises(products.DraftExpired):
- products.get_draft(migrated_conn, merchant["storefront_id"], draft["id"])
- assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0
-
-
-@pytest.fixture()
-def telemetry_propagation():
- """create_app() sets propagate=False on the parent "ecomm" logger
- (main._ensure_app_logging), which hides ecomm.telemetry records from caplog's
- root-logger handler whenever an API test ran first. Restore propagation here."""
- lg = logging.getLogger("ecomm")
- prior = lg.propagate
- lg.propagate = True
- yield
- lg.propagate = prior
-
-
-def test_tel1_emitted(migrated_conn, merchant, caplog, telemetry_propagation):
- with caplog.at_level(logging.INFO, logger="ecomm.telemetry"):
- products.import_validate(
- migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
- events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"]
- assert any(
- e["event"] == "import_draft_created" and e["adds"] == 2 and e["row_count"] == 2
- and "duration_ms" in e and e["unknown_columns_count"] == 0
- for e in events
- )
-
-
-UPDATE_CSV = b"Handle,Title,Vendor,Variant Price\nmoon-mug,Moon Mug,Acme,21.00\nstar-tee,Star Tee,Acme,24.00\n"
-MIXED_CSV = b"Handle,Title,Variant Price\ngood-mug,Mug,10.00\nbad-tee,Tee,not-a-price\n"
-
-
-def _validate(conn, m, data=GOOD_CSV):
- return products.import_validate(conn, m["storefront_id"], m["account_id"], "cat.csv", data)
-
-
-def test_confirm_applies_adds_and_records_run(migrated_conn, merchant):
- draft = _validate(migrated_conn, merchant)
- run_id = products.confirm_draft(
- migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
- assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 2
- run = products.get_run(migrated_conn, merchant["storefront_id"], run_id)
- assert run["products_added"] == 2 and run["status"] == "complete"
- assert run["by"] == "m@example.com"
- assert run["image_progress"] == {"done": 0, "total": 0} and run["image_outcomes"] == []
- assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0
-
-
-def test_confirm_update_changes_only_diffed_fields(migrated_conn, merchant):
- d1 = _validate(migrated_conn, merchant)
- products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"])
- d2 = _validate(migrated_conn, merchant, UPDATE_CSV)
- assert d2["summary"] == {"adds": 0, "updates": 1, "unchanged": 1, "errors": 0}
- products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"])
- price = migrated_conn.execute(
- "SELECT v.price FROM variant v JOIN product p ON p.id = v.product_id WHERE p.handle='moon-mug'"
- ).fetchone()[0]
- assert str(price) == "21.00"
- assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 2 # no dupes (BUC-3)
-
-
-def test_confirm_blank_position_cell_round_trips(migrated_conn, merchant):
- # A present-but-empty Variant Position cell resolves to file order at diff
- # time — never SET position = NULL (which would abort the confirm on the
- # NOT NULL constraint).
- d1 = _validate(migrated_conn, merchant)
- products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"])
- blank_position_csv = (
- b"Handle,Title,Vendor,Variant Price,Variant Position\n"
- b"moon-mug,Moon Mug,Acme,21.00,\n"
- b"star-tee,Star Tee,Acme,24.00,\n"
- )
- d2 = _validate(migrated_conn, merchant, blank_position_csv)
- products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"])
- price = migrated_conn.execute(
- "SELECT v.price FROM variant v JOIN product p ON p.id = v.product_id WHERE p.handle='moon-mug'"
- ).fetchone()[0]
- assert str(price) == "21.00"
-
-
-def test_confirm_mixed_applies_valid_records_errors(migrated_conn, merchant):
- draft = _validate(migrated_conn, merchant, MIXED_CSV)
- run_id = products.confirm_draft(
- migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
- assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 1
- run = products.get_run(migrated_conn, merchant["storefront_id"], run_id)
- assert run["rows_errored"] == 1
- assert run["errors"][0]["column"] == "Variant Price"
-
-
-def test_confirm_stale_fingerprint_409_inv11(migrated_conn, merchant):
- draft = _validate(migrated_conn, merchant)
- other = _validate(migrated_conn, merchant)
- products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], other["id"])
- with pytest.raises(products.PreviewStale):
- products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
-
-
-def test_confirm_nothing_to_apply_puc10(migrated_conn, merchant):
- d1 = _validate(migrated_conn, merchant)
- products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"])
- d2 = _validate(migrated_conn, merchant)
- assert d2["summary"]["unchanged"] == 2
- with pytest.raises(products.NothingToApply):
- products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"])
-
-
-def test_runs_history_newest_first(migrated_conn, merchant):
- d1 = _validate(migrated_conn, merchant)
- products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"])
- d2 = _validate(migrated_conn, merchant, UPDATE_CSV)
- r2 = products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"])
- runs = products.list_runs(migrated_conn, merchant["storefront_id"])
- assert [r["id"] for r in runs][0] == r2
-
-
-def test_summary_counts(migrated_conn, merchant):
- assert products.summary(migrated_conn, merchant["storefront_id"]) == {
- "product_count": 0, "image_problem_count": 0, "latest_run_id": None}
- d = _validate(migrated_conn, merchant)
- rid = products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d["id"])
- s = products.summary(migrated_conn, merchant["storefront_id"])
- assert s == {"product_count": 2, "image_problem_count": 0, "latest_run_id": rid}
-
-
-def test_tel2_emitted_on_confirm(migrated_conn, merchant, caplog, telemetry_propagation):
- draft = _validate(migrated_conn, merchant)
- with caplog.at_level(logging.INFO, logger="ecomm.telemetry"):
- products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
- events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"]
- assert any(e["event"] == "import_run_completed" and e["added"] == 2 for e in events)
-
-
-def test_confirm_marks_run_fetching_images_when_images_present(migrated_conn, merchant):
- csv = b"Handle,Title,Image Src\nlamp,Lamp,https://m.example/a.png\n"
- draft = products.import_validate(
- migrated_conn, merchant["storefront_id"], merchant["account_id"], "c.csv", csv)
- run_id = products.confirm_draft(
- migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
- run = products.get_run(migrated_conn, merchant["storefront_id"], run_id)
- assert run["status"] == "fetching_images"
- assert run["image_progress"]["total"] >= 1
-
-
-def test_confirm_marks_run_complete_when_no_images(migrated_conn, merchant):
- csv = b"Handle,Title,Vendor\nlamp,Lamp,Acme\n"
- draft = products.import_validate(
- migrated_conn, merchant["storefront_id"], merchant["account_id"], "c.csv", csv)
- run_id = products.confirm_draft(
- migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
- assert products.get_run(migrated_conn, merchant["storefront_id"], run_id)["status"] == "complete"
diff --git a/backend/tests/test_static_spa.py b/backend/tests/test_static_spa.py
deleted file mode 100644
index e07a643..0000000
--- a/backend/tests/test_static_spa.py
+++ /dev/null
@@ -1,24 +0,0 @@
-"""Deployed topology (launch-app SPEC §2): nginx proxies EVERYTHING to the backend, so
-the backend must serve the built SPA (frontend/dist) itself. Dev is unaffected — Vite
-serves the frontend and the default dist dir simply doesn't exist."""
-from fastapi.testclient import TestClient
-
-from app.main import create_app
-
-
-def test_spa_served_when_dist_present(fresh_db_url, tmp_path):
- (tmp_path / "index.html").write_text("ecomm spa ")
- with TestClient(create_app(database_url=fresh_db_url, static_dir=tmp_path)) as client:
- root = client.get("/")
- assert root.status_code == 200
- assert "ecomm spa" in root.text
- # API + health still win over the mount
- assert client.get("/healthz").json()["status"] == "ok"
- assert client.get("/api/auth/me").status_code == 401
-
-
-def test_no_mount_when_dist_absent(fresh_db_url, tmp_path):
- # An empty/missing dist (the dev case) must not 500 the app — / just 404s.
- with TestClient(create_app(database_url=fresh_db_url, static_dir=tmp_path / "nope")) as client:
- assert client.get("/").status_code == 404
- assert client.get("/healthz").json()["status"] == "ok"
diff --git a/backend/tests/test_storefronts_create.py b/backend/tests/test_storefronts_create.py
deleted file mode 100644
index 1160a69..0000000
--- a/backend/tests/test_storefronts_create.py
+++ /dev/null
@@ -1,154 +0,0 @@
-"""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"
diff --git a/backend/tests/test_storefronts_invariants.py b/backend/tests/test_storefronts_invariants.py
deleted file mode 100644
index 6ce0e60..0000000
--- a/backend/tests/test_storefronts_invariants.py
+++ /dev/null
@@ -1,64 +0,0 @@
-"""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
diff --git a/deployment.toml b/deployment.toml
index 865f27c..af0de76 100644
--- a/deployment.toml
+++ b/deployment.toml
@@ -35,8 +35,9 @@ ECOMM_SMTP_HOST = "smtp.gmail.com"
ECOMM_SMTP_PORT = "587"
ECOMM_SMTP_USER = "ben.stull@wiggleverse.org"
ECOMM_SMTP_FROM = "ecomm "
-ECOMM_OBJECTSTORE_KIND = "gcs"
-ECOMM_OBJECTSTORE_BUCKET = "wiggleverse-ecomm-ppe-media"
+# Objectstore (GCS) env removed in the network-seed reset — the seed no longer reads it.
+# Re-add when network catalog-image storage lands; bucket wiggleverse-ecomm-ppe-media stays
+# provisioned.
[secrets] # REFERENCES only — never bytes (§8.3)
ECOMM_SESSION_SECRET = "wiggleverse-ecomm/ecomm-ppe-session-secret"
diff --git a/e2e/.gitignore b/e2e/.gitignore
deleted file mode 100644
index 1fcd284..0000000
--- a/e2e/.gitignore
+++ /dev/null
@@ -1,5 +0,0 @@
-node_modules/
-.backend.log
-.objectstore/
-test-results/
-playwright-report/
diff --git a/e2e/fixtures/good.csv b/e2e/fixtures/good.csv
deleted file mode 100644
index 5c54ca4..0000000
--- a/e2e/fixtures/good.csv
+++ /dev/null
@@ -1,4 +0,0 @@
-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
diff --git a/e2e/fixtures/images/good.png b/e2e/fixtures/images/good.png
deleted file mode 100644
index 0a50ae4..0000000
Binary files a/e2e/fixtures/images/good.png and /dev/null differ
diff --git a/e2e/fixtures/images/tiny.png b/e2e/fixtures/images/tiny.png
deleted file mode 100644
index 293a859..0000000
Binary files a/e2e/fixtures/images/tiny.png and /dev/null differ
diff --git a/e2e/fixtures/missing-title.csv b/e2e/fixtures/missing-title.csv
deleted file mode 100644
index e4e06e4..0000000
--- a/e2e/fixtures/missing-title.csv
+++ /dev/null
@@ -1,2 +0,0 @@
-Handle,Vendor
-mug,Acme
diff --git a/e2e/fixtures/mixed-errors.csv b/e2e/fixtures/mixed-errors.csv
deleted file mode 100644
index 4b260bd..0000000
--- a/e2e/fixtures/mixed-errors.csv
+++ /dev/null
@@ -1,4 +0,0 @@
-Handle,Title,Variant Price
-good-mug,Good Mug,10.00
-bad-tee,Bad Tee,not-a-price
-also-good,Also Good,5.00
diff --git a/e2e/fixtures/shopify-export.csv b/e2e/fixtures/shopify-export.csv
deleted file mode 100644
index 2a3ac4c..0000000
--- a/e2e/fixtures/shopify-export.csv
+++ /dev/null
@@ -1,2 +0,0 @@
-Handle,Title,Body (HTML),Vendor,Type,Tags,Published,Status,Variant SKU,Variant Grams,Variant Inventory Qty,Variant Price,Variant Compare At Price,Gift Card,SEO Title,Cost per item
-shopify-mug,Shopify Mug,Imported from Shopify.
,Acme,Drinkware,"mugs",TRUE,active,SM-001,300,25,14.00,20.00,FALSE,Shopify Mug | Acme,7.00
diff --git a/e2e/fixtures/with-images.csv b/e2e/fixtures/with-images.csv
deleted file mode 100644
index 4677cac..0000000
--- a/e2e/fixtures/with-images.csv
+++ /dev/null
@@ -1,4 +0,0 @@
-Handle,Title,Image Src
-good-lamp,Good Lamp,http://127.0.0.1:8799/good.png
-tiny-lamp,Tiny Lamp,http://127.0.0.1:8799/tiny.png
-gone-lamp,Gone Lamp,http://127.0.0.1:8799/missing.png
diff --git a/e2e/helpers.ts b/e2e/helpers.ts
deleted file mode 100644
index fec4f28..0000000
--- a/e2e/helpers.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-// 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, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-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 -> | Your ecomm code: <6 digits>\n
-// The first 6-digit group after the marker is the code.
-async function codeFor(email: string): Promise {
- 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 {
- // 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));
-}
-
-// Import good.csv and confirm it, leaving a 2-product catalog. Returns nothing;
-// callers continue from the run-detail screen.
-export async function importGoodCsv(page: Page) {
- await uploadFixture(page, "good.csv");
- await expect(page.getByRole("heading", { name: "Import preview — good.csv" })).toBeVisible();
- await page.getByRole("button", { name: "Import 2 products" }).click();
- await expect(page.getByRole("heading", { level: 1, name: "good.csv" })).toBeVisible();
-}
-
-// Import with-images.csv (3 products, each with one Image Src) and confirm it.
-// Mirrors importGoodCsv; callers continue from the run-detail screen, where the
-// background image fetch progresses fetching_images → terminal.
-export async function importWithImages(page: Page) {
- await uploadFixture(page, "with-images.csv");
- await expect(page.getByRole("heading", { name: "Import preview — with-images.csv" })).toBeVisible();
- await page.getByRole("button", { name: "Import 3 products" }).click();
- await expect(page.getByRole("heading", { level: 1, name: "with-images.csv" })).toBeVisible();
-}
-
-// Click an Export status option and capture the downloaded CSV's text + path.
-export async function exportCatalog(page: Page, label: string): Promise<{ text: string; path: string }> {
- // Open the menu, then click the status option, capturing the download.
- // The menu is a native that *toggles* on each summary click and is
- // NOT closed by clicking a download (a download doesn't navigate). So on a
- // second export the menu may already be open — deterministically open it
- // rather than blind-toggling, and wait for the status link to be visible.
- const details = page.locator("details.products__export");
- if (!(await details.evaluate((el: HTMLDetailsElement) => el.open))) {
- await details.locator("> summary").click();
- }
- const link = page.getByRole("link", { name: label, exact: true });
- await expect(link).toBeVisible();
- const [download] = await Promise.all([
- page.waitForEvent("download"),
- link.click(),
- ]);
- const stream = await download.createReadStream();
- const chunks: Buffer[] = [];
- for await (const c of stream) chunks.push(c as Buffer);
- const text = Buffer.concat(chunks).toString("utf8");
- const path = join(tmpdir(), `export-${Date.now()}.csv`);
- await writeFile(path, text, "utf8");
- return { text, path };
-}
diff --git a/e2e/image-host.mjs b/e2e/image-host.mjs
deleted file mode 100644
index c8ef1ac..0000000
--- a/e2e/image-host.mjs
+++ /dev/null
@@ -1,19 +0,0 @@
-import { createServer } from "node:http";
-import { readFile } from "node:fs/promises";
-import { fileURLToPath } from "node:url";
-import { dirname, join } from "node:path";
-
-const dir = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "images");
-createServer(async (req, res) => {
- const name = (req.url || "").replace(/^\//, "").split("?")[0];
- if (name === "good.png" || name === "tiny.png") {
- try {
- const buf = await readFile(join(dir, name));
- res.writeHead(200, { "Content-Type": "image/png" });
- res.end(buf);
- return;
- } catch { /* fallthrough */ }
- }
- res.writeHead(404);
- res.end();
-}).listen(8799, "127.0.0.1");
diff --git a/e2e/package-lock.json b/e2e/package-lock.json
deleted file mode 100644
index 4b6066b..0000000
--- a/e2e/package-lock.json
+++ /dev/null
@@ -1,76 +0,0 @@
-{
- "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"
- }
- }
- }
-}
diff --git a/e2e/package.json b/e2e/package.json
deleted file mode 100644
index c7a9766..0000000
--- a/e2e/package.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "name": "wiggleverse-ecomm-e2e",
- "private": true,
- "scripts": { "test": "playwright test" },
- "devDependencies": { "@playwright/test": "^1.48.0" }
-}
diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts
deleted file mode 100644
index a809705..0000000
--- a/e2e/playwright.config.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-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,
- },
-});
diff --git a/e2e/serve.sh b/e2e/serve.sh
deleted file mode 100755
index c2c1cee..0000000
--- a/e2e/serve.sh
+++ /dev/null
@@ -1,34 +0,0 @@
-#!/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
-
-# Image pipeline (SLICE-7): local objectstore + a fixture image host on :8799.
-# ECOMM_IMAGE_FETCH_ALLOW_PRIVATE lets the SSRF guard reach 127.0.0.1 — TEST-ONLY,
-# never set in the deployed overlay. The &-backgrounded node host is a child of
-# this serve process; Playwright tears the whole webServer group down between runs.
-export ECOMM_OBJECTSTORE_KIND=local
-export ECOMM_OBJECTSTORE_DIR="$repo_root/e2e/.objectstore"
-export ECOMM_IMAGE_FETCH_ALLOW_PRIVATE=1
-rm -rf "$repo_root/e2e/.objectstore"
-node "$repo_root/e2e/image-host.mjs" &
-
-cd "$repo_root/backend"
-exec "$repo_root/.venv/bin/python" -m uvicorn app.main:app --port 8765 \
- > "$repo_root/e2e/.backend.log" 2>&1
diff --git a/e2e/tests/export-download.spec.ts b/e2e/tests/export-download.spec.ts
deleted file mode 100644
index 78ba518..0000000
--- a/e2e/tests/export-download.spec.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-// DoD scenario e2e_export_download (SD-0002 §6.8): export downloads a canonical
-// CSV and the status filter is respected.
-import { expect, test } from "@playwright/test";
-import { exportCatalog, gotoProducts, importGoodCsv, signUpWithStorefront } from "../helpers";
-
-test("e2e_export_download", async ({ page }) => {
- await signUpWithStorefront(page);
- await gotoProducts(page);
- await importGoodCsv(page);
- await gotoProducts(page);
-
- // Export "All products" → a canonical CSV with both handles.
- const all = await exportCatalog(page, "All products");
- expect(all.text.split("\n")[0]).toContain("Handle,");
- expect(all.text).toContain("moon-mug");
- expect(all.text).toContain("star-tee");
-
- // good.csv's products are active → "Active" exports both, "Draft" is empty/disabled-path.
- const active = await exportCatalog(page, "Active");
- expect(active.text).toContain("moon-mug");
-});
diff --git a/e2e/tests/image-outcomes.spec.ts b/e2e/tests/image-outcomes.spec.ts
deleted file mode 100644
index ce8736b..0000000
--- a/e2e/tests/image-outcomes.spec.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { expect, test } from "@playwright/test";
-import { gotoProducts, importWithImages, signUpWithStorefront } from "../helpers";
-
-test("e2e_image_outcomes: per-image outcomes, problem rows, notice band", async ({ page }) => {
- await signUpWithStorefront(page);
- await gotoProducts(page);
- await importWithImages(page);
- // Run detail polls fetching_images → terminal; wait for the outcome summary.
- await expect(page.getByText("1 fetched · 1 rejected · 1 failed")).toBeVisible({ timeout: 30_000 });
- // Problem images listed in the outcomes table.
- await expect(page.getByText("tiny-lamp")).toBeVisible();
- await expect(page.getByText("gone-lamp")).toBeVisible();
- // Products page surfaces the aggregate notice.
- await gotoProducts(page);
- await expect(page.getByText(/products have image problems/)).toBeVisible();
-});
diff --git a/e2e/tests/import-cancel.spec.ts b/e2e/tests/import-cancel.spec.ts
deleted file mode 100644
index 2264bb7..0000000
--- a/e2e/tests/import-cancel.spec.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-// 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();
-});
diff --git a/e2e/tests/import-errors.spec.ts b/e2e/tests/import-errors.spec.ts
deleted file mode 100644
index 9c6a2f1..0000000
--- a/e2e/tests/import-errors.spec.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-// 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();
-});
diff --git a/e2e/tests/import-file-rejected.spec.ts b/e2e/tests/import-file-rejected.spec.ts
deleted file mode 100644
index 3872296..0000000
--- a/e2e/tests/import-file-rejected.spec.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-// 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();
-});
diff --git a/e2e/tests/import-preview-confirm.spec.ts b/e2e/tests/import-preview-confirm.spec.ts
deleted file mode 100644
index 7b8091b..0000000
--- a/e2e/tests/import-preview-confirm.spec.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-// 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();
-});
diff --git a/e2e/tests/import-shopify-dialect.spec.ts b/e2e/tests/import-shopify-dialect.spec.ts
deleted file mode 100644
index 63a7b17..0000000
--- a/e2e/tests/import-shopify-dialect.spec.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-// DoD scenario e2e_import_shopify_dialect (SD-0002 §6.8, SLICE-8): a Shopify
-// product CSV imports directly (PUC-6). Upload an unmodified-shape Shopify export;
-// the preview recognizes the dialect ("Shopify product CSV — mapped"), lists the
-// columns with no canonical home as not-imported, and the import confirms and
-// completes — the run report card carrying the same dialect label.
-import { expect, test } from "@playwright/test";
-import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers";
-
-test("e2e_import_shopify_dialect", async ({ page }) => {
- await signUpWithStorefront(page);
- await gotoProducts(page);
-
- await uploadFixture(page, "shopify-export.csv");
-
- // Preview (§5.4): the file name, the recognized dialect, and the not-imported band.
- await expect(
- page.getByRole("heading", { name: "Import preview — shopify-export.csv" }),
- ).toBeVisible();
- await expect(page.getByText("Shopify product CSV — mapped")).toBeVisible();
- // The not-imported band lists Shopify columns with no canonical home. Assert a
- // column that never appears in canonical diff detail, so the match is unambiguous.
- await expect(page.getByText("Columns not imported")).toBeVisible();
- await expect(page.getByText("Variant Compare At Price")).toBeVisible();
- await expect(page.getByRole("button", { name: "1 to add" })).toBeVisible();
- await expect(page.getByRole("button", { name: "0 errors" })).toBeVisible();
-
- // Consent gate (PUC-3): confirm the import.
- await page.getByRole("button", { name: "Import 1 products" }).click();
-
- // Run detail (§5.5): report card carries the same dialect label and completes.
- await expect(page.getByRole("heading", { level: 1, name: "shopify-export.csv" })).toBeVisible();
- await expect(page.getByText("Shopify product CSV — mapped")).toBeVisible();
- await expect(page.getByText("1 added · 0 updated · 0 rows in error")).toBeVisible();
- await expect(page.getByText("Complete", { exact: true })).toBeVisible();
-});
diff --git a/e2e/tests/roundtrip-noop.spec.ts b/e2e/tests/roundtrip-noop.spec.ts
deleted file mode 100644
index bef0c82..0000000
--- a/e2e/tests/roundtrip-noop.spec.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-// DoD scenario e2e_roundtrip_noop (SD-0002 §6.8, PUC-10): exporting then
-// re-importing the unmodified file previews as all-unchanged with the import
-// action disabled and the "Nothing to change" note.
-import { expect, test } from "@playwright/test";
-import { exportCatalog, gotoProducts, importGoodCsv, signUpWithStorefront } from "../helpers";
-
-test("e2e_roundtrip_noop", async ({ page }) => {
- await signUpWithStorefront(page);
- await gotoProducts(page);
- await importGoodCsv(page);
- await gotoProducts(page);
-
- // Export the catalog, then re-import the unmodified file.
- const { path } = await exportCatalog(page, "All products");
- 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(path);
-
- // Preview: everything unchanged, nothing to add/update (PUC-10).
- await expect(page.getByRole("button", { name: "2 unchanged" })).toBeVisible();
- await expect(page.getByRole("button", { name: "0 to add" })).toBeVisible();
- await expect(page.getByRole("button", { name: "0 to update" })).toBeVisible();
-
- // The import action is disabled, with the no-op note.
- const importBtn = page.getByRole("button", { name: /^Import 0 products$/ });
- await expect(importBtn).toBeDisabled();
- await expect(
- page.getByText("Nothing to change — your catalog already matches this file"),
- ).toBeVisible();
-});
diff --git a/frontend/index.html b/frontend/index.html
deleted file mode 100644
index 567778d..0000000
--- a/frontend/index.html
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
-
-
-
-
- ecomm
-
-
-
-
-
-
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
deleted file mode 100644
index 5a8e519..0000000
--- a/frontend/package-lock.json
+++ /dev/null
@@ -1,2174 +0,0 @@
-{
- "name": "wiggleverse-ecomm-frontend",
- "version": "0.8.0",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "wiggleverse-ecomm-frontend",
- "version": "0.8.0",
- "dependencies": {
- "react": "^18.3.1",
- "react-dom": "^18.3.1"
- },
- "devDependencies": {
- "@types/react": "^18.3.12",
- "@types/react-dom": "^18.3.1",
- "@vitejs/plugin-react": "^4.3.4",
- "typescript": "^5.6.3",
- "vite": "^5.4.11",
- "vitest": "^2.1.8"
- }
- },
- "node_modules/@babel/code-frame": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
- "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-validator-identifier": "^7.29.7",
- "js-tokens": "^4.0.0",
- "picocolors": "^1.1.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/compat-data": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
- "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/core": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
- "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/code-frame": "^7.29.7",
- "@babel/generator": "^7.29.7",
- "@babel/helper-compilation-targets": "^7.29.7",
- "@babel/helper-module-transforms": "^7.29.7",
- "@babel/helpers": "^7.29.7",
- "@babel/parser": "^7.29.7",
- "@babel/template": "^7.29.7",
- "@babel/traverse": "^7.29.7",
- "@babel/types": "^7.29.7",
- "@jridgewell/remapping": "^2.3.5",
- "convert-source-map": "^2.0.0",
- "debug": "^4.1.0",
- "gensync": "^1.0.0-beta.2",
- "json5": "^2.2.3",
- "semver": "^6.3.1"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/babel"
- }
- },
- "node_modules/@babel/generator": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
- "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.29.7",
- "@babel/types": "^7.29.7",
- "@jridgewell/gen-mapping": "^0.3.12",
- "@jridgewell/trace-mapping": "^0.3.28",
- "jsesc": "^3.0.2"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-compilation-targets": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
- "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/compat-data": "^7.29.7",
- "@babel/helper-validator-option": "^7.29.7",
- "browserslist": "^4.24.0",
- "lru-cache": "^5.1.1",
- "semver": "^6.3.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-globals": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
- "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-module-imports": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
- "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/traverse": "^7.29.7",
- "@babel/types": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-module-transforms": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
- "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-module-imports": "^7.29.7",
- "@babel/helper-validator-identifier": "^7.29.7",
- "@babel/traverse": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0"
- }
- },
- "node_modules/@babel/helper-plugin-utils": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
- "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-string-parser": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
- "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-validator-identifier": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
- "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-validator-option": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
- "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helpers": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
- "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/template": "^7.29.7",
- "@babel/types": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/parser": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
- "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/types": "^7.29.7"
- },
- "bin": {
- "parser": "bin/babel-parser.js"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@babel/plugin-transform-react-jsx-self": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
- "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-transform-react-jsx-source": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
- "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/template": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
- "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/code-frame": "^7.29.7",
- "@babel/parser": "^7.29.7",
- "@babel/types": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/traverse": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
- "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/code-frame": "^7.29.7",
- "@babel/generator": "^7.29.7",
- "@babel/helper-globals": "^7.29.7",
- "@babel/parser": "^7.29.7",
- "@babel/template": "^7.29.7",
- "@babel/types": "^7.29.7",
- "debug": "^4.3.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/types": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
- "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-string-parser": "^7.29.7",
- "@babel/helper-validator-identifier": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@esbuild/aix-ppc64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
- "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/android-arm": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
- "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/android-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
- "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/android-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
- "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/darwin-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
- "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/darwin-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
- "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/freebsd-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
- "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/freebsd-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
- "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-arm": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
- "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
- "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-ia32": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
- "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-loong64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
- "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-mips64el": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
- "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-ppc64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
- "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-riscv64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
- "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-s390x": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
- "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/linux-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
- "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/netbsd-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
- "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
- "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
- "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
- "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
- "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@esbuild/win32-x64": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
- "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.13",
- "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
- "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.0",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/remapping": {
- "version": "2.3.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
- "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/resolve-uri": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
- "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
- "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.31",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
- "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/resolve-uri": "^3.1.0",
- "@jridgewell/sourcemap-codec": "^1.4.14"
- }
- },
- "node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-beta.27",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
- "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz",
- "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-android-arm64": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz",
- "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz",
- "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz",
- "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz",
- "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz",
- "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz",
- "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz",
- "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz",
- "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz",
- "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz",
- "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-musl": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz",
- "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz",
- "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-musl": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz",
- "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz",
- "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz",
- "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz",
- "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz",
- "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz",
- "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-openbsd-x64": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz",
- "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ]
- },
- "node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz",
- "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ]
- },
- "node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz",
- "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz",
- "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz",
- "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz",
- "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@types/babel__core": {
- "version": "7.20.5",
- "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
- "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.20.7",
- "@babel/types": "^7.20.7",
- "@types/babel__generator": "*",
- "@types/babel__template": "*",
- "@types/babel__traverse": "*"
- }
- },
- "node_modules/@types/babel__generator": {
- "version": "7.27.0",
- "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
- "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/types": "^7.0.0"
- }
- },
- "node_modules/@types/babel__template": {
- "version": "7.4.4",
- "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
- "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.1.0",
- "@babel/types": "^7.0.0"
- }
- },
- "node_modules/@types/babel__traverse": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
- "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/types": "^7.28.2"
- }
- },
- "node_modules/@types/estree": {
- "version": "1.0.9",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
- "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/prop-types": {
- "version": "15.7.15",
- "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
- "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@types/react": {
- "version": "18.3.31",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
- "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/prop-types": "*",
- "csstype": "^3.2.2"
- }
- },
- "node_modules/@types/react-dom": {
- "version": "18.3.7",
- "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
- "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "^18.0.0"
- }
- },
- "node_modules/@vitejs/plugin-react": {
- "version": "4.7.0",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
- "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/core": "^7.28.0",
- "@babel/plugin-transform-react-jsx-self": "^7.27.1",
- "@babel/plugin-transform-react-jsx-source": "^7.27.1",
- "@rolldown/pluginutils": "1.0.0-beta.27",
- "@types/babel__core": "^7.20.5",
- "react-refresh": "^0.17.0"
- },
- "engines": {
- "node": "^14.18.0 || >=16.0.0"
- },
- "peerDependencies": {
- "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
- }
- },
- "node_modules/@vitest/expect": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
- "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/spy": "2.1.9",
- "@vitest/utils": "2.1.9",
- "chai": "^5.1.2",
- "tinyrainbow": "^1.2.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/mocker": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
- "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/spy": "2.1.9",
- "estree-walker": "^3.0.3",
- "magic-string": "^0.30.12"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "msw": "^2.4.9",
- "vite": "^5.0.0"
- },
- "peerDependenciesMeta": {
- "msw": {
- "optional": true
- },
- "vite": {
- "optional": true
- }
- }
- },
- "node_modules/@vitest/pretty-format": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
- "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "tinyrainbow": "^1.2.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/runner": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
- "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/utils": "2.1.9",
- "pathe": "^1.1.2"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/snapshot": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
- "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/pretty-format": "2.1.9",
- "magic-string": "^0.30.12",
- "pathe": "^1.1.2"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/spy": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
- "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "tinyspy": "^3.0.2"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/utils": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
- "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/pretty-format": "2.1.9",
- "loupe": "^3.1.2",
- "tinyrainbow": "^1.2.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/assertion-error": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
- "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/baseline-browser-mapping": {
- "version": "2.10.35",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz",
- "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "baseline-browser-mapping": "dist/cli.cjs"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/browserslist": {
- "version": "4.28.2",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
- "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/browserslist"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/browserslist"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "baseline-browser-mapping": "^2.10.12",
- "caniuse-lite": "^1.0.30001782",
- "electron-to-chromium": "^1.5.328",
- "node-releases": "^2.0.36",
- "update-browserslist-db": "^1.2.3"
- },
- "bin": {
- "browserslist": "cli.js"
- },
- "engines": {
- "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
- }
- },
- "node_modules/cac": {
- "version": "6.7.14",
- "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
- "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/caniuse-lite": {
- "version": "1.0.30001797",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz",
- "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/browserslist"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "CC-BY-4.0"
- },
- "node_modules/chai": {
- "version": "5.3.3",
- "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
- "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "assertion-error": "^2.0.1",
- "check-error": "^2.1.1",
- "deep-eql": "^5.0.1",
- "loupe": "^3.1.0",
- "pathval": "^2.0.0"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/check-error": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
- "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 16"
- }
- },
- "node_modules/convert-source-map": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
- "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/csstype": {
- "version": "3.2.3",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
- "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/debug": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/deep-eql": {
- "version": "5.0.2",
- "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
- "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/electron-to-chromium": {
- "version": "1.5.371",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz",
- "integrity": "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/es-module-lexer": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
- "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/esbuild": {
- "version": "0.21.5",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
- "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
- },
- "engines": {
- "node": ">=12"
- },
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.21.5",
- "@esbuild/android-arm": "0.21.5",
- "@esbuild/android-arm64": "0.21.5",
- "@esbuild/android-x64": "0.21.5",
- "@esbuild/darwin-arm64": "0.21.5",
- "@esbuild/darwin-x64": "0.21.5",
- "@esbuild/freebsd-arm64": "0.21.5",
- "@esbuild/freebsd-x64": "0.21.5",
- "@esbuild/linux-arm": "0.21.5",
- "@esbuild/linux-arm64": "0.21.5",
- "@esbuild/linux-ia32": "0.21.5",
- "@esbuild/linux-loong64": "0.21.5",
- "@esbuild/linux-mips64el": "0.21.5",
- "@esbuild/linux-ppc64": "0.21.5",
- "@esbuild/linux-riscv64": "0.21.5",
- "@esbuild/linux-s390x": "0.21.5",
- "@esbuild/linux-x64": "0.21.5",
- "@esbuild/netbsd-x64": "0.21.5",
- "@esbuild/openbsd-x64": "0.21.5",
- "@esbuild/sunos-x64": "0.21.5",
- "@esbuild/win32-arm64": "0.21.5",
- "@esbuild/win32-ia32": "0.21.5",
- "@esbuild/win32-x64": "0.21.5"
- }
- },
- "node_modules/escalade": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
- "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/estree-walker": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
- "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "^1.0.0"
- }
- },
- "node_modules/expect-type": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
- "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=12.0.0"
- }
- },
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/gensync": {
- "version": "1.0.0-beta.2",
- "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
- "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/js-tokens": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
- "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "license": "MIT"
- },
- "node_modules/jsesc": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
- "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "jsesc": "bin/jsesc"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/json5": {
- "version": "2.2.3",
- "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
- "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "json5": "lib/cli.js"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/loose-envify": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
- "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
- "license": "MIT",
- "dependencies": {
- "js-tokens": "^3.0.0 || ^4.0.0"
- },
- "bin": {
- "loose-envify": "cli.js"
- }
- },
- "node_modules/loupe": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
- "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/lru-cache": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
- "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "yallist": "^3.0.2"
- }
- },
- "node_modules/magic-string": {
- "version": "0.30.21",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
- "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.5"
- }
- },
- "node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/nanoid": {
- "version": "3.3.12",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
- "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "bin": {
- "nanoid": "bin/nanoid.cjs"
- },
- "engines": {
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
- }
- },
- "node_modules/node-releases": {
- "version": "2.0.47",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
- "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/pathe": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
- "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/pathval": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
- "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 14.16"
- }
- },
- "node_modules/picocolors": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
- "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/postcss": {
- "version": "8.5.15",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
- "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/postcss"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "nanoid": "^3.3.12",
- "picocolors": "^1.1.1",
- "source-map-js": "^1.2.1"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- }
- },
- "node_modules/react": {
- "version": "18.3.1",
- "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
- "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
- "license": "MIT",
- "dependencies": {
- "loose-envify": "^1.1.0"
- },
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/react-dom": {
- "version": "18.3.1",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
- "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
- "license": "MIT",
- "dependencies": {
- "loose-envify": "^1.1.0",
- "scheduler": "^0.23.2"
- },
- "peerDependencies": {
- "react": "^18.3.1"
- }
- },
- "node_modules/react-refresh": {
- "version": "0.17.0",
- "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
- "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/rollup": {
- "version": "4.61.1",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
- "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "1.0.9"
- },
- "bin": {
- "rollup": "dist/bin/rollup"
- },
- "engines": {
- "node": ">=18.0.0",
- "npm": ">=8.0.0"
- },
- "optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.61.1",
- "@rollup/rollup-android-arm64": "4.61.1",
- "@rollup/rollup-darwin-arm64": "4.61.1",
- "@rollup/rollup-darwin-x64": "4.61.1",
- "@rollup/rollup-freebsd-arm64": "4.61.1",
- "@rollup/rollup-freebsd-x64": "4.61.1",
- "@rollup/rollup-linux-arm-gnueabihf": "4.61.1",
- "@rollup/rollup-linux-arm-musleabihf": "4.61.1",
- "@rollup/rollup-linux-arm64-gnu": "4.61.1",
- "@rollup/rollup-linux-arm64-musl": "4.61.1",
- "@rollup/rollup-linux-loong64-gnu": "4.61.1",
- "@rollup/rollup-linux-loong64-musl": "4.61.1",
- "@rollup/rollup-linux-ppc64-gnu": "4.61.1",
- "@rollup/rollup-linux-ppc64-musl": "4.61.1",
- "@rollup/rollup-linux-riscv64-gnu": "4.61.1",
- "@rollup/rollup-linux-riscv64-musl": "4.61.1",
- "@rollup/rollup-linux-s390x-gnu": "4.61.1",
- "@rollup/rollup-linux-x64-gnu": "4.61.1",
- "@rollup/rollup-linux-x64-musl": "4.61.1",
- "@rollup/rollup-openbsd-x64": "4.61.1",
- "@rollup/rollup-openharmony-arm64": "4.61.1",
- "@rollup/rollup-win32-arm64-msvc": "4.61.1",
- "@rollup/rollup-win32-ia32-msvc": "4.61.1",
- "@rollup/rollup-win32-x64-gnu": "4.61.1",
- "@rollup/rollup-win32-x64-msvc": "4.61.1",
- "fsevents": "~2.3.2"
- }
- },
- "node_modules/scheduler": {
- "version": "0.23.2",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
- "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
- "license": "MIT",
- "dependencies": {
- "loose-envify": "^1.1.0"
- }
- },
- "node_modules/semver": {
- "version": "6.3.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
- "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- }
- },
- "node_modules/siginfo": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
- "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/source-map-js": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
- "dev": true,
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/stackback": {
- "version": "0.0.2",
- "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
- "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/std-env": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
- "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/tinybench": {
- "version": "2.9.0",
- "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
- "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/tinyexec": {
- "version": "0.3.2",
- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
- "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/tinypool": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
- "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^18.0.0 || >=20.0.0"
- }
- },
- "node_modules/tinyrainbow": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
- "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/tinyspy": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
- "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/typescript": {
- "version": "5.9.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
- "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "node_modules/update-browserslist-db": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
- "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/browserslist"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/browserslist"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "escalade": "^3.2.0",
- "picocolors": "^1.1.1"
- },
- "bin": {
- "update-browserslist-db": "cli.js"
- },
- "peerDependencies": {
- "browserslist": ">= 4.21.0"
- }
- },
- "node_modules/vite": {
- "version": "5.4.21",
- "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
- "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "esbuild": "^0.21.3",
- "postcss": "^8.4.43",
- "rollup": "^4.20.0"
- },
- "bin": {
- "vite": "bin/vite.js"
- },
- "engines": {
- "node": "^18.0.0 || >=20.0.0"
- },
- "funding": {
- "url": "https://github.com/vitejs/vite?sponsor=1"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- },
- "peerDependencies": {
- "@types/node": "^18.0.0 || >=20.0.0",
- "less": "*",
- "lightningcss": "^1.21.0",
- "sass": "*",
- "sass-embedded": "*",
- "stylus": "*",
- "sugarss": "*",
- "terser": "^5.4.0"
- },
- "peerDependenciesMeta": {
- "@types/node": {
- "optional": true
- },
- "less": {
- "optional": true
- },
- "lightningcss": {
- "optional": true
- },
- "sass": {
- "optional": true
- },
- "sass-embedded": {
- "optional": true
- },
- "stylus": {
- "optional": true
- },
- "sugarss": {
- "optional": true
- },
- "terser": {
- "optional": true
- }
- }
- },
- "node_modules/vite-node": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
- "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "cac": "^6.7.14",
- "debug": "^4.3.7",
- "es-module-lexer": "^1.5.4",
- "pathe": "^1.1.2",
- "vite": "^5.0.0"
- },
- "bin": {
- "vite-node": "vite-node.mjs"
- },
- "engines": {
- "node": "^18.0.0 || >=20.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/vitest": {
- "version": "2.1.9",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
- "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/expect": "2.1.9",
- "@vitest/mocker": "2.1.9",
- "@vitest/pretty-format": "^2.1.9",
- "@vitest/runner": "2.1.9",
- "@vitest/snapshot": "2.1.9",
- "@vitest/spy": "2.1.9",
- "@vitest/utils": "2.1.9",
- "chai": "^5.1.2",
- "debug": "^4.3.7",
- "expect-type": "^1.1.0",
- "magic-string": "^0.30.12",
- "pathe": "^1.1.2",
- "std-env": "^3.8.0",
- "tinybench": "^2.9.0",
- "tinyexec": "^0.3.1",
- "tinypool": "^1.0.1",
- "tinyrainbow": "^1.2.0",
- "vite": "^5.0.0",
- "vite-node": "2.1.9",
- "why-is-node-running": "^2.3.0"
- },
- "bin": {
- "vitest": "vitest.mjs"
- },
- "engines": {
- "node": "^18.0.0 || >=20.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "@edge-runtime/vm": "*",
- "@types/node": "^18.0.0 || >=20.0.0",
- "@vitest/browser": "2.1.9",
- "@vitest/ui": "2.1.9",
- "happy-dom": "*",
- "jsdom": "*"
- },
- "peerDependenciesMeta": {
- "@edge-runtime/vm": {
- "optional": true
- },
- "@types/node": {
- "optional": true
- },
- "@vitest/browser": {
- "optional": true
- },
- "@vitest/ui": {
- "optional": true
- },
- "happy-dom": {
- "optional": true
- },
- "jsdom": {
- "optional": true
- }
- }
- },
- "node_modules/why-is-node-running": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
- "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "siginfo": "^2.0.0",
- "stackback": "0.0.2"
- },
- "bin": {
- "why-is-node-running": "cli.js"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/yallist": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
- "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
- "dev": true,
- "license": "ISC"
- }
- }
-}
diff --git a/frontend/package.json b/frontend/package.json
deleted file mode 100644
index c207e7a..0000000
--- a/frontend/package.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "name": "wiggleverse-ecomm-frontend",
- "private": true,
- "version": "0.8.0",
- "type": "module",
- "scripts": {
- "dev": "vite",
- "build": "tsc && vite build",
- "preview": "vite preview",
- "test": "vitest run"
- },
- "dependencies": {
- "react": "^18.3.1",
- "react-dom": "^18.3.1"
- },
- "devDependencies": {
- "@types/react": "^18.3.12",
- "@types/react-dom": "^18.3.1",
- "@vitejs/plugin-react": "^4.3.4",
- "typescript": "^5.6.3",
- "vite": "^5.4.11",
- "vitest": "^2.1.8"
- }
-}
diff --git a/frontend/public/brand/mark-mono-gold.svg b/frontend/public/brand/mark-mono-gold.svg
deleted file mode 100644
index cc7fde2..0000000
--- a/frontend/public/brand/mark-mono-gold.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
- Wiggleverse — mono gold
-
-
-
-
-
-
\ No newline at end of file
diff --git a/frontend/public/brand/mark-tile.svg b/frontend/public/brand/mark-tile.svg
deleted file mode 100644
index 51d6c69..0000000
--- a/frontend/public/brand/mark-tile.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-
- Wiggleverse
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/frontend/public/fonts/inter-v20-latin-500.woff2 b/frontend/public/fonts/inter-v20-latin-500.woff2
deleted file mode 100644
index 54f0a59..0000000
Binary files a/frontend/public/fonts/inter-v20-latin-500.woff2 and /dev/null differ
diff --git a/frontend/public/fonts/inter-v20-latin-600.woff2 b/frontend/public/fonts/inter-v20-latin-600.woff2
deleted file mode 100644
index d189794..0000000
Binary files a/frontend/public/fonts/inter-v20-latin-600.woff2 and /dev/null differ
diff --git a/frontend/public/fonts/inter-v20-latin-regular.woff2 b/frontend/public/fonts/inter-v20-latin-regular.woff2
deleted file mode 100644
index f15b025..0000000
Binary files a/frontend/public/fonts/inter-v20-latin-regular.woff2 and /dev/null differ
diff --git a/frontend/public/fonts/space-grotesk-v22-latin-500.woff2 b/frontend/public/fonts/space-grotesk-v22-latin-500.woff2
deleted file mode 100644
index 0db251f..0000000
Binary files a/frontend/public/fonts/space-grotesk-v22-latin-500.woff2 and /dev/null differ
diff --git a/frontend/public/fonts/space-grotesk-v22-latin-700.woff2 b/frontend/public/fonts/space-grotesk-v22-latin-700.woff2
deleted file mode 100644
index 44604a0..0000000
Binary files a/frontend/public/fonts/space-grotesk-v22-latin-700.woff2 and /dev/null differ
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
deleted file mode 100644
index 6ddc626..0000000
--- a/frontend/src/App.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-// App shell — loads the session, applies the entry-routing rule (SD-0001 §6.5), and renders
-// the matching screen. The rule is exhaustive: no state lands nowhere (BUC-4). The verify
-// response carries the same shape as /me, so onAuthed feeds the session directly.
-import { useEffect, useState } from "react";
-import { getMe, type StorefrontResult, type VerifyResult } from "./api";
-import { routeFor, type SessionState } from "./routing";
-import Admin from "./screens/Admin";
-import CreateStorefront from "./screens/CreateStorefront";
-import Landing from "./screens/Landing";
-import SignIn from "./screens/SignIn";
-
-type Door = "signup" | "login";
-type Welcome = "new" | "back" | null;
-
-export default function App() {
- const [session, setSession] = useState(null);
- const [loading, setLoading] = useState(true);
- const [door, setDoor] = useState(null);
- const [welcome, setWelcome] = useState(null);
-
- async function reload() {
- setLoading(true);
- setSession(await getMe());
- setLoading(false);
- }
-
- useEffect(() => {
- void reload();
- }, []);
-
- if (loading) {
- return (
-
- );
- }
-
- const route = routeFor(session ?? { account: null, storefront: null });
-
- if (route === "landing") {
- if (door) {
- return (
- setDoor(null)}
- onAuthed={(result: VerifyResult) => {
- setWelcome(result.created ? "new" : "back");
- setDoor(null);
- setSession({ account: result.account, storefront: result.storefront });
- }}
- />
- );
- }
- return setDoor("signup")} onLogIn={() => setDoor("login")} />;
- }
-
- const email = session!.account!.email;
-
- function signedOut() {
- setWelcome(null);
- setDoor(null);
- setSession(null);
- }
-
- if (route === "create-storefront") {
- return (
- {
- setWelcome(null);
- setSession({ account: { email }, storefront: sf });
- }}
- onAlreadyOwns={() => void reload()}
- onSignedOut={signedOut}
- />
- );
- }
-
- return (
-
- );
-}
diff --git a/frontend/src/adminRouting.test.ts b/frontend/src/adminRouting.test.ts
deleted file mode 100644
index 76acf0d..0000000
--- a/frontend/src/adminRouting.test.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-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" });
- });
-});
diff --git a/frontend/src/adminRouting.ts b/frontend/src/adminRouting.ts
deleted file mode 100644
index adfb6ed..0000000
--- a/frontend/src/adminRouting.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-// 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}`;
- }
-}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
deleted file mode 100644
index caf75cf..0000000
--- a/frontend/src/api.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-// Typed fetch wrappers for the /api/auth/* surface (SD-0001 §6.4). Same-origin via the Vite
-// proxy; cookies carry the session. Errors return the §6.4 envelope; helpers normalize them.
-import type { SessionState } from "./routing";
-
-export interface ApiError {
- code: string;
- message: string;
- retry_after_s?: number;
- attempts_remaining?: number;
-}
-
-export interface VerifyResult {
- account: { email: string };
- storefront: { id: number; name: string } | null;
- created: boolean;
-}
-
-export async function errorOf(resp: Response): Promise {
- try {
- const body = await resp.json();
- if (body && body.error) return body.error as ApiError;
- } catch {
- /* fall through */
- }
- return { code: "unexpected", message: "Something went wrong. Please try again." };
-}
-
-export async function getMe(): Promise {
- const resp = await fetch("/api/auth/me", { credentials: "include" });
- if (resp.status === 401) return null;
- if (!resp.ok) return null;
- return (await resp.json()) as SessionState;
-}
-
-export async function requestCode(email: string): Promise {
- const resp = await fetch("/api/auth/request-code", {
- method: "POST",
- headers: { "content-type": "application/json" },
- credentials: "include",
- body: JSON.stringify({ email }),
- });
- return resp.ok ? null : await errorOf(resp);
-}
-
-export async function verifyCode(
- email: string,
- code: string,
-): Promise<{ ok: true; result: VerifyResult } | { ok: false; error: ApiError }> {
- const resp = await fetch("/api/auth/verify", {
- method: "POST",
- headers: { "content-type": "application/json" },
- credentials: "include",
- body: JSON.stringify({ email, code }),
- });
- if (resp.ok) return { ok: true, result: (await resp.json()) as VerifyResult };
- return { ok: false, error: await errorOf(resp) };
-}
-
-export async function logout(): Promise {
- 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) };
-}
diff --git a/frontend/src/cooldown.test.ts b/frontend/src/cooldown.test.ts
deleted file mode 100644
index 10405d1..0000000
--- a/frontend/src/cooldown.test.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { cooldownAppliesTo } from "./cooldown";
-
-describe("resend cooldown keying (SD-0001 §5.2, INV-3; bug #20)", () => {
- it("no cooldown running -> send not blocked", () => {
- expect(cooldownAppliesTo("a@example.com", null, 0)).toBe(false);
- });
-
- it("cooldown running for the same address -> blocked", () => {
- expect(cooldownAppliesTo("a@example.com", "a@example.com", 31)).toBe(true);
- });
-
- it("same address modulo case/whitespace -> still blocked", () => {
- expect(cooldownAppliesTo(" A@Example.COM ", "a@example.com", 31)).toBe(true);
- });
-
- it("cooldown running but the input is a different address -> not blocked", () => {
- expect(cooldownAppliesTo("right@example.com", "wrong@example.com", 31)).toBe(false);
- });
-
- it("cooldown expired -> not blocked even for the same address", () => {
- expect(cooldownAppliesTo("a@example.com", "a@example.com", 0)).toBe(false);
- });
-});
diff --git a/frontend/src/cooldown.ts b/frontend/src/cooldown.ts
deleted file mode 100644
index 4e25b48..0000000
--- a/frontend/src/cooldown.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-// Resend-cooldown keying (SD-0001 §5.2, INV-3; bug #20). The server's cooldown is
-// per-address, so the client countdown must be too: it blocks sending only while it
-// is running AND the current input is the address it was set for. Normalization
-// mirrors the backend's normalize_email (lowercase + strip, INV-2).
-
-function normalize(email: string): string {
- return email.trim().toLowerCase();
-}
-
-export function cooldownAppliesTo(
- input: string,
- cooldownEmail: string | null,
- secondsLeft: number,
-): boolean {
- if (secondsLeft <= 0 || cooldownEmail === null) return false;
- return normalize(input) === normalize(cooldownEmail);
-}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
deleted file mode 100644
index 0b18e9d..0000000
--- a/frontend/src/main.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import "./styles/index.css";
-import { StrictMode } from "react";
-import { createRoot } from "react-dom/client";
-import App from "./App";
-
-createRoot(document.getElementById("root")!).render(
-
-
- ,
-);
diff --git a/frontend/src/productsApi.dialect.test.ts b/frontend/src/productsApi.dialect.test.ts
deleted file mode 100644
index dd53acc..0000000
--- a/frontend/src/productsApi.dialect.test.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { dialectLabel } from "./productsApi";
-
-describe("dialectLabel", () => {
- it("labels canonical", () => expect(dialectLabel("canonical")).toBe("Canonical format"));
- it("labels shopify as mapped (PUC-6)", () =>
- expect(dialectLabel("shopify")).toBe("Shopify product CSV — mapped"));
- it("passes through an unknown dialect verbatim", () =>
- expect(dialectLabel("weird")).toBe("weird"));
-});
diff --git a/frontend/src/productsApi.export.test.ts b/frontend/src/productsApi.export.test.ts
deleted file mode 100644
index 28c936e..0000000
--- a/frontend/src/productsApi.export.test.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { EXPORT_STATUSES, exportUrl } from "./productsApi";
-
-describe("export url", () => {
- it("lists the four status filters with 'all' first", () => {
- expect(EXPORT_STATUSES.map((s) => s.value)).toEqual(["all", "active", "draft", "archived"]);
- });
- it("builds the endpoint url with the status query", () => {
- expect(exportUrl("all")).toBe("/api/products/export?status=all");
- expect(exportUrl("archived")).toBe("/api/products/export?status=archived");
- });
-});
diff --git a/frontend/src/productsApi.ts b/frontend/src/productsApi.ts
deleted file mode 100644
index 94c2dbd..0000000
--- a/frontend/src/productsApi.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-// 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; 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;
- option_names?: (string | null)[];
- changes?: FieldChange[];
- variants?: VariantEntry[];
- images?: ImageEntry[];
- errors?: RowErrorDetail[];
- };
-}
-export interface ImageOutcome {
- handle: string; url: string; outcome: string; reason: string | null; variant: string | null;
-}
-export interface ImageCounts { fetched: number; rejected: number; failed: number; }
-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;
- image_counts?: ImageCounts;
-}
-export interface RunDetail extends RunSummary {
- errors: RowErrorDetail[];
- image_progress: { done: number; total: number };
- image_counts: ImageCounts;
- image_outcomes: ImageOutcome[];
-}
-export interface ProductsSummary {
- product_count: number; image_problem_count: number; latest_run_id: number | null;
-}
-
-export type Result = { 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 {
- if (d === "canonical") return "Canonical format";
- if (d === "shopify") return "Shopify product CSV — mapped";
- return d;
-}
-
-export type ExportStatus = "all" | "active" | "draft" | "archived";
-
-// PUC-9 status filter, 'all' first (the default). Labels drive the export menu.
-export const EXPORT_STATUSES: { value: ExportStatus; label: string }[] = [
- { value: "all", label: "All products" },
- { value: "active", label: "Active" },
- { value: "draft", label: "Draft" },
- { value: "archived", label: "Archived" },
-];
-
-// The export is a browser download (native save dialog + streaming), not a fetch
-// wrapper — so the API module contributes a URL builder, not a request().
-export function exportUrl(status: ExportStatus): string {
- return `/api/products/export?status=${status}`;
-}
-
-async function request(path: string, init?: RequestInit): Promise> {
- 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> {
- return request("/api/products/summary");
-}
-
-export function uploadImport(file: File): Promise> {
- 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> {
- return request(`/api/products/imports/drafts/${id}`);
-}
-
-export async function getDraftRecords(
- id: number, kind?: RecordKind, limit = 100, offset = 0,
-): Promise> {
- 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> {
- return request(`/api/products/imports/drafts/${id}/confirm`, { method: "POST" });
-}
-
-export function cancelDraft(id: number): Promise> {
- return request(`/api/products/imports/drafts/${id}`, { method: "DELETE" });
-}
-
-export async function listRuns(): Promise> {
- 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> {
- return request(`/api/products/imports/runs/${id}`);
-}
diff --git a/frontend/src/routing.test.ts b/frontend/src/routing.test.ts
deleted file mode 100644
index 9e954fa..0000000
--- a/frontend/src/routing.test.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { routeFor } from "./routing";
-
-describe("entry routing (SD-0001 §6.5)", () => {
- it("no session -> landing", () => {
- expect(routeFor({ account: null, storefront: null })).toBe("landing");
- });
-
- it("session without storefront -> create-storefront", () => {
- expect(routeFor({ account: { email: "m@example.com" }, storefront: null })).toBe(
- "create-storefront",
- );
- });
-
- it("session with storefront -> admin", () => {
- expect(
- routeFor({ account: { email: "m@example.com" }, storefront: { id: 1, name: "Shop" } }),
- ).toBe("admin");
- });
-});
diff --git a/frontend/src/routing.ts b/frontend/src/routing.ts
deleted file mode 100644
index 16096dd..0000000
--- a/frontend/src/routing.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-// The single client-side entry-routing rule (SD-0001 §6.5). Exhaustive: every state lands
-// somewhere (BUC-4 — never stranded). Fed by one server answer (GET /api/auth/me, or the
-// verify response, which share this shape). storefront is always null in SLICE-2; SLICE-3
-// makes "admin" reachable.
-export interface SessionState {
- account: { email: string } | null;
- storefront: { id: number; name: string } | null;
-}
-
-export type Screen = "landing" | "create-storefront" | "admin";
-
-export function routeFor(s: SessionState): Screen {
- if (!s.account) return "landing";
- if (!s.storefront) return "create-storefront";
- return "admin";
-}
diff --git a/frontend/src/screens/Admin.tsx b/frontend/src/screens/Admin.tsx
deleted file mode 100644
index e44dc45..0000000
--- a/frontend/src/screens/Admin.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-// 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;
- email: string;
- welcome: "new" | "back" | null;
- onSignedOut: () => void;
-}
-
-export default function Admin({ storefrontName, email, welcome, onSignedOut }: Props) {
- const [view, setView] = useState(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();
- }
-
- return (
-
-
-
-
- {storefrontName}
- ecomm storefront
-
-
- }
- right={ }
- />
-
-
- Overview
-
-
- Products
-
-
-
- {view.view === "home" && (
-
- {welcome && (
-
-
- {welcome === "new"
- ? "A new account was created for this email."
- : "Signed in to your existing account."}
-
-
- )}
-
-
-
-
Your storefront
-
{storefrontName}
-
- 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.
-
-
- )}
- {view.view === "products" && }
- {view.view === "import-upload" && }
- {view.view === "import-preview" && }
- {view.view === "run-detail" && }
-
-
- );
-}
diff --git a/frontend/src/screens/CreateStorefront.tsx b/frontend/src/screens/CreateStorefront.tsx
deleted file mode 100644
index 4b521ef..0000000
--- a/frontend/src/screens/CreateStorefront.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-// 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(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 (
-
- } right={ } />
-
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/screens/Landing.tsx b/frontend/src/screens/Landing.tsx
deleted file mode 100644
index 7e5ca5d..0000000
--- a/frontend/src/screens/Landing.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-// Landing (SD-0001 §5.1) — a Visitor learns what ecomm is and picks a door. Honest voice,
-// no trial/pricing/fee (corpus 14.01.0011). Both doors open the same sign-in flow (§5.2);
-// they differ only in framing. Visuals per the ui/designs export (hf-landing, Direction A).
-import { 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 (
-
- }
- right={
-
- Log in
-
- }
- />
-
-
-
One storefront, fully yours
-
- Sell online,
-
- honestly.
-
-
- 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.
-
-
-
- Create your storefront →
-
-
- Already selling with us? Log in
-
-
-
- {PROMISES.map(([title, copy]) => (
-
- ))}
-
-
-
-
-
- );
-}
diff --git a/frontend/src/screens/SignIn.tsx b/frontend/src/screens/SignIn.tsx
deleted file mode 100644
index b00e7b2..0000000
--- a/frontend/src/screens/SignIn.tsx
+++ /dev/null
@@ -1,198 +0,0 @@
-// Sign in (SD-0001 §5.2) — the single email + one-time-code flow behind both doors. Step 1
-// requests a code; step 2 verifies it. Honest copy for every §5.2 state; storefront routing
-// is handled by App on success. Visuals per the ui/designs export (hf-signin).
-import { useEffect, useState } from "react";
-import { requestCode, verifyCode, type ApiError, type VerifyResult } from "../api";
-import { cooldownAppliesTo } from "../cooldown";
-import CodeInput from "../ui/CodeInput";
-import { AuthCard, Banner, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
-
-type Door = "signup" | "login";
-
-interface Props {
- door: Door;
- onAuthed: (result: VerifyResult) => void;
- onBack: () => void;
-}
-
-function useCooldown(): [number, (s: number) => void] {
- const [left, setLeft] = useState(0);
- 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 [fieldError, setFieldError] = useState(null);
- const [codeError, setCodeError] = useState(null);
- const [bannerError, setBannerError] = useState(null);
- const [cooldown, setCooldown] = useCooldown();
- // The address the running cooldown belongs to — the server's cooldown is per-address
- // (INV-3), so a different address must not be blocked by it (bug #20).
- const [cooldownFor, setCooldownFor] = useState(null);
-
- function applyError(err: ApiError) {
- setFieldError(null);
- setCodeError(null);
- setBannerError(null);
- if (err.code === "invalid_email") setFieldError(err.message);
- else if (err.code === "resend_cooldown") setCooldown(err.retry_after_s ?? 60);
- else if (err.code === "code_mismatch" || err.code === "code_expired") setCodeError(err.message);
- else setBannerError(err); // delivery_failed, code_exhausted, unexpected
- }
-
- async function sendCode(): Promise {
- setBusy(true);
- setFieldError(null);
- setBannerError(null);
- const err = await requestCode(email);
- setBusy(false);
- if (err) {
- applyError(err);
- if (err.code === "resend_cooldown") setCooldownFor(email);
- return err.code === "resend_cooldown"; // a cooldown still means a code is out there
- }
- setCooldown(60);
- setCooldownFor(email);
- return true;
- }
-
- async function submitEmail(e: React.FormEvent) {
- e.preventDefault();
- if (await sendCode()) {
- setCode("");
- setCodeError(null);
- setStep("code");
- }
- }
-
- async function resend() {
- setCodeError(null);
- setCode("");
- await sendCode();
- }
-
- async function submitCode(e: React.FormEvent) {
- e.preventDefault();
- setBusy(true);
- setCodeError(null);
- setBannerError(null);
- const res = await verifyCode(email, code);
- setBusy(false);
- if (!res.ok) {
- applyError(res.error);
- return;
- }
- onAuthed(res.result);
- }
-
- const heading = door === "signup" ? "Create your storefront" : "Log in";
- const sub =
- door === "signup"
- ? "Enter your email to begin. There's no password to create."
- : "Enter your email and we'll send a one-time code to sign in.";
-
- return (
-
- }
- right={
-
- ← Back
-
- }
- />
-
-
- {step === "email" ? (
-
- ) : (
-
- )}
-
-
-
-
- );
-}
diff --git a/frontend/src/screens/products/ImportPreview.tsx b/frontend/src/screens/products/ImportPreview.tsx
deleted file mode 100644
index 6b63d0d..0000000
--- a/frontend/src/screens/products/ImportPreview.tsx
+++ /dev/null
@@ -1,393 +0,0 @@
-// 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 {kind} ;
-}
-
-function SetLine({ field, value }: { field: string; value: unknown }) {
- return (
-
- +
- {field}: {fmt(value)}
-
- );
-}
-
-function ChangeRow({ change }: { change: FieldChange }) {
- return (
-
- {change.field}: − {fmt(change.before)} →{" "}
- + {fmt(change.after)}
-
- );
-}
-
-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 (
-
- {(d.errors ?? []).map((e, i) => (
-
- line {e.line}: {e.column != null && `'${e.column}' — `}
- {e.message}
-
- ))}
-
- );
- }
- return (
-
- {Object.entries(d.set ?? {}).map(([field, value]) => (
-
- ))}
- {(d.changes ?? []).map((c, i) => (
-
- ))}
- {(d.variants ?? []).map((v, i) => (
-
-
- {variantLabel(v)}
- {v.kind ? ` (${v.kind})` : ""}
-
- {Object.entries(v.set ?? {}).map(([field, value]) => (
-
- ))}
- {(v.changes ?? []).map((c, j) => (
-
- ))}
-
- ))}
- {(d.images ?? []).map((img, i) =>
- img.kind && img.kind !== "add" ? (
-
-
- image: {img.src} ({img.kind})
-
- {(img.changes ?? []).map((c, j) => (
-
- ))}
-
- ) : (
-
- + image: {img.src}
-
- ),
- )}
-
- );
-}
-
-function ErrorTable({ records }: { records: DraftRecord[] }) {
- const rows = records.flatMap((r) => r.detail.errors ?? []);
- if (rows.length === 0) return null;
- return (
-
-
-
- Line
- Column
- Problem
-
-
-
- {rows.map((e, i) => (
-
- {e.line}
- {e.column ?? "—"}
- {e.message}
-
- ))}
-
-
- );
-}
-
-export default function ImportPreview({ draftId }: { draftId: number }) {
- const [draft, setDraft] = useState(null);
- const [loadFail, setLoadFail] = useState<"gone" | "expired" | "failed" | null>(null);
- const [filter, setFilter] = useState(null);
- const [records, setRecords] = useState(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(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 (
-
- Back to Products
-
- );
- }
- if (loadFail === "expired") {
- return (
-
- Upload the file again
-
- );
- }
- if (loadFail === "failed") {
- return (
-
- Something went wrong on our side.{" "}
- void loadDraft()}>
- Retry
-
-
- );
- }
- if (!draft) {
- return (
-
- Loading…
-
- );
- }
-
- 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 (
-
-
- ← Products
-
-
Import preview — {draft.file_name}
-
{dialectLabel(draft.dialect)}
- {draft.unknown_columns.length > 0 && (
-
- {draft.unknown_columns.length > 8 ? (
-
- {draft.unknown_columns.length} columns not imported
- {draft.unknown_columns.join(", ")}
-
- ) : (
- draft.unknown_columns.join(", ")
- )}
-
- )}
-
- {tiles.map((t) => (
- setFilter(filter === t.kind ? null : t.kind)}
- >
- {t.num.toLocaleString()}
- {t.label}
-
- ))}
-
- {filter === "error" && records &&
}
- {recordsError === "load" ? (
-
- Couldn't load these records.{" "}
- loadRecords(filter)}>
- Retry
-
-
- ) : records === null ? (
-
- Loading…
-
- ) : records.length === 0 ? (
-
Nothing to show here.
- ) : (
-
- {records.map((r, i) => (
-
-
- {r.handle} · {r.title} ·{" "}
- · {r.variant_count}{" "}
- {r.variant_count === 1 ? "variant" : "variants"}
-
-
-
- ))}
-
- )}
- {hasMore && (
-
- void showMore()}>
- {moreBusy ? "Loading…" : "Show more"}
-
- {recordsError === "more" && (
-
- Couldn't load more records.{" "}
- void showMore()}>
- Retry
-
-
- )}
-
- )}
-
- {stale ? (
-
- Upload the file again
-
- ) : (
- <>
-
void onConfirm()}
- >
- {confirming ? "Importing…" : `Import ${toApply.toLocaleString()} products`}
-
-
void onCancel()}
- >
- Cancel
-
- {(toApply === 0 || nothingNote) && (
-
Nothing to change — your catalog already matches this file
- )}
- {confirmError && (
-
- {confirmError}
-
- )}
- >
- )}
-
-
- );
-}
diff --git a/frontend/src/screens/products/ImportUpload.tsx b/frontend/src/screens/products/ImportUpload.tsx
deleted file mode 100644
index 9e18fe5..0000000
--- a/frontend/src/screens/products/ImportUpload.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-// 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(null);
- const inputRef = useRef(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 (
-
-
- ← Products
-
-
Import products
- {error && (
-
- {error}
-
- )}
-
- void onPick(e.target.files)}
- />
-
- {busy ? "Validating…" : "Choose a CSV file"}
-
- CSV, up to 5,000 rows
-
-
- Works with the canonical format or a Shopify product CSV.{" "}
-
- Download sample CSV
- {" "}
- ·{" "}
-
- Column reference
-
-
-
- );
-}
diff --git a/frontend/src/screens/products/ProductsPage.tsx b/frontend/src/screens/products/ProductsPage.tsx
deleted file mode 100644
index 29f3180..0000000
--- a/frontend/src/screens/products/ProductsPage.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-// Products page (SD-0002 §5.2) — the catalog's home: where imports start, exports
-// download, and history lives. SLICE-6 ships the export menu; the browsable list is #14's.
-import { useEffect, useState } from "react";
-import {
- dialectLabel,
- EXPORT_STATUSES,
- exportUrl,
- getProductsSummary,
- listRuns,
- type ProductsSummary,
- type RunSummary,
-} from "../../productsApi";
-import { Banner } from "../../ui/kit";
-import { isExportEnabled } from "./exportMenu";
-import { historyImageCell } from "./runImages";
-
-const STATUS_LABELS: Record = {
- applying: "Importing…",
- fetching_images: "Fetching images…",
- complete: "Complete",
- complete_with_problems: "Complete with problems",
-};
-
-export default function ProductsPage() {
- const [summary, setSummary] = useState(null);
- const [runs, setRuns] = useState(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 (
-
- Something went wrong on our side.{" "}
- void load()}>
- Retry
-
-
- );
- }
- if (!summary || !runs) {
- return (
-
- Loading…
-
- );
- }
-
- const empty = !isExportEnabled(summary.product_count);
- return (
-
-
- {summary.image_problem_count > 0 && (
-
- )}
- {empty ? (
-
- ) : (
-
Your catalog is loaded. The browsable product list arrives with an upcoming release.
- )}
-
- Import history
- {runs.length === 0 ? (
- No imports yet.
- ) : (
-
-
-
- Date File Dialect Added Updated Errors Images Status
-
-
-
- {runs.map((r) => (
- {
- window.location.hash = `#/products/imports/runs/${r.id}`;
- }}
- >
- {new Date(r.created_at).toLocaleString()}
-
- {/* 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. */}
- {r.file_name}
-
- {dialectLabel(r.dialect)}
- {r.products_added}
- {r.products_updated}
- {r.rows_errored}
- {historyImageCell(r.image_counts)}
- {STATUS_LABELS[r.status] ?? r.status}
-
- ))}
-
-
- )}
-
-
- );
-}
diff --git a/frontend/src/screens/products/RunDetail.tsx b/frontend/src/screens/products/RunDetail.tsx
deleted file mode 100644
index cb578ef..0000000
--- a/frontend/src/screens/products/RunDetail.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-// Run detail (SD-0002 §5.5) — report card for a completed or in-progress import run.
-// The durable record of the import's image-fetch outcome: live progress while
-// fetching (polled, no websockets — deliberate), then per-image problem rows. PUC-4/5/8.
-import { useEffect, useState } from "react";
-import { dialectLabel, getRun, type RunDetail as RunDetailType } from "../../productsApi";
-import {
- imageOutcomeSummary,
- imageProgressLabel,
- isRunTerminal,
- outcomeLabel,
-} from "./runImages";
-import { Banner } from "../../ui/kit";
-
-const STATUS_LABELS: Record = {
- 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(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]);
-
- // While the run is still applying / fetching images, poll the endpoint so the
- // "Fetching images: X of Y" line advances (no websockets — deliberate, §5.5).
- // The interval is cleared on terminal status and on unmount.
- useEffect(() => {
- if (!run || isRunTerminal(run.status)) return;
- const id = setInterval(() => {
- void (async () => {
- const resp = await getRun(runId);
- if (resp.ok) setRun(resp.value);
- })();
- }, 2000);
- return () => clearInterval(id);
- }, [run, runId]);
-
- if (loadFail === "gone") {
- return (
-
- ← Products
-
- );
- }
- if (loadFail === "failed") {
- return (
-
- Something went wrong on our side.{" "}
- void load()}>
- Retry
-
-
- );
- }
- if (!run) {
- return (
-
- Loading…
-
- );
- }
-
- return (
-
-
- ← Products
-
-
{run.file_name}
-
- Imported {new Date(run.created_at).toLocaleString()} by {run.by} · {dialectLabel(run.dialect)}
-
-
- {run.products_added} added · {run.products_updated} updated · {run.rows_errored} rows in
- error
-
-
{STATUS_LABELS[run.status] ?? run.status}
- {run.errors.length > 0 && (
-
-
-
- Line
- Column
- Problem
-
-
-
- {run.errors.map((e, i) => (
-
- {e.line}
- {e.column ?? "—"}
- {e.message}
-
- ))}
-
-
- )}
- {!isRunTerminal(run.status) ? (
-
- {imageProgressLabel(run.image_progress)}
-
- ) : (
- run.image_progress.total > 0 && (
- <>
-
{imageOutcomeSummary(run.image_counts)}
- {run.image_outcomes.length > 0 ? (
-
-
-
- Handle
- Variant
- Image URL
- Outcome
- What to do
-
-
-
- {run.image_outcomes.map((o, i) => (
-
- {o.handle}
- {o.variant ?? "—"}
- {o.url}
- {outcomeLabel(o.outcome)}
- Correct the URL and re-import.
-
- ))}
-
-
- ) : (
-
All images fetched.
- )}
- >
- )
- )}
-
- );
-}
diff --git a/frontend/src/screens/products/exportMenu.test.ts b/frontend/src/screens/products/exportMenu.test.ts
deleted file mode 100644
index 63e4e79..0000000
--- a/frontend/src/screens/products/exportMenu.test.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { EXPORT_STATUSES, exportUrl } from "../../productsApi";
-import { isExportEnabled } from "./exportMenu";
-
-describe("export menu", () => {
- it("disables export for an empty catalog and enables it once there are products", () => {
- expect(isExportEnabled(0)).toBe(false);
- expect(isExportEnabled(3)).toBe(true);
- });
-
- it("builds the four status download URLs, 'all' first", () => {
- expect(EXPORT_STATUSES.map((s) => exportUrl(s.value))).toEqual([
- "/api/products/export?status=all",
- "/api/products/export?status=active",
- "/api/products/export?status=draft",
- "/api/products/export?status=archived",
- ]);
- });
-});
diff --git a/frontend/src/screens/products/exportMenu.ts b/frontend/src/screens/products/exportMenu.ts
deleted file mode 100644
index 6640c8a..0000000
--- a/frontend/src/screens/products/exportMenu.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-// Pure logic behind the Products page Export menu (SD-0002 §5.2, PUC-9). The
-// status list + URL builder live in productsApi.ts (re-used by the component);
-// this is the one decision the menu turns on — whether export is offered at all.
-// The disclosure JSX is verified by the E2E suite, matching SLICE-2/3's pattern
-// of pure-logic unit tests + E2E for screens.
-
-// Export is offered only when the catalog has products (PUC-9: an empty catalog
-// shows a disabled button + note instead).
-export function isExportEnabled(count: number): boolean {
- return count > 0;
-}
diff --git a/frontend/src/screens/products/runImages.test.ts b/frontend/src/screens/products/runImages.test.ts
deleted file mode 100644
index 6065207..0000000
--- a/frontend/src/screens/products/runImages.test.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { historyImageCell, imageOutcomeSummary, imageProgressLabel, isRunTerminal, outcomeLabel } from "./runImages";
-
-describe("runImages helpers", () => {
- it("progress label", () => {
- expect(imageProgressLabel({ done: 312, total: 4950 })).toBe("Fetching images: 312 of 4,950");
- });
- it("outcome summary", () => {
- expect(imageOutcomeSummary({ fetched: 4938, rejected: 9, failed: 3 }))
- .toBe("4,938 fetched · 9 rejected · 3 failed");
- });
- it("history cell collapses clean / shows problems / dashes empty", () => {
- expect(historyImageCell({ fetched: 10, rejected: 0, failed: 0 })).toBe("10 ✓");
- expect(historyImageCell({ fetched: 8, rejected: 1, failed: 1 })).toBe("8 ✓ · 2 ✗");
- expect(historyImageCell(undefined)).toBe("—");
- });
- it("terminal status", () => {
- expect(isRunTerminal("fetching_images")).toBe(false);
- expect(isRunTerminal("complete_with_problems")).toBe(true);
- });
- it("outcome labels", () => {
- expect(outcomeLabel("rejected_low_res")).toMatch(/resolution bar/);
- expect(outcomeLabel("failed")).toMatch(/unreachable/);
- });
-});
diff --git a/frontend/src/screens/products/runImages.ts b/frontend/src/screens/products/runImages.ts
deleted file mode 100644
index 3026ac7..0000000
--- a/frontend/src/screens/products/runImages.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-// Pure logic behind the image-fetch surfaces (SD-0002 §5.5): the run-detail
-// progress/outcome labels + the history "Images" cell + terminal-status test
-// that drives polling. Mirrors exportMenu.ts: pure helpers unit-tested here,
-// the JSX verified by the E2E suite (SLICE-2/3 pattern).
-import type { ImageCounts } from "../../productsApi";
-
-export function imageProgressLabel(p: { done: number; total: number }): string {
- return `Fetching images: ${p.done.toLocaleString()} of ${p.total.toLocaleString()}`;
-}
-
-export function imageOutcomeSummary(c: ImageCounts): string {
- return `${c.fetched.toLocaleString()} fetched · ${c.rejected.toLocaleString()} rejected · ${c.failed.toLocaleString()} failed`;
-}
-
-export function historyImageCell(c: ImageCounts | undefined): string {
- if (!c || c.fetched + c.rejected + c.failed === 0) return "—";
- const bad = c.rejected + c.failed;
- return bad === 0
- ? `${c.fetched.toLocaleString()} ✓`
- : `${c.fetched.toLocaleString()} ✓ · ${bad.toLocaleString()} ✗`;
-}
-
-export function isRunTerminal(status: string): boolean {
- return status === "complete" || status === "complete_with_problems";
-}
-
-export function outcomeLabel(outcome: string): string {
- if (outcome === "rejected_low_res") return "Rejected — below the resolution bar";
- if (outcome === "rejected_not_image") return "Rejected — not a supported image";
- if (outcome === "failed") return "Failed — unreachable";
- return outcome;
-}
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css
deleted file mode 100644
index 683dd53..0000000
--- a/frontend/src/styles/app.css
+++ /dev/null
@@ -1,394 +0,0 @@
-/* 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; }
-}
diff --git a/frontend/src/styles/fonts.css b/frontend/src/styles/fonts.css
deleted file mode 100644
index 56abbf6..0000000
--- a/frontend/src/styles/fonts.css
+++ /dev/null
@@ -1,37 +0,0 @@
-/* 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");
-}
diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css
deleted file mode 100644
index e733f58..0000000
--- a/frontend/src/styles/index.css
+++ /dev/null
@@ -1,8 +0,0 @@
-/* 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";
-@import "./products.css";
diff --git a/frontend/src/styles/products.css b/frontend/src/styles/products.css
deleted file mode 100644
index 57f976f..0000000
--- a/frontend/src/styles/products.css
+++ /dev/null
@@ -1,303 +0,0 @@
-/* Products section (SD-0002 §5) — admin nav strip, the catalog's home page, and the
- import-flow primitives (dropzone, tiles, difflist — Tasks 12–14 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 "no products yet" caption, stacked (empty catalog). */
-.products__export { display: flex; flex-direction: column; gap: 4px; align-items: center; }
-.products__export .note { font-size: 11.5px; }
-
-/* Export status menu (SD-0002 §5.2 — PUC-9). A native disclosure so
- it's keyboard-accessible with no extra JS (§6.6). Tokens align with the design
- bundle (--surface-raised / --border-card / --radius-panel); the fallbacks keep
- it working regardless. */
-.products__export.menu {
- position: relative;
- display: inline-block;
-}
-.products__export.menu > summary {
- list-style: none;
- cursor: pointer;
-}
-.products__export.menu > summary::-webkit-details-marker {
- display: none;
-}
-.menu__list {
- position: absolute;
- right: 0;
- z-index: 10;
- margin: 0.25rem 0 0;
- padding: 0.25rem;
- list-style: none;
- background: var(--surface-raised, #fff);
- border: 1px solid var(--border-card, #d8d3c8);
- border-radius: var(--radius-panel, 8px);
- box-shadow: var(--shadow-soft, 0 6px 20px rgba(0, 0, 0, 0.12));
- min-width: 10rem;
-}
-.menu__item {
- display: block;
- padding: 0.5rem 0.75rem;
- border-radius: var(--radius-sm, 6px);
- text-decoration: none;
- color: var(--text-on-dark-soft, inherit);
- font-family: var(--wv-font-display);
- font-size: 14px;
-}
-.menu__item:hover,
-.menu__item:focus {
- background: var(--surface-raised-hi, #f3efe7);
- color: var(--wv-starlight);
-}
-.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; }
-}
diff --git a/frontend/src/styles/tokens-colors.css b/frontend/src/styles/tokens-colors.css
deleted file mode 100644
index 57edf8e..0000000
--- a/frontend/src/styles/tokens-colors.css
+++ /dev/null
@@ -1,60 +0,0 @@
-/* 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);
-}
diff --git a/frontend/src/styles/tokens-spacing.css b/frontend/src/styles/tokens-spacing.css
deleted file mode 100644
index 04a084a..0000000
--- a/frontend/src/styles/tokens-spacing.css
+++ /dev/null
@@ -1,55 +0,0 @@
-/* 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 */
-}
diff --git a/frontend/src/styles/tokens-typography.css b/frontend/src/styles/tokens-typography.css
deleted file mode 100644
index 900a7fe..0000000
--- a/frontend/src/styles/tokens-typography.css
+++ /dev/null
@@ -1,53 +0,0 @@
-/* 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;
-}
diff --git a/frontend/src/ui/CodeInput.tsx b/frontend/src/ui/CodeInput.tsx
deleted file mode 100644
index 32934e0..0000000
--- a/frontend/src/ui/CodeInput.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-// Six-cell one-time-code input: one real (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 (
-
- setFocused(true)}
- onBlur={() => setFocused(false)}
- onChange={(ev) => onChange(ev.target.value.replace(/\D/g, "").slice(0, LENGTH))}
- />
- {Array.from({ length: LENGTH }, (_, i) => (
-
- {digits[i] ?? ""}
-
- ))}
-
- );
-}
diff --git a/frontend/src/ui/kit.tsx b/frontend/src/ui/kit.tsx
deleted file mode 100644
index bbda4ec..0000000
--- a/frontend/src/ui/kit.tsx
+++ /dev/null
@@ -1,150 +0,0 @@
-// 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 {children}
;
-}
-
-export function TopBar({ left, right }: { left: ReactNode; right?: ReactNode }) {
- return (
-
- );
-}
-
-export function Wordmark({ size = 26 }: { size?: number }) {
- return (
-
-
-
- ecomm
-
-
- );
-}
-
-export function Footer() {
- return (
-
-
-
- ecomm · a Wiggleverse line
-
-
- );
-}
-
-export function AuthCard({ children }: { children: ReactNode }) {
- return {children}
;
-}
-
-export function Eyebrow({ children }: { children: ReactNode }) {
- return {children}
;
-}
-
-export function Banner({
- tone = "attn",
- title,
- children,
-}: {
- tone?: "attn" | "info";
- title?: string;
- children: ReactNode;
-}) {
- return (
-
-
- {tone === "attn" ? "◆" : "◇"}
-
-
- {title &&
{title}
}
- {children}
-
-
- );
-}
-
-export function PrimaryButton({
- children,
- busy = false,
- disabled = false,
- type = "submit",
- onClick,
-}: {
- children: ReactNode;
- busy?: boolean;
- disabled?: boolean;
- type?: "submit" | "button";
- onClick?: () => void;
-}) {
- return (
-
- {children}
-
- );
-}
-
-export function Field({
- label,
- optional = false,
- error,
- helper,
- inputProps,
-}: {
- label: string;
- optional?: boolean;
- error?: string | null;
- helper?: string;
- inputProps: React.InputHTMLAttributes;
-}) {
- const id = useId();
- return (
-
-
- {label}
- {optional && optional }
-
-
- {error && (
-
- {error}
-
- )}
- {helper && !error &&
{helper}
}
-
- );
-}
-
-export function AccountChip({ email, onSignOut }: { email: string; onSignOut: () => void }) {
- return (
-
-
- {email[0]?.toUpperCase()}
-
- {email}
-
-
- Sign out
-
-
- );
-}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
deleted file mode 100644
index 42e0521..0000000
--- a/frontend/tsconfig.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2020",
- "useDefineForClassFields": true,
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
- "module": "ESNext",
- "skipLibCheck": true,
- "moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
- "resolveJsonModule": true,
- "isolatedModules": true,
- "noEmit": true,
- "jsx": "react-jsx",
- "strict": true
- },
- "include": ["src"]
-}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
deleted file mode 100644
index 270cc61..0000000
--- a/frontend/vite.config.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-///
-import react from "@vitejs/plugin-react";
-import { defineConfig } from "vitest/config";
-
-// The SPA talks to the FastAPI backend on :8000 via a same-origin proxy, so the
-// browser makes no cross-origin calls in dev. Override the target with
-// VITE_API_TARGET when :8000 is occupied. Screen-shaped /api/* endpoints arrive in
-// SLICE-2/3 (SD-0001 §6.4).
-const apiTarget = process.env.VITE_API_TARGET || "http://localhost:8000";
-
-export default defineConfig({
- plugins: [react()],
- server: {
- port: 5173,
- proxy: {
- "/api": apiTarget,
- },
- },
- // Tests live alongside source; scoping the include to src/ keeps the runner out of
- // node_modules (and any stray local backup dirs) without naming them.
- test: {
- include: ["src/**/*.test.{ts,tsx}"],
- },
-});
diff --git a/scripts/check.sh b/scripts/check.sh
index 4c76d9a..ffff0a0 100755
--- a/scripts/check.sh
+++ b/scripts/check.sh
@@ -2,7 +2,8 @@
# The single pre-merge gate for wiggleverse-ecomm (SD-0001 §6.8). Runs, in order:
# 1. the import-linter layering contract (main > domains > platform)
# 2. the backend test suite (pytest, against Postgres)
-# 3. the frontend typecheck + production build
+# The network-seed reset stripped the SPA frontend, so the frontend typecheck/build
+# and vitest steps are gone — this is a backend-only gate now.
# CI (.gitea/workflows/ci.yml) calls this exact script, so local and server gates
# cannot drift. Exits non-zero on the first failure.
#
@@ -31,10 +32,4 @@ echo "==> import boundaries (lint-imports)"
echo "==> backend tests (pytest)"
( cd "$repo_root/backend" && "$PY" -m pytest -q )
-echo "==> frontend typecheck + build"
-( cd "$repo_root/frontend" && npm run build )
-
-echo "==> frontend unit tests (vitest)"
-( cd "$repo_root/frontend" && npm test )
-
echo "==> all gates green"
diff --git a/scripts/dev.sh b/scripts/dev.sh
index c1adbfd..421679f 100755
--- a/scripts/dev.sh
+++ b/scripts/dev.sh
@@ -1,12 +1,14 @@
#!/usr/bin/env bash
-# Local bring-up for ecomm (SD-0001 PUC-10). From a clean checkout this:
+# Local bring-up for the ecomm network-service seed (SD-0001 PUC-10). From a clean
+# checkout this:
# 1. starts the dev Postgres container and waits for it to be healthy (owns its
# lifecycle — Docker is the one prerequisite, docs/BOOTSTRAP.md);
-# 2. ensures the backend venv + deps and the frontend node_modules;
-# 3. runs the FastAPI backend (:8000, self-migrating an empty DB) and the Vite dev
-# server (:5173, proxying /api -> :8000) together.
-# Ctrl-C stops both processes; the container keeps running (stop it with
+# 2. ensures the backend venv + deps;
+# 3. runs the FastAPI backend (:8000, self-migrating an empty DB).
+# Ctrl-C stops the backend; the container keeps running (stop it with
# `docker compose down`, or `docker compose down -v` to reset to empty).
+# The storefront SPA was removed in the network-seed reset — there is no frontend dev
+# server yet (the network admin UI is a future build).
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -24,41 +26,6 @@ if [ ! -x "$repo_root/.venv/bin/python" ]; then
"$repo_root/.venv/bin/python" -m pip install -r "$repo_root/backend/requirements.txt"
fi
-if [ ! -d "$repo_root/frontend/node_modules" ]; then
- echo "==> installing frontend deps"
- ( cd "$repo_root/frontend" && npm install )
-fi
-
-echo "==> starting backend (:8000) and Vite (:5173) — Ctrl-C to stop"
-"$repo_root/.venv/bin/python" -m uvicorn app.main:app --app-dir "$repo_root/backend" --port 8000 --reload &
-backend_pid=$!
-( cd "$repo_root/frontend" && npm run dev ) &
-frontend_pid=$!
-
-# Kill a process and its whole descendant tree (children first), portably across
-# macOS and Linux — npm spawns vite as a grandchild, so killing the captured pid
-# alone would orphan it on :5173. `pgrep -P` (list children) exists on both.
-kill_tree() {
- local pid=$1 child
- for child in $(pgrep -P "$pid" 2>/dev/null); do
- kill_tree "$child"
- done
- kill "$pid" 2>/dev/null || true
-}
-
-cleanup() {
- trap - INT TERM
- echo
- echo "==> stopping backend and Vite"
- kill_tree "$backend_pid"
- kill_tree "$frontend_pid"
- wait "$backend_pid" "$frontend_pid" 2>/dev/null || true
-}
-trap cleanup INT TERM
-
-# Wait until either process exits, then tear the other down. `wait -n` would be
-# tidier but is unsupported on macOS's bundled bash 3.2, so poll portably.
-while kill -0 "$backend_pid" 2>/dev/null && kill -0 "$frontend_pid" 2>/dev/null; do
- sleep 1
-done
-cleanup
+echo "==> starting backend (:8000) — Ctrl-C to stop"
+# exec hands the terminal (and Ctrl-C) straight to uvicorn; no child-process juggling.
+exec "$repo_root/.venv/bin/python" -m uvicorn app.main:app --app-dir "$repo_root/backend" --port 8000 --reload
diff --git a/scripts/e2e.sh b/scripts/e2e.sh
deleted file mode 100755
index 373dd01..0000000
--- a/scripts/e2e.sh
+++ /dev/null
@@ -1,11 +0,0 @@
-#!/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 "$@"