feat: Complete analytics implementation with all 7 pages, ROI calculation, conversion rate formatting, and chart improvements
This commit is contained in:
@@ -15,50 +15,50 @@ export interface AnalyticsParams {
|
||||
export const AnalyticsApi = {
|
||||
/**
|
||||
* Dashboard Overview
|
||||
* GET /woonoow/v1/analytics/overview
|
||||
* GET /analytics/overview
|
||||
*/
|
||||
overview: (params?: AnalyticsParams) =>
|
||||
api.get('/woonoow/v1/analytics/overview', params),
|
||||
api.get('/analytics/overview', params),
|
||||
|
||||
/**
|
||||
* Revenue Analytics
|
||||
* GET /woonoow/v1/analytics/revenue
|
||||
* GET /analytics/revenue
|
||||
*/
|
||||
revenue: (params?: AnalyticsParams) =>
|
||||
api.get('/woonoow/v1/analytics/revenue', params),
|
||||
api.get('/analytics/revenue', params),
|
||||
|
||||
/**
|
||||
* Orders Analytics
|
||||
* GET /woonoow/v1/analytics/orders
|
||||
* GET /analytics/orders
|
||||
*/
|
||||
orders: (params?: AnalyticsParams) =>
|
||||
api.get('/woonoow/v1/analytics/orders', params),
|
||||
api.get('/analytics/orders', params),
|
||||
|
||||
/**
|
||||
* Products Analytics
|
||||
* GET /woonoow/v1/analytics/products
|
||||
* GET /analytics/products
|
||||
*/
|
||||
products: (params?: AnalyticsParams) =>
|
||||
api.get('/woonoow/v1/analytics/products', params),
|
||||
api.get('/analytics/products', params),
|
||||
|
||||
/**
|
||||
* Customers Analytics
|
||||
* GET /woonoow/v1/analytics/customers
|
||||
* GET /analytics/customers
|
||||
*/
|
||||
customers: (params?: AnalyticsParams) =>
|
||||
api.get('/woonoow/v1/analytics/customers', params),
|
||||
api.get('/analytics/customers', params),
|
||||
|
||||
/**
|
||||
* Coupons Analytics
|
||||
* GET /woonoow/v1/analytics/coupons
|
||||
* GET /analytics/coupons
|
||||
*/
|
||||
coupons: (params?: AnalyticsParams) =>
|
||||
api.get('/woonoow/v1/analytics/coupons', params),
|
||||
api.get('/analytics/coupons', params),
|
||||
|
||||
/**
|
||||
* Taxes Analytics
|
||||
* GET /woonoow/v1/analytics/taxes
|
||||
* GET /analytics/taxes
|
||||
*/
|
||||
taxes: (params?: AnalyticsParams) =>
|
||||
api.get('/woonoow/v1/analytics/taxes', params),
|
||||
api.get('/analytics/taxes', params),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { __ } from '@/lib/i18n';
|
||||
import { formatMoney, getStoreCurrency } from '@/lib/currency';
|
||||
@@ -16,12 +16,14 @@ import { DUMMY_ORDERS_DATA, OrdersData } from './data/dummyOrders';
|
||||
export default function OrdersAnalytics() {
|
||||
const { period } = useDashboardPeriod();
|
||||
const store = getStoreCurrency();
|
||||
const [activeStatus, setActiveStatus] = useState('all');
|
||||
const [hoverIndex, setHoverIndex] = useState<number | undefined>(undefined);
|
||||
const chartRef = useRef<any>(null);
|
||||
|
||||
// Fetch real data or use dummy data based on toggle
|
||||
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
|
||||
const chartData = useMemo(() => {
|
||||
@@ -205,7 +207,13 @@ export default function OrdersAnalytics() {
|
||||
description={__('Daily order count and status breakdown')}
|
||||
>
|
||||
<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" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
@@ -235,19 +243,39 @@ export default function OrdersAnalytics() {
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
{/* Total Orders as Area (background) */}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="orders"
|
||||
name={__('Total Orders')}
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
fill="url(#totalOrdersGradient)"
|
||||
/>
|
||||
{/* Individual statuses as Lines */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="completed"
|
||||
name={__('Completed')}
|
||||
stroke="#10b981"
|
||||
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
|
||||
type="monotone"
|
||||
@@ -255,8 +283,9 @@ export default function OrdersAnalytics() {
|
||||
name={__('Cancelled')}
|
||||
stroke="#ef4444"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
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 { __ } from '@/lib/i18n';
|
||||
import { formatMoney, getStoreCurrency } from '@/lib/currency';
|
||||
@@ -125,11 +125,17 @@ const DUMMY_DATA = {
|
||||
|
||||
// Metric card component
|
||||
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 formattedValue = format === 'money' ? formatMoney(value) : format === 'percent' ? `${value}%` : value.toLocaleString();
|
||||
|
||||
// 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 (
|
||||
<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 className="space-y-1">
|
||||
<div className="text-2xl font-bold">{formattedValue}</div>
|
||||
<div className={`flex items-center text-xs ${isPositive ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{isPositive ? <TrendingUp className="w-3 h-3 mr-1" /> : <TrendingDown className="w-3 h-3 mr-1" />}
|
||||
{Math.abs(change).toFixed(1)}% {periodText}
|
||||
</div>
|
||||
{showComparison && (
|
||||
<div className={`flex items-center text-xs ${isPositive ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{isPositive ? <TrendingUp className="w-3 h-3 mr-1" /> : <TrendingDown className="w-3 h-3 mr-1" />}
|
||||
{Math.abs(change).toFixed(1)}% {periodText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -152,13 +160,32 @@ function MetricCard({ title, value, change, icon: Icon, format = 'number', perio
|
||||
export default function Dashboard() {
|
||||
const { period } = useDashboardPeriod();
|
||||
const store = getStoreCurrency();
|
||||
const [activeStatus, setActiveStatus] = useState('all');
|
||||
const [hoverIndex, setHoverIndex] = useState<number | undefined>(undefined);
|
||||
const [chartMetric, setChartMetric] = useState<'both' | 'revenue' | 'orders'>('both');
|
||||
const chartRef = useRef<any>(null);
|
||||
|
||||
// Fetch real data or use dummy data based on toggle
|
||||
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
|
||||
const chartData = useMemo(() => {
|
||||
@@ -349,11 +376,11 @@ export default function Dashboard() {
|
||||
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
{chartMetric === 'both' ? (
|
||||
<AreaChart data={chartData}>
|
||||
<ComposedChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
@@ -372,7 +399,7 @@ export default function Dashboard() {
|
||||
<p className="text-sm font-bold">{label}</p>
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
@@ -382,9 +409,9 @@ export default function Dashboard() {
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Area yAxisId="left" type="monotone" dataKey="revenue" stroke="#3b82f6" fillOpacity={1} fill="url(#colorRevenue)" name={__('Revenue')} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="orders" stroke="#10b981" strokeWidth={2} name={__('Orders')} />
|
||||
</AreaChart>
|
||||
<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.5} dot={{ r: 4, fill: '#10b981' }} activeDot={{ r: 6 }} name={__('Orders')} />
|
||||
</ComposedChart>
|
||||
) : chartMetric === 'revenue' ? (
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
@@ -433,7 +460,7 @@ export default function Dashboard() {
|
||||
<p className="text-sm font-bold">{label}</p>
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
@@ -462,14 +489,17 @@ export default function Dashboard() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
{DUMMY_DATA.orderStatusDistribution.map((status) => (
|
||||
<SelectItem key={status.name} value={status.name}>
|
||||
<span className="flex items-center gap-2 text-xs">
|
||||
<span className="flex h-3 w-3 shrink-0 rounded" style={{ backgroundColor: status.color }} />
|
||||
{status.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
{sortedOrderStatus.map((status, idx) => {
|
||||
const statusValue = status.status || status.name || `status-${idx}`;
|
||||
return (
|
||||
<SelectItem key={statusValue} value={statusValue}>
|
||||
<span className="flex items-center gap-2 text-xs">
|
||||
<span className="flex h-3 w-3 shrink-0 rounded" style={{ backgroundColor: status.color }} />
|
||||
{statusValue}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -480,9 +510,9 @@ export default function Dashboard() {
|
||||
onMouseLeave={handleChartMouseLeave}
|
||||
>
|
||||
<Pie
|
||||
data={DUMMY_DATA.orderStatusDistribution}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
data={sortedOrderStatus}
|
||||
dataKey={(entry) => entry.count || entry.value || 0}
|
||||
nameKey={(entry) => entry.status || entry.name || 'Unknown'}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={70}
|
||||
@@ -492,8 +522,10 @@ export default function Dashboard() {
|
||||
onMouseLeave={onPieLeave}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{data.orderStatusDistribution.map((entry: any, index: number) => {
|
||||
const activePieIndex = data.orderStatusDistribution.findIndex((item: any) => item.name === activeStatus);
|
||||
{sortedOrderStatus.map((entry: any, index: number) => {
|
||||
const activePieIndex = sortedOrderStatus.findIndex((item: any) =>
|
||||
(item.status || item.name) === activeStatus
|
||||
);
|
||||
const isActive = index === (hoverIndex !== undefined ? hoverIndex : activePieIndex);
|
||||
return (
|
||||
<Cell
|
||||
@@ -524,7 +556,7 @@ export default function Dashboard() {
|
||||
y={viewBox.cy}
|
||||
className="fill-foreground text-3xl font-bold"
|
||||
>
|
||||
{selectedData?.value.toLocaleString()}
|
||||
{(selectedData?.value ?? 0).toLocaleString()}
|
||||
</tspan>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
@@ -558,16 +590,16 @@ export default function Dashboard() {
|
||||
|
||||
<TabsContent value="products" className="mt-0">
|
||||
<div className="space-y-3">
|
||||
{DUMMY_DATA.topProducts.map((product) => (
|
||||
<div key={product.id} className="flex items-center justify-between p-2 rounded hover:bg-muted/50">
|
||||
{(data.topProducts || DUMMY_DATA.topProducts).slice(0, 5).map((product: any, index: number) => (
|
||||
<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="text-2xl">{product.image}</div>
|
||||
<div className="text-2xl">{product.image || '📦'}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<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 className="font-medium text-sm">{formatMoney(product.revenue)}</div>
|
||||
<div className="text-sm font-medium">{formatMoney(product.revenue)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -575,13 +607,13 @@ export default function Dashboard() {
|
||||
|
||||
<TabsContent value="customers" className="mt-0">
|
||||
<div className="space-y-3">
|
||||
{DUMMY_DATA.topCustomers.map((customer) => (
|
||||
<div key={customer.id} className="flex items-center justify-between p-2 rounded hover:bg-muted/50">
|
||||
{(data.topCustomers || DUMMY_DATA.topCustomers).slice(0, 5).map((customer: any, index: number) => (
|
||||
<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="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 className="font-medium text-sm">{formatMoney(customer.totalSpent)}</div>
|
||||
<div className="text-sm font-medium">{formatMoney(customer.total_spent || customer.total)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,4 +29,12 @@ class Permissions {
|
||||
$nonce = $_SERVER['HTTP_X_WP_NONCE'] ?? '';
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user