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: 'pending' | '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 { const res = await fetch('/api/config'); return res.json(); } export async function fetchItems(filters?: ItemFilters): Promise { 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 { 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 { 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 { 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 { 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 { await expectOk(await fetch(`/api/admin/items/${id}`, { method: 'DELETE' }), 'failed to delete item'); } export async function deleteItemImage(itemId: number, imageId: number): Promise { await expectOk( await fetch(`/api/admin/items/${itemId}/images/${imageId}`, { method: 'DELETE' }), 'failed to remove image' ); } export async function markSold(id: number): Promise { const res = await expectOk( await fetch(`/api/admin/items/${id}/mark-sold`, { method: 'POST' }), 'failed to mark sold' ); return res.json(); } // Publishing a pending item is mark-available: it is the same transition and // the same UPDATE, so the admin UI simply labels the button "Publish" when the // item is pending rather than calling a second endpoint that does the same // thing. export async function markAvailable(id: number): Promise { const res = await expectOk( await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' }), 'failed to mark available' ); return res.json(); } // Not symmetrical with the above: the server refuses to unpublish a reserved or // sold item and says which, so the message it returns is worth surfacing rather // than replacing with a generic one. export async function unpublishItem(id: number): Promise { const res = await expectOk( await fetch(`/api/admin/items/${id}/unpublish`, { method: 'POST' }), 'failed to unpublish' ); 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(url: string, method: string, body?: unknown): Promise { 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 { const res = await fetch('/api/admin/categories'); return res.json(); } export function createCategory(name: string, parentId: number | null): Promise { return sendJson('/api/admin/categories', 'POST', { name, parent_id: parentId }); } export function updateCategory( id: number, changes: { name?: string; parent_id?: number | null } ): Promise { 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 { const res = await fetch('/api/admin/tags'); return res.json(); } export function createTag(name: string): Promise { return sendJson('/api/admin/tags', 'POST', { name }); } export function updateTag(id: number, changes: { name?: string; color?: string }): Promise { return sendJson(`/api/admin/tags/${id}`, 'PUT', changes); } export function deleteTag(id: number): Promise { return sendJson(`/api/admin/tags/${id}`, 'DELETE'); }