Three of the four candidates were real. The fourth was my mistake in the issue. **The category tree adapter**, duplicated verbatim between `CategoryTreeSelect.tsx` and `FilterDrawer.tsx`. This one was mine: #139 moved the storefront filter to a `TreeSelect` and copied the admin's adapter rather than sharing it, with a comment saying the shape "matches the admin's CategoryTreeSelect so the two stay comparable" — an argument for one implementation that instead produced two. It now lives in `filters.ts` beside `buildCategoryTree`, which was already shared for exactly the same reason: one meaning, one implementation. `Categories.tsx` keeps its own. It builds a different shape for a real antd `Tree`, keyed rather than valued, with a title that is a React node carrying that screen's buttons. Genuinely different, and folding it in would mean a parameterised adapter that serves neither case clearly. **The `item_images` insert loop**, written separately by create and update and differing only in where the id came from and where the sort order started. Both are parameters now, which also means the `/uploads/` prefix is written once — #103 made that the value `uploadUrl` joins an origin onto, so it is a contract rather than a string. Extracting it turned up two things the inline versions hid. Create indexed `files[i]?.filename ?? ''`, so a missing element would have stored a path pointing at the uploads directory itself; iterating by entry removes the possibility rather than defending against it. And the helper's typed `itemId` surfaced that `req.params.id` is `string | undefined` under `noUncheckedIndexedAccess`, which the old inline `unknown[]` swallowed — now `Number()`, as the `setItemTags` call two lines above already did. **The optional-field guards**, eight identical lines opening both routes. The distinction worth preserving is that `undefined` means "not submitted", which update reads as "leave as-is", so an unparseable value has to be told apart from an absent one. That is what makes it more than a null check and worth stating once. **`TAG_COLORS` was not a duplication.** The issue listed four files on the strength of a grep that also matched `STATUS_TAG_COLORS` in `Admin.tsx` — a status-to-colour map for the inventory table, unrelated to the tag palette. What remains is one definition in `backend/src/utils.ts` and one mirror in `frontend/src/admin/Tags.tsx`, already carrying a comment pointing at the other, which is the same treatment `ALLOWED_IMAGE_TYPES` gets and is correct: there is no shared package, and creating one for a colour list would cost more than it saves. Verified beyond the type checker, since three of these are pure moves that compile either way: 278 unit and 254 integration tests, and the end-to-end specs covering both consumers of the shared adapter — the storefront drawer and the admin item form's category picker, including inline category creation. Closes #182
271 lines
11 KiB
TypeScript
271 lines
11 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'
|
||
};
|
||
}
|
||
|
||
// 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.
|
||
//
|
||
// Status is deliberately not counted. It has its own always-visible control
|
||
// beside this button rather than living in the drawer, so counting it would put
|
||
// a number on a button whose drawer shows nothing set — and the control already
|
||
// displays its own position.
|
||
// 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 function activeFilterCount(filters: ItemFilters): number {
|
||
let count = 0;
|
||
count += filters.categoryIds.length;
|
||
count += filters.tagIds.length;
|
||
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++;
|
||
if (filters.favoritesOnly) count++;
|
||
return count;
|
||
}
|
||
|
||
// Broader than the count above, and intentionally so: this decides whether an
|
||
// empty result reads as "no items match these filters" with a way out, or as an
|
||
// empty shop. A status filter that matched nothing is exactly the case where
|
||
// that distinction matters, so it counts here even though it is not in the
|
||
// drawer's tally.
|
||
export function hasActiveFilters(filters: ItemFilters): boolean {
|
||
return activeFilterCount(filters) > 0 || filters.status !== null;
|
||
}
|
||
|
||
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 '';
|
||
}
|