import type { Category } from './api'; export type ItemStatus = 'available' | 'reserved' | 'sold'; export interface ItemFilters { categoryId: number | null; tagIds: number[]; minPriceCents: number | null; maxPriceCents: number | null; // Only the admin Inventory tab sets this; the storefront leaves it null and // shows every status, as it always has. status: ItemStatus | null; // Storefront only, and only meaningful when signed in. Sold favorites are // included: the storefront shows sold items everywhere else, and a favorite // that has just sold is often exactly what the customer came to look at. favoritesOnly: boolean; } export const EMPTY_FILTERS: ItemFilters = { categoryId: null, tagIds: [], minPriceCents: null, maxPriceCents: null, status: null, favoritesOnly: false }; // Filters live in the URL so a filtered view can be linked, bookmarked, and // walked back through with the browser's back button. The param names match // what GET /api/items accepts, so the same object serializes for both. export function filtersToSearchParams(filters: ItemFilters): URLSearchParams { const params = new URLSearchParams(); if (filters.categoryId !== null) params.set('category', String(filters.categoryId)); if (filters.tagIds.length) params.set('tags', filters.tagIds.join(',')); if (filters.minPriceCents !== null) params.set('min_price', String(filters.minPriceCents)); if (filters.maxPriceCents !== null) params.set('max_price', String(filters.maxPriceCents)); if (filters.status !== null) params.set('status', filters.status); if (filters.favoritesOnly) params.set('favorites', '1'); return params; } function readInt(raw: string | null): number | null { if (raw === null || raw.trim() === '') return null; const parsed = Number(raw); return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null; } export function filtersFromSearchParams(params: URLSearchParams): ItemFilters { const tags = (params.get('tags') || '') .split(',') .map((part) => readInt(part)) .filter((id): id is number => id !== null && id > 0); const rawStatus = params.get('status'); const status = rawStatus === 'available' || rawStatus === 'reserved' || rawStatus === 'sold' ? rawStatus : null; const favorites = params.get('favorites'); return { categoryId: readInt(params.get('category')), tagIds: tags, minPriceCents: readInt(params.get('min_price')), maxPriceCents: readInt(params.get('max_price')), status, favoritesOnly: favorites === '1' || favorites === 'true' }; } // One count for the "Filters (N)" button. A price range counts once however // many ends are set, since it reads as a single filter to the user. export function activeFilterCount(filters: ItemFilters): number { let count = 0; if (filters.categoryId !== null) count++; count += filters.tagIds.length; if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++; if (filters.status !== null) count++; if (filters.favoritesOnly) count++; return count; } export function hasActiveFilters(filters: ItemFilters): boolean { return activeFilterCount(filters) > 0; } export interface CategoryNode extends Category { children: CategoryNode[]; } // The API returns categories flat; the tree is rebuilt here so the drawer and // the admin tab share one nesting implementation. export function buildCategoryTree(categories: Category[]): CategoryNode[] { const byId = new Map(); for (const category of categories) { byId.set(category.id, { ...category, children: [] }); } const roots: CategoryNode[] = []; for (const node of byId.values()) { const parent = node.parent_id === null ? undefined : byId.get(node.parent_id); // A node whose parent is missing is treated as a root rather than dropped, // so nothing can silently disappear from the tree. if (parent) { parent.children.push(node); } else { roots.push(node); } } return roots; } // "Furniture / Tables / Coffee Tables" — used on chips and in the admin form so // a leaf name like "Vintage" isn't ambiguous between branches. export function categoryPath(categories: Category[], id: number): string { const byId = new Map(categories.map((category) => [category.id, category])); const parts: string[] = []; let current = byId.get(id); while (current) { parts.unshift(current.name); current = current.parent_id === null ? undefined : byId.get(current.parent_id); // Guards against a cycle that somehow reached the client. if (parts.length > 32) break; } return parts.join(' / '); } export function formatPriceRange(minCents: number | null, maxCents: number | null): string { const dollars = (cents: number) => `$${(cents / 100).toFixed(0)}`; if (minCents !== null && maxCents !== null) return `${dollars(minCents)}–${dollars(maxCents)}`; if (minCents !== null) return `${dollars(minCents)}+`; if (maxCents !== null) return `Up to ${dollars(maxCents)}`; return ''; }