import type { Category } from './api'; export type ItemStatus = 'pending' | 'available' | 'reserved' | 'sold'; export interface ItemFilters { // Several categories, combined as OR — picking Furniture and Decor means // either, not the empty intersection. Deliberately the opposite of tagIds // below, which is AND, and both controls label their rule so the difference // is stated rather than discovered. // // The admin's inventory filter is single-select and holds a list of one; the // type is shared, and one shape is better than two that drift. categoryIds: number[]; tagIds: number[]; minPriceCents: number | null; maxPriceCents: number | null; // Several statuses, because the control this serves is not a status filter: // "Not Sold" is available-or-reserved on the storefront and includes pending // in the admin, neither of which is one value. // // Null means "no preference", which each side turns into its own default - // Not Sold on the storefront, every status in the admin. Keeping the default // as null rather than as an explicit list is what keeps it out of the URL and // out of the active-filter count. 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; } // The three-way control both screens offer. It is a preset over the status // list rather than a filter of its own, so there is only ever one dimension and // no way to express a contradiction like "sold and not sold". export type SaleState = 'sold' | 'not-sold' | 'all'; // The same three words mean different sets in the two places, which is worth // stating twice rather than sharing one table that would be wrong for one of // them. Pending is excluded from every public read regardless of filter, so on // the storefront "All" cannot and must not include it — a label promising more // than it delivers. export const STOREFRONT_SALE_STATUSES: Record = { 'not-sold': ['available', 'reserved'], sold: ['sold'], all: ['available', 'reserved', 'sold'] }; // The admin had a table of its own here until #132, where the preset was // replaced by a multi-select of the statuses themselves. Presets could not // express Published or Unpublished and could not isolate a single status, and // the admin is where those questions get asked. The storefront keeps its // preset: pending never reaches a customer, so the distinction does not exist // for them. function isPublicStatus(value: string): value is ItemStatus { return value === 'available' || value === 'reserved' || value === 'sold'; } // Sorted with an explicit comparator rather than a bare .sort(). The default // sorts by UTF-16 code unit, which is a perfectly good total order for the // ASCII status values passed here — but the rule exists because that stops // being true the moment a non-ASCII value appears, and a set comparison that // silently depends on its inputs staying ASCII is not worth keeping. const sameSet = (a: readonly string[], b: readonly string[]) => a.length === b.length && [...a].sort((x, y) => x.localeCompare(y)).join() === [...b].sort((x, y) => x.localeCompare(y)).join(); // Which preset a status list corresponds to, for showing the control's current // position. Null means no preference, which each screen renders as its default. // A list matching none of the three - only reachable by hand-editing the URL - // reports as the default rather than leaving the control blank. export function saleStateFromStatuses( statuses: ItemStatus[] | null, table: Record, fallback: SaleState = 'not-sold' ): SaleState { if (statuses === null) return fallback; const match = (Object.keys(table) as SaleState[]).find((state) => sameSet(statuses, table[state]) ); return match ?? fallback; } export const EMPTY_FILTERS: ItemFilters = { categoryIds: [], 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(); // Comma-separated under the singular name it has always had, so a link // written before this went multi-valued still means what it meant. if (filters.categoryIds.length) params.set('category', filters.categoryIds.join(',')); 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.join(',')); 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 categories = (params.get('category') || '') .split(',') .map((part) => readInt(part)) .filter((id): id is number => id !== null && id > 0); const tags = (params.get('tags') || '') .split(',') .map((part) => readInt(part)) .filter((id): id is number => id !== null && id > 0); // Deliberately does NOT accept 'pending', even though it is a valid // ItemStatus. This reader exists for the storefront's URL, where filtering by // pending is not a thing a customer may ask for — the public API refuses it // outright, so parsing it here would only produce a request guaranteed to // fail. The admin's status filter holds its value in React state and never // round-trips through this function, so it is unaffected. Do not "complete" // this list to match the type. // // A list containing anything unreadable yields null - the default - rather // than the readable subset, so a mangled link falls back to a view that is // explainable instead of one silently narrower than it looks. const rawStatus = params.get('status'); const parsedStatus = (rawStatus || '') .split(',') .map((part) => part.trim()) .filter((part) => part !== ''); const status = parsedStatus.length > 0 && parsedStatus.every(isPublicStatus) ? (parsedStatus as ItemStatus[]) : null; const favorites = params.get('favorites'); return { categoryIds: categories, tagIds: tags, minPriceCents: readInt(params.get('min_price')), maxPriceCents: readInt(params.get('max_price')), status, favoritesOnly: favorites === '1' || favorites === 'true' }; } // Named individually rather than grouped, because grouping is what the preset // this replaced did. Pending is listed first: "what is waiting to be published" // is the question that prompted #132. // // Here rather than in the admin screen because the drawer and the active-filter // chips both need to turn a status into a label, and a second copy of this list // is a second place for a new status to be forgotten. export const STATUS_OPTIONS: { value: ItemStatus; label: string }[] = [ { value: 'pending', label: 'Pending' }, { value: 'available', label: 'Available' }, { value: 'reserved', label: 'Reserved' }, { value: 'sold', label: 'Sold' } ]; export function statusLabel(status: ItemStatus): string { return STATUS_OPTIONS.find((option) => option.value === status)?.label ?? status; } 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; } /** * A category tree in the shape antd's `TreeSelect` reads. * * Beside `buildCategoryTree` because it has the same property: one meaning, so * one implementation. It lived in both `CategoryTreeSelect.tsx` and * `FilterDrawer.tsx` verbatim after #139 copied it rather than sharing it, and * two copies of a mapping is two places for a field to be renamed. * * `value` rather than `key`: a `TreeSelect` selects and searches by value, * where an antd `Tree` identifies nodes by key. `Categories.tsx` builds a third * shape for a real `Tree`, whose title is a React node carrying that screen's * own buttons — genuinely different, and deliberately not folded in here. */ export interface CategoryTreeOption { value: number; title: string; children?: CategoryTreeOption[]; } export function toCategoryTreeData(nodes: CategoryNode[]): CategoryTreeOption[] { return nodes.map((node) => ({ value: node.id, title: node.name, children: node.children.length ? toCategoryTreeData(node.children) : undefined })); } // "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 ''; }