Adds a self-referencing categories tree, a tag registry with deterministic colours, and item_tags, plus admin CRUD for both. GET /api/items now accepts category, tags, min_price and max_price. Category matching walks the subtree with a recursive CTE so selecting a parent includes everything filed beneath it; tags match with AND via a count check, since ANY() alone would return items carrying only one of them. Malformed filter params return 400 rather than being ignored, so a broken link doesn't quietly list the whole catalogue. GET /api/filters serves the drawer its tree, tags, and price bounds in one request. Item image/tag aggregation moves from LEFT JOIN + GROUP BY to scalar subqueries. Joining two one-to-many relations multiplies their rows, so an item with 2 images and 3 tags would have repeated every image three times once tags were added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
69 lines
2.6 KiB
TypeScript
Executable File
69 lines
2.6 KiB
TypeScript
Executable File
export function toCents(price: string | number): number {
|
|
const n = typeof price === 'string' ? parseFloat(price) : price;
|
|
if (Number.isNaN(n) || n < 0) {
|
|
throw new Error('invalid price');
|
|
}
|
|
return Math.round(n * 100);
|
|
}
|
|
|
|
export function formatPrice(cents: number): string {
|
|
return `$${(cents / 100).toFixed(2)}`;
|
|
}
|
|
|
|
// RFC 5321 caps an address at 254 characters; reject anything longer up front so
|
|
// validation cost stays bounded regardless of what a client posts.
|
|
const MAX_EMAIL_LENGTH = 254;
|
|
|
|
// Both patterns are anchored single character classes with no overlapping
|
|
// alternatives, so they match in linear time. Splitting on '@' and '.' in code
|
|
// rather than in one combined pattern avoids the ambiguous (and backtracking)
|
|
// `[^\s@]+\.[^\s@]+` domain match.
|
|
const LOCAL_PART_RE = /^[^\s@]+$/;
|
|
const DOMAIN_LABEL_RE = /^[^\s@.]+$/;
|
|
|
|
export function isValidEmail(email: string): boolean {
|
|
const trimmed = email.trim();
|
|
if (trimmed.length === 0 || trimmed.length > MAX_EMAIL_LENGTH) {
|
|
return false;
|
|
}
|
|
|
|
const at = trimmed.indexOf('@');
|
|
if (at === -1 || at !== trimmed.lastIndexOf('@')) {
|
|
return false;
|
|
}
|
|
|
|
if (!LOCAL_PART_RE.test(trimmed.slice(0, at))) {
|
|
return false;
|
|
}
|
|
|
|
const labels = trimmed.slice(at + 1).split('.');
|
|
return labels.length >= 2 && labels.every((label) => DOMAIN_LABEL_RE.test(label));
|
|
}
|
|
|
|
// antd's preset Tag colours. Kept as the single source of truth for tag
|
|
// colours so the admin palette picker and the auto-assignment below can never
|
|
// drift apart — the frontend renders whatever string lands in tags.color.
|
|
export const TAG_COLORS = [
|
|
'magenta', 'red', 'volcano', 'orange', 'gold', 'lime',
|
|
'green', 'cyan', 'blue', 'geekblue', 'purple'
|
|
];
|
|
|
|
// Tags get a colour the moment they're created inline from the item form, with
|
|
// no prompt. Deriving it from the name (rather than picking at random or
|
|
// round-robining on insert order) means the same tag name always lands on the
|
|
// same colour, so a tag deleted and re-added doesn't silently change colour.
|
|
// The admin can still override it afterwards.
|
|
export function tagColorFor(name: string): string {
|
|
const normalized = name.trim().toLowerCase();
|
|
// djb2 — cheap, well-spread for short strings, and stable across Node
|
|
// versions. `| 0` keeps it in int32 range instead of drifting into float.
|
|
let hash = 5381;
|
|
for (let i = 0; i < normalized.length; i++) {
|
|
hash = ((hash << 5) + hash + normalized.charCodeAt(i)) | 0;
|
|
}
|
|
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length];
|
|
}
|
|
|
|
export const MARKETING_CONSENT_TEXT =
|
|
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|