Files
redefined-designs/frontend/src/filters.ts
T
bermudalamb d4e2abe743 feat: filter the storefront by favorited items (#35)
Adds favorites as another dimension of the existing storefront filter rather than a separate view, so it lives in the URL, shows up as a removable chip, and combines with category, tags, and price by AND like everything else. A customer can ask for "my favorites under $500 in Furniture" instead of only "my favorites".

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 back to look at after being emailed about it in #34. Hiding them would make items disappear from a list the customer curated themselves. Anyone wanting only what they can still buy can combine the toggle with the status filter.

Which customer "my favorites" means comes from the session, never from the query string, so a hand-edited URL cannot name someone else's favorites. A signed-out visitor sees the toggle and gets the same inline register/login prompt the heart button and Add to Cart already use; signing in resolves the gate and the filter applies on its own. A bookmarked favorites link whose session has expired says so rather than rendering an empty grid, which would tell the visitor they have no favorites instead of that we do not know who they are. The API answers 401 for the same reason, and the admin inventory refuses the filter outright rather than ignoring it.

The shared SQL builder now requires callers to say whose favorites they mean, even when that is nobody, and throws instead of dropping the clause — a future caller that forgets the guard fails loudly rather than quietly returning the whole catalogue.

Verified with 59 unit tests, 134 backend integration tests, and 70 end-to-end tests, all passing, with type checking clean on both sides.
2026-08-18 15:10:45 -05:00

135 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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;
}
// "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 '';
}