TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1/**2 * Currency handling. Prices are stored as integer minor units? No — collectibles span3 * cents to eight figures and many sources report fractional amounts; we use NUMERIC(18,4)4 * in the database and JS numbers at the edges. Never mix currencies without an FX row.5 */6export const SUPPORTED_CURRENCIES = [7 'USD', 'CAD', 'EUR', 'GBP', 'JPY', 'CHF', 'AUD', 'HKD', 'SGD', 'CNY', 'KRW', 'SEK', 'NOK', 'DKK', 'NZD', 'MXN', 'BRL', 'INR', 'PLN', 'CZK', 'TWD', 'THB', 'AED',8] as const;9export type CurrencyCode = (typeof SUPPORTED_CURRENCIES)[number];1011export const DISPLAY_CURRENCIES: CurrencyCode[] = ['USD', 'CAD', 'EUR', 'GBP', 'JPY'];1213const SYMBOL_MAP: Array<[RegExp, CurrencyCode]> = [14 [/\bUS\s?\$|\bUSD\b|U\.S\.\s?\$/i, 'USD'],15 [/\bCA\s?\$|\bC\$|\bCAD\b|CDN\s?\$/i, 'CAD'],16 [/\bAU\s?\$|\bA\$|\bAUD\b/i, 'AUD'],17 [/\bNZ\s?\$|\bNZD\b/i, 'NZD'],18 [/\bHK\s?\$|\bHKD\b/i, 'HKD'],19 [/\bS\$|\bSGD\b/i, 'SGD'],20 [/\bMX\s?\$|\bMXN\b/i, 'MXN'],21 [/€|\bEUR\b/i, 'EUR'],22 [/£|\bGBP\b/i, 'GBP'],23 [/¥|¥|\bJPY\b/i, 'JPY'],24 [/\bCHF\b|\bFr\.\s?\d/i, 'CHF'],25 [/\bCNY\b|\bRMB\b/i, 'CNY'],26 [/₩|\bKRW\b/i, 'KRW'],27 [/\bSEK\b|\bkr\b/i, 'SEK'],28 [/\bNOK\b/i, 'NOK'],29 [/\bDKK\b/i, 'DKK'],30 [/\bAED\b/i, 'AED'],31 [/\bINR\b|₹/i, 'INR'],32 [/\bBRL\b|R\$/i, 'BRL'],33 [/\bPLN\b|zł/i, 'PLN'],34 [/\bCZK\b|Kč/i, 'CZK'],35 [/\bTWD\b|NT\$/i, 'TWD'],36 [/\bTHB\b|฿/i, 'THB'],37];3839export interface ParsedPrice {40 amount: number;41 currency: CurrencyCode | null;42 /** 0–1 confidence that the parse is correct */43 confidence: number;44}4546/**47 * Parse a human-formatted price. Handles "$1,234.56", "1.234,56 €", "£12", "¥15,000", "US $2,500.00",48 * "CA$1 200,00", "1 234,56 EUR", "2.500" (ambiguous → treated as thousands separator when 3 trailing digits).49 * Returns null when no number is found. Never guesses a currency when none is present50 * (caller supplies `defaultCurrency` if the source is single-currency).51 */52export function parsePrice(raw: string | null | undefined, defaultCurrency?: CurrencyCode): ParsedPrice | null {53 if (!raw) return null;54 const text = raw.replace(/ /g, ' ').trim();55 if (!text) return null;5657 let currency: CurrencyCode | null = null;58 for (const [re, code] of SYMBOL_MAP) {59 if (re.test(text)) {60 currency = code;61 break;62 }63 }64 // Bare "$" defaults to USD only when caller allows via defaultCurrency or nothing else matched.65 if (!currency && /\$/.test(text)) currency = defaultCurrency ?? 'USD';66 if (!currency) currency = defaultCurrency ?? null;6768 const m = text.match(/-?\d[\d\s.,']*\d|\d/);69 if (!m) return null;70 let num = m[0].replace(/[\s']/g, '');71 const negative = num.startsWith('-');72 num = num.replace('-', '');7374 const lastComma = num.lastIndexOf(',');75 const lastDot = num.lastIndexOf('.');76 let confidence = 0.95;77 if (lastComma >= 0 && lastDot >= 0) {78 // Whichever separator is last is the decimal separator.79 if (lastComma > lastDot) num = num.replace(/\./g, '').replace(',', '.');80 else num = num.replace(/,/g, '');81 } else if (lastComma >= 0) {82 const tail = num.length - lastComma - 1;83 if (tail === 3 && (num.match(/,/g)?.length ?? 0) >= 1 && !/^\d{1,3},\d{3}$/.test(num)) {84 num = num.replace(/,/g, '');85 } else if (tail === 3) {86 // "2,500" → ambiguous; thousands in EN sources, decimal in some EU sources. Prefer thousands.87 num = num.replace(/,/g, '');88 confidence = 0.8;89 } else {90 num = num.replace(',', '.');91 }92 } else if (lastDot >= 0) {93 const tail = num.length - lastDot - 1;94 const dots = num.match(/\./g)?.length ?? 0;95 if (dots > 1 || (tail === 3 && currency === 'EUR')) {96 num = num.replace(/\./g, '');97 confidence = dots > 1 ? 0.9 : 0.7;98 }99 }100 const amount = Number.parseFloat(num);101 if (!Number.isFinite(amount)) return null;102 if (currency === 'JPY' || currency === 'KRW') confidence = Math.min(confidence, 0.9);103 return { amount: negative ? -amount : amount, currency, confidence };104}105106export function formatMoney(amount: number | null | undefined, currency: CurrencyCode = 'USD', opts: { compact?: boolean; maximumFractionDigits?: number } = {}): string {107 if (amount === null || amount === undefined || !Number.isFinite(amount)) return '—';108 const zeroDecimal = currency === 'JPY' || currency === 'KRW';109 const fmt = new Intl.NumberFormat('en-US', {110 style: 'currency',111 currency,112 notation: opts.compact ? 'compact' : 'standard',113 maximumFractionDigits: opts.maximumFractionDigits ?? (zeroDecimal ? 0 : amount >= 1000 ? 0 : 2),114 minimumFractionDigits: zeroDecimal || amount >= 1000 || opts.compact ? 0 : 2,115 });116 return fmt.format(amount);117}118119export function formatPct(value: number | null | undefined, digits = 2): string {120 if (value === null || value === undefined || !Number.isFinite(value)) return '—';121 const sign = value > 0 ? '+' : '';122 return `${sign}${(value * 100).toFixed(digits)}%`;123}124