diff --git a/frontend/src/productsApi.ts b/frontend/src/productsApi.ts
index 486ec2e..ae039fe 100644
--- a/frontend/src/productsApi.ts
+++ b/frontend/src/productsApi.ts
@@ -42,6 +42,11 @@ export interface ProductsSummary {
export type Result = { ok: true; value: T } | { ok: false; error: ApiError; status: number };
+// One label rule for CSV dialects, shared by Products history / preview / run detail.
+export function dialectLabel(d: string): string {
+ return d === "canonical" ? "Canonical format" : d;
+}
+
async function request(path: string, init?: RequestInit): Promise> {
const resp = await fetch(path, { credentials: "include", ...init });
if (!resp.ok) return { ok: false, error: await errorOf(resp), status: resp.status };
diff --git a/frontend/src/screens/products/ImportPreview.tsx b/frontend/src/screens/products/ImportPreview.tsx
index 65945fd..6b63d0d 100644
--- a/frontend/src/screens/products/ImportPreview.tsx
+++ b/frontend/src/screens/products/ImportPreview.tsx
@@ -1,10 +1,11 @@
// 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 { useEffect, useRef, useState } from "react";
import {
cancelDraft,
confirmDraft,
+ dialectLabel,
getDraft,
getDraftRecords,
type Draft,
@@ -40,8 +41,8 @@ function SetLine({ field, value }: { field: string; value: unknown }) {
function ChangeRow({ change }: { change: FieldChange }) {
return (
- {change.field}: {fmt(change.before)} →{" "}
- {fmt(change.after)}
+ {change.field}: − {fmt(change.before)} →{" "}
+ + {fmt(change.after)}
);
}
@@ -137,6 +138,7 @@ export default function ImportPreview({ draftId }: { draftId: number }) {
const [loadFail, setLoadFail] = useState<"gone" | "expired" | "failed" | null>(null);
const [filter, setFilter] = useState(null);
const [records, setRecords] = useState(null);
+ const [recordsError, setRecordsError] = useState<"load" | "more" | null>(null);
const [hasMore, setHasMore] = useState(false);
const [moreBusy, setMoreBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
@@ -144,6 +146,10 @@ export default function ImportPreview({ draftId }: { draftId: number }) {
const [stale, setStale] = useState(false);
const [nothingNote, setNothingNote] = useState(false);
const [confirmError, setConfirmError] = useState(null);
+ // Generation counter for the records list: bumped on every page-0 (re)load, so a
+ // page-0 or show-more response that resolves after a tile/filter (or draft) switch
+ // is recognized as stale and dropped instead of clobbering/appending to the new list.
+ const recordsGen = useRef(0);
async function loadDraft() {
setLoadFail(null);
@@ -155,32 +161,54 @@ export default function ImportPreview({ draftId }: { draftId: number }) {
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]);
- useEffect(() => {
- if (!draft) return;
- let gone = false;
+ function loadRecords(kind: RecordKind | null) {
+ recordsGen.current += 1;
+ const gen = recordsGen.current;
setRecords(null);
+ setRecordsError(null);
setHasMore(false);
- void getDraftRecords(draftId, filter ?? undefined, PAGE, 0).then((resp) => {
- if (gone || !resp.ok) return;
+ 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);
});
- return () => {
- gone = true;
- };
+ }
+ 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);
- if (!resp.ok) return;
- setRecords([...records, ...resp.value]);
+ // 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);
}
@@ -233,7 +261,13 @@ export default function ImportPreview({ draftId }: { draftId: number }) {
);
}
- if (!draft) return Loading…
;
+ if (!draft) {
+ return (
+
+ Loading…
+
+ );
+ }
const { summary } = draft;
const tiles: { kind: RecordKind; num: number; label: string }[] = [
@@ -250,10 +284,17 @@ export default function ImportPreview({ draftId }: { draftId: number }) {
← Products
Import preview — {draft.file_name}
- {draft.dialect === "canonical" ? "Canonical format" : draft.dialect}
+ {dialectLabel(draft.dialect)}
{draft.unknown_columns.length > 0 && (
- {draft.unknown_columns.join(", ")}
+ {draft.unknown_columns.length > 8 ? (
+
+ {draft.unknown_columns.length} columns not imported
+ {draft.unknown_columns.join(", ")}
+
+ ) : (
+ draft.unknown_columns.join(", ")
+ )}
)}
@@ -271,8 +312,17 @@ export default function ImportPreview({ draftId }: { draftId: number }) {
))}
{filter === "error" && records && }
- {records === null ? (
- Loading…
+ {recordsError === "load" ? (
+
+ Couldn't load these records.{" "}
+ loadRecords(filter)}>
+ Retry
+
+
+ ) : records === null ? (
+
+ Loading…
+
) : records.length === 0 ? (
Nothing to show here.
) : (
@@ -294,9 +344,17 @@ export default function ImportPreview({ draftId }: { draftId: number }) {
void showMore()}>
{moreBusy ? "Loading…" : "Show more"}
+ {recordsError === "more" && (
+
+ Couldn't load more records.{" "}
+ void showMore()}>
+ Retry
+
+
+ )}
)}
-
+
{stale ? (
Upload the file again
diff --git a/frontend/src/screens/products/ImportUpload.tsx b/frontend/src/screens/products/ImportUpload.tsx
index a5e8421..934ccdc 100644
--- a/frontend/src/screens/products/ImportUpload.tsx
+++ b/frontend/src/screens/products/ImportUpload.tsx
@@ -44,7 +44,9 @@ export default function ImportUpload() {
disabled={busy}
onChange={(e) => void onPick(e.target.files)}
/>
- {busy ? "Validating…" : "Choose a CSV file"}
+
+ {busy ? "Validating…" : "Choose a CSV file"}
+
CSV, up to 5,000 rows
diff --git a/frontend/src/screens/products/ProductsPage.tsx b/frontend/src/screens/products/ProductsPage.tsx
index d6dca2c..b8de3f6 100644
--- a/frontend/src/screens/products/ProductsPage.tsx
+++ b/frontend/src/screens/products/ProductsPage.tsx
@@ -1,7 +1,13 @@
// Products page (SD-0002 §5.2) — the catalog's home: where imports start and history
// lives. SLICE-5: export is disabled (SLICE-6 ships it); the browsable list is #14's.
import { useEffect, useState } from "react";
-import { getProductsSummary, listRuns, type ProductsSummary, type RunSummary } from "../../productsApi";
+import {
+ dialectLabel,
+ getProductsSummary,
+ listRuns,
+ type ProductsSummary,
+ type RunSummary,
+} from "../../productsApi";
import { Banner } from "../../ui/kit";
const STATUS_LABELS: Record = {
@@ -40,7 +46,13 @@ export default function ProductsPage() {
);
}
- if (!summary || !runs) return
Loading…
;
+ if (!summary || !runs) {
+ return (
+
+ Loading…
+
+ );
+ }
const empty = summary.product_count === 0;
return (
@@ -51,9 +63,12 @@ export default function ProductsPage() {
{!empty &&
· {summary.product_count.toLocaleString()} }
-
- Export
-
+
+
+ Export
+
+ Export arrives in a coming release
+
Import products
@@ -95,8 +110,13 @@ export default function ProductsPage() {
}}
>
{new Date(r.created_at).toLocaleString()}
-
{r.file_name}
-
{r.dialect}
+
+ {/* Anchor = the keyboard/SR path (§6.6); the row onClick stays as a
+ mouse convenience. Both set the same hash, so the double fire on
+ an anchor click is idempotent. */}
+ {r.file_name}
+
+
{dialectLabel(r.dialect)}
{r.products_added}
{r.products_updated}
{r.rows_errored}
diff --git a/frontend/src/screens/products/RunDetail.tsx b/frontend/src/screens/products/RunDetail.tsx
index cd79704..c0a8194 100644
--- a/frontend/src/screens/products/RunDetail.tsx
+++ b/frontend/src/screens/products/RunDetail.tsx
@@ -1,7 +1,7 @@
// 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 { getRun, type RunDetail as RunDetailType } from "../../productsApi";
+import { dialectLabel, getRun, type RunDetail as RunDetailType } from "../../productsApi";
import { Banner } from "../../ui/kit";
const STATUS_LABELS: Record
= {
@@ -46,9 +46,13 @@ export default function RunDetail({ runId }: { runId: number }) {
);
}
- if (!run) return Loading…
;
-
- const dialectLabel = run.dialect === "canonical" ? "Canonical format" : run.dialect;
+ if (!run) {
+ return (
+
+ Loading…
+
+ );
+ }
return (
@@ -57,7 +61,7 @@ export default function RunDetail({ runId }: { runId: number }) {
{run.file_name}
- Imported {new Date(run.created_at).toLocaleString()} by {run.by} · {dialectLabel}
+ Imported {new Date(run.created_at).toLocaleString()} by {run.by} · {dialectLabel(run.dialect)}
{run.products_added} added · {run.products_updated} updated · {run.rows_errored} rows in
@@ -77,7 +81,7 @@ export default function RunDetail({ runId }: { runId: number }) {
{run.errors.map((e, i) => (
{e.line}
- {e.column ?? ""}
+ {e.column ?? "—"}
{e.message}
))}
diff --git a/frontend/src/styles/products.css b/frontend/src/styles/products.css
index 27de45f..d59d556 100644
--- a/frontend/src/styles/products.css
+++ b/frontend/src/styles/products.css
@@ -53,6 +53,9 @@
}
.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 "coming release" caption, stacked. */
+.products__export { display: flex; flex-direction: column; gap: 4px; align-items: center; }
+.products__export .note { font-size: 11.5px; }
.products .btn-primary { width: auto; text-decoration: none; }
.products .empty { margin: 24px auto 0; }
@@ -160,7 +163,6 @@
/* ── 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;