Seven reported items, of which the first four had two root causes. The active tab was invisible in dark mode because colorPrimary was hardcoded to #1a1a1a in both themes. The accent now inverts with the theme, and colorTextLightSolid inverts with it, or a near-white accent would get antd's default white label and disappear. The Category tab, Tag tab, and item-form category selector ignored the theme entirely. antd declares main: lib/index.js and module: es/index.js, so importing from 'antd' resolves to the ES build while 'antd/lib/...' loads the CommonJS one — two copies, two React contexts, and no ConfigProvider for anything deep-imported. Switching those files to antd/es/* keeps the deep-import convention and shares the instance. This was introduced by my own use of the lib path; es is correct under Vite. Two storefront components had the same latent bug. "Colour" is now "Color". The Customers tab shows how many items each customer is holding, as a link opening the item list with a Release button. Release mirrors the customer's own cart removal — drop the cart row, return the item to available, guarded on 'reserved' so it can never resurrect a sold item — and deliberately sends no email about an action the customer did not take. The count is a subquery rather than another join, which would have multiplied rows and inflated order_count and total_spent_cents. The Inventory tab filters by category, tags, price, and status, reusing the storefront's parser and query builder so the two cannot drift. Reserved is one option in a Status filter rather than a standalone toggle. Also fixes two defects the screenshots exposed: the reserved-count link bubbled to the row handler and opened the customer drawer behind the dialog, and .admin-category-node had no CSS at all, so the tree node name, item count, and actions ran together as one string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
179 lines
5.5 KiB
TypeScript
Executable File
179 lines
5.5 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(filters?: ItemFilters): Promise<Item[]> {
|
|
const query = filters ? filtersToSearchParams(filters).toString() : '';
|
|
const res = await expectOk(
|
|
await fetch(query ? `/api/admin/items?${query}` : '/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');
|
|
}
|