Implemented pagination controls and page size selection for the top customers section in CustomerInsightsChart. Updated analytics-service to support paginated customer insights API calls and handle pagination metadata. Improves usability for stores with large customer bases.
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Users, Crown, UserPlus, UserCheck, Star, ChevronLeft, ChevronRight } from "lucide-react";
|
|
import { getCustomerInsightsWithStore, type CustomerInsights } from "@/lib/services/analytics-service";
|
|
import { formatGBP } from "@/utils/format";
|
|
import { CustomerInsightsSkeleton } from './SkeletonLoaders';
|
|
|
|
export default function CustomerInsightsChart() {
|
|
const [data, setData] = useState<CustomerInsights | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [pageSize, setPageSize] = useState(10);
|
|
const { toast } = useToast();
|
|
|
|
useEffect(() => {
|
|
const fetchCustomerData = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
const response = await getCustomerInsightsWithStore(currentPage, pageSize);
|
|
setData(response);
|
|
} catch (err) {
|
|
console.error('Error fetching customer insights:', err);
|
|
setError('Failed to load customer data');
|
|
toast({
|
|
title: "Error",
|
|
description: "Failed to load customer insights data.",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchCustomerData();
|
|
}, [toast, currentPage, pageSize]);
|
|
|
|
const getSegmentColor = (segment: string) => {
|
|
switch (segment) {
|
|
case 'new':
|
|
return 'bg-blue-500';
|
|
case 'returning':
|
|
return 'bg-green-500';
|
|
case 'loyal':
|
|
return 'bg-purple-500';
|
|
case 'vip':
|
|
return 'bg-yellow-500';
|
|
default:
|
|
return 'bg-gray-500';
|
|
}
|
|
};
|
|
|
|
const getSegmentIcon = (segment: string) => {
|
|
switch (segment) {
|
|
case 'new':
|
|
return <UserPlus className="h-4 w-4" />;
|
|
case 'returning':
|
|
return <UserCheck className="h-4 w-4" />;
|
|
case 'loyal':
|
|
return <Star className="h-4 w-4" />;
|
|
case 'vip':
|
|
return <Crown className="h-4 w-4" />;
|
|
default:
|
|
return <Users className="h-4 w-4" />;
|
|
}
|
|
};
|
|
|
|
const getSegmentLabel = (segment: string) => {
|
|
switch (segment) {
|
|
case 'new':
|
|
return 'New Customers';
|
|
case 'returning':
|
|
return 'Returning Customers';
|
|
case 'loyal':
|
|
return 'Loyal Customers';
|
|
case 'vip':
|
|
return 'VIP Customers';
|
|
default:
|
|
return 'Unknown';
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<CustomerInsightsSkeleton
|
|
title="Customer Insights"
|
|
description="Customer segmentation and behavior analysis"
|
|
icon={Users}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (error || !data) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Users className="h-5 w-5" />
|
|
Customer Insights
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-center py-8">
|
|
<Users className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
|
<p className="text-muted-foreground">Failed to load customer data</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const segments = Object.entries(data.segments);
|
|
const totalCustomers = data.totalCustomers;
|
|
|
|
const handlePageChange = (newPage: number) => {
|
|
setCurrentPage(newPage);
|
|
};
|
|
|
|
const handlePageSizeChange = (newPageSize: string) => {
|
|
setPageSize(parseInt(newPageSize));
|
|
setCurrentPage(1); // Reset to first page when changing page size
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Customer Overview */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Users className="h-5 w-5" />
|
|
Customer Overview
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Total customers and average orders per customer
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<div className="text-center">
|
|
<div className="text-3xl font-bold text-blue-600">
|
|
{data.totalCustomers}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">Total Customers</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-3xl font-bold text-green-600">
|
|
{data.averageOrdersPerCustomer}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">Avg Orders/Customer</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Customer Segments */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Customer Segments</CardTitle>
|
|
<CardDescription>
|
|
Breakdown of customers by purchase frequency
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{segments.map(([segment, count]) => {
|
|
const percentage = totalCustomers > 0 ? (count / totalCustomers * 100).toFixed(1) : '0';
|
|
const Icon = getSegmentIcon(segment);
|
|
|
|
return (
|
|
<div key={segment} className="flex items-center justify-between p-3 border rounded-lg">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`p-2 rounded-full ${getSegmentColor(segment)} text-white`}>
|
|
{Icon}
|
|
</div>
|
|
<div>
|
|
<div className="font-medium">{getSegmentLabel(segment)}</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{count} customers
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-lg font-bold">{percentage}%</div>
|
|
<div className="text-sm text-muted-foreground">of total</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Top Customers */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Crown className="h-5 w-5" />
|
|
Top Customers
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Your highest-spending customers
|
|
{data.pagination && (
|
|
<span className="ml-2 text-sm">
|
|
(Showing {data.pagination.startIndex}-{data.pagination.endIndex} of {data.pagination.totalCustomers})
|
|
</span>
|
|
)}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{data.topCustomers.length === 0 ? (
|
|
<div className="text-center py-8">
|
|
<Crown className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
|
<p className="text-muted-foreground">No customer data available</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="space-y-4">
|
|
{data.topCustomers.map((customer, index) => {
|
|
const globalRank = data.pagination ? (data.pagination.currentPage - 1) * data.pagination.limit + index + 1 : index + 1;
|
|
return (
|
|
<div key={customer._id} className="flex items-center justify-between p-3 border rounded-lg">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center justify-center w-8 h-8 bg-primary text-primary-foreground rounded-full text-sm font-bold">
|
|
{globalRank}
|
|
</div>
|
|
<div>
|
|
<div className="font-medium">{customer.displayName || `Customer #${customer._id.slice(-6)}`}</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{customer.orderCount} orders
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="font-bold text-green-600">
|
|
{formatGBP(customer.totalSpent)}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{formatGBP(customer.averageOrderValue)} avg
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Pagination Controls */}
|
|
{data.pagination && data.pagination.totalPages > 1 && (
|
|
<div className="mt-6 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-muted-foreground">Show</span>
|
|
<Select value={pageSize.toString()} onValueChange={handlePageSizeChange}>
|
|
<SelectTrigger className="w-20">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="5">5</SelectItem>
|
|
<SelectItem value="10">10</SelectItem>
|
|
<SelectItem value="20">20</SelectItem>
|
|
<SelectItem value="50">50</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<span className="text-sm text-muted-foreground">customers per page</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handlePageChange(currentPage - 1)}
|
|
disabled={!data.pagination.hasPrevPage || isLoading}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
Previous
|
|
</Button>
|
|
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-sm text-muted-foreground">
|
|
Page {data.pagination.currentPage} of {data.pagination.totalPages}
|
|
</span>
|
|
</div>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handlePageChange(currentPage + 1)}
|
|
disabled={!data.pagination.hasNextPage || isLoading}
|
|
>
|
|
Next
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|