Files
redefined-designs/frontend/src/api.ts
T
bermudalambandClaude Opus 5 77e58c0b92 fix(admin): confirm writes succeeded and allow inline category creation (#23)
Reported from testing: the item form said "Item added" for a save that
never happened.

saveItem and the other admin calls returned res.json() without checking
res.ok, so a 4xx/5xx resolved normally and every caller reported success
for a write the server had rejected. That is worse than failing outright,
because nothing prompts the user to look for the missing row. All admin
calls now throw on a non-OK response, and the handlers report the error,
keep the form open so entered values survive, and only claim success once
the server has accepted the write. Mark sold/available previously did
nothing visible on failure at all.

Categories can now be created from the item form, as tags already could.
Previously a category that did not exist yet meant abandoning a
half-filled form for the Categories tab. New categories are created at
the top level; nesting stays in the Categories tab.

The control lives in its own component: inline, every keystroke
re-rendered the whole Inventory component and rebuilt the category tree,
which visibly jittered the open popup. It sits above the tree rather than
below it, where a long list both hid it and made its position depend on
the list's measured height. The tree no longer expands everything on
open, which does not scale past a screenful; it has search instead.

The app now honours prefers-reduced-motion by disabling antd transitions,
and the e2e suite runs with that preference set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 11:07:44 -05:00

175 lines
5.4 KiB
TypeScript
Executable File

import type { ItemFilters } from './filters';
import { filtersToSearchParams } from './filters';
export interface ItemTag {
id: number;
name: string;
color: string;
}
export interface Item {
id: number;
name: string;
description: string | null;
price_cents: number;
images: { id: number; image_path: string; sort_order: number }[];
status: 'available' | 'reserved' | 'sold';
category_id: number | null;
category_name: string | null;
tags: ItemTag[];
}
export interface Category {
id: number;
name: string;
parent_id: number | null;
sort_order: number;
item_count: number;
}
export interface Tag {
id: number;
name: string;
color: string;
item_count: number;
}
export interface FilterOptions {
categories: Category[];
tags: Tag[];
priceRange: { min_cents: number; max_cents: number };
}
export interface SiteConfig {
paypalClientId: string | null;
demoMode: boolean;
currency: string;
}
export async function fetchConfig(): Promise<SiteConfig> {
const res = await fetch('/api/config');
return res.json();
}
export async function fetchItems(filters?: ItemFilters): Promise<Item[]> {
const query = filters ? filtersToSearchParams(filters).toString() : '';
const res = await fetch(query ? `/api/items?${query}` : '/api/items');
// An error response still parses as JSON — as `{ error: ... }`, not an array.
// Returning that unchecked would set it as the item list and crash the grid
// on `.map`, so a failure has to surface as a rejection the caller can show.
if (!res.ok) throw new Error('failed to load items');
return res.json();
}
export async function fetchFilterOptions(): Promise<FilterOptions> {
const res = await fetch('/api/filters');
if (!res.ok) throw new Error('failed to load filters');
return res.json();
}
// Every admin call goes through this. Without the res.ok check a 4xx/5xx still
// resolves — the caller then reports success for a write that never happened,
// which is worse than failing outright because nothing prompts the user to look
// for the missing row.
async function expectOk(res: Response, action: string): Promise<Response> {
if (res.ok) return res;
const detail = await res.json().catch(() => null);
throw new Error(detail?.error ? `${action}: ${detail.error}` : action);
}
export async function fetchAdminItems(): Promise<Item[]> {
const res = await expectOk(await fetch('/api/admin/items'), 'failed to load items');
return res.json();
}
export async function saveItem(id: number | null, formData: FormData): Promise<Item> {
const url = id ? `/api/admin/items/${id}` : '/api/admin/items';
const res = await expectOk(
await fetch(url, { method: id ? 'PUT' : 'POST', body: formData }),
'failed to save item'
);
return res.json();
}
export async function deleteItem(id: number): Promise<void> {
await expectOk(await fetch(`/api/admin/items/${id}`, { method: 'DELETE' }), 'failed to delete item');
}
export async function deleteItemImage(itemId: number, imageId: number): Promise<void> {
await expectOk(
await fetch(`/api/admin/items/${itemId}/images/${imageId}`, { method: 'DELETE' }),
'failed to remove image'
);
}
export async function markSold(id: number): Promise<Item> {
const res = await expectOk(
await fetch(`/api/admin/items/${id}/mark-sold`, { method: 'POST' }),
'failed to mark sold'
);
return res.json();
}
export async function markAvailable(id: number): Promise<Item> {
const res = await expectOk(
await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' }),
'failed to mark available'
);
return res.json();
}
// The admin endpoints return a JSON error body on 4xx; surfacing its message
// lets the UI say "that name is already used here" instead of a generic
// failure.
async function sendJson<T>(url: string, method: string, body?: unknown): Promise<T> {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body)
});
if (!res.ok) {
const detail = await res.json().catch(() => ({ error: 'request failed' }));
throw new Error(detail.error || 'request failed');
}
return res.status === 204 ? (undefined as T) : res.json();
}
export async function fetchAdminCategories(): Promise<Category[]> {
const res = await fetch('/api/admin/categories');
return res.json();
}
export function createCategory(name: string, parentId: number | null): Promise<Category> {
return sendJson('/api/admin/categories', 'POST', { name, parent_id: parentId });
}
export function updateCategory(
id: number,
changes: { name?: string; parent_id?: number | null }
): Promise<Category> {
return sendJson(`/api/admin/categories/${id}`, 'PUT', changes);
}
export function deleteCategory(
id: number
): Promise<{ deleted_categories: number; uncategorized_items: number }> {
return sendJson(`/api/admin/categories/${id}`, 'DELETE');
}
export async function fetchAdminTags(): Promise<Tag[]> {
const res = await fetch('/api/admin/tags');
return res.json();
}
export function createTag(name: string): Promise<Tag> {
return sendJson('/api/admin/tags', 'POST', { name });
}
export function updateTag(id: number, changes: { name?: string; color?: string }): Promise<Tag> {
return sendJson(`/api/admin/tags/${id}`, 'PUT', changes);
}
export function deleteTag(id: number): Promise<void> {
return sendJson(`/api/admin/tags/${id}`, 'DELETE');
}