✨ 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
192 lines
7.6 KiB
TypeScript
192 lines
7.6 KiB
TypeScript
/**
|
||
* 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;
|
||
} |