feat(ui): storefront filters and admin category/tag management (#23)
Storefront gains a Filters drawer holding the category tree, colour-coded tag pills, and a price range, with applied filters shown as removable chips. Filter state lives in the URL query string, so a filtered view is shareable and the back button works. Item cards now show their category and tags. Admin gains Categories and Tags tabs, and the item form gains a category TreeSelect plus a tags Select that creates new tags on the fly. The admin category tree tracks expansion in state rather than using defaultExpandAll: that prop is evaluated once at mount, so a branch added afterwards rendered collapsed and its children were unreachable. Creating or moving a node now expands its parent. Caught by the new admin e2e spec. The chip row is marked as a named group so its "Clear all" stays distinguishable from the drawer's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+96
-2
@@ -1,3 +1,12 @@
|
||||
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;
|
||||
@@ -5,6 +14,30 @@ export interface Item {
|
||||
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 {
|
||||
@@ -18,8 +51,14 @@ export async function fetchConfig(): Promise<SiteConfig> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchItems(): Promise<Item[]> {
|
||||
const res = await fetch('/api/items');
|
||||
export async function fetchItems(filters?: ItemFilters): Promise<Item[]> {
|
||||
const query = filters ? filtersToSearchParams(filters).toString() : '';
|
||||
const res = await fetch(query ? `/api/items?${query}` : '/api/items');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchFilterOptions(): Promise<FilterOptions> {
|
||||
const res = await fetch('/api/filters');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -51,3 +90,58 @@ export async function markAvailable(id: number): Promise<Item> {
|
||||
const res = await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' });
|
||||
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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user