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 (
+
+
+
+ | Line |
+ Column |
+ Problem |
+
+
+
+ {rows.map((e, i) => (
+
+ | {e.line} |
+ {e.column ?? "—"} |
+ {e.message} |
+
+ ))}
+
+
+ );
+}
+
+export default function ImportPreview({ draftId }: { draftId: number }) {
+ const [draft, setDraft] = useState(null);
+ const [loadFail, setLoadFail] = useState<"gone" | "expired" | "failed" | null>(null);
+ const [filter, setFilter] = useState(null);
+ const [records, setRecords] = useState(null);
+ const [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;