feat: Complete analytics implementation with all 7 pages, ROI calculation, conversion rate formatting, and chart improvements

This commit is contained in:
dwindown
2025-11-04 18:08:00 +07:00
parent 232059e928
commit 7c0d9639b6
6 changed files with 1124 additions and 120 deletions

View File

@@ -15,50 +15,50 @@ export interface AnalyticsParams {
export const AnalyticsApi = { export const AnalyticsApi = {
/** /**
* Dashboard Overview * Dashboard Overview
* GET /woonoow/v1/analytics/overview * GET /analytics/overview
*/ */
overview: (params?: AnalyticsParams) => overview: (params?: AnalyticsParams) =>
api.get('/woonoow/v1/analytics/overview', params), api.get('/analytics/overview', params),
/** /**
* Revenue Analytics * Revenue Analytics
* GET /woonoow/v1/analytics/revenue * GET /analytics/revenue
*/ */
revenue: (params?: AnalyticsParams) => revenue: (params?: AnalyticsParams) =>
api.get('/woonoow/v1/analytics/revenue', params), api.get('/analytics/revenue', params),
/** /**
* Orders Analytics * Orders Analytics
* GET /woonoow/v1/analytics/orders * GET /analytics/orders
*/ */
orders: (params?: AnalyticsParams) => orders: (params?: AnalyticsParams) =>
api.get('/woonoow/v1/analytics/orders', params), api.get('/analytics/orders', params),
/** /**
* Products Analytics * Products Analytics
* GET /woonoow/v1/analytics/products * GET /analytics/products
*/ */
products: (params?: AnalyticsParams) => products: (params?: AnalyticsParams) =>
api.get('/woonoow/v1/analytics/products', params), api.get('/analytics/products', params),
/** /**
* Customers Analytics * Customers Analytics
* GET /woonoow/v1/analytics/customers * GET /analytics/customers
*/ */
customers: (params?: AnalyticsParams) => customers: (params?: AnalyticsParams) =>
api.get('/woonoow/v1/analytics/customers', params), api.get('/analytics/customers', params),
/** /**
* Coupons Analytics * Coupons Analytics
* GET /woonoow/v1/analytics/coupons * GET /analytics/coupons
*/ */
coupons: (params?: AnalyticsParams) => coupons: (params?: AnalyticsParams) =>
api.get('/woonoow/v1/analytics/coupons', params), api.get('/analytics/coupons', params),
/** /**
* Taxes Analytics * Taxes Analytics
* GET /woonoow/v1/analytics/taxes * GET /analytics/taxes
*/ */
taxes: (params?: AnalyticsParams) => taxes: (params?: AnalyticsParams) =>
api.get('/woonoow/v1/analytics/taxes', params), api.get('/analytics/taxes', params),
}; };

View File

@@ -1,5 +1,5 @@
import React, { useState, useMemo, useRef } from 'react'; import React, { useState, useMemo, useRef } from 'react';
import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, Label, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; import { BarChart, Bar, LineChart, Line, AreaChart, Area, ComposedChart, PieChart, Pie, Cell, Label, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { ShoppingCart, TrendingUp, Package, XCircle, DollarSign, CheckCircle, Clock } from 'lucide-react'; import { ShoppingCart, TrendingUp, Package, XCircle, DollarSign, CheckCircle, Clock } from 'lucide-react';
import { __ } from '@/lib/i18n'; import { __ } from '@/lib/i18n';
import { formatMoney, getStoreCurrency } from '@/lib/currency'; import { formatMoney, getStoreCurrency } from '@/lib/currency';
@@ -16,12 +16,14 @@ import { DUMMY_ORDERS_DATA, OrdersData } from './data/dummyOrders';
export default function OrdersAnalytics() { export default function OrdersAnalytics() {
const { period } = useDashboardPeriod(); const { period } = useDashboardPeriod();
const store = getStoreCurrency(); const store = getStoreCurrency();
const [activeStatus, setActiveStatus] = useState('all');
const [hoverIndex, setHoverIndex] = useState<number | undefined>(undefined); const [hoverIndex, setHoverIndex] = useState<number | undefined>(undefined);
const chartRef = useRef<any>(null); const chartRef = useRef<any>(null);
// Fetch real data or use dummy data based on toggle // Fetch real data or use dummy data based on toggle
const { data, isLoading, error, refetch } = useOrdersAnalytics(DUMMY_ORDERS_DATA); const { data, isLoading, error, refetch } = useOrdersAnalytics(DUMMY_ORDERS_DATA);
// Auto-select first status (completed) on load
const [activeStatus, setActiveStatus] = useState(data.by_status[0]?.status_label || 'All');
// Filter chart data by period // Filter chart data by period
const chartData = useMemo(() => { const chartData = useMemo(() => {
@@ -205,7 +207,13 @@ export default function OrdersAnalytics() {
description={__('Daily order count and status breakdown')} description={__('Daily order count and status breakdown')}
> >
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData}> <ComposedChart data={chartData}>
<defs>
<linearGradient id="totalOrdersGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0.05}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis <XAxis
dataKey="date" dataKey="date"
@@ -235,19 +243,39 @@ export default function OrdersAnalytics() {
}} }}
/> />
<Legend /> <Legend />
<Line {/* Total Orders as Area (background) */}
<Area
type="monotone" type="monotone"
dataKey="orders" dataKey="orders"
name={__('Total Orders')} name={__('Total Orders')}
stroke="#3b82f6" stroke="#3b82f6"
strokeWidth={2} strokeWidth={2}
fill="url(#totalOrdersGradient)"
/> />
{/* Individual statuses as Lines */}
<Line <Line
type="monotone" type="monotone"
dataKey="completed" dataKey="completed"
name={__('Completed')} name={__('Completed')}
stroke="#10b981" stroke="#10b981"
strokeWidth={2} strokeWidth={2}
dot={{ r: 3 }}
/>
<Line
type="monotone"
dataKey="processing"
name={__('Processing')}
stroke="#60a5fa"
strokeWidth={2}
dot={{ r: 3 }}
/>
<Line
type="monotone"
dataKey="pending"
name={__('Pending')}
stroke="#f59e0b"
strokeWidth={2}
dot={{ r: 3 }}
/> />
<Line <Line
type="monotone" type="monotone"
@@ -255,8 +283,9 @@ export default function OrdersAnalytics() {
name={__('Cancelled')} name={__('Cancelled')}
stroke="#ef4444" stroke="#ef4444"
strokeWidth={2} strokeWidth={2}
dot={{ r: 3 }}
/> />
</LineChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
</ChartCard> </ChartCard>

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo, useRef, useEffect } from 'react'; import React, { useState, useMemo, useRef, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { AreaChart, Area, BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, Sector, Label, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; import { AreaChart, Area, BarChart, Bar, LineChart, Line, ComposedChart, PieChart, Pie, Cell, Sector, Label, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { TrendingUp, TrendingDown, ShoppingCart, DollarSign, Package, Users, AlertTriangle, ArrowUpRight } from 'lucide-react'; import { TrendingUp, TrendingDown, ShoppingCart, DollarSign, Package, Users, AlertTriangle, ArrowUpRight } from 'lucide-react';
import { __ } from '@/lib/i18n'; import { __ } from '@/lib/i18n';
import { formatMoney, getStoreCurrency } from '@/lib/currency'; import { formatMoney, getStoreCurrency } from '@/lib/currency';
@@ -125,11 +125,17 @@ const DUMMY_DATA = {
// Metric card component // Metric card component
function MetricCard({ title, value, change, icon: Icon, format = 'number', period }: any) { function MetricCard({ title, value, change, icon: Icon, format = 'number', period }: any) {
const safeValue = value ?? 0;
const formattedValue = format === 'money' ? formatMoney(safeValue) : format === 'percent' ? `${safeValue.toFixed(2)}%` : safeValue.toLocaleString();
// Don't show comparison for "All Time"
const showComparison = change !== undefined && period !== 'all';
const isPositive = change >= 0; const isPositive = change >= 0;
const formattedValue = format === 'money' ? formatMoney(value) : format === 'percent' ? `${value}%` : value.toLocaleString();
// Period comparison text // Period comparison text
const periodText = period === '7' ? __('vs previous 7 days') : period === '14' ? __('vs previous 14 days') : __('vs previous 30 days'); const periodText = period === '7' ? __('vs previous 7 days') :
period === '14' ? __('vs previous 14 days') :
period === '30' ? __('vs previous 30 days') : '';
return ( return (
<div className="rounded-lg border bg-card p-6"> <div className="rounded-lg border bg-card p-6">
@@ -139,10 +145,12 @@ function MetricCard({ title, value, change, icon: Icon, format = 'number', perio
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<div className="text-2xl font-bold">{formattedValue}</div> <div className="text-2xl font-bold">{formattedValue}</div>
<div className={`flex items-center text-xs ${isPositive ? 'text-green-600' : 'text-red-600'}`}> {showComparison && (
{isPositive ? <TrendingUp className="w-3 h-3 mr-1" /> : <TrendingDown className="w-3 h-3 mr-1" />} <div className={`flex items-center text-xs ${isPositive ? 'text-green-600' : 'text-red-600'}`}>
{Math.abs(change).toFixed(1)}% {periodText} {isPositive ? <TrendingUp className="w-3 h-3 mr-1" /> : <TrendingDown className="w-3 h-3 mr-1" />}
</div> {Math.abs(change).toFixed(1)}% {periodText}
</div>
)}
</div> </div>
</div> </div>
); );
@@ -152,13 +160,32 @@ function MetricCard({ title, value, change, icon: Icon, format = 'number', perio
export default function Dashboard() { export default function Dashboard() {
const { period } = useDashboardPeriod(); const { period } = useDashboardPeriod();
const store = getStoreCurrency(); const store = getStoreCurrency();
const [activeStatus, setActiveStatus] = useState('all');
const [hoverIndex, setHoverIndex] = useState<number | undefined>(undefined); const [hoverIndex, setHoverIndex] = useState<number | undefined>(undefined);
const [chartMetric, setChartMetric] = useState<'both' | 'revenue' | 'orders'>('both'); const [chartMetric, setChartMetric] = useState<'both' | 'revenue' | 'orders'>('both');
const chartRef = useRef<any>(null); const chartRef = useRef<any>(null);
// Fetch real data or use dummy data based on toggle // Fetch real data or use dummy data based on toggle
const { data, isLoading, error, refetch } = useOverviewAnalytics(DUMMY_DATA); const { data, isLoading, error, refetch } = useOverviewAnalytics(DUMMY_DATA);
// Sort order status by importance (completed first)
const sortedOrderStatus = useMemo(() => {
const statusOrder = ['completed', 'processing', 'pending', 'on-hold', 'cancelled', 'refunded', 'failed'];
const orderStatus = data.orderStatus || data.orderStatusDistribution || [];
if (!Array.isArray(orderStatus)) return [];
return [...orderStatus].sort((a, b) => {
const statusA = (a.status || a.name || '').toLowerCase();
const statusB = (b.status || b.name || '').toLowerCase();
const posA = statusOrder.indexOf(statusA);
const posB = statusOrder.indexOf(statusB);
return (posA === -1 ? 999 : posA) - (posB === -1 ? 999 : posB);
});
}, [data.orderStatus, data.orderStatusDistribution]);
// Auto-select first status (completed) on load
const [activeStatus, setActiveStatus] = useState(
sortedOrderStatus[0]?.status || sortedOrderStatus[0]?.name || 'All'
);
// Filter chart data based on period // Filter chart data based on period
const chartData = useMemo(() => { const chartData = useMemo(() => {
@@ -349,11 +376,11 @@ export default function Dashboard() {
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
{chartMetric === 'both' ? ( {chartMetric === 'both' ? (
<AreaChart data={chartData}> <ComposedChart data={chartData}>
<defs> <defs>
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} /> <stop offset="5%" stopColor="#3b82f6" stopOpacity={0.4} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} /> <stop offset="95%" stopColor="#3b82f6" stopOpacity={0.05} />
</linearGradient> </linearGradient>
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
@@ -372,7 +399,7 @@ export default function Dashboard() {
<p className="text-sm font-bold">{label}</p> <p className="text-sm font-bold">{label}</p>
{payload.map((entry: any, index: number) => ( {payload.map((entry: any, index: number) => (
<p key={index} style={{ color: entry.color }}> <p key={index} style={{ color: entry.color }}>
<span className="font-bold">{entry.name}:</span> {entry.name === __('Revenue') ? formatMoney(Number(entry.value)) : entry.value.toLocaleString()} <span className="font-bold">{entry.name}:</span> {entry.name === __('Revenue') ? formatMoney(Number(entry.value || 0)) : (entry.value ?? 0).toLocaleString()}
</p> </p>
))} ))}
</div> </div>
@@ -382,9 +409,9 @@ export default function Dashboard() {
}} }}
/> />
<Legend /> <Legend />
<Area yAxisId="left" type="monotone" dataKey="revenue" stroke="#3b82f6" fillOpacity={1} fill="url(#colorRevenue)" name={__('Revenue')} /> <Area yAxisId="left" type="monotone" dataKey="revenue" stroke="#3b82f6" strokeWidth={2.5} fillOpacity={1} fill="url(#colorRevenue)" name={__('Revenue')} />
<Line yAxisId="right" type="monotone" dataKey="orders" stroke="#10b981" strokeWidth={2} name={__('Orders')} /> <Line yAxisId="right" type="monotone" dataKey="orders" stroke="#10b981" strokeWidth={2.5} dot={{ r: 4, fill: '#10b981' }} activeDot={{ r: 6 }} name={__('Orders')} />
</AreaChart> </ComposedChart>
) : chartMetric === 'revenue' ? ( ) : chartMetric === 'revenue' ? (
<AreaChart data={chartData}> <AreaChart data={chartData}>
<defs> <defs>
@@ -433,7 +460,7 @@ export default function Dashboard() {
<p className="text-sm font-bold">{label}</p> <p className="text-sm font-bold">{label}</p>
{payload.map((entry: any, index: number) => ( {payload.map((entry: any, index: number) => (
<p key={index} style={{ color: entry.color }}> <p key={index} style={{ color: entry.color }}>
<span className="font-bold">{entry.name}:</span> {entry.value.toLocaleString()} <span className="font-bold">{entry.name}:</span> {(entry.value ?? 0).toLocaleString()}
</p> </p>
))} ))}
</div> </div>
@@ -462,14 +489,17 @@ export default function Dashboard() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent align="end"> <SelectContent align="end">
{DUMMY_DATA.orderStatusDistribution.map((status) => ( {sortedOrderStatus.map((status, idx) => {
<SelectItem key={status.name} value={status.name}> const statusValue = status.status || status.name || `status-${idx}`;
<span className="flex items-center gap-2 text-xs"> return (
<span className="flex h-3 w-3 shrink-0 rounded" style={{ backgroundColor: status.color }} /> <SelectItem key={statusValue} value={statusValue}>
{status.name} <span className="flex items-center gap-2 text-xs">
</span> <span className="flex h-3 w-3 shrink-0 rounded" style={{ backgroundColor: status.color }} />
</SelectItem> {statusValue}
))} </span>
</SelectItem>
);
})}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -480,9 +510,9 @@ export default function Dashboard() {
onMouseLeave={handleChartMouseLeave} onMouseLeave={handleChartMouseLeave}
> >
<Pie <Pie
data={DUMMY_DATA.orderStatusDistribution} data={sortedOrderStatus}
dataKey="value" dataKey={(entry) => entry.count || entry.value || 0}
nameKey="name" nameKey={(entry) => entry.status || entry.name || 'Unknown'}
cx="50%" cx="50%"
cy="50%" cy="50%"
innerRadius={70} innerRadius={70}
@@ -492,8 +522,10 @@ export default function Dashboard() {
onMouseLeave={onPieLeave} onMouseLeave={onPieLeave}
isAnimationActive={false} isAnimationActive={false}
> >
{data.orderStatusDistribution.map((entry: any, index: number) => { {sortedOrderStatus.map((entry: any, index: number) => {
const activePieIndex = data.orderStatusDistribution.findIndex((item: any) => item.name === activeStatus); const activePieIndex = sortedOrderStatus.findIndex((item: any) =>
(item.status || item.name) === activeStatus
);
const isActive = index === (hoverIndex !== undefined ? hoverIndex : activePieIndex); const isActive = index === (hoverIndex !== undefined ? hoverIndex : activePieIndex);
return ( return (
<Cell <Cell
@@ -524,7 +556,7 @@ export default function Dashboard() {
y={viewBox.cy} y={viewBox.cy}
className="fill-foreground text-3xl font-bold" className="fill-foreground text-3xl font-bold"
> >
{selectedData?.value.toLocaleString()} {(selectedData?.value ?? 0).toLocaleString()}
</tspan> </tspan>
<tspan <tspan
x={viewBox.cx} x={viewBox.cx}
@@ -558,16 +590,16 @@ export default function Dashboard() {
<TabsContent value="products" className="mt-0"> <TabsContent value="products" className="mt-0">
<div className="space-y-3"> <div className="space-y-3">
{DUMMY_DATA.topProducts.map((product) => ( {(data.topProducts || DUMMY_DATA.topProducts).slice(0, 5).map((product: any, index: number) => (
<div key={product.id} className="flex items-center justify-between p-2 rounded hover:bg-muted/50"> <div key={product.product_id || product.id || index} className="flex items-center justify-between p-2 rounded hover:bg-muted/50">
<div className="flex items-center gap-3 flex-1"> <div className="flex items-center gap-3 flex-1">
<div className="text-2xl">{product.image}</div> <div className="text-2xl">{product.image || '📦'}</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{product.name}</div> <div className="font-medium text-sm truncate">{product.name}</div>
<div className="text-xs text-muted-foreground">{product.quantity} {__('sold')}</div> <div className="text-xs text-muted-foreground">{product.items_sold || product.sales} {__('sold')}</div>
</div> </div>
</div> </div>
<div className="font-medium text-sm">{formatMoney(product.revenue)}</div> <div className="text-sm font-medium">{formatMoney(product.revenue)}</div>
</div> </div>
))} ))}
</div> </div>
@@ -575,13 +607,13 @@ export default function Dashboard() {
<TabsContent value="customers" className="mt-0"> <TabsContent value="customers" className="mt-0">
<div className="space-y-3"> <div className="space-y-3">
{DUMMY_DATA.topCustomers.map((customer) => ( {(data.topCustomers || DUMMY_DATA.topCustomers).slice(0, 5).map((customer: any, index: number) => (
<div key={customer.id} className="flex items-center justify-between p-2 rounded hover:bg-muted/50"> <div key={customer.customer_id || customer.id || index} className="flex items-center justify-between p-2 rounded hover:bg-muted/50">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{customer.name}</div> <div className="font-medium text-sm truncate">{customer.name}</div>
<div className="text-xs text-muted-foreground">{customer.orders} {__('orders')}</div> <div className="text-xs text-muted-foreground">{customer.order_count || customer.orders} {__('orders')}</div>
</div> </div>
<div className="font-medium text-sm">{formatMoney(customer.totalSpent)}</div> <div className="text-sm font-medium">{formatMoney(customer.total_spent || customer.total)}</div>
</div> </div>
))} ))}
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -29,4 +29,12 @@ class Permissions {
$nonce = $_SERVER['HTTP_X_WP_NONCE'] ?? ''; $nonce = $_SERVER['HTTP_X_WP_NONCE'] ?? '';
return (bool) wp_verify_nonce($nonce, 'wp_rest'); return (bool) wp_verify_nonce($nonce, 'wp_rest');
} }
/**
* Check if user has admin/manage_woocommerce permission
* Used for analytics and admin-only endpoints
*/
public static function check_admin_permission(): bool {
return current_user_can('manage_woocommerce') || current_user_can('manage_options');
}
} }

View File

@@ -0,0 +1,23 @@
<?php
namespace WooNooW\Api;
use WP_REST_Request;
use WP_REST_Response;
use WooNooW\Api\CheckoutController;
use WooNooW\Api\OrdersController;
use WooNooW\Api\AnalyticsController;
class Routes {
public static function init() {
// Initialize controllers (register action hooks)
OrdersController::init();
add_action('rest_api_init', function () {
$namespace = 'woonoow/v1';
// Defer to controllers to register their endpoints
CheckoutController::register();
OrdersController::register();
AnalyticsController::register_routes();
});
}
}