From 26f84cb916d22f1da3caeac626583c105f7e9ee7 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 16:29:20 -0700 Subject: [PATCH] =?UTF-8?q?feat(products-ui):=20admin=20nav=20+=20Products?= =?UTF-8?q?=20page=20with=20import=20history=20(=C2=A75.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- frontend/src/screens/Admin.tsx | 73 ++++-- .../src/screens/products/ImportPreview.tsx | 5 + .../src/screens/products/ImportUpload.tsx | 4 + .../src/screens/products/ProductsPage.tsx | 112 +++++++++ frontend/src/screens/products/RunDetail.tsx | 5 + frontend/src/styles/index.css | 1 + frontend/src/styles/products.css | 217 ++++++++++++++++++ 7 files changed, 395 insertions(+), 22 deletions(-) create mode 100644 frontend/src/screens/products/ImportPreview.tsx create mode 100644 frontend/src/screens/products/ImportUpload.tsx create mode 100644 frontend/src/screens/products/ProductsPage.tsx create mode 100644 frontend/src/screens/products/RunDetail.tsx create mode 100644 frontend/src/styles/products.css diff --git a/frontend/src/screens/Admin.tsx b/frontend/src/screens/Admin.tsx index aac9afb..e44dc45 100644 --- a/frontend/src/screens/Admin.tsx +++ b/frontend/src/screens/Admin.tsx @@ -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(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={} /> +
-
- {welcome && ( -
- - {welcome === "new" - ? "A new account was created for this email." - : "Signed in to your existing account."} - + {view.view === "home" && ( +
+ {welcome && ( +
+ + {welcome === "new" + ? "A new account was created for this email." + : "Signed in to your existing account."} + +
+ )} + - )} - - Your storefront -

{storefrontName}

-

- 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. -

-
+ )} + {view.view === "products" && } + {view.view === "import-upload" && } + {view.view === "import-preview" && } + {view.view === "run-detail" && }
); diff --git a/frontend/src/screens/products/ImportPreview.tsx b/frontend/src/screens/products/ImportPreview.tsx new file mode 100644 index 0000000..b785e01 --- /dev/null +++ b/frontend/src/screens/products/ImportPreview.tsx @@ -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; +} diff --git a/frontend/src/screens/products/ImportUpload.tsx b/frontend/src/screens/products/ImportUpload.tsx new file mode 100644 index 0000000..185e536 --- /dev/null +++ b/frontend/src/screens/products/ImportUpload.tsx @@ -0,0 +1,4 @@ +// Import upload (SD-0002 §5.3) — stub; Task 12 replaces this with the dropzone. +export default function ImportUpload() { + return null; +} diff --git a/frontend/src/screens/products/ProductsPage.tsx b/frontend/src/screens/products/ProductsPage.tsx new file mode 100644 index 0000000..d6dca2c --- /dev/null +++ b/frontend/src/screens/products/ProductsPage.tsx @@ -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 = { + applying: "Importing…", + fetching_images: "Fetching images…", + complete: "Complete", + complete_with_problems: "Complete with problems", +}; + +export default function ProductsPage() { + const [summary, setSummary] = useState(null); + const [runs, setRuns] = useState(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 ( + + Something went wrong on our side.{" "} + + + ); + } + if (!summary || !runs) return

Loading…

; + + const empty = summary.product_count === 0; + return ( +
+
+

+ Products + {!empty && · {summary.product_count.toLocaleString()}} +

+
+ + + Import products + +
+
+ {empty ? ( +
+

No products yet. Bulk import is how product data gets in.

+ + Import products + +

+ + Download sample CSV + +

+
+ ) : ( +

Your catalog is loaded. The browsable product list arrives with an upcoming release.

+ )} +
+

Import history

+ {runs.length === 0 ? ( +

No imports yet.

+ ) : ( + + + + + + + + {runs.map((r) => ( + { + window.location.hash = `#/products/imports/runs/${r.id}`; + }} + > + + + + + + + + + ))} + +
DateFileDialectAddedUpdatedErrorsStatus
{new Date(r.created_at).toLocaleString()}{r.file_name}{r.dialect}{r.products_added}{r.products_updated}{r.rows_errored}{STATUS_LABELS[r.status] ?? r.status}
+ )} +
+
+ ); +} diff --git a/frontend/src/screens/products/RunDetail.tsx b/frontend/src/screens/products/RunDetail.tsx new file mode 100644 index 0000000..cabba61 --- /dev/null +++ b/frontend/src/screens/products/RunDetail.tsx @@ -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; +} diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index d924144..e733f58 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -5,3 +5,4 @@ @import "./tokens-typography.css"; @import "./tokens-spacing.css"; @import "./app.css"; +@import "./products.css"; diff --git a/frontend/src/styles/products.css b/frontend/src/styles/products.css new file mode 100644 index 0000000..cc6dde3 --- /dev/null +++ b/frontend/src/styles/products.css @@ -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 12–14 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; } +}