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: [string, ...string[]] = [ '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; } // The modulo keeps this in range, but an index signature cannot say so. The // fallback is the first colour rather than a throw: a tag with an unexpected // colour is not worth failing a request over. // TAG_COLORS is typed as a non-empty tuple, so index 0 is known to exist — // the annotation, rather than `as const`, because the elements must stay // `string` for the callers that assign them. The modulo keeps the computed // index in range; the fallback only exists because indexing cannot say so. return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length] ?? TAG_COLORS[0]; } 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.'; /** * Strips trailing slashes so a base URL can be joined with a stored path. * * A loop rather than `/\/+$/`, which backtracks: sonarjs flags that pattern as * super-linear, and the input here is an environment variable rather than * anything hostile, but the cheap version is no harder to read. * * Shared because two callers now need it — `/api/config` sends * `uploadsBaseUrl` this way, and the upload-link routes build a submission URL * from PUBLIC_URL. Stored paths always begin with a slash, so trimming the * base is what stops the join producing a double. */ export function trimTrailingSlashes(value: string): string { let trimmed = value; while (trimmed.endsWith('/')) trimmed = trimmed.slice(0, -1); return trimmed; } /** * A route's `:id` as a positive integer, or null when it is not one. * * Guarding this is not cosmetic. `Number('abc')` is NaN, which the driver sends * to Postgres as the text "NaN"; Postgres raises 22P02 for an integer column, * the route's catch turns that into a 500, and a caller asking for an item that * cannot exist is told the server broke. Returning null lets the route answer * 404, which is what "/items/abc" actually means. See #207. * * Rejects 0 and negatives as well as fractions: every id in this schema is a * positive serial, so anything else identifies nothing. */ export function readId(value: string | undefined): number | null { if (value === undefined || value.trim() === '') return null; const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : null; }