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:
@@ -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,7 +47,16 @@ 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">
|
||||
{view.view === "home" && (
|
||||
<div className="empty">
|
||||
{welcome && (
|
||||
<div style={{ marginBottom: 24, width: "100%" }}>
|
||||
@@ -53,6 +77,11 @@ export default function Admin({ storefrontName, email, welcome, onSignedOut }: P
|
||||
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;
|
||||
}
|
||||
@@ -5,3 +5,4 @@
|
||||
@import "./tokens-typography.css";
|
||||
@import "./tokens-spacing.css";
|
||||
@import "./app.css";
|
||||
@import "./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; }
|
||||
}
|
||||
Reference in New Issue
Block a user