From 4141285b89349019dc67875e00e4272d0e6afbba Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 16:34:33 -0700 Subject: [PATCH] =?UTF-8?q?feat(products-ui):=20import=20preview=20?= =?UTF-8?q?=E2=80=94=20tiles,=20drill-in=20diffs,=20confirm/cancel=20(?= =?UTF-8?q?=C2=A75.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../src/screens/products/ImportPreview.tsx | 338 +++++++++++++++++- frontend/src/styles/products.css | 33 ++ 2 files changed, 367 insertions(+), 4 deletions(-) diff --git a/frontend/src/screens/products/ImportPreview.tsx b/frontend/src/screens/products/ImportPreview.tsx index b785e01..65945fd 100644 --- a/frontend/src/screens/products/ImportPreview.tsx +++ b/frontend/src/screens/products/ImportPreview.tsx @@ -1,5 +1,335 @@ -// Import preview (SD-0002 §5.4) — stub; Task 13 replaces this with the diff preview. -export default function ImportPreview({ draftId }: { draftId: number }) { - void draftId; - return null; +// 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, useState } from "react"; +import { + cancelDraft, + confirmDraft, + getDraft, + getDraftRecords, + type Draft, + type DraftRecord, + type FieldChange, + type RecordKind, + type VariantEntry, +} from "../../productsApi"; +import { Banner } from "../../ui/kit"; + +const PAGE = 100; + +function fmt(v: unknown): string { + if (v === null || v === undefined) return "—"; + if (Array.isArray(v)) return v.length ? v.join(", ") : "—"; + if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; + return String(v); +} + +function KindChip({ kind }: { kind: RecordKind }) { + return {kind}; +} + +function SetLine({ field, value }: { field: string; value: unknown }) { + return ( +
+ + + {field}: {fmt(value)} +
+ ); +} + +function ChangeRow({ change }: { change: FieldChange }) { + return ( +
+ {change.field}: {fmt(change.before)} →{" "} + {fmt(change.after)} +
+ ); +} + +function variantLabel(v: VariantEntry): string { + const opts = v.options.filter((o): o is string => o != null); + return opts.length ? `Variant ${opts.join(" / ")}` : "Variant"; +} + +function RecordDetail({ record }: { record: DraftRecord }) { + const d = record.detail; + if (record.kind === "error") { + return ( +
+ {(d.errors ?? []).map((e, i) => ( +
+ line {e.line}: {e.column != null && `'${e.column}' — `} + {e.message} +
+ ))} +
+ ); + } + return ( +
+ {Object.entries(d.set ?? {}).map(([field, value]) => ( + + ))} + {(d.changes ?? []).map((c, i) => ( + + ))} + {(d.variants ?? []).map((v, i) => ( +
+
+ {variantLabel(v)} + {v.kind ? ` (${v.kind})` : ""} +
+ {Object.entries(v.set ?? {}).map(([field, value]) => ( + + ))} + {(v.changes ?? []).map((c, j) => ( + + ))} +
+ ))} + {(d.images ?? []).map((img, i) => + img.kind && img.kind !== "add" ? ( +
+
+ image: {img.src} ({img.kind}) +
+ {(img.changes ?? []).map((c, j) => ( + + ))} +
+ ) : ( +
+ + image: {img.src} +
+ ), + )} +
+ ); +} + +function ErrorTable({ records }: { records: DraftRecord[] }) { + const rows = records.flatMap((r) => r.detail.errors ?? []); + if (rows.length === 0) return null; + return ( + + + + + + + + + + {rows.map((e, i) => ( + + + + + + ))} + +
LineColumnProblem
{e.line}{e.column ?? "—"}{e.message}
+ ); +} + +export default function ImportPreview({ draftId }: { draftId: number }) { + const [draft, setDraft] = useState(null); + const [loadFail, setLoadFail] = useState<"gone" | "expired" | "failed" | null>(null); + const [filter, setFilter] = useState(null); + const [records, setRecords] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [moreBusy, setMoreBusy] = useState(false); + const [confirming, setConfirming] = useState(false); + const [cancelling, setCancelling] = useState(false); + const [stale, setStale] = useState(false); + const [nothingNote, setNothingNote] = useState(false); + const [confirmError, setConfirmError] = useState(null); + + 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(() => { + void loadDraft(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [draftId]); + + useEffect(() => { + if (!draft) return; + let gone = false; + setRecords(null); + setHasMore(false); + void getDraftRecords(draftId, filter ?? undefined, PAGE, 0).then((resp) => { + if (gone || !resp.ok) return; + setRecords(resp.value); + setHasMore(resp.value.length === PAGE); + }); + return () => { + gone = true; + }; + }, [draft, draftId, filter]); + + async function showMore() { + if (!records) return; + setMoreBusy(true); + const resp = await getDraftRecords(draftId, filter ?? undefined, PAGE, records.length); + setMoreBusy(false); + if (!resp.ok) return; + setRecords([...records, ...resp.value]); + setHasMore(resp.value.length === PAGE); + } + + async function onConfirm() { + setConfirming(true); + setConfirmError(null); + const resp = await confirmDraft(draftId); + if (resp.ok) { + window.location.hash = `#/products/imports/runs/${resp.value.run_id}`; + return; + } + setConfirming(false); + if ((resp.status === 409 && resp.error.code === "preview_stale") || resp.status === 410) { + setStale(true); + } else if (resp.status === 409 && resp.error.code === "nothing_to_apply") { + setNothingNote(true); + } else { + setConfirmError(resp.error.message); + } + } + + async function onCancel() { + setCancelling(true); + // PUC-3a — cancel even on draft-gone (404) still navigates home. + await cancelDraft(draftId); + window.location.hash = "#/products"; + } + + if (loadFail === "gone") { + return ( + + Back to Products + + ); + } + if (loadFail === "expired") { + return ( + + Upload the file again + + ); + } + if (loadFail === "failed") { + return ( + + Something went wrong on our side.{" "} + + + ); + } + if (!draft) return

Loading…

; + + const { summary } = draft; + const tiles: { kind: RecordKind; num: number; label: string }[] = [ + { kind: "add", num: summary.adds, label: "to add" }, + { kind: "update", num: summary.updates, label: "to update" }, + { kind: "unchanged", num: summary.unchanged, label: "unchanged" }, + { kind: "error", num: summary.errors, label: "errors" }, + ]; + const toApply = summary.adds + summary.updates; + + return ( +
+

+ ← Products +

+

Import preview — {draft.file_name}

+

{draft.dialect === "canonical" ? "Canonical format" : draft.dialect}

+ {draft.unknown_columns.length > 0 && ( + + {draft.unknown_columns.join(", ")} + + )} +
+ {tiles.map((t) => ( + + ))} +
+ {filter === "error" && records && } + {records === null ? ( +

Loading…

+ ) : records.length === 0 ? ( +

Nothing to show here.

+ ) : ( +
+ {records.map((r, i) => ( +
+ + {r.handle} · {r.title} ·{" "} + · {r.variant_count}{" "} + {r.variant_count === 1 ? "variant" : "variants"} + + +
+ ))} +
+ )} + {hasMore && ( +

+ +

+ )} +
+ {stale ? ( + + Upload the file again + + ) : ( + <> + + + {(toApply === 0 || nothingNote) && ( + Nothing to change — your catalog already matches this file + )} + {confirmError && ( + + {confirmError} + + )} + + )} +
+
+ ); } diff --git a/frontend/src/styles/products.css b/frontend/src/styles/products.css index 1778798..27de45f 100644 --- a/frontend/src/styles/products.css +++ b/frontend/src/styles/products.css @@ -161,15 +161,48 @@ /* ── diff list (preview records, Task 13) ───────────────────────────────────── */ .difflist { list-style: none; margin: 0; padding: 0; } .difflist > li { padding: 12px 4px; border-bottom: 1px solid var(--border-soft); } +.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;