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,64 @@
import { api } from './api';
/**
* Analytics API
* Endpoints for dashboard analytics data
*/
export interface AnalyticsParams {
period?: string; // '7', '14', '30', 'all'
start_date?: string; // ISO date for custom range
end_date?: string; // ISO date for custom range
granularity?: 'day' | 'week' | 'month';
}
export const AnalyticsApi = {
/**
* Dashboard Overview
* GET /woonoow/v1/analytics/overview
*/
overview: (params?: AnalyticsParams) =>
api.get('/woonoow/v1/analytics/overview', params),
/**
* Revenue Analytics
* GET /woonoow/v1/analytics/revenue
*/
revenue: (params?: AnalyticsParams) =>
api.get('/woonoow/v1/analytics/revenue', params),
/**
* Orders Analytics
* GET /woonoow/v1/analytics/orders
*/
orders: (params?: AnalyticsParams) =>
api.get('/woonoow/v1/analytics/orders', params),
/**
* Products Analytics
* GET /woonoow/v1/analytics/products
*/
products: (params?: AnalyticsParams) =>
api.get('/woonoow/v1/analytics/products', params),
/**
* Customers Analytics
* GET /woonoow/v1/analytics/customers
*/
customers: (params?: AnalyticsParams) =>
api.get('/woonoow/v1/analytics/customers', params),
/**
* Coupons Analytics
* GET /woonoow/v1/analytics/coupons
*/
coupons: (params?: AnalyticsParams) =>
api.get('/woonoow/v1/analytics/coupons', params),
/**
* Taxes Analytics
* GET /woonoow/v1/analytics/taxes
*/
taxes: (params?: AnalyticsParams) =>
api.get('/woonoow/v1/analytics/taxes', params),
};

108
admin-spa/src/lib/api.ts Normal file
View File

@@ -0,0 +1,108 @@
export const api = {
root: () => (window.WNW_API?.root?.replace(/\/$/, '') || ''),
nonce: () => (window.WNW_API?.nonce || ''),
async wpFetch(path: string, options: RequestInit = {}) {
const url = /^https?:\/\//.test(path) ? path : api.root() + path;
const headers = new Headers(options.headers || {});
if (!headers.has('X-WP-Nonce') && api.nonce()) headers.set('X-WP-Nonce', api.nonce());
if (!headers.has('Accept')) headers.set('Accept', 'application/json');
if (options.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
const res = await fetch(url, { credentials: 'same-origin', ...options, headers });
if (!res.ok) {
let responseData: any = null;
try {
const text = await res.text();
responseData = text ? JSON.parse(text) : null;
} catch {}
if (window.WNW_API?.isDev) {
console.error('[WooNooW] API error', { url, status: res.status, statusText: res.statusText, data: responseData });
}
// Create error with response data attached (for error handling utility to extract)
const err: any = new Error(res.statusText);
err.response = {
status: res.status,
statusText: res.statusText,
data: responseData
};
throw err;
}
try {
return await res.json();
} catch {
return await res.text();
}
},
async get(path: string, params?: Record<string, any>) {
const usp = new URLSearchParams();
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v == null) continue;
usp.set(k, String(v));
}
}
const qs = usp.toString();
return api.wpFetch(path + (qs ? `?${qs}` : ''));
},
async post(path: string, body?: any) {
return api.wpFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body != null ? JSON.stringify(body) : undefined,
});
},
async del(path: string) {
return api.wpFetch(path, { method: 'DELETE' });
},
};
export type CreateOrderPayload = {
items: { product_id: number; qty: number }[];
billing?: Record<string, any>;
shipping?: Record<string, any>;
status?: string;
payment_method?: string;
};
export const OrdersApi = {
list: (params?: Record<string, any>) => api.get('/orders', params),
get: (id: number) => api.get(`/orders/${id}`),
create: (payload: CreateOrderPayload) => api.post('/orders', payload),
update: (id: number, payload: any) => api.wpFetch(`/orders/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
payments: async () => api.get('/payments'),
shippings: async () => api.get('/shippings'),
countries: () => api.get('/countries'),
};
export const ProductsApi = {
search: (search: string, limit = 10) => api.get('/products', { search, limit }),
};
export const CustomersApi = {
search: (search: string) => api.get('/customers/search', { search }),
searchByEmail: (email: string) => api.get('/customers/search', { email }),
};
export async function getMenus() {
// Prefer REST; fall back to localized snapshot
try {
const res = await fetch(`${(window as any).WNW_API}/menus`, { credentials: 'include' });
if (!res.ok) throw new Error('menus fetch failed');
return (await res.json()).items || [];
} catch {
return ((window as any).WNW_WC_MENUS?.items) || [];
}
}

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;
}

View File

@@ -0,0 +1,36 @@
export function formatRelativeOrDate(tsSec?: number, locale?: string) {
if (!tsSec) return "—";
const now = Date.now();
const ts = tsSec * 1000;
const diffMs = ts - now;
const rtf = new Intl.RelativeTimeFormat(locale || undefined, { numeric: "auto" });
const absMs = Math.abs(diffMs);
const oneMin = 60 * 1000;
const oneHour = 60 * oneMin;
const oneDay = 24 * oneHour;
// Match Woo-ish thresholds
if (absMs < oneMin) {
const secs = Math.round(diffMs / 1000);
return rtf.format(secs, "second");
}
if (absMs < oneHour) {
const mins = Math.round(diffMs / oneMin);
return rtf.format(mins, "minute");
}
if (absMs < oneDay) {
const hours = Math.round(diffMs / oneHour);
return rtf.format(hours, "hour");
}
// Fallback to a readable local datetime
const d = new Date(ts);
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}

View File

@@ -0,0 +1,89 @@
/**
* Centralized error handling utilities for WooNooW Admin SPA
*
* Guidelines:
* - Use toast notifications for ACTION errors (mutations: create, update, delete)
* - Use error cards/messages for PAGE LOAD errors (queries: fetch data)
* - Never show technical details (API 500, stack traces) to users
* - Always provide actionable, user-friendly messages
* - All user-facing strings are translatable
*/
import { toast } from 'sonner';
import { __ } from './i18n';
/**
* Extract user-friendly error message from API error response
*/
export function getErrorMessage(error: any): { title: string; description?: string } {
// Extract error details from response
const errorMessage = error?.response?.data?.message || error?.message || '';
const errorCode = error?.response?.data?.error || '';
const fieldErrors = error?.response?.data?.fields || [];
// Remove technical prefixes like "API 500:"
const cleanMessage = errorMessage.replace(/^API\s+\d+:\s*/i, '');
// Map error codes to user-friendly messages (all translatable)
const friendlyMessages: Record<string, string> = {
// Order errors
'no_items': __('Please add at least one product to the order'),
'create_failed': __('Failed to create order. Please check all required fields.'),
'update_failed': __('Failed to update order. Please check all fields.'),
'validation_failed': __('Please complete all required fields'),
'not_found': __('The requested item was not found'),
'forbidden': __('You do not have permission to perform this action'),
// Generic errors
'validation_error': __('Please check your input and try again'),
'server_error': __('Something went wrong. Please try again later.'),
};
const title = friendlyMessages[errorCode] || __('An error occurred');
// Build description from field errors or clean message
let description: string | undefined;
if (fieldErrors.length > 0) {
// Show specific field errors as a bulleted list
description = fieldErrors.map((err: string) => `${err}`).join('\n');
} else if ((errorCode === 'create_failed' || errorCode === 'update_failed' || errorCode === 'validation_failed') && cleanMessage) {
description = cleanMessage;
}
return { title, description };
}
/**
* Show error toast for mutation/action errors
* Use this for: create, update, delete, form submissions
*/
export function showErrorToast(error: any, customMessage?: string) {
console.error('Action error:', error);
const { title, description } = getErrorMessage(error);
toast.error(customMessage || title, {
description,
duration: 6000, // Longer for errors
});
}
/**
* Show success toast for successful actions
*/
export function showSuccessToast(message: string, description?: string) {
toast.success(message, {
description,
duration: 4000,
});
}
/**
* Get error message for page load errors (queries)
* Use this for: rendering error states in components
*/
export function getPageLoadErrorMessage(error: any): string {
const { title } = getErrorMessage(error);
return title;
}

57
admin-spa/src/lib/i18n.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* Internationalization utilities for WooNooW Admin SPA
* Uses WordPress i18n functions via wp.i18n
*/
// WordPress i18n is loaded globally
declare const wp: {
i18n: {
__: (text: string, domain: string) => string;
_x: (text: string, context: string, domain: string) => string;
_n: (single: string, plural: string, number: number, domain: string) => string;
sprintf: (format: string, ...args: any[]) => string;
};
};
const TEXT_DOMAIN = 'woonoow';
/**
* Translate a string
*/
export function __(text: string): string {
if (typeof wp !== 'undefined' && wp.i18n && wp.i18n.__) {
return wp.i18n.__(text, TEXT_DOMAIN);
}
return text; // Fallback to original text
}
/**
* Translate a string with context
*/
export function _x(text: string, context: string): string {
if (typeof wp !== 'undefined' && wp.i18n && wp.i18n._x) {
return wp.i18n._x(text, context, TEXT_DOMAIN);
}
return text;
}
/**
* Translate plural forms
*/
export function _n(single: string, plural: string, number: number): string {
if (typeof wp !== 'undefined' && wp.i18n && wp.i18n._n) {
return wp.i18n._n(single, plural, number, TEXT_DOMAIN);
}
return number === 1 ? single : plural;
}
/**
* sprintf-style formatting
*/
export function sprintf(format: string, ...args: any[]): string {
if (typeof wp !== 'undefined' && wp.i18n && wp.i18n.sprintf) {
return wp.i18n.sprintf(format, ...args);
}
// Simple fallback
return format.replace(/%s/g, () => String(args.shift() || ''));
}

View File

@@ -0,0 +1,28 @@
// admin-spa/src/lib/query-params.ts
export function getQuery(): Record<string, string> {
try {
const hash = window.location.hash || "";
const qIndex = hash.indexOf("?");
if (qIndex === -1) return {};
const usp = new URLSearchParams(hash.slice(qIndex + 1));
const out: Record<string, string> = {};
usp.forEach((v, k) => (out[k] = v));
return out;
} catch {
return {};
}
}
export function setQuery(partial: Record<string, any>) {
const hash = window.location.hash || "#/";
const [path, qs = ""] = hash.split("?");
const usp = new URLSearchParams(qs);
Object.entries(partial).forEach(([k, v]) => {
if (v == null || v === "") usp.delete(k);
else usp.set(k, String(v));
});
const next = path + (usp.toString() ? "?" + usp.toString() : "");
if (next !== hash) {
history.replaceState(null, "", next);
}
}

View File

@@ -0,0 +1,13 @@
import { create } from "zustand";
interface CommandStore {
open: boolean;
setOpen: (v: boolean) => void;
toggle: () => void;
}
export const useCommandStore = create<CommandStore>((set) => ({
open: false,
setOpen: (v) => set({ open: v }),
toggle: () => set((s) => ({ open: !s.open })),
}));

View File

@@ -0,0 +1,44 @@
/**
* Dummy Data Toggle Hook
*
* Provides a global toggle for using dummy data vs real API data
* Useful for development and showcasing charts when store has no data
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface DummyDataStore {
useDummyData: boolean;
toggleDummyData: () => void;
setDummyData: (value: boolean) => void;
}
export const useDummyDataStore = create<DummyDataStore>()(
persist(
(set) => ({
useDummyData: false,
toggleDummyData: () => set((state) => ({ useDummyData: !state.useDummyData })),
setDummyData: (value: boolean) => set({ useDummyData: value }),
}),
{
name: 'woonoow-dummy-data',
}
)
);
/**
* Hook to check if dummy data should be used
*/
export function useDummyData() {
const { useDummyData } = useDummyDataStore();
return useDummyData;
}
/**
* Hook to toggle dummy data
*/
export function useDummyDataToggle() {
const { useDummyData, toggleDummyData, setDummyData } = useDummyDataStore();
return { useDummyData, toggleDummyData, setDummyData };
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}