Files
WooNooW/admin-spa/src/lib/currency.ts
dwindown e49a0d1e3d feat: Implement Phase 1 Shopify-inspired settings (Store, Payments, Shipping)
 Features:
- Store Details page with live currency preview
- Payments page with visual provider cards and test mode
- Shipping & Delivery page with zone cards and local pickup
- Shared components: SettingsLayout, SettingsCard, SettingsSection, ToggleField

🎨 UI/UX:
- Card-based layouts (not boring forms)
- Generous whitespace and visual hierarchy
- Toast notifications using sonner (reused from Orders)
- Sticky save button at top
- Mobile-responsive design

🔧 Technical:
- Installed ESLint with TypeScript support
- Fixed all lint errors (0 errors)
- Phase 1 files have zero warnings
- Used existing toast from sonner (not reinvented)
- Updated routes in App.tsx

📝 Files Created:
- Store.tsx (currency preview, address, timezone)
- Payments.tsx (provider cards, manual methods)
- Shipping.tsx (zone cards, rates, local pickup)
- SettingsLayout.tsx, SettingsCard.tsx, SettingsSection.tsx, ToggleField.tsx

Phase 1 complete: 18-24 hours estimated work
2025-11-05 18:54:41 +07:00

192 lines
7.6 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.

/**
* 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 zerodecimal 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 zerodecimal 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 0decimal 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;
}