Introduces a NotificationProvider context to centralize notification logic and state, refactoring UnifiedNotifications to use this context. Adds a privacy toggle to AnalyticsDashboard and RevenueChart to allow hiding sensitive numbers. Updates layout to wrap the app with NotificationProvider.
316 lines
10 KiB
TypeScript
316 lines
10 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, Suspense } from 'react';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import {
|
|
TrendingUp,
|
|
ShoppingCart,
|
|
Users,
|
|
Package,
|
|
DollarSign,
|
|
BarChart3,
|
|
PieChart,
|
|
Activity,
|
|
RefreshCw,
|
|
Eye,
|
|
EyeOff
|
|
} from "lucide-react";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import MetricsCard from "./MetricsCard";
|
|
import { getAnalyticsOverviewWithStore, type AnalyticsOverview } from "@/lib/services/analytics-service";
|
|
import { formatGBP } from "@/utils/format";
|
|
import { MetricsCardSkeleton } from './SkeletonLoaders';
|
|
import dynamic from 'next/dynamic';
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
|
|
// Lazy load chart components
|
|
const RevenueChart = dynamic(() => import('./RevenueChart'), {
|
|
loading: () => <ChartSkeleton />
|
|
});
|
|
|
|
const ProductPerformanceChart = dynamic(() => import('./ProductPerformanceChart'), {
|
|
loading: () => <ChartSkeleton />
|
|
});
|
|
|
|
const CustomerInsightsChart = dynamic(() => import('./CustomerInsightsChart'), {
|
|
loading: () => <ChartSkeleton />
|
|
});
|
|
|
|
const OrderAnalyticsChart = dynamic(() => import('./OrderAnalyticsChart'), {
|
|
loading: () => <ChartSkeleton />
|
|
});
|
|
|
|
// Chart loading skeleton
|
|
function ChartSkeleton() {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<Skeleton className="h-6 w-48" />
|
|
<Skeleton className="h-4 w-72" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="h-80 w-full flex items-center justify-center">
|
|
<div className="flex flex-col items-center gap-2">
|
|
<BarChart3 className="h-8 w-8 text-muted-foreground animate-pulse" />
|
|
<Skeleton className="h-4 w-32" />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
interface AnalyticsDashboardProps {
|
|
initialData: AnalyticsOverview;
|
|
}
|
|
|
|
export default function AnalyticsDashboard({ initialData }: AnalyticsDashboardProps) {
|
|
const [data, setData] = useState<AnalyticsOverview>(initialData);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [timeRange, setTimeRange] = useState('30');
|
|
const [hideNumbers, setHideNumbers] = useState(false);
|
|
const { toast } = useToast();
|
|
|
|
// Function to mask sensitive numbers
|
|
const maskValue = (value: string): string => {
|
|
if (!hideNumbers) return value;
|
|
|
|
// For currency values (£X.XX), show £***
|
|
if (value.includes('£')) {
|
|
return '£***';
|
|
}
|
|
|
|
// For regular numbers, replace with asterisks maintaining similar length
|
|
if (value.match(/^\d/)) {
|
|
const numLength = value.replace(/[,\.]/g, '').length;
|
|
return '*'.repeat(Math.min(numLength, 4));
|
|
}
|
|
|
|
return value;
|
|
};
|
|
|
|
const refreshData = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
const newData = await getAnalyticsOverviewWithStore();
|
|
setData(newData);
|
|
toast({
|
|
title: "Data refreshed",
|
|
description: "Analytics data has been updated successfully.",
|
|
});
|
|
} catch (error) {
|
|
toast({
|
|
title: "Error",
|
|
description: "Failed to refresh analytics data.",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const metrics = [
|
|
{
|
|
title: "Total Revenue",
|
|
value: maskValue(formatGBP(data.revenue.total)),
|
|
description: "All-time revenue",
|
|
icon: DollarSign,
|
|
trend: data.revenue.monthly > 0 ? "up" as const : "neutral" as const,
|
|
trendValue: hideNumbers ? "Hidden" : `${formatGBP(data.revenue.monthly)} this month`
|
|
},
|
|
{
|
|
title: "Total Orders",
|
|
value: maskValue(data.orders.total.toLocaleString()),
|
|
description: "All-time orders",
|
|
icon: ShoppingCart,
|
|
trend: data.orders.completed > 0 ? "up" as const : "neutral" as const,
|
|
trendValue: hideNumbers ? "Hidden" : `${data.orders.completed} completed`
|
|
},
|
|
{
|
|
title: "Unique Customers",
|
|
value: maskValue(data.customers.unique.toLocaleString()),
|
|
description: "Total customers",
|
|
icon: Users,
|
|
trend: "neutral" as const,
|
|
trendValue: "Lifetime customers"
|
|
},
|
|
{
|
|
title: "Products",
|
|
value: maskValue(data.products.total.toLocaleString()),
|
|
description: "Active products",
|
|
icon: Package,
|
|
trend: "neutral" as const,
|
|
trendValue: "In your store"
|
|
}
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header with Privacy Toggle */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-3xl font-bold tracking-tight">Analytics Dashboard</h2>
|
|
<p className="text-muted-foreground">
|
|
Overview of your store's performance and metrics.
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setHideNumbers(!hideNumbers)}
|
|
className="flex items-center gap-2"
|
|
>
|
|
{hideNumbers ? (
|
|
<>
|
|
<EyeOff className="h-4 w-4" />
|
|
Show Numbers
|
|
</>
|
|
) : (
|
|
<>
|
|
<Eye className="h-4 w-4" />
|
|
Hide Numbers
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={refreshData}
|
|
disabled={isLoading}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Key Metrics Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
{isLoading ? (
|
|
[...Array(4)].map((_, i) => (
|
|
<MetricsCardSkeleton key={i} />
|
|
))
|
|
) : (
|
|
metrics.map((metric) => (
|
|
<MetricsCard key={metric.title} {...metric} />
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Completion Rate Card */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Activity className="h-5 w-5" />
|
|
Order Completion Rate
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Percentage of orders that have been successfully completed
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isLoading ? (
|
|
<div className="flex items-center gap-4">
|
|
<div className="h-12 w-16 bg-muted/20 rounded animate-pulse" />
|
|
<div className="flex-1">
|
|
<div className="w-full bg-muted/20 rounded-full h-2 animate-pulse" />
|
|
</div>
|
|
<div className="h-6 w-16 bg-muted/20 rounded animate-pulse" />
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-4">
|
|
<div className="text-3xl font-bold">
|
|
{hideNumbers ? "**%" : `${data.orders.completionRate}%`}
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="w-full bg-secondary rounded-full h-2">
|
|
<div
|
|
className="bg-primary h-2 rounded-full transition-all duration-300"
|
|
style={{ width: hideNumbers ? "0%" : `${data.orders.completionRate}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Badge variant="secondary">
|
|
{hideNumbers ? "** / **" : `${data.orders.completed} / ${data.orders.total}`}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Time Period Selector */}
|
|
<div className="flex flex-col sm:flex-row gap-4 sm:items-center sm:justify-between">
|
|
<div>
|
|
<h3 className="text-lg font-semibold">Time Period</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
Revenue and Orders tabs use time filtering. Products and Customers show all-time data.
|
|
</p>
|
|
</div>
|
|
<Select value={timeRange} onValueChange={setTimeRange}>
|
|
<SelectTrigger className="w-32">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="7">Last 7 days</SelectItem>
|
|
<SelectItem value="30">Last 30 days</SelectItem>
|
|
<SelectItem value="90">Last 90 days</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Analytics Tabs */}
|
|
<div className="space-y-6">
|
|
<Tabs defaultValue="revenue" className="space-y-6">
|
|
<TabsList className="grid w-full grid-cols-4">
|
|
<TabsTrigger value="revenue" className="flex items-center gap-2">
|
|
<TrendingUp className="h-4 w-4" />
|
|
Revenue
|
|
</TabsTrigger>
|
|
<TabsTrigger value="products" className="flex items-center gap-2">
|
|
<Package className="h-4 w-4" />
|
|
Products
|
|
</TabsTrigger>
|
|
<TabsTrigger value="customers" className="flex items-center gap-2">
|
|
<Users className="h-4 w-4" />
|
|
Customers
|
|
</TabsTrigger>
|
|
<TabsTrigger value="orders" className="flex items-center gap-2">
|
|
<BarChart3 className="h-4 w-4" />
|
|
Orders
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="revenue" className="space-y-6">
|
|
<Suspense fallback={<ChartSkeleton />}>
|
|
<RevenueChart timeRange={timeRange} hideNumbers={hideNumbers} />
|
|
</Suspense>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="products" className="space-y-6">
|
|
<Suspense fallback={<ChartSkeleton />}>
|
|
<ProductPerformanceChart />
|
|
</Suspense>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="customers" className="space-y-6">
|
|
<Suspense fallback={<ChartSkeleton />}>
|
|
<CustomerInsightsChart />
|
|
</Suspense>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="orders" className="space-y-6">
|
|
<Suspense fallback={<ChartSkeleton />}>
|
|
<OrderAnalyticsChart timeRange={timeRange} />
|
|
</Suspense>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|