/** * Currency utilities — single source of truth for formatting money in WooNooW. * * Goals: * - Prefer WooCommerce symbol when available (e.g., "Rp", "$", "RM"). * - Fall back to ISO currency code using Intl if no symbol given. * - Reasonable default decimals (0 for common zero‑decimal currencies like IDR/JPY/KRW/VND). * - Allow overrides per call (locale/decimals/symbol usage). */ export type MoneyInput = number | string | null | undefined; export type MoneyOptions = { /** ISO code like 'IDR', 'USD', 'MYR' */ currency?: string; /** Symbol like 'Rp', '$', 'RM' */ symbol?: string | null; /** Force number of fraction digits (Woo setting); if omitted, use heuristic or store */ decimals?: number; /** Locale passed to Intl; if omitted, browser default */ locale?: string; /** When true (default), use symbol if provided; otherwise always use Intl currency code */ preferSymbol?: boolean; /** Woo thousand separator (e.g., '.' for IDR) */ thousandSep?: string; /** Woo decimal separator (e.g., ',' for IDR) */ decimalSep?: string; /** Woo currency position: 'left' | 'right' | 'left_space' | 'right_space' */ position?: 'left' | 'right' | 'left_space' | 'right_space'; }; /** * Known zero‑decimal currencies across common stores. * (WooCommerce may also set decimals=0 per store; pass `decimals` to override.) */ export const ZERO_DECIMAL_CURRENCIES = new Set([ 'BIF','CLP','DJF','GNF','ISK','JPY','KMF','KRW','PYG','RWF','UGX','VND','VUV','XAF','XOF','XPF', // widely used as 0‑decimal in Woo stores 'IDR','MYR' ]); /** Resolve desired decimals. */ export function resolveDecimals(currency?: string, override?: number): number { if (typeof override === 'number' && override >= 0) return override; if (!currency) return 0; return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2; } /** Resolve the best display token (symbol over code). */ export function resolveDisplayToken(opts: MoneyOptions): string | undefined { const token = (opts.preferSymbol ?? true) ? (opts.symbol || undefined) : undefined; return token || opts.currency; } function formatWithSeparators(num: number, decimals: number, thousandSep: string, decimalSep: string) { const fixed = (decimals >= 0 ? num.toFixed(decimals) : String(num)); const [intRaw, frac = ''] = fixed.split('.'); const intPart = intRaw.replace(/\B(?=(\d{3})+(?!\d))/g, thousandSep); return decimals > 0 ? `${intPart}${decimalSep}${frac}` : intPart; } export function formatMoney(value: MoneyInput, opts: MoneyOptions = {}): string { if (value === null || value === undefined || value === '') return '—'; const num = typeof value === 'string' ? Number(value) : value; if (!isFinite(num as number)) return '—'; const store = getStoreCurrency(); const currency = opts.currency || store.currency || 'USD'; const decimals = resolveDecimals(currency, opts.decimals ?? (typeof store.decimals === 'number' ? store.decimals : Number(store.decimals))); const thousandSep = opts.thousandSep ?? store.thousand_sep ?? ','; const decimalSep = opts.decimalSep ?? store.decimal_sep ?? '.'; const position = (opts.position ?? (store as any).position ?? (store as any).currency_pos ?? 'left') as 'left' | 'right' | 'left_space' | 'right_space'; const symbol = (opts.symbol ?? store.symbol) as string | undefined; const preferSymbol = opts.preferSymbol !== false; if (preferSymbol && symbol) { const n = formatWithSeparators(num as number, decimals, thousandSep, decimalSep); switch (position) { case 'left': return `${symbol}${n}`; case 'left_space': return `${symbol} ${n}`; case 'right': return `${n}${symbol}`; case 'right_space': return `${n} ${symbol}`; default: return `${symbol}${n}`; } } try { return new Intl.NumberFormat(opts.locale, { style: 'currency', currency, minimumFractionDigits: decimals, maximumFractionDigits: decimals, }).format(num as number); } catch { const n = formatWithSeparators(num as number, decimals, thousandSep, decimalSep); return `${currency} ${n}`; } } export function makeMoneyFormatter(opts: MoneyOptions) { const store = getStoreCurrency(); const currency = opts.currency || store.currency || 'USD'; const decimals = resolveDecimals(currency, opts.decimals ?? (typeof store.decimals === 'number' ? store.decimals : Number(store.decimals))); const thousandSep = opts.thousandSep ?? store.thousand_sep ?? ','; const decimalSep = opts.decimalSep ?? store.decimal_sep ?? '.'; const position = (opts.position ?? (store as any).position ?? (store as any).currency_pos ?? 'left') as 'left' | 'right' | 'left_space' | 'right_space'; const symbol = (opts.symbol ?? store.symbol) as string | undefined; const preferSymbol = opts.preferSymbol !== false && !!symbol; if (preferSymbol) { return (v: MoneyInput) => { if (v === null || v === undefined || v === '') return '—'; const num = typeof v === 'string' ? Number(v) : v; if (!isFinite(num as number)) return '—'; const n = formatWithSeparators(num as number, decimals, thousandSep, decimalSep); switch (position) { case 'left': return `${symbol}${n}`; case 'left_space': return `${symbol} ${n}`; case 'right': return `${n}${symbol}`; case 'right_space': return `${n} ${symbol}`; default: return `${symbol}${n}`; } }; } let intl: Intl.NumberFormat | null = null; try { intl = new Intl.NumberFormat(opts.locale, { style: 'currency', currency, minimumFractionDigits: decimals, maximumFractionDigits: decimals, }); } catch { intl = null; } return (v: MoneyInput) => { if (v === null || v === undefined || v === '') return '—'; const num = typeof v === 'string' ? Number(v) : v; if (!isFinite(num as number)) return '—'; if (intl) return intl.format(num as number); const n = formatWithSeparators(num as number, decimals, thousandSep, decimalSep); return `${currency} ${n}`; }; } /** * Convenience hook wrapper for React components (optional import). * Use inside components to avoid repeating memo logic. */ export function useMoneyFormatter(opts: MoneyOptions) { // Note: file lives in /lib so we keep dependency-free; simple memo by JSON key is fine. const key = JSON.stringify({ c: opts.currency, s: opts.symbol, d: resolveDecimals(opts.currency, opts.decimals), l: opts.locale, p: opts.preferSymbol !== false, ts: opts.thousandSep, ds: opts.decimalSep, pos: opts.position, }); const ref = (globalThis as any).__wnw_money_cache || ((globalThis as any).__wnw_money_cache = new Map()); if (!ref.has(key)) ref.set(key, makeMoneyFormatter(opts)); return ref.get(key) as (v: MoneyInput) => string; } /** * Read global WooCommerce store currency data provided via window.WNW_STORE. * Returns normalized currency, symbol, and decimals for consistent usage. */ export function getStoreCurrency() { const store = (window as any).WNW_STORE || (window as any).WNW_META || {}; const decimals = typeof store.decimals === 'number' ? store.decimals : Number(store.decimals); const position = (store.currency_pos || store.currency_position || 'left') as 'left' | 'right' | 'left_space' | 'right_space'; const result = { currency: store.currency || 'USD', symbol: store.currency_symbol || '$', decimals: Number.isFinite(decimals) ? decimals : 2, thousand_sep: store.thousand_sep || ',', decimal_sep: store.decimal_sep || '.', position, }; // Debug log in dev mode if ((window as any).wnw?.isDev && !((window as any).__wnw_currency_logged)) { (window as any).__wnw_currency_logged = true; } return result; }