feat(products-ui): admin nav + Products page with import history (§5.2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:29:20 -07:00
parent b5dac8886f
commit 26f84cb916
7 changed files with 395 additions and 22 deletions
+51 -22
View File
@@ -1,9 +1,16 @@
// Admin shell (SD-0001 §5.4) — the storefront's stable home; honestly empty this release
// (PUC-8; PUC-9 sign-out). Renders from /me alone: storefront name + signed-in email. No
// zeroed metric tiles, no locked-feature teasers (OHM: Agency & Anti-Manipulation).
// Visuals per the ui/designs export (hf-admin).
// Admin shell (SD-0001 §5.4) — the storefront's stable home; the home view is honestly
// empty this release (PUC-8; PUC-9 sign-out). Renders from /me alone: storefront name +
// signed-in email. No zeroed metric tiles, no locked-feature teasers (OHM: Agency &
// 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 { 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 {
storefrontName: string;
@@ -13,6 +20,14 @@ interface 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() {
await logout();
onSignedOut();
@@ -32,27 +47,41 @@ export default function Admin({ storefrontName, email, welcome, onSignedOut }: P
}
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">
<div className="empty">
{welcome && (
<div style={{ marginBottom: 24, width: "100%" }}>
<Banner tone="info" title={welcome === "new" ? "Welcome to ecomm" : "Welcome back"}>
{welcome === "new"
? "A new account was created for this email."
: "Signed in to your existing account."}
</Banner>
{view.view === "home" && (
<div className="empty">
{welcome && (
<div style={{ marginBottom: 24, width: "100%" }}>
<Banner tone="info" title={welcome === "new" ? "Welcome to ecomm" : "Welcome back"}>
{welcome === "new"
? "A new account was created for this email."
: "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 className="empty__seal" aria-hidden="true">
<img src="/brand/mark-mono-gold.svg" width={36} height={36} alt="" />
<Eyebrow>Your storefront</Eyebrow>
<h1>{storefrontName}</h1>
<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>
<Eyebrow>Your storefront</Eyebrow>
<h1>{storefrontName}</h1>
<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>
)}
{view.view === "products" && <ProductsPage />}
{view.view === "import-upload" && <ImportUpload />}
{view.view === "import-preview" && <ImportPreview draftId={view.draftId} />}
{view.view === "run-detail" && <RunDetail runId={view.runId} />}
</main>
</Screen>
);
@@ -0,0 +1,5 @@
// 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;
}
@@ -0,0 +1,4 @@
// Import upload (SD-0002 §5.3) — stub; Task 12 replaces this with the dropzone.
export default function ImportUpload() {
return null;
}
@@ -0,0 +1,112 @@
// 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 { 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">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">
<button type="button" className="btn-secondary" disabled title="Export arrives in a coming release">
Export
</button>
<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>{r.file_name}</td>
<td>{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,5 @@
// Run detail (SD-0002 §5.5) — stub; Task 14 replaces this with the run report.
export default function RunDetail({ runId }: { runId: number }) {
void runId;
return null;
}
+1
View File
@@ -5,3 +5,4 @@
@import "./tokens-typography.css";
@import "./tokens-spacing.css";
@import "./app.css";
@import "./products.css";
+217
View File
@@ -0,0 +1,217 @@
/* Products section (SD-0002 §5) admin nav strip, the catalog's home page, and the
import-flow primitives (dropzone, tiles, difflist Tasks 1214 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; }
.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 > li { padding: 12px 4px; border-bottom: 1px solid var(--border-soft); }
.diffchange {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12.5px;
line-height: 1.6;
color: var(--text-on-dark-soft);
}
.diffchange__glyph--add { color: var(--st-add); }
.diffchange__glyph--del { color: var(--st-error); }
/* ── 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;
}
/* ── small screens ──────────────────────────────────────────────────────────── */
@media (max-width: 720px) {
.adminnav { padding: 0 20px; }
.tiles { grid-template-columns: repeat(2, 1fr); }
.products__header { flex-wrap: wrap; }
}