From b5dac8886ff8d5c405d65be879301212fb73acb0 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 16:25:37 -0700 Subject: [PATCH] feat(products-ui): typed products API client + admin hash routing Co-Authored-By: Claude Fable 5 --- frontend/src/adminRouting.test.ts | 18 ++++++ frontend/src/adminRouting.ts | 31 +++++++++++ frontend/src/api.ts | 2 +- frontend/src/productsApi.ts | 93 +++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 frontend/src/adminRouting.test.ts create mode 100644 frontend/src/adminRouting.ts create mode 100644 frontend/src/productsApi.ts diff --git a/frontend/src/adminRouting.test.ts b/frontend/src/adminRouting.test.ts new file mode 100644 index 0000000..76acf0d --- /dev/null +++ b/frontend/src/adminRouting.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { adminViewFor, hashFor, type AdminView } from "./adminRouting"; + +const VIEWS: AdminView[] = [ + { view: "home" }, { view: "products" }, { view: "import-upload" }, + { view: "import-preview", draftId: 7 }, { view: "run-detail", runId: 12 }, +]; + +describe("adminRouting", () => { + it("round-trips every view", () => { + for (const v of VIEWS) expect(adminViewFor(hashFor(v))).toEqual(v); + }); + it("defaults junk to home/products", () => { + expect(adminViewFor("")).toEqual({ view: "home" }); + expect(adminViewFor("#/nonsense")).toEqual({ view: "home" }); + expect(adminViewFor("#/products/imports/drafts/abc")).toEqual({ view: "products" }); + }); +}); diff --git a/frontend/src/adminRouting.ts b/frontend/src/adminRouting.ts new file mode 100644 index 0000000..adfb6ed --- /dev/null +++ b/frontend/src/adminRouting.ts @@ -0,0 +1,31 @@ +// Hash routing for admin sections (SD-0002 §5). The URL is the durable handle on a +// section (PUC-8: run detail can be left and returned to); the SD-0001 entry-routing +// rule (routing.ts) still decides whether the admin renders at all. +export type AdminView = + | { view: "home" } + | { view: "products" } + | { view: "import-upload" } + | { view: "import-preview"; draftId: number } + | { view: "run-detail"; runId: number }; + +export function adminViewFor(hash: string): AdminView { + const parts = hash.replace(/^#\/?/, "").split("/").filter(Boolean); + if (parts[0] !== "products") return { view: "home" }; + if (parts.length === 1) return { view: "products" }; + if (parts[1] === "import" && parts.length === 2) return { view: "import-upload" }; + if (parts[1] === "imports" && parts[2] === "drafts" && /^\d+$/.test(parts[3] ?? "")) + return { view: "import-preview", draftId: Number(parts[3]) }; + if (parts[1] === "imports" && parts[2] === "runs" && /^\d+$/.test(parts[3] ?? "")) + return { view: "run-detail", runId: Number(parts[3]) }; + return { view: "products" }; +} + +export function hashFor(v: AdminView): string { + switch (v.view) { + case "home": return "#/"; + case "products": return "#/products"; + case "import-upload": return "#/products/import"; + case "import-preview": return `#/products/imports/drafts/${v.draftId}`; + case "run-detail": return `#/products/imports/runs/${v.runId}`; + } +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 1e6c4f5..caf75cf 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -15,7 +15,7 @@ export interface VerifyResult { created: boolean; } -async function errorOf(resp: Response): Promise { +export async function errorOf(resp: Response): Promise { try { const body = await resp.json(); if (body && body.error) return body.error as ApiError; diff --git a/frontend/src/productsApi.ts b/frontend/src/productsApi.ts new file mode 100644 index 0000000..486ec2e --- /dev/null +++ b/frontend/src/productsApi.ts @@ -0,0 +1,93 @@ +// Typed fetch wrappers for the /api/products/* surface (SD-0002 §6.4). Same +// conventions as api.ts: same-origin, cookie session, §6.4 error envelope. +import { errorOf, type ApiError } from "./api"; + +export interface DiffSummary { adds: number; updates: number; unchanged: number; errors: number; } +export interface Draft { + id: number; file_name: string; dialect: string; + summary: DiffSummary; unknown_columns: string[]; expires_at: string; +} +export type RecordKind = "add" | "update" | "unchanged" | "error"; +export interface RowErrorDetail { line: number; column: string | null; message: string; } +export interface FieldChange { field: string; before: unknown; after: unknown; } +export interface VariantEntry { + options: (string | null)[]; kind?: string; + set?: Record; changes?: FieldChange[]; +} +export interface ImageEntry { src: string; kind?: string; position?: number; alt_text?: string | null; changes?: FieldChange[]; } +export interface DraftRecord { + handle: string; title: string; kind: RecordKind; variant_count: number; + detail: { + set?: Record; + option_names?: (string | null)[]; + changes?: FieldChange[]; + variants?: VariantEntry[]; + images?: ImageEntry[]; + errors?: RowErrorDetail[]; + }; +} +export interface RunSummary { + id: number; file_name: string; dialect: string; created_at: string; + completed_at: string | null; status: string; by: string; + products_added: number; products_updated: number; rows_errored: number; +} +export interface RunDetail extends RunSummary { + errors: RowErrorDetail[]; + image_progress: { done: number; total: number }; + image_outcomes: unknown[]; +} +export interface ProductsSummary { + product_count: number; image_problem_count: number; latest_run_id: number | null; +} + +export type Result = { ok: true; value: T } | { ok: false; error: ApiError; status: number }; + +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 }; + if (resp.status === 204) return { ok: true, value: undefined as T }; + return { ok: true, value: (await resp.json()) as T }; +} + +export function getProductsSummary(): Promise> { + return request("/api/products/summary"); +} + +export function uploadImport(file: File): Promise> { + const body = new FormData(); + body.append("file", file); + // No content-type header: the browser sets the multipart boundary. + return request("/api/products/imports", { method: "POST", body }); +} + +export function getDraft(id: number): Promise> { + return request(`/api/products/imports/drafts/${id}`); +} + +export async function getDraftRecords( + id: number, kind?: RecordKind, limit = 100, offset = 0, +): Promise> { + const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); + if (kind) params.set("kind", kind); + const resp = await request<{ records: DraftRecord[] }>( + `/api/products/imports/drafts/${id}/records?${params}`, + ); + return resp.ok ? { ok: true, value: resp.value.records } : resp; +} + +export function confirmDraft(id: number): Promise> { + return request(`/api/products/imports/drafts/${id}/confirm`, { method: "POST" }); +} + +export function cancelDraft(id: number): Promise> { + return request(`/api/products/imports/drafts/${id}`, { method: "DELETE" }); +} + +export async function listRuns(): Promise> { + const resp = await request<{ runs: RunSummary[] }>("/api/products/imports/runs"); + return resp.ok ? { ok: true, value: resp.value.runs } : resp; +} + +export function getRun(id: number): Promise> { + return request(`/api/products/imports/runs/${id}`); +}