Files
ember-market-frontend/app/dashboard/storefront/customers/page.tsx
g 211cdc71f9
All checks were successful
Build Frontend / build (push) Successful in 1m12s
Enhance dashboard UI and add order timeline
Refactored dashboard pages for improved layout and visual consistency using Card components, motion animations, and updated color schemes. Added an OrderTimeline component to the order details page to visualize order lifecycle. Improved customer management page with better sorting, searching, and a detailed customer dialog. Updated storefront settings page with a modernized layout and clearer sectioning.
2026-01-12 06:53:28 +00:00

586 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,
CreditCard,
Calendar,
ShoppingBag
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { motion, AnimatePresence } from "framer-motion";
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>
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
<div className="p-4 border-b border-border/50 bg-muted/30 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-muted-foreground">Show:</div>
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
<SelectTrigger className="w-[70px] bg-background/50 border-border/50">
<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-muted-foreground" />
</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-background/50 border-border/50 focus:ring-primary/20 transition-all duration-300"
/>
{searchQuery && (
<button
className="absolute inset-y-0 right-0 flex items-center pr-3"
onClick={clearSearch}
>
<X className="h-4 w-4 text-muted-foreground hover:text-foreground" />
</button>
)}
</div>
<div className="text-sm text-muted-foreground whitespace-nowrap">
{loading
? "Loading..."
: searchQuery
? `Found ${filteredCustomers.length} matching customers`
: `Showing ${filteredCustomers.length} of ${totalPages * itemsPerPage} customers`}
</div>
</div>
<CardContent className="p-0">
{loading ? (
<div className="p-8">
{/* Loading indicator */}
<div className="absolute top-[69px] left-0 right-0 h-0.5 bg-muted overflow-hidden">
<div className="h-full bg-primary w-1/3 animate-shimmer"
style={{
background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)',
backgroundSize: '200% 100%',
}}
/>
</div>
<div className="space-y-4">
<div className="flex items-center gap-4 pb-4 border-b border-border/50">
{['Customer', 'Orders', 'Total Spent', 'Last Order', 'Status'].map((header, i) => (
<Skeleton
key={i}
className="h-4 w-20 flex-1"
/>
))}
</div>
{[...Array(5)].map((_, i) => (
<div
key={i}
className="flex items-center gap-4 pb-4 border-b border-border/50 last:border-b-0"
>
<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-12 text-center">
<div className="w-16 h-16 bg-muted/50 rounded-full flex items-center justify-center mx-auto mb-4">
<Users className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2 text-foreground">
{searchQuery ? "No matching customers" : "No customers yet"}
</h3>
<p className="text-muted-foreground max-w-sm mx-auto mb-6">
{searchQuery
? "We couldn't find any customers matching your search criteria."
: "Once you have customers placing orders, they will appear here."}
</p>
{searchQuery && (
<Button variant="outline" size="sm" onClick={clearSearch}>
Clear Search
</Button>
)}
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader className="bg-muted/50">
<TableRow className="hover:bg-transparent border-border/50">
<TableHead className="w-[200px]">Customer</TableHead>
<TableHead
className="cursor-pointer w-[100px] text-center hover:text-primary transition-colors"
onClick={() => handleSort("totalOrders")}
>
<div className="flex items-center justify-center gap-1">
Orders
<ArrowUpDown className="h-3 w-3" />
</div>
</TableHead>
<TableHead
className="cursor-pointer w-[150px] text-center hover:text-primary transition-colors"
onClick={() => handleSort("totalSpent")}
>
<div className="flex items-center justify-center gap-1">
Total Spent
<ArrowUpDown className="h-3 w-3" />
</div>
</TableHead>
<TableHead
className="cursor-pointer w-[180px] text-center hover:text-primary transition-colors"
onClick={() => handleSort("lastOrderDate")}
>
<div className="flex items-center justify-center gap-1">
Last Order
<ArrowUpDown className="h-3 w-3" />
</div>
</TableHead>
<TableHead className="w-[250px] text-center">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<AnimatePresence mode="popLayout">
{filteredCustomers.map((customer, index) => (
<motion.tr
key={customer.userId}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, delay: index * 0.03 }}
className={`cursor-pointer group hover:bg-muted/50 border-b border-border/50 transition-colors ${!customer.hasOrders ? "bg-primary/5 hover:bg-primary/10" : ""}`}
onClick={() => setSelectedCustomer(customer)}
>
<TableCell className="py-3">
<div className="flex items-center gap-3">
<div className={`h-9 w-9 rounded-full flex items-center justify-center text-xs font-medium ${!customer.hasOrders ? 'bg-primary/20 text-primary' : 'bg-muted text-muted-foreground'
}`}>
{customer.telegramUsername ? customer.telegramUsername.substring(0, 2).toUpperCase() : 'ID'}
</div>
<div>
<div className="font-medium flex items-center gap-2">
@{customer.telegramUsername || "Unknown"}
{!customer.hasOrders && (
<Badge variant="outline" className="h-5 px-1.5 text-[10px] bg-primary/10 text-primary border-primary/20">
New
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground flex items-center font-mono mt-0.5">
<span className="opacity-50 select-none">ID:</span>
<span className="ml-1">{customer.telegramUserId}</span>
</div>
</div>
</div>
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary" className="font-mono font-normal">
{customer.totalOrders}
</Badge>
</TableCell>
<TableCell className="text-center font-mono text-sm">
{formatCurrency(customer.totalSpent)}
</TableCell>
<TableCell className="text-center text-sm text-muted-foreground">
{customer.lastOrderDate ? (
<div className="flex items-center justify-center gap-1.5">
<Calendar className="h-3 w-3 opacity-70" />
{formatDate(customer.lastOrderDate).split(",")[0]}
</div>
) : "Never"}
</TableCell>
<TableCell className="text-center">
{customer.hasOrders ? (
<div className="flex justify-center flex-wrap gap-1">
{customer.ordersByStatus.paid > 0 && (
<Badge className="bg-blue-500/15 text-blue-500 hover:bg-blue-500/25 border-blue-500/20 h-5 px-1.5 text-[10px]">
{customer.ordersByStatus.paid} Paid
</Badge>
)}
{customer.ordersByStatus.completed > 0 && (
<Badge className="bg-emerald-500/15 text-emerald-500 hover:bg-emerald-500/25 border-emerald-500/20 h-5 px-1.5 text-[10px]">
{customer.ordersByStatus.completed} Done
</Badge>
)}
{customer.ordersByStatus.shipped > 0 && (
<Badge className="bg-amber-500/15 text-amber-500 hover:bg-amber-500/25 border-amber-500/20 h-5 px-1.5 text-[10px]">
{customer.ordersByStatus.shipped} Ship
</Badge>
)}
</div>
) : (
<span className="text-xs text-muted-foreground italic">No activity</span>
)}
</TableCell>
</motion.tr>
))}
</AnimatePresence>
</TableBody>
</Table>
</div>
)}
</CardContent>
<div className="p-4 border-t border-border/50 bg-background/50 flex justify-between items-center">
<div className="text-sm text-muted-foreground">
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}
className="h-8"
>
<ChevronLeft className="h-3 w-3 mr-1" />
Previous
</Button>
{totalPages > 2 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 px-2">
<MoreHorizontal className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="max-h-60 overflow-y-auto">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => (
<DropdownMenuItem
key={pageNum}
onClick={() => handlePageChange(pageNum)}
className={pageNum === page ? 'bg-primary/10 text-primary' : ''}
>
Page {pageNum}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(Math.min(totalPages, page + 1))}
disabled={page === totalPages || loading}
className="h-8"
>
Next
<ChevronRight className="h-3 w-3 ml-1" />
</Button>
</div>
</div>
</Card>
{/* 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>
);
}