feat: Complete Dashboard API Integration with Analytics Controller

 Features:
- Implemented API integration for all 7 dashboard pages
- Added Analytics REST API controller with 7 endpoints
- Full loading and error states with retry functionality
- Seamless dummy data toggle for development

📊 Dashboard Pages:
- Customers Analytics (complete)
- Revenue Analytics (complete)
- Orders Analytics (complete)
- Products Analytics (complete)
- Coupons Analytics (complete)
- Taxes Analytics (complete)
- Dashboard Overview (complete)

🔌 Backend:
- Created AnalyticsController.php with REST endpoints
- All endpoints return 501 (Not Implemented) for now
- Ready for HPOS-based implementation
- Proper permission checks

🎨 Frontend:
- useAnalytics hook for data fetching
- React Query caching
- ErrorCard with retry functionality
- TypeScript type safety
- Zero build errors

📝 Documentation:
- DASHBOARD_API_IMPLEMENTATION.md guide
- Backend implementation roadmap
- Testing strategy

🔧 Build:
- All pages compile successfully
- Production-ready with dummy data fallback
- Zero TypeScript errors
This commit is contained in:
dwindown
2025-11-04 11:19:00 +07:00
commit 232059e928
148 changed files with 28984 additions and 0 deletions

View File

@@ -0,0 +1,194 @@
/**
* 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) {
// eslint-disable-next-line react-hooks/rules-of-hooks
// 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,
});
// eslint-disable-next-line react-hooks/rules-of-hooks
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;
}