d18a84e135
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
133 lines
4.3 KiB
TypeScript
133 lines
4.3 KiB
TypeScript
// 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 {
|
|
dialectLabel,
|
|
getProductsSummary,
|
|
listRuns,
|
|
type ProductsSummary,
|
|
type RunSummary,
|
|
} 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 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 = summary.product_count === 0;
|
|
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">
|
|
<div className="products__export">
|
|
<button type="button" className="btn-secondary" disabled title="Export arrives in a coming release">
|
|
Export
|
|
</button>
|
|
<span className="note">Export arrives in a coming release</span>
|
|
</div>
|
|
<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>
|
|
);
|
|
}
|