Replaces App.tsx's hand-written Segmented/Filters-button/ActiveFilterChips region with a single FilterBar composed from five dimensions, and removes the FilterDrawer and ActiveFilterChips components along with the activeFilterCount and hasActiveFilters helpers they were the only callers of. Catalogue now receives a filtered boolean computed the same way FilterBar computes its own chip tally, rather than the ItemFilters object it only ever used for that one check.
246 lines
10 KiB
TypeScript
246 lines
10 KiB
TypeScript
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<SaleState, ItemStatus[]> = {
|
||
'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';
|
||
}
|
||
|
||
const sameSet = (a: readonly string[], b: readonly string[]) =>
|
||
a.length === b.length && [...a].sort().join() === [...b].sort().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<SaleState, ItemStatus[]>,
|
||
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<number, CategoryNode>();
|
||
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 '';
|
||
}
|