Enhance dashboard UI and add order timeline
All checks were successful
Build Frontend / build (push) Successful in 1m12s

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.
This commit is contained in:
g
2026-01-12 06:53:28 +00:00
parent 7b95589867
commit 211cdc71f9
12 changed files with 1793 additions and 1331 deletions

View File

@@ -39,6 +39,8 @@ import {
} from "@/components/ui/alert-dialog";
import Layout from "@/components/layout/layout";
import { cacheUtils } from '@/lib/api-client';
import OrderTimeline from "@/components/orders/order-timeline";
import { motion, AnimatePresence } from "framer-motion";
interface Order {
orderId: string;
@@ -432,7 +434,7 @@ export default function OrderDetailsPage() {
setTimeout(() => {
setProductNames(prev => {
const newMap = {...prev};
const newMap = { ...prev };
productIds.forEach(id => {
if (!newMap[id] || newMap[id] === "Loading...") {
newMap[id] = "Unknown Product (Deleted)";
@@ -619,11 +621,11 @@ export default function OrderDetailsPage() {
const isOrderUnderpaid = (order: Order | null) => {
// More robust check - only show underpaid if status is NOT paid and underpayment exists
return order?.underpaid === true &&
order?.underpaymentAmount &&
order.underpaymentAmount > 0 &&
order.status !== "paid" &&
order.status !== "completed" &&
order.status !== "shipped";
order?.underpaymentAmount &&
order.underpaymentAmount > 0 &&
order.status !== "paid" &&
order.status !== "completed" &&
order.status !== "shipped";
};
// Helper function to get underpaid information
@@ -791,10 +793,26 @@ export default function OrderDetailsPage() {
</CardContent>
</Card>
)}
{/* Order Timeline */}
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
<CardHeader className="pb-0">
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Order Lifecycle</CardTitle>
</CardHeader>
<CardContent>
<OrderTimeline
status={order?.status || ''}
orderDate={order?.orderDate || ''}
paidAt={order?.paidAt}
/>
</CardContent>
</Card>
<div className="grid grid-cols-3 gap-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="grid grid-cols-3 gap-6"
>
{/* Left Column - Order Details */}
<div className="col-span-2 space-y-6">
{/* Products Card */}
@@ -1093,33 +1111,33 @@ export default function OrderDetailsPage() {
{/* Cancel Order Button */}
{order?.status !== "cancelled" &&
order?.status !== "completed" &&
order?.status !== "shipped" && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full">
Cancel Order
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel Order</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to cancel this order? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleCancelOrder}
className="bg-red-500 hover:bg-red-600"
>
Confirm Cancel
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
order?.status !== "completed" &&
order?.status !== "shipped" && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full">
Cancel Order
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel Order</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to cancel this order? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleCancelOrder}
className="bg-red-500 hover:bg-red-600"
>
Confirm Cancel
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{/* No Actions Available Message */}
{(order?.status === "completed" || order?.status === "cancelled") && (
@@ -1168,11 +1186,10 @@ export default function OrderDetailsPage() {
{[...Array(5)].map((_, i) => (
<svg
key={i}
className={`w-4 h-4 ${
i < (order?.review?.stars || 0)
className={`w-4 h-4 ${i < (order?.review?.stars || 0)
? "text-yellow-400"
: "text-zinc-600"
}`}
}`}
fill="currentColor"
viewBox="0 0 20 20"
>
@@ -1198,10 +1215,10 @@ export default function OrderDetailsPage() {
</Card>
)}
</div>
</div>
{/* Shipping Dialog removed; use inline tracking input above */}
</motion.div>
</div>
{/* Shipping Dialog removed; use inline tracking input above */}
</Layout>
);
}

View File

@@ -40,11 +40,16 @@ import {
UserPlus,
MoreHorizontal,
Search,
X
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,
@@ -138,424 +143,444 @@ export default function CustomerManagementPage() {
}
}, [searchQuery, customers]);
const handlePageChange = (newPage: number) => {
setPage(newPage);
};
const handlePageChange = (newPage: number) => {
setPage(newPage);
};
const handleItemsPerPageChange = (value: string) => {
setItemsPerPage(parseInt(value, 10));
setPage(1);
};
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 handleSort = (column: "totalOrders" | "totalSpent" | "lastOrderDate") => {
setSortConfig(prev => ({
column,
direction: prev.column === column && prev.direction === "asc" ? "desc" : "asc"
}));
};
const clearSearch = () => {
setSearchQuery("");
};
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";
}
};
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>
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="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 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>
{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>
<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>
{/* 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>
<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>
{[...Array(5)].map((_, i) => (
<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="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>
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>
) : 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>
) : 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>
) : (
<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
<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}
className={`cursor-pointer ${!customer.hasOrders ? "bg-black/30" : ""}`}
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>
<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>
)}
<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>
<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>
<Badge variant="secondary" className="font-mono font-normal">
{customer.totalOrders}
</Badge>
</TableCell>
<TableCell className="font-medium text-gray-100 text-center">
<TableCell className="text-center font-mono text-sm">
{formatCurrency(customer.totalSpent)}
</TableCell>
<TableCell className="text-sm text-gray-100 text-center">
{customer.lastOrderDate ? formatDate(customer.lastOrderDate) : "N/A"}
<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 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 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>
) : (
<Badge variant="outline" className="bg-gray-800 text-gray-300 border-gray-700">
No orders yet
</Badge>
<span className="text-xs text-muted-foreground italic">No activity</span>
)}
</TableCell>
</TableRow>
</motion.tr>
))}
</TableBody>
</Table>
</div>
)}
</AnimatePresence>
</TableBody>
</Table>
</div>
)}
</CardContent>
<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>
<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">
<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>
)}
{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"
onClick={() => handlePageChange(Math.min(totalPages, page + 1))}
disabled={page === totalPages || loading}
className="w-full"
onClick={() => {
window.open(`https://t.me/${selectedCustomer.telegramUsername || selectedCustomer.telegramUserId}`, '_blank');
}}
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
<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>
</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>
{/* 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>
{/* 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 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>
);
}
<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>
);
}

View File

@@ -6,11 +6,15 @@ import Layout from "@/components/layout/layout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Save, Send, Key, MessageSquare, Shield, Globe, Wallet } from "lucide-react";
import { Save, Send, Key, MessageSquare, Shield, Globe, Wallet, RefreshCw } from "lucide-react";
import { apiRequest } from "@/lib/api";
import { toast } from "sonner";
import BroadcastDialog from "@/components/modals/broadcast-dialog";
import Dashboard from "@/components/dashboard/dashboard";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { motion, AnimatePresence } from "framer-motion";
import {
Select,
SelectContent,
@@ -166,44 +170,25 @@ export default function StorefrontPage() {
return (
<Dashboard>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-6">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white flex items-center">
<Globe className="mr-2 h-6 w-6" />
Storefront Settings
</h1>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<Switch
checked={storefront.isEnabled}
onCheckedChange={(checked) =>
setStorefront((prev) => ({
...prev,
isEnabled: checked,
}))
}
/>
<span className={`text-sm font-medium ${storefront.isEnabled ? 'text-emerald-400' : 'text-zinc-400'}`}>
{storefront.isEnabled ? 'Store Open' : 'Store Closed'}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{storefront.isEnabled ? 'Click to close store' : 'Click to open store'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-4">
<div className="p-3 rounded-xl bg-primary/10 text-primary">
<Globe className="h-8 w-8" />
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight text-foreground">
Storefront Settings
</h1>
<p className="text-muted-foreground">
Manage your shop's appearance, policies, and configuration
</p>
</div>
</div>
<div className="flex gap-2">
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setBroadcastOpen(true)}
className="gap-2"
size="sm"
className="gap-2 h-10"
>
<Send className="h-4 w-4" />
Broadcast
@@ -211,206 +196,272 @@ export default function StorefrontPage() {
<Button
onClick={saveStorefront}
disabled={saving}
className="gap-2"
size="sm"
className="gap-2 h-10 min-w-[120px]"
>
<Save className="h-4 w-4" />
{saving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{saving ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6">
{/* Security Settings */}
<div className="space-y-3">
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-3">
<Shield className="h-4 w-4 text-purple-400" />
<h2 className="text-base font-medium text-zinc-100">
Security
</h2>
</div>
<div className="space-y-3">
<div>
<label className="text-xs font-medium mb-1 block text-zinc-400">PGP Public Key</label>
<Textarea
value={storefront.pgpKey}
onChange={(e) => setStorefront(prev => ({ ...prev, pgpKey: e.target.value }))}
placeholder="Enter your PGP public key"
className="font-mono text-sm h-24 bg-[#1C1C1C] border-zinc-800 resize-none"
/>
</div>
<div>
<label className="text-xs font-medium mb-1 block text-zinc-400">Telegram Bot Token</label>
<Input
type="password"
value={storefront.telegramToken}
onChange={(e) => setStorefront(prev => ({ ...prev, telegramToken: e.target.value }))}
placeholder="Enter your Telegram bot token"
className="bg-[#1C1C1C] border-zinc-800 h-8 text-sm"
/>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Column */}
<div className="lg:col-span-2 space-y-6">
{/* Store Status Card */}
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}>
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden relative">
<div className={`absolute top-0 left-0 w-1 h-full ${storefront.isEnabled ? 'bg-emerald-500' : 'bg-destructive'}`} />
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="space-y-1">
<CardTitle>Store Status</CardTitle>
<CardDescription>Control your store's visibility to customers</CardDescription>
</div>
<Badge variant={storefront.isEnabled ? "default" : "destructive"} className="h-6">
{storefront.isEnabled ? "Open for Business" : "Store Closed"}
</Badge>
</div>
</CardHeader>
<CardContent className="pb-6">
<div className="flex items-center gap-4 p-4 rounded-lg bg-card border border-border/50">
<Switch
checked={storefront.isEnabled}
onCheckedChange={(checked) =>
setStorefront((prev) => ({
...prev,
isEnabled: checked,
}))
}
className="data-[state=checked]:bg-emerald-500"
/>
<div>
<h4 className="font-medium text-sm">
{storefront.isEnabled ? 'Your store is currently online' : 'Your store is currently offline'}
</h4>
<p className="text-xs text-muted-foreground">
{storefront.isEnabled
? 'Customers can browse listings and place orders normally.'
: 'Customers will see a maintenance page. No new orders can be placed.'}
</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
{/* Welcome & Policy */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}>
<Card className="h-full border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<MessageSquare className="h-4 w-4 text-primary" />
Welcome Message
</CardTitle>
</CardHeader>
<CardContent>
<Textarea
value={storefront.welcomeMessage}
onChange={(e) => setStorefront(prev => ({ ...prev, welcomeMessage: e.target.value }))}
placeholder="Enter the welcome message for new customers..."
className="min-h-[180px] bg-background/50 border-border/50 resize-none focus:ring-primary/20"
/>
</CardContent>
</Card>
</motion.div>
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}>
<Card className="h-full border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Shield className="h-4 w-4 text-orange-400" />
Store Policy
</CardTitle>
</CardHeader>
<CardContent>
<Textarea
value={storefront.storePolicy}
onChange={(e) => setStorefront(prev => ({ ...prev, storePolicy: e.target.value }))}
placeholder="Enter your store's policies, terms, and conditions..."
className="min-h-[180px] bg-background/50 border-border/50 resize-none focus:ring-primary/20"
/>
</CardContent>
</Card>
</motion.div>
</div>
{/* Shipping Settings */}
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-3">
<Globe className="h-4 w-4 text-blue-400" />
<h2 className="text-base font-medium text-zinc-100">
Shipping
</h2>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium mb-1 block text-zinc-400">Ships From</label>
<Select
value={storefront.shipsFrom}
onValueChange={(value) =>
setStorefront((prev) => ({ ...prev, shipsFrom: value as any }))
}
>
<SelectTrigger className="bg-[#1C1C1C] border-zinc-800 h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SHIPPING_REGIONS.map((region) => (
<SelectItem key={region.value} value={region.value}>
{region.emoji} {region.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs font-medium mb-1 block text-zinc-400">Ships To</label>
<Select
value={storefront.shipsTo}
onValueChange={(value) =>
setStorefront((prev) => ({ ...prev, shipsTo: value as any }))
}
>
<SelectTrigger className="bg-[#1C1C1C] border-zinc-800 h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SHIPPING_REGIONS.map((region) => (
<SelectItem key={region.value} value={region.value}>
{region.emoji} {region.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Security Settings */}
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }}>
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="h-5 w-5 text-purple-400" />
Security Configuration
</CardTitle>
<CardDescription>Manage keys and access tokens for your store security</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">PGP Public Key</Label>
<Textarea
value={storefront.pgpKey}
onChange={(e) => setStorefront(prev => ({ ...prev, pgpKey: e.target.value }))}
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----..."
className="font-mono text-xs h-32 bg-zinc-950/50 border-zinc-800/50 resize-none"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Telegram Bot Token</Label>
<div className="relative">
<Input
type="password"
value={storefront.telegramToken}
onChange={(e) => setStorefront(prev => ({ ...prev, telegramToken: e.target.value }))}
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
className="bg-background/50 border-border/50 font-mono text-sm pl-10"
/>
<div className="absolute left-3 top-2.5 text-muted-foreground">
<Shield className="h-4 w-4" />
</div>
</div>
<p className="text-[10px] text-muted-foreground">Used for notifications and bot integration.</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
</div>
{/* Messaging and Payments */}
<div className="space-y-3">
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-3">
<MessageSquare className="h-4 w-4 text-emerald-400" />
<h2 className="text-base font-medium text-zinc-100">
Welcome Message
</h2>
</div>
<Textarea
value={storefront.welcomeMessage}
onChange={(e) => setStorefront(prev => ({ ...prev, welcomeMessage: e.target.value }))}
placeholder="Enter the welcome message for new customers"
className="h-36 bg-[#1C1C1C] border-zinc-800 text-sm resize-none"
/>
</div>
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-3">
<Shield className="h-4 w-4 text-orange-400" />
<h2 className="text-base font-medium text-zinc-100">
Store Policy
</h2>
</div>
<Textarea
value={storefront.storePolicy}
onChange={(e) => setStorefront(prev => ({ ...prev, storePolicy: e.target.value }))}
placeholder="Enter your store's policies, terms, and conditions"
className="h-48 bg-[#1C1C1C] border-zinc-800 text-sm resize-none"
/>
</div>
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-3">
<Wallet className="h-4 w-4 text-yellow-400" />
<h2 className="text-base font-medium text-zinc-100">
Payment Methods
</h2>
</div>
<div className="space-y-2">
{WALLET_OPTIONS.map((wallet) => (
<div key={wallet.id} className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-medium flex items-center gap-2 text-zinc-400">
<span>{wallet.emoji}</span>
{wallet.name}
{wallet.comingSoon && (
<span className="text-[10px] bg-purple-900/50 text-purple-400 px-1.5 py-0.5 rounded">
Coming Soon
</span>
)}
</label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Switch
checked={storefront.enabledWallets[wallet.id]}
onCheckedChange={(checked) =>
setStorefront((prev) => ({
...prev,
enabledWallets: {
...prev.enabledWallets,
[wallet.id]: checked,
},
}))
}
disabled={wallet.disabled}
/>
</div>
</TooltipTrigger>
{wallet.disabled && (
<TooltipContent>
<p>Coming soon</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div>
{storefront.enabledWallets[wallet.id] && !wallet.disabled && (
<Input
value={storefront.wallets[wallet.id]}
onChange={(e) =>
setStorefront((prev) => ({
...prev,
wallets: {
...prev.wallets,
[wallet.id]: e.target.value,
},
}))
}
placeholder={wallet.placeholder}
className="font-mono text-sm h-8 bg-[#1C1C1C] border-zinc-800"
/>
)}
{/* Sidebar Column */}
<div className="space-y-6">
{/* Shipping Settings */}
<motion.div initial={{ opacity: 0, x: 10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.4 }}>
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Globe className="h-4 w-4 text-blue-400" />
Shipping & Logistics
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Ships From</Label>
<Select
value={storefront.shipsFrom}
onValueChange={(value) =>
setStorefront((prev) => ({ ...prev, shipsFrom: value as any }))
}
>
<SelectTrigger className="bg-background/50 border-border/50">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SHIPPING_REGIONS.map((region) => (
<SelectItem key={region.value} value={region.value}>
<span className="flex items-center gap-2">
<span className="text-lg">{region.emoji}</span>
{region.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
</div>
<div className="space-y-2">
<Label>Ships To</Label>
<Select
value={storefront.shipsTo}
onValueChange={(value) =>
setStorefront((prev) => ({ ...prev, shipsTo: value as any }))
}
>
<SelectTrigger className="bg-background/50 border-border/50">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SHIPPING_REGIONS.map((region) => (
<SelectItem key={region.value} value={region.value}>
<span className="flex items-center gap-2">
<span className="text-lg">{region.emoji}</span>
{region.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</motion.div>
{/* Payment Methods */}
<motion.div initial={{ opacity: 0, x: 10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.5 }}>
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Wallet className="h-4 w-4 text-yellow-500" />
Crypto Wallets
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{WALLET_OPTIONS.map((wallet) => (
<div key={wallet.id} className="p-3 rounded-lg border border-border/50 bg-card/30 hover:bg-card/50 transition-colors">
<div className="flex items-center justify-between mb-3">
<label className="text-sm font-medium flex items-center gap-2">
<span className="text-lg">{wallet.emoji}</span>
{wallet.name}
</label>
<div className="flex items-center gap-2">
{wallet.comingSoon && (
<Badge variant="secondary" className="text-[10px] h-5">Soon</Badge>
)}
<Switch
checked={storefront.enabledWallets[wallet.id]}
onCheckedChange={(checked) =>
setStorefront((prev) => ({
...prev,
enabledWallets: {
...prev.enabledWallets,
[wallet.id]: checked,
},
}))
}
disabled={wallet.disabled}
className="scale-90"
/>
</div>
</div>
{storefront.enabledWallets[wallet.id] && !wallet.disabled && (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }}>
<Input
value={storefront.wallets[wallet.id]}
onChange={(e) =>
setStorefront((prev) => ({
...prev,
wallets: {
...prev.wallets,
[wallet.id]: e.target.value,
},
}))
}
placeholder={wallet.placeholder}
className="font-mono text-xs h-9 bg-background/50"
/>
</motion.div>
)}
</div>
))}
</CardContent>
</Card>
</motion.div>
</div>
</div>
</div>
<BroadcastDialog open={broadcastOpen} setOpen={setBroadcastOpen} />
</Dashboard>
</Dashboard >
);
}

View File

@@ -56,7 +56,6 @@ const getFileNameFromUrl = (url: string): string => {
return 'attachment';
}
// URL decode the filename (handle spaces and special characters)
try {
fileName = decodeURIComponent(fileName);
} catch (e) {
@@ -72,7 +71,7 @@ const getFileIcon = (url: string): React.ReactNode => {
// Image files
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(fileName) ||
url.includes('/photos/') || url.includes('/photo/')) {
url.includes('/photos/') || url.includes('/photo/')) {
return <ImageIcon className="h-5 w-5" />;
}
@@ -532,8 +531,8 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
chat.messages.forEach((msg, msgIndex) => {
msg.attachments.forEach((att, attIndex) => {
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(att) ||
att.includes('/photos/') ||
att.includes('/photo/')) {
att.includes('/photos/') ||
att.includes('/photo/')) {
allImages.push({ messageIndex: msgIndex, attachmentIndex: attIndex, url: att });
}
});
@@ -609,8 +608,8 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
return (
<div className="flex flex-col h-screen w-full relative">
<div className={cn(
"border-b bg-card z-10 flex items-center justify-between",
isTouchDevice ? "h-20 px-3" : "h-16 px-4"
"border-b bg-background/80 backdrop-blur-md z-10 flex items-center justify-between sticky top-0",
isTouchDevice ? "h-16 px-4" : "h-16 px-6"
)}>
<div className="flex items-center space-x-2">
<Button
@@ -681,11 +680,10 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
>
<div
className={cn(
"max-w-[90%] rounded-lg chat-message",
isTouchDevice ? "p-4" : "p-3",
"max-w-[85%] rounded-2xl p-4 shadow-sm",
msg.sender === "vendor"
? "bg-primary text-primary-foreground"
: "bg-muted"
? "bg-primary text-primary-foreground rounded-tr-none"
: "bg-muted text-muted-foreground rounded-tl-none border border-border/50"
)}
>
<div className="flex items-center space-x-2 mb-1">
@@ -706,8 +704,8 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
<div className="mt-2 space-y-2">
{msg.attachments.map((attachment, attachmentIndex) => {
const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff)($|\?)/i.test(attachment) ||
attachment.includes('/photos/') ||
attachment.includes('/photo/');
attachment.includes('/photos/') ||
attachment.includes('/photo/');
const fileName = getFileNameFromUrl(attachment);
@@ -796,47 +794,36 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
</div>
<div className={cn(
"absolute bottom-0 left-0 right-0 border-t border-border bg-background",
isTouchDevice ? "p-3" : "p-4",
"pb-[env(safe-area-inset-bottom)]"
"absolute bottom-0 left-0 right-0 px-4 pt-10 bg-gradient-to-t from-background via-background/95 to-transparent",
"pb-[calc(1.5rem+env(safe-area-inset-bottom))]"
)}>
<form onSubmit={handleSendMessage} className="flex space-x-2">
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
disabled={sending}
className={cn(
"flex-1 text-base transition-all duration-200 form-input",
isTouchDevice ? "min-h-[52px] text-lg" : "min-h-[48px]"
)}
onKeyDown={handleKeyDown}
autoFocus
aria-label="Message input"
aria-describedby="message-help"
role="textbox"
autoComplete="off"
spellCheck="true"
maxLength={2000}
style={{
WebkitAppearance: 'none',
borderRadius: '0.5rem'
}}
/>
<form onSubmit={handleSendMessage} className="flex space-x-2 max-w-4xl mx-auto items-end">
<div className="relative flex-1">
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
disabled={sending}
className={cn(
"w-full pl-4 pr-12 py-3 bg-background/50 border-border/50 backdrop-blur-sm shadow-sm focus:ring-primary/20 transition-all duration-200 rounded-full",
isTouchDevice ? "min-h-[52px] text-lg" : "min-h-[48px] text-base"
)}
onKeyDown={handleKeyDown}
autoFocus
aria-label="Message input"
role="textbox"
autoComplete="off"
/>
</div>
<Button
type="submit"
disabled={sending || !message.trim()}
aria-label={sending ? "Sending message" : "Send message"}
className={cn(
"transition-all duration-200 btn-chromebook",
isTouchDevice ? "min-h-[52px] min-w-[52px]" : "min-h-[48px] min-w-[48px]"
"rounded-full shadow-md transition-all duration-200 bg-primary hover:bg-primary/90 text-primary-foreground",
isTouchDevice ? "h-[52px] w-[52px]" : "h-[48px] w-[48px]"
)}
style={{
WebkitAppearance: 'none',
touchAction: 'manipulation'
}}
>
{sending ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
{sending ? <RefreshCw className="h-5 w-5 animate-spin" /> : <Send className="h-5 w-5 ml-0.5" />}
</Button>
</form>
<div id="message-help" className="sr-only">

View File

@@ -2,6 +2,7 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { motion, AnimatePresence } from "framer-motion";
import {
Table,
TableBody,
@@ -10,6 +11,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -30,7 +32,8 @@ import {
CheckCheck,
Search,
Volume2,
VolumeX
VolumeX,
MoreHorizontal
} from "lucide-react";
import {
Select,
@@ -261,161 +264,211 @@ export default function ChatTable() {
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={loading}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
<span className="ml-2">Refresh</span>
</Button>
<div className="flex justify-between items-end">
<div>
<h2 className="text-2xl font-bold tracking-tight">Messages</h2>
<p className="text-muted-foreground">Manage your customer conversations</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={loading}
className="h-9"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
Refresh
</Button>
<Button onClick={handleCreateChat} size="sm">
<Plus className="h-4 w-4 mr-2" />
New Chat
</Button>
<Button onClick={handleCreateChat} size="sm" className="h-9">
<Plus className="h-4 w-4 mr-2" />
New Chat
</Button>
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]">Customer</TableHead>
<TableHead>Last Activity</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center">
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
</TableCell>
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
<CardContent className="p-0">
<Table>
<TableHeader className="bg-muted/50">
<TableRow className="hover:bg-transparent">
<TableHead className="w-[300px] pl-6">Customer</TableHead>
<TableHead>Last Activity</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right pr-6">Actions</TableHead>
</TableRow>
) : chats.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center">
<div className="flex flex-col items-center justify-center">
<MessageCircle className="h-8 w-8 text-muted-foreground mb-2" />
<p className="text-muted-foreground">No chats found</p>
</div>
</TableCell>
</TableRow>
) : (
chats.map((chat) => (
<TableRow
key={chat._id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleChatClick(chat._id)}
>
<TableCell>
<div className="flex items-center space-x-3">
<Avatar>
<AvatarFallback>
<User className="h-4t w-4" />
</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">
{chat.telegramUsername ? `@${chat.telegramUsername}` : 'Customer'}
</TableHeader>
<TableBody>
<AnimatePresence mode="popLayout">
{loading ? (
<TableRow>
<TableCell colSpan={4} className="h-32 text-center">
<div className="flex flex-col items-center justify-center gap-2">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">Loading conversations...</span>
</div>
</TableCell>
</TableRow>
) : chats.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="h-32 text-center">
<div className="flex flex-col items-center justify-center">
<MessageCircle className="h-10 w-10 text-muted-foreground/50 mb-3" />
<p className="text-muted-foreground font-medium">No chats found</p>
<p className="text-xs text-muted-foreground mt-1">Start a new conversation to communicate with customers</p>
</div>
</TableCell>
</TableRow>
) : (
chats.map((chat, index) => (
<motion.tr
key={chat._id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, delay: index * 0.05 }}
className="group cursor-pointer hover:bg-muted/30 transition-colors border-b border-border/50 last:border-0"
onClick={() => handleChatClick(chat._id)}
style={{ display: 'table-row' }} // Essential for table layout
>
<TableCell className="pl-6 py-4">
<div className="flex items-center space-x-4">
<div className="relative">
<Avatar className="h-10 w-10 border-2 border-background shadow-sm group-hover:scale-105 transition-transform duration-200">
<AvatarFallback className={cn(
"font-medium text-xs",
unreadCounts.chatCounts[chat._id] > 0 ? "bg-primary/20 text-primary" : "bg-muted text-muted-foreground"
)}>
{chat.buyerId.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{unreadCounts.chatCounts[chat._id] > 0 && (
<span className="absolute -top-1 -right-1 h-3 w-3 bg-primary rounded-full ring-2 ring-background animate-pulse" />
)}
</div>
<div>
<div className="font-semibold text-sm flex items-center gap-2">
{chat.telegramUsername ? (
<span className="text-blue-400">@{chat.telegramUsername}</span>
) : (
<span className="text-foreground">Customer {chat.buyerId.slice(0, 6)}...</span>
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5 font-mono">
ID: {chat.buyerId}
</div>
{chat.orderId && (
<div className="text-[10px] text-muted-foreground mt-1 flex items-center gap-1 bg-muted/50 px-1.5 py-0.5 rounded w-fit">
<span className="w-1 h-1 rounded-full bg-zinc-400" />
Order #{chat.orderId}
</div>
)}
</div>
</div>
<div className="text-xs text-muted-foreground">
ID: {chat.buyerId}
</TableCell>
<TableCell className="py-4">
<div className="flex flex-col">
<span className="text-sm font-medium">
{formatDistanceToNow(new Date(chat.lastUpdated), { addSuffix: true })}
</span>
<span className="text-xs text-muted-foreground">
{new Date(chat.lastUpdated).toLocaleDateString()}
</span>
</div>
{chat.orderId && (
<div className="text-xs text-muted-foreground">
Order #{chat.orderId}
</TableCell>
<TableCell className="py-4">
{unreadCounts.chatCounts[chat._id] > 0 ? (
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-primary/10 text-primary text-xs font-medium border border-primary/20 shadow-[0_0_10px_rgba(var(--primary),0.1)]">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
</span>
{unreadCounts.chatCounts[chat._id]} new message{unreadCounts.chatCounts[chat._id] !== 1 ? 's' : ''}
</div>
) : (
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-muted text-muted-foreground text-xs font-medium border border-border">
<CheckCheck className="h-3 w-3" />
All caught up
</div>
)}
</div>
</div>
</TableCell>
<TableCell>
{formatDistanceToNow(new Date(chat.lastUpdated), { addSuffix: true })}
</TableCell>
<TableCell>
{unreadCounts.chatCounts[chat._id] > 0 ? (
<Badge variant="destructive" className="ml-1">
{unreadCounts.chatCounts[chat._id]} new
</Badge>
) : (
<Badge variant="outline">Read</Badge>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleChatClick(chat._id);
}}
>
<Eye className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</TableCell>
<TableCell className="text-right pr-6 py-4">
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<Button
variant="secondary"
size="sm"
className="h-8 w-8 p-0 rounded-full"
onClick={(e) => {
e.stopPropagation();
handleChatClick(chat._id);
}}
>
<ArrowRightCircle className="h-4 w-4" />
<span className="sr-only">View</span>
</Button>
</div>
</TableCell>
</motion.tr>
))
)}
</AnimatePresence>
</TableBody>
</Table>
</CardContent>
</Card>
{/* Pagination controls */}
{!loading && chats.length > 0 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Showing {chats.length} of {totalChats} chats
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-muted-foreground">Rows per page:</span>
<Select
value={itemsPerPage.toString()}
onValueChange={handleItemsPerPageChange}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={itemsPerPage.toString()} />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
{
!loading && chats.length > 0 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Showing {chats.length} of {totalChats} chats
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={goToPrevPage}
disabled={currentPage <= 1 || loading}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-sm">
Page {currentPage} of {totalPages}
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-muted-foreground">Rows per page:</span>
<Select
value={itemsPerPage.toString()}
onValueChange={handleItemsPerPageChange}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={itemsPerPage.toString()} />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={goToPrevPage}
disabled={currentPage <= 1 || loading}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-sm">
Page {currentPage} of {totalPages}
</div>
<Button
variant="outline"
size="sm"
onClick={goToNextPage}
disabled={currentPage >= totalPages || loading}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={goToNextPage}
disabled={currentPage >= totalPages || loading}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</div>

View File

@@ -2,16 +2,20 @@
import { useState, useEffect } from "react"
import OrderStats from "./order-stats"
import QuickActions from "./quick-actions"
import RecentActivity from "./recent-activity"
import { getGreeting } from "@/lib/utils/general"
import { statsConfig } from "@/config/dashboard"
import { getRandomQuote } from "@/config/quotes"
import type { OrderStatsData } from "@/lib/types"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ShoppingCart, RefreshCcw } from "lucide-react"
import { ShoppingCart, RefreshCcw, ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useToast } from "@/components/ui/use-toast"
import { Skeleton } from "@/components/ui/skeleton"
import { clientFetch } from "@/lib/api"
import { motion } from "framer-motion"
import Link from "next/link"
interface ContentProps {
username: string
@@ -34,145 +38,165 @@ export default function Content({ username, orderStats }: ContentProps) {
const [error, setError] = useState<string | null>(null);
const { toast } = useToast();
// Initialize with a random quote from the quotes config
const [randomQuote, setRandomQuote] = useState(getRandomQuote());
// Fetch top-selling products data
const fetchTopProducts = async () => {
try {
setIsLoading(true);
const data = await clientFetch('/orders/top-products');
setTopProducts(data);
} catch (err) {
console.error("Error fetching top products:", err);
setError(err instanceof Error ? err.message : "Failed to fetch top products");
toast({
title: "Error loading top products",
description: "Please try refreshing the page",
variant: "destructive"
});
} finally {
setIsLoading(false);
}
};
// Initialize greeting and fetch data on component mount
useEffect(() => {
setGreeting(getGreeting());
fetchTopProducts();
}, []);
// Retry fetching top products data
const handleRetry = () => {
fetchTopProducts();
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-foreground">
{greeting}, {username}!
</h1>
<p className="text-muted-foreground mt-1 italic text-sm">
"{randomQuote.text}" <span className="font-medium">{randomQuote.author}</span>
</p>
</div>
<div className="space-y-10 pb-10">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col md:flex-row md:items-end md:justify-between gap-4"
>
<div>
<h1 className="text-4xl font-bold tracking-tight text-foreground">
{greeting}, <span className="text-primary">{username}</span>!
</h1>
<p className="text-muted-foreground mt-2 max-w-2xl text-lg">
"{randomQuote.text}" <span className="font-medium">{randomQuote.author}</span>
</p>
</div>
<div className="flex items-center gap-2">
</div>
</motion.div>
{/* Quick ActionsSection */}
<section className="space-y-4">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Quick Actions</h2>
<QuickActions />
</section>
{/* Order Statistics */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 lg:gap-6">
{statsConfig.map((stat) => (
<OrderStats
key={stat.title}
title={stat.title}
value={orderStats[stat.key as keyof OrderStatsData].toLocaleString()}
icon={stat.icon}
/>
))}
</div>
<section className="space-y-4">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Overview</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{statsConfig.map((stat, index) => (
<OrderStats
key={stat.title}
title={stat.title}
value={orderStats[stat.key as keyof OrderStatsData]?.toLocaleString() || "0"}
icon={stat.icon}
index={index}
/>
))}
</div>
</section>
{/* Best Selling Products Section */}
<div className="mt-8">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div>
<CardTitle>Your Best Selling Products</CardTitle>
<CardDescription>Products with the highest sales from your store</CardDescription>
</div>
{error && (
<Button
variant="outline"
size="sm"
onClick={handleRetry}
className="flex items-center gap-1"
>
<RefreshCcw className="h-3 w-3" />
<span>Retry</span>
</Button>
)}
</CardHeader>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
{/* Recent Activity Section */}
<div className="xl:col-span-1">
<RecentActivity />
</div>
<CardContent>
{isLoading ? (
// Loading skeleton
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-12 w-12 rounded-md" />
<div className="space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-20" />
{/* Best Selling Products Section */}
<div className="xl:col-span-2">
<Card className="h-full border-border/40 bg-background/50 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div>
<CardTitle>Top Performing Listings</CardTitle>
<CardDescription>Your products with the highest sales volume</CardDescription>
</div>
{error && (
<Button
variant="outline"
size="sm"
onClick={handleRetry}
className="flex items-center gap-1"
>
<RefreshCcw className="h-3 w-3" />
<span>Retry</span>
</Button>
)}
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-14 w-14 rounded-xl" />
<div className="space-y-2 flex-1">
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-3 w-1/4" />
</div>
<Skeleton className="h-4 w-16" />
</div>
<div className="ml-auto text-right">
<Skeleton className="h-4 w-16 ml-auto" />
<Skeleton className="h-4 w-16 ml-auto mt-2" />
</div>
</div>
))}
</div>
) : error ? (
// Error state
<div className="py-8 text-center">
<div className="text-muted-foreground mb-4">Failed to load products</div>
</div>
) : topProducts.length === 0 ? (
// Empty state
<div className="py-8 text-center">
<ShoppingCart className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No products sold yet</h3>
<p className="text-muted-foreground">
Your best-selling products will appear here after you make some sales.
</p>
</div>
) : (
// Data view
<div className="space-y-4">
{topProducts.map((product) => (
<div key={product.id} className="flex items-center gap-4 py-2 border-b last:border-0">
<div
className="h-12 w-12 bg-cover bg-center rounded-md border flex-shrink-0 flex items-center justify-center overflow-hidden"
style={{
backgroundImage: product.image
? `url(/api/products/${product.id}/image)`
: 'none'
}}
))}
</div>
) : error ? (
<div className="py-12 text-center">
<div className="text-muted-foreground mb-4">Failed to load product insights</div>
</div>
) : topProducts.length === 0 ? (
<div className="py-12 text-center">
<ShoppingCart className="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium mb-1">Begin your sales journey</h3>
<p className="text-muted-foreground text-sm max-w-xs mx-auto">
Your top performing listings will materialize here as you receive orders.
</p>
</div>
) : (
<div className="space-y-1">
{topProducts.map((product, index) => (
<motion.div
key={product.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 + index * 0.05 }}
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
>
{!product.image && (
<ShoppingCart className="h-6 w-6 text-muted-foreground" />
)}
</div>
<div className="flex-grow min-w-0">
<h4 className="font-medium truncate">{product.name}</h4>
</div>
<div className="text-right">
<div className="font-medium">{product.count} sold</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<div
className="h-14 w-14 bg-muted bg-cover bg-center rounded-xl border flex-shrink-0 flex items-center justify-center overflow-hidden group-hover:scale-105 transition-transform"
style={{
backgroundImage: product.image
? `url(/api/products/${product.id}/image)`
: 'none'
}}
>
{!product.image && (
<ShoppingCart className="h-6 w-6 text-muted-foreground/50" />
)}
</div>
<div className="flex-grow min-w-0">
<h4 className="font-semibold text-lg truncate group-hover:text-primary transition-colors">{product.name}</h4>
<div className="flex items-center gap-3 mt-0.5">
<span className="text-sm text-muted-foreground font-medium">£{product.price.toFixed(2)}</span>
<span className="h-1 w-1 rounded-full bg-muted-foreground/30" />
<span className="text-xs text-muted-foreground">ID: {product.id.slice(-6)}</span>
</div>
</div>
<div className="text-right">
<div className="text-xl font-bold">{product.count}</div>
<div className="text-xs text-muted-foreground font-medium uppercase tracking-tighter">Units Sold</div>
</div>
</motion.div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);

View File

@@ -1,23 +1,37 @@
import type { LucideIcon } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { motion } from "framer-motion"
interface OrderStatsProps {
title: string
value: string
icon: LucideIcon
index?: number
}
export default function OrderStats({ title, value, icon: Icon }: OrderStatsProps) {
export default function OrderStats({ title, value, icon: Icon, index = 0 }: OrderStatsProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
</CardContent>
</Card>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.05 }}
>
<Card className="relative overflow-hidden group border-border/40 bg-background/50 backdrop-blur-sm hover:bg-background/80 transition-all duration-300">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
<CardTitle className="text-sm font-medium tracking-tight text-muted-foreground group-hover:text-foreground transition-colors">
{title}
</CardTitle>
<div className="p-2 rounded-lg bg-muted group-hover:bg-primary/10 group-hover:text-primary transition-all duration-300">
<Icon className="h-4 w-4" />
</div>
</CardHeader>
<CardContent className="relative z-10">
<div className="text-3xl font-bold tracking-tight">{value}</div>
<div className="mt-1 h-1 w-0 bg-primary/20 group-hover:w-full transition-all duration-500 rounded-full" />
</CardContent>
</Card>
</motion.div>
)
}

View File

@@ -0,0 +1,75 @@
"use client"
import Link from "next/link"
import { motion } from "framer-motion"
import {
PlusCircle,
Package,
BarChart3,
Settings,
MessageSquare,
Truck,
Tag,
Users
} from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
const actions = [
{
title: "Add Product",
icon: PlusCircle,
href: "/dashboard/products/new",
color: "bg-blue-500/10 text-blue-500",
description: "Create a new listing"
},
{
title: "Process Orders",
icon: Truck,
href: "/dashboard/orders?status=paid",
color: "bg-emerald-500/10 text-emerald-500",
description: "Ship pending orders"
},
{
title: "Analytics",
icon: BarChart3,
href: "/dashboard/analytics",
color: "bg-purple-500/10 text-purple-500",
description: "View sales performance"
},
{
title: "Messages",
icon: MessageSquare,
href: "/dashboard/chats",
color: "bg-amber-500/10 text-amber-500",
description: "Chat with customers"
}
]
export default function QuickActions() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{actions.map((action, index) => (
<motion.div
key={action.title}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Link href={action.href}>
<Card className="hover:border-primary/50 transition-colors cursor-pointer group h-full">
<CardContent className="p-6 flex flex-col items-center text-center">
<div className={`p-3 rounded-xl ${action.color} mb-4 group-hover:scale-110 transition-transform`}>
<action.icon className="h-6 w-6" />
</div>
<h3 className="font-semibold text-lg">{action.title}</h3>
<p className="text-sm text-muted-foreground mt-1">{action.description}</p>
</CardContent>
</Card>
</Link>
</motion.div>
))}
</div>
)
}

View File

@@ -0,0 +1,119 @@
"use client"
import { useState, useEffect } from "react"
import { motion } from "framer-motion"
import { ShoppingBag, CreditCard, Truck, MessageSquare, AlertCircle } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { clientFetch } from "@/lib/api"
import { Skeleton } from "@/components/ui/skeleton"
import { formatDistanceToNow } from "date-fns"
import Link from "next/link"
interface ActivityItem {
_id: string;
orderId: string;
status: string;
totalPrice: number;
orderDate: string;
username?: string;
}
export default function RecentActivity() {
const [activities, setActivities] = useState<ActivityItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchRecentOrders() {
try {
const data = await clientFetch("/orders?limit=5&sortBy=orderDate&sortOrder=desc");
setActivities(data.orders || []);
} catch (error) {
console.error("Failed to fetch recent activity:", error);
} finally {
setLoading(false);
}
}
fetchRecentOrders();
}, []);
const getStatusIcon = (status: string) => {
switch (status) {
case "paid": return <CreditCard className="h-4 w-4" />;
case "shipped": return <Truck className="h-4 w-4" />;
case "unpaid": return <ShoppingBag className="h-4 w-4" />;
default: return <AlertCircle className="h-4 w-4" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "paid": return "bg-emerald-500/10 text-emerald-500";
case "shipped": return "bg-blue-500/10 text-blue-500";
case "unpaid": return "bg-amber-500/10 text-amber-500";
default: return "bg-gray-500/10 text-gray-500";
}
};
return (
<Card className="h-full">
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>Latest updates from your store</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-4">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="space-y-2 flex-1">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/4" />
</div>
</div>
))}
</div>
) : activities.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
No recent activity
</div>
) : (
<div className="space-y-6">
{activities.map((item, index) => (
<motion.div
key={item._id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
className="flex items-start gap-4 relative"
>
{index !== activities.length - 1 && (
<div className="absolute left-[15px] top-8 bottom-[-24px] w-[2px] bg-border/50" />
)}
<div className={`mt-1 p-2 rounded-full z-10 ${getStatusColor(item.status)}`}>
{getStatusIcon(item.status)}
</div>
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<Link href={`/dashboard/orders/${item._id}`} className="font-medium hover:underline">
Order #{item.orderId}
</Link>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(item.orderDate), { addSuffix: true })}
</span>
</div>
<p className="text-sm text-muted-foreground">
{item.status === "paid" ? "Payment received" :
item.status === "shipped" ? "Order marked as shipped" :
`Order status: ${item.status}`} for £{item.totalPrice.toFixed(2)}
</p>
</div>
</motion.div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,86 @@
"use client"
import { CheckCircle2, Circle, Clock, Package, Truck, Flag } from "lucide-react"
import { motion } from "framer-motion"
interface OrderTimelineProps {
status: string;
orderDate: Date | string;
paidAt?: Date | string;
completedAt?: Date | string;
}
const steps = [
{ status: "unpaid", label: "Ordered", icon: Clock },
{ status: "paid", label: "Paid", icon: CheckCircle2 },
{ status: "acknowledged", label: "Processing", icon: Package },
{ status: "shipped", label: "Shipped", icon: Truck },
{ status: "completed", label: "Completed", icon: Flag },
]
export default function OrderTimeline({ status, orderDate, paidAt }: OrderTimelineProps) {
const currentStatusIndex = steps.findIndex(step =>
step.status === status ||
(status === "confirming" && step.status === "unpaid") ||
(status === "acknowledged" && step.status === "paid") // Processed is after paid
);
// If status is "confirming", it's basically "unpaid" for the timeline
// If status is "acknowledged", it's "Processing"
const getStepStatus = (index: number) => {
// Basic logic to determine if a step is completed, current, or pending
let effectiveIndex = currentStatusIndex;
if (status === "confirming") effectiveIndex = 0;
if (status === "paid") effectiveIndex = 1;
if (status === "acknowledged") effectiveIndex = 2;
if (status === "shipped") effectiveIndex = 3;
if (status === "completed") effectiveIndex = 4;
if (index < effectiveIndex) return "completed";
if (index === effectiveIndex) return "current";
return "pending";
};
return (
<div className="relative flex justify-between items-center w-full px-4 py-8">
{/* Connector Line */}
<div className="absolute left-10 right-10 top-1/2 h-0.5 bg-muted -translate-y-1/2 z-0">
<motion.div
className="h-full bg-primary"
initial={{ width: "0%" }}
animate={{ width: `${(Math.max(0, steps.findIndex(s => s.status === status)) / (steps.length - 1)) * 100}%` }}
transition={{ duration: 1, ease: "easeInOut" }}
/>
</div>
{steps.map((step, index) => {
const stepStatus = getStepStatus(index);
const Icon = step.icon;
return (
<div key={step.label} className="relative flex flex-col items-center z-10">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: index * 0.1 }}
className={`flex items-center justify-center w-10 h-10 rounded-full border-2 transition-colors duration-500 ${stepStatus === "completed"
? "bg-primary border-primary text-primary-foreground"
: stepStatus === "current"
? "bg-background border-primary text-primary ring-4 ring-primary/10"
: "bg-background border-muted text-muted-foreground"
}`}
>
<Icon className="h-5 w-5" />
</motion.div>
<div className="absolute top-12 whitespace-nowrap text-xs font-medium tracking-tight">
<p className={stepStatus === "pending" ? "text-muted-foreground" : "text-foreground"}>
{step.label}
</p>
</div>
</div>
);
})}
</div>
)
}

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from "react";
import React from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Table,
TableBody,
@@ -11,6 +12,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import {
Select,
SelectContent,
@@ -278,39 +280,40 @@ export default function OrderTable() {
const statusConfig: Record<OrderStatus, StatusConfig> = {
acknowledged: {
icon: CheckCircle2,
color: "text-white",
bgColor: "bg-purple-600"
color: "text-purple-100",
bgColor: "bg-purple-600/90 shadow-[0_0_10px_rgba(147,51,234,0.3)]"
},
paid: {
icon: CheckCircle2,
color: "text-white",
bgColor: "bg-emerald-600"
color: "text-emerald-100",
bgColor: "bg-emerald-600/90 shadow-[0_0_10px_rgba(16,185,129,0.3)]",
animate: "animate-pulse"
},
unpaid: {
icon: XCircle,
color: "text-white",
bgColor: "bg-red-500"
color: "text-amber-100",
bgColor: "bg-amber-500/90"
},
confirming: {
icon: Loader2,
color: "text-white",
bgColor: "bg-yellow-500",
color: "text-blue-100",
bgColor: "bg-blue-500/90",
animate: "animate-spin"
},
shipped: {
icon: Truck,
color: "text-white",
bgColor: "bg-blue-600"
color: "text-indigo-100",
bgColor: "bg-indigo-600/90 shadow-[0_0_10px_rgba(79,70,229,0.3)]"
},
completed: {
icon: CheckCircle2,
color: "text-white",
bgColor: "bg-green-600"
color: "text-green-100",
bgColor: "bg-green-600/90"
},
cancelled: {
icon: XCircle,
color: "text-white",
bgColor: "bg-gray-500"
color: "text-gray-100",
bgColor: "bg-gray-600/90"
}
};
@@ -318,12 +321,12 @@ export default function OrderTable() {
const isOrderUnderpaid = (order: Order) => {
// More robust check - only show underpaid if status allows it and underpayment exists
return order.underpaid === true &&
order.underpaymentAmount &&
order.underpaymentAmount > 0 &&
order.status !== "paid" &&
order.status !== "completed" &&
order.status !== "shipped" &&
order.status !== "cancelled";
order.underpaymentAmount &&
order.underpaymentAmount > 0 &&
order.status !== "paid" &&
order.status !== "completed" &&
order.status !== "shipped" &&
order.status !== "cancelled";
};
// Helper function to get underpaid display info
@@ -391,9 +394,9 @@ export default function OrderTable() {
return (
<div className="space-y-4">
<div className="border border-zinc-800 rounded-md bg-black/40 overflow-hidden">
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
{/* Filters header */}
<div className="p-4 border-b border-zinc-800 bg-black/60">
<div className="p-4 border-b border-border/50 bg-muted/30">
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4">
<div className="flex flex-col sm:flex-row gap-2 sm:items-center w-full lg:w-auto">
<StatusFilter
@@ -413,6 +416,7 @@ export default function OrderTable() {
disabled={exporting}
variant="outline"
size="sm"
className="bg-background/50 border-border/50"
>
{exporting ? (
<>
@@ -432,8 +436,8 @@ export default function OrderTable() {
<div className="flex items-center gap-2 self-end lg:self-auto">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button disabled={selectedOrders.size === 0 || isShipping}>
<Truck className="mr-2 h-5 w-5" />
<Button disabled={selectedOrders.size === 0 || isShipping} className="shadow-md">
<Truck className="mr-2 h-4 w-4" />
{isShipping ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
@@ -462,163 +466,168 @@ export default function OrderTable() {
</div>
{/* Table */}
<div className="relative">
<CardContent className="p-0 relative min-h-[400px]">
{loading && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin" />
<div className="absolute inset-0 bg-background/50 backdrop-blur-[1px] flex items-center justify-center z-50">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)}
<div className="max-h-[calc(100vh-300px)] overflow-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>
<div className="max-h-[calc(100vh-350px)] overflow-auto">
<Table>
<TableHeader className="bg-muted/50 sticky top-0 z-20">
<TableRow className="hover:bg-transparent border-border/50">
<TableHead className="w-12">
<Checkbox
checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0}
onCheckedChange={toggleAll}
/>
</TableHead>
<TableHead className="cursor-pointer" onClick={() => handleSort("orderId")}>
Order ID <ArrowUpDown className="ml-2 inline h-4 w-4" />
<TableHead className="cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("orderId")}>
Order ID <ArrowUpDown className="ml-2 inline h-3 w-3" />
</TableHead>
<TableHead className="cursor-pointer" onClick={() => handleSort("totalPrice")}>
Total <ArrowUpDown className="ml-2 inline h-4 w-4" />
<TableHead className="cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("totalPrice")}>
Total <ArrowUpDown className="ml-2 inline h-3 w-3" />
</TableHead>
<TableHead className="hidden lg:table-cell">Promotion</TableHead>
<TableHead className="cursor-pointer" onClick={() => handleSort("status")}>
Status <ArrowUpDown className="ml-2 inline h-4 w-4" />
<TableHead className="cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("status")}>
Status <ArrowUpDown className="ml-2 inline h-3 w-3" />
</TableHead>
<TableHead className="hidden md:table-cell cursor-pointer" onClick={() => handleSort("orderDate")}>
Order Date <ArrowUpDown className="ml-2 inline h-4 w-4" />
<TableHead className="hidden md:table-cell cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("orderDate")}>
Date <ArrowUpDown className="ml-2 inline h-3 w-3" />
</TableHead>
<TableHead className="hidden xl:table-cell cursor-pointer" onClick={() => handleSort("paidAt")}>
Paid At <ArrowUpDown className="ml-2 inline h-4 w-4" />
<TableHead className="hidden xl:table-cell cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("paidAt")}>
Paid At <ArrowUpDown className="ml-2 inline h-3 w-3" />
</TableHead>
<TableHead className="hidden lg:table-cell">Buyer</TableHead>
<TableHead className="w-24 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedOrders.map((order) => {
const StatusIcon = statusConfig[order.status as keyof typeof statusConfig]?.icon || XCircle;
const underpaidInfo = getUnderpaidInfo(order);
<AnimatePresence mode="popLayout">
{paginatedOrders.map((order, index) => {
const StatusIcon = statusConfig[order.status as keyof typeof statusConfig]?.icon || XCircle;
const underpaidInfo = getUnderpaidInfo(order);
return (
<TableRow key={order._id}>
<TableCell>
<Checkbox
checked={selectedOrders.has(order._id)}
onCheckedChange={() => toggleSelection(order._id)}
disabled={order.status !== "paid" && order.status !== "acknowledged"}
/>
</TableCell>
<TableCell>#{order.orderId}</TableCell>
<TableCell>
<div className="flex flex-col">
<span>£{order.totalPrice.toFixed(2)}</span>
{underpaidInfo && (
<span className="text-xs text-red-400">
Missing: £{underpaidInfo.missingGbp.toFixed(2)} ({underpaidInfo.missing.toFixed(8)} LTC)
</span>
)}
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
{order.promotionCode ? (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<Tag className="h-3 w-3 text-green-500" />
<span className="text-xs font-mono bg-green-100 text-green-800 px-2 py-0.5 rounded">
{order.promotionCode}
return (
<motion.tr
key={order._id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, delay: index * 0.03 }}
className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
>
<TableCell>
<Checkbox
checked={selectedOrders.has(order._id)}
onCheckedChange={() => toggleSelection(order._id)}
disabled={order.status !== "paid" && order.status !== "acknowledged"}
/>
</TableCell>
<TableCell className="font-mono text-sm font-medium">#{order.orderId}</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">£{order.totalPrice.toFixed(2)}</span>
{underpaidInfo && (
<span className="text-[10px] text-destructive flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
-£{underpaidInfo.missingGbp.toFixed(2)}
</span>
</div>
<div className="flex items-center gap-1 text-xs text-green-600">
<Percent className="h-3 w-3" />
<span>-£{(order.discountAmount || 0).toFixed(2)}</span>
{order.subtotalBeforeDiscount && order.subtotalBeforeDiscount > 0 && (
<span className="text-muted-foreground">
(was £{order.subtotalBeforeDiscount.toFixed(2)})
)}
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
{order.promotionCode ? (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<Tag className="h-3 w-3 text-emerald-500" />
<span className="text-[10px] font-mono bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-1.5 py-0.5 rounded border border-emerald-500/20">
{order.promotionCode}
</span>
)}
</div>
</div>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className={`inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm ${
statusConfig[order.status as OrderStatus]?.bgColor || "bg-gray-500"
} ${statusConfig[order.status as OrderStatus]?.color || "text-white"}`}>
{React.createElement(statusConfig[order.status as OrderStatus]?.icon || XCircle, {
className: `h-4 w-4 ${statusConfig[order.status as OrderStatus]?.animate || ""}`
})}
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
</div>
{isOrderUnderpaid(order) && (
<div className="flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-red-600 text-white">
<AlertTriangle className="h-3 w-3" />
{underpaidInfo?.percentage}%
</div>
<div className="flex items-center gap-1 text-[10px] text-emerald-600/80">
<Percent className="h-2.5 w-2.5" />
<span>-£{(order.discountAmount || 0).toFixed(2)}</span>
</div>
</div>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</div>
</TableCell>
<TableCell className="hidden md:table-cell">
{new Date(order.orderDate).toLocaleDateString("en-GB", {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
})}
</TableCell>
<TableCell className="hidden xl:table-cell">
{order.paidAt ? new Date(order.paidAt).toLocaleDateString("en-GB", {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
}) : "-"}
</TableCell>
<TableCell className="hidden lg:table-cell">
{order.telegramUsername ? `@${order.telegramUsername}` : "-"}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="sm" asChild>
<Link href={`/dashboard/orders/${order._id}`}>
<Eye className="h-4 w-4" />
</Link>
</Button>
{(order.telegramBuyerId || order.telegramUsername) && (
<Button
variant="ghost"
size="sm"
asChild
title={`Chat with customer${order.telegramUsername ? ` @${order.telegramUsername}` : ''}`}
>
<Link href={`/dashboard/chats/new?buyerId=${order.telegramBuyerId || order.telegramUsername}`}>
<MessageCircle className="h-4 w-4 text-primary" />
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border shadow-sm ${statusConfig[order.status as OrderStatus]?.bgColor || "bg-muted text-muted-foreground border-border"} ${statusConfig[order.status as OrderStatus]?.color || ""}`}>
{React.createElement(statusConfig[order.status as OrderStatus]?.icon || XCircle, {
className: `h-3.5 w-3.5 ${statusConfig[order.status as OrderStatus]?.animate || ""}`
})}
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
</div>
{isOrderUnderpaid(order) && (
<div className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-destructive/10 text-destructive border border-destructive/20 font-medium">
{underpaidInfo?.percentage}%
</div>
)}
</div>
</TableCell>
<TableCell className="hidden md:table-cell text-sm text-muted-foreground">
{new Date(order.orderDate).toLocaleDateString("en-GB", {
day: '2-digit',
month: 'short',
year: 'numeric',
})}
<span className="ml-1 opacity-50 text-[10px]">
{new Date(order.orderDate).toLocaleTimeString("en-GB", { hour: '2-digit', minute: '2-digit' })}
</span>
</TableCell>
<TableCell className="hidden xl:table-cell text-sm text-muted-foreground">
{order.paidAt ? new Date(order.paidAt).toLocaleDateString("en-GB", {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit'
}) : "-"}
</TableCell>
<TableCell className="hidden lg:table-cell">
{order.telegramUsername ? (
<span className="text-sm font-medium text-primary">@{order.telegramUsername}</span>
) : (
<span className="text-xs text-muted-foreground italic">Guest</span>
)}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-foreground" asChild>
<Link href={`/dashboard/orders/${order._id}`}>
<Eye className="h-4 w-4" />
</Link>
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})}
{(order.telegramBuyerId || order.telegramUsername) && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-primary"
asChild
title={`Chat with customer${order.telegramUsername ? ` @${order.telegramUsername}` : ''}`}
>
<Link href={`/dashboard/chats/new?buyerId=${order.telegramBuyerId || order.telegramUsername}`}>
<MessageCircle className="h-4 w-4" />
</Link>
</Button>
)}
</div>
</TableCell>
</motion.tr>
);
})}
</AnimatePresence>
</TableBody>
</Table>
</div>
</div>
</CardContent>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-4 border-t border-zinc-800 bg-black/40">
<div className="flex items-center justify-between px-4 py-4 border-t border-border/50 bg-background/50">
<div className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages} ({totalOrders} total)
</div>
@@ -628,8 +637,9 @@ export default function OrderTable() {
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || loading}
className="h-8"
>
<ChevronLeft className="h-4 w-4" />
<ChevronLeft className="h-3 w-3 mr-1" />
Previous
</Button>
<Button
@@ -637,14 +647,15 @@ export default function OrderTable() {
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || loading}
className="h-8"
>
Next
<ChevronRight className="h-4 w-4" />
<ChevronRight className="h-3 w-3 ml-1" />
</Button>
</div>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -1,4 +1,4 @@
{
"commitHash": "a05787a",
"buildTime": "2026-01-12T05:47:06.100Z"
"commitHash": "7b95589",
"buildTime": "2026-01-12T06:32:31.897Z"
}