Compare commits
25 Commits
a8538d3ecc
..
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 385df8d728 | |||
| af72299a57 | |||
| 6f22c8b146 | |||
| 767011fbae | |||
| 8077f2e07b | |||
| 0a85c4fef8 | |||
| 6f213e1f02 | |||
| 155f9bd147 | |||
| 1c2a6af986 | |||
| 5a97b9dc59 | |||
| fce0b5eaed | |||
| b40e4d30b5 | |||
| 7652e92cbe | |||
| 627e257d4c | |||
| 011f4d5dc1 | |||
| 2606fbf826 | |||
| ad738adbc0 | |||
| a58f42cf86 | |||
| d18a84e135 | |||
| 87a56a66c4 | |||
| 4141285b89 | |||
| 188494272a | |||
| 26f84cb916 | |||
| b5dac8886f | |||
| 4dacc2dafd |
@@ -6,9 +6,12 @@ drafts/runs. Storefront-scoped throughout (INV-14); upsert is the only mutation
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from .errors import (
|
from .errors import (
|
||||||
DraftExpired,
|
DraftExpired,
|
||||||
DraftNotFound,
|
DraftNotFound,
|
||||||
|
EmptyCatalog,
|
||||||
FileRejected,
|
FileRejected,
|
||||||
NothingToApply,
|
NothingToApply,
|
||||||
PreviewStale,
|
PreviewStale,
|
||||||
@@ -19,6 +22,7 @@ from .models import MAX_DATA_ROWS, MAX_FILE_BYTES
|
|||||||
from .service import (
|
from .service import (
|
||||||
confirm_draft,
|
confirm_draft,
|
||||||
discard_draft,
|
discard_draft,
|
||||||
|
export_catalog,
|
||||||
get_draft,
|
get_draft,
|
||||||
get_draft_records,
|
get_draft_records,
|
||||||
get_run,
|
get_run,
|
||||||
@@ -27,10 +31,13 @@ from .service import (
|
|||||||
summary,
|
summary,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# DOC-3: the downloadable worked-example CSV the BFF serves at /api/products/sample.csv.
|
||||||
|
SAMPLE_CSV_PATH = Path(__file__).parent / "sample.csv"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ProductsError", "FileRejected", "DraftNotFound", "DraftExpired",
|
"ProductsError", "FileRejected", "DraftNotFound", "DraftExpired",
|
||||||
"PreviewStale", "NothingToApply", "RunNotFound",
|
"PreviewStale", "NothingToApply", "RunNotFound", "EmptyCatalog",
|
||||||
"MAX_DATA_ROWS", "MAX_FILE_BYTES",
|
"MAX_DATA_ROWS", "MAX_FILE_BYTES", "SAMPLE_CSV_PATH",
|
||||||
"import_validate", "get_draft", "get_draft_records", "discard_draft",
|
"import_validate", "get_draft", "get_draft_records", "discard_draft",
|
||||||
"confirm_draft", "list_runs", "get_run", "summary",
|
"confirm_draft", "list_runs", "get_run", "summary", "export_catalog",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -35,3 +35,7 @@ class NothingToApply(ProductsError):
|
|||||||
|
|
||||||
class RunNotFound(ProductsError):
|
class RunNotFound(ProductsError):
|
||||||
"""No such import run for this storefront."""
|
"""No such import run for this storefront."""
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyCatalog(ProductsError):
|
||||||
|
"""PUC-9: nothing to export (no products, or none matching the status filter)."""
|
||||||
|
|||||||
@@ -104,6 +104,23 @@ def load_catalog(conn: psycopg.Connection, storefront_id: int) -> dict[str, Cata
|
|||||||
return catalog
|
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:
|
def product_count(conn: psycopg.Connection, storefront_id: int) -> int:
|
||||||
return conn.execute(
|
return conn.execute(
|
||||||
"SELECT count(*) FROM product WHERE storefront_id = %s", (storefront_id,)
|
"SELECT count(*) FROM product WHERE storefront_id = %s", (storefront_id,)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Handle,Title,Description,Vendor,Type,Google Product Category,Tags,Status,Published,Option1 Name,Option1 Value,Option2 Name,Option2 Value,Variant SKU,Variant Price,Variant Inventory Qty,Image Src,Image Position,Image Alt Text
|
||||||
|
moon-mug,Moon Mug,"<p>A ceramic mug glazed in moonlight grey.</p>",Wiggle Goods,standalone,Home & Garden > Kitchen & Dining,"kitchen, mugs",active,TRUE,,,,,WG-MUG-001,18.00,40,https://images.example.com/moon-mug.jpg,1,Moon Mug on a desk
|
||||||
|
star-tee,Star Tee,"<p>Soft cotton tee with a hand-printed star.</p>",Wiggle Goods,standalone,Apparel & Accessories > Clothing,"apparel, tees",active,TRUE,Size,S,Color,Indigo,WG-TEE-S,24.00,12,https://images.example.com/star-tee.jpg,1,Star Tee flat lay
|
||||||
|
star-tee,,,,,,,,,,M,,Indigo,WG-TEE-M,24.00,18,,,
|
||||||
|
star-tee,,,,,,,,,,L,,Indigo,WG-TEE-L,26.00,9,,,
|
||||||
|
star-tee,,,,,,,,,,,,,,,,https://images.example.com/star-tee-back.jpg,2,Star Tee back print
|
||||||
|
@@ -0,0 +1,119 @@
|
|||||||
|
"""Canonical serializer — CatalogProduct snapshot → canonical CSV (SD-0002 §6.5.5).
|
||||||
|
|
||||||
|
The export half of "one codec, two directions": this writes exactly the columns
|
||||||
|
codec.py/validate.py parse, in a row grammar (§6.5.1) the validator regroups
|
||||||
|
identically — so re-importing an unmodified export diffs to nothing (INV-12).
|
||||||
|
DB-free, like diff.py: it consumes the same CatalogProduct snapshot the diff
|
||||||
|
engine reads (repo.load_catalog), and the service streams the result.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from collections.abc import Iterable, Iterator
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from .diff import CatalogProduct
|
||||||
|
from .models import (
|
||||||
|
IMAGE_COLUMNS,
|
||||||
|
OPTION_VALUE_COLUMNS,
|
||||||
|
PRODUCT_COLUMNS,
|
||||||
|
VARIANT_COLUMNS,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Canonical column order: Handle, then product, option-value, variant, image
|
||||||
|
# columns. A superset of everything the parser knows (models.KNOWN_COLUMNS minus
|
||||||
|
# the #15-reserved Component columns, which export never emits).
|
||||||
|
HEADER: list[str] = [
|
||||||
|
"Handle",
|
||||||
|
*PRODUCT_COLUMNS,
|
||||||
|
*OPTION_VALUE_COLUMNS,
|
||||||
|
*VARIANT_COLUMNS,
|
||||||
|
*IMAGE_COLUMNS,
|
||||||
|
]
|
||||||
|
|
||||||
|
# field name -> column name, inverting the model registry (the parser reads
|
||||||
|
# column->field; the serializer writes field->column).
|
||||||
|
_PRODUCT_FIELD_TO_COL = {field: col for col, field in PRODUCT_COLUMNS.items()}
|
||||||
|
_VARIANT_FIELD_TO_COL = {field: col for col, field in VARIANT_COLUMNS.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _cell(value: object) -> str:
|
||||||
|
"""Serialize one value to its canonical cell text (the parse inverse)."""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "TRUE" if value else "FALSE"
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
return str(value)
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return ", ".join(str(v) for v in value)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_to_csv(products: Iterable[CatalogProduct]) -> 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):
|
||||||
|
writer.writerow([row.get(col, "") for col in HEADER])
|
||||||
|
yield _drain(buf)
|
||||||
|
|
||||||
|
|
||||||
|
def _drain(buf: io.StringIO) -> str:
|
||||||
|
text = buf.getvalue()
|
||||||
|
buf.seek(0)
|
||||||
|
buf.truncate(0)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _product_rows(product: CatalogProduct) -> 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."""
|
||||||
|
base = {"Handle": product.handle, "Title": product.title}
|
||||||
|
for field, col in _PRODUCT_FIELD_TO_COL.items():
|
||||||
|
if field in product.fields:
|
||||||
|
base[col] = _cell(product.fields[field])
|
||||||
|
for slot, name in enumerate(product.option_names, start=1):
|
||||||
|
if name:
|
||||||
|
base[f"Option{slot} Name"] = name
|
||||||
|
|
||||||
|
count = max(len(product.variants), len(product.images))
|
||||||
|
rows: list[dict[str, str]] = []
|
||||||
|
for i in range(count):
|
||||||
|
# Row 0 carries the product-level fields; later rows carry only Handle.
|
||||||
|
row = dict(base) if i == 0 else {"Handle": product.handle}
|
||||||
|
if i < len(product.variants):
|
||||||
|
_write_variant(row, product, product.variants[i])
|
||||||
|
if i < len(product.images):
|
||||||
|
_write_image(row, product.images[i])
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _write_image(row: dict[str, str], image) -> None:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _write_variant(row: dict[str, str], product: CatalogProduct, variant) -> None:
|
||||||
|
"""Fill a row's option-value + variant columns for one variant."""
|
||||||
|
for slot, value in enumerate(variant.options, start=1):
|
||||||
|
# Only emit an option value when the product actually has that option
|
||||||
|
# (a no-option product's single variant carries all-NULL options).
|
||||||
|
if product.option_names[slot - 1] and value is not None:
|
||||||
|
row[f"Option{slot} Value"] = value
|
||||||
|
# position is a CatalogVariant attribute, not a fields{} entry — emit it
|
||||||
|
# explicitly. An empty Variant Position cell re-imports as "reset to file
|
||||||
|
# order", so a non-sequential stored position would round-trip to an update
|
||||||
|
# (INV-12). (Like _write_image, which emits its position attribute.)
|
||||||
|
row["Variant Position"] = str(variant.position)
|
||||||
|
for field, col in _VARIANT_FIELD_TO_COL.items():
|
||||||
|
if field in variant.fields:
|
||||||
|
row[col] = _cell(variant.fields[field])
|
||||||
@@ -7,14 +7,22 @@ writes exactly one row — the import_draft. TEL events per §9.1.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Iterator
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import psycopg
|
import psycopg
|
||||||
|
|
||||||
from app.platform import telemetry
|
from app.platform import telemetry
|
||||||
|
|
||||||
from . import codec, diff, repo, validate
|
from . import codec, diff, repo, serialize, validate
|
||||||
from .errors import DraftExpired, DraftNotFound, NothingToApply, PreviewStale, RunNotFound
|
from .errors import (
|
||||||
|
DraftExpired,
|
||||||
|
DraftNotFound,
|
||||||
|
EmptyCatalog,
|
||||||
|
NothingToApply,
|
||||||
|
PreviewStale,
|
||||||
|
RunNotFound,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def import_validate(conn: psycopg.Connection, storefront_id: int, account_id: int,
|
def import_validate(conn: psycopg.Connection, storefront_id: int, account_id: int,
|
||||||
@@ -218,6 +226,34 @@ def get_run(conn: psycopg.Connection, storefront_id: int, run_id: int) -> dict:
|
|||||||
return run
|
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:
|
def summary(conn: psycopg.Connection, storefront_id: int) -> dict:
|
||||||
"""The products dashboard counts (§6.4)."""
|
"""The products dashboard counts (§6.4)."""
|
||||||
return {
|
return {
|
||||||
|
|||||||
+200
-4
@@ -4,7 +4,8 @@ SLICE-1 mounted /healthz; SLICE-2 adds the /api/auth/* identity endpoints (§6.4
|
|||||||
translates HTTP <-> domain calls and owns no business logic (INV-6): every rule lives in
|
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 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
|
the configured mailer (INV-8) at startup. SLICE-3 adds POST /api/storefronts and feeds the
|
||||||
_storefront_for seam from the storefronts domain.
|
_storefront_for seam from the storefronts domain. SLICE-5 adds the /api/products/* import
|
||||||
|
spine (SD-0002 §6.4): each endpoint is a gate + one products-domain call + error mapping.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -15,12 +16,12 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import psycopg
|
import psycopg
|
||||||
from fastapi import Depends, FastAPI, Response
|
from fastapi import Depends, FastAPI, File, Query, Response, UploadFile
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.domains import accounts, storefronts
|
from app.domains import accounts, products, storefronts
|
||||||
from app.platform import config, db
|
from app.platform import config, db
|
||||||
from app.platform import mailer as mailer_mod
|
from app.platform import mailer as mailer_mod
|
||||||
from app.platform.deps import SESSION_COOKIE, get_conn, get_mailer, get_session
|
from app.platform.deps import SESSION_COOKIE, get_conn, get_mailer, get_session
|
||||||
@@ -61,6 +62,26 @@ def _storefront_for(conn: psycopg.Connection, account: accounts.Account) -> dict
|
|||||||
return {"id": sf.id, "name": sf.name} if sf else None
|
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:
|
def _ensure_app_logging() -> None:
|
||||||
"""Surface the app's own `ecomm.*` INFO logs on stderr (idempotent).
|
"""Surface the app's own `ecomm.*` INFO logs on stderr (idempotent).
|
||||||
|
|
||||||
@@ -208,6 +229,181 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None =
|
|||||||
)
|
)
|
||||||
return JSONResponse(status_code=201, content={"id": sf.id, "name": sf.name})
|
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
|
# 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
|
# 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.
|
# dist dir doesn't exist (Vite serves the frontend) and the mount is skipped.
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""§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
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""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]
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""Canonical serializer + INV-12 round-trip lock (SD-0002 §6.5.5, §6.8)."""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
from app.domains.products import serialize
|
||||||
|
from app.domains.products.diff import CatalogImage, CatalogProduct, CatalogVariant
|
||||||
|
|
||||||
|
|
||||||
|
def _product(**kw) -> CatalogProduct:
|
||||||
|
"""A minimal no-option catalog product (one all-NULL variant)."""
|
||||||
|
base = dict(
|
||||||
|
id=1, handle="moon-mug", title="Moon Mug",
|
||||||
|
option_names=(None, None, None),
|
||||||
|
fields={
|
||||||
|
"title": "Moon Mug", "description_html": None, "vendor": "Acme",
|
||||||
|
"product_type": "standalone", "google_product_category": None,
|
||||||
|
"tags": [], "status": "active", "published": True,
|
||||||
|
},
|
||||||
|
variants=[CatalogVariant(
|
||||||
|
id=1, options=(None, None, None), position=1,
|
||||||
|
fields={"sku": "WG-MUG", "barcode": None, "price": None, "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": None,
|
||||||
|
"variant_image": None})],
|
||||||
|
images=[],
|
||||||
|
)
|
||||||
|
base.update(kw)
|
||||||
|
return CatalogProduct(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(products) -> list[dict]:
|
||||||
|
text = "".join(serialize.catalog_to_csv(products))
|
||||||
|
return list(csv.DictReader(io.StringIO(text)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_is_full_canonical_set():
|
||||||
|
text = "".join(serialize.catalog_to_csv([_product()]))
|
||||||
|
header = next(csv.reader(io.StringIO(text)))
|
||||||
|
# Handle + Title first; every known column present exactly once.
|
||||||
|
assert header[0] == "Handle"
|
||||||
|
assert "Title" in header and "Variant SKU" in header and "Image Src" in header
|
||||||
|
assert len(header) == len(set(header))
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_no_option_product_one_row():
|
||||||
|
rows = _rows([_product()])
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["Handle"] == "moon-mug"
|
||||||
|
assert rows[0]["Title"] == "Moon Mug"
|
||||||
|
assert rows[0]["Variant SKU"] == "WG-MUG"
|
||||||
|
# A no-option product emits no option values.
|
||||||
|
assert rows[0]["Option1 Value"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def _star_tee() -> CatalogProduct:
|
||||||
|
"""A 3-variant, 2-image product (the sample.csv shape)."""
|
||||||
|
return CatalogProduct(
|
||||||
|
id=2, handle="star-tee", title="Star Tee",
|
||||||
|
option_names=("Size", "Color", None),
|
||||||
|
fields={"title": "Star Tee", "description_html": None, "vendor": "Acme",
|
||||||
|
"product_type": "standalone", "google_product_category": None,
|
||||||
|
"tags": ["apparel", "tees"], "status": "active", "published": True},
|
||||||
|
variants=[
|
||||||
|
CatalogVariant(id=10, options=("S", "Indigo", None), position=1,
|
||||||
|
fields={"sku": "WG-TEE-S", "variant_image": None}),
|
||||||
|
CatalogVariant(id=11, options=("M", "Indigo", None), position=2,
|
||||||
|
fields={"sku": "WG-TEE-M", "variant_image": None}),
|
||||||
|
CatalogVariant(id=12, options=("L", "Indigo", None), position=3,
|
||||||
|
fields={"sku": "WG-TEE-L", "variant_image": None}),
|
||||||
|
],
|
||||||
|
images=[
|
||||||
|
CatalogImage(id=1, source_url="https://x/a.jpg", position=1, alt_text="front"),
|
||||||
|
CatalogImage(id=2, source_url="https://x/b.jpg", position=2, alt_text="back"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_multivariant_with_images_interleaves():
|
||||||
|
rows = _rows([_star_tee()])
|
||||||
|
assert len(rows) == 3 # max(3 variants, 2 images)
|
||||||
|
# Product-level fields only on the first row.
|
||||||
|
assert rows[0]["Title"] == "Star Tee" and rows[1]["Title"] == ""
|
||||||
|
assert rows[0]["Tags"] == "apparel, tees" and rows[1]["Tags"] == ""
|
||||||
|
# Option names on row 0; option values on every variant row.
|
||||||
|
assert rows[0]["Option1 Name"] == "Size" and rows[1]["Option1 Name"] == ""
|
||||||
|
assert [r["Option1 Value"] for r in rows] == ["S", "M", "L"]
|
||||||
|
assert [r["Variant SKU"] for r in rows] == ["WG-TEE-S", "WG-TEE-M", "WG-TEE-L"]
|
||||||
|
# Two images on the first two rows; third row has no image.
|
||||||
|
assert [r["Image Src"] for r in rows] == ["https://x/a.jpg", "https://x/b.jpg", ""]
|
||||||
|
assert [r["Image Position"] for r in rows] == ["1", "2", ""]
|
||||||
|
|
||||||
|
|
||||||
|
def test_more_images_than_variants_emits_image_only_rows():
|
||||||
|
p = _product(images=[
|
||||||
|
CatalogImage(id=1, source_url="https://x/a.jpg", position=1, alt_text=None),
|
||||||
|
CatalogImage(id=2, source_url="https://x/b.jpg", position=2, alt_text=None),
|
||||||
|
CatalogImage(id=3, source_url="https://x/c.jpg", position=3, alt_text=None),
|
||||||
|
])
|
||||||
|
rows = _rows([p])
|
||||||
|
assert len(rows) == 3 # 1 variant, 3 images
|
||||||
|
assert rows[0]["Variant SKU"] == "WG-MUG"
|
||||||
|
assert rows[1]["Variant SKU"] == "" and rows[1]["Handle"] == "moon-mug"
|
||||||
|
assert [r["Image Src"] for r in rows] == ["https://x/a.jpg", "https://x/b.jpg", "https://x/c.jpg"]
|
||||||
|
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
from app.domains.products import codec, diff, validate
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_catalog(seed: int) -> dict:
|
||||||
|
"""A deterministic random text-field catalog, in load_catalog()'s shape."""
|
||||||
|
rng = random.Random(seed)
|
||||||
|
words = ["moon", "star", "river", "cloud", "ember", "fern", "slate", "wave"]
|
||||||
|
catalog: dict[str, CatalogProduct] = {}
|
||||||
|
n_products = rng.randint(1, 6)
|
||||||
|
for pid in range(1, n_products + 1):
|
||||||
|
handle = "-".join(rng.sample(words, rng.randint(1, 3))) + f"-{pid}"
|
||||||
|
if handle in catalog:
|
||||||
|
continue
|
||||||
|
has_opts = rng.random() < 0.6
|
||||||
|
option_names = ("Size", "Color", None) if has_opts else (None, None, None)
|
||||||
|
tags = rng.sample(["apparel", "kitchen", "sale", "new"], rng.randint(0, 3))
|
||||||
|
fields = {
|
||||||
|
"title": f"{handle.title()} Thing",
|
||||||
|
"description_html": rng.choice([None, "<p>hi</p>"]),
|
||||||
|
"vendor": rng.choice([None, "Acme", "Wiggle Goods"]),
|
||||||
|
"product_type": "standalone",
|
||||||
|
"google_product_category": rng.choice([None, "Home & Garden"]),
|
||||||
|
"tags": tags,
|
||||||
|
"status": rng.choice(["draft", "active", "archived"]),
|
||||||
|
"published": rng.choice([True, False]),
|
||||||
|
}
|
||||||
|
variants = []
|
||||||
|
if has_opts:
|
||||||
|
sizes = rng.sample(["S", "M", "L", "XL"], rng.randint(1, 4))
|
||||||
|
for vi, size in enumerate(sizes, start=1):
|
||||||
|
# Non-sequential positions (×10) so a serializer that drops the
|
||||||
|
# stored position and lets re-import default to file order is
|
||||||
|
# caught — a merchant can import explicit positions like 10/20.
|
||||||
|
variants.append(CatalogVariant(
|
||||||
|
id=pid * 100 + vi, options=(size, "Indigo", None), position=vi * 10,
|
||||||
|
fields={"sku": f"SKU-{pid}-{vi}", "variant_image": None}))
|
||||||
|
else:
|
||||||
|
variants.append(CatalogVariant(
|
||||||
|
id=pid * 100 + 1, options=(None, None, None), position=rng.choice([1, 5, 9]),
|
||||||
|
fields={"sku": f"SKU-{pid}", "variant_image": None}))
|
||||||
|
images = []
|
||||||
|
for ii in range(rng.randint(0, 3)):
|
||||||
|
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}"])))
|
||||||
|
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:
|
||||||
|
"""export → bytes → import pipeline → diff against the same catalog."""
|
||||||
|
text = "".join(serialize.catalog_to_csv(catalog.values()))
|
||||||
|
parsed = codec.parse_csv(text.encode("utf-8"))
|
||||||
|
products = validate.build_products(parsed)
|
||||||
|
return diff.compute_diff(catalog, products)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inv12_roundtrip_is_noop_over_generated_catalogs():
|
||||||
|
for seed in range(200):
|
||||||
|
catalog = _gen_catalog(seed)
|
||||||
|
result = _roundtrip_diff(catalog)
|
||||||
|
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}"
|
||||||
|
assert result.summary["unchanged"] == len(catalog), f"seed {seed}"
|
||||||
|
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
|
def test_decimal_and_int_variant_fields_roundtrip():
|
||||||
|
catalog = {
|
||||||
|
"priced": CatalogProduct(
|
||||||
|
id=1, handle="priced", title="Priced",
|
||||||
|
option_names=(None, None, None),
|
||||||
|
fields={"title": "Priced", "description_html": None, "vendor": None,
|
||||||
|
"product_type": "standalone", "google_product_category": None,
|
||||||
|
"tags": [], "status": "active", "published": True},
|
||||||
|
variants=[CatalogVariant(
|
||||||
|
id=1, options=(None, None, None), position=1,
|
||||||
|
fields={"sku": "P1", "price": Decimal("18.00"),
|
||||||
|
"cost": Decimal("9.5"), "weight": Decimal("0.250"),
|
||||||
|
"inventory_qty": 40, "variant_image": None})],
|
||||||
|
images=[],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
result = _roundtrip_diff(catalog)
|
||||||
|
assert result.summary["unchanged"] == 1, result.records
|
||||||
|
assert result.summary["updates"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonsequential_variant_position_roundtrips():
|
||||||
|
"""A stored variant position that isn't its file order must survive export —
|
||||||
|
else re-import reads the empty cell as 'reset to file order' and the round-trip
|
||||||
|
spuriously updates (INV-12 regression: position is a CatalogVariant attribute,
|
||||||
|
not a fields{} entry, so the serializer must emit it explicitly)."""
|
||||||
|
catalog = {
|
||||||
|
"tee": CatalogProduct(
|
||||||
|
id=1, handle="tee", title="Tee", option_names=("Size", None, None),
|
||||||
|
fields={"title": "Tee", "description_html": None, "vendor": None,
|
||||||
|
"product_type": "standalone", "google_product_category": None,
|
||||||
|
"tags": [], "status": "active", "published": True},
|
||||||
|
variants=[
|
||||||
|
CatalogVariant(id=1, options=("S", None, None), position=10,
|
||||||
|
fields={"sku": "T-S", "variant_image": None}),
|
||||||
|
CatalogVariant(id=2, options=("M", None, None), position=20,
|
||||||
|
fields={"sku": "T-M", "variant_image": None}),
|
||||||
|
],
|
||||||
|
images=[],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
result = _roundtrip_diff(catalog)
|
||||||
|
assert result.summary["unchanged"] == 1, result.records
|
||||||
|
assert result.summary["updates"] == 0
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Operating ecomm
|
||||||
|
|
||||||
|
The framework-repo operator guide (SD-0002 DOC-1), started at SLICE-5. It covers
|
||||||
|
the app's operational surface — telemetry, runbooks, alert gestures, the E2E
|
||||||
|
gate. Per-deployment mechanics (deploy, secrets, VM access) live in the
|
||||||
|
deployment's flotilla docs and `deployment.toml`; environment bring-up is in
|
||||||
|
[`BOOTSTRAP.md`](./BOOTSTRAP.md).
|
||||||
|
|
||||||
|
## Products import/export ops (SD-0002, SLICE-5)
|
||||||
|
|
||||||
|
The §6.4 surface: a merchant uploads a catalog CSV (`POST
|
||||||
|
/api/products/imports`), the app validates it and stores an **import draft**
|
||||||
|
with a full preview (adds / updates / unchanged / errors), the merchant
|
||||||
|
confirms or cancels at the preview gate, and a confirm applies the previewed
|
||||||
|
diff as one **import run** recorded in the history (`/api/products/imports/runs`).
|
||||||
|
|
||||||
|
Caps and behavior to know (all enforced in code, not config):
|
||||||
|
|
||||||
|
- **Caps (INV-18):** ≤ 5,000 data rows and ≤ 10 MB per file —
|
||||||
|
`MAX_DATA_ROWS` / `MAX_FILE_BYTES` in `backend/app/domains/products/models.py`,
|
||||||
|
enforced in `backend/app/domains/products/codec.py` (file-level rejection)
|
||||||
|
plus a 413 `file_too_large` guard in the BFF (`backend/app/main.py`).
|
||||||
|
- **Draft expiry:** ~1 hour (`expires_at = now() + interval '1 hour'`). Cleanup
|
||||||
|
is a lazy sweep — expired drafts are deleted on the next upload and on any
|
||||||
|
access to an expired draft; there is no background job to babysit.
|
||||||
|
- **Upsert-only (INV-10):** an import adds and updates, never deletes. Catalog
|
||||||
|
products/variants/images absent from the file are untouched.
|
||||||
|
- **One-transaction apply (INV-11):** a confirm applies the whole previewed
|
||||||
|
diff in a single DB transaction — it lands completely or not at all.
|
||||||
|
|
||||||
|
### Telemetry
|
||||||
|
|
||||||
|
Structured JSON events on the `ecomm.telemetry` logger
|
||||||
|
(`backend/app/platform/telemetry.py`), one JSON object per line, emitted from
|
||||||
|
`backend/app/domains/products/service.py`. The app's `ecomm.*` log handler
|
||||||
|
writes to the process's stderr, which journald captures on the VM (and Cloud
|
||||||
|
Logging where the agent ships it). Events carry counts and durations only —
|
||||||
|
never file names, URLs, catalog content, or secret bytes.
|
||||||
|
|
||||||
|
| Event | Trigger | Payload fields |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| TEL-1 `import_draft_created` | validation completes, draft stored | `storefront_id, dialect, row_count, adds, updates, unchanged, errors, unknown_columns_count, duration_ms` |
|
||||||
|
| TEL-2 `import_run_completed` | apply transaction commits | `run_id, storefront_id, added, updated, errored, duration_ms` |
|
||||||
|
| TEL-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` |
|
||||||
|
|
||||||
|
### Export (PUC-9, SLICE-6)
|
||||||
|
|
||||||
|
`GET /api/products/export?status=all|active|draft|archived` streams the
|
||||||
|
storefront's catalog as a canonical-format CSV (one codec, two directions — the
|
||||||
|
same format the importer parses). It is **read-only** (no draft, no run) and
|
||||||
|
storefront-scoped (INV-14). An empty catalog — no products, or none matching the
|
||||||
|
status filter — returns `409 empty_catalog`; the Products page disables the
|
||||||
|
Export action with a note in that case. The round-trip is lossless (INV-12):
|
||||||
|
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.
|
||||||
|
|
||||||
|
### RB-2 — import apply failed
|
||||||
|
|
||||||
|
Triggered by ALR-2 (any TEL-6 event). The apply raised mid-transaction and
|
||||||
|
rolled back.
|
||||||
|
|
||||||
|
1. **Locate the failure.** On the VM, filter the journal for the event and note
|
||||||
|
the `draft_id`, `storefront_id`, and `error_class`:
|
||||||
|
|
||||||
|
```
|
||||||
|
journalctl -u ecomm.service | grep import_apply_failed
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Confirm the rollback held (INV-11).** The apply is one transaction, so a
|
||||||
|
failure leaves the catalog exactly as it was: the storefront's runs history
|
||||||
|
(`GET /api/products/imports/runs`) shows **no new run**, and the products
|
||||||
|
summary (`GET /api/products/summary`) shows an unchanged `product_count`.
|
||||||
|
3. **The merchant's draft is intact.** A failed apply does not consume the
|
||||||
|
draft — the merchant can retry confirm, or re-upload if the draft has since
|
||||||
|
expired (~1 h). Advise accordingly.
|
||||||
|
4. **File a bug** on `wiggleverse/wiggleverse-ecomm` with the `error_class`
|
||||||
|
and the surrounding log context (the traceback is in the app log next to
|
||||||
|
the event).
|
||||||
|
|
||||||
|
### ALR-2 — the log-based alert (one gesture per environment)
|
||||||
|
|
||||||
|
Run once per environment, at this slice's PPE deploy. This is an ad hoc op on
|
||||||
|
the existing GCP project (`wiggleverse-ecomm`), not a provisioning gesture.
|
||||||
|
|
||||||
|
```
|
||||||
|
# Select the deployment's gcloud config for this one process (handbook §8.4).
|
||||||
|
export CLOUDSDK_ACTIVE_CONFIG_NAME=wiggleverse-ecomm
|
||||||
|
|
||||||
|
# Log-based metric counting import-apply failures (TEL-6).
|
||||||
|
gcloud logging metrics create ecomm_import_apply_failed \
|
||||||
|
--description="ecomm TEL-6 import_apply_failed events (SD-0002 ALR-2)" \
|
||||||
|
--log-filter='resource.type="gce_instance" AND jsonPayload.message:"import_apply_failed" OR textPayload:"import_apply_failed"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Then attach an alert policy to the metric — **operator email channel, threshold
|
||||||
|
any event > 0 in 5 minutes, severity notify-only** (pre-v1: no paging). The
|
||||||
|
policy is created in the Cloud Console or with
|
||||||
|
`gcloud alpha monitoring policies create`; the exact command depends on the
|
||||||
|
notification-channel id, so list channels first:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Find the operator email channel's id for the policy.
|
||||||
|
gcloud beta monitoring channels list
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E browser suite
|
||||||
|
|
||||||
|
- Lives at `e2e/` — Playwright, Chromium, six scenarios (SLICE-5:
|
||||||
|
preview/confirm happy path, actionable errors, file rejection, cancel;
|
||||||
|
SLICE-6: `e2e_export_download`, `e2e_roundtrip_noop`).
|
||||||
|
- Run with `bash scripts/e2e.sh`. The harness boots a **fresh `ecomm_e2e`
|
||||||
|
database** against the local compose Postgres and serves the built SPA from
|
||||||
|
the backend on **:8765** (the deployed topology), so it needs the dev
|
||||||
|
Postgres up (`scripts/dev.sh`).
|
||||||
|
- **Not in `scripts/check.sh` / CI yet** — the Gitea runner has no browsers
|
||||||
|
(the §10.6 machinery gap). Run it locally before merge, and against PPE per
|
||||||
|
the §9 pipeline (the PPE browser run is still manual this slice).
|
||||||
|
|
||||||
|
## Cross-references
|
||||||
|
|
||||||
|
- SLO and alert definitions: SD-0002 §9–§10 (content repo,
|
||||||
|
`wiggleverse-ecomm-content/specs/SD-0002-products-bulk-csv-import-export.md`).
|
||||||
|
- Import/diff engine internals: [`products-domain.md`](./products-domain.md).
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# products domain — developer notes
|
||||||
|
|
||||||
|
DOC-4 (SD-0002 §11): the import/export spine as built in SLICE-5, extended by
|
||||||
|
SLICE-6–8. Operator-facing material is in [`OPERATIONS.md`](./OPERATIONS.md);
|
||||||
|
the spec is SD-0002 in the content repo.
|
||||||
|
|
||||||
|
## Layout and pipeline
|
||||||
|
|
||||||
|
`backend/app/domains/products/` is layered like the rest of the app
|
||||||
|
(main → domains → platform, enforced by import-linter):
|
||||||
|
|
||||||
|
- `models.py` — canonical row model + the column registry.
|
||||||
|
- `codec.py` — bytes → `ParsedFile`; file-level gates only.
|
||||||
|
- `validate.py` — rows → `CanonicalProduct` blocks + per-row errors.
|
||||||
|
- `diff.py` — catalog × canonical products → apply plan + preview records.
|
||||||
|
- `serialize.py` — the export half: `CatalogProduct` snapshot → canonical CSV
|
||||||
|
(the inverse of `codec`/`validate`). DB-free, like `diff.py`.
|
||||||
|
- `repo.py` — SQL only: catalog snapshot, draft/run CRUD, apply primitives.
|
||||||
|
Never commits or rolls back.
|
||||||
|
- `service.py` — use-case orchestration; owns every transaction boundary and
|
||||||
|
emits the TEL events via `backend/app/platform/telemetry.py`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph validate_path [import_validate]
|
||||||
|
A[parse_csv] --> B[build_products] --> D[compute_diff] --> E[(import_draft)]
|
||||||
|
C[load_catalog] --> D
|
||||||
|
end
|
||||||
|
subgraph confirm_path [confirm_draft]
|
||||||
|
E --> F[re-derive: parse → build → diff] --> G{fingerprint match?}
|
||||||
|
G -- yes --> H[one-transaction apply<br/>run + products + delete draft]
|
||||||
|
G -- no --> I[PreviewStale<br/>draft kept]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm re-derives everything from the draft's stored `file_bytes` against the
|
||||||
|
live catalog, then checks the fingerprint — so what lands is exactly what the
|
||||||
|
preview showed, or the confirm refuses (`preview_stale`). A confirm with no
|
||||||
|
adds and no updates refuses with `nothing_to_apply`.
|
||||||
|
|
||||||
|
## Canonical model and blank-vs-absent
|
||||||
|
|
||||||
|
`models.py` is the one model every dialect maps to (INV-17). `KNOWN_COLUMNS`
|
||||||
|
is the registry header detection, unknown-column warnings, and validation all
|
||||||
|
read; `CLEAR_DEFAULTS` holds the reset values for clearable fields
|
||||||
|
(`status`, `published`, `product_type`, `tags`).
|
||||||
|
|
||||||
|
The §6.5.1 cell semantics, as implemented:
|
||||||
|
|
||||||
|
- **Absent column** → the field never enters `fields{}` → untouched by diff
|
||||||
|
and apply (never a change).
|
||||||
|
- **Present-but-empty cell** → `fields[name] = None` (an explicit clear) →
|
||||||
|
resolved at diff time to its `CLEAR_DEFAULTS` entry, or NULL where none
|
||||||
|
exists.
|
||||||
|
- **The position exception:** a cleared `Variant Position` has no
|
||||||
|
`CLEAR_DEFAULTS` entry — `diff.resolved_variant_fields` resolves it to the
|
||||||
|
variant's 1-based file order within its product, never to NULL.
|
||||||
|
|
||||||
|
Option names live both in `CanonicalProduct.option_names` (the values) and in
|
||||||
|
`fields{}` (the file-presence marker the diff needs for the absent-vs-clear
|
||||||
|
distinction).
|
||||||
|
|
||||||
|
## Error granularity
|
||||||
|
|
||||||
|
`validate.py` never raises on a row problem: every violation is recorded as a
|
||||||
|
merchant-language `RowError` and **poisons its whole product block** — the
|
||||||
|
product previews as `kind="error"` and is excluded from apply, while parsing
|
||||||
|
continues so one pass yields a complete accounting (BUC-1a). On apply, error
|
||||||
|
rows are recorded per line in `import_run_error`; `rows_errored` on the run is
|
||||||
|
the **error-row count**, while the preview's errors tile counts error
|
||||||
|
**products** — the two numbers legitimately differ.
|
||||||
|
|
||||||
|
File-level problems (`not_csv`, `missing_required_column`, `too_many_rows`,
|
||||||
|
`file_too_large`) raise `FileRejected` in `codec.py` instead: no draft is
|
||||||
|
created. The BFF adds an early 413 for oversized uploads (`main.py`).
|
||||||
|
|
||||||
|
## INV-11 mechanics
|
||||||
|
|
||||||
|
`compute_diff` makes **one walk** that produces two views of the same
|
||||||
|
computation: the typed apply `plan` (resolved natives — `Decimal`, `bool`,
|
||||||
|
lists) that `confirm_draft` executes, and the JSON-safe preview `records`
|
||||||
|
stored as draft JSONB and served verbatim to the SPA. Because both derive from
|
||||||
|
the same walk they cannot diverge. The `fingerprint` is
|
||||||
|
`sha256(json.dumps(records, sort_keys=True, separators=(",", ":")))`; a
|
||||||
|
mismatch at confirm means the catalog drifted since preview → `PreviewStale`
|
||||||
|
(409 `preview_stale`, draft kept for re-validation). The whole apply — run row,
|
||||||
|
product/variant/image writes, error rows, draft delete — is one transaction;
|
||||||
|
any exception rolls it back and emits TEL-6.
|
||||||
|
|
||||||
|
## Export & the round-trip (SLICE-6)
|
||||||
|
|
||||||
|
`serialize.py` is the export half of "one codec, two directions": it turns the
|
||||||
|
`CatalogProduct` snapshot (the same one `repo.load_catalog` builds for the diff
|
||||||
|
engine) back into canonical CSV, writing exactly the columns `codec.py` /
|
||||||
|
`validate.py` parse, in the §6.5.1 row grammar — `HEADER` is the full canonical
|
||||||
|
column set; product fields + option names sit on the first row; one variant per
|
||||||
|
row; images interleave; a product with more images than variants emits
|
||||||
|
image-only rows.
|
||||||
|
|
||||||
|
`repo.export_catalog` returns the status-filtered snapshot list (sorted by
|
||||||
|
handle, deterministic); the `service.export_catalog` generator streams
|
||||||
|
`serialize.catalog_to_csv` over it and emits TEL-3 (`catalog_exported`) once the
|
||||||
|
stream is exhausted. The BFF wraps it in a `StreamingResponse`; an empty
|
||||||
|
(filtered) catalog raises `EmptyCatalog` **eagerly** → `409 empty_catalog`
|
||||||
|
before any bytes stream.
|
||||||
|
|
||||||
|
**INV-12** (`diff(catalog, import(export(catalog))) = ∅`) is locked two ways: a
|
||||||
|
property test (`test_products_serialize.py`) runs the real
|
||||||
|
export→parse→diff loop over 200 generated **text-field** catalogs and asserts
|
||||||
|
every product is `unchanged`; the `e2e_roundtrip_noop` browser scenario does the
|
||||||
|
same through the UI (export download → re-upload → all-unchanged preview, import
|
||||||
|
disabled). Numeric/`Decimal` fields — the property test's deliberate blind spot
|
||||||
|
(string-form vs value-identity) — get explicit round-trip unit tests.
|
||||||
|
|
||||||
|
## Named seams (what later slices replace)
|
||||||
|
|
||||||
|
- **`import_draft.file_bytes` → objectstore key (SLICE-7).** The upload
|
||||||
|
currently lives as BYTEA on the draft row (`0002_products.sql`); SLICE-7
|
||||||
|
moves the bytes to object storage and stores a key.
|
||||||
|
- **`codec.detect_dialect` → Shopify (SLICE-8).** Today it always returns
|
||||||
|
`"canonical"`; SLICE-8 recognizes Shopify's exact header set here (INV-17).
|
||||||
|
- **Run-status complete shortcut → `fetching_images` (SLICE-7).**
|
||||||
|
`confirm_draft` inserts the run with `status="complete"` directly; SLICE-7
|
||||||
|
inserts it as `fetching_images` and hands off to the image-fetch task.
|
||||||
|
Relatedly, image rows are created with the schema default
|
||||||
|
`status='pending'` **today and stay pending** — placeholder behavior until
|
||||||
|
SLICE-7's fetch phase (the run-detail payload already carries the stable
|
||||||
|
`image_progress` / `image_outcomes` shape, zeroed).
|
||||||
|
|
||||||
|
## Test map
|
||||||
|
|
||||||
|
| File | Covers |
|
||||||
|
| --- | --- |
|
||||||
|
| `backend/tests/test_products_codec.py` | file-level gates: parse, caps (INV-18), required columns, dialect |
|
||||||
|
| `backend/tests/test_products_validate.py` | every §6.5.1 row-error rule, one fixture each |
|
||||||
|
| `backend/tests/test_products_diff.py` | classification, blank-vs-absent, option matching, fingerprint |
|
||||||
|
| `backend/tests/test_products_serialize.py` | serializer grammar + INV-12 property test (round-trip no-op over generated catalogs) + decimal round-trip |
|
||||||
|
| `backend/tests/test_products_export.py` | status-filtered snapshot, streamed export, EmptyCatalog, TEL-3 |
|
||||||
|
| `backend/tests/test_products_service.py` | draft lifecycle: validate/preview/discard, expiry, TEL-1 |
|
||||||
|
| `backend/tests/test_products_invariants.py` | INV-10 (never deletes), INV-14 (storefront isolation), apply transactionality, TEL-6 |
|
||||||
|
| `backend/tests/test_products_endpoints.py` | §6.4 API scenarios + auth/storefront gates |
|
||||||
|
| `e2e/tests/import-preview-confirm.spec.ts` | happy path: upload → preview → confirm → history |
|
||||||
|
| `e2e/tests/import-errors.spec.ts` | actionable row errors at preview and on the run report |
|
||||||
|
| `e2e/tests/import-file-rejected.spec.ts` | file-level rejection, picker stays live, no trace |
|
||||||
|
| `e2e/tests/import-cancel.spec.ts` | cancel at preview leaves no trace |
|
||||||
|
| `e2e/tests/export-download.spec.ts` | export downloads canonical CSV, status filter respected |
|
||||||
|
| `e2e/tests/roundtrip-noop.spec.ts` | export → re-import → all-unchanged, import disabled (PUC-10) |
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
.backend.log
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Handle,Title,Vendor,Tags,Status,Published,Option1 Name,Option1 Value,Variant SKU,Variant Price,Variant Inventory Qty
|
||||||
|
moon-mug,Moon Mug,Wiggle Goods,"kitchen, mugs",active,TRUE,,,WG-MUG-001,18.00,40
|
||||||
|
star-tee,Star Tee,Wiggle Goods,apparel,active,TRUE,Size,S,WG-TEE-S,24.00,12
|
||||||
|
star-tee,,,,,,,M,WG-TEE-M,24.00,18
|
||||||
|
@@ -0,0 +1,2 @@
|
|||||||
|
Handle,Vendor
|
||||||
|
mug,Acme
|
||||||
|
@@ -0,0 +1,4 @@
|
|||||||
|
Handle,Title,Variant Price
|
||||||
|
good-mug,Good Mug,10.00
|
||||||
|
bad-tee,Bad Tee,not-a-price
|
||||||
|
also-good,Also Good,5.00
|
||||||
|
+110
@@ -0,0 +1,110 @@
|
|||||||
|
// 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
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"name": "wiggleverse-ecomm-e2e",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "wiggleverse-ecomm-e2e",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.48.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "wiggleverse-ecomm-e2e",
|
||||||
|
"private": true,
|
||||||
|
"scripts": { "test": "playwright test" },
|
||||||
|
"devDependencies": { "@playwright/test": "^1.48.0" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests",
|
||||||
|
timeout: 60_000,
|
||||||
|
retries: 0,
|
||||||
|
// One shared backend + log file; parallel sign-ins would interleave codes.
|
||||||
|
workers: 1,
|
||||||
|
use: { baseURL: "http://localhost:8765" },
|
||||||
|
webServer: {
|
||||||
|
command: "bash ./serve.sh",
|
||||||
|
url: "http://localhost:8765/healthz",
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# E2E server: fresh ecomm_e2e database, LogMailer (codes land in .backend.log),
|
||||||
|
# backend on :8765 serving the built SPA (the deployed topology, SD-0001 §6.2).
|
||||||
|
set -euo pipefail
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
if [ ! -f "$repo_root/frontend/dist/index.html" ]; then
|
||||||
|
( cd "$repo_root/frontend" && npm run build )
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$repo_root/.venv/bin/python" - <<'PY'
|
||||||
|
import psycopg
|
||||||
|
admin = psycopg.connect("postgresql://ecomm:ecomm@localhost:5432/postgres", autocommit=True)
|
||||||
|
admin.execute("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='ecomm_e2e' AND pid <> pg_backend_pid()")
|
||||||
|
admin.execute("DROP DATABASE IF EXISTS ecomm_e2e")
|
||||||
|
admin.execute("CREATE DATABASE ecomm_e2e")
|
||||||
|
PY
|
||||||
|
|
||||||
|
export ECOMM_DATABASE_URL="postgresql://ecomm:ecomm@localhost:5432/ecomm_e2e"
|
||||||
|
export ECOMM_MAILER=log
|
||||||
|
cd "$repo_root/backend"
|
||||||
|
exec "$repo_root/.venv/bin/python" -m uvicorn app.main:app --port 8765 \
|
||||||
|
> "$repo_root/e2e/.backend.log" 2>&1
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// 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");
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// DoD scenario e2e_import_cancel_no_trace (SD-0002 §6.8): cancelling at the
|
||||||
|
// preview gate (PUC-3a) leaves no trace — no products, no run in the history.
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers";
|
||||||
|
|
||||||
|
test("e2e_import_cancel_no_trace", async ({ page }) => {
|
||||||
|
await signUpWithStorefront(page);
|
||||||
|
await gotoProducts(page);
|
||||||
|
await uploadFixture(page, "good.csv");
|
||||||
|
|
||||||
|
// The preview gate is up.
|
||||||
|
await expect(page.getByRole("heading", { name: "Import preview — good.csv" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "2 to add" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Cancel" }).click();
|
||||||
|
|
||||||
|
// Lands back on Products — still the empty state, no run recorded.
|
||||||
|
await expect(page.getByRole("heading", { level: 1, name: "Products" })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText("No products yet. Bulk import is how product data gets in."),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("No imports yet.")).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// DoD scenario e2e_import_errors_actionable (SD-0002 §6.8): row-level errors are
|
||||||
|
// actionable — line, column, message — at the preview gate AND on the run report
|
||||||
|
// card (PUC-5), while the good rows still import.
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers";
|
||||||
|
|
||||||
|
test("e2e_import_errors_actionable", async ({ page }) => {
|
||||||
|
await signUpWithStorefront(page);
|
||||||
|
await gotoProducts(page);
|
||||||
|
await uploadFixture(page, "mixed-errors.csv");
|
||||||
|
|
||||||
|
// Preview tiles: two good products, one errored row.
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Import preview — mixed-errors.csv" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "2 to add" })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "1 errors" }).click();
|
||||||
|
|
||||||
|
// The error table names the line, the column, and the problem (actionable, PUC-5).
|
||||||
|
const previewRow = page.locator(".errortable tbody tr");
|
||||||
|
await expect(previewRow).toHaveCount(1);
|
||||||
|
await expect(previewRow.locator("td").nth(0)).toHaveText("3");
|
||||||
|
await expect(previewRow.locator("td").nth(1)).toHaveText("Variant Price");
|
||||||
|
await expect(previewRow.locator("td").nth(2)).toContainText("is not a price");
|
||||||
|
|
||||||
|
// Good rows still import.
|
||||||
|
await page.getByRole("button", { name: "Import 2 products" }).click();
|
||||||
|
|
||||||
|
// Run detail: counts include the errored row, and the same error row persists.
|
||||||
|
await expect(page.getByRole("heading", { level: 1, name: "mixed-errors.csv" })).toBeVisible();
|
||||||
|
await expect(page.getByText("2 added · 0 updated · 1 rows in error")).toBeVisible();
|
||||||
|
const runRow = page.locator(".errortable tbody tr");
|
||||||
|
await expect(runRow).toHaveCount(1);
|
||||||
|
await expect(runRow.locator("td").nth(0)).toHaveText("3");
|
||||||
|
await expect(runRow.locator("td").nth(1)).toHaveText("Variant Price");
|
||||||
|
await expect(runRow.locator("td").nth(2)).toContainText("is not a price");
|
||||||
|
|
||||||
|
// The catalog gained the two good products.
|
||||||
|
await gotoProducts(page);
|
||||||
|
await expect(page.getByRole("heading", { level: 1, name: "Products · 2" })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// DoD scenario e2e_import_file_rejected (SD-0002 §6.8): a file-level rejection
|
||||||
|
// (PUC-5a) renders in place on the upload screen, the picker stays live for a
|
||||||
|
// retry, and nothing is recorded — no products, no run.
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers";
|
||||||
|
|
||||||
|
test("e2e_import_file_rejected", async ({ page }) => {
|
||||||
|
await signUpWithStorefront(page);
|
||||||
|
await gotoProducts(page);
|
||||||
|
await uploadFixture(page, "missing-title.csv");
|
||||||
|
|
||||||
|
// The rejection renders in place, naming the missing column.
|
||||||
|
await expect(page.getByText("That file can't be imported")).toBeVisible();
|
||||||
|
await expect(page.getByText("missing the required column 'Title'")).toBeVisible();
|
||||||
|
|
||||||
|
// The picker is live again for a retry.
|
||||||
|
await expect(page.locator('input[type="file"]')).toBeEnabled();
|
||||||
|
|
||||||
|
// No trace: still the empty catalog, and no run recorded.
|
||||||
|
await gotoProducts(page);
|
||||||
|
await expect(
|
||||||
|
page.getByText("No products yet. Bulk import is how product data gets in."),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("No imports yet.")).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// DoD scenario e2e_import_preview_confirm (SD-0002 §6.8): the happy path end to
|
||||||
|
// end — sign up, empty catalog, upload good.csv, preview gate (PUC-2/3), confirm,
|
||||||
|
// run report card, and the catalog + history reflecting the import. Subsumes the
|
||||||
|
// Task-15 harness smoke (sign-up journey + products empty state).
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { gotoProducts, signUpWithStorefront, STOREFRONT_NAME, uploadFixture } from "../helpers";
|
||||||
|
|
||||||
|
test("e2e_import_preview_confirm", async ({ page }) => {
|
||||||
|
await signUpWithStorefront(page);
|
||||||
|
|
||||||
|
// Admin topbar: storefront identity + the signed-in account chip (ex-smoke).
|
||||||
|
await expect(page.locator(".storeid__name")).toHaveText(STOREFRONT_NAME);
|
||||||
|
await expect(page.getByRole("button", { name: "Sign out" })).toBeVisible();
|
||||||
|
|
||||||
|
await gotoProducts(page);
|
||||||
|
|
||||||
|
// Empty state + the import affordances (SD-0002 §5.2, ex-smoke).
|
||||||
|
await expect(
|
||||||
|
page.getByText("No products yet. Bulk import is how product data gets in."),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: "Download sample CSV" })).toBeVisible();
|
||||||
|
|
||||||
|
await uploadFixture(page, "good.csv");
|
||||||
|
|
||||||
|
// Preview (§5.4): summary tiles + the file's name in the heading.
|
||||||
|
await expect(page.getByRole("heading", { name: "Import preview — good.csv" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "2 to add" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "0 errors" })).toBeVisible();
|
||||||
|
|
||||||
|
// Drill in: open star-tee's diff row and see field-level detail (the S-variant SKU).
|
||||||
|
const starTee = page.locator(".difflist__item", { hasText: "star-tee" });
|
||||||
|
await starTee.locator("summary").click();
|
||||||
|
await expect(starTee.getByText("WG-TEE-S")).toBeVisible();
|
||||||
|
|
||||||
|
// Consent gate (PUC-3): confirm the import.
|
||||||
|
await page.getByRole("button", { name: "Import 2 products" }).click();
|
||||||
|
|
||||||
|
// Run detail (§5.5): report card with the file name, counts, and status.
|
||||||
|
await expect(page.getByRole("heading", { level: 1, name: "good.csv" })).toBeVisible();
|
||||||
|
await expect(page.getByText("2 added · 0 updated · 0 rows in error")).toBeVisible();
|
||||||
|
await expect(page.getByText("Complete", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
// Back on Products: the catalog count and exactly one history row for this run.
|
||||||
|
await gotoProducts(page);
|
||||||
|
await expect(page.getByRole("heading", { level: 1, name: "Products · 2" })).toBeVisible();
|
||||||
|
const rows = page.locator(".datatable tbody tr");
|
||||||
|
await expect(rows).toHaveCount(1);
|
||||||
|
await expect(rows.first().getByRole("link", { name: "good.csv" })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// 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();
|
||||||
|
});
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "wiggleverse-ecomm-frontend",
|
"name": "wiggleverse-ecomm-frontend",
|
||||||
"version": "0.4.0",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wiggleverse-ecomm-frontend",
|
"name": "wiggleverse-ecomm-frontend",
|
||||||
"version": "0.4.0",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "wiggleverse-ecomm-frontend",
|
"name": "wiggleverse-ecomm-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.0",
|
"version": "0.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { adminViewFor, hashFor, type AdminView } from "./adminRouting";
|
||||||
|
|
||||||
|
const VIEWS: AdminView[] = [
|
||||||
|
{ view: "home" }, { view: "products" }, { view: "import-upload" },
|
||||||
|
{ view: "import-preview", draftId: 7 }, { view: "run-detail", runId: 12 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("adminRouting", () => {
|
||||||
|
it("round-trips every view", () => {
|
||||||
|
for (const v of VIEWS) expect(adminViewFor(hashFor(v))).toEqual(v);
|
||||||
|
});
|
||||||
|
it("defaults junk to home/products", () => {
|
||||||
|
expect(adminViewFor("")).toEqual({ view: "home" });
|
||||||
|
expect(adminViewFor("#/nonsense")).toEqual({ view: "home" });
|
||||||
|
expect(adminViewFor("#/products/imports/drafts/abc")).toEqual({ view: "products" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Hash routing for admin sections (SD-0002 §5). The URL is the durable handle on a
|
||||||
|
// section (PUC-8: run detail can be left and returned to); the SD-0001 entry-routing
|
||||||
|
// rule (routing.ts) still decides whether the admin renders at all.
|
||||||
|
export type AdminView =
|
||||||
|
| { view: "home" }
|
||||||
|
| { view: "products" }
|
||||||
|
| { view: "import-upload" }
|
||||||
|
| { view: "import-preview"; draftId: number }
|
||||||
|
| { view: "run-detail"; runId: number };
|
||||||
|
|
||||||
|
export function adminViewFor(hash: string): AdminView {
|
||||||
|
const parts = hash.replace(/^#\/?/, "").split("/").filter(Boolean);
|
||||||
|
if (parts[0] !== "products") return { view: "home" };
|
||||||
|
if (parts.length === 1) return { view: "products" };
|
||||||
|
if (parts[1] === "import" && parts.length === 2) return { view: "import-upload" };
|
||||||
|
if (parts[1] === "imports" && parts[2] === "drafts" && /^\d+$/.test(parts[3] ?? ""))
|
||||||
|
return { view: "import-preview", draftId: Number(parts[3]) };
|
||||||
|
if (parts[1] === "imports" && parts[2] === "runs" && /^\d+$/.test(parts[3] ?? ""))
|
||||||
|
return { view: "run-detail", runId: Number(parts[3]) };
|
||||||
|
return { view: "products" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashFor(v: AdminView): string {
|
||||||
|
switch (v.view) {
|
||||||
|
case "home": return "#/";
|
||||||
|
case "products": return "#/products";
|
||||||
|
case "import-upload": return "#/products/import";
|
||||||
|
case "import-preview": return `#/products/imports/drafts/${v.draftId}`;
|
||||||
|
case "run-detail": return `#/products/imports/runs/${v.runId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -15,7 +15,7 @@ export interface VerifyResult {
|
|||||||
created: boolean;
|
created: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function errorOf(resp: Response): Promise<ApiError> {
|
export async function errorOf(resp: Response): Promise<ApiError> {
|
||||||
try {
|
try {
|
||||||
const body = await resp.json();
|
const body = await resp.json();
|
||||||
if (body && body.error) return body.error as ApiError;
|
if (body && body.error) return body.error as ApiError;
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// 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,9 +1,16 @@
|
|||||||
// Admin shell (SD-0001 §5.4) — the storefront's stable home; honestly empty this release
|
// Admin shell (SD-0001 §5.4) — the storefront's stable home; the home view is honestly
|
||||||
// (PUC-8; PUC-9 sign-out). Renders from /me alone: storefront name + signed-in email. No
|
// empty this release (PUC-8; PUC-9 sign-out). Renders from /me alone: storefront name +
|
||||||
// zeroed metric tiles, no locked-feature teasers (OHM: Agency & Anti-Manipulation).
|
// signed-in email. No zeroed metric tiles, no locked-feature teasers (OHM: Agency &
|
||||||
// Visuals per the ui/designs export (hf-admin).
|
// 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 { logout } from "../api";
|
||||||
import { AccountChip, Banner, Eyebrow, Screen, TopBar } from "../ui/kit";
|
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 {
|
interface Props {
|
||||||
storefrontName: string;
|
storefrontName: string;
|
||||||
@@ -13,6 +20,14 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Admin({ storefrontName, email, welcome, onSignedOut }: Props) {
|
export default function Admin({ storefrontName, email, welcome, onSignedOut }: Props) {
|
||||||
|
const [view, setView] = useState<AdminView>(adminViewFor(window.location.hash));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onHashChange = () => setView(adminViewFor(window.location.hash));
|
||||||
|
window.addEventListener("hashchange", onHashChange);
|
||||||
|
return () => window.removeEventListener("hashchange", onHashChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
await logout();
|
await logout();
|
||||||
onSignedOut();
|
onSignedOut();
|
||||||
@@ -32,27 +47,41 @@ export default function Admin({ storefrontName, email, welcome, onSignedOut }: P
|
|||||||
}
|
}
|
||||||
right={<AccountChip email={email} onSignOut={signOut} />}
|
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">
|
<main className="screen__main">
|
||||||
<div className="empty">
|
{view.view === "home" && (
|
||||||
{welcome && (
|
<div className="empty">
|
||||||
<div style={{ marginBottom: 24, width: "100%" }}>
|
{welcome && (
|
||||||
<Banner tone="info" title={welcome === "new" ? "Welcome to ecomm" : "Welcome back"}>
|
<div style={{ marginBottom: 24, width: "100%" }}>
|
||||||
{welcome === "new"
|
<Banner tone="info" title={welcome === "new" ? "Welcome to ecomm" : "Welcome back"}>
|
||||||
? "A new account was created for this email."
|
{welcome === "new"
|
||||||
: "Signed in to your existing account."}
|
? "A new account was created for this email."
|
||||||
</Banner>
|
: "Signed in to your existing account."}
|
||||||
|
</Banner>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="empty__seal" aria-hidden="true">
|
||||||
|
<img src="/brand/mark-mono-gold.svg" width={36} height={36} alt="" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
<Eyebrow>Your storefront</Eyebrow>
|
||||||
<div className="empty__seal" aria-hidden="true">
|
<h1>{storefrontName}</h1>
|
||||||
<img src="/brand/mark-mono-gold.svg" width={36} height={36} alt="" />
|
<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>
|
</div>
|
||||||
<Eyebrow>Your storefront</Eyebrow>
|
)}
|
||||||
<h1>{storefrontName}</h1>
|
{view.view === "products" && <ProductsPage />}
|
||||||
<p className="empty__copy">
|
{view.view === "import-upload" && <ImportUpload />}
|
||||||
There's nothing to manage yet — and that's a finished state, not a missing one.
|
{view.view === "import-preview" && <ImportPreview draftId={view.draftId} />}
|
||||||
Catalog, orders, and settings will appear here as ecomm grows.
|
{view.view === "run-detail" && <RunDetail runId={view.runId} />}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
// Import preview (SD-0002 §5.4) — the consent gate. Summary tiles filter a
|
||||||
|
// drill-in diff list; the sticky footer carries confirm (PUC-3) / cancel (PUC-3a).
|
||||||
|
// Diff glyphs pair with color, never color alone (§6.6).
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
cancelDraft,
|
||||||
|
confirmDraft,
|
||||||
|
dialectLabel,
|
||||||
|
getDraft,
|
||||||
|
getDraftRecords,
|
||||||
|
type Draft,
|
||||||
|
type DraftRecord,
|
||||||
|
type FieldChange,
|
||||||
|
type RecordKind,
|
||||||
|
type VariantEntry,
|
||||||
|
} from "../../productsApi";
|
||||||
|
import { Banner } from "../../ui/kit";
|
||||||
|
|
||||||
|
const PAGE = 100;
|
||||||
|
|
||||||
|
function fmt(v: unknown): string {
|
||||||
|
if (v === null || v === undefined) return "—";
|
||||||
|
if (Array.isArray(v)) return v.length ? v.join(", ") : "—";
|
||||||
|
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KindChip({ kind }: { kind: RecordKind }) {
|
||||||
|
return <span className={`kindchip kindchip--${kind}`}>{kind}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SetLine({ field, value }: { field: string; value: unknown }) {
|
||||||
|
return (
|
||||||
|
<div className="diffchange">
|
||||||
|
<span className="diffchange__glyph--add">+ </span>
|
||||||
|
{field}: {fmt(value)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChangeRow({ change }: { change: FieldChange }) {
|
||||||
|
return (
|
||||||
|
<div className="diffchange">
|
||||||
|
{change.field}: <span className="diffchange__glyph--del">− {fmt(change.before)}</span> →{" "}
|
||||||
|
<span className="diffchange__glyph--add">+ {fmt(change.after)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function variantLabel(v: VariantEntry): string {
|
||||||
|
const opts = v.options.filter((o): o is string => o != null);
|
||||||
|
return opts.length ? `Variant ${opts.join(" / ")}` : "Variant";
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecordDetail({ record }: { record: DraftRecord }) {
|
||||||
|
const d = record.detail;
|
||||||
|
if (record.kind === "error") {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{(d.errors ?? []).map((e, i) => (
|
||||||
|
<div className="diffchange" key={i}>
|
||||||
|
line {e.line}: {e.column != null && `'${e.column}' — `}
|
||||||
|
{e.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{Object.entries(d.set ?? {}).map(([field, value]) => (
|
||||||
|
<SetLine key={field} field={field} value={value} />
|
||||||
|
))}
|
||||||
|
{(d.changes ?? []).map((c, i) => (
|
||||||
|
<ChangeRow key={i} change={c} />
|
||||||
|
))}
|
||||||
|
{(d.variants ?? []).map((v, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="diffchange diffchange--head">
|
||||||
|
{variantLabel(v)}
|
||||||
|
{v.kind ? ` (${v.kind})` : ""}
|
||||||
|
</div>
|
||||||
|
{Object.entries(v.set ?? {}).map(([field, value]) => (
|
||||||
|
<SetLine key={field} field={field} value={value} />
|
||||||
|
))}
|
||||||
|
{(v.changes ?? []).map((c, j) => (
|
||||||
|
<ChangeRow key={j} change={c} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(d.images ?? []).map((img, i) =>
|
||||||
|
img.kind && img.kind !== "add" ? (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="diffchange diffchange--head">
|
||||||
|
image: {img.src} ({img.kind})
|
||||||
|
</div>
|
||||||
|
{(img.changes ?? []).map((c, j) => (
|
||||||
|
<ChangeRow key={j} change={c} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="diffchange" key={i}>
|
||||||
|
<span className="diffchange__glyph--add">+ </span>image: {img.src}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorTable({ records }: { records: DraftRecord[] }) {
|
||||||
|
const rows = records.flatMap((r) => r.detail.errors ?? []);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<table className="errortable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Line</th>
|
||||||
|
<th>Column</th>
|
||||||
|
<th>Problem</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((e, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{e.line}</td>
|
||||||
|
<td>{e.column ?? "—"}</td>
|
||||||
|
<td>{e.message}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImportPreview({ draftId }: { draftId: number }) {
|
||||||
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
|
const [loadFail, setLoadFail] = useState<"gone" | "expired" | "failed" | null>(null);
|
||||||
|
const [filter, setFilter] = useState<RecordKind | null>(null);
|
||||||
|
const [records, setRecords] = useState<DraftRecord[] | null>(null);
|
||||||
|
const [recordsError, setRecordsError] = useState<"load" | "more" | null>(null);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [moreBusy, setMoreBusy] = useState(false);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [cancelling, setCancelling] = useState(false);
|
||||||
|
const [stale, setStale] = useState(false);
|
||||||
|
const [nothingNote, setNothingNote] = useState(false);
|
||||||
|
const [confirmError, setConfirmError] = useState<string | null>(null);
|
||||||
|
// Generation counter for the records list: bumped on every page-0 (re)load, so a
|
||||||
|
// page-0 or show-more response that resolves after a tile/filter (or draft) switch
|
||||||
|
// is recognized as stale and dropped instead of clobbering/appending to the new list.
|
||||||
|
const recordsGen = useRef(0);
|
||||||
|
|
||||||
|
async function loadDraft() {
|
||||||
|
setLoadFail(null);
|
||||||
|
const resp = await getDraft(draftId);
|
||||||
|
if (!resp.ok) {
|
||||||
|
setLoadFail(resp.status === 404 ? "gone" : resp.status === 410 ? "expired" : "failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraft(resp.value);
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
// A new draft means a fresh consent gate — reset everything the old one set.
|
||||||
|
setFilter(null);
|
||||||
|
setStale(false);
|
||||||
|
setConfirmError(null);
|
||||||
|
setNothingNote(false);
|
||||||
|
setRecords(null);
|
||||||
|
setRecordsError(null);
|
||||||
|
setHasMore(false);
|
||||||
|
void loadDraft();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [draftId]);
|
||||||
|
|
||||||
|
function loadRecords(kind: RecordKind | null) {
|
||||||
|
recordsGen.current += 1;
|
||||||
|
const gen = recordsGen.current;
|
||||||
|
setRecords(null);
|
||||||
|
setRecordsError(null);
|
||||||
|
setHasMore(false);
|
||||||
|
void getDraftRecords(draftId, kind ?? undefined, PAGE, 0).then((resp) => {
|
||||||
|
if (gen !== recordsGen.current) return;
|
||||||
|
if (!resp.ok) {
|
||||||
|
setRecordsError("load");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRecords(resp.value);
|
||||||
|
setHasMore(resp.value.length === PAGE);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
if (!draft) return;
|
||||||
|
loadRecords(filter);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [draft, draftId, filter]);
|
||||||
|
|
||||||
|
async function showMore() {
|
||||||
|
if (!records) return;
|
||||||
|
const gen = recordsGen.current;
|
||||||
|
setMoreBusy(true);
|
||||||
|
setRecordsError(null);
|
||||||
|
const resp = await getDraftRecords(draftId, filter ?? undefined, PAGE, records.length);
|
||||||
|
setMoreBusy(false);
|
||||||
|
// Filter/draft switched while this page was in flight — drop the stale page.
|
||||||
|
if (gen !== recordsGen.current) return;
|
||||||
|
if (!resp.ok) {
|
||||||
|
setRecordsError("more");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRecords((prev) => [...(prev ?? []), ...resp.value]);
|
||||||
|
setHasMore(resp.value.length === PAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfirm() {
|
||||||
|
setConfirming(true);
|
||||||
|
setConfirmError(null);
|
||||||
|
const resp = await confirmDraft(draftId);
|
||||||
|
if (resp.ok) {
|
||||||
|
window.location.hash = `#/products/imports/runs/${resp.value.run_id}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfirming(false);
|
||||||
|
if ((resp.status === 409 && resp.error.code === "preview_stale") || resp.status === 410) {
|
||||||
|
setStale(true);
|
||||||
|
} else if (resp.status === 409 && resp.error.code === "nothing_to_apply") {
|
||||||
|
setNothingNote(true);
|
||||||
|
} else {
|
||||||
|
setConfirmError(resp.error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCancel() {
|
||||||
|
setCancelling(true);
|
||||||
|
// PUC-3a — cancel even on draft-gone (404) still navigates home.
|
||||||
|
await cancelDraft(draftId);
|
||||||
|
window.location.hash = "#/products";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadFail === "gone") {
|
||||||
|
return (
|
||||||
|
<Banner tone="attn" title="This preview is gone">
|
||||||
|
<a href="#/products">Back to Products</a>
|
||||||
|
</Banner>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (loadFail === "expired") {
|
||||||
|
return (
|
||||||
|
<Banner tone="attn" title="This preview expired — upload the file again">
|
||||||
|
<a href="#/products/import">Upload the file again</a>
|
||||||
|
</Banner>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (loadFail === "failed") {
|
||||||
|
return (
|
||||||
|
<Banner tone="attn" title="Couldn't load this preview">
|
||||||
|
Something went wrong on our side.{" "}
|
||||||
|
<button type="button" className="linklike" onClick={() => void loadDraft()}>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</Banner>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!draft) {
|
||||||
|
return (
|
||||||
|
<p className="note" role="status">
|
||||||
|
Loading…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { summary } = draft;
|
||||||
|
const tiles: { kind: RecordKind; num: number; label: string }[] = [
|
||||||
|
{ kind: "add", num: summary.adds, label: "to add" },
|
||||||
|
{ kind: "update", num: summary.updates, label: "to update" },
|
||||||
|
{ kind: "unchanged", num: summary.unchanged, label: "unchanged" },
|
||||||
|
{ kind: "error", num: summary.errors, label: "errors" },
|
||||||
|
];
|
||||||
|
const toApply = summary.adds + summary.updates;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="products">
|
||||||
|
<p className="note">
|
||||||
|
<a href="#/products">← Products</a>
|
||||||
|
</p>
|
||||||
|
<h1>Import preview — {draft.file_name}</h1>
|
||||||
|
<p className="note">{dialectLabel(draft.dialect)}</p>
|
||||||
|
{draft.unknown_columns.length > 0 && (
|
||||||
|
<Banner tone="info" title="Columns not imported">
|
||||||
|
{draft.unknown_columns.length > 8 ? (
|
||||||
|
<details>
|
||||||
|
<summary>{draft.unknown_columns.length} columns not imported</summary>
|
||||||
|
{draft.unknown_columns.join(", ")}
|
||||||
|
</details>
|
||||||
|
) : (
|
||||||
|
draft.unknown_columns.join(", ")
|
||||||
|
)}
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
<div className="tiles">
|
||||||
|
{tiles.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.kind}
|
||||||
|
type="button"
|
||||||
|
className={`tile tile--${t.kind}${filter === t.kind ? " tile--active" : ""}`}
|
||||||
|
aria-pressed={filter === t.kind}
|
||||||
|
onClick={() => setFilter(filter === t.kind ? null : t.kind)}
|
||||||
|
>
|
||||||
|
<span className="tile__num">{t.num.toLocaleString()}</span>
|
||||||
|
<span className="tile__label">{t.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{filter === "error" && records && <ErrorTable records={records} />}
|
||||||
|
{recordsError === "load" ? (
|
||||||
|
<p className="note note--attn" role="alert">
|
||||||
|
Couldn't load these records.{" "}
|
||||||
|
<button type="button" className="linklike" onClick={() => loadRecords(filter)}>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
) : records === null ? (
|
||||||
|
<p className="note" role="status">
|
||||||
|
Loading…
|
||||||
|
</p>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<p className="note">Nothing to show here.</p>
|
||||||
|
) : (
|
||||||
|
<div className="difflist">
|
||||||
|
{records.map((r, i) => (
|
||||||
|
<details className="difflist__item" key={`${r.handle}-${i}`}>
|
||||||
|
<summary>
|
||||||
|
<span className="difflist__handle">{r.handle}</span> · {r.title} ·{" "}
|
||||||
|
<KindChip kind={r.kind} /> · {r.variant_count}{" "}
|
||||||
|
{r.variant_count === 1 ? "variant" : "variants"}
|
||||||
|
</summary>
|
||||||
|
<RecordDetail record={r} />
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasMore && (
|
||||||
|
<p>
|
||||||
|
<button type="button" className="btn-secondary" disabled={moreBusy} onClick={() => void showMore()}>
|
||||||
|
{moreBusy ? "Loading…" : "Show more"}
|
||||||
|
</button>
|
||||||
|
{recordsError === "more" && (
|
||||||
|
<span className="note note--attn" role="alert">
|
||||||
|
Couldn't load more records.{" "}
|
||||||
|
<button type="button" className="linklike" onClick={() => void showMore()}>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="sticky-footer" aria-live="polite">
|
||||||
|
{stale ? (
|
||||||
|
<Banner tone="attn" title="Your catalog changed since this preview — upload the file again">
|
||||||
|
<a href="#/products/import">Upload the file again</a>
|
||||||
|
</Banner>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={toApply === 0 || confirming || cancelling}
|
||||||
|
onClick={() => void onConfirm()}
|
||||||
|
>
|
||||||
|
{confirming ? "Importing…" : `Import ${toApply.toLocaleString()} products`}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={confirming || cancelling}
|
||||||
|
onClick={() => void onCancel()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{(toApply === 0 || nothingNote) && (
|
||||||
|
<span className="note">Nothing to change — your catalog already matches this file</span>
|
||||||
|
)}
|
||||||
|
{confirmError && (
|
||||||
|
<span className="note note--attn" role="alert">
|
||||||
|
{confirmError}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Import — upload (SD-0002 §5.3). Selecting a file starts upload + validation
|
||||||
|
// immediately (PUC-2); file-level rejections (PUC-5a) render in place with the
|
||||||
|
// picker live for retry. No notifications — errors render here.
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { uploadImport } from "../../productsApi";
|
||||||
|
import { Banner } from "../../ui/kit";
|
||||||
|
|
||||||
|
export default function ImportUpload() {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
async function onPick(files: FileList | null) {
|
||||||
|
const file = files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
const resp = await uploadImport(file);
|
||||||
|
setBusy(false);
|
||||||
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
|
if (!resp.ok) {
|
||||||
|
setError(resp.error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.hash = `#/products/imports/drafts/${resp.value.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="products products--narrow">
|
||||||
|
<p className="note">
|
||||||
|
<a href="#/products">← Products</a>
|
||||||
|
</p>
|
||||||
|
<h1>Import products</h1>
|
||||||
|
{error && (
|
||||||
|
<Banner tone="attn" title="That file can't be imported">
|
||||||
|
{error}
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
<label className={`dropzone${busy ? " dropzone--busy" : ""}`}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => void onPick(e.target.files)}
|
||||||
|
/>
|
||||||
|
<span className="dropzone__title" aria-live="polite">
|
||||||
|
{busy ? "Validating…" : "Choose a CSV file"}
|
||||||
|
</span>
|
||||||
|
<span className="note">CSV, up to 5,000 rows</span>
|
||||||
|
</label>
|
||||||
|
<p className="note">
|
||||||
|
Works with the canonical format.{" "}
|
||||||
|
<a href="/api/products/sample.csv" download>
|
||||||
|
Download sample CSV
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Run detail (SD-0002 §5.5) — report card for a completed or in-progress import run.
|
||||||
|
// NO images section this slice (SLICE-7). PUC-4/5/8.
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { dialectLabel, getRun, type RunDetail as RunDetailType } from "../../productsApi";
|
||||||
|
import { Banner } from "../../ui/kit";
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
applying: "Importing…",
|
||||||
|
fetching_images: "Fetching images…",
|
||||||
|
complete: "Complete",
|
||||||
|
complete_with_problems: "Complete with problems",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RunDetail({ runId }: { runId: number }) {
|
||||||
|
const [run, setRun] = useState<RunDetailType | null>(null);
|
||||||
|
const [loadFail, setLoadFail] = useState<"gone" | "failed" | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoadFail(null);
|
||||||
|
const resp = await getRun(runId);
|
||||||
|
if (!resp.ok) {
|
||||||
|
setLoadFail(resp.status === 404 ? "gone" : "failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRun(resp.value);
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [runId]);
|
||||||
|
|
||||||
|
if (loadFail === "gone") {
|
||||||
|
return (
|
||||||
|
<Banner tone="attn" title="No such import run">
|
||||||
|
<a href="#/products">← Products</a>
|
||||||
|
</Banner>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (loadFail === "failed") {
|
||||||
|
return (
|
||||||
|
<Banner tone="attn" title="Couldn't load this import run">
|
||||||
|
Something went wrong on our side.{" "}
|
||||||
|
<button type="button" className="linklike" onClick={() => void load()}>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</Banner>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!run) {
|
||||||
|
return (
|
||||||
|
<p className="note" role="status">
|
||||||
|
Loading…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="products">
|
||||||
|
<p className="note">
|
||||||
|
<a href="#/products">← Products</a>
|
||||||
|
</p>
|
||||||
|
<h1>{run.file_name}</h1>
|
||||||
|
<p className="note">
|
||||||
|
Imported {new Date(run.created_at).toLocaleString()} by {run.by} · {dialectLabel(run.dialect)}
|
||||||
|
</p>
|
||||||
|
<p className="note">
|
||||||
|
{run.products_added} added · {run.products_updated} updated · {run.rows_errored} rows in
|
||||||
|
error
|
||||||
|
</p>
|
||||||
|
<p className="note">{STATUS_LABELS[run.status] ?? run.status}</p>
|
||||||
|
{run.errors.length > 0 && (
|
||||||
|
<table className="errortable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Line</th>
|
||||||
|
<th>Column</th>
|
||||||
|
<th>Problem</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{run.errors.map((e, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{e.line}</td>
|
||||||
|
<td>{e.column ?? "—"}</td>
|
||||||
|
<td>{e.message}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
@@ -5,3 +5,4 @@
|
|||||||
@import "./tokens-typography.css";
|
@import "./tokens-typography.css";
|
||||||
@import "./tokens-spacing.css";
|
@import "./tokens-spacing.css";
|
||||||
@import "./app.css";
|
@import "./app.css";
|
||||||
|
@import "./products.css";
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
/* 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; }
|
||||||
|
}
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# E2E browser gate (SD-0002 §6.8) — Playwright against a fresh local stack.
|
||||||
|
# Not yet part of check.sh/CI: the CI runner has no browsers (§10.6 gap).
|
||||||
|
set -euo pipefail
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$repo_root/e2e"
|
||||||
|
if [ ! -d node_modules ]; then
|
||||||
|
npm install
|
||||||
|
npx playwright install chromium
|
||||||
|
fi
|
||||||
|
npx playwright test "$@"
|
||||||
Reference in New Issue
Block a user