feat(products-ui): typed products API client + admin hash routing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:25:37 -07:00
parent 4dacc2dafd
commit b5dac8886f
4 changed files with 143 additions and 1 deletions
+18
View File
@@ -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" });
});
});
+31
View File
@@ -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}`;
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ export interface VerifyResult {
created: boolean;
}
async function errorOf(resp: Response): Promise<ApiError> {
export async function errorOf(resp: Response): Promise<ApiError> {
try {
const body = await resp.json();
if (body && body.error) return body.error as ApiError;
+93
View File
@@ -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<string, unknown>; 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<string, unknown>;
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<T> = { ok: true; value: T } | { ok: false; error: ApiError; status: number };
async function request<T>(path: string, init?: RequestInit): Promise<Result<T>> {
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<Result<ProductsSummary>> {
return request("/api/products/summary");
}
export function uploadImport(file: File): Promise<Result<Draft>> {
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<Result<Draft>> {
return request(`/api/products/imports/drafts/${id}`);
}
export async function getDraftRecords(
id: number, kind?: RecordKind, limit = 100, offset = 0,
): Promise<Result<DraftRecord[]>> {
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<Result<{ run_id: number }>> {
return request(`/api/products/imports/drafts/${id}/confirm`, { method: "POST" });
}
export function cancelDraft(id: number): Promise<Result<void>> {
return request(`/api/products/imports/drafts/${id}`, { method: "DELETE" });
}
export async function listRuns(): Promise<Result<RunSummary[]>> {
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<Result<RunDetail>> {
return request(`/api/products/imports/runs/${id}`);
}