Files
ember-market-frontend/app/dashboard/storefront/customers/page.tsx
g 0062aa2dfe Add robust error boundaries and improved skeletons to dashboard
Introduces reusable error boundary and suspense timeout components across dashboard pages for better error handling and user feedback. Enhances loading skeletons with subtle progress indicators, animation, and slow-loading warnings. All dynamic imports now include error handling and improved fallback skeletons, and a shared DashboardContentWrapper is added for consistent dashboard content loading experience.
2025-12-31 05:20:44 +00:00

561 lines
25 KiB
TypeScript

"use client";
import React, { useEffect, useState, useCallback } from "react";
import { getCustomers, type CustomerStats } from "@/lib/api";
import { formatCurrency } from "@/utils/format";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import Layout from "@/components/layout/layout";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
ChevronLeft,
ChevronRight,
Loader2,
Users,
ArrowUpDown,
MessageCircle,
UserPlus,
MoreHorizontal,
Search,
X
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
export default function CustomerManagementPage() {
const router = useRouter();
const [customers, setCustomers] = useState<CustomerStats[]>([]);
const [filteredCustomers, setFilteredCustomers] = useState<CustomerStats[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(25);
const [selectedCustomer, setSelectedCustomer] = useState<CustomerStats | null>(null);
const [sortConfig, setSortConfig] = useState<{
column: "totalOrders" | "totalSpent" | "lastOrderDate";
direction: "asc" | "desc";
}>({ column: "totalSpent", direction: "desc" });
const fetchCustomers = useCallback(async () => {
try {
setLoading(true);
const response = await getCustomers(page, itemsPerPage);
// Sort customers based on current sort config
let sortedCustomers = [...response.customers];
sortedCustomers.sort((a, b) => {
if (sortConfig.column === "totalOrders") {
return sortConfig.direction === "asc"
? a.totalOrders - b.totalOrders
: b.totalOrders - a.totalOrders;
} else if (sortConfig.column === "totalSpent") {
return sortConfig.direction === "asc"
? a.totalSpent - b.totalSpent
: b.totalSpent - a.totalSpent;
} else if (sortConfig.column === "lastOrderDate") {
// Handle null lastOrderDate values
if (!a.lastOrderDate && !b.lastOrderDate) return 0;
if (!a.lastOrderDate) return sortConfig.direction === "asc" ? -1 : 1;
if (!b.lastOrderDate) return sortConfig.direction === "asc" ? 1 : -1;
// Both have valid dates
return sortConfig.direction === "asc"
? new Date(a.lastOrderDate).getTime() - new Date(b.lastOrderDate).getTime()
: new Date(b.lastOrderDate).getTime() - new Date(a.lastOrderDate).getTime();
}
return 0;
});
setCustomers(sortedCustomers);
setFilteredCustomers(sortedCustomers);
setTotalPages(Math.ceil(response.total / itemsPerPage));
} catch (err) {
toast.error("Failed to fetch customers");
console.error(err);
} finally {
setLoading(false);
}
}, [page, itemsPerPage, sortConfig]);
useEffect(() => {
fetchCustomers();
}, [fetchCustomers]);
useEffect(() => {
const authToken = document.cookie
.split("; ")
.find((row) => row.startsWith("Authorization="))
?.split("=")[1];
if (!authToken) {
router.push("/login");
}
}, [router]);
// Add filter function to filter customers when search query changes
useEffect(() => {
if (!searchQuery.trim()) {
setFilteredCustomers(customers);
} else {
const query = searchQuery.toLowerCase().trim();
const filtered = customers.filter(customer => {
const usernameMatch = customer.telegramUsername?.toLowerCase().includes(query);
const userIdMatch = customer.telegramUserId.toString().includes(query);
return usernameMatch || userIdMatch;
});
setFilteredCustomers(filtered);
}
}, [searchQuery, customers]);
const handlePageChange = (newPage: number) => {
setPage(newPage);
};
const handleItemsPerPageChange = (value: string) => {
setItemsPerPage(parseInt(value, 10));
setPage(1);
};
const handleSort = (column: "totalOrders" | "totalSpent" | "lastOrderDate") => {
setSortConfig(prev => ({
column,
direction: prev.column === column && prev.direction === "asc" ? "desc" : "asc"
}));
};
const clearSearch = () => {
setSearchQuery("");
};
const formatDate = (dateString: string | null | undefined) => {
if (!dateString) return "N/A";
try {
const date = new Date(dateString);
return new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date);
} catch (error) {
return "N/A";
}
};
return (
<Layout>
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-white flex items-center">
<Users className="mr-2 h-6 w-6" />
Customer Management
</h1>
</div>
<div className="bg-black/40 border border-zinc-800 rounded-md overflow-hidden">
<div className="p-4 border-b border-zinc-800 bg-black/60 flex flex-col md:flex-row md:justify-between md:items-center gap-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-gray-400">Show:</div>
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
<SelectTrigger className="w-[70px]">
<SelectValue placeholder="25" />
</SelectTrigger>
<SelectContent>
{[5, 10, 25, 50, 100].map(size => (
<SelectItem key={size} value={size.toString()}>{size}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="relative flex-1 max-w-md">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<Search className="h-4 w-4 text-gray-400" />
</div>
<Input
type="text"
placeholder="Search by username or Telegram ID..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 pr-10 py-2 w-full bg-black/40 border-zinc-700 text-white"
/>
{searchQuery && (
<button
className="absolute inset-y-0 right-0 flex items-center pr-3"
onClick={clearSearch}
>
<X className="h-4 w-4 text-gray-400 hover:text-gray-200" />
</button>
)}
</div>
<div className="text-sm text-gray-400 whitespace-nowrap">
{loading
? "Loading..."
: searchQuery
? `Found ${filteredCustomers.length} matching customers`
: `Showing ${filteredCustomers.length} of ${totalPages * itemsPerPage} customers`}
</div>
</div>
{loading ? (
<div className="p-8 bg-black/60">
{/* Loading indicator */}
<div className="absolute top-0 left-0 right-0 h-1 bg-muted overflow-hidden">
<div className="h-full bg-primary w-1/3"
style={{
background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)',
backgroundSize: '200% 100%',
animation: 'shimmer 2s ease-in-out infinite',
}}
/>
</div>
{/* Table skeleton */}
<div className="space-y-4">
<div className="flex items-center gap-4 pb-4 border-b border-zinc-800">
{['Customer', 'Orders', 'Total Spent', 'Last Order', 'Status'].map((header, i) => (
<Skeleton
key={i}
className="h-4 w-20 flex-1 animate-in fade-in"
style={{
animationDelay: `${i * 50}ms`,
animationDuration: '300ms',
animationFillMode: 'both',
}}
/>
))}
</div>
{[...Array(5)].map((_, i) => (
<div
key={i}
className="flex items-center gap-4 pb-4 border-b border-zinc-800 last:border-b-0 animate-in fade-in"
style={{
animationDelay: `${250 + i * 50}ms`,
animationDuration: '300ms',
animationFillMode: 'both',
}}
>
<div className="flex items-center gap-3 flex-1">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-1">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
<Skeleton className="h-6 w-12 flex-1 rounded-full" />
<Skeleton className="h-4 w-20 flex-1" />
<Skeleton className="h-4 w-24 flex-1" />
<Skeleton className="h-6 w-24 flex-1 rounded-full" />
</div>
))}
</div>
</div>
) : filteredCustomers.length === 0 ? (
<div className="p-8 text-center bg-black/60">
<Users className="h-12 w-12 mx-auto text-gray-500 mb-4" />
<h3 className="text-lg font-medium mb-2 text-white">
{searchQuery ? "No customers matching your search" : "No customers found"}
</h3>
<p className="text-gray-500">
{searchQuery
? "Try a different search term or clear the search"
: "Once you have customers placing orders, they will appear here."}
</p>
{searchQuery && (
<Button variant="outline" size="sm" onClick={clearSearch} className="mt-4">
Clear search
</Button>
)}
</div>
) : (
<div className="overflow-x-auto">
<Table className="[&_tr]:border-b [&_tr]:border-zinc-800 [&_tr:last-child]:border-b-0 [&_td]:border-r [&_td]:border-zinc-800 [&_td:last-child]:border-r-0 [&_th]:border-r [&_th]:border-zinc-800 [&_th:last-child]:border-r-0 [&_tr:hover]:bg-zinc-900/70">
<TableHeader className="bg-black/60 sticky top-0 z-10">
<TableRow>
<TableHead className="w-[180px] text-gray-300">Customer</TableHead>
<TableHead
className="cursor-pointer w-[100px] text-gray-300 text-center"
onClick={() => handleSort("totalOrders")}
>
<div className="flex items-center justify-center">
Orders
<ArrowUpDown className="ml-2 h-4 w-4" />
</div>
</TableHead>
<TableHead
className="cursor-pointer w-[150px] text-gray-300 text-center"
onClick={() => handleSort("totalSpent")}
>
<div className="flex items-center justify-center">
Total Spent
<ArrowUpDown className="ml-2 h-4 w-4" />
</div>
</TableHead>
<TableHead
className="cursor-pointer w-[180px] text-gray-300 text-center"
onClick={() => handleSort("lastOrderDate")}
>
<div className="flex items-center justify-center">
Last Order
<ArrowUpDown className="ml-2 h-4 w-4" />
</div>
</TableHead>
<TableHead className="w-[250px] text-gray-300 text-center">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCustomers.map((customer) => (
<TableRow
key={customer.userId}
className={`cursor-pointer ${!customer.hasOrders ? "bg-black/30" : ""}`}
onClick={() => setSelectedCustomer(customer)}
>
<TableCell>
<div className="font-medium text-gray-100">
@{customer.telegramUsername || "Unknown"}
{!customer.hasOrders && (
<Badge variant="outline" className="ml-2 bg-purple-900/30 text-purple-300 border-purple-700">
<UserPlus className="h-3 w-3 mr-1" />
New
</Badge>
)}
</div>
<div className="text-sm text-gray-400">ID: {customer.telegramUserId}</div>
</TableCell>
<TableCell className="text-center">
<Badge className="bg-gray-700 text-white hover:bg-gray-600">{customer.totalOrders}</Badge>
</TableCell>
<TableCell className="font-medium text-gray-100 text-center">
{formatCurrency(customer.totalSpent)}
</TableCell>
<TableCell className="text-sm text-gray-100 text-center">
{customer.lastOrderDate ? formatDate(customer.lastOrderDate) : "N/A"}
</TableCell>
<TableCell className="text-center">
{customer.hasOrders ? (
<div className="flex justify-center space-x-1">
<Badge className="bg-blue-500 text-white hover:bg-blue-600">
{customer.ordersByStatus.paid} Paid
</Badge>
<Badge className="bg-green-500 text-white hover:bg-green-600">
{customer.ordersByStatus.completed} Completed
</Badge>
<Badge className="bg-amber-500 text-white hover:bg-amber-600">
{customer.ordersByStatus.shipped} Shipped
</Badge>
</div>
) : (
<Badge variant="outline" className="bg-gray-800 text-gray-300 border-gray-700">
No orders yet
</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<div className="p-4 border-t border-zinc-800 bg-black/40 flex justify-between items-center">
<div className="text-sm text-gray-400">
Page {page} of {totalPages}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(Math.max(1, page - 1))}
disabled={page === 1 || loading}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Previous
</Button>
{totalPages > 2 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<span className="sr-only">Go to page</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="bg-black/90 border-zinc-800 max-h-60 overflow-y-auto">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => (
<DropdownMenuItem
key={pageNum}
onClick={() => handlePageChange(pageNum)}
className={`cursor-pointer ${pageNum === page ? 'bg-zinc-800 text-white' : 'text-gray-300'}`}
>
Page {pageNum}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(Math.min(totalPages, page + 1))}
disabled={page === totalPages || loading}
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
</div>
{/* Customer Details Dialog */}
{selectedCustomer && (
<Dialog open={!!selectedCustomer} onOpenChange={(open) => !open && setSelectedCustomer(null)}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl w-full overflow-y-auto max-h-[90vh] z-[80]">
<DialogHeader>
<DialogTitle className="text-base">
Customer Details
</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 py-4">
{/* Customer Information */}
<div>
<div className="mb-4">
<h3 className="text-sm font-medium mb-2">Customer Information</h3>
<div className="space-y-3">
<div className="flex justify-between items-center text-sm">
<div className="text-muted-foreground">Username:</div>
<div className="font-medium">@{selectedCustomer.telegramUsername || "Unknown"}</div>
</div>
<div className="flex justify-between items-center text-sm">
<div className="text-muted-foreground">Telegram ID:</div>
<div className="font-medium">{selectedCustomer.telegramUserId}</div>
</div>
<div className="flex justify-between items-center text-sm">
<div className="text-muted-foreground">Chat ID:</div>
<div className="font-medium">{selectedCustomer.chatId}</div>
</div>
</div>
</div>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
window.open(`https://t.me/${selectedCustomer.telegramUsername || selectedCustomer.telegramUserId}`, '_blank');
}}
>
<MessageCircle className="h-4 w-4 mr-2" />
Open Telegram Chat
</Button>
</div>
{/* Order Statistics */}
<div>
<h3 className="text-sm font-medium mb-2">Order Statistics</h3>
<div className="bg-background rounded-lg border border-border p-4 space-y-3">
<div className="flex justify-between items-center text-sm">
<div className="text-muted-foreground">Total Orders:</div>
<div className="font-medium">{selectedCustomer.totalOrders}</div>
</div>
<div className="flex justify-between items-center text-sm">
<div className="text-muted-foreground">Total Spent:</div>
<div className="font-medium">{formatCurrency(selectedCustomer.totalSpent)}</div>
</div>
<div className="flex justify-between items-center text-sm">
<div className="text-muted-foreground">First Order:</div>
<div className="font-medium">
{formatDate(selectedCustomer.firstOrderDate)}
</div>
</div>
<div className="flex justify-between items-center text-sm">
<div className="text-muted-foreground">Last Order:</div>
<div className="font-medium">
{formatDate(selectedCustomer.lastOrderDate)}
</div>
</div>
</div>
</div>
</div>
{/* Order Status Breakdown */}
<div className="mb-4">
<h3 className="text-sm font-medium mb-2">Order Status Breakdown</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-blue-500/10 rounded-lg border border-blue-500/20 p-3">
<p className="text-sm text-muted-foreground">Paid</p>
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.paid}</p>
</div>
<div className="bg-purple-500/10 rounded-lg border border-purple-500/20 p-3">
<p className="text-sm text-muted-foreground">Acknowledged</p>
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.acknowledged}</p>
</div>
<div className="bg-amber-500/10 rounded-lg border border-amber-500/20 p-3">
<p className="text-sm text-muted-foreground">Shipped</p>
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.shipped}</p>
</div>
<div className="bg-green-500/10 rounded-lg border border-green-500/20 p-3">
<p className="text-sm text-muted-foreground">Completed</p>
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.completed}</p>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setSelectedCustomer(null)}
>
Close
</Button>
<Button
onClick={() => router.push(`/dashboard/storefront/chat/new?userId=${selectedCustomer.telegramUserId}`)}
>
<MessageCircle className="h-4 w-4 mr-2" />
Start Chat
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
</Layout>
);
}