Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c18ddcc561 | |||
| 84a2bdbd78 | |||
| df92e3f94c | |||
| e2d8b5caae | |||
| a851d3587c | |||
| cb6b5996f3 | |||
| 17f3b600fd | |||
| 6459e34480 | |||
| 60a06de302 | |||
| 0aeccf7d2c | |||
| a00f5baaec | |||
| f170950aec | |||
| 0c7a76a305 | |||
| daa237dd59 | |||
| e16ec7cf94 | |||
| 8ed9b25771 | |||
| c929282e07 | |||
| 757412ef2a | |||
| 0775537d4c | |||
| d81215be1d | |||
| 7f51f5242f | |||
| 5d5341b29b | |||
| 23267c4d4c | |||
| 13e74f4c61 | |||
| 984dc98dee | |||
| 906bc87c96 | |||
| da5177c950 | |||
| 0fc29a34dd | |||
| f27a24353d | |||
| 385df8d728 |
+3
-12
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -1,13 +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 .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,
|
||||
@@ -18,26 +32,37 @@ from .errors import (
|
||||
ProductsError,
|
||||
RunNotFound,
|
||||
)
|
||||
from .models import MAX_DATA_ROWS, MAX_FILE_BYTES
|
||||
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.
|
||||
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",
|
||||
"import_validate", "get_draft", "get_draft_records", "discard_draft",
|
||||
"confirm_draft", "list_runs", "get_run", "summary", "export_catalog",
|
||||
# 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",
|
||||
]
|
||||
|
||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
||||
import csv
|
||||
import io
|
||||
|
||||
from .dialect_shopify import is_shopify_header, map_shopify_header
|
||||
from .errors import FileRejected
|
||||
from .models import KNOWN_COLUMNS, MAX_DATA_ROWS, MAX_FILE_BYTES, ParsedFile, Row
|
||||
|
||||
@@ -13,8 +14,8 @@ _REQUIRED_HEADER_COLUMNS = ("Handle", "Title")
|
||||
|
||||
|
||||
def detect_dialect(header: list[str]) -> str:
|
||||
"""The INV-17 seam: SLICE-8 recognizes Shopify's exact header set here."""
|
||||
return "canonical"
|
||||
"""The INV-17 seam: recognize Shopify's header set, else canonical (§6.5.1)."""
|
||||
return "shopify" if is_shopify_header(header) else "canonical"
|
||||
|
||||
|
||||
def parse_csv(data: bytes) -> ParsedFile:
|
||||
@@ -31,19 +32,34 @@ def parse_csv(data: bytes) -> ParsedFile:
|
||||
except StopIteration:
|
||||
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
|
||||
header = [h.strip() for h in raw_header]
|
||||
dialect = detect_dialect(header)
|
||||
|
||||
# INV-17: normalize the header to canonical names at the boundary. mapped[i]
|
||||
# is the canonical name for header[i] (or None when that column has no
|
||||
# canonical home); unknown is the not-imported warning list. Canonical files
|
||||
# map to themselves; unknown columns are warned exactly as before.
|
||||
if dialect == "shopify":
|
||||
mapped, unknown = map_shopify_header(header)
|
||||
else:
|
||||
mapped = [c if c in KNOWN_COLUMNS else None for c in header]
|
||||
unknown = [c for c in header if c and c not in KNOWN_COLUMNS]
|
||||
|
||||
for col in _REQUIRED_HEADER_COLUMNS:
|
||||
if col not in header:
|
||||
if col not in mapped:
|
||||
raise FileRejected(
|
||||
"missing_required_column",
|
||||
f"This file is missing the required column '{col}'.",
|
||||
)
|
||||
# First occurrence of a duplicated column wins.
|
||||
|
||||
# First occurrence of a duplicated canonical column wins.
|
||||
col_index: dict[str, int] = {}
|
||||
for i, name in enumerate(header):
|
||||
for i, name in enumerate(mapped):
|
||||
if name and name not in col_index:
|
||||
col_index[name] = i
|
||||
known_present = [c for c in col_index if c in KNOWN_COLUMNS]
|
||||
unknown = [c for c in col_index if c not in KNOWN_COLUMNS]
|
||||
# De-dup the warning list, order-preserving.
|
||||
seen: set[str] = set()
|
||||
unknown = [c for c in unknown if not (c in seen or seen.add(c))]
|
||||
|
||||
rows: list[Row] = []
|
||||
for raw in reader:
|
||||
if not any(cell.strip() for cell in raw):
|
||||
@@ -55,11 +71,16 @@ def parse_csv(data: bytes) -> ParsedFile:
|
||||
)
|
||||
cells = {
|
||||
c: (raw[col_index[c]].strip() if col_index[c] < len(raw) else "")
|
||||
for c in known_present
|
||||
for c in col_index
|
||||
}
|
||||
# Shopify grams carry an implicit unit; canonical needs it explicit. In a
|
||||
# Shopify file "Variant Weight" can only come from the Variant Grams rename
|
||||
# (a canonical-named Variant Weight would have vetoed Shopify detection), so
|
||||
# weight and unit move together: a value -> "g"; a cleared grams (present-but-
|
||||
# empty) clears the unit too, never leaving a stale unit (§6.5.1).
|
||||
if dialect == "shopify" and "Variant Weight" in cells:
|
||||
cells["Variant Weight Unit"] = "g" if cells["Variant Weight"] else ""
|
||||
rows.append(Row(line_number=reader.line_num, cells=cells))
|
||||
except csv.Error:
|
||||
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
|
||||
return ParsedFile(
|
||||
dialect=detect_dialect(header), header=header, unknown_columns=unknown, rows=rows
|
||||
)
|
||||
return ParsedFile(dialect=dialect, header=header, unknown_columns=unknown, rows=rows)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Product CSV — column reference
|
||||
|
||||
This is the complete reference for the product CSV you import into and export from
|
||||
your store (SD-0002 §6.5.1). The format is **canonical** — a clean superset of the
|
||||
Shopify product CSV — and a **Shopify product CSV** imports directly too: we detect
|
||||
which one you uploaded and map it for you (see *Shopify dialect* below).
|
||||
|
||||
You don't pick a format. Upload your file; the preview tells you which format was
|
||||
recognized and shows exactly what will change before anything is applied.
|
||||
|
||||
## File shape
|
||||
|
||||
- UTF-8 (a byte-order mark is tolerated), comma-delimited, RFC 4180 quoting.
|
||||
- A header row is **required**. `Handle` and `Title` are the only always-required
|
||||
columns.
|
||||
- Up to 5,000 rows and 10 MB per file. Split larger catalogs and import in parts.
|
||||
|
||||
## Row grammar
|
||||
|
||||
Consecutive rows that share a `Handle` describe **one product**. The product's first
|
||||
row carries the product-level fields (`Title` is required there). Each row may carry:
|
||||
|
||||
- a **variant** — its `Option n Value`s plus `Variant *` fields;
|
||||
- an **image** — `Image Src` with `Image Position` / `Image Alt Text`;
|
||||
- or both.
|
||||
|
||||
An image-only row (just `Handle` + `Image *`) is valid — that's how a product carries
|
||||
more images than it has variants.
|
||||
|
||||
## Updating: blank vs. absent
|
||||
|
||||
- A column **absent from your file** is left untouched on existing products.
|
||||
- A cell that is **present but empty** clears that field (resets it to its default).
|
||||
|
||||
Every clear is shown explicitly in the preview, so this is deliberate, visible
|
||||
behavior — never a silent surprise.
|
||||
|
||||
## Canonical columns
|
||||
|
||||
| Column | Level | Required | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `Handle` | product | every row | Identity: lowercase letters, numbers, and dashes. |
|
||||
| `Title` | product | first row of a product | |
|
||||
| `Description` | product | — | HTML allowed; sanitized on import. |
|
||||
| `Vendor` | product | — | Free text. |
|
||||
| `Type` | product | — | `standalone` (default). `kit_virtual` / `kit_assembled` are reserved; any non-`standalone` value is a row error until kits ship. |
|
||||
| `Google Product Category` | product | — | Taxonomy string. |
|
||||
| `Tags` | product | — | Comma-separated within the cell. |
|
||||
| `Status` | product | — | `draft` \| `active` \| `archived`; defaults to `active` on add. |
|
||||
| `Published` | product | — | `TRUE` \| `FALSE`; defaults to `TRUE` on add. |
|
||||
| `Option1 Name` … `Option3 Name` | product | with values | e.g. "Size". A value without its name is a row error. |
|
||||
| `Option1 Value` … `Option3 Value` | variant | per variant | The variant's identity combination. |
|
||||
| `Variant SKU` | variant | — | Indexed; not identity. |
|
||||
| `Variant Barcode` | variant | — | |
|
||||
| `Variant Price` | variant | — | Decimal; no currency symbol. |
|
||||
| `Variant Cost` | variant | — | Decimal. |
|
||||
| `Variant Weight` + `Variant Weight Unit` | variant | — | Decimal weight plus a unit string. |
|
||||
| `Variant Volume` + `Variant Volume Unit` | variant | — | Decimal volume plus a unit string. |
|
||||
| `Variant Tax ID 1` / `Variant Tax ID 2` | variant | — | Opaque references. |
|
||||
| `Variant Inventory Tracker` | variant | — | |
|
||||
| `Variant Inventory Qty` | variant | — | Integer ≥ 0. |
|
||||
| `Variant Position` | variant | — | Display order; defaults to file order. |
|
||||
| `Image Src` | image | — | URL (or a store-hosted URL on re-import). |
|
||||
| `Image Position` | image | — | Integer order. |
|
||||
| `Image Alt Text` | image | — | |
|
||||
| `Variant Image` | variant | — | URL for this variant's specific image. |
|
||||
| `Component 1 SKU` / `Component 1 Quantity` … `Component 10 …` | variant | — | Reserved for kits — a non-empty value is a row error today. |
|
||||
|
||||
## Shopify dialect
|
||||
|
||||
If you upload a **Shopify product CSV**, we recognize it by its header set and map it
|
||||
to the canonical columns automatically. The preview labels it *"Shopify product
|
||||
CSV — mapped"*.
|
||||
|
||||
**Mapped — renamed**
|
||||
|
||||
| Shopify column | Becomes |
|
||||
| --- | --- |
|
||||
| `Body (HTML)` | `Description` |
|
||||
| `Product Category` | `Google Product Category` |
|
||||
| `Cost per item` | `Variant Cost` |
|
||||
| `Variant Grams` | `Variant Weight` (in grams) |
|
||||
|
||||
**Mapped — same name:** `Handle`, `Title`, `Vendor`, `Tags`, `Published`, `Status`,
|
||||
`Option1–3 Name` / `Option1–3 Value`, `Variant SKU`, `Variant Barcode`,
|
||||
`Variant Price`, `Variant Inventory Tracker`, `Variant Inventory Qty`,
|
||||
`Variant Image`, `Image Src`, `Image Position`, `Image Alt Text`.
|
||||
|
||||
**Not imported — listed in the preview as a heads-up, never silently dropped:** the
|
||||
Shopify free-text `Type` (canonical `Type` is structural, so we don't fold a category
|
||||
string into it), `Variant Compare At Price`, `Variant Inventory Policy`,
|
||||
`Variant Fulfillment Service`, `Variant Requires Shipping`, `Variant Taxable`,
|
||||
`Variant Tax Code`, `Gift Card`, SEO fields, every `Google Shopping / …` field, and
|
||||
market/region price columns. These have no canonical home yet; your other data still
|
||||
imports.
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Shopify product-CSV adapter (INV-17): detect by header signature, map to canonical.
|
||||
|
||||
The mapping is the §6.5.1 contract; the exhaustive table is pinned here and
|
||||
mirrored by tests/test_products_dialect_shopify.py + e2e/fixtures/shopify-export.csv.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import KNOWN_COLUMNS
|
||||
|
||||
# Shopify header -> canonical header (renames only; direct same-name columns pass
|
||||
# through via KNOWN_COLUMNS). Variant Grams carries a value transform (see codec).
|
||||
SHOPIFY_RENAME: dict[str, str] = {
|
||||
"Body (HTML)": "Description",
|
||||
"Product Category": "Google Product Category",
|
||||
"Cost per item": "Variant Cost",
|
||||
"Variant Grams": "Variant Weight",
|
||||
}
|
||||
|
||||
# Canonical-named columns Shopify uses differently — must NOT pass through.
|
||||
# Type: Shopify's free-text type vs canonical's structural Type (§6.5.1, decision D).
|
||||
# Variant Weight Unit: superseded by the Variant Grams -> Variant Weight transform.
|
||||
SHOPIFY_DROP: frozenset[str] = frozenset({"Type", "Variant Weight Unit"})
|
||||
|
||||
# Columns whose presence proves the file is a Shopify export (canonical never has them).
|
||||
_SIGNATURE: frozenset[str] = frozenset(
|
||||
{
|
||||
"Body (HTML)",
|
||||
"Variant Grams",
|
||||
"Cost per item",
|
||||
"Variant Compare At Price",
|
||||
"Variant Inventory Policy",
|
||||
"Variant Fulfillment Service",
|
||||
"Variant Requires Shipping",
|
||||
"Variant Taxable",
|
||||
"Gift Card",
|
||||
"SEO Title",
|
||||
"SEO Description",
|
||||
}
|
||||
)
|
||||
|
||||
# Canonical-distinctive columns — names Shopify renames away (so a real Shopify export
|
||||
# never carries them) plus canonical-only columns Shopify has no equivalent for. Their
|
||||
# presence proves the file is canonical and VETOES Shopify detection: detection must
|
||||
# lean conservative, because under-detection is safe (Shopify-named columns are warned
|
||||
# as not-imported) while over-detection corrupts (canonical Type / weight-unit are
|
||||
# dropped). This closes the misdetection hazard — a canonical file that merely contains
|
||||
# a stray signature-named column (e.g. `SEO Title`) is never misread as Shopify (§7.4).
|
||||
_CANONICAL_SIGNATURE: frozenset[str] = frozenset(
|
||||
{
|
||||
"Description",
|
||||
"Variant Cost",
|
||||
"Variant Weight",
|
||||
"Google Product Category",
|
||||
"Variant Volume",
|
||||
"Variant Volume Unit",
|
||||
"Variant Tax ID 1",
|
||||
"Variant Tax ID 2",
|
||||
"Variant Position",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_shopify_header(header: list[str]) -> bool:
|
||||
"""True iff the header carries a Shopify-only signature column AND no
|
||||
canonical-distinctive column. A canonical signal vetoes Shopify detection so an
|
||||
ambiguous or hybrid header is treated as canonical — where Shopify-named columns
|
||||
are warned as not-imported rather than silently remapped (never misparses, §7.4)."""
|
||||
cols = {h.strip() for h in header}
|
||||
if cols & _CANONICAL_SIGNATURE:
|
||||
return False
|
||||
if cols & _SIGNATURE:
|
||||
return True
|
||||
return any(c.startswith("Google Shopping / ") for c in cols)
|
||||
|
||||
|
||||
def map_shopify_header(header: list[str]) -> tuple[list[str | None], list[str]]:
|
||||
"""Return (mapped, not_imported): mapped[i] is the canonical name for header[i],
|
||||
or None when that Shopify column has no canonical home; not_imported lists the
|
||||
original Shopify names (in order) that were dropped — the preview warning."""
|
||||
mapped: list[str | None] = []
|
||||
not_imported: list[str] = []
|
||||
for raw in header:
|
||||
col = raw.strip()
|
||||
if col in SHOPIFY_RENAME:
|
||||
mapped.append(SHOPIFY_RENAME[col])
|
||||
elif col in SHOPIFY_DROP:
|
||||
mapped.append(None)
|
||||
not_imported.append(col)
|
||||
elif col in KNOWN_COLUMNS:
|
||||
mapped.append(col)
|
||||
else:
|
||||
mapped.append(None)
|
||||
if col:
|
||||
not_imported.append(col)
|
||||
return mapped, not_imported
|
||||
@@ -21,10 +21,11 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from decimal import Decimal
|
||||
|
||||
from .models import CLEAR_DEFAULTS, CanonicalProduct, CanonicalVariant
|
||||
from . import hosted
|
||||
from .models import CLEAR_DEFAULTS, CanonicalProduct, CanonicalVariant, RowError
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -44,6 +45,7 @@ class CatalogImage:
|
||||
source_url: str
|
||||
position: int
|
||||
alt_text: str | None
|
||||
status: str = "pending"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -118,6 +120,7 @@ def compute_diff(catalog: dict[str, CatalogProduct], products: list[CanonicalPro
|
||||
summary = {"adds": 0, "updates": 0, "unchanged": 0, "errors": 0}
|
||||
for product in products:
|
||||
current = catalog.get(product.handle)
|
||||
_resolve_hosted_images(product, current)
|
||||
if product.errors:
|
||||
product_plan = ProductPlan(kind="error", canonical=product, catalog=current)
|
||||
detail: dict = {"errors": [e.as_json() for e in product.errors]}
|
||||
@@ -144,6 +147,31 @@ def compute_diff(catalog: dict[str, CatalogProduct], products: list[CanonicalPro
|
||||
return DiffResult(records=records, summary=summary, fingerprint=fingerprint, plan=plan)
|
||||
|
||||
|
||||
def _resolve_hosted_images(product: CanonicalProduct, current: CatalogProduct | None) -> None:
|
||||
"""Recognize re-imported hosted image URLs (INV-12). A hosted URL is resolved
|
||||
to this product's existing image with that id — re-keyed onto that image's
|
||||
source_url so the by_src match treats it as the existing image (never an add,
|
||||
never a fetch). A hosted URL whose id is not one of this product's images
|
||||
(cross-storefront, deleted, or a brand-new product) is a row error."""
|
||||
current_by_id = {i.id: i for i in current.images} if current else {}
|
||||
resolved: list = []
|
||||
for image in product.images:
|
||||
hosted_id = hosted.parse_image_id(image.source_url)
|
||||
if hosted_id is None:
|
||||
resolved.append(image)
|
||||
continue
|
||||
match = current_by_id.get(hosted_id)
|
||||
if match is None:
|
||||
product.errors.append(
|
||||
RowError(image.line_number, "Image Src",
|
||||
f"image '{image.source_url}' refers to an unknown hosted id")
|
||||
)
|
||||
resolved.append(image)
|
||||
continue
|
||||
resolved.append(replace(image, source_url=match.source_url))
|
||||
product.images = resolved
|
||||
|
||||
|
||||
def _add_plan(product: CanonicalProduct) -> ProductPlan:
|
||||
return ProductPlan(
|
||||
kind="add",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Hosted-image URL helpers (SD-0002 §6.3.1, INV-12).
|
||||
|
||||
Build the canonical hosted Image Src for a fetched image, and recognize one on
|
||||
re-import. DB-free and host-agnostic: recognition is a path-parse, so an export
|
||||
round-trips across localhost / PPE / the #23 rebrand. The diff resolves a parsed
|
||||
id against the storefront-scoped catalog snapshot.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
RENDITIONS = ("original", "thumb", "card", "detail")
|
||||
EXPORT_RENDITION = "detail"
|
||||
|
||||
_PATH = re.compile(r"^/api/products/images/(\d+)/(original|thumb|card|detail)$")
|
||||
|
||||
|
||||
def image_url(base_url: str, image_id: int, rendition: str = EXPORT_RENDITION) -> str:
|
||||
"""Absolute when base_url is set, else a root-relative path."""
|
||||
path = f"/api/products/images/{image_id}/{rendition}"
|
||||
return f"{base_url.rstrip('/')}{path}" if base_url else path
|
||||
|
||||
|
||||
def parse_image_id(url: str) -> int | None:
|
||||
"""The image id if url is one of our hosted-image URLs (any host), else None."""
|
||||
if not url:
|
||||
return None
|
||||
m = _PATH.match(urlsplit(url).path)
|
||||
return int(m.group(1)) if m else None
|
||||
@@ -1,496 +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"
|
||||
" 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])
|
||||
)
|
||||
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()
|
||||
return [_run_dict(row) for row in rows]
|
||||
|
||||
|
||||
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,),
|
||||
)
|
||||
]
|
||||
# SLICE-7 fills these; the §6.4 payload shape is stable from SLICE-5 on.
|
||||
run["image_progress"] = {"done": 0, "total": 0}
|
||||
run["image_outcomes"] = []
|
||||
return run
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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])
|
||||
@@ -14,6 +14,7 @@ from collections.abc import Iterable, Iterator
|
||||
from decimal import Decimal
|
||||
|
||||
from .diff import CatalogProduct
|
||||
from .hosted import EXPORT_RENDITION, image_url
|
||||
from .models import (
|
||||
IMAGE_COLUMNS,
|
||||
OPTION_VALUE_COLUMNS,
|
||||
@@ -51,14 +52,14 @@ def _cell(value: object) -> str:
|
||||
return str(value)
|
||||
|
||||
|
||||
def catalog_to_csv(products: Iterable[CatalogProduct]) -> Iterator[str]:
|
||||
def catalog_to_csv(products: Iterable[CatalogProduct], base_url: str = "") -> Iterator[str]:
|
||||
"""Stream canonical CSV text, header first, one product block at a time."""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(HEADER)
|
||||
yield _drain(buf)
|
||||
for product in products:
|
||||
for row in _product_rows(product):
|
||||
for row in _product_rows(product, base_url):
|
||||
writer.writerow([row.get(col, "") for col in HEADER])
|
||||
yield _drain(buf)
|
||||
|
||||
@@ -70,7 +71,7 @@ def _drain(buf: io.StringIO) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def _product_rows(product: CatalogProduct) -> list[dict[str, str]]:
|
||||
def _product_rows(product: CatalogProduct, base_url: str = "") -> list[dict[str, str]]:
|
||||
"""The product's CSV rows (§6.5.1 grammar): product fields + option names on
|
||||
the first row; one variant per row; images interleaved; image-only rows when
|
||||
a product has more images than variants."""
|
||||
@@ -90,13 +91,18 @@ def _product_rows(product: CatalogProduct) -> list[dict[str, str]]:
|
||||
if i < len(product.variants):
|
||||
_write_variant(row, product, product.variants[i])
|
||||
if i < len(product.images):
|
||||
_write_image(row, product.images[i])
|
||||
_write_image(row, product.images[i], base_url)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _write_image(row: dict[str, str], image) -> None:
|
||||
row["Image Src"] = image.source_url
|
||||
def _write_image(row: dict[str, str], image, base_url: str) -> None:
|
||||
# Fetched images export the platform-hosted URL (INV-12/16); everything
|
||||
# else keeps the original source_url so nothing is lost (§6.5.5).
|
||||
if image.status == "fetched":
|
||||
row["Image Src"] = image_url(base_url, image.id, EXPORT_RENDITION)
|
||||
else:
|
||||
row["Image Src"] = image.source_url
|
||||
row["Image Position"] = str(image.position)
|
||||
if image.alt_text is not None:
|
||||
row["Image Alt Text"] = image.alt_text
|
||||
|
||||
@@ -1,263 +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 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="complete",
|
||||
)
|
||||
for plan in diff_result.plan:
|
||||
_apply_product_plan(conn, storefront_id, plan, run_id)
|
||||
repo.insert_run_errors(conn, run_id, error_rows)
|
||||
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)
|
||||
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),
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)."""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
+18
-250
@@ -1,11 +1,14 @@
|
||||
"""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
|
||||
|
||||
@@ -16,12 +19,11 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import psycopg
|
||||
from fastapi import Depends, FastAPI, File, Query, Response, UploadFile
|
||||
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.deps import SESSION_COOKIE, get_conn, get_mailer, get_session
|
||||
@@ -47,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).
|
||||
|
||||
@@ -111,13 +83,13 @@ 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()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
app.state.pool = db.open_pool(dsn)
|
||||
app.state.pool = db.open_pool(dsn, max_size=10)
|
||||
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
|
||||
@@ -168,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:
|
||||
@@ -184,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)
|
||||
@@ -193,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():
|
||||
@@ -208,209 +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.",
|
||||
)
|
||||
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"'},
|
||||
)
|
||||
|
||||
@app.get("/api/products/sample.csv")
|
||||
def products_sample_csv():
|
||||
"""The DOC-3 worked-example CSV. Documentation, so no auth gate (§6.4)."""
|
||||
return PlainTextResponse(
|
||||
products.SAMPLE_CSV_PATH.read_text(),
|
||||
media_type="text/csv",
|
||||
headers={"content-disposition": 'attachment; filename="ecomm-products-sample.csv"'},
|
||||
)
|
||||
|
||||
# Deployed topology (launch-app SPEC §2): nginx proxies everything here, so the
|
||||
# backend serves the built SPA. Mounted LAST so /healthz and /api/* win. In dev the
|
||||
# dist dir doesn't exist (Vite serves the frontend) and the mount is skipped.
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -68,3 +68,31 @@ def smtp_from() -> str:
|
||||
def smtp_starttls() -> bool:
|
||||
"""STARTTLS on the relay connection (default on; disable only for odd relays)."""
|
||||
return os.environ.get("ECOMM_SMTP_STARTTLS", "1").strip().lower() not in {"0", "false", "no", "off"}
|
||||
|
||||
|
||||
def objectstore_kind() -> str:
|
||||
"""Which objectstore adapter to build: 'local' (dev/tests) or 'gcs' (deployed)."""
|
||||
return os.environ.get("ECOMM_OBJECTSTORE_KIND") or "local"
|
||||
|
||||
|
||||
def objectstore_bucket() -> str:
|
||||
"""The GCS bucket name for the media objects (gcs adapter; deployment overlay)."""
|
||||
return os.environ.get("ECOMM_OBJECTSTORE_BUCKET", "")
|
||||
|
||||
|
||||
def objectstore_local_dir() -> str:
|
||||
"""Base directory for the local-disk objectstore adapter (dev/tests)."""
|
||||
return os.environ.get("ECOMM_OBJECTSTORE_DIR") or "/tmp/ecomm-objectstore"
|
||||
|
||||
|
||||
def public_base_url() -> str:
|
||||
"""Absolute origin for hosted image URLs in export (e.g. APP_URL); '' → relative."""
|
||||
return (os.environ.get("APP_URL") or "").rstrip("/")
|
||||
|
||||
|
||||
def image_fetch_allow_private() -> bool:
|
||||
"""Allow image fetch from private/loopback hosts (dev/E2E fixture host only).
|
||||
Default off — the SSRF guard rejects private ranges in deployed envs."""
|
||||
return os.environ.get("ECOMM_IMAGE_FETCH_ALLOW_PRIVATE", "").strip().lower() in {
|
||||
"1", "true", "yes", "on",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""platform/images — pure image processing (SD-0002 §6.2).
|
||||
|
||||
Model-free, deterministic, no I/O of its own: decode bytes, enforce the
|
||||
resolution bar (INV-18 / Q-3), and emit downscale-only WebP renditions. The
|
||||
caller (the image-fetch phase) owns fetching, storage, and status.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
# Q-3 (SD-0002 §13): the minimum shorter-side dimension; below it an image is
|
||||
# rejected_low_res. Rejects icons/thumbnails, accepts normal product photos.
|
||||
MIN_IMAGE_SHORT_SIDE = 500
|
||||
|
||||
# Rendition longest-side caps (px); downscale-only — a smaller source is kept
|
||||
# at its own size, never upscaled. Encoded WebP.
|
||||
RENDITIONS = {"thumb": 160, "card": 480, "detail": 1600}
|
||||
|
||||
# Source formats we accept (Pillow format names).
|
||||
_ACCEPTED = {"JPEG", "PNG", "WEBP"}
|
||||
|
||||
_WEBP_QUALITY = 82
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Processed:
|
||||
source_format: str
|
||||
width: int
|
||||
height: int
|
||||
renditions: dict[str, bytes] # name -> WebP bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Rejected:
|
||||
reason: str # "rejected_low_res" | "rejected_not_image"
|
||||
|
||||
|
||||
def process(data: bytes) -> Processed | Rejected:
|
||||
"""Decode + bar-check + renditions, or a typed rejection. Never raises on
|
||||
bad input — undecodable/unsupported bytes are Rejected('rejected_not_image')."""
|
||||
try:
|
||||
with Image.open(io.BytesIO(data)) as im:
|
||||
im.load()
|
||||
source_format = im.format or ""
|
||||
if source_format not in _ACCEPTED:
|
||||
return Rejected(reason="rejected_not_image")
|
||||
rgb = im.convert("RGB")
|
||||
width, height = rgb.size
|
||||
if min(width, height) < MIN_IMAGE_SHORT_SIDE:
|
||||
return Rejected(reason="rejected_low_res")
|
||||
renditions = {
|
||||
name: _rendition(rgb, cap) for name, cap in RENDITIONS.items()
|
||||
}
|
||||
return Processed(
|
||||
source_format=source_format,
|
||||
width=width,
|
||||
height=height,
|
||||
renditions=renditions,
|
||||
)
|
||||
except (UnidentifiedImageError, OSError, ValueError, Image.DecompressionBombError):
|
||||
return Rejected(reason="rejected_not_image")
|
||||
|
||||
|
||||
def _rendition(rgb: Image.Image, cap: int) -> bytes:
|
||||
longest = max(rgb.size)
|
||||
if longest > cap:
|
||||
scale = cap / longest
|
||||
size = (max(1, round(rgb.width * scale)), max(1, round(rgb.height * scale)))
|
||||
resized = rgb.resize(size, Image.LANCZOS)
|
||||
else:
|
||||
resized = rgb
|
||||
buf = io.BytesIO()
|
||||
resized.save(buf, format="WEBP", quality=_WEBP_QUALITY, method=6)
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""platform/objectstore — the media-blob port (SD-0002 §6.2, §6.3.1).
|
||||
|
||||
A tiny put/get/delete port with two adapters, mirroring the SD-0001 mailer
|
||||
pattern: local-disk (dev/tests) and GCS (deployed). Owns no semantics — keys
|
||||
and content types come from the caller (the image-fetch phase). Objects are
|
||||
written once, never mutated (immutable, image-id-addressed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from app.platform import config
|
||||
|
||||
|
||||
class ObjectNotFound(Exception):
|
||||
"""Raised by get() when the key has no object."""
|
||||
|
||||
|
||||
class ObjectStore(Protocol):
|
||||
def put(self, key: str, data: bytes, content_type: str) -> None: ...
|
||||
def get(self, key: str) -> bytes: ...
|
||||
def delete(self, key: str) -> None: ...
|
||||
|
||||
|
||||
def build_objectstore(kind: str) -> ObjectStore:
|
||||
"""Select the objectstore adapter by configured kind (config.objectstore_kind, INV-8)."""
|
||||
if kind == "local":
|
||||
return LocalObjectStore(config.objectstore_local_dir())
|
||||
if kind == "gcs":
|
||||
return GcsObjectStore(config.objectstore_bucket())
|
||||
raise ValueError(f"unknown objectstore kind: {kind!r}")
|
||||
|
||||
|
||||
def _safe_segments(key: str) -> tuple[str, ...]:
|
||||
parts = tuple(p for p in key.split("/") if p)
|
||||
if not parts or any(p in {".", ".."} for p in parts):
|
||||
raise ValueError(f"unsafe objectstore key: {key!r}")
|
||||
return parts
|
||||
|
||||
|
||||
class LocalObjectStore:
|
||||
"""Disk-backed adapter under a base dir; key path-segments become subdirs."""
|
||||
|
||||
def __init__(self, base_dir: str) -> None:
|
||||
self._base = Path(base_dir)
|
||||
|
||||
def _path(self, key: str) -> Path:
|
||||
return self._base.joinpath(*_safe_segments(key))
|
||||
|
||||
def put(self, key: str, data: bytes, content_type: str) -> None:
|
||||
path = self._path(key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
|
||||
def get(self, key: str) -> bytes:
|
||||
path = self._path(key)
|
||||
try:
|
||||
return path.read_bytes()
|
||||
except FileNotFoundError as exc:
|
||||
raise ObjectNotFound(key) from exc
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
try:
|
||||
os.remove(self._path(key))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
class GcsObjectStore:
|
||||
"""GCS adapter; auth via the VM service account ADC (no secret bytes).
|
||||
|
||||
google-cloud-storage is imported lazily so dev/test runs (local adapter)
|
||||
don't require the package at import time.
|
||||
"""
|
||||
|
||||
def __init__(self, bucket_name: str) -> None:
|
||||
if not bucket_name:
|
||||
raise ValueError("gcs objectstore requires ECOMM_OBJECTSTORE_BUCKET")
|
||||
from google.cloud import storage # lazy
|
||||
|
||||
self._bucket = storage.Client().bucket(bucket_name)
|
||||
|
||||
def put(self, key: str, data: bytes, content_type: str) -> None:
|
||||
_safe_segments(key)
|
||||
self._bucket.blob(key).upload_from_string(data, content_type=content_type)
|
||||
|
||||
def get(self, key: str) -> bytes:
|
||||
from google.cloud.exceptions import NotFound # lazy
|
||||
|
||||
try:
|
||||
return self._bucket.blob(key).download_as_bytes()
|
||||
except NotFound as exc:
|
||||
raise ObjectNotFound(key) from exc
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
from google.cloud.exceptions import NotFound # lazy
|
||||
|
||||
try:
|
||||
self._bucket.blob(key).delete()
|
||||
except NotFound:
|
||||
pass
|
||||
@@ -7,3 +7,5 @@ pytest>=8.0
|
||||
import-linter>=2.0
|
||||
nh3>=0.2
|
||||
python-multipart>=0.0.9
|
||||
Pillow>=11.0
|
||||
google-cloud-storage>=2.18
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
Handle,Title,Body (HTML),Vendor,Product Category,Type,Tags,Published,Status,Option1 Name,Option1 Value,Variant SKU,Variant Grams,Variant Inventory Tracker,Variant Inventory Qty,Variant Inventory Policy,Variant Fulfillment Service,Variant Price,Variant Compare At Price,Variant Requires Shipping,Variant Taxable,Variant Barcode,Image Src,Image Position,Image Alt Text,Gift Card,SEO Title,SEO Description,Google Shopping / MPN,Variant Image,Variant Weight Unit,Cost per item,Status
|
||||
star-tee,Star Tee,<p>Soft cotton tee.</p>,Wiggle Goods,Apparel & Accessories > Clothing,Shirts,"apparel, tees",TRUE,active,Size,S,WG-TEE-S,180,shopify,12,deny,manual,24.00,30.00,TRUE,TRUE,0001,https://img.example.com/star-tee.jpg,1,Star Tee,FALSE,Star Tee | Wiggle,Soft tee,MPN-1,https://img.example.com/star-s.jpg,g,11.00,active
|
||||
star-tee,,,,,,,,,,M,WG-TEE-M,180,shopify,18,deny,manual,24.00,30.00,TRUE,TRUE,0002,,,,FALSE,,,,,g,11.00,
|
||||
star-tee,,,,,,,,,,,,,,,,,,,,,,https://img.example.com/star-back.jpg,2,Star Tee back,,,,,,,,
|
||||
|
@@ -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):
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""platform/images — pure decode + resolution bar + WebP renditions (SD-0002 §6.2)."""
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.platform import images
|
||||
|
||||
|
||||
def _png(width: int, height: int, color=(120, 80, 200)) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (width, height), color).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _jpeg(width: int, height: int) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (width, height), (10, 200, 90)).save(buf, format="JPEG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_good_image_produces_three_webp_renditions():
|
||||
result = images.process(_png(1200, 900))
|
||||
assert isinstance(result, images.Processed)
|
||||
assert result.source_format == "PNG"
|
||||
assert set(result.renditions) == {"thumb", "card", "detail"}
|
||||
for name, blob in result.renditions.items():
|
||||
with Image.open(io.BytesIO(blob)) as im:
|
||||
assert im.format == "WEBP"
|
||||
with Image.open(io.BytesIO(result.renditions["thumb"])) as im:
|
||||
assert max(im.size) <= images.RENDITIONS["thumb"]
|
||||
with Image.open(io.BytesIO(result.renditions["detail"])) as im:
|
||||
assert max(im.size) <= images.RENDITIONS["detail"]
|
||||
|
||||
|
||||
def test_downscale_only_never_upscales_small_source():
|
||||
result = images.process(_png(520, 520))
|
||||
with Image.open(io.BytesIO(result.renditions["detail"])) as im:
|
||||
assert im.size == (520, 520)
|
||||
|
||||
|
||||
def test_below_resolution_bar_rejected_low_res():
|
||||
result = images.process(_png(300, 1200)) # shorter side 300 < 500
|
||||
assert isinstance(result, images.Rejected)
|
||||
assert result.reason == "rejected_low_res"
|
||||
|
||||
|
||||
def test_not_an_image_rejected_not_image():
|
||||
result = images.process(b"this is not an image")
|
||||
assert isinstance(result, images.Rejected)
|
||||
assert result.reason == "rejected_not_image"
|
||||
|
||||
|
||||
def test_jpeg_source_format_preserved_in_result():
|
||||
result = images.process(_jpeg(800, 800))
|
||||
assert result.source_format == "JPEG"
|
||||
|
||||
|
||||
def test_decompression_bomb_rejected_not_image(monkeypatch):
|
||||
# A small file whose pixel count exceeds Pillow's bomb threshold must be a
|
||||
# typed rejection, never an escaping exception.
|
||||
from PIL import Image as PILImage
|
||||
monkeypatch.setattr(PILImage, "MAX_IMAGE_PIXELS", 100) # 10x10 exceeds it
|
||||
result = images.process(_png(800, 800))
|
||||
assert isinstance(result, images.Rejected)
|
||||
assert result.reason == "rejected_not_image"
|
||||
@@ -0,0 +1,44 @@
|
||||
"""platform/objectstore — local-disk adapter round-trip (SD-0002 §6.2/§6.3.1)."""
|
||||
import pytest
|
||||
|
||||
from app.platform import objectstore
|
||||
|
||||
|
||||
def test_local_put_get_round_trip(tmp_path):
|
||||
store = objectstore.LocalObjectStore(str(tmp_path))
|
||||
key = "storefronts/1/product-images/9/original"
|
||||
store.put(key, b"\x89PNG-bytes", "image/png")
|
||||
assert store.get(key) == b"\x89PNG-bytes"
|
||||
|
||||
|
||||
def test_local_get_missing_raises_not_found(tmp_path):
|
||||
store = objectstore.LocalObjectStore(str(tmp_path))
|
||||
with pytest.raises(objectstore.ObjectNotFound):
|
||||
store.get("nope/missing")
|
||||
|
||||
|
||||
def test_local_delete_is_idempotent(tmp_path):
|
||||
store = objectstore.LocalObjectStore(str(tmp_path))
|
||||
store.put("a/b", b"x", "text/plain")
|
||||
store.delete("a/b")
|
||||
store.delete("a/b") # no error second time
|
||||
with pytest.raises(objectstore.ObjectNotFound):
|
||||
store.get("a/b")
|
||||
|
||||
|
||||
def test_local_keys_cannot_escape_base_dir(tmp_path):
|
||||
store = objectstore.LocalObjectStore(str(tmp_path))
|
||||
with pytest.raises(ValueError):
|
||||
store.put("../escape", b"x", "text/plain")
|
||||
|
||||
|
||||
def test_build_objectstore_local(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("ECOMM_OBJECTSTORE_KIND", "local")
|
||||
monkeypatch.setenv("ECOMM_OBJECTSTORE_DIR", str(tmp_path))
|
||||
store = objectstore.build_objectstore("local")
|
||||
assert isinstance(store, objectstore.LocalObjectStore)
|
||||
|
||||
|
||||
def test_build_objectstore_unknown_kind_raises():
|
||||
with pytest.raises(ValueError):
|
||||
objectstore.build_objectstore("s3")
|
||||
@@ -67,3 +67,29 @@ def test_missing_column_message_names_the_column():
|
||||
with pytest.raises(FileRejected) as exc:
|
||||
parse_csv(_csv("Handle,Vendor", "mug,Acme"))
|
||||
assert "'Title'" in exc.value.message
|
||||
|
||||
|
||||
def test_parse_detects_and_maps_shopify():
|
||||
data = (
|
||||
"Handle,Title,Body (HTML),Cost per item,Variant Price,Variant Grams,Type,Gift Card\n"
|
||||
"mug,Moon Mug,<p>Grey</p>,9.50,18.00,300,Drinkware,false\n"
|
||||
).encode("utf-8")
|
||||
parsed = parse_csv(data)
|
||||
assert parsed.dialect == "shopify"
|
||||
cells = parsed.rows[0].cells
|
||||
assert cells["Description"] == "<p>Grey</p>"
|
||||
assert cells["Variant Cost"] == "9.50"
|
||||
assert cells["Variant Weight"] == "300"
|
||||
assert cells["Variant Weight Unit"] == "g" # synthesized
|
||||
# Type (free-text) and Gift Card warned, never mapped:
|
||||
assert "Type" in parsed.unknown_columns
|
||||
assert "Gift Card" in parsed.unknown_columns
|
||||
assert "product_type" not in str(cells) # Type never reached canonical
|
||||
|
||||
|
||||
def test_parse_canonical_unchanged():
|
||||
data = b"Handle,Title,Description,Variant Price\nmug,Moon Mug,Grey,18.00\n"
|
||||
parsed = parse_csv(data)
|
||||
assert parsed.dialect == "canonical"
|
||||
assert parsed.rows[0].cells["Description"] == "Grey"
|
||||
assert parsed.unknown_columns == []
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Shopify dialect adapter — detection + the §6.5.1 mapping contract (SLICE-8)."""
|
||||
from pathlib import Path
|
||||
|
||||
from app.domains.products.codec import parse_csv
|
||||
from app.domains.products.dialect_shopify import is_shopify_header, map_shopify_header
|
||||
|
||||
_FIXTURE = Path(__file__).parent / "fixtures" / "shopify-export.csv"
|
||||
|
||||
|
||||
def test_detects_shopify_by_signature_column():
|
||||
assert is_shopify_header(["Handle", "Title", "Body (HTML)", "Variant Price"]) is True
|
||||
assert is_shopify_header(["Handle", "Title", "Google Shopping / MPN"]) is True
|
||||
|
||||
|
||||
def test_canonical_header_is_not_shopify():
|
||||
assert is_shopify_header(["Handle", "Title", "Description", "Variant Cost"]) is False
|
||||
|
||||
|
||||
def test_ambiguous_shared_only_header_defaults_canonical():
|
||||
# Only columns common to both dialects -> not Shopify (safe default, never misparse).
|
||||
assert (
|
||||
is_shopify_header(["Handle", "Title", "Option1 Name", "Variant Price", "Image Src"])
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_map_renames_and_passes_through():
|
||||
mapped, not_imported = map_shopify_header(
|
||||
["Handle", "Body (HTML)", "Cost per item", "Variant Price"]
|
||||
)
|
||||
assert mapped == ["Handle", "Description", "Variant Cost", "Variant Price"]
|
||||
assert not_imported == []
|
||||
|
||||
|
||||
def test_map_drops_type_and_weight_unit_overrides():
|
||||
mapped, not_imported = map_shopify_header(
|
||||
["Handle", "Type", "Variant Grams", "Variant Weight Unit"]
|
||||
)
|
||||
assert mapped == ["Handle", None, "Variant Weight", None]
|
||||
assert not_imported == ["Type", "Variant Weight Unit"]
|
||||
|
||||
|
||||
def test_map_drops_shopify_only_and_market_columns():
|
||||
mapped, not_imported = map_shopify_header(
|
||||
["Handle", "Variant Compare At Price", "Gift Card", "Price / International"]
|
||||
)
|
||||
assert mapped == ["Handle", None, None, None]
|
||||
assert not_imported == ["Variant Compare At Price", "Gift Card", "Price / International"]
|
||||
|
||||
|
||||
def test_shopify_fixture_maps_exhaustively():
|
||||
parsed = parse_csv(_FIXTURE.read_bytes())
|
||||
assert parsed.dialect == "shopify"
|
||||
first = parsed.rows[0].cells
|
||||
# renamed
|
||||
assert first["Description"] == "<p>Soft cotton tee.</p>"
|
||||
assert first["Google Product Category"] == "Apparel & Accessories > Clothing"
|
||||
assert first["Variant Cost"] == "11.00"
|
||||
assert first["Variant Weight"] == "180"
|
||||
assert first["Variant Weight Unit"] == "g"
|
||||
# direct
|
||||
assert first["Variant SKU"] == "WG-TEE-S"
|
||||
assert first["Image Src"] == "https://img.example.com/star-tee.jpg"
|
||||
# not imported — warned, none leaked into canonical cells
|
||||
for col in (
|
||||
"Type",
|
||||
"Variant Compare At Price",
|
||||
"Gift Card",
|
||||
"SEO Title",
|
||||
"Google Shopping / MPN",
|
||||
"Variant Weight Unit",
|
||||
"Variant Inventory Policy",
|
||||
"Variant Fulfillment Service",
|
||||
"Variant Requires Shipping",
|
||||
"Variant Taxable",
|
||||
):
|
||||
assert col in parsed.unknown_columns, col
|
||||
assert "Variant Compare At Price" not in first
|
||||
|
||||
|
||||
# --- Detection robustness (review findings #1, #2): bias conservative ----------------
|
||||
|
||||
|
||||
def test_canonical_distinctive_column_vetoes_shopify_detection():
|
||||
# A canonical file carrying a stray Shopify-signature name (`SEO Title`) stays
|
||||
# canonical because it also has canonical-distinctive columns — no misparse,
|
||||
# no dropped Type, no corrupted weight unit (review finding #2).
|
||||
header = ["Handle", "Title", "Type", "Variant Weight", "Variant Weight Unit", "SEO Title"]
|
||||
assert is_shopify_header(header) is False
|
||||
|
||||
|
||||
def test_dual_named_file_stays_canonical_and_warns_shopify_name():
|
||||
# A malformed file with BOTH `Body (HTML)` and canonical `Description`: the
|
||||
# canonical column vetoes detection, so `Body (HTML)` is honestly warned as an
|
||||
# unknown column rather than silently shadowing `Description` (review finding #1).
|
||||
data = (
|
||||
b"Handle,Title,Body (HTML),Description,Variant Price\n"
|
||||
b"mug,Moon Mug,FROM_BODY,FROM_DESC,18.00\n"
|
||||
)
|
||||
parsed = parse_csv(data)
|
||||
assert parsed.dialect == "canonical"
|
||||
assert parsed.rows[0].cells["Description"] == "FROM_DESC"
|
||||
assert "Body (HTML)" in parsed.unknown_columns
|
||||
|
||||
|
||||
def test_real_shopify_export_still_detected():
|
||||
# The conservative veto must not break a genuine Shopify export (no canonical-
|
||||
# distinctive columns present).
|
||||
parsed = parse_csv(_FIXTURE.read_bytes())
|
||||
assert parsed.dialect == "shopify"
|
||||
|
||||
|
||||
def test_shopify_grams_clear_clears_weight_unit_together():
|
||||
# An empty Variant Grams (a clear) clears the synthesized unit too — weight and
|
||||
# unit move together, never a stale unit (review finding #3).
|
||||
data = b"Handle,Title,Body (HTML),Variant Grams\nmug,Moon Mug,<p>x</p>,\n"
|
||||
parsed = parse_csv(data)
|
||||
assert parsed.dialect == "shopify"
|
||||
cells = parsed.rows[0].cells
|
||||
assert cells["Variant Weight"] == ""
|
||||
assert cells["Variant Weight Unit"] == ""
|
||||
@@ -131,6 +131,56 @@ def test_blank_position_cell_updates_to_file_order():
|
||||
assert {"field": "position", "before": 2, "after": 1} in ventry["changes"]
|
||||
|
||||
|
||||
def _catalog_lamp_with_image():
|
||||
# A well-formed single-variant product carrying one fetched image id=55.
|
||||
fields = {
|
||||
"title": "Lamp", "description_html": None, "vendor": "Acme",
|
||||
"product_type": "standalone", "google_product_category": None,
|
||||
"tags": ["home"], "status": "active", "published": True,
|
||||
}
|
||||
return {
|
||||
"lamp": CatalogProduct(
|
||||
id=1, handle="lamp", title="Lamp",
|
||||
option_names=(None, None, None), fields=fields,
|
||||
variants=[CatalogVariant(id=10, options=(None, None, None), position=1,
|
||||
fields={"sku": "SKU-L", "barcode": None, "price": Decimal("30.00"),
|
||||
"cost": None, "weight": None, "weight_unit": None,
|
||||
"volume": None, "volume_unit": None, "tax_id_1": None,
|
||||
"tax_id_2": None, "inventory_tracker": None,
|
||||
"inventory_qty": 5, "variant_image": None})],
|
||||
images=[CatalogImage(id=55, source_url="https://m.example/a.png", position=1,
|
||||
alt_text=None, status="fetched")],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
LAMP_HEADER = "Handle,Title,Vendor,Tags,Status,Variant SKU,Variant Price,Variant Inventory Qty,Image Src,Image Position"
|
||||
LAMP_ROW_PREFIX = "lamp,Lamp,Acme,home,active,SKU-L,30.00,5,"
|
||||
|
||||
|
||||
def test_hosted_url_for_existing_image_is_unchanged_not_add():
|
||||
# Catalog product "lamp" has a fetched image id=55; canonical re-imports it
|
||||
# as the hosted URL -> must be 'unchanged', never 'add'/'update'.
|
||||
diff = compute_diff(
|
||||
_catalog_lamp_with_image(),
|
||||
_canon(LAMP_HEADER, LAMP_ROW_PREFIX + "/api/products/images/55/detail,1"),
|
||||
)
|
||||
kinds = {r["handle"]: r["kind"] for r in diff.records}
|
||||
assert kinds["lamp"] == "unchanged"
|
||||
|
||||
|
||||
def test_hosted_url_for_unknown_id_is_row_error():
|
||||
# Catalog "lamp" has NO images; canonical references /images/999/detail -> error.
|
||||
catalog = _catalog_lamp_with_image()
|
||||
catalog["lamp"].images = []
|
||||
diff = compute_diff(
|
||||
catalog,
|
||||
_canon(LAMP_HEADER, LAMP_ROW_PREFIX + "/api/products/images/999/detail,1"),
|
||||
)
|
||||
kinds = {r["handle"]: r["kind"] for r in diff.records}
|
||||
assert kinds["lamp"] == "error"
|
||||
|
||||
|
||||
def test_error_product_classifies_error():
|
||||
diff = compute_diff({}, _canon("Handle,Title,Variant Price", "mug,Mug,nope"))
|
||||
[rec] = diff.records
|
||||
|
||||
@@ -1,135 +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_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
|
||||
@@ -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]
|
||||
@@ -1,79 +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)
|
||||
@@ -104,6 +104,24 @@ def test_more_images_than_variants_emits_image_only_rows():
|
||||
assert [r["Image Src"] for r in rows] == ["https://x/a.jpg", "https://x/b.jpg", "https://x/c.jpg"]
|
||||
|
||||
|
||||
def test_fetched_image_serializes_hosted_detail_url():
|
||||
product = CatalogProduct(
|
||||
id=1, handle="lamp", title="Lamp", option_names=(None, None, None),
|
||||
fields={"title": "Lamp", "status": "active"},
|
||||
variants=[CatalogVariant(id=1, options=(None, None, None), position=1, fields={})],
|
||||
images=[
|
||||
CatalogImage(id=55, source_url="https://m.example/a.png", position=1,
|
||||
alt_text=None, status="fetched"),
|
||||
CatalogImage(id=56, source_url="https://m.example/b.png", position=2,
|
||||
alt_text=None, status="failed"),
|
||||
],
|
||||
)
|
||||
rows = list(serialize._product_rows(product, base_url="https://shop.test"))
|
||||
srcs = [r.get("Image Src") for r in rows if r.get("Image Src")]
|
||||
assert "https://shop.test/api/products/images/55/detail" in srcs # fetched -> hosted
|
||||
assert "https://m.example/b.png" in srcs # failed -> source kept
|
||||
|
||||
|
||||
import random
|
||||
|
||||
from app.domains.products import codec, diff, validate
|
||||
@@ -148,18 +166,23 @@ def _gen_catalog(seed: int) -> dict:
|
||||
fields={"sku": f"SKU-{pid}", "variant_image": None}))
|
||||
images = []
|
||||
for ii in range(rng.randint(0, 3)):
|
||||
# Mix fetched and non-fetched images: a fetched image exports its
|
||||
# hosted /images/{id}/detail URL, which the diff pre-pass resolves
|
||||
# back to the same id -> still a no-op (INV-12 over hosted images).
|
||||
status = "fetched" if rng.random() < 0.5 else "pending"
|
||||
images.append(CatalogImage(
|
||||
id=pid * 10 + ii, source_url=f"https://img/{handle}-{ii}.jpg",
|
||||
position=ii + 1, alt_text=rng.choice([None, f"alt {ii}"])))
|
||||
position=ii + 1, alt_text=rng.choice([None, f"alt {ii}"]),
|
||||
status=status))
|
||||
catalog[handle] = CatalogProduct(
|
||||
id=pid, handle=handle, title=fields["title"], option_names=option_names,
|
||||
fields=fields, variants=variants, images=images)
|
||||
return catalog
|
||||
|
||||
|
||||
def _roundtrip_diff(catalog: dict) -> diff.DiffResult:
|
||||
def _roundtrip_diff(catalog: dict, base_url: str = "") -> diff.DiffResult:
|
||||
"""export → bytes → import pipeline → diff against the same catalog."""
|
||||
text = "".join(serialize.catalog_to_csv(catalog.values()))
|
||||
text = "".join(serialize.catalog_to_csv(catalog.values(), base_url=base_url))
|
||||
parsed = codec.parse_csv(text.encode("utf-8"))
|
||||
products = validate.build_products(parsed)
|
||||
return diff.compute_diff(catalog, products)
|
||||
@@ -168,7 +191,9 @@ def _roundtrip_diff(catalog: dict) -> diff.DiffResult:
|
||||
def test_inv12_roundtrip_is_noop_over_generated_catalogs():
|
||||
for seed in range(200):
|
||||
catalog = _gen_catalog(seed)
|
||||
result = _roundtrip_diff(catalog)
|
||||
# A fixed base_url so fetched images export an absolute hosted URL; the
|
||||
# diff resolves it back by id -> the round-trip stays a no-op.
|
||||
result = _roundtrip_diff(catalog, base_url="https://shop.test")
|
||||
assert result.summary["adds"] == 0, f"seed {seed}: {result.summary}"
|
||||
assert result.summary["updates"] == 0, f"seed {seed}: {result.summary}"
|
||||
assert result.summary["errors"] == 0, f"seed {seed}: {result.summary}"
|
||||
|
||||
@@ -1,222 +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)
|
||||
@@ -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("<!doctype html><title>ecomm spa</title>")
|
||||
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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -35,6 +35,9 @@ ECOMM_SMTP_HOST = "smtp.gmail.com"
|
||||
ECOMM_SMTP_PORT = "587"
|
||||
ECOMM_SMTP_USER = "ben.stull@wiggleverse.org"
|
||||
ECOMM_SMTP_FROM = "ecomm <ben.stull@wiggleverse.org>"
|
||||
# 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"
|
||||
|
||||
@@ -43,6 +43,8 @@ never file names, URLs, catalog content, or secret bytes.
|
||||
| TEL-2 `import_run_completed` | apply transaction commits | `run_id, storefront_id, added, updated, errored, duration_ms` |
|
||||
| TEL-3 `catalog_exported` | export stream completes | `storefront_id, status_filter, product_count, duration_ms` |
|
||||
| TEL-6 `import_apply_failed` | apply transaction aborts unexpectedly | `draft_id, storefront_id, error_class` |
|
||||
| TEL-4 `image_phase_completed` | run's fetch task finishes | `run_id, fetched, rejected, failed, duration_ms` |
|
||||
| TEL-5 `image_phase_recovered` | startup recovery resumes a run | `run_id, pending_resumed` |
|
||||
|
||||
### Export (PUC-9, SLICE-6)
|
||||
|
||||
@@ -56,6 +58,98 @@ re-importing an unmodified export previews as all-unchanged with the import
|
||||
action disabled (PUC-10). TEL-3 (`catalog_exported`) is emitted once the stream
|
||||
completes — counts and duration only, never catalog content.
|
||||
|
||||
### Images (SLICE-7)
|
||||
|
||||
After a merchant confirms an import, the app transitions the run to
|
||||
`fetching_images` and processes image URLs via a bounded thread pool
|
||||
(4 workers). Each image goes through the SSRF guard, is decoded, and up to
|
||||
four WebP renditions (`original`, `thumb`, `card`, `detail`) are written to
|
||||
the objectstore under `product-images/`. Progress is tracked per run in
|
||||
`image_progress` / `image_counts` / `image_outcomes` and visible in the
|
||||
import history detail panel (PUC-8). TEL-4 (`image_phase_completed`) fires
|
||||
when the phase finishes; startup recovery emits TEL-5 (`image_phase_recovered`)
|
||||
when it resumes a stuck run.
|
||||
|
||||
#### `provision-bucket` — one-time gesture per environment (SLICE-7 PPE deploy)
|
||||
|
||||
Before the first SLICE-7 deploy, run the `provision-bucket` skill from the
|
||||
engineering `launch-app` suite. This gesture:
|
||||
|
||||
1. Creates the GCS bucket `wiggleverse-ecomm-ppe-media` in the
|
||||
`wiggleverse-ecomm` GCP project.
|
||||
2. Grants the PPE VM's service account `roles/storage.objectAdmin` on the
|
||||
bucket.
|
||||
3. Sets an `import-drafts/` lifecycle rule (forward-compat for the draft-blob
|
||||
migration deferred past SLICE-7).
|
||||
|
||||
After provisioning, `deployment.toml`'s `[overlay]` already carries:
|
||||
|
||||
```
|
||||
ECOMM_OBJECTSTORE_KIND = "gcs"
|
||||
ECOMM_OBJECTSTORE_BUCKET = "wiggleverse-ecomm-ppe-media"
|
||||
```
|
||||
|
||||
so the next `flotilla deploy` picks them up automatically. GCS auth is the
|
||||
VM service-account ADC — no credential bytes needed.
|
||||
|
||||
### RB-3 — image phase stuck
|
||||
|
||||
Triggered by ALR-3 (run in `fetching_images` > 2 h, or the same run recovered
|
||||
≥ 3×).
|
||||
|
||||
1. **Identify the stuck run.** The import history (PUC-8, `GET
|
||||
/api/products/imports/runs`) lists runs by status; look for a run with
|
||||
`status="fetching_images"` that hasn't advanced.
|
||||
|
||||
```
|
||||
journalctl -u ecomm.service | grep image_phase
|
||||
```
|
||||
|
||||
2. **Restart to trigger recovery.** A process restart causes the startup
|
||||
recovery scan to re-enqueue pending images for any run still in
|
||||
`fetching_images` (TEL-5 confirms the resumption). Restarts are safe —
|
||||
per-image claims are idempotent; already-fetched images are skipped.
|
||||
|
||||
```
|
||||
systemctl restart ecomm.service
|
||||
```
|
||||
|
||||
3. **If a specific image URL is the cause.** The `image_outcomes` field on
|
||||
the run records per-image rejection reasons (SSRF block, resolution
|
||||
rejection, network error, etc.). The merchant can update or remove the
|
||||
offending URL and re-import.
|
||||
|
||||
4. **File a bug** on `wiggleverse/wiggleverse-ecomm` with the `run_id` and
|
||||
the `image_outcomes` content from the log.
|
||||
|
||||
### ALR-3 — the log-based alert for stuck image phases
|
||||
|
||||
Run once per environment, at the SLICE-7 PPE deploy. Mirrors ALR-2's
|
||||
approach — a log metric over the stuck/recovered signal, the existing email
|
||||
channel, and an alert policy in project `wiggleverse-ecomm`.
|
||||
|
||||
```
|
||||
# Select the deployment's gcloud config for this one process (handbook §8.4).
|
||||
export CLOUDSDK_ACTIVE_CONFIG_NAME=wiggleverse-ecomm
|
||||
|
||||
# Log-based metric counting image-phase events (TEL-4/TEL-5 — stuck/recovered).
|
||||
gcloud logging metrics create ecomm_image_phase_events \
|
||||
--description="ecomm TEL-4/TEL-5 image phase completed/recovered (SD-0002 ALR-3)" \
|
||||
--log-filter='resource.type="gce_instance" AND (jsonPayload.message:"image_phase_completed" OR jsonPayload.message:"image_phase_recovered" OR textPayload:"image_phase_completed" OR textPayload:"image_phase_recovered")'
|
||||
```
|
||||
|
||||
Then attach an alert policy — **operator email channel, threshold any event >
|
||||
0 within a 2-hour window, severity notify-only**. List channels first:
|
||||
|
||||
```
|
||||
# Find the operator email channel's id for the policy.
|
||||
gcloud beta monitoring channels list
|
||||
```
|
||||
|
||||
Create the alert policy (Cloud Console or `gcloud alpha monitoring policies
|
||||
create`); condition: `fetching_images` duration > 2 h **or** the same
|
||||
`run_id` appears in TEL-5 ≥ 3 times.
|
||||
|
||||
### RB-2 — import apply failed
|
||||
|
||||
Triggered by ALR-2 (any TEL-6 event). The apply raised mid-transaction and
|
||||
|
||||
+138
-10
@@ -60,6 +60,47 @@ Option names live both in `CanonicalProduct.option_names` (the values) and in
|
||||
`fields{}` (the file-presence marker the diff needs for the absent-vs-clear
|
||||
distinction).
|
||||
|
||||
## Dialects (INV-17, SLICE-8)
|
||||
|
||||
A file is normalized to canonical names **at the codec boundary**, so every stage
|
||||
downstream of `parse_csv` (validate → diff → apply) is dialect-agnostic — it only
|
||||
ever sees canonical cells. Two dialects exist: `canonical` and `shopify`.
|
||||
|
||||
`dialect_shopify.py` is the Shopify adapter and the source of truth for the
|
||||
mapping (mirrored by `tests/test_products_dialect_shopify.py` and
|
||||
`tests/fixtures/shopify-export.csv`):
|
||||
|
||||
- **Detection** — `is_shopify_header()` returns `shopify` iff the header carries a
|
||||
Shopify-only *signature* column (`Body (HTML)`, `Variant Grams`, `Cost per item`,
|
||||
`Gift Card`, a `Google Shopping / …` column, …) **and no canonical-distinctive
|
||||
column**. A canonical-distinctive name — one Shopify renames away (`Description`,
|
||||
`Variant Cost`, `Variant Weight`, `Google Product Category`) or a canonical-only
|
||||
column Shopify lacks (`Variant Volume`, `Variant Tax ID 1/2`, `Variant Position`) —
|
||||
**vetoes** Shopify detection. Detection is by signature, not an exact header set, so
|
||||
extra market/region columns don't defeat it; the veto biases it conservative, because
|
||||
under-detection is safe (Shopify-named columns are warned as not-imported) while
|
||||
over-detection corrupts (canonical `Type` / `Variant Weight Unit` would be dropped).
|
||||
So a hybrid or ambiguous header falls through to `canonical`, which maps shared
|
||||
columns identically and warns the rest — it never misparses (§7.4).
|
||||
- **Mapping** — `map_shopify_header(header)` returns `(mapped, not_imported)`:
|
||||
`mapped[i]` is the canonical name for column `i` (or `None` when it has no
|
||||
canonical home), and `not_imported` is the warning list surfaced as the draft's
|
||||
`unknown_columns`. The rule per column, in order: in `SHOPIFY_RENAME` →
|
||||
canonical name; in `SHOPIFY_DROP` (`Type`, `Variant Weight Unit`) → not imported;
|
||||
in `KNOWN_COLUMNS` → pass through; else → not imported (this last branch absorbs
|
||||
the unbounded `… / <Market>` price columns without enumerating them).
|
||||
- **The two name-collision overrides** are why `SHOPIFY_DROP` exists: Shopify's
|
||||
`Type` is a free-text category, but canonical `Type` is *structural*
|
||||
(`standalone`/kits), so it is warned rather than folded in; and Shopify's
|
||||
`Variant Weight Unit` is dropped because the `Variant Grams` → `Variant Weight`
|
||||
rename is the weight source.
|
||||
- **The one value transform** lives in `parse_csv`: when a Shopify row has a mapped
|
||||
`Variant Weight` (from `Variant Grams`), the codec synthesizes
|
||||
`Variant Weight Unit = "g"`, since Shopify grams are unitless integers.
|
||||
|
||||
`detect_dialect()` in `codec.py` is the INV-17 seam; a new dialect is a new adapter
|
||||
module plus a branch there, with no change to validate/diff/apply.
|
||||
|
||||
## Error granularity
|
||||
|
||||
`validate.py` never raises on a row problem: every violation is recorded as a
|
||||
@@ -112,20 +153,107 @@ same through the UI (export download → re-upload → all-unchanged preview, im
|
||||
disabled). Numeric/`Decimal` fields — the property test's deliberate blind spot
|
||||
(string-form vs value-identity) — get explicit round-trip unit tests.
|
||||
|
||||
## Image pipeline (SLICE-7)
|
||||
|
||||
### `platform/objectstore`
|
||||
|
||||
A two-adapter port (`backend/app/platform/objectstore/`):
|
||||
|
||||
- `local.py` — writes blobs under a configurable local directory; used in
|
||||
tests and local dev (`ECOMM_OBJECTSTORE_KIND=local`).
|
||||
- `gcs.py` — wraps `google-cloud-storage`; bucket name comes from
|
||||
`ECOMM_OBJECTSTORE_BUCKET` (`ECOMM_OBJECTSTORE_KIND=gcs`). Auth is the
|
||||
VM's service-account ADC — no credential bytes in the overlay or secrets.
|
||||
|
||||
Object keys for product images follow the prefix `product-images/` (the
|
||||
`provision-bucket` gesture also sets a lifecycle rule on `import-drafts/` for
|
||||
forward-compat, even though the draft blob remains BYTEA this slice — see
|
||||
seams below).
|
||||
|
||||
### `platform/images`
|
||||
|
||||
`backend/app/platform/images.py` decodes an uploaded or fetched image byte
|
||||
string and produces up to four renditions stored in the objectstore:
|
||||
|
||||
- **`original`** — stored verbatim after the resolution bar passes.
|
||||
- **`thumb`** — 150 px on the shorter side, WebP.
|
||||
- **`card`** — 400 px on the shorter side, WebP.
|
||||
- **`detail`** — 800 px on the shorter side, WebP.
|
||||
|
||||
**Resolution bar (`MIN_IMAGE_SHORT_SIDE = 500`):** images whose shorter side
|
||||
is below 500 px are rejected (Q-3). This is the only hard gate; oversized
|
||||
images are scaled down without rejection. All renditions share the same
|
||||
objectstore key prefix (`product-images/<image_id>/`).
|
||||
|
||||
### `domains/products/imagefetch.py` — the fetch phase
|
||||
|
||||
After `confirm_draft` commits, the run enters `fetching_images` (if it has
|
||||
any pending image URLs) and a `ThreadPoolExecutor(max_workers=4)` processes
|
||||
them concurrently:
|
||||
|
||||
- **SSRF guard (INV-18, extended):** only `http`/`https` schemes are
|
||||
permitted; the resolved IP must be public (RFC-1918, loopback, link-local,
|
||||
and multicast ranges are blocked); redirects are followed only within the
|
||||
same guard — a redirect to a private IP is rejected mid-chain.
|
||||
- **Bounds:** ≤ 20 MB response body, ≤ 30 s per fetch.
|
||||
- **Per-image presence/idempotency check (`claim_image_for_fetch`):** before
|
||||
processing, each `CatalogImage` row is checked so an already-handled row is
|
||||
skipped. This is a presence guard adequate for the **single-worker,
|
||||
in-process** model — the deployed app runs one uvicorn worker, recovery runs
|
||||
at startup over prior-process runs, and a fresh confirm always targets a new
|
||||
run, so two phase invocations never process the same run's images
|
||||
concurrently. It is **not** a cross-process lock. A true multi-worker claim
|
||||
would need a distinct in-flight status transition (`pending → fetching` with
|
||||
`RETURNING`, or `SELECT … FOR UPDATE SKIP LOCKED`) — that is the future seam
|
||||
if the fetch phase ever moves multi-process or onto a queue (§6.9).
|
||||
- **Startup recovery scan:** on process start the app scans for runs stuck in
|
||||
`fetching_images` and re-enqueues their pending images (emits TEL-5). This
|
||||
covers crash/restart scenarios without a separate scheduler. On a mid-fetch
|
||||
process restart (e.g. a deploy) the phase is interrupted and the run is left
|
||||
resumable: pending images stay `pending`, fetched ones stay `fetched`, and
|
||||
the startup scan completes the run. Any pool-closed error in an in-flight
|
||||
worker at shutdown is benign — the uncommitted image stays `pending` and is
|
||||
re-fetched on resume. Mid-flight interruption is tolerated by construction
|
||||
(§6.9).
|
||||
- **Progress tracking:** the run row carries `image_progress`,
|
||||
`image_counts`, and `image_outcomes` (updated per image); these fields feed
|
||||
the run-detail UI panel.
|
||||
|
||||
On completion the phase sets the run status to `complete` and emits TEL-4.
|
||||
|
||||
### Image-serving route
|
||||
|
||||
`GET /api/products/images/{id}/{rendition}` — storefront-authorized,
|
||||
streams the objectstore blob. The response sets
|
||||
`Cache-Control: public, immutable, max-age=31536000` (the object key encodes
|
||||
the image id, so the URL is stable for the lifetime of the image). INV-16:
|
||||
an image belongs to exactly one storefront; the route enforces storefront
|
||||
scope before reading from the objectstore.
|
||||
|
||||
### Hosted-URL recognition and INV-12 over images
|
||||
|
||||
`domains/products/hosted.py` provides a predicate that recognises whether an
|
||||
image URL is already served by this app (i.e., a `GET /api/products/images/…`
|
||||
URL at the current `APP_URL`). The diff pre-pass `_resolve_hosted_images`
|
||||
uses it to convert hosted URLs back to their `CatalogImage` FK before hashing
|
||||
the diff fingerprint — this closes the INV-12 round-trip guarantee over
|
||||
image columns (a re-imported export previews as all-unchanged even when
|
||||
images are hosted here).
|
||||
|
||||
## Named seams (what later slices replace)
|
||||
|
||||
- **`import_draft.file_bytes` → objectstore key (SLICE-7).** The upload
|
||||
currently lives as BYTEA on the draft row (`0002_products.sql`); SLICE-7
|
||||
moves the bytes to object storage and stores a key.
|
||||
- **`import_draft.file_bytes` remains BYTEA (deferred past SLICE-7).** Moving
|
||||
the draft blob to object storage was scoped out of SLICE-7. The media
|
||||
bucket holds only `product-images/` objects this slice; the `provision-bucket`
|
||||
script sets the `import-drafts/` lifecycle rule for forward-compat so the
|
||||
migration will be clean when it lands.
|
||||
- **`codec.detect_dialect` → Shopify (SLICE-8).** Today it always returns
|
||||
`"canonical"`; SLICE-8 recognizes Shopify's exact header set here (INV-17).
|
||||
- **Run-status complete shortcut → `fetching_images` (SLICE-7).**
|
||||
`confirm_draft` inserts the run with `status="complete"` directly; SLICE-7
|
||||
inserts it as `fetching_images` and hands off to the image-fetch task.
|
||||
Relatedly, image rows are created with the schema default
|
||||
`status='pending'` **today and stay pending** — placeholder behavior until
|
||||
SLICE-7's fetch phase (the run-detail payload already carries the stable
|
||||
`image_progress` / `image_outcomes` shape, zeroed).
|
||||
- **Run-status complete shortcut → shipped in SLICE-7.**
|
||||
`confirm_draft` now inserts the run as `applying`, then transitions to
|
||||
`fetching_images` (if the run has pending image URLs) or directly to
|
||||
`complete`. The fetch phase fills `image_progress` / `image_counts` /
|
||||
`image_outcomes` and sets `complete` when done.
|
||||
|
||||
## Test map
|
||||
|
||||
|
||||
@@ -0,0 +1,721 @@
|
||||
# SLICE-8 — Shopify dialect + public format docs — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Auto-detect a Shopify product-CSV export by its header set, map it to the canonical model at the codec boundary (INV-17), warn (not fail) on Shopify columns with no canonical home, and publish the public column reference (DOC-2) + finalized sample CSV (DOC-3) — completing PUC-6 (a Shopify export imports directly) and PUC-11 (learning the format).
|
||||
|
||||
**Architecture:** A Shopify file is recognized by *signature columns* it carries that canonical never does (`Body (HTML)`, `Variant Grams`, `Cost per item`, `Google Shopping / *`, …). Detected Shopify headers are rewritten to canonical names *before* the existing known/unknown split in `parse_csv`, so every downstream stage (validate → diff → apply) is already dialect-agnostic and unchanged (INV-17). Shopify columns with no canonical home — including two name-collision overrides (`Type` is Shopify's *free-text* type vs canonical's *structural* type; `Variant Weight Unit` is superseded by the grams→weight transform) — flow into the existing `unknown_columns` warning, which the preview UI already renders. The only value transform is `Variant Grams` → canonical `Variant Weight` (+ synthesized unit `g`). Frontend is already built for dialect display and the not-imported banner; SLICE-8 only sharpens the label and the upload-screen format help, and adds DOC-2.
|
||||
|
||||
**Tech Stack:** Python 3.13 / FastAPI backend (`backend/app/domains/products/`), Vitest + React/TS SPA (`frontend/src/`), Playwright E2E (`e2e/`). Tests: `pytest` (backend), `npm test` (frontend), `scripts/e2e.sh` (browser).
|
||||
|
||||
---
|
||||
|
||||
## The exhaustive Shopify → canonical mapping (the §6.5.1 fixture)
|
||||
|
||||
This table is the contract. It is pinned as a Python literal in Task 1 and as a CSV fixture in Task 3.
|
||||
|
||||
**Mapped — renamed** (Shopify header → canonical header):
|
||||
|
||||
| Shopify column | Canonical column | Note |
|
||||
| --- | --- | --- |
|
||||
| `Body (HTML)` | `Description` | HTML, sanitized downstream (INV-15) |
|
||||
| `Product Category` | `Google Product Category` | taxonomy string |
|
||||
| `Cost per item` | `Variant Cost` | decimal |
|
||||
| `Variant Grams` | `Variant Weight` | value transform: grams numeric; unit `g` synthesized |
|
||||
|
||||
**Mapped — direct** (same name, already in `KNOWN_COLUMNS`, pass through): `Handle`, `Title`, `Vendor`, `Tags`, `Published`, `Status`, `Option1 Name`, `Option2 Name`, `Option3 Name`, `Option1 Value`, `Option2 Value`, `Option3 Value`, `Variant SKU`, `Variant Barcode`, `Variant Price`, `Variant Inventory Tracker`, `Variant Inventory Qty`, `Variant Image`, `Image Src`, `Image Position`, `Image Alt Text`.
|
||||
|
||||
**Not imported — warned** (no canonical home; surfaced in `unknown_columns`):
|
||||
|
||||
- **Name-collision overrides (must NOT pass through):**
|
||||
- `Type` — Shopify free-text type; canonical `Type` is structural (`standalone`/kits, #15). Per decision D (spec §11): warned, never folded into canonical `Type` or `Tags`.
|
||||
- `Variant Weight Unit` — superseded by the `Variant Grams` → weight transform (unit forced to `g`).
|
||||
- **Shopify-only columns:** `Variant Compare At Price` (a.k.a. `Compare At Price`), `Variant Inventory Policy`, `Variant Fulfillment Service`, `Variant Requires Shipping`, `Variant Taxable`, `Variant Tax Code`, `Gift Card`, `SEO Title`, `SEO Description`, every `Google Shopping / *` column, and any market/region column (`Included / *`, `Price / *`, `Compare At Price / *`, `Price / International`, …).
|
||||
|
||||
**Algorithm (handles the unbounded market columns without enumerating them):** for each header column, in order —
|
||||
1. in `SHOPIFY_RENAME` → its canonical name (mapped);
|
||||
2. in `SHOPIFY_DROP` overrides (`{"Type", "Variant Weight Unit"}`) → not imported;
|
||||
3. else in `KNOWN_COLUMNS` → pass through (mapped);
|
||||
4. else → not imported.
|
||||
|
||||
**Detection signature** — a header is Shopify iff it contains ≥1 column canonical never has:
|
||||
`{"Body (HTML)", "Variant Grams", "Cost per item", "Variant Compare At Price", "Variant Inventory Policy", "Variant Fulfillment Service", "Variant Requires Shipping", "Variant Taxable", "Gift Card", "SEO Title", "SEO Description"}` **or** any column starting `"Google Shopping / "`. Otherwise canonical (the safe default — shared columns map identically, so an ambiguous file never misparses; §7.4 risk row).
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
- **Create** `backend/app/domains/products/dialect_shopify.py` — detection + header mapping + the pinned table. One responsibility: the Shopify adapter.
|
||||
- **Modify** `backend/app/domains/products/codec.py` — `detect_dialect()` calls the adapter; `parse_csv()` rewrites the header before the known/unknown split and synthesizes the weight unit.
|
||||
- **Create** `backend/tests/test_products_dialect_shopify.py` — adapter unit tests + the exhaustive-mapping fixture test.
|
||||
- **Modify** `backend/tests/test_products_codec.py` — detection + end-to-end parse-as-shopify tests.
|
||||
- **Modify** `backend/tests/test_products_invariants.py` — INV-10 over a partial Shopify file.
|
||||
- **Create** `backend/app/domains/products/columns.md` — DOC-2 source (column reference, every column + dialect notes).
|
||||
- **Modify** `backend/app/main.py` — add `GET /api/products/columns.md`.
|
||||
- **Modify** `backend/tests/test_products_api.py` (or the existing API test module) — route test for DOC-2.
|
||||
- **Modify** `frontend/src/productsApi.ts` — `dialectLabel("shopify")` → `"Shopify product CSV — mapped"`.
|
||||
- **Modify** `frontend/src/screens/products/ImportUpload.tsx` — format help mentions Shopify + links DOC-2.
|
||||
- **Modify** `frontend/src/screens/products/ProductsPage.tsx` — link DOC-2 beside the sample CSV.
|
||||
- **Modify** `frontend/src/productsApi.test.ts` (or create) — `dialectLabel` cases.
|
||||
- **Create** `e2e/fixtures/shopify-export.csv` — an unmodified-shape Shopify export fixture.
|
||||
- **Create** `e2e/tests/import-shopify-dialect.spec.ts` — `e2e_import_shopify_dialect`.
|
||||
- **Modify** `docs/products-domain.md` — DOC-4 dialect-adapter notes.
|
||||
- **Modify** `backend/app/__init__.py` (or wherever `__version__`/`app.json` version lives) + `CHANGELOG`/version bump to v0.8.0.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Shopify dialect adapter module
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/domains/products/dialect_shopify.py`
|
||||
- Test: `backend/tests/test_products_dialect_shopify.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
# backend/tests/test_products_dialect_shopify.py
|
||||
from app.domains.products.dialect_shopify import is_shopify_header, map_shopify_header
|
||||
|
||||
|
||||
def test_detects_shopify_by_signature_column():
|
||||
assert is_shopify_header(["Handle", "Title", "Body (HTML)", "Variant Price"]) is True
|
||||
assert is_shopify_header(["Handle", "Title", "Google Shopping / MPN"]) is True
|
||||
|
||||
|
||||
def test_canonical_header_is_not_shopify():
|
||||
assert is_shopify_header(["Handle", "Title", "Description", "Variant Cost"]) is False
|
||||
|
||||
|
||||
def test_ambiguous_shared_only_header_defaults_canonical():
|
||||
# Only columns common to both dialects -> not Shopify (safe default, never misparse).
|
||||
assert is_shopify_header(["Handle", "Title", "Option1 Name", "Variant Price", "Image Src"]) is False
|
||||
|
||||
|
||||
def test_map_renames_and_passes_through():
|
||||
mapped, not_imported = map_shopify_header(
|
||||
["Handle", "Body (HTML)", "Cost per item", "Variant Price"]
|
||||
)
|
||||
assert mapped == ["Handle", "Description", "Variant Cost", "Variant Price"]
|
||||
assert not_imported == []
|
||||
|
||||
|
||||
def test_map_drops_type_and_weight_unit_overrides():
|
||||
mapped, not_imported = map_shopify_header(
|
||||
["Handle", "Type", "Variant Grams", "Variant Weight Unit"]
|
||||
)
|
||||
assert mapped == ["Handle", None, "Variant Weight", None]
|
||||
assert not_imported == ["Type", "Variant Weight Unit"]
|
||||
|
||||
|
||||
def test_map_drops_shopify_only_and_market_columns():
|
||||
mapped, not_imported = map_shopify_header(
|
||||
["Handle", "Variant Compare At Price", "Gift Card", "Price / International"]
|
||||
)
|
||||
assert mapped == ["Handle", None, None, None]
|
||||
assert not_imported == ["Variant Compare At Price", "Gift Card", "Price / International"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_products_dialect_shopify.py -v`
|
||||
Expected: FAIL — `ModuleNotFoundError: app.domains.products.dialect_shopify`
|
||||
|
||||
- [ ] **Step 3: Implement the adapter**
|
||||
|
||||
```python
|
||||
# backend/app/domains/products/dialect_shopify.py
|
||||
"""Shopify product-CSV adapter (INV-17): detect by header signature, map to canonical.
|
||||
|
||||
The mapping is the §6.5.1 contract; the exhaustive table is pinned here and
|
||||
mirrored by tests/test_products_dialect_shopify.py + e2e/fixtures/shopify-export.csv.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import KNOWN_COLUMNS
|
||||
|
||||
# Shopify header -> canonical header (renames only; direct same-name columns pass
|
||||
# through via KNOWN_COLUMNS). Variant Grams carries a value transform (see codec).
|
||||
SHOPIFY_RENAME: dict[str, str] = {
|
||||
"Body (HTML)": "Description",
|
||||
"Product Category": "Google Product Category",
|
||||
"Cost per item": "Variant Cost",
|
||||
"Variant Grams": "Variant Weight",
|
||||
}
|
||||
|
||||
# Canonical-named columns Shopify uses differently — must NOT pass through.
|
||||
SHOPIFY_DROP: frozenset[str] = frozenset({"Type", "Variant Weight Unit"})
|
||||
|
||||
# Columns whose presence proves the file is a Shopify export (canonical never has them).
|
||||
_SIGNATURE: frozenset[str] = frozenset({
|
||||
"Body (HTML)", "Variant Grams", "Cost per item", "Variant Compare At Price",
|
||||
"Variant Inventory Policy", "Variant Fulfillment Service",
|
||||
"Variant Requires Shipping", "Variant Taxable", "Gift Card",
|
||||
"SEO Title", "SEO Description",
|
||||
})
|
||||
|
||||
|
||||
def is_shopify_header(header: list[str]) -> bool:
|
||||
cols = {h.strip() for h in header}
|
||||
if cols & _SIGNATURE:
|
||||
return True
|
||||
return any(c.startswith("Google Shopping / ") for c in cols)
|
||||
|
||||
|
||||
def map_shopify_header(header: list[str]) -> tuple[list[str | None], list[str]]:
|
||||
"""Return (mapped, not_imported): mapped[i] is the canonical name for header[i],
|
||||
or None when that Shopify column has no canonical home; not_imported lists the
|
||||
original Shopify names (in order) that were dropped — the preview warning."""
|
||||
mapped: list[str | None] = []
|
||||
not_imported: list[str] = []
|
||||
for raw in header:
|
||||
col = raw.strip()
|
||||
if col in SHOPIFY_RENAME:
|
||||
mapped.append(SHOPIFY_RENAME[col])
|
||||
elif col in SHOPIFY_DROP:
|
||||
mapped.append(None)
|
||||
not_imported.append(col)
|
||||
elif col in KNOWN_COLUMNS:
|
||||
mapped.append(col)
|
||||
else:
|
||||
mapped.append(None)
|
||||
if col:
|
||||
not_imported.append(col)
|
||||
return mapped, not_imported
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_products_dialect_shopify.py -v`
|
||||
Expected: PASS (6 tests)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/domains/products/dialect_shopify.py backend/tests/test_products_dialect_shopify.py
|
||||
git commit -m "feat(products): Shopify dialect adapter — header detection + canonical mapping (SD-0002 §6.5.1, SLICE-8)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Wire the adapter into the codec
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/domains/products/codec.py:15-66`
|
||||
- Test: `backend/tests/test_products_codec.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** (append to `test_products_codec.py`)
|
||||
|
||||
```python
|
||||
def test_parse_detects_and_maps_shopify():
|
||||
data = (
|
||||
"Handle,Title,Body (HTML),Cost per item,Variant Price,Variant Grams,Type,Gift Card\n"
|
||||
"mug,Moon Mug,<p>Grey</p>,9.50,18.00,300,Drinkware,false\n"
|
||||
).encode("utf-8")
|
||||
parsed = parse_csv(data)
|
||||
assert parsed.dialect == "shopify"
|
||||
cells = parsed.rows[0].cells
|
||||
assert cells["Description"] == "<p>Grey</p>"
|
||||
assert cells["Variant Cost"] == "9.50"
|
||||
assert cells["Variant Weight"] == "300"
|
||||
assert cells["Variant Weight Unit"] == "g" # synthesized
|
||||
# Type (free-text) and Gift Card warned, never mapped:
|
||||
assert "Type" in parsed.unknown_columns
|
||||
assert "Gift Card" in parsed.unknown_columns
|
||||
assert "product_type" not in str(cells) # Type never reached canonical
|
||||
|
||||
|
||||
def test_parse_canonical_unchanged():
|
||||
data = b"Handle,Title,Description,Variant Price\nmug,Moon Mug,Grey,18.00\n"
|
||||
parsed = parse_csv(data)
|
||||
assert parsed.dialect == "canonical"
|
||||
assert parsed.rows[0].cells["Description"] == "Grey"
|
||||
assert parsed.unknown_columns == []
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_products_codec.py -k "shopify or canonical_unchanged" -v`
|
||||
Expected: FAIL — `parsed.dialect == "canonical"` (detection still stubbed) / `KeyError: 'Description'`
|
||||
|
||||
- [ ] **Step 3: Rewrite `detect_dialect` + `parse_csv`** in `codec.py`
|
||||
|
||||
Replace the `detect_dialect` stub and the header/cell-building block. New `codec.py` (lines from the import down through `parse_csv`):
|
||||
|
||||
```python
|
||||
from .dialect_shopify import is_shopify_header, map_shopify_header
|
||||
from .models import KNOWN_COLUMNS, MAX_DATA_ROWS, MAX_FILE_BYTES, ParsedFile, Row
|
||||
|
||||
_REQUIRED_HEADER_COLUMNS = ("Handle", "Title")
|
||||
|
||||
|
||||
def detect_dialect(header: list[str]) -> str:
|
||||
"""The INV-17 seam: recognize Shopify's header set, else canonical (§6.5.1)."""
|
||||
return "shopify" if is_shopify_header(header) else "canonical"
|
||||
|
||||
|
||||
def parse_csv(data: bytes) -> ParsedFile:
|
||||
if len(data) > MAX_FILE_BYTES:
|
||||
raise FileRejected("file_too_large", "This file is larger than 10 MB.")
|
||||
try:
|
||||
text = data.decode("utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
|
||||
reader = csv.reader(io.StringIO(text))
|
||||
try:
|
||||
try:
|
||||
raw_header = next(reader)
|
||||
except StopIteration:
|
||||
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
|
||||
header = [h.strip() for h in raw_header]
|
||||
dialect = detect_dialect(header)
|
||||
|
||||
# INV-17: normalize the header to canonical names at the boundary. For Shopify,
|
||||
# mapped[i] is the canonical name (or None when dropped); not_imported is the
|
||||
# warning list. Canonical files map to themselves, unknowns warned as before.
|
||||
if dialect == "shopify":
|
||||
mapped, unknown = map_shopify_header(header)
|
||||
else:
|
||||
mapped = [c if c in KNOWN_COLUMNS else None for c in header]
|
||||
unknown = [c for c in header if c and c not in KNOWN_COLUMNS]
|
||||
|
||||
for col in _REQUIRED_HEADER_COLUMNS:
|
||||
if col not in (mapped):
|
||||
raise FileRejected(
|
||||
"missing_required_column",
|
||||
f"This file is missing the required column '{col}'.",
|
||||
)
|
||||
|
||||
# First occurrence of a duplicated canonical column wins.
|
||||
col_index: dict[str, int] = {}
|
||||
for i, name in enumerate(mapped):
|
||||
if name and name not in col_index:
|
||||
col_index[name] = i
|
||||
# De-dup the warning list, order-preserving.
|
||||
seen: set[str] = set()
|
||||
unknown = [c for c in unknown if not (c in seen or seen.add(c))]
|
||||
|
||||
rows: list[Row] = []
|
||||
for raw in reader:
|
||||
if not any(cell.strip() for cell in raw):
|
||||
continue
|
||||
if len(rows) >= MAX_DATA_ROWS:
|
||||
raise FileRejected(
|
||||
"too_many_rows",
|
||||
f"This file has more than {MAX_DATA_ROWS:,} rows — split it and import in parts.",
|
||||
)
|
||||
cells = {
|
||||
c: (raw[col_index[c]].strip() if col_index[c] < len(raw) else "")
|
||||
for c in col_index
|
||||
}
|
||||
# Shopify grams carry an implicit unit; canonical needs it explicit (§6.5.1).
|
||||
if dialect == "shopify" and cells.get("Variant Weight"):
|
||||
cells["Variant Weight Unit"] = "g"
|
||||
rows.append(Row(line_number=reader.line_num, cells=cells))
|
||||
except csv.Error:
|
||||
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
|
||||
return ParsedFile(dialect=dialect, header=header, unknown_columns=unknown, rows=rows)
|
||||
```
|
||||
|
||||
Note: the required-column check now reads `mapped` (canonical names) so a Shopify file whose `Title`/`Handle` are present passes, and a file genuinely missing them still fails honestly. `Handle` and `Title` are direct pass-through names in both dialects, so `mapped` contains them when present.
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_products_codec.py -v`
|
||||
Expected: PASS (existing canonical tests + 2 new)
|
||||
|
||||
- [ ] **Step 5: Run the whole products suite (no regressions)**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/ -q`
|
||||
Expected: PASS — validate/diff/invariants unchanged (they consume canonical cells; INV-17 holds).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/domains/products/codec.py backend/tests/test_products_codec.py
|
||||
git commit -m "feat(products): codec detects + maps Shopify dialect at the boundary (INV-17, SLICE-8)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Exhaustive-mapping fixture test (the §6.5.1 pin)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/tests/fixtures/shopify-export.csv`
|
||||
- Test: `backend/tests/test_products_dialect_shopify.py` (append)
|
||||
|
||||
- [ ] **Step 1: Create a realistic full-width Shopify export fixture**
|
||||
|
||||
`backend/tests/fixtures/shopify-export.csv` — one product, two variants, one image-only row, exercising every mapped + several not-imported columns:
|
||||
|
||||
```csv
|
||||
Handle,Title,Body (HTML),Vendor,Product Category,Type,Tags,Published,Status,Option1 Name,Option1 Value,Variant SKU,Variant Grams,Variant Inventory Tracker,Variant Inventory Qty,Variant Inventory Policy,Variant Fulfillment Service,Variant Price,Variant Compare At Price,Variant Requires Shipping,Variant Taxable,Variant Barcode,Image Src,Image Position,Image Alt Text,Gift Card,SEO Title,SEO Description,Google Shopping / MPN,Variant Image,Variant Weight Unit,Cost per item,Status
|
||||
star-tee,Star Tee,<p>Soft cotton tee.</p>,Wiggle Goods,Apparel & Accessories > Clothing,Shirts,"apparel, tees",TRUE,active,Size,S,WG-TEE-S,180,shopify,12,deny,manual,24.00,30.00,TRUE,TRUE,0001,https://img.example.com/star-tee.jpg,1,Star Tee,FALSE,Star Tee | Wiggle,Soft tee,MPN-1,https://img.example.com/star-s.jpg,g,11.00,active
|
||||
star-tee,,,,,,,,,,M,WG-TEE-M,180,shopify,18,deny,manual,24.00,30.00,TRUE,TRUE,0002,,,,FALSE,,,,,g,11.00,
|
||||
star-tee,,,,,,,,,,,,,,,,,,,,,,https://img.example.com/star-back.jpg,2,Star Tee back,,,,,,,,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
```python
|
||||
import csv as _csv
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from app.domains.products.codec import parse_csv
|
||||
|
||||
_FIXTURE = Path(__file__).parent / "fixtures" / "shopify-export.csv"
|
||||
|
||||
|
||||
def test_shopify_fixture_maps_exhaustively():
|
||||
parsed = parse_csv(_FIXTURE.read_bytes())
|
||||
assert parsed.dialect == "shopify"
|
||||
first = parsed.rows[0].cells
|
||||
# renamed
|
||||
assert first["Description"] == "<p>Soft cotton tee.</p>"
|
||||
assert first["Google Product Category"] == "Apparel & Accessories > Clothing"
|
||||
assert first["Variant Cost"] == "11.00"
|
||||
assert first["Variant Weight"] == "180"
|
||||
assert first["Variant Weight Unit"] == "g"
|
||||
# direct
|
||||
assert first["Variant SKU"] == "WG-TEE-S"
|
||||
assert first["Image Src"] == "https://img.example.com/star-tee.jpg"
|
||||
# not imported — warned, none leaked into canonical cells
|
||||
for col in ("Type", "Variant Compare At Price", "Gift Card", "SEO Title",
|
||||
"Google Shopping / MPN", "Variant Weight Unit", "Variant Inventory Policy",
|
||||
"Variant Fulfillment Service", "Variant Requires Shipping", "Variant Taxable"):
|
||||
assert col in parsed.unknown_columns, col
|
||||
assert "Variant Compare At Price" not in first
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run — expect PASS** (codec from Task 2 already maps correctly)
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_products_dialect_shopify.py::test_shopify_fixture_maps_exhaustively -v`
|
||||
Expected: PASS. If a column assertion fails, fix the mapping table in `dialect_shopify.py` to match this fixture — the fixture and table must agree.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/tests/fixtures/shopify-export.csv backend/tests/test_products_dialect_shopify.py
|
||||
git commit -m "test(products): exhaustive Shopify->canonical mapping fixture (SD-0002 §6.5.1, SLICE-8)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: INV-10 over a partial Shopify import (never deletes)
|
||||
|
||||
**Files:**
|
||||
- Test: `backend/tests/test_products_invariants.py` (append)
|
||||
|
||||
- [ ] **Step 1: Inspect the existing INV-10 test** to reuse its catalog/diff helpers
|
||||
|
||||
Run: `cd backend && grep -n "def test_inv10\|compute_diff\|build_products\|catalog" tests/test_products_invariants.py | head`
|
||||
Expected: shows the helper pattern (catalog fixture → parse → build_products → compute_diff → assert no deletes in plan).
|
||||
|
||||
- [ ] **Step 2: Write the failing test** (mirror the existing INV-10 test, swapping in a Shopify file that omits an existing product)
|
||||
|
||||
```python
|
||||
def test_inv10_shopify_partial_never_deletes():
|
||||
# An existing catalog of two products; a Shopify file mentioning only one.
|
||||
catalog = _catalog_with(["star-tee", "moon-mug"]) # existing helper
|
||||
data = (
|
||||
"Handle,Title,Body (HTML),Variant Price,Variant Grams\n"
|
||||
"star-tee,Star Tee,<p>x</p>,24.00,180\n"
|
||||
).encode("utf-8")
|
||||
parsed = parse_csv(data)
|
||||
assert parsed.dialect == "shopify"
|
||||
products = build_products(parsed)
|
||||
result = compute_diff(catalog, products)
|
||||
# INV-10: nothing in the plan deletes; moon-mug (unmentioned) is untouched.
|
||||
assert all(op.kind != "delete" for op in result.plan)
|
||||
assert "moon-mug" not in {p.handle for p in products}
|
||||
```
|
||||
|
||||
Adapt `_catalog_with` / `op.kind` / `result.plan` to the actual symbols found in Step 1.
|
||||
|
||||
- [ ] **Step 3: Run to verify it passes**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/test_products_invariants.py -k inv10_shopify -v`
|
||||
Expected: PASS (INV-10 is dialect-agnostic; this proves it over Shopify).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/tests/test_products_invariants.py
|
||||
git commit -m "test(products): INV-10 holds over a partial Shopify import (SLICE-8)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Frontend — Shopify label + format help
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/productsApi.ts:52-54`
|
||||
- Modify: `frontend/src/screens/products/ImportUpload.tsx:53`
|
||||
- Modify: `frontend/src/screens/products/ProductsPage.tsx:114`
|
||||
- Test: `frontend/src/productsApi.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (create or append `frontend/src/productsApi.test.ts`)
|
||||
|
||||
```ts
|
||||
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", () =>
|
||||
expect(dialectLabel("shopify")).toBe("Shopify product CSV — mapped"));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd frontend && npm test -- productsApi`
|
||||
Expected: FAIL — receives `"shopify"`, expected `"Shopify product CSV — mapped"`.
|
||||
|
||||
- [ ] **Step 3: Update `dialectLabel`**
|
||||
|
||||
```ts
|
||||
// frontend/src/productsApi.ts
|
||||
export function dialectLabel(d: string): string {
|
||||
if (d === "canonical") return "Canonical format";
|
||||
if (d === "shopify") return "Shopify product CSV — mapped";
|
||||
return d;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `cd frontend && npm test -- productsApi`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Update the upload-screen format help** — `ImportUpload.tsx:53`
|
||||
|
||||
Replace the line `Works with the canonical format.{" "}` and the following sample link block so it reads (PUC-11, §5.4 wireframe text):
|
||||
|
||||
```tsx
|
||||
Works with the canonical format or a Shopify product CSV.{" "}
|
||||
<a href="/api/products/sample.csv" download>
|
||||
Download a sample
|
||||
</a>{" "}
|
||||
·{" "}
|
||||
<a href="/api/products/columns.md" target="_blank" rel="noopener">
|
||||
Column reference
|
||||
</a>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add the column-reference link beside the sample on the Products page** — `ProductsPage.tsx:114`
|
||||
|
||||
After the existing `<a href="/api/products/sample.csv" download>` element, add:
|
||||
|
||||
```tsx
|
||||
{" · "}
|
||||
<a href="/api/products/columns.md" target="_blank" rel="noopener">
|
||||
Column reference
|
||||
</a>
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Build + typecheck + test**
|
||||
|
||||
Run: `cd frontend && npm run build && npm test`
|
||||
Expected: PASS (no TS errors, vitest green).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/productsApi.ts frontend/src/productsApi.test.ts frontend/src/screens/products/ImportUpload.tsx frontend/src/screens/products/ProductsPage.tsx
|
||||
git commit -m "feat(products-ui): Shopify 'mapped' label + format help links to column reference (PUC-6/PUC-11, SLICE-8)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: DOC-2 column reference + route; finalize DOC-3
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/domains/products/columns.md`
|
||||
- Modify: `backend/app/main.py` (after the `sample.csv` route, ~line 451)
|
||||
- Modify: `backend/app/domains/products/__init__.py` (export `COLUMNS_MD_PATH` next to `SAMPLE_CSV_PATH`)
|
||||
- Test: the existing products API test module (find it in Step 4)
|
||||
|
||||
- [ ] **Step 1: Write the DOC-2 column reference** — `backend/app/domains/products/columns.md`
|
||||
|
||||
A complete merchant-facing reference: a short intro (canonical = Shopify-flavored superset; auto-detection; blank-vs-absent semantics), then a table of **every canonical column** (name · level · required · accepted values), then a **Shopify dialect notes** section reproducing the Task-1 mapping table (renamed, direct, not-imported). Source the canonical column rows from spec §6.5.1 (the table at lines 852-873) and the dialect notes from §6.5.1 (lines 880-889). Keep it plain Markdown — no app-specific build step.
|
||||
|
||||
- [ ] **Step 2: Export the path** — `backend/app/domains/products/__init__.py`
|
||||
|
||||
Add beside the existing `SAMPLE_CSV_PATH`:
|
||||
|
||||
```python
|
||||
COLUMNS_MD_PATH = _HERE / "columns.md"
|
||||
```
|
||||
|
||||
(Use the same `_HERE` / `Path(__file__).parent` idiom already present for `SAMPLE_CSV_PATH`.)
|
||||
|
||||
- [ ] **Step 3: Add the route** — `backend/app/main.py`, immediately after `products_sample_csv`
|
||||
|
||||
```python
|
||||
@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",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write the failing route test** — find the API test module first
|
||||
|
||||
Run: `cd backend && ls tests/ | grep -i "api\|main"`
|
||||
Append to the module that tests the other unauthenticated doc routes:
|
||||
|
||||
```python
|
||||
def test_columns_md_served(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 # dialect notes present
|
||||
assert "Handle" in body # canonical columns present
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to verify it passes**
|
||||
|
||||
Run: `cd backend && python -m pytest tests/ -k "columns_md or sample" -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Verify DOC-3 sample.csv is final** — it already carries a variant product (`star-tee`, S/M/L) and image rows. Confirm it parses clean as canonical:
|
||||
|
||||
Run: `cd backend && python -c "from app.domains.products.codec import parse_csv; from app.domains.products import SAMPLE_CSV_PATH; p=parse_csv(SAMPLE_CSV_PATH.read_bytes()); print(p.dialect, len(p.rows), p.unknown_columns)"`
|
||||
Expected: `canonical 5 []` (no unknown columns; 5 data rows). If `unknown_columns` is non-empty, fix the sample header to canonical names.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/domains/products/columns.md backend/app/domains/products/__init__.py backend/app/main.py backend/tests/
|
||||
git commit -m "docs(products): DOC-2 column reference served at /api/products/columns.md (SLICE-8)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: DOC-4 dialect-adapter dev notes
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/products-domain.md`
|
||||
|
||||
- [ ] **Step 1: Add a "Dialects (INV-17)" subsection** documenting: detection by signature columns; the `dialect_shopify` adapter; the rename/drop/passthrough algorithm; the grams→weight transform; that all downstream stages are dialect-agnostic. Point to `dialect_shopify.py` and the fixture as the source of truth (documentation-leads-automation, §4.1).
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/products-domain.md
|
||||
git commit -m "docs(products): DOC-4 dialect-adapter notes (SLICE-8)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: E2E — `e2e_import_shopify_dialect`
|
||||
|
||||
**Files:**
|
||||
- Create: `e2e/fixtures/shopify-export.csv` (copy the Task-3 fixture)
|
||||
- Create: `e2e/tests/import-shopify-dialect.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Add the fixture** — same content as `backend/tests/fixtures/shopify-export.csv`.
|
||||
|
||||
- [ ] **Step 2: Read an existing import E2E spec** to reuse the sign-up + upload helpers
|
||||
|
||||
Run: `sed -n '1,60p' e2e/tests/import-preview-confirm.spec.ts`
|
||||
Expected: shows the page-object / sign-up helper and the upload→preview→confirm flow to mirror.
|
||||
|
||||
- [ ] **Step 3: Write `e2e/tests/import-shopify-dialect.spec.ts`** (mirroring that helper)
|
||||
|
||||
```ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { signUpAndOpenImport } from "./helpers"; // use the actual helper from Step 2
|
||||
|
||||
test("e2e_import_shopify_dialect: a Shopify export imports directly (PUC-6)", async ({ page }) => {
|
||||
await signUpAndOpenImport(page);
|
||||
await page.getByLabel(/choose|upload|file/i).setInputFiles("fixtures/shopify-export.csv");
|
||||
// Preview shows the mapped banner + not-imported warning
|
||||
await expect(page.getByText("Shopify product CSV — mapped")).toBeVisible();
|
||||
await expect(page.getByText(/not imported/i)).toBeVisible();
|
||||
await expect(page.getByText("Type")).toBeVisible(); // a warned column
|
||||
// Confirm and land on the run detail
|
||||
await page.getByRole("button", { name: /^Import \d/ }).click();
|
||||
await expect(page).toHaveURL(/\/products\/imports\/runs\/\d+/);
|
||||
await expect(page.getByText("Shopify product CSV — mapped")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
Adapt selectors to the real upload control + helper names found in Step 2.
|
||||
|
||||
- [ ] **Step 4: Run the E2E suite**
|
||||
|
||||
Run: `./scripts/e2e.sh`
|
||||
Expected: all specs PASS including `import-shopify-dialect` (7 prior + 1 new = 8).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add e2e/fixtures/shopify-export.csv e2e/tests/import-shopify-dialect.spec.ts
|
||||
git commit -m "test(e2e): e2e_import_shopify_dialect — Shopify export imports directly (PUC-6, SLICE-8)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Traceability audit, version bump, full green
|
||||
|
||||
**Files:**
|
||||
- Modify: version source (`backend/app/__init__.py` or `app.json`) + any `CHANGELOG`
|
||||
- Read-only: spec §12 traceability matrix
|
||||
|
||||
- [ ] **Step 1: Audit §12 traceability** — confirm PUC-6, PUC-11, BUC-1 (Shopify), DOC-2, DOC-3 now have shipping code + tests. Note any gap in the transcript `## Deferred decisions`. (Spec audit is read-only; do not edit the content repo here — finalize submits the plan.)
|
||||
|
||||
- [ ] **Step 2: Bump the version to v0.8.0**
|
||||
|
||||
Run: `cd backend && grep -rn "0.7.0" app/__init__.py app.json 2>/dev/null`
|
||||
Update the version string to `0.8.0` wherever the prior slice set it (mirror the SLICE-7 v0.7.0 bump).
|
||||
|
||||
- [ ] **Step 3: Full local gate**
|
||||
|
||||
Run: `./scripts/check.sh && ./scripts/e2e.sh` (or the repo's canonical localhost gate)
|
||||
Expected: backend pytest, frontend build+vitest, import-linter, and Playwright all PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore(products): SLICE-8 complete — bump v0.8.0; §12 traceability audited (SD-0002)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Post-plan (session flow, not subagent tasks)
|
||||
|
||||
After all tasks are green on the branch:
|
||||
1. **PR → merge to `main`** (branch→PR→merge; §5.4).
|
||||
2. **PPE deploy** via flotilla + E2E green on `https://ecomm-ppe.wiggleverse.org`; stamp the `release/<ts>` tag (§9.1). Prod promotion stays the operator's gate.
|
||||
3. **Close story #13** (SLICE-5/6/7/8 all done) with a pointer to the merge + PPE release tag.
|
||||
4. **Finalize** the session (`wgl-session-finalize`) — archives this plan to the content repo `plans/`, updates memory, publishes the transcript.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage (SLICE-8 §7.2 DoD):**
|
||||
- Header-set dialect detection → Task 1 (`is_shopify_header`) + Task 2 (`detect_dialect`). ✓
|
||||
- Shopify→canonical mapping + exhaustive fixture → Task 1 (table) + Task 3 (fixture pin). ✓
|
||||
- Preview "mapped" banner + not-imported warnings → Task 5 (label) + already-built `unknown_columns` banner (`ImportPreview.tsx:288`). ✓
|
||||
- DOC-2 column reference (published) → Task 6. ✓
|
||||
- DOC-3 final sample CSV → Task 6 Step 6 (verify; already has variants+images). ✓
|
||||
- `e2e_import_shopify_dialect` green → Task 8. ✓
|
||||
- BUC-1 acceptance for an unmodified Shopify export → Task 3 fixture + Task 8 E2E. ✓
|
||||
- §12 traceability audited → Task 9. ✓
|
||||
- INV-10 over Shopify → Task 4. ✓
|
||||
|
||||
**Placeholder scan:** every code step shows concrete code; selectors/helpers in Tasks 4/6/8 are explicitly flagged to adapt to symbols discovered in a named inspection step (not silent TBDs).
|
||||
|
||||
**Type consistency:** `is_shopify_header` / `map_shopify_header` signatures match between Task 1 (def), Task 2 (import + call), and Task 3 (test). `dialectLabel("shopify")` string is identical in Task 5 def, Task 5 test, and Task 8 E2E assertion (`"Shopify product CSV — mapped"`). `COLUMNS_MD_PATH` defined (Task 6 Step 2) before use (Task 6 Step 3).
|
||||
|
||||
**Deferred decisions (logged to transcript):**
|
||||
- `Variant Grams` → `Variant Weight` value with unit forced to `g`; Shopify's own `Variant Weight Unit` display column is *not imported* (warned). Lossless in mass (grams is exact); chosen over carrying a possibly-inconsistent unit. Revisit if merchants need kg/lb display fidelity.
|
||||
- DOC-2 served as raw Markdown (`text/markdown`), mirroring how DOC-3 `sample.csv` is served — not a styled HTML page. Satisfies "app-served, source in framework repo"; styled rendering is a future enhancement.
|
||||
- Ambiguous header (only shared columns) defaults to **canonical** rather than a distinct `unknown_dialect` state — shared columns map identically so it never misparses (§7.4 intent); avoids a third dialect state the model doesn't carry.
|
||||
@@ -1,4 +0,0 @@
|
||||
node_modules/
|
||||
.backend.log
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -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
|
||||
|
@@ -1,2 +0,0 @@
|
||||
Handle,Vendor
|
||||
mug,Acme
|
||||
|
@@ -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
|
||||
|
-110
@@ -1,110 +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 -> <to> | Your ecomm code: <6 digits>\n<body>
|
||||
// The first 6-digit group after the marker is the code.
|
||||
async function codeFor(email: string): Promise<string> {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const log = await readFile(LOG, "utf8").catch(() => "");
|
||||
const at = log.lastIndexOf(`LogMailer -> ${email}`);
|
||||
if (at >= 0) {
|
||||
const m = log.slice(at).match(/\b(\d{6})\b/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error(`no code logged for ${email}`);
|
||||
}
|
||||
|
||||
export async function signUpWithStorefront(page: Page, email = freshEmail()): Promise<string> {
|
||||
// Landing → the sign-up door.
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create your storefront →" }).click();
|
||||
|
||||
// Sign-in step 1: request the one-time code.
|
||||
await page.getByLabel("Email").fill(email);
|
||||
await page.getByRole("button", { name: "Send code" }).click();
|
||||
|
||||
// Sign-in step 2: read the code from the backend log and verify it.
|
||||
await expect(page.getByRole("heading", { name: "Check your email" })).toBeVisible();
|
||||
const code = await codeFor(email);
|
||||
await page.getByLabel("One-time code").fill(code);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Create-storefront screen (a new account has none yet).
|
||||
await expect(page.getByRole("heading", { name: "Create your storefront" })).toBeVisible();
|
||||
await page.getByLabel("Storefront name").fill(STOREFRONT_NAME);
|
||||
await page.getByRole("button", { name: "Create storefront", exact: true }).click();
|
||||
|
||||
// Admin shell: nav strip + the storefront identity in the topbar.
|
||||
await expect(page.getByRole("navigation", { name: "Admin sections" })).toBeVisible();
|
||||
await expect(page.locator(".storeid__name")).toHaveText(STOREFRONT_NAME);
|
||||
return email;
|
||||
}
|
||||
|
||||
export async function gotoProducts(page: Page) {
|
||||
await page
|
||||
.getByRole("navigation", { name: "Admin sections" })
|
||||
.getByRole("link", { name: "Products" })
|
||||
.click();
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Products" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function uploadFixture(page: Page, fixture: string) {
|
||||
// Two "Import products" links render on the empty products page (header + empty
|
||||
// state) — same destination, so take the first.
|
||||
await page.getByRole("link", { name: "Import products" }).first().click();
|
||||
await expect(page.getByRole("heading", { name: "Import products" })).toBeVisible();
|
||||
await page.locator('input[type="file"]').setInputFiles(join(__dirname, "fixtures", fixture));
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
// 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 <details> menu, then click the status option, capturing the download.
|
||||
// The menu is a native <details> that *toggles* on each summary click and is
|
||||
// NOT closed by clicking a download <a> (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 };
|
||||
}
|
||||
Generated
-76
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "wiggleverse-ecomm-e2e",
|
||||
"private": true,
|
||||
"scripts": { "test": "playwright test" },
|
||||
"devDependencies": { "@playwright/test": "^1.48.0" }
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -1,23 +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
|
||||
cd "$repo_root/backend"
|
||||
exec "$repo_root/.venv/bin/python" -m uvicorn app.main:app --port 8765 \
|
||||
> "$repo_root/e2e/.backend.log" 2>&1
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0E1230" />
|
||||
<style>html { background: #0E1230; }</style>
|
||||
<link rel="icon" type="image/svg+xml" href="/brand/mark-tile.svg" />
|
||||
<title>ecomm</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-2174
File diff suppressed because it is too large
Load Diff
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "wiggleverse-ecomm-frontend",
|
||||
"private": true,
|
||||
"version": "0.6.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"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
|
||||
<title>Wiggleverse — mono gold</title>
|
||||
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="#F4C76B" stroke-width="2.4" fill="none" stroke-linejoin="round"></path>
|
||||
<g fill="#F4C76B">
|
||||
<circle cx="98" cy="60" r="4.5"></circle><circle cx="79" cy="93" r="4.5"></circle><circle cx="41" cy="93" r="4.5"></circle>
|
||||
<circle cx="22" cy="60" r="4.5"></circle><circle cx="41" cy="27" r="4.5"></circle><circle cx="79" cy="27" r="4.5"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 648 B |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
|
||||
<title>Wiggleverse</title>
|
||||
<rect width="120" height="120" rx="26" fill="#0E1230"></rect>
|
||||
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="#F4C76B" stroke-width="5" fill="none" stroke-linejoin="round"></path>
|
||||
<g fill="#EDEAFF">
|
||||
<circle cx="98" cy="60" r="7"></circle><circle cx="79" cy="93" r="7"></circle><circle cx="41" cy="93" r="7"></circle>
|
||||
<circle cx="22" cy="60" r="7"></circle><circle cx="41" cy="27" r="7"></circle><circle cx="79" cy="27" r="7"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 684 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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<SessionState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [door, setDoor] = useState<Door | null>(null);
|
||||
const [welcome, setWelcome] = useState<Welcome>(null);
|
||||
|
||||
async function reload() {
|
||||
setLoading(true);
|
||||
setSession(await getMe());
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="screen screen--plain">
|
||||
<main className="screen__main">
|
||||
<p className="note">Loading…</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const route = routeFor(session ?? { account: null, storefront: null });
|
||||
|
||||
if (route === "landing") {
|
||||
if (door) {
|
||||
return (
|
||||
<SignIn
|
||||
door={door}
|
||||
onBack={() => setDoor(null)}
|
||||
onAuthed={(result: VerifyResult) => {
|
||||
setWelcome(result.created ? "new" : "back");
|
||||
setDoor(null);
|
||||
setSession({ account: result.account, storefront: result.storefront });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Landing onGetStarted={() => setDoor("signup")} onLogIn={() => setDoor("login")} />;
|
||||
}
|
||||
|
||||
const email = session!.account!.email;
|
||||
|
||||
function signedOut() {
|
||||
setWelcome(null);
|
||||
setDoor(null);
|
||||
setSession(null);
|
||||
}
|
||||
|
||||
if (route === "create-storefront") {
|
||||
return (
|
||||
<CreateStorefront
|
||||
email={email}
|
||||
welcome={welcome}
|
||||
onCreated={(sf: StorefrontResult) => {
|
||||
setWelcome(null);
|
||||
setSession({ account: { email }, storefront: sf });
|
||||
}}
|
||||
onAlreadyOwns={() => void reload()}
|
||||
onSignedOut={signedOut}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Admin
|
||||
storefrontName={session!.storefront!.name}
|
||||
email={email}
|
||||
welcome={welcome}
|
||||
onSignedOut={signedOut}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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<ApiError> {
|
||||
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<SessionState | null> {
|
||||
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<ApiError | null> {
|
||||
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<void> {
|
||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||
}
|
||||
|
||||
export interface StorefrontResult {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export async function createStorefront(
|
||||
name: string,
|
||||
): Promise<{ ok: true; storefront: StorefrontResult } | { ok: false; error: ApiError }> {
|
||||
const resp = await fetch("/api/storefronts", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(name.trim() ? { name: name.trim() } : {}),
|
||||
});
|
||||
if (resp.ok) return { ok: true, storefront: (await resp.json()) as StorefrontResult };
|
||||
return { ok: false, error: await errorOf(resp) };
|
||||
}
|
||||
@@ -1,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,114 +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<string, unknown>; changes?: FieldChange[];
|
||||
}
|
||||
export interface ImageEntry { src: string; kind?: string; position?: number; alt_text?: string | null; changes?: FieldChange[]; }
|
||||
export interface DraftRecord {
|
||||
handle: string; title: string; kind: RecordKind; variant_count: number;
|
||||
detail: {
|
||||
set?: Record<string, unknown>;
|
||||
option_names?: (string | null)[];
|
||||
changes?: FieldChange[];
|
||||
variants?: VariantEntry[];
|
||||
images?: ImageEntry[];
|
||||
errors?: RowErrorDetail[];
|
||||
};
|
||||
}
|
||||
export interface RunSummary {
|
||||
id: number; file_name: string; dialect: string; created_at: string;
|
||||
completed_at: string | null; status: string; by: string;
|
||||
products_added: number; products_updated: number; rows_errored: number;
|
||||
}
|
||||
export interface RunDetail extends RunSummary {
|
||||
errors: RowErrorDetail[];
|
||||
image_progress: { done: number; total: number };
|
||||
image_outcomes: unknown[];
|
||||
}
|
||||
export interface ProductsSummary {
|
||||
product_count: number; image_problem_count: number; latest_run_id: number | null;
|
||||
}
|
||||
|
||||
export type Result<T> = { ok: true; value: T } | { ok: false; error: ApiError; status: number };
|
||||
|
||||
// One label rule for CSV dialects, shared by Products history / preview / run detail.
|
||||
export function dialectLabel(d: string): string {
|
||||
return d === "canonical" ? "Canonical format" : d;
|
||||
}
|
||||
|
||||
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<T>(path: string, init?: RequestInit): Promise<Result<T>> {
|
||||
const resp = await fetch(path, { credentials: "include", ...init });
|
||||
if (!resp.ok) return { ok: false, error: await errorOf(resp), status: resp.status };
|
||||
if (resp.status === 204) return { ok: true, value: undefined as T };
|
||||
return { ok: true, value: (await resp.json()) as T };
|
||||
}
|
||||
|
||||
export function getProductsSummary(): Promise<Result<ProductsSummary>> {
|
||||
return request("/api/products/summary");
|
||||
}
|
||||
|
||||
export function uploadImport(file: File): Promise<Result<Draft>> {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
// No content-type header: the browser sets the multipart boundary.
|
||||
return request("/api/products/imports", { method: "POST", body });
|
||||
}
|
||||
|
||||
export function getDraft(id: number): Promise<Result<Draft>> {
|
||||
return request(`/api/products/imports/drafts/${id}`);
|
||||
}
|
||||
|
||||
export async function getDraftRecords(
|
||||
id: number, kind?: RecordKind, limit = 100, offset = 0,
|
||||
): Promise<Result<DraftRecord[]>> {
|
||||
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||
if (kind) params.set("kind", kind);
|
||||
const resp = await request<{ records: DraftRecord[] }>(
|
||||
`/api/products/imports/drafts/${id}/records?${params}`,
|
||||
);
|
||||
return resp.ok ? { ok: true, value: resp.value.records } : resp;
|
||||
}
|
||||
|
||||
export function confirmDraft(id: number): Promise<Result<{ run_id: number }>> {
|
||||
return request(`/api/products/imports/drafts/${id}/confirm`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function cancelDraft(id: number): Promise<Result<void>> {
|
||||
return request(`/api/products/imports/drafts/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function listRuns(): Promise<Result<RunSummary[]>> {
|
||||
const resp = await request<{ runs: RunSummary[] }>("/api/products/imports/runs");
|
||||
return resp.ok ? { ok: true, value: resp.value.runs } : resp;
|
||||
}
|
||||
|
||||
export function getRun(id: number): Promise<Result<RunDetail>> {
|
||||
return request(`/api/products/imports/runs/${id}`);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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<AdminView>(adminViewFor(window.location.hash));
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => setView(adminViewFor(window.location.hash));
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
return () => window.removeEventListener("hashchange", onHashChange);
|
||||
}, []);
|
||||
|
||||
async function signOut() {
|
||||
await logout();
|
||||
onSignedOut();
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen plain>
|
||||
<TopBar
|
||||
left={
|
||||
<span className="storeid">
|
||||
<img src="/brand/mark-tile.svg" width={24} height={24} alt="" />
|
||||
<span className="storeid__col">
|
||||
<span className="storeid__name">{storefrontName}</span>
|
||||
<span className="storeid__sub">ecomm storefront</span>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
right={<AccountChip email={email} onSignOut={signOut} />}
|
||||
/>
|
||||
<nav className="adminnav" aria-label="Admin sections">
|
||||
<a className={`adminnav__item${view.view === "home" ? " adminnav__item--active" : ""}`} href="#/">
|
||||
Overview
|
||||
</a>
|
||||
<a className={`adminnav__item${view.view !== "home" ? " adminnav__item--active" : ""}`} href="#/products">
|
||||
Products
|
||||
</a>
|
||||
</nav>
|
||||
<main className="screen__main">
|
||||
{view.view === "home" && (
|
||||
<div className="empty">
|
||||
{welcome && (
|
||||
<div style={{ marginBottom: 24, width: "100%" }}>
|
||||
<Banner tone="info" title={welcome === "new" ? "Welcome to ecomm" : "Welcome back"}>
|
||||
{welcome === "new"
|
||||
? "A new account was created for this email."
|
||||
: "Signed in to your existing account."}
|
||||
</Banner>
|
||||
</div>
|
||||
)}
|
||||
<div className="empty__seal" aria-hidden="true">
|
||||
<img src="/brand/mark-mono-gold.svg" width={36} height={36} alt="" />
|
||||
</div>
|
||||
<Eyebrow>Your storefront</Eyebrow>
|
||||
<h1>{storefrontName}</h1>
|
||||
<p className="empty__copy">
|
||||
There's nothing to manage yet — and that's a finished state, not a missing one.
|
||||
Catalog, orders, and settings will appear here as ecomm grows.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{view.view === "products" && <ProductsPage />}
|
||||
{view.view === "import-upload" && <ImportUpload />}
|
||||
{view.view === "import-preview" && <ImportPreview draftId={view.draftId} />}
|
||||
{view.view === "run-detail" && <RunDetail runId={view.runId} />}
|
||||
</main>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
async function signOut() {
|
||||
await logout();
|
||||
onSignedOut();
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const res = await createStorefront(name);
|
||||
setBusy(false);
|
||||
if (res.ok) {
|
||||
onCreated(res.storefront);
|
||||
return;
|
||||
}
|
||||
if (res.error.code === "already_owns_storefront") setAlreadyOwns(true);
|
||||
else setError(res.error.message);
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen horizon>
|
||||
<TopBar left={<Wordmark size={22} />} right={<AccountChip email={email} onSignOut={signOut} />} />
|
||||
<main className="screen__main">
|
||||
<AuthCard>
|
||||
<form onSubmit={submit} style={{ display: "contents" }}>
|
||||
<div>
|
||||
<Eyebrow>One storefront, fully yours</Eyebrow>
|
||||
<h1 style={{ marginTop: 12 }}>Create your storefront</h1>
|
||||
<p className="card__sub">
|
||||
This is the one thing to do right now. It costs nothing and commits you to nothing.
|
||||
</p>
|
||||
</div>
|
||||
{welcome && (
|
||||
<Banner tone="info" title={welcome === "new" ? "Welcome to ecomm" : "Welcome back"}>
|
||||
{welcome === "new"
|
||||
? "A new account was created for this email."
|
||||
: "Signed in to your existing account."}
|
||||
</Banner>
|
||||
)}
|
||||
{alreadyOwns && (
|
||||
<Banner title="Your account already has its storefront">
|
||||
ecomm is one storefront per account today.{" "}
|
||||
<button type="button" className="lk" onClick={onAlreadyOwns}>
|
||||
Go to your admin →
|
||||
</button>
|
||||
</Banner>
|
||||
)}
|
||||
{error && <Banner title="That didn't work">{error} Try again.</Banner>}
|
||||
<Field
|
||||
label="Storefront name"
|
||||
optional
|
||||
helper="You can leave this blank — we'll pick a placeholder name you can change any time."
|
||||
inputProps={{
|
||||
type: "text",
|
||||
value: name,
|
||||
placeholder: "e.g. Ben's Bets",
|
||||
autoFocus: true,
|
||||
onChange: (ev) => setName(ev.target.value),
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<PrimaryButton busy={busy}>{busy ? "Creating…" : "Create storefront"}</PrimaryButton>
|
||||
<p className="note">You can rename, configure, or close your storefront whenever you like.</p>
|
||||
</div>
|
||||
</form>
|
||||
</AuthCard>
|
||||
</main>
|
||||
<Footer />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -1,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 (
|
||||
<Screen horizon>
|
||||
<TopBar
|
||||
left={<Wordmark />}
|
||||
right={
|
||||
<button type="button" className="lk lk--nav" onClick={onLogIn}>
|
||||
Log in
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<main className="screen__main">
|
||||
<div className="hero">
|
||||
<Eyebrow>One storefront, fully yours</Eyebrow>
|
||||
<h1>
|
||||
Sell online,
|
||||
<br />
|
||||
<em>honestly.</em>
|
||||
</h1>
|
||||
<p className="hero__lead">
|
||||
Claim the one storefront that's yours on a platform that takes only what it takes
|
||||
to run — no trial countdown, no plan wall, no data you didn't agree to give.
|
||||
</p>
|
||||
<div className="hero__actions">
|
||||
<PrimaryButton type="button" onClick={onGetStarted}>
|
||||
Create your storefront →
|
||||
</PrimaryButton>
|
||||
<button type="button" className="lk lk--mute" onClick={onLogIn}>
|
||||
Already selling with us? <span style={{ color: "var(--wv-lilac)" }}>Log in</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="promises">
|
||||
{PROMISES.map(([title, copy]) => (
|
||||
<div key={title}>
|
||||
<div className="promises__title">{title}</div>
|
||||
<p>{copy}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [codeError, setCodeError] = useState<string | null>(null);
|
||||
const [bannerError, setBannerError] = useState<ApiError | null>(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<string | null>(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<boolean> {
|
||||
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 (
|
||||
<Screen horizon>
|
||||
<TopBar
|
||||
left={<Wordmark size={22} />}
|
||||
right={
|
||||
<button type="button" className="lk lk--mute" onClick={onBack}>
|
||||
← Back
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<main className="screen__main">
|
||||
<AuthCard>
|
||||
{step === "email" ? (
|
||||
<form onSubmit={submitEmail} style={{ display: "contents" }}>
|
||||
<div>
|
||||
<h1>{heading}</h1>
|
||||
<p className="card__sub">{sub}</p>
|
||||
</div>
|
||||
{bannerError && (
|
||||
<Banner title="We couldn't send the code">
|
||||
The email didn't go out. Try again in a moment — nothing was lost.
|
||||
</Banner>
|
||||
)}
|
||||
<Field
|
||||
label="Email"
|
||||
error={fieldError}
|
||||
inputProps={{
|
||||
type: "email",
|
||||
value: email,
|
||||
placeholder: "you@example.com",
|
||||
autoFocus: true,
|
||||
required: true,
|
||||
autoComplete: "email",
|
||||
onChange: (ev) => setEmail(ev.target.value),
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<PrimaryButton busy={busy} disabled={cooldownAppliesTo(email, cooldownFor, cooldown)}>
|
||||
{busy
|
||||
? "Sending…"
|
||||
: cooldownAppliesTo(email, cooldownFor, cooldown)
|
||||
? `Resend in ${cooldown}s`
|
||||
: "Send code"}
|
||||
</PrimaryButton>
|
||||
<p className="note">
|
||||
We'll email you a one-time code. That's all we need — no password, ever.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={submitCode} style={{ display: "contents" }}>
|
||||
<div>
|
||||
<h1>Check your email</h1>
|
||||
<p className="card__sub">
|
||||
We sent a 6-digit code to <span style={{ color: "var(--wv-starlight)" }}>{email}</span>.{" "}
|
||||
<button type="button" className="lk" onClick={() => setStep("email")}>
|
||||
Wrong address?
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
{bannerError && (
|
||||
<Banner title={bannerError.code === "code_exhausted" ? "Too many attempts" : "Something went wrong"}>
|
||||
{bannerError.code === "code_exhausted"
|
||||
? "That code is no longer valid. Request a fresh one to keep going."
|
||||
: bannerError.message}
|
||||
</Banner>
|
||||
)}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<CodeInput value={code} onChange={setCode} error={!!codeError} />
|
||||
{codeError && (
|
||||
<p className="note note--attn" role="alert">
|
||||
{codeError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<PrimaryButton busy={busy}>{busy ? "Checking…" : "Continue"}</PrimaryButton>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<p className="note">Good for 10 minutes.</p>
|
||||
{cooldown > 0 ? (
|
||||
<span className="note">Resend in {cooldown}s</span>
|
||||
) : (
|
||||
<button type="button" className="lk" onClick={resend}>
|
||||
Resend code
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</AuthCard>
|
||||
</main>
|
||||
<Footer />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -1,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 <span className={`kindchip kindchip--${kind}`}>{kind}</span>;
|
||||
}
|
||||
|
||||
function SetLine({ field, value }: { field: string; value: unknown }) {
|
||||
return (
|
||||
<div className="diffchange">
|
||||
<span className="diffchange__glyph--add">+ </span>
|
||||
{field}: {fmt(value)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeRow({ change }: { change: FieldChange }) {
|
||||
return (
|
||||
<div className="diffchange">
|
||||
{change.field}: <span className="diffchange__glyph--del">− {fmt(change.before)}</span> →{" "}
|
||||
<span className="diffchange__glyph--add">+ {fmt(change.after)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function variantLabel(v: VariantEntry): string {
|
||||
const opts = v.options.filter((o): o is string => o != null);
|
||||
return opts.length ? `Variant ${opts.join(" / ")}` : "Variant";
|
||||
}
|
||||
|
||||
function RecordDetail({ record }: { record: DraftRecord }) {
|
||||
const d = record.detail;
|
||||
if (record.kind === "error") {
|
||||
return (
|
||||
<div>
|
||||
{(d.errors ?? []).map((e, i) => (
|
||||
<div className="diffchange" key={i}>
|
||||
line {e.line}: {e.column != null && `'${e.column}' — `}
|
||||
{e.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{Object.entries(d.set ?? {}).map(([field, value]) => (
|
||||
<SetLine key={field} field={field} value={value} />
|
||||
))}
|
||||
{(d.changes ?? []).map((c, i) => (
|
||||
<ChangeRow key={i} change={c} />
|
||||
))}
|
||||
{(d.variants ?? []).map((v, i) => (
|
||||
<div key={i}>
|
||||
<div className="diffchange diffchange--head">
|
||||
{variantLabel(v)}
|
||||
{v.kind ? ` (${v.kind})` : ""}
|
||||
</div>
|
||||
{Object.entries(v.set ?? {}).map(([field, value]) => (
|
||||
<SetLine key={field} field={field} value={value} />
|
||||
))}
|
||||
{(v.changes ?? []).map((c, j) => (
|
||||
<ChangeRow key={j} change={c} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{(d.images ?? []).map((img, i) =>
|
||||
img.kind && img.kind !== "add" ? (
|
||||
<div key={i}>
|
||||
<div className="diffchange diffchange--head">
|
||||
image: {img.src} ({img.kind})
|
||||
</div>
|
||||
{(img.changes ?? []).map((c, j) => (
|
||||
<ChangeRow key={j} change={c} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="diffchange" key={i}>
|
||||
<span className="diffchange__glyph--add">+ </span>image: {img.src}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorTable({ records }: { records: DraftRecord[] }) {
|
||||
const rows = records.flatMap((r) => r.detail.errors ?? []);
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<table className="errortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Line</th>
|
||||
<th>Column</th>
|
||||
<th>Problem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((e, i) => (
|
||||
<tr key={i}>
|
||||
<td>{e.line}</td>
|
||||
<td>{e.column ?? "—"}</td>
|
||||
<td>{e.message}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImportPreview({ draftId }: { draftId: number }) {
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [loadFail, setLoadFail] = useState<"gone" | "expired" | "failed" | null>(null);
|
||||
const [filter, setFilter] = useState<RecordKind | null>(null);
|
||||
const [records, setRecords] = useState<DraftRecord[] | null>(null);
|
||||
const [recordsError, setRecordsError] = useState<"load" | "more" | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [moreBusy, setMoreBusy] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [stale, setStale] = useState(false);
|
||||
const [nothingNote, setNothingNote] = useState(false);
|
||||
const [confirmError, setConfirmError] = useState<string | null>(null);
|
||||
// Generation counter for the records list: bumped on every page-0 (re)load, so a
|
||||
// page-0 or show-more response that resolves after a tile/filter (or draft) switch
|
||||
// is recognized as stale and dropped instead of clobbering/appending to the new list.
|
||||
const recordsGen = useRef(0);
|
||||
|
||||
async function loadDraft() {
|
||||
setLoadFail(null);
|
||||
const resp = await getDraft(draftId);
|
||||
if (!resp.ok) {
|
||||
setLoadFail(resp.status === 404 ? "gone" : resp.status === 410 ? "expired" : "failed");
|
||||
return;
|
||||
}
|
||||
setDraft(resp.value);
|
||||
}
|
||||
useEffect(() => {
|
||||
// A new draft means a fresh consent gate — reset everything the old one set.
|
||||
setFilter(null);
|
||||
setStale(false);
|
||||
setConfirmError(null);
|
||||
setNothingNote(false);
|
||||
setRecords(null);
|
||||
setRecordsError(null);
|
||||
setHasMore(false);
|
||||
void loadDraft();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [draftId]);
|
||||
|
||||
function loadRecords(kind: RecordKind | null) {
|
||||
recordsGen.current += 1;
|
||||
const gen = recordsGen.current;
|
||||
setRecords(null);
|
||||
setRecordsError(null);
|
||||
setHasMore(false);
|
||||
void getDraftRecords(draftId, kind ?? undefined, PAGE, 0).then((resp) => {
|
||||
if (gen !== recordsGen.current) return;
|
||||
if (!resp.ok) {
|
||||
setRecordsError("load");
|
||||
return;
|
||||
}
|
||||
setRecords(resp.value);
|
||||
setHasMore(resp.value.length === PAGE);
|
||||
});
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!draft) return;
|
||||
loadRecords(filter);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [draft, draftId, filter]);
|
||||
|
||||
async function showMore() {
|
||||
if (!records) return;
|
||||
const gen = recordsGen.current;
|
||||
setMoreBusy(true);
|
||||
setRecordsError(null);
|
||||
const resp = await getDraftRecords(draftId, filter ?? undefined, PAGE, records.length);
|
||||
setMoreBusy(false);
|
||||
// Filter/draft switched while this page was in flight — drop the stale page.
|
||||
if (gen !== recordsGen.current) return;
|
||||
if (!resp.ok) {
|
||||
setRecordsError("more");
|
||||
return;
|
||||
}
|
||||
setRecords((prev) => [...(prev ?? []), ...resp.value]);
|
||||
setHasMore(resp.value.length === PAGE);
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
setConfirming(true);
|
||||
setConfirmError(null);
|
||||
const resp = await confirmDraft(draftId);
|
||||
if (resp.ok) {
|
||||
window.location.hash = `#/products/imports/runs/${resp.value.run_id}`;
|
||||
return;
|
||||
}
|
||||
setConfirming(false);
|
||||
if ((resp.status === 409 && resp.error.code === "preview_stale") || resp.status === 410) {
|
||||
setStale(true);
|
||||
} else if (resp.status === 409 && resp.error.code === "nothing_to_apply") {
|
||||
setNothingNote(true);
|
||||
} else {
|
||||
setConfirmError(resp.error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function onCancel() {
|
||||
setCancelling(true);
|
||||
// PUC-3a — cancel even on draft-gone (404) still navigates home.
|
||||
await cancelDraft(draftId);
|
||||
window.location.hash = "#/products";
|
||||
}
|
||||
|
||||
if (loadFail === "gone") {
|
||||
return (
|
||||
<Banner tone="attn" title="This preview is gone">
|
||||
<a href="#/products">Back to Products</a>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (loadFail === "expired") {
|
||||
return (
|
||||
<Banner tone="attn" title="This preview expired — upload the file again">
|
||||
<a href="#/products/import">Upload the file again</a>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (loadFail === "failed") {
|
||||
return (
|
||||
<Banner tone="attn" title="Couldn't load this preview">
|
||||
Something went wrong on our side.{" "}
|
||||
<button type="button" className="linklike" onClick={() => void loadDraft()}>
|
||||
Retry
|
||||
</button>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (!draft) {
|
||||
return (
|
||||
<p className="note" role="status">
|
||||
Loading…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary } = draft;
|
||||
const tiles: { kind: RecordKind; num: number; label: string }[] = [
|
||||
{ kind: "add", num: summary.adds, label: "to add" },
|
||||
{ kind: "update", num: summary.updates, label: "to update" },
|
||||
{ kind: "unchanged", num: summary.unchanged, label: "unchanged" },
|
||||
{ kind: "error", num: summary.errors, label: "errors" },
|
||||
];
|
||||
const toApply = summary.adds + summary.updates;
|
||||
|
||||
return (
|
||||
<div className="products">
|
||||
<p className="note">
|
||||
<a href="#/products">← Products</a>
|
||||
</p>
|
||||
<h1>Import preview — {draft.file_name}</h1>
|
||||
<p className="note">{dialectLabel(draft.dialect)}</p>
|
||||
{draft.unknown_columns.length > 0 && (
|
||||
<Banner tone="info" title="Columns not imported">
|
||||
{draft.unknown_columns.length > 8 ? (
|
||||
<details>
|
||||
<summary>{draft.unknown_columns.length} columns not imported</summary>
|
||||
{draft.unknown_columns.join(", ")}
|
||||
</details>
|
||||
) : (
|
||||
draft.unknown_columns.join(", ")
|
||||
)}
|
||||
</Banner>
|
||||
)}
|
||||
<div className="tiles">
|
||||
{tiles.map((t) => (
|
||||
<button
|
||||
key={t.kind}
|
||||
type="button"
|
||||
className={`tile tile--${t.kind}${filter === t.kind ? " tile--active" : ""}`}
|
||||
aria-pressed={filter === t.kind}
|
||||
onClick={() => setFilter(filter === t.kind ? null : t.kind)}
|
||||
>
|
||||
<span className="tile__num">{t.num.toLocaleString()}</span>
|
||||
<span className="tile__label">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{filter === "error" && records && <ErrorTable records={records} />}
|
||||
{recordsError === "load" ? (
|
||||
<p className="note note--attn" role="alert">
|
||||
Couldn't load these records.{" "}
|
||||
<button type="button" className="linklike" onClick={() => loadRecords(filter)}>
|
||||
Retry
|
||||
</button>
|
||||
</p>
|
||||
) : records === null ? (
|
||||
<p className="note" role="status">
|
||||
Loading…
|
||||
</p>
|
||||
) : records.length === 0 ? (
|
||||
<p className="note">Nothing to show here.</p>
|
||||
) : (
|
||||
<div className="difflist">
|
||||
{records.map((r, i) => (
|
||||
<details className="difflist__item" key={`${r.handle}-${i}`}>
|
||||
<summary>
|
||||
<span className="difflist__handle">{r.handle}</span> · {r.title} ·{" "}
|
||||
<KindChip kind={r.kind} /> · {r.variant_count}{" "}
|
||||
{r.variant_count === 1 ? "variant" : "variants"}
|
||||
</summary>
|
||||
<RecordDetail record={r} />
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasMore && (
|
||||
<p>
|
||||
<button type="button" className="btn-secondary" disabled={moreBusy} onClick={() => void showMore()}>
|
||||
{moreBusy ? "Loading…" : "Show more"}
|
||||
</button>
|
||||
{recordsError === "more" && (
|
||||
<span className="note note--attn" role="alert">
|
||||
Couldn't load more records.{" "}
|
||||
<button type="button" className="linklike" onClick={() => void showMore()}>
|
||||
Retry
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className="sticky-footer" aria-live="polite">
|
||||
{stale ? (
|
||||
<Banner tone="attn" title="Your catalog changed since this preview — upload the file again">
|
||||
<a href="#/products/import">Upload the file again</a>
|
||||
</Banner>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={toApply === 0 || confirming || cancelling}
|
||||
onClick={() => void onConfirm()}
|
||||
>
|
||||
{confirming ? "Importing…" : `Import ${toApply.toLocaleString()} products`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
disabled={confirming || cancelling}
|
||||
onClick={() => void onCancel()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{(toApply === 0 || nothingNote) && (
|
||||
<span className="note">Nothing to change — your catalog already matches this file</span>
|
||||
)}
|
||||
{confirmError && (
|
||||
<span className="note note--attn" role="alert">
|
||||
{confirmError}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +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<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function onPick(files: FileList | null) {
|
||||
const file = files?.[0];
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const resp = await uploadImport(file);
|
||||
setBusy(false);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
if (!resp.ok) {
|
||||
setError(resp.error.message);
|
||||
return;
|
||||
}
|
||||
window.location.hash = `#/products/imports/drafts/${resp.value.id}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="products products--narrow">
|
||||
<p className="note">
|
||||
<a href="#/products">← Products</a>
|
||||
</p>
|
||||
<h1>Import products</h1>
|
||||
{error && (
|
||||
<Banner tone="attn" title="That file can't be imported">
|
||||
{error}
|
||||
</Banner>
|
||||
)}
|
||||
<label className={`dropzone${busy ? " dropzone--busy" : ""}`}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
disabled={busy}
|
||||
onChange={(e) => void onPick(e.target.files)}
|
||||
/>
|
||||
<span className="dropzone__title" aria-live="polite">
|
||||
{busy ? "Validating…" : "Choose a CSV file"}
|
||||
</span>
|
||||
<span className="note">CSV, up to 5,000 rows</span>
|
||||
</label>
|
||||
<p className="note">
|
||||
Works with the canonical format.{" "}
|
||||
<a href="/api/products/sample.csv" download>
|
||||
Download sample CSV
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +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";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
applying: "Importing…",
|
||||
fetching_images: "Fetching images…",
|
||||
complete: "Complete",
|
||||
complete_with_problems: "Complete with problems",
|
||||
};
|
||||
|
||||
export default function ProductsPage() {
|
||||
const [summary, setSummary] = useState<ProductsSummary | null>(null);
|
||||
const [runs, setRuns] = useState<RunSummary[] | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
async function load() {
|
||||
setFailed(false);
|
||||
const [s, r] = await Promise.all([getProductsSummary(), listRuns()]);
|
||||
if (!s.ok || !r.ok) {
|
||||
setFailed(true);
|
||||
return;
|
||||
}
|
||||
setSummary(s.value);
|
||||
setRuns(r.value);
|
||||
}
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<Banner tone="attn" title="Couldn't load your products">
|
||||
Something went wrong on our side.{" "}
|
||||
<button type="button" className="linklike" onClick={() => void load()}>
|
||||
Retry
|
||||
</button>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (!summary || !runs) {
|
||||
return (
|
||||
<p className="note" role="status">
|
||||
Loading…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const empty = !isExportEnabled(summary.product_count);
|
||||
return (
|
||||
<div className="products">
|
||||
<header className="products__header">
|
||||
<h1>
|
||||
Products
|
||||
{!empty && <span className="products__count"> · {summary.product_count.toLocaleString()}</span>}
|
||||
</h1>
|
||||
<div className="products__actions">
|
||||
{empty ? (
|
||||
<div className="products__export">
|
||||
<button type="button" className="btn-secondary" disabled>
|
||||
Export
|
||||
</button>
|
||||
<span className="note">Export arrives when you have products</span>
|
||||
</div>
|
||||
) : (
|
||||
<details className="products__export menu">
|
||||
<summary className="btn-secondary" role="button">
|
||||
Export
|
||||
</summary>
|
||||
<ul className="menu__list">
|
||||
{EXPORT_STATUSES.map((s) => (
|
||||
<li key={s.value}>
|
||||
{/* A real download: the browser navigates to the streamed
|
||||
endpoint and saves the attachment (PUC-9). */}
|
||||
<a className="menu__item" href={exportUrl(s.value)} download>
|
||||
{s.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
<a className="btn-primary" href="#/products/import">
|
||||
Import products
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
{empty ? (
|
||||
<div className="empty">
|
||||
<p className="empty__copy">No products yet. Bulk import is how product data gets in.</p>
|
||||
<a className="btn-primary" href="#/products/import">
|
||||
Import products
|
||||
</a>
|
||||
<p className="note">
|
||||
<a href="/api/products/sample.csv" download>
|
||||
Download sample CSV
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="note">Your catalog is loaded. The browsable product list arrives with an upcoming release.</p>
|
||||
)}
|
||||
<section className="products__history">
|
||||
<h2>Import history</h2>
|
||||
{runs.length === 0 ? (
|
||||
<p className="note">No imports yet.</p>
|
||||
) : (
|
||||
<table className="datatable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th><th>File</th><th>Dialect</th><th>Added</th><th>Updated</th><th>Errors</th><th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((r) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className="datatable__rowlink"
|
||||
onClick={() => {
|
||||
window.location.hash = `#/products/imports/runs/${r.id}`;
|
||||
}}
|
||||
>
|
||||
<td>{new Date(r.created_at).toLocaleString()}</td>
|
||||
<td>
|
||||
{/* Anchor = the keyboard/SR path (§6.6); the row onClick stays as a
|
||||
mouse convenience. Both set the same hash, so the double fire on
|
||||
an anchor click is idempotent. */}
|
||||
<a href={`#/products/imports/runs/${r.id}`}>{r.file_name}</a>
|
||||
</td>
|
||||
<td>{dialectLabel(r.dialect)}</td>
|
||||
<td>{r.products_added}</td>
|
||||
<td>{r.products_updated}</td>
|
||||
<td>{r.rows_errored}</td>
|
||||
<td>{STATUS_LABELS[r.status] ?? r.status}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
// Run detail (SD-0002 §5.5) — report card for a completed or in-progress import run.
|
||||
// NO images section this slice (SLICE-7). PUC-4/5/8.
|
||||
import { useEffect, useState } from "react";
|
||||
import { dialectLabel, getRun, type RunDetail as RunDetailType } from "../../productsApi";
|
||||
import { Banner } from "../../ui/kit";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
applying: "Importing…",
|
||||
fetching_images: "Fetching images…",
|
||||
complete: "Complete",
|
||||
complete_with_problems: "Complete with problems",
|
||||
};
|
||||
|
||||
export default function RunDetail({ runId }: { runId: number }) {
|
||||
const [run, setRun] = useState<RunDetailType | null>(null);
|
||||
const [loadFail, setLoadFail] = useState<"gone" | "failed" | null>(null);
|
||||
|
||||
async function load() {
|
||||
setLoadFail(null);
|
||||
const resp = await getRun(runId);
|
||||
if (!resp.ok) {
|
||||
setLoadFail(resp.status === 404 ? "gone" : "failed");
|
||||
return;
|
||||
}
|
||||
setRun(resp.value);
|
||||
}
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [runId]);
|
||||
|
||||
if (loadFail === "gone") {
|
||||
return (
|
||||
<Banner tone="attn" title="No such import run">
|
||||
<a href="#/products">← Products</a>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (loadFail === "failed") {
|
||||
return (
|
||||
<Banner tone="attn" title="Couldn't load this import run">
|
||||
Something went wrong on our side.{" "}
|
||||
<button type="button" className="linklike" onClick={() => void load()}>
|
||||
Retry
|
||||
</button>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (!run) {
|
||||
return (
|
||||
<p className="note" role="status">
|
||||
Loading…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="products">
|
||||
<p className="note">
|
||||
<a href="#/products">← Products</a>
|
||||
</p>
|
||||
<h1>{run.file_name}</h1>
|
||||
<p className="note">
|
||||
Imported {new Date(run.created_at).toLocaleString()} by {run.by} · {dialectLabel(run.dialect)}
|
||||
</p>
|
||||
<p className="note">
|
||||
{run.products_added} added · {run.products_updated} updated · {run.rows_errored} rows in
|
||||
error
|
||||
</p>
|
||||
<p className="note">{STATUS_LABELS[run.status] ?? run.status}</p>
|
||||
{run.errors.length > 0 && (
|
||||
<table className="errortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Line</th>
|
||||
<th>Column</th>
|
||||
<th>Problem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{run.errors.map((e, i) => (
|
||||
<tr key={i}>
|
||||
<td>{e.line}</td>
|
||||
<td>{e.column ?? "—"}</td>
|
||||
<td>{e.message}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 <details> 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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// Six-cell one-time-code input: one real <input> (invisible, full-bleed) carries the value
|
||||
// and the OTC autofill semantics; the cells are presentation. Click anywhere to focus;
|
||||
// paste works because the input is real.
|
||||
import { useState } from "react";
|
||||
|
||||
const LENGTH = 6;
|
||||
|
||||
export default function CodeInput({
|
||||
value,
|
||||
onChange,
|
||||
error = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (code: string) => void;
|
||||
error?: boolean;
|
||||
}) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const digits = value.slice(0, LENGTH).split("");
|
||||
const active = Math.min(digits.length, LENGTH - 1);
|
||||
|
||||
return (
|
||||
<div className={`code${focused ? " code--focus" : ""}${error ? " code--error" : ""}`}>
|
||||
<input
|
||||
className="code__hidden"
|
||||
value={value}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
aria-label="One-time code"
|
||||
autoFocus
|
||||
maxLength={LENGTH}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
onChange={(ev) => onChange(ev.target.value.replace(/\D/g, "").slice(0, LENGTH))}
|
||||
/>
|
||||
{Array.from({ length: LENGTH }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
className={`code__cell${i === active && focused ? " code__cell--active" : ""}`}
|
||||
>
|
||||
{digits[i] ?? ""}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <div className={cls}>{children}</div>;
|
||||
}
|
||||
|
||||
export function TopBar({ left, right }: { left: ReactNode; right?: ReactNode }) {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar__side">{left}</div>
|
||||
<div className="topbar__side">{right}</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function Wordmark({ size = 26 }: { size?: number }) {
|
||||
return (
|
||||
<span className="wordmark">
|
||||
<img src="/brand/mark-tile.svg" width={size} height={size} alt="" />
|
||||
<span className="wordmark__name" style={{ fontSize: size * 0.86 }}>
|
||||
ecomm
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<span className="footer__id">
|
||||
<img src="/brand/mark-mono-gold.svg" width={16} height={16} alt="" />
|
||||
ecomm · a Wiggleverse line
|
||||
</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthCard({ children }: { children: ReactNode }) {
|
||||
return <div className="card">{children}</div>;
|
||||
}
|
||||
|
||||
export function Eyebrow({ children }: { children: ReactNode }) {
|
||||
return <p className="eyebrow">{children}</p>;
|
||||
}
|
||||
|
||||
export function Banner({
|
||||
tone = "attn",
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
tone?: "attn" | "info";
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={`banner banner--${tone}`} role={tone === "attn" ? "alert" : "status"}>
|
||||
<span className="banner__icon" aria-hidden="true">
|
||||
{tone === "attn" ? "◆" : "◇"}
|
||||
</span>
|
||||
<div>
|
||||
{title && <div className="banner__title">{title}</div>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrimaryButton({
|
||||
children,
|
||||
busy = false,
|
||||
disabled = false,
|
||||
type = "submit",
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
busy?: boolean;
|
||||
disabled?: boolean;
|
||||
type?: "submit" | "button";
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button className="btn-primary" type={type} disabled={disabled || busy} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
optional = false,
|
||||
error,
|
||||
helper,
|
||||
inputProps,
|
||||
}: {
|
||||
label: string;
|
||||
optional?: boolean;
|
||||
error?: string | null;
|
||||
helper?: string;
|
||||
inputProps: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor={id}>
|
||||
<span>{label}</span>
|
||||
{optional && <span className="field__optional">optional</span>}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
className={`field__input${error ? " field__input--error" : ""}`}
|
||||
aria-invalid={!!error}
|
||||
{...inputProps}
|
||||
/>
|
||||
{error && (
|
||||
<p className="note note--attn" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helper && !error && <p className="note">{helper}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountChip({ email, onSignOut }: { email: string; onSignOut: () => void }) {
|
||||
return (
|
||||
<div className="chip">
|
||||
<span className="chip__avatar" aria-hidden="true">
|
||||
{email[0]?.toUpperCase()}
|
||||
</span>
|
||||
<span className="chip__email">{email}</span>
|
||||
<span className="chip__divider" aria-hidden="true" />
|
||||
<button type="button" className="signout" onClick={onSignOut}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user