2087 lines
66 KiB
JavaScript
2087 lines
66 KiB
JavaScript
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
DollarSign,
|
|
ShoppingCart,
|
|
Package,
|
|
Clock,
|
|
Map,
|
|
Tags,
|
|
Star,
|
|
XCircle,
|
|
TrendingUp,
|
|
AlertCircle,
|
|
Box,
|
|
RefreshCcw,
|
|
CircleDollarSign,
|
|
ArrowDown,
|
|
ArrowUp,
|
|
MapPin
|
|
} from "lucide-react";
|
|
import { DateTime } from "luxon";
|
|
import { TimeRangeSelect } from "@/components/dashboard/TimeRangeSelect";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
BarChart,
|
|
Bar,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
LineChart,
|
|
Line,
|
|
Area,
|
|
AreaChart,
|
|
ComposedChart,
|
|
} from "recharts";
|
|
|
|
const CHART_COLORS = [
|
|
"hsl(var(--primary))",
|
|
"hsl(var(--secondary))",
|
|
"#8b5cf6",
|
|
"#10b981",
|
|
"#f59e0b",
|
|
"#ef4444",
|
|
];
|
|
// Formatting utilities
|
|
const formatCurrency = (value, minimumFractionDigits = 0) => {
|
|
if (!value || isNaN(value)) return "$0";
|
|
return new Intl.NumberFormat("en-US", {
|
|
style: "currency",
|
|
currency: "USD",
|
|
minimumFractionDigits,
|
|
maximumFractionDigits: minimumFractionDigits,
|
|
}).format(value);
|
|
};
|
|
|
|
const formatHour = (hour) => {
|
|
const hourNum = parseInt(hour);
|
|
if (hourNum === 0) return "12am";
|
|
if (hourNum === 12) return "12pm";
|
|
if (hourNum > 12) return `${hourNum - 12}pm`;
|
|
return `${hourNum}am`;
|
|
};
|
|
|
|
const normalizePaymentMethod = (method) => {
|
|
if (method.toLowerCase().includes("credit card")) return "Credit Card";
|
|
if (method.toLowerCase().includes("gift")) return "Gift Card";
|
|
return method;
|
|
};
|
|
|
|
const formatPercent = (value, total) => {
|
|
if (!total || !value) return "0%";
|
|
return `${((value / total) * 100).toFixed(1)}%`;
|
|
};
|
|
|
|
const getPreviousPeriod = (timeRange) => {
|
|
switch (timeRange) {
|
|
case 'today': return 'yesterday';
|
|
case 'yesterday': return 'last2days';
|
|
case 'last7days': return 'previous7days';
|
|
case 'last30days': return 'previous30days';
|
|
case 'last90days': return 'previous90days';
|
|
default: return timeRange;
|
|
}
|
|
};
|
|
|
|
const formatShipMethod = (method) => {
|
|
if (!method) return "Standard Shipping";
|
|
return method
|
|
.replace("usps_", "USPS ")
|
|
.replace("ups_", "UPS ")
|
|
.replace("fedex_", "FedEx ")
|
|
.replace(/_/g, " ")
|
|
.split(" ")
|
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
.join(" ");
|
|
};
|
|
|
|
// Component building blocks
|
|
const DetailCard = ({ title, icon: Icon, children }) => (
|
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 space-y-3">
|
|
<div className="flex items-center space-x-2">
|
|
{Icon && <Icon className="h-5 w-5 text-gray-500" />}
|
|
<h3 className="font-medium text-gray-900 dark:text-gray-100">{title}</h3>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
|
|
const SkeletonMetricCard = () => (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
<Skeleton className="h-4 w-24" />
|
|
<Skeleton className="h-4 w-4 rounded-full" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className="h-8 w-32 mb-2" />
|
|
<Skeleton className="h-3 w-20" />
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
|
|
const SkeletonChart = ({ type = "line" }) => (
|
|
<div className="h-[400px] w-full bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
|
<div className="h-full flex flex-col">
|
|
<div className="flex-1 relative">
|
|
{type === "bar" ? (
|
|
<div className="h-full flex items-end justify-between gap-1">
|
|
{[...Array(24)].map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="w-full bg-gray-200 dark:bg-gray-700 rounded-t"
|
|
style={{ height: `${15 + Math.random() * 70}%` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : type === "range" ? (
|
|
<div className="h-full flex items-center justify-between">
|
|
<div className="h-full w-full relative">
|
|
{[...Array(5)].map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="absolute w-full h-1 bg-gray-200 dark:bg-gray-700"
|
|
style={{ top: `${20 + i * 20}%` }}
|
|
/>
|
|
))}
|
|
<div className="absolute inset-x-0 top-1/2 h-8 bg-gray-300 dark:bg-gray-600 opacity-25" />
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="h-full w-full relative">
|
|
{[...Array(5)].map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="absolute w-full h-px bg-gray-200 dark:bg-gray-700"
|
|
style={{ top: `${20 + i * 20}%` }}
|
|
/>
|
|
))}
|
|
<div
|
|
className="absolute inset-0 bg-gray-300 dark:bg-gray-600"
|
|
style={{
|
|
opacity: 0.2,
|
|
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
|
|
const SkeletonTable = ({ rows = 5 }) => (
|
|
<div className="space-y-2">
|
|
{/* Header */}
|
|
<div className="grid grid-cols-3 gap-4 pb-2">
|
|
{[...Array(3)].map((_, i) => (
|
|
<Skeleton key={i} className="h-5 w-full" />
|
|
))}
|
|
</div>
|
|
{/* Rows */}
|
|
{[...Array(rows)].map((_, i) => (
|
|
<div key={i} className="grid grid-cols-3 gap-4 py-2">
|
|
{[...Array(3)].map((_, j) => (
|
|
<Skeleton key={j} className="h-4 w-full" />
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
const SkeletonMetricGrid = () => (
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
{Array(12)
|
|
.fill(0)
|
|
.map((_, i) => (
|
|
<SkeletonMetricCard key={i} />
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
const StatRow = ({ label, value, change, emphasize }) => (
|
|
<div className="flex justify-between items-center py-1">
|
|
<span className="text-gray-600 dark:text-gray-400">{label}</span>
|
|
<div className="flex items-center space-x-2">
|
|
<span className={emphasize ? "font-medium text-primary" : "font-medium"}>
|
|
{value}
|
|
</span>
|
|
{change && (
|
|
<span
|
|
className={`text-sm ${
|
|
change > 0 ? "text-green-500" : "text-red-500"
|
|
}`}
|
|
>
|
|
{change > 0 ? "↑" : "↓"} {Math.abs(change)}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const DetailSection = ({ title, children }) => (
|
|
<div className="space-y-4">
|
|
<h3 className="text-lg font-medium">{title}</h3>
|
|
{children}
|
|
</div>
|
|
);
|
|
|
|
const formatChartDate = (value) => {
|
|
if (!value) return '';
|
|
try {
|
|
return DateTime.fromISO(value).setZone('America/New_York').toFormat('LLL d');
|
|
} catch (error) {
|
|
console.error("[KLAVIYO STATS] Date formatting error:", error);
|
|
return value;
|
|
}
|
|
};
|
|
|
|
export const RevenueDetails = ({ metrics, isLoading }) => {
|
|
if (isLoading) {
|
|
return <SkeletonChart type="line" />;
|
|
}
|
|
|
|
// Ensure we have valid daily data
|
|
if (!metrics?.revenue?.daily || !Array.isArray(metrics.revenue.daily)) {
|
|
console.error("[KLAVIYO STATS] Invalid daily revenue data:", metrics?.revenue);
|
|
return null;
|
|
}
|
|
|
|
// Sort daily data by date to ensure correct order
|
|
const revenueData = metrics.revenue.daily
|
|
.sort((a, b) => DateTime.fromISO(a.date).toMillis() - DateTime.fromISO(b.date).toMillis())
|
|
.map(day => ({
|
|
date: day.date,
|
|
revenue: parseFloat(day.value) || 0,
|
|
orders: parseInt(day.orders) || 0,
|
|
items: parseInt(day.items) || 0
|
|
}));
|
|
|
|
console.log("[KLAVIYO STATS] Processed revenue data:", {
|
|
totalDays: revenueData.length,
|
|
dates: revenueData.map(d => d.date),
|
|
totals: {
|
|
revenue: revenueData.reduce((sum, day) => sum + day.revenue, 0),
|
|
orders: revenueData.reduce((sum, day) => sum + day.orders, 0),
|
|
items: revenueData.reduce((sum, day) => sum + day.items, 0)
|
|
}
|
|
});
|
|
|
|
return (
|
|
<div className="h-[400px] w-full bg-white dark:bg-gray-800 rounded-lg p-4">
|
|
<ResponsiveContainer>
|
|
<LineChart
|
|
data={revenueData}
|
|
margin={{ top: 10, right: 30, left: 10, bottom: 30 }}
|
|
>
|
|
<CartesianGrid
|
|
strokeDasharray="3 3"
|
|
className="stroke-gray-200 dark:stroke-gray-700"
|
|
/>
|
|
<XAxis
|
|
dataKey="date"
|
|
tickFormatter={formatChartDate}
|
|
height={50}
|
|
tick={{ fontSize: 12 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<YAxis
|
|
tickFormatter={(value) => formatCurrency(value, 0)}
|
|
width={80}
|
|
tick={{ fontSize: 12 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<Tooltip
|
|
labelFormatter={formatChartDate}
|
|
formatter={(value) => [formatCurrency(value, 0), "Revenue"]}
|
|
/>
|
|
<Line
|
|
type="monotone"
|
|
dataKey="revenue"
|
|
stroke={CHART_COLORS[0]}
|
|
name="Revenue"
|
|
dot={false}
|
|
strokeWidth={2}
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const OrdersDetails = ({ metrics, isLoading }) => {
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<SkeletonChart />
|
|
<div>
|
|
<Skeleton className="h-6 w-40 mb-4" />
|
|
<SkeletonChart />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const orderData =
|
|
metrics.revenue?.daily?.map((day) => ({
|
|
date: day.date,
|
|
orders: day.orders || 0,
|
|
})) || [];
|
|
|
|
const hourlyData =
|
|
metrics.hourly_distribution
|
|
?.map((count, hour) => ({
|
|
hour,
|
|
orders: count,
|
|
}))
|
|
.filter((data) => data.orders > 0) || [];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="h-[400px]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<LineChart
|
|
data={orderData}
|
|
margin={{ top: 10, right: 30, left: 10, bottom: 30 }}
|
|
>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis
|
|
dataKey="date"
|
|
tickFormatter={formatChartDate}
|
|
height={50}
|
|
tick={{ fontSize: 12 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<YAxis
|
|
width={60}
|
|
tick={{ fontSize: 12 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<Tooltip
|
|
labelFormatter={formatChartDate}
|
|
formatter={(value) => [value.toLocaleString(), "Orders"]}
|
|
/>
|
|
<Line
|
|
type="monotone"
|
|
dataKey="orders"
|
|
stroke="hsl(var(--primary))"
|
|
name="Orders"
|
|
strokeWidth={2}
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
{hourlyData.length > 0 && (
|
|
<div>
|
|
<h3 className="text-lg font-medium mb-4">Hourly Distribution</h3>
|
|
<div className="h-[300px]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={hourlyData}>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="hour" tickFormatter={formatHour} />
|
|
<YAxis />
|
|
<Tooltip
|
|
formatter={(value) => [value.toLocaleString(), "Orders"]}
|
|
labelFormatter={formatHour}
|
|
/>
|
|
<Bar dataKey="orders" fill={CHART_COLORS[0]} name="Orders" />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const AverageOrderDetails = ({ metrics, isLoading }) => {
|
|
if (isLoading) {
|
|
return <SkeletonChart />;
|
|
}
|
|
|
|
const avgOrderData =
|
|
metrics.revenue?.daily?.map((day) => ({
|
|
date: day.date,
|
|
average: day.orders > 0 ? day.value / day.orders : 0,
|
|
})) || [];
|
|
|
|
return (
|
|
<TimeSeriesChart
|
|
data={avgOrderData}
|
|
valueKey="average"
|
|
label="Average Order Value"
|
|
type="line"
|
|
valueFormatter={(value) => formatCurrency(value, 0)}
|
|
minDays={30}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const DataTable = ({ title, data, isLoading }) => {
|
|
if (isLoading) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<Skeleton className="h-6 w-32" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-2">
|
|
{Array(10).fill(0).map((_, i) => (
|
|
<Skeleton key={i} className="h-10 w-full" />
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{title}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead className="text-right">Count</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{data.map(([name, count]) => (
|
|
<TableRow key={name}>
|
|
<TableCell>{name}</TableCell>
|
|
<TableCell className="text-right font-medium">{count}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export const BrandsAndCategoriesDetails = ({ metrics, isLoading }) => {
|
|
const brands = Object.entries(metrics?.orders?.brands || {})
|
|
.sort(([, a], [, b]) => b - a)
|
|
.slice(0, 20);
|
|
|
|
const categories = Object.entries(metrics?.orders?.categories || {})
|
|
.sort(([, a], [, b]) => b - a)
|
|
.slice(0, 20);
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<DataTable
|
|
title="Top Brands"
|
|
data={brands}
|
|
isLoading={isLoading}
|
|
/>
|
|
<DataTable
|
|
title="Top Categories"
|
|
data={categories}
|
|
isLoading={isLoading}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
export const ShippedOrdersDetails = ({ metrics, isLoading }) => {
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<SkeletonChart />
|
|
<div>
|
|
<Skeleton className="h-6 w-48 mb-4" />
|
|
<div className="space-y-2">
|
|
{Array(8)
|
|
.fill(0)
|
|
.map((_, i) => (
|
|
<Skeleton key={i} className="h-12 w-full rounded-lg" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const shippedData = metrics.revenue?.daily?.map((day) => ({
|
|
date: day.date,
|
|
shipped_orders: day.shipped_orders || 0,
|
|
})) || [];
|
|
|
|
const locations = Object.entries(metrics.orders?.shipping_states || {})
|
|
.sort(([, a], [, b]) => b - a)
|
|
.filter(([location]) => location);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="h-[400px]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<LineChart
|
|
data={shippedData}
|
|
margin={{ top: 10, right: 30, left: 10, bottom: 30 }}
|
|
>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis
|
|
dataKey="date"
|
|
tickFormatter={formatChartDate}
|
|
height={50}
|
|
tick={{ fontSize: 12 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<YAxis
|
|
width={60}
|
|
tick={{ fontSize: 12 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<Tooltip
|
|
labelFormatter={formatChartDate}
|
|
formatter={(value) => [value.toLocaleString(), "Orders"]}
|
|
/>
|
|
<Line
|
|
type="monotone"
|
|
dataKey="shipped_orders"
|
|
stroke="hsl(var(--primary))"
|
|
name="Shipped Orders"
|
|
strokeWidth={2}
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 className="text-lg font-medium mb-4">Top Shipping Locations</h3>
|
|
<div className="space-y-2">
|
|
{locations.map(([location, count]) => (
|
|
<div
|
|
key={location}
|
|
className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
|
|
>
|
|
<span>{location}</span>
|
|
<span className="font-medium">{count.toLocaleString()}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
export const PreOrdersDetails = ({ metrics, isLoading }) => {
|
|
if (isLoading) {
|
|
return <SkeletonChart type="line" />;
|
|
}
|
|
|
|
const preOrderData =
|
|
metrics.revenue?.daily?.map((day) => ({
|
|
date: day.date,
|
|
percentage:
|
|
day.orders > 0 ? ((day.pre_orders || 0) / day.orders) * 100 : 0,
|
|
})) || [];
|
|
|
|
return (
|
|
<div className="h-[400px] w-full bg-white dark:bg-gray-800 rounded-lg p-4">
|
|
<ResponsiveContainer>
|
|
<LineChart
|
|
data={preOrderData}
|
|
margin={{ top: 10, right: 30, left: 10, bottom: 30 }}
|
|
>
|
|
<CartesianGrid
|
|
strokeDasharray="3 3"
|
|
className="stroke-gray-200 dark:stroke-gray-700"
|
|
/>
|
|
<XAxis
|
|
dataKey="date"
|
|
tickFormatter={formatChartDate}
|
|
height={50}
|
|
tick={{ fontSize: 12 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<YAxis
|
|
tickFormatter={(value) => `${Math.round(value)}%`}
|
|
width={60}
|
|
tick={{ fontSize: 12 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<Tooltip content={<ChartTooltip />} />
|
|
<Line
|
|
type="monotone"
|
|
dataKey="percentage"
|
|
stroke={CHART_COLORS[0]}
|
|
name="Pre-Orders"
|
|
dot={false}
|
|
strokeWidth={2}
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
};
|
|
export const LocalPickupDetails = ({ metrics, isLoading }) => {
|
|
if (isLoading) {
|
|
return <SkeletonChart type="line" />;
|
|
}
|
|
|
|
const pickupData =
|
|
metrics.revenue?.daily?.map((day) => ({
|
|
date: day.date,
|
|
percentage:
|
|
day.orders > 0 ? ((day.local_pickup || 0) / day.orders) * 100 : 0,
|
|
})) || [];
|
|
|
|
return (
|
|
<div className="h-[400px] w-full bg-white dark:bg-gray-800 rounded-lg p-4">
|
|
<ResponsiveContainer>
|
|
<LineChart
|
|
data={pickupData}
|
|
margin={{ top: 10, right: 30, left: 10, bottom: 30 }}
|
|
>
|
|
<CartesianGrid
|
|
strokeDasharray="3 3"
|
|
className="stroke-gray-200 dark:stroke-gray-700"
|
|
/>
|
|
<XAxis
|
|
dataKey="date"
|
|
tickFormatter={formatChartDate}
|
|
height={50}
|
|
tick={{ fontSize: 12 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<YAxis
|
|
tickFormatter={(value) => `${Math.round(value)}%`}
|
|
width={60}
|
|
tick={{ fontSize: 12 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<Tooltip content={<ChartTooltip />} />
|
|
<Line
|
|
type="monotone"
|
|
dataKey="percentage"
|
|
stroke={CHART_COLORS[0]}
|
|
name="Local Pickup"
|
|
dot={false}
|
|
strokeWidth={2}
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
};
|
|
export const OnHoldDetails = ({ metrics, isLoading }) => {
|
|
if (isLoading) {
|
|
return <SkeletonChart type="line" />;
|
|
}
|
|
|
|
const onHoldData =
|
|
metrics.revenue?.daily?.map((day) => ({
|
|
date: day.date,
|
|
percentage:
|
|
day.orders > 0 ? ((day.status?.on_hold || 0) / day.orders) * 100 : 0,
|
|
})) || [];
|
|
|
|
return (
|
|
<div className="h-[400px] w-full bg-white dark:bg-gray-800 rounded-lg p-4">
|
|
<ResponsiveContainer>
|
|
<LineChart
|
|
data={onHoldData}
|
|
margin={{ top: 10, right: 30, left: 10, bottom: 30 }}
|
|
>
|
|
<CartesianGrid
|
|
strokeDasharray="3 3"
|
|
className="stroke-gray-200 dark:stroke-gray-700"
|
|
/>
|
|
<XAxis
|
|
dataKey="date"
|
|
tickFormatter={formatChartDate}
|
|
height={50}
|
|
tick={{ fontSize: 12 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<YAxis
|
|
tickFormatter={(value) => `${Math.round(value)}%`}
|
|
width={60}
|
|
tick={{ fontSize: 12 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<Tooltip content={<ChartTooltip />} />
|
|
<Line
|
|
type="monotone"
|
|
dataKey="percentage"
|
|
stroke={CHART_COLORS[0]}
|
|
name="On Hold"
|
|
dot={false}
|
|
strokeWidth={2}
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const ShippingDetails = ({ metrics }) => {
|
|
const locations = Object.entries(
|
|
metrics.orders?.shipping_locations || {}
|
|
).sort(([, a], [, b]) => b - a);
|
|
const shippedOrders = metrics.orders?.shipped_orders || 0;
|
|
const totalOrders = metrics.orders?.total || 0;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<DetailCard title="Shipping Overview" icon={Package}>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<StatRow
|
|
label="Orders Shipped"
|
|
value={shippedOrders.toLocaleString()}
|
|
subtitle={`${((shippedOrders / totalOrders) * 100).toFixed(
|
|
1
|
|
)}% of total`}
|
|
/>
|
|
<StatRow
|
|
label="Items Shipped"
|
|
value={(metrics.shipped?.items || 0).toLocaleString()}
|
|
/>
|
|
<StatRow
|
|
label="Locations"
|
|
value={locations.length.toLocaleString()}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<StatRow
|
|
label="Average Processing Time"
|
|
value={
|
|
metrics.shipping?.average_processing_days
|
|
? `${metrics.shipping.average_processing_days.toFixed(
|
|
1
|
|
)} days`
|
|
: "N/A"
|
|
}
|
|
/>
|
|
<StatRow
|
|
label="Rush Orders"
|
|
value={(metrics.shipping?.rush_orders || 0).toLocaleString()}
|
|
/>
|
|
<StatRow
|
|
label="Local Pickup"
|
|
value={(metrics.orders?.local_pickup || 0).toLocaleString()}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</DetailCard>
|
|
|
|
<DetailCard title="Shipping Methods" icon={Package}>
|
|
<div className="space-y-3">
|
|
{Object.entries(metrics.orders?.shipping_methods || {})
|
|
.sort(([, a], [, b]) => b - a)
|
|
.map(([method, count]) => (
|
|
<div
|
|
key={method}
|
|
className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
|
|
>
|
|
<span className="font-medium">{formatShipMethod(method)}</span>
|
|
<div className="text-sm">
|
|
<span className="font-medium">{count.toLocaleString()}</span>
|
|
<span className="text-gray-500 ml-2">
|
|
({((count / shippedOrders) * 100).toFixed(1)}%)
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</DetailCard>
|
|
|
|
<DetailCard title="Delivery Locations" icon={MapPin}>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{locations.map(([location, count]) => (
|
|
<div
|
|
key={location}
|
|
className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
|
|
>
|
|
<span className="font-medium">{location}</span>
|
|
<div className="text-sm">
|
|
<span className="font-medium">{count.toLocaleString()}</span>
|
|
<span className="text-gray-500 ml-2">
|
|
({((count / totalOrders) * 100).toFixed(1)}%)
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</DetailCard>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const ProductsDetails = ({ metrics }) => {
|
|
const brands = Object.entries(metrics.orders?.brands || {}).sort(
|
|
([, a], [, b]) => b - a
|
|
);
|
|
const categories = Object.entries(metrics.orders?.categories || {}).sort(
|
|
([, a], [, b]) => b - a
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<DetailCard title="Top Brands" icon={Star}>
|
|
<div className="grid gap-3">
|
|
{brands.map(([brand, count], index) => (
|
|
<div
|
|
key={brand}
|
|
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
|
|
>
|
|
<div className="flex items-center space-x-3">
|
|
<span className="text-sm font-medium w-6">{index + 1}.</span>
|
|
<span className="font-medium">{brand}</span>
|
|
</div>
|
|
<div className="text-sm">
|
|
<span className="font-medium">{count.toLocaleString()}</span>
|
|
<span className="text-gray-500 ml-2">
|
|
({((count / metrics.orders.items_total) * 100).toFixed(1)}%)
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</DetailCard>
|
|
|
|
<DetailCard title="Top Categories" icon={Tags}>
|
|
<div className="grid gap-3">
|
|
{categories.map(([category, count], index) => (
|
|
<div
|
|
key={category}
|
|
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
|
|
>
|
|
<div className="flex items-center space-x-3">
|
|
<span className="text-sm font-medium w-6">{index + 1}.</span>
|
|
<span className="font-medium">{category}</span>
|
|
</div>
|
|
<div className="text-sm">
|
|
<span className="font-medium">{count.toLocaleString()}</span>
|
|
<span className="text-gray-500 ml-2">
|
|
({((count / metrics.orders.items_total) * 100).toFixed(1)}%)
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</DetailCard>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const RefundsDetails = ({ metrics, isLoading }) => {
|
|
if (isLoading) {
|
|
return <SkeletonChart type="bar" />;
|
|
}
|
|
|
|
const refundData =
|
|
metrics.revenue?.daily?.map((day) => ({
|
|
date: day.date,
|
|
amount: day.refunds?.total || 0,
|
|
})) || [];
|
|
|
|
return (
|
|
<TimeSeriesChart
|
|
data={refundData}
|
|
valueKey="amount"
|
|
label="Refunds"
|
|
type="bar"
|
|
valueFormatter={(value) => formatCurrency(value, 0)}
|
|
minDays={30}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export const PeakHourDetails = ({ metrics, isLoading }) => {
|
|
if (isLoading) {
|
|
return <SkeletonChart type="bar" />;
|
|
}
|
|
|
|
const hourlyData =
|
|
metrics.hourly_distribution
|
|
?.map((count, hour) => ({
|
|
hour,
|
|
orders: count,
|
|
}))
|
|
.filter((data) => data.orders > 0) || [];
|
|
|
|
return (
|
|
<div className="h-[400px] w-full bg-white dark:bg-gray-800 rounded-lg p-4">
|
|
<ResponsiveContainer>
|
|
<BarChart
|
|
data={hourlyData}
|
|
margin={{ top: 10, right: 30, left: 10, bottom: 30 }}
|
|
>
|
|
<CartesianGrid
|
|
strokeDasharray="3 3"
|
|
className="stroke-gray-200 dark:stroke-gray-700"
|
|
/>
|
|
<XAxis
|
|
dataKey="hour"
|
|
tickFormatter={formatHour}
|
|
height={50}
|
|
tick={{ fontSize: 12 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<YAxis
|
|
width={60}
|
|
tick={{ fontSize: 12 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<Tooltip
|
|
formatter={(value) => [value, "Orders"]}
|
|
labelFormatter={formatHour}
|
|
content={<ChartTooltip />}
|
|
/>
|
|
<Bar
|
|
dataKey="orders"
|
|
fill={CHART_COLORS[0]}
|
|
name="Orders"
|
|
radius={[4, 4, 0, 0]}
|
|
/>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
};
|
|
export const CancellationsDetails = ({ metrics, isLoading }) => {
|
|
if (isLoading) {
|
|
return <SkeletonChart type="bar" />;
|
|
}
|
|
const cancelData =
|
|
metrics.revenue?.daily?.map((day) => ({
|
|
date: day.date,
|
|
amount: day.cancellations?.total || 0,
|
|
})) || [];
|
|
|
|
return (
|
|
<TimeSeriesChart
|
|
data={cancelData}
|
|
valueKey="amount"
|
|
label="Cancellations"
|
|
type="bar"
|
|
valueFormatter={(value) => formatCurrency(value, 0)}
|
|
minDays={30}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export const OrderRangeDetails = ({ metrics, isLoading }) => {
|
|
if (isLoading) {
|
|
return <SkeletonChart type="range" />;
|
|
}
|
|
|
|
const rangeData = metrics.revenue?.daily
|
|
?.map((day) => {
|
|
if (!day.orders_list?.length) return null;
|
|
|
|
const validOrders = day.orders_list
|
|
.map((order) => parseFloat(order.TotalAmount))
|
|
.filter((amount) => amount > 0);
|
|
|
|
if (!validOrders.length) return null;
|
|
|
|
return {
|
|
date: day.date,
|
|
min: Math.min(...validOrders),
|
|
max: Math.max(...validOrders),
|
|
avg: validOrders.reduce((a, b) => a + b, 0) / validOrders.length,
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
|
|
return (
|
|
<div className="h-[400px] w-full bg-white dark:bg-gray-800 rounded-lg p-4">
|
|
<ResponsiveContainer>
|
|
<ComposedChart
|
|
data={rangeData}
|
|
margin={{ top: 10, right: 30, left: 10, bottom: 30 }}
|
|
>
|
|
<CartesianGrid
|
|
strokeDasharray="3 3"
|
|
className="stroke-gray-200 dark:stroke-gray-700"
|
|
/>
|
|
<XAxis
|
|
dataKey="date"
|
|
tickFormatter={formatChartDate}
|
|
height={50}
|
|
tick={{ fontSize: 12 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<YAxis
|
|
tickFormatter={(value) => formatCurrency(value, 0)}
|
|
width={80}
|
|
tick={{ fontSize: 12 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<Tooltip content={<ChartTooltip />} />
|
|
<Bar
|
|
dataKey="min"
|
|
fill={CHART_COLORS[1]}
|
|
name="Minimum Order"
|
|
stackId="range"
|
|
/>
|
|
<Bar
|
|
dataKey="max"
|
|
fill={CHART_COLORS[0]}
|
|
name="Maximum Order"
|
|
stackId="range"
|
|
/>
|
|
<Line
|
|
type="monotone"
|
|
dataKey="avg"
|
|
stroke={CHART_COLORS[2]}
|
|
name="Average Order"
|
|
dot={false}
|
|
strokeWidth={2}
|
|
/>
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
};
|
|
const MetricCard = React.memo(
|
|
({ title, value, subtitle, icon: Icon, iconColor, onClick, secondaryValue }) => (
|
|
<Card
|
|
className={`bg-white dark:bg-gray-800 ${
|
|
onClick
|
|
? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
|
: ""
|
|
}`}
|
|
onClick={onClick}
|
|
>
|
|
<CardHeader className="flex flex-row items-center justify-between p-4 pb-0">
|
|
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">
|
|
{title}
|
|
</CardTitle>
|
|
<Icon className={`h-4 w-4 ${iconColor}`} />
|
|
</CardHeader>
|
|
<CardContent className="p-4">
|
|
<div className="text-2xl font-bold">
|
|
{value}
|
|
</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
{subtitle}
|
|
</div>
|
|
{secondaryValue && (
|
|
<div className="text-sm text-primary mt-1 font-medium">
|
|
{secondaryValue}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
);
|
|
|
|
MetricCard.displayName = "MetricCard";
|
|
|
|
const ChartTooltip = ({ active, payload, label }) => {
|
|
if (!active || !payload?.length) return null;
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-gray-900 p-2 shadow-lg border rounded-lg">
|
|
<p className="text-sm font-semibold mb-1">{formatChartDate(label)}</p>
|
|
{payload.map((entry, index) => (
|
|
<p key={index} className="text-sm">
|
|
<span style={{ color: entry.color }}>{entry.name}: </span>
|
|
{typeof entry.value === "number"
|
|
? entry.name.toLowerCase().includes("revenue")
|
|
? formatCurrency(entry.value)
|
|
: entry.value.toLocaleString()
|
|
: entry.value}
|
|
</p>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const TimeSeriesChart = ({
|
|
data,
|
|
valueKey,
|
|
label,
|
|
type = "line",
|
|
valueFormatter = (v) => v,
|
|
}) => {
|
|
const ChartComponent =
|
|
type === "line" ? LineChart : type === "bar" ? BarChart : AreaChart;
|
|
|
|
return (
|
|
<div className="h-[400px] w-full bg-white dark:bg-gray-800 rounded-lg p-4">
|
|
<ResponsiveContainer>
|
|
<ChartComponent
|
|
data={data}
|
|
margin={{ top: 10, right: 30, left: 10, bottom: 30 }}
|
|
>
|
|
<CartesianGrid
|
|
strokeDasharray="3 3"
|
|
className="stroke-gray-200 dark:stroke-gray-700"
|
|
/>
|
|
<XAxis
|
|
dataKey="date"
|
|
tickFormatter={formatChartDate}
|
|
height={50}
|
|
tick={{ fontSize: 12 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<YAxis
|
|
tickFormatter={valueFormatter}
|
|
width={60}
|
|
tick={{ fontSize: 12 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
/>
|
|
<Tooltip content={<ChartTooltip />} />
|
|
{type === "line" && (
|
|
<Line
|
|
type="monotone"
|
|
dataKey={valueKey}
|
|
stroke={CHART_COLORS[0]}
|
|
name={label}
|
|
dot={false}
|
|
strokeWidth={2}
|
|
/>
|
|
)}
|
|
{type === "bar" && (
|
|
<Bar
|
|
dataKey={valueKey}
|
|
fill={CHART_COLORS[0]}
|
|
name={label}
|
|
radius={[4, 4, 0, 0]}
|
|
/>
|
|
)}
|
|
{type === "area" && (
|
|
<Area
|
|
type="monotone"
|
|
dataKey={valueKey}
|
|
stroke={CHART_COLORS[0]}
|
|
fill={`url(#${label.replace(/\s+/g, "")}-gradient)`}
|
|
name={label}
|
|
>
|
|
<defs>
|
|
<linearGradient
|
|
id={`${label.replace(/\s+/g, "")}-gradient`}
|
|
x1="0"
|
|
y1="0"
|
|
x2="0"
|
|
y2="1"
|
|
>
|
|
<stop
|
|
offset="5%"
|
|
stopColor={CHART_COLORS[0]}
|
|
stopOpacity={0.8}
|
|
/>
|
|
<stop
|
|
offset="95%"
|
|
stopColor={CHART_COLORS[0]}
|
|
stopOpacity={0}
|
|
/>
|
|
</linearGradient>
|
|
</defs>
|
|
</Area>
|
|
)}
|
|
</ChartComponent>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Status components
|
|
const OrderStatusTags = React.memo(({ details }) => {
|
|
if (!details) return null;
|
|
|
|
const tags = [
|
|
{
|
|
condition: details.HasPreorder,
|
|
label: "Pre-order",
|
|
color: "bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300",
|
|
},
|
|
{
|
|
condition: details.LocalPickup,
|
|
label: "Local Pickup",
|
|
color:
|
|
"bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300",
|
|
},
|
|
{
|
|
condition: details.IsOnHold,
|
|
label: "On Hold",
|
|
color:
|
|
"bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300",
|
|
},
|
|
{
|
|
condition: details.HasDigitalGC,
|
|
label: "Gift Card",
|
|
color:
|
|
"bg-indigo-100 dark:bg-indigo-900/20 text-indigo-800 dark:text-indigo-300",
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="flex flex-wrap gap-2">
|
|
{tags.map(
|
|
({ condition, label, color }, index) =>
|
|
condition && (
|
|
<span
|
|
key={index}
|
|
className={`px-2 py-1 rounded-full text-xs cursor-help ${color}`}
|
|
>
|
|
{label}
|
|
</span>
|
|
)
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
OrderStatusTags.displayName = "OrderStatusTags";
|
|
|
|
const PromotionalInfo = React.memo(({ details }) => {
|
|
if (!details?.PromosUsedReg?.length && !details?.PointsDiscount) return null;
|
|
|
|
return (
|
|
<div className="mt-4 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
|
<h4 className="text-sm font-medium text-green-800 dark:text-green-400 mb-2">
|
|
Savings Applied
|
|
</h4>
|
|
<div className="space-y-2">
|
|
{details.PromosUsedReg?.map(([code, amount], index) => (
|
|
<div key={index} className="flex justify-between text-sm">
|
|
<span className="font-mono text-green-700 dark:text-green-300">
|
|
{code}
|
|
</span>
|
|
<span className="font-medium text-green-700 dark:text-green-300">
|
|
-{formatCurrency(amount)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
{details.PointsDiscount > 0 && (
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-green-700 dark:text-green-300">
|
|
Points Discount
|
|
</span>
|
|
<span className="font-medium text-green-700 dark:text-green-300">
|
|
-{formatCurrency(details.PointsDiscount)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
PromotionalInfo.displayName = "PromotionalInfo";
|
|
|
|
|
|
const ShippingInfo = React.memo(({ details }) => (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<h4 className="text-sm font-medium text-gray-500">Shipping Address</h4>
|
|
<div className="text-sm space-y-1">
|
|
<div className="font-medium">{details.ShippingName}</div>
|
|
<div>{details.ShippingStreet1}</div>
|
|
{details.ShippingStreet2 && <div>{details.ShippingStreet2}</div>}
|
|
<div>
|
|
{details.ShippingCity}, {details.ShippingState} {details.ShippingZip}
|
|
</div>
|
|
{details.ShippingCountry !== "US" && (
|
|
<div>{details.ShippingCountry}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{details.TrackingNumber && (
|
|
<div className="space-y-2">
|
|
<h4 className="text-sm font-medium text-gray-500">
|
|
Tracking Information
|
|
</h4>
|
|
<div className="text-sm space-y-1">
|
|
<div className="font-medium">
|
|
{formatShipMethod(details.ShipMethod)}
|
|
</div>
|
|
<div className="font-mono text-blue-600 dark:text-blue-400">
|
|
{details.TrackingNumber}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
));
|
|
|
|
ShippingInfo.displayName = "ShippingInfo";
|
|
|
|
const OrderSummary = React.memo(({ details }) => (
|
|
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg space-y-3">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<h4 className="text-sm font-medium text-gray-500 mb-2">Subtotal</h4>
|
|
<div className="space-y-1">
|
|
<div className="flex justify-between">
|
|
<span>Items ({details.Items?.length || 0})</span>
|
|
<span>{formatCurrency(details.Subtotal)}</span>
|
|
</div>
|
|
{details.PointsDiscount > 0 && (
|
|
<div className="flex justify-between text-green-600">
|
|
<span>Points Discount</span>
|
|
<span>-{formatCurrency(details.PointsDiscount)}</span>
|
|
</div>
|
|
)}
|
|
{details.TotalDiscounts > 0 && (
|
|
<div className="flex justify-between text-green-600">
|
|
<span>Discounts</span>
|
|
<span>-{formatCurrency(details.TotalDiscounts)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h4 className="text-sm font-medium text-gray-500 mb-2">Shipping</h4>
|
|
<div className="space-y-1">
|
|
<div className="flex justify-between">
|
|
<span>Shipping Cost</span>
|
|
<span>{formatCurrency(details.ShippingTotal)}</span>
|
|
</div>
|
|
{details.RushFee > 0 && (
|
|
<div className="flex justify-between">
|
|
<span>Rush Fee</span>
|
|
<span>{formatCurrency(details.RushFee)}</span>
|
|
</div>
|
|
)}
|
|
{details.SalesTax > 0 && (
|
|
<div className="flex justify-between">
|
|
<span>Sales Tax</span>
|
|
<span>{formatCurrency(details.SalesTax)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-3 border-t border-gray-200 dark:border-gray-700">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<span className="text-sm font-medium">Total</span>
|
|
{details.TotalSavings > 0 && (
|
|
<div className="text-xs text-green-600">
|
|
You saved {formatCurrency(details.TotalSavings)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<span className="text-lg font-bold">
|
|
{formatCurrency(details.TotalAmount)}
|
|
</span>
|
|
</div>
|
|
|
|
{details.Payments?.length > 0 && (
|
|
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
|
<h4 className="text-sm font-medium text-gray-500 mb-2">
|
|
Payment Details
|
|
</h4>
|
|
<div className="space-y-1">
|
|
{details.Payments.map(([method, amount], index) => (
|
|
<div key={index} className="flex justify-between text-sm">
|
|
<span>{method}</span>
|
|
<span>{formatCurrency(amount)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<PromotionalInfo details={details} />
|
|
</div>
|
|
));
|
|
|
|
OrderSummary.displayName = "OrderSummary";
|
|
|
|
// Loading and empty states
|
|
const LoadingState = () => (
|
|
<div className="flex flex-col items-center justify-center p-8 space-y-4">
|
|
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
|
<div className="h-full bg-primary animate-[loader_1s_ease-in-out_infinite]" />
|
|
</div>
|
|
<p className="text-sm text-gray-500">Loading data...</p>
|
|
</div>
|
|
);
|
|
|
|
const ErrorState = ({ message }) => (
|
|
<div className="flex flex-col items-center justify-center p-6">
|
|
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
|
|
<h3 className="text-lg font-medium mb-2">Error Loading Metrics</h3>
|
|
<p className="text-sm text-gray-500 text-center">{message}</p>
|
|
</div>
|
|
);
|
|
|
|
// Add data validation and normalization utilities
|
|
const validateMetricsData = (data) => {
|
|
if (!data || typeof data !== 'object') {
|
|
throw new Error('Invalid metrics data: expected object');
|
|
}
|
|
|
|
if (!data.revenue?.daily || !Array.isArray(data.revenue.daily)) {
|
|
throw new Error('Invalid metrics data: missing or invalid daily revenue data');
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const normalizeMetricsData = (data) => {
|
|
// Ensure all required properties exist with default values
|
|
const normalized = {
|
|
revenue: {
|
|
total: parseFloat(data.revenue?.total) || 0,
|
|
daily: data.revenue?.daily?.map(day => ({
|
|
date: day.date,
|
|
value: parseFloat(day.value) || 0,
|
|
orders: parseInt(day.orders) || 0,
|
|
items: parseInt(day.items) || 0,
|
|
pre_orders: parseInt(day.pre_orders) || 0,
|
|
local_pickup: parseInt(day.local_pickup) || 0,
|
|
refunds: {
|
|
total: parseFloat(day.refunds?.total) || 0,
|
|
count: parseInt(day.refunds?.count) || 0
|
|
},
|
|
cancellations: {
|
|
total: parseFloat(day.cancellations?.total) || 0,
|
|
count: parseInt(day.cancellations?.count) || 0
|
|
},
|
|
status: {
|
|
on_hold: parseInt(day.status?.on_hold) || 0,
|
|
processing: parseInt(day.status?.processing) || 0,
|
|
completed: parseInt(day.status?.completed) || 0
|
|
},
|
|
hourly_orders: Array.isArray(day.hourly_orders) ?
|
|
day.hourly_orders.map(count => parseInt(count) || 0) :
|
|
Array(24).fill(0),
|
|
payment_methods: day.payment_methods || {},
|
|
categories: day.categories || {},
|
|
brands: day.brands || {},
|
|
shipping_states: day.shipping_states || {},
|
|
unique_customers: Array.isArray(day.unique_customers) ?
|
|
[...new Set(day.unique_customers)] : []
|
|
})) || []
|
|
},
|
|
orders: {
|
|
total: parseInt(data.orders?.total) || 0,
|
|
items_total: parseInt(data.orders?.items_total) || 0,
|
|
pre_orders: parseInt(data.orders?.pre_orders) || 0,
|
|
local_pickup: parseInt(data.orders?.local_pickup) || 0,
|
|
unique_customers: Array.isArray(data.orders?.unique_customers) ?
|
|
[...new Set(data.orders.unique_customers)] : [],
|
|
payment_methods: data.orders?.payment_methods || {},
|
|
categories: data.orders?.categories || {},
|
|
brands: data.orders?.brands || {},
|
|
shipping_states: data.orders?.shipping_states || {},
|
|
status: {
|
|
pre_order: parseInt(data.orders?.status?.pre_order) || 0,
|
|
ready: parseInt(data.orders?.status?.ready) || 0,
|
|
on_hold: parseInt(data.orders?.status?.on_hold) || 0
|
|
}
|
|
},
|
|
hourly_distribution: Array.isArray(data.hourly_distribution) ?
|
|
data.hourly_distribution.map(count => parseInt(count) || 0) :
|
|
Array(24).fill(0),
|
|
refunds: {
|
|
count: parseInt(data.refunds?.count) || 0,
|
|
total: parseFloat(data.refunds?.total) || 0
|
|
},
|
|
cancellations: {
|
|
count: parseInt(data.cancellations?.count) || 0,
|
|
total: parseFloat(data.cancellations?.total) || 0
|
|
}
|
|
};
|
|
|
|
// Sort daily data by date
|
|
normalized.revenue.daily.sort((a, b) => new Date(a.date) - new Date(b.date));
|
|
|
|
// Calculate any derived values
|
|
if (normalized.orders.total > 0) {
|
|
normalized.orders.average_order_value = normalized.revenue.total / normalized.orders.total;
|
|
normalized.orders.average_items = normalized.orders.items_total / normalized.orders.total;
|
|
}
|
|
|
|
return normalized;
|
|
};
|
|
|
|
// Main component
|
|
const KlaviyoStats = ({ className }) => {
|
|
const [timeRange, setTimeRange] = useState("today");
|
|
const [timeRangeChanging, setTimeRangeChanging] = useState(false);
|
|
const [metrics, setMetrics] = useState(null);
|
|
const [previousMetrics, setPreviousMetrics] = useState(null);
|
|
const [error, setError] = useState(null);
|
|
const [selectedMetric, setSelectedMetric] = useState(null);
|
|
const [lastUpdate, setLastUpdate] = useState(null);
|
|
|
|
// Extended data pre-loaded in background
|
|
const [extendedData, setExtendedData] = useState(null);
|
|
const [isLoadingExtraData, setIsLoadingExtraData] = useState(false);
|
|
const [isLoadingPrevious, setIsLoadingPrevious] = useState(false);
|
|
|
|
const handleTimeRangeChange = useCallback(async (newRange) => {
|
|
const validRanges = [
|
|
'today',
|
|
'yesterday',
|
|
'last2days',
|
|
'last7days',
|
|
'last30days',
|
|
'last90days',
|
|
'previous7days',
|
|
'previous30days',
|
|
'previous90days'
|
|
];
|
|
|
|
if (!validRanges.includes(newRange)) {
|
|
console.error(`Invalid time range: ${newRange}`);
|
|
return;
|
|
}
|
|
setTimeRangeChanging(true);
|
|
setTimeRange(newRange);
|
|
}, []);
|
|
|
|
const isSingleDay = timeRange === "today" || timeRange === "yesterday";
|
|
|
|
const processedMetrics = useMemo(() => {
|
|
if (!metrics) return null;
|
|
|
|
console.log("[KLAVIYO STATS] Processing metrics:", {
|
|
hasMetrics: !!metrics,
|
|
hasPreviousMetrics: !!previousMetrics,
|
|
revenue: metrics.revenue,
|
|
orders: metrics.orders,
|
|
refunds: metrics.refunds,
|
|
cancellations: metrics.cancellations,
|
|
timeRange
|
|
});
|
|
|
|
const getComparison = (current, previous) => {
|
|
if (!previous) return null;
|
|
const diff = current - previous;
|
|
return {
|
|
diff,
|
|
percent: previous ? (diff / previous) * 100 : 0,
|
|
increased: diff > 0,
|
|
};
|
|
};
|
|
|
|
// Ensure we have valid numbers for all metrics
|
|
const currentRevenue = parseFloat(metrics.revenue?.total) || 0;
|
|
const previousRevenue = parseFloat(previousMetrics?.revenue?.total) || 0;
|
|
const revenueComparison = getComparison(currentRevenue, previousRevenue);
|
|
|
|
const totalOrders = parseInt(metrics.orders?.total) || 0;
|
|
const totalItems = parseInt(metrics.orders?.items_total) || 0;
|
|
const shippedOrders = parseInt(metrics.orders?.shipped_orders) || 0;
|
|
const onHoldCount = parseInt(metrics.orders?.status?.on_hold) || 0;
|
|
const preOrderCount = parseInt(metrics.orders?.pre_orders) || 0;
|
|
const localPickupCount = parseInt(metrics.orders?.local_pickup) || 0;
|
|
const refundCount = parseInt(metrics.refunds?.count) || 0;
|
|
const refundTotal = parseFloat(metrics.refunds?.total) || 0;
|
|
const cancelCount = parseInt(metrics.cancellations?.count) || 0;
|
|
const cancelTotal = parseFloat(metrics.cancellations?.total) || 0;
|
|
|
|
const avgOrderValue = totalOrders > 0 ? currentRevenue / totalOrders : 0;
|
|
const avgItems = totalOrders > 0 ? totalItems / totalOrders : 0;
|
|
|
|
const processed = {
|
|
revenue: {
|
|
current: currentRevenue,
|
|
previous: previousRevenue,
|
|
comparison: revenueComparison,
|
|
},
|
|
orders: {
|
|
current: totalOrders,
|
|
items: totalItems,
|
|
avgItems: avgItems,
|
|
avgValue: avgOrderValue,
|
|
shipped_orders: shippedOrders,
|
|
locations: Object.keys(metrics.orders?.shipping_locations || {}).length,
|
|
preOrders: {
|
|
count: preOrderCount,
|
|
percent: totalOrders > 0 ? (preOrderCount / totalOrders) * 100 : 0,
|
|
},
|
|
localPickup: {
|
|
count: localPickupCount,
|
|
percent: totalOrders > 0 ? (localPickupCount / totalOrders) * 100 : 0,
|
|
},
|
|
onHold: {
|
|
count: onHoldCount,
|
|
percent: totalOrders > 0 ? (onHoldCount / totalOrders) * 100 : 0,
|
|
},
|
|
largest: metrics.orders?.largest || { value: 0, items: 0 },
|
|
smallest: metrics.orders?.smallest || { value: 0, items: 0 },
|
|
},
|
|
products: {
|
|
brands: Object.keys(metrics.orders?.brands || {}).length,
|
|
categories: Object.keys(metrics.orders?.categories || {}).length,
|
|
},
|
|
peak: metrics.peak_hour || null,
|
|
bestDay: metrics.revenue?.best_day || null,
|
|
refunds: {
|
|
count: refundCount,
|
|
total: refundTotal,
|
|
},
|
|
cancellations: {
|
|
count: cancelCount,
|
|
total: cancelTotal,
|
|
},
|
|
};
|
|
|
|
console.log("[KLAVIYO STATS] Processed metrics:", processed);
|
|
return processed;
|
|
}, [metrics, previousMetrics, timeRange]);
|
|
|
|
const RevenueCard = useMemo(() => {
|
|
const comparison = processedMetrics?.revenue.comparison;
|
|
|
|
return (
|
|
<MetricCard
|
|
title="Revenue"
|
|
value={
|
|
timeRangeChanging ? (
|
|
<Skeleton className="h-8 w-32" />
|
|
) : formatCurrency(processedMetrics?.revenue.current, 0)
|
|
}
|
|
subtitle={
|
|
<div className="flex items-center justify-between">
|
|
{timeRangeChanging || isLoadingPrevious ? (
|
|
<Skeleton className="h-4 w-24" />
|
|
) : previousMetrics ? (
|
|
<>
|
|
<span>{`prev: ${formatCurrency(previousMetrics.revenue?.total || 0, 0)}`}</span>
|
|
{comparison && (
|
|
<div className={`flex items-center ${comparison.increased ? "text-green-500" : "text-red-500"} text-xs ml-2`}>
|
|
{comparison.increased ? <ArrowUp className="h-3 w-3 mr-1" /> : <ArrowDown className="h-3 w-3 mr-1" />}
|
|
{Math.abs(comparison.percent).toFixed(1)}%
|
|
</div>
|
|
)}
|
|
</>
|
|
) : null}
|
|
</div>
|
|
}
|
|
icon={DollarSign}
|
|
iconColor="text-green-500"
|
|
onClick={() => setSelectedMetric("revenue")}
|
|
/>
|
|
);
|
|
}, [timeRangeChanging, isLoadingPrevious, processedMetrics, previousMetrics]);
|
|
|
|
const hasFetchedExtendedData = useRef(false);
|
|
|
|
const fetchExtendedData = useCallback(async () => {
|
|
if (hasFetchedExtendedData.current) return; // Skip fetch if already fetched
|
|
setIsLoadingExtraData(true);
|
|
|
|
try {
|
|
const extendedResponse = await fetch(`/api/klaviyo/metrics/last30days`);
|
|
const extendedData = await extendedResponse.json();
|
|
setExtendedData(extendedData);
|
|
hasFetchedExtendedData.current = true; // Mark as fetched
|
|
} catch (error) {
|
|
console.error("Error fetching extended data:", error);
|
|
} finally {
|
|
setIsLoadingExtraData(false);
|
|
}
|
|
}, []);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
console.log("[KLAVIYO STATS] Starting fetchData:", { timeRange });
|
|
setTimeRangeChanging(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// Fetch current period data
|
|
const currentResponse = await fetch(`/api/klaviyo/metrics/${timeRange}`);
|
|
if (!currentResponse.ok) {
|
|
throw new Error(`Failed to fetch current metrics: ${currentResponse.status}`);
|
|
}
|
|
|
|
const rawData = await currentResponse.json();
|
|
|
|
// Validate and normalize the data
|
|
try {
|
|
validateMetricsData(rawData);
|
|
const currentData = normalizeMetricsData(rawData);
|
|
setMetrics(currentData);
|
|
setLastUpdate(DateTime.now().setZone('America/New_York'));
|
|
} catch (validationError) {
|
|
console.error("[KLAVIYO STATS] Data validation error:", validationError);
|
|
throw new Error(`Invalid data structure: ${validationError.message}`);
|
|
}
|
|
|
|
// Fetch previous period data
|
|
const prevPeriod = getPreviousPeriod(timeRange);
|
|
setIsLoadingPrevious(true);
|
|
|
|
try {
|
|
const previousResponse = await fetch(`/api/klaviyo/metrics/${prevPeriod}`);
|
|
if (!previousResponse.ok) {
|
|
console.warn(`Failed to fetch previous period metrics: ${previousResponse.status}`);
|
|
} else {
|
|
const rawPreviousData = await previousResponse.json();
|
|
try {
|
|
validateMetricsData(rawPreviousData);
|
|
const previousData = normalizeMetricsData(rawPreviousData);
|
|
setPreviousMetrics(previousData);
|
|
} catch (validationError) {
|
|
console.warn("[KLAVIYO STATS] Previous period data validation error:", validationError);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("[KLAVIYO STATS] Error fetching previous period:", error);
|
|
} finally {
|
|
setIsLoadingPrevious(false);
|
|
}
|
|
|
|
console.log("[KLAVIYO STATS] Processed data:", {
|
|
timeRange,
|
|
totalRevenue: metrics?.revenue?.total,
|
|
dailyCount: metrics?.revenue?.daily?.length,
|
|
hasPreviousData: !!previousMetrics
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error("[KLAVIYO STATS] Error fetching data:", error);
|
|
setError(error.message);
|
|
} finally {
|
|
setTimeRangeChanging(false);
|
|
}
|
|
}, [timeRange, getPreviousPeriod]);
|
|
|
|
// Add helper function for getting previous period
|
|
const getPreviousPeriod = useCallback((currentRange) => {
|
|
switch (currentRange) {
|
|
case 'today':
|
|
return 'yesterday';
|
|
case 'yesterday':
|
|
return 'last2days';
|
|
case 'last7days':
|
|
return 'previous7days';
|
|
case 'last30days':
|
|
return 'previous30days';
|
|
case 'last90days':
|
|
return 'previous90days';
|
|
default:
|
|
return currentRange;
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let isSubscribed = true;
|
|
|
|
const loadMainData = async () => {
|
|
if (!isSubscribed) return;
|
|
console.log("[KLAVIYO STATS] Loading main data for timeRange:", timeRange);
|
|
await fetchData();
|
|
};
|
|
|
|
const loadExtendedData = async () => {
|
|
if (!isSubscribed) return;
|
|
console.log("[KLAVIYO STATS] Loading extended data");
|
|
await fetchExtendedData();
|
|
};
|
|
|
|
loadMainData().then(() => {
|
|
loadExtendedData();
|
|
});
|
|
|
|
let interval;
|
|
if (timeRange === "today") {
|
|
interval = setInterval(() => {
|
|
if (isSubscribed) {
|
|
console.log("[KLAVIYO STATS] Auto-refreshing today's data");
|
|
loadMainData();
|
|
}
|
|
}, 5 * 60 * 1000);
|
|
}
|
|
|
|
return () => {
|
|
isSubscribed = false;
|
|
if (interval) clearInterval(interval);
|
|
};
|
|
}, [timeRange, fetchData, fetchExtendedData]);
|
|
|
|
const dataForDetails = extendedData || metrics;
|
|
|
|
const renderMetricDetails = useCallback(() => {
|
|
if (!selectedMetric || !metrics) return null;
|
|
const isDetailLoading = !extendedData || timeRangeChanging || isLoadingExtraData;
|
|
|
|
switch (selectedMetric) {
|
|
case "revenue":
|
|
return <RevenueDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
|
|
case "orders":
|
|
return <OrdersDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
|
|
case "average_order":
|
|
return <AverageOrderDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
|
|
case "brands_categories":
|
|
return <BrandsAndCategoriesDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
|
|
case "shipped":
|
|
return <ShippedOrdersDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
|
|
case "pre_orders":
|
|
return <PreOrdersDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
|
|
case "local_pickup":
|
|
return <LocalPickupDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
|
|
case "on_hold":
|
|
return <OnHoldDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
|
|
case "refunds":
|
|
return <RefundsDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
|
|
case "cancellations":
|
|
return <CancellationsDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
|
|
case "order_range":
|
|
return <OrderRangeDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
|
|
case "peak_hour":
|
|
return <PeakHourDetails metrics={dataForDetails} isLoading={isDetailLoading} />;
|
|
default:
|
|
return null;
|
|
}
|
|
}, [selectedMetric, metrics, timeRangeChanging, isLoadingExtraData, extendedData, dataForDetails]);
|
|
|
|
if (error) {
|
|
return (
|
|
<Card className={className}>
|
|
<CardContent className="pt-6">
|
|
<div className="text-red-600 dark:text-red-400">
|
|
Error loading metrics: {error}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}return (
|
|
<Card className={className}>
|
|
<CardHeader>
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<CardTitle>Sales Dashboard</CardTitle>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{timeRange === "today" ? (
|
|
`Today (${DateTime.now().setZone('America/New_York').toFormat('M/d h:mm a')} ET)`
|
|
) : timeRange === "yesterday" ? (
|
|
`Yesterday (${DateTime.now().setZone('America/New_York').minus({ days: 1 }).toFormat('M/d')} 1:00 AM - 12:59 AM ET)`
|
|
) : (
|
|
`${DateTime.now().setZone('America/New_York').minus({
|
|
days: timeRange === "last7days" ? 6 :
|
|
timeRange === "last30days" ? 29 : 89
|
|
}).toFormat('M/d')} - ${DateTime.now().setZone('America/New_York').toFormat('M/d h:mm a')} ET`
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<TimeRangeSelect
|
|
value={timeRange}
|
|
onChange={handleTimeRangeChange}
|
|
className="w-40"
|
|
/>
|
|
{lastUpdate && !timeRangeChanging && (
|
|
<span className="text-sm text-gray-500">
|
|
{lastUpdate.toFormat("hh:mm a")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent>
|
|
{error ? (
|
|
<ErrorState message={error} />
|
|
) : timeRangeChanging ? (
|
|
<SkeletonMetricGrid />
|
|
) : processedMetrics ? (
|
|
<>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{RevenueCard}
|
|
|
|
<MetricCard
|
|
title="Orders"
|
|
value={processedMetrics.orders.current.toLocaleString()}
|
|
subtitle={`${processedMetrics.orders.items.toLocaleString()} items`}
|
|
icon={ShoppingCart}
|
|
iconColor="text-blue-500"
|
|
onClick={() => setSelectedMetric("orders")}
|
|
/>
|
|
|
|
<MetricCard
|
|
title="Average Order"
|
|
value={formatCurrency(processedMetrics.orders.avgValue)}
|
|
subtitle={`${processedMetrics.orders.avgItems.toFixed(
|
|
1
|
|
)} items/order`}
|
|
icon={CircleDollarSign}
|
|
iconColor="text-purple-500"
|
|
onClick={() => setSelectedMetric("average_order")}
|
|
/>
|
|
|
|
<MetricCard
|
|
title="Brands & Categories"
|
|
value={processedMetrics.products.brands}
|
|
subtitle={`${processedMetrics.products.categories} categories`}
|
|
icon={Tags}
|
|
iconColor="text-indigo-500"
|
|
onClick={() => setSelectedMetric("brands_categories")}
|
|
/>
|
|
|
|
<MetricCard
|
|
title="Shipped Orders"
|
|
value={processedMetrics.orders.shipped_orders}
|
|
subtitle={`${processedMetrics.orders.locations} locations`}
|
|
icon={Package}
|
|
iconColor="text-teal-500"
|
|
onClick={() => setSelectedMetric("shipped")}
|
|
/>
|
|
|
|
<MetricCard
|
|
title="Pre-Orders"
|
|
value={processedMetrics.orders.preOrders.count}
|
|
subtitle={`${processedMetrics.orders.preOrders.percent.toFixed(
|
|
1
|
|
)}% of orders`}
|
|
icon={Clock}
|
|
iconColor="text-yellow-500"
|
|
onClick={() => setSelectedMetric("pre_orders")}
|
|
/>
|
|
|
|
<MetricCard
|
|
title="Local Pickup"
|
|
value={processedMetrics.orders.localPickup.count}
|
|
subtitle={`${processedMetrics.orders.localPickup.percent.toFixed(
|
|
1
|
|
)}% of orders`}
|
|
icon={Map}
|
|
iconColor="text-cyan-500"
|
|
onClick={() => setSelectedMetric("local_pickup")}
|
|
/>
|
|
|
|
<MetricCard
|
|
title="On Hold"
|
|
value={processedMetrics?.orders?.onHold?.count ?? 0}
|
|
subtitle={`${(processedMetrics?.orders?.onHold?.percent ?? 0).toFixed(
|
|
1
|
|
)}% of orders`}
|
|
icon={AlertCircle}
|
|
iconColor="text-red-500"
|
|
onClick={() => setSelectedMetric("on_hold")}
|
|
/>
|
|
|
|
{isSingleDay ? (
|
|
<MetricCard
|
|
title="Peak Hour"
|
|
value={
|
|
processedMetrics.peak
|
|
? `${processedMetrics.peak.hour
|
|
.toString()
|
|
.padStart(2, "0")}:00`
|
|
: "N/A"
|
|
}
|
|
subtitle={
|
|
processedMetrics.peak
|
|
? `${processedMetrics.peak.orders} orders`
|
|
: undefined
|
|
}
|
|
icon={Clock}
|
|
iconColor="text-pink-500"
|
|
onClick={() => setSelectedMetric("peak_hour")}
|
|
/>
|
|
) : (
|
|
<MetricCard
|
|
title="Best Day"
|
|
value={formatCurrency(processedMetrics.bestDay?.value || 0)}
|
|
subtitle={
|
|
processedMetrics.bestDay?.date
|
|
? formatChartDate(processedMetrics.bestDay.date)
|
|
: undefined
|
|
}
|
|
icon={TrendingUp}
|
|
iconColor="text-emerald-500"
|
|
onClick={() => setSelectedMetric("revenue")}
|
|
/>
|
|
|
|
)}
|
|
|
|
<MetricCard
|
|
title="Refunds"
|
|
value={processedMetrics.refunds.count}
|
|
subtitle={formatCurrency(processedMetrics.refunds.total)}
|
|
icon={RefreshCcw}
|
|
iconColor="text-orange-500"
|
|
onClick={() => setSelectedMetric("refunds")}
|
|
/>
|
|
|
|
<MetricCard
|
|
title="Cancellations"
|
|
value={processedMetrics.cancellations.count}
|
|
subtitle={formatCurrency(processedMetrics.cancellations.total)}
|
|
icon={XCircle}
|
|
iconColor="text-rose-500"
|
|
onClick={() => setSelectedMetric("cancellations")}
|
|
/>
|
|
|
|
<MetricCard
|
|
title="Order Range"
|
|
value={formatCurrency(processedMetrics.orders.largest.value)}
|
|
subtitle={formatCurrency(
|
|
processedMetrics.orders.smallest.value
|
|
)}
|
|
icon={TrendingUp}
|
|
iconColor="text-violet-500"
|
|
onClick={() => setSelectedMetric("order_range")}
|
|
/>
|
|
</div>
|
|
{selectedMetric && (
|
|
<Dialog
|
|
open={!!selectedMetric}
|
|
onOpenChange={() => setSelectedMetric(null)}
|
|
>
|
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{selectedMetric
|
|
.split("_")
|
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
.join(" ")}{" "}
|
|
Details
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
{renderMetricDetails()}
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default KlaviyoStats; |