Enhance dashboard UI and add order timeline
All checks were successful
Build Frontend / build (push) Successful in 1m12s
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:
@@ -39,6 +39,8 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import Layout from "@/components/layout/layout";
|
import Layout from "@/components/layout/layout";
|
||||||
import { cacheUtils } from '@/lib/api-client';
|
import { cacheUtils } from '@/lib/api-client';
|
||||||
|
import OrderTimeline from "@/components/orders/order-timeline";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
interface Order {
|
interface Order {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
@@ -791,10 +793,26 @@ export default function OrderDetailsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<div className="grid grid-cols-3 gap-6">
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="grid grid-cols-3 gap-6"
|
||||||
|
>
|
||||||
{/* Left Column - Order Details */}
|
{/* Left Column - Order Details */}
|
||||||
<div className="col-span-2 space-y-6">
|
<div className="col-span-2 space-y-6">
|
||||||
{/* Products Card */}
|
{/* Products Card */}
|
||||||
@@ -1168,8 +1186,7 @@ export default function OrderDetailsPage() {
|
|||||||
{[...Array(5)].map((_, i) => (
|
{[...Array(5)].map((_, i) => (
|
||||||
<svg
|
<svg
|
||||||
key={i}
|
key={i}
|
||||||
className={`w-4 h-4 ${
|
className={`w-4 h-4 ${i < (order?.review?.stars || 0)
|
||||||
i < (order?.review?.stars || 0)
|
|
||||||
? "text-yellow-400"
|
? "text-yellow-400"
|
||||||
: "text-zinc-600"
|
: "text-zinc-600"
|
||||||
}`}
|
}`}
|
||||||
@@ -1198,10 +1215,10 @@ export default function OrderDetailsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Shipping Dialog removed; use inline tracking input above */}
|
{/* Shipping Dialog removed; use inline tracking input above */}
|
||||||
</div>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -40,11 +40,16 @@ import {
|
|||||||
UserPlus,
|
UserPlus,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Search,
|
Search,
|
||||||
X
|
X,
|
||||||
|
CreditCard,
|
||||||
|
Calendar,
|
||||||
|
ShoppingBag
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
@@ -184,13 +189,13 @@ export default function CustomerManagementPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-black/40 border border-zinc-800 rounded-md overflow-hidden">
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm 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="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-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-sm font-medium text-gray-400">Show:</div>
|
<div className="text-sm font-medium text-muted-foreground">Show:</div>
|
||||||
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
|
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
|
||||||
<SelectTrigger className="w-[70px]">
|
<SelectTrigger className="w-[70px] bg-background/50 border-border/50">
|
||||||
<SelectValue placeholder="25" />
|
<SelectValue placeholder="25" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -204,26 +209,26 @@ export default function CustomerManagementPage() {
|
|||||||
|
|
||||||
<div className="relative flex-1 max-w-md">
|
<div className="relative flex-1 max-w-md">
|
||||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
<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" />
|
<Search className="h-4 w-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by username or Telegram ID..."
|
placeholder="Search by username or Telegram ID..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-10 pr-10 py-2 w-full bg-black/40 border-zinc-700 text-white"
|
className="pl-10 pr-10 py-2 w-full bg-background/50 border-border/50 focus:ring-primary/20 transition-all duration-300"
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-3"
|
className="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||||
onClick={clearSearch}
|
onClick={clearSearch}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 text-gray-400 hover:text-gray-200" />
|
<X className="h-4 w-4 text-muted-foreground hover:text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-gray-400 whitespace-nowrap">
|
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
{loading
|
{loading
|
||||||
? "Loading..."
|
? "Loading..."
|
||||||
: searchQuery
|
: searchQuery
|
||||||
@@ -232,31 +237,25 @@ export default function CustomerManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="p-0">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-8 bg-black/60">
|
<div className="p-8">
|
||||||
{/* Loading indicator */}
|
{/* Loading indicator */}
|
||||||
<div className="absolute top-0 left-0 right-0 h-1 bg-muted overflow-hidden">
|
<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"
|
<div className="h-full bg-primary w-1/3 animate-shimmer"
|
||||||
style={{
|
style={{
|
||||||
background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)',
|
background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)',
|
||||||
backgroundSize: '200% 100%',
|
backgroundSize: '200% 100%',
|
||||||
animation: 'shimmer 2s ease-in-out infinite',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table skeleton */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-4 pb-4 border-b border-zinc-800">
|
<div className="flex items-center gap-4 pb-4 border-b border-border/50">
|
||||||
{['Customer', 'Orders', 'Total Spent', 'Last Order', 'Status'].map((header, i) => (
|
{['Customer', 'Orders', 'Total Spent', 'Last Order', 'Status'].map((header, i) => (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
key={i}
|
key={i}
|
||||||
className="h-4 w-20 flex-1 animate-in fade-in"
|
className="h-4 w-20 flex-1"
|
||||||
style={{
|
|
||||||
animationDelay: `${i * 50}ms`,
|
|
||||||
animationDuration: '300ms',
|
|
||||||
animationFillMode: 'both',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -264,12 +263,7 @@ export default function CustomerManagementPage() {
|
|||||||
{[...Array(5)].map((_, i) => (
|
{[...Array(5)].map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex items-center gap-4 pb-4 border-b border-zinc-800 last:border-b-0 animate-in fade-in"
|
className="flex items-center gap-4 pb-4 border-b border-border/50 last:border-b-0"
|
||||||
style={{
|
|
||||||
animationDelay: `${250 + i * 50}ms`,
|
|
||||||
animationDuration: '300ms',
|
|
||||||
animationFillMode: 'both',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 flex-1">
|
<div className="flex items-center gap-3 flex-1">
|
||||||
<Skeleton className="h-10 w-10 rounded-full" />
|
<Skeleton className="h-10 w-10 rounded-full" />
|
||||||
@@ -287,114 +281,144 @@ export default function CustomerManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : filteredCustomers.length === 0 ? (
|
) : filteredCustomers.length === 0 ? (
|
||||||
<div className="p-8 text-center bg-black/60">
|
<div className="p-12 text-center">
|
||||||
<Users className="h-12 w-12 mx-auto text-gray-500 mb-4" />
|
<div className="w-16 h-16 bg-muted/50 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<h3 className="text-lg font-medium mb-2 text-white">
|
<Users className="h-8 w-8 text-muted-foreground" />
|
||||||
{searchQuery ? "No customers matching your search" : "No customers found"}
|
</div>
|
||||||
|
<h3 className="text-lg font-medium mb-2 text-foreground">
|
||||||
|
{searchQuery ? "No matching customers" : "No customers yet"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-500">
|
<p className="text-muted-foreground max-w-sm mx-auto mb-6">
|
||||||
{searchQuery
|
{searchQuery
|
||||||
? "Try a different search term or clear the search"
|
? "We couldn't find any customers matching your search criteria."
|
||||||
: "Once you have customers placing orders, they will appear here."}
|
: "Once you have customers placing orders, they will appear here."}
|
||||||
</p>
|
</p>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<Button variant="outline" size="sm" onClick={clearSearch} className="mt-4">
|
<Button variant="outline" size="sm" onClick={clearSearch}>
|
||||||
Clear search
|
Clear Search
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<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">
|
<Table>
|
||||||
<TableHeader className="bg-black/60 sticky top-0 z-10">
|
<TableHeader className="bg-muted/50">
|
||||||
<TableRow>
|
<TableRow className="hover:bg-transparent border-border/50">
|
||||||
<TableHead className="w-[180px] text-gray-300">Customer</TableHead>
|
<TableHead className="w-[200px]">Customer</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer w-[100px] text-gray-300 text-center"
|
className="cursor-pointer w-[100px] text-center hover:text-primary transition-colors"
|
||||||
onClick={() => handleSort("totalOrders")}
|
onClick={() => handleSort("totalOrders")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center gap-1">
|
||||||
Orders
|
Orders
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<ArrowUpDown className="h-3 w-3" />
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer w-[150px] text-gray-300 text-center"
|
className="cursor-pointer w-[150px] text-center hover:text-primary transition-colors"
|
||||||
onClick={() => handleSort("totalSpent")}
|
onClick={() => handleSort("totalSpent")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center gap-1">
|
||||||
Total Spent
|
Total Spent
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<ArrowUpDown className="h-3 w-3" />
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer w-[180px] text-gray-300 text-center"
|
className="cursor-pointer w-[180px] text-center hover:text-primary transition-colors"
|
||||||
onClick={() => handleSort("lastOrderDate")}
|
onClick={() => handleSort("lastOrderDate")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center gap-1">
|
||||||
Last Order
|
Last Order
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<ArrowUpDown className="h-3 w-3" />
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[250px] text-gray-300 text-center">Status</TableHead>
|
<TableHead className="w-[250px] text-center">Status</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredCustomers.map((customer) => (
|
<AnimatePresence mode="popLayout">
|
||||||
<TableRow
|
{filteredCustomers.map((customer, index) => (
|
||||||
|
<motion.tr
|
||||||
key={customer.userId}
|
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)}
|
onClick={() => setSelectedCustomer(customer)}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell className="py-3">
|
||||||
<div className="font-medium text-gray-100">
|
<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.telegramUsername || "Unknown"}
|
||||||
{!customer.hasOrders && (
|
{!customer.hasOrders && (
|
||||||
<Badge variant="outline" className="ml-2 bg-purple-900/30 text-purple-300 border-purple-700">
|
<Badge variant="outline" className="h-5 px-1.5 text-[10px] bg-primary/10 text-primary border-primary/20">
|
||||||
<UserPlus className="h-3 w-3 mr-1" />
|
|
||||||
New
|
New
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-400">ID: {customer.telegramUserId}</div>
|
<div className="text-xs text-muted-foreground flex items-center font-mono mt-0.5">
|
||||||
|
<span className="opacity-50 select-none">ID:</span>
|
||||||
|
<span className="ml-1">{customer.telegramUserId}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<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>
|
||||||
<TableCell className="font-medium text-gray-100 text-center">
|
<TableCell className="text-center font-mono text-sm">
|
||||||
{formatCurrency(customer.totalSpent)}
|
{formatCurrency(customer.totalSpent)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-gray-100 text-center">
|
<TableCell className="text-center text-sm text-muted-foreground">
|
||||||
{customer.lastOrderDate ? formatDate(customer.lastOrderDate) : "N/A"}
|
{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>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
{customer.hasOrders ? (
|
{customer.hasOrders ? (
|
||||||
<div className="flex justify-center space-x-1">
|
<div className="flex justify-center flex-wrap gap-1">
|
||||||
<Badge className="bg-blue-500 text-white hover:bg-blue-600">
|
{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
|
{customer.ordersByStatus.paid} Paid
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge className="bg-green-500 text-white hover:bg-green-600">
|
)}
|
||||||
{customer.ordersByStatus.completed} Completed
|
{customer.ordersByStatus.completed > 0 && (
|
||||||
</Badge>
|
<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]">
|
||||||
<Badge className="bg-amber-500 text-white hover:bg-amber-600">
|
{customer.ordersByStatus.completed} Done
|
||||||
{customer.ordersByStatus.shipped} Shipped
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" className="bg-gray-800 text-gray-300 border-gray-700">
|
|
||||||
No orders yet
|
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{customer.ordersByStatus.shipped > 0 && (
|
||||||
|
<Badge className="bg-amber-500/15 text-amber-500 hover:bg-amber-500/25 border-amber-500/20 h-5 px-1.5 text-[10px]">
|
||||||
|
{customer.ordersByStatus.shipped} Ship
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground italic">No activity</span>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</motion.tr>
|
||||||
))}
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
<div className="p-4 border-t border-zinc-800 bg-black/40 flex justify-between items-center">
|
<div className="p-4 border-t border-border/50 bg-background/50 flex justify-between items-center">
|
||||||
<div className="text-sm text-gray-400">
|
<div className="text-sm text-muted-foreground">
|
||||||
Page {page} of {totalPages}
|
Page {page} of {totalPages}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -403,45 +427,46 @@ export default function CustomerManagementPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlePageChange(Math.max(1, page - 1))}
|
onClick={() => handlePageChange(Math.max(1, page - 1))}
|
||||||
disabled={page === 1 || loading}
|
disabled={page === 1 || loading}
|
||||||
|
className="h-8"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
<ChevronLeft className="h-3 w-3 mr-1" />
|
||||||
Previous
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{totalPages > 2 && (
|
{totalPages > 2 ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm" className="h-8 px-2">
|
||||||
<span className="sr-only">Go to page</span>
|
<MoreHorizontal className="h-3 w-3" />
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="center" className="bg-black/90 border-zinc-800 max-h-60 overflow-y-auto">
|
<DropdownMenuContent align="center" className="max-h-60 overflow-y-auto">
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => (
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={pageNum}
|
key={pageNum}
|
||||||
onClick={() => handlePageChange(pageNum)}
|
onClick={() => handlePageChange(pageNum)}
|
||||||
className={`cursor-pointer ${pageNum === page ? 'bg-zinc-800 text-white' : 'text-gray-300'}`}
|
className={pageNum === page ? 'bg-primary/10 text-primary' : ''}
|
||||||
>
|
>
|
||||||
Page {pageNum}
|
Page {pageNum}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlePageChange(Math.min(totalPages, page + 1))}
|
onClick={() => handlePageChange(Math.min(totalPages, page + 1))}
|
||||||
disabled={page === totalPages || loading}
|
disabled={page === totalPages || loading}
|
||||||
|
className="h-8"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
<ChevronRight className="h-4 w-4 ml-1" />
|
<ChevronRight className="h-3 w-3 ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* Customer Details Dialog */}
|
{/* Customer Details Dialog */}
|
||||||
{selectedCustomer && (
|
{selectedCustomer && (
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ import Layout from "@/components/layout/layout";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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 { apiRequest } from "@/lib/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import BroadcastDialog from "@/components/modals/broadcast-dialog";
|
import BroadcastDialog from "@/components/modals/broadcast-dialog";
|
||||||
import Dashboard from "@/components/dashboard/dashboard";
|
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 {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -166,17 +170,61 @@ export default function StorefrontPage() {
|
|||||||
return (
|
return (
|
||||||
<Dashboard>
|
<Dashboard>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-4">
|
||||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white flex items-center">
|
<div className="p-3 rounded-xl bg-primary/10 text-primary">
|
||||||
<Globe className="mr-2 h-6 w-6" />
|
<Globe className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-foreground">
|
||||||
Storefront Settings
|
Storefront Settings
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-2">
|
<p className="text-muted-foreground">
|
||||||
<TooltipProvider>
|
Manage your shop's appearance, policies, and configuration
|
||||||
<Tooltip>
|
</p>
|
||||||
<TooltipTrigger asChild>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setBroadcastOpen(true)}
|
||||||
|
className="gap-2 h-10"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
Broadcast
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={saveStorefront}
|
||||||
|
disabled={saving}
|
||||||
|
className="gap-2 h-10 min-w-[120px]"
|
||||||
|
>
|
||||||
|
{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 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
|
<Switch
|
||||||
checked={storefront.isEnabled}
|
checked={storefront.isEnabled}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
@@ -185,183 +233,190 @@ export default function StorefrontPage() {
|
|||||||
isEnabled: checked,
|
isEnabled: checked,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
className="data-[state=checked]:bg-emerald-500"
|
||||||
/>
|
/>
|
||||||
<span className={`text-sm font-medium ${storefront.isEnabled ? 'text-emerald-400' : 'text-zinc-400'}`}>
|
<div>
|
||||||
{storefront.isEnabled ? 'Store Open' : 'Store Closed'}
|
<h4 className="font-medium text-sm">
|
||||||
</span>
|
{storefront.isEnabled ? 'Your store is currently online' : 'Your store is currently offline'}
|
||||||
</div>
|
</h4>
|
||||||
</TooltipTrigger>
|
<p className="text-xs text-muted-foreground">
|
||||||
<TooltipContent>
|
{storefront.isEnabled
|
||||||
<p>{storefront.isEnabled ? 'Click to close store' : 'Click to open store'}</p>
|
? 'Customers can browse listings and place orders normally.'
|
||||||
</TooltipContent>
|
: 'Customers will see a maintenance page. No new orders can be placed.'}
|
||||||
</Tooltip>
|
</p>
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
</CardContent>
|
||||||
<Button
|
</Card>
|
||||||
variant="outline"
|
</motion.div>
|
||||||
onClick={() => setBroadcastOpen(true)}
|
|
||||||
className="gap-2"
|
{/* Welcome & Policy */}
|
||||||
size="sm"
|
<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 }}>
|
||||||
<Send className="h-4 w-4" />
|
<Card className="h-full border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||||
Broadcast
|
<CardHeader>
|
||||||
</Button>
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
<Button
|
<MessageSquare className="h-4 w-4 text-primary" />
|
||||||
onClick={saveStorefront}
|
Welcome Message
|
||||||
disabled={saving}
|
</CardTitle>
|
||||||
className="gap-2"
|
</CardHeader>
|
||||||
size="sm"
|
<CardContent>
|
||||||
>
|
<Textarea
|
||||||
<Save className="h-4 w-4" />
|
value={storefront.welcomeMessage}
|
||||||
{saving ? "Saving..." : "Save Changes"}
|
onChange={(e) => setStorefront(prev => ({ ...prev, welcomeMessage: e.target.value }))}
|
||||||
</Button>
|
placeholder="Enter the welcome message for new customers..."
|
||||||
</div>
|
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>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6">
|
|
||||||
{/* Security Settings */}
|
{/* Security Settings */}
|
||||||
<div className="space-y-3">
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }}>
|
||||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<CardHeader>
|
||||||
<Shield className="h-4 w-4 text-purple-400" />
|
<CardTitle className="flex items-center gap-2">
|
||||||
<h2 className="text-base font-medium text-zinc-100">
|
<Key className="h-5 w-5 text-purple-400" />
|
||||||
Security
|
Security Configuration
|
||||||
</h2>
|
</CardTitle>
|
||||||
</div>
|
<CardDescription>Manage keys and access tokens for your store security</CardDescription>
|
||||||
<div className="space-y-3">
|
</CardHeader>
|
||||||
<div>
|
<CardContent className="space-y-6">
|
||||||
<label className="text-xs font-medium mb-1 block text-zinc-400">PGP Public Key</label>
|
<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
|
<Textarea
|
||||||
value={storefront.pgpKey}
|
value={storefront.pgpKey}
|
||||||
onChange={(e) => setStorefront(prev => ({ ...prev, pgpKey: e.target.value }))}
|
onChange={(e) => setStorefront(prev => ({ ...prev, pgpKey: e.target.value }))}
|
||||||
placeholder="Enter your PGP public key"
|
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----..."
|
||||||
className="font-mono text-sm h-24 bg-[#1C1C1C] border-zinc-800 resize-none"
|
className="font-mono text-xs h-32 bg-zinc-950/50 border-zinc-800/50 resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium mb-1 block text-zinc-400">Telegram Bot Token</label>
|
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Telegram Bot Token</Label>
|
||||||
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={storefront.telegramToken}
|
value={storefront.telegramToken}
|
||||||
onChange={(e) => setStorefront(prev => ({ ...prev, telegramToken: e.target.value }))}
|
onChange={(e) => setStorefront(prev => ({ ...prev, telegramToken: e.target.value }))}
|
||||||
placeholder="Enter your Telegram bot token"
|
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||||
className="bg-[#1C1C1C] border-zinc-800 h-8 text-sm"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">Used for notifications and bot integration.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar Column */}
|
||||||
|
<div className="space-y-6">
|
||||||
{/* Shipping Settings */}
|
{/* Shipping Settings */}
|
||||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
<motion.div initial={{ opacity: 0, x: 10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.4 }}>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<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" />
|
<Globe className="h-4 w-4 text-blue-400" />
|
||||||
<h2 className="text-base font-medium text-zinc-100">
|
Shipping & Logistics
|
||||||
Shipping
|
</CardTitle>
|
||||||
</h2>
|
</CardHeader>
|
||||||
</div>
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="space-y-2">
|
||||||
<div>
|
<Label>Ships From</Label>
|
||||||
<label className="text-xs font-medium mb-1 block text-zinc-400">Ships From</label>
|
|
||||||
<Select
|
<Select
|
||||||
value={storefront.shipsFrom}
|
value={storefront.shipsFrom}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setStorefront((prev) => ({ ...prev, shipsFrom: value as any }))
|
setStorefront((prev) => ({ ...prev, shipsFrom: value as any }))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-[#1C1C1C] border-zinc-800 h-8 text-sm">
|
<SelectTrigger className="bg-background/50 border-border/50">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SHIPPING_REGIONS.map((region) => (
|
{SHIPPING_REGIONS.map((region) => (
|
||||||
<SelectItem key={region.value} value={region.value}>
|
<SelectItem key={region.value} value={region.value}>
|
||||||
{region.emoji} {region.label}
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">{region.emoji}</span>
|
||||||
|
{region.label}
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium mb-1 block text-zinc-400">Ships To</label>
|
<Label>Ships To</Label>
|
||||||
<Select
|
<Select
|
||||||
value={storefront.shipsTo}
|
value={storefront.shipsTo}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setStorefront((prev) => ({ ...prev, shipsTo: value as any }))
|
setStorefront((prev) => ({ ...prev, shipsTo: value as any }))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-[#1C1C1C] border-zinc-800 h-8 text-sm">
|
<SelectTrigger className="bg-background/50 border-border/50">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SHIPPING_REGIONS.map((region) => (
|
{SHIPPING_REGIONS.map((region) => (
|
||||||
<SelectItem key={region.value} value={region.value}>
|
<SelectItem key={region.value} value={region.value}>
|
||||||
{region.emoji} {region.label}
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">{region.emoji}</span>
|
||||||
|
{region.label}
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Messaging and Payments */}
|
{/* Payment Methods */}
|
||||||
<div className="space-y-3">
|
<motion.div initial={{ opacity: 0, x: 10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.5 }}>
|
||||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<CardHeader>
|
||||||
<MessageSquare className="h-4 w-4 text-emerald-400" />
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
<h2 className="text-base font-medium text-zinc-100">
|
<Wallet className="h-4 w-4 text-yellow-500" />
|
||||||
Welcome Message
|
Crypto Wallets
|
||||||
</h2>
|
</CardTitle>
|
||||||
</div>
|
</CardHeader>
|
||||||
<Textarea
|
<CardContent className="space-y-4">
|
||||||
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) => (
|
{WALLET_OPTIONS.map((wallet) => (
|
||||||
<div key={wallet.id} className="space-y-1.5">
|
<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">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<label className="text-xs font-medium flex items-center gap-2 text-zinc-400">
|
<label className="text-sm font-medium flex items-center gap-2">
|
||||||
<span>{wallet.emoji}</span>
|
<span className="text-lg">{wallet.emoji}</span>
|
||||||
{wallet.name}
|
{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>
|
</label>
|
||||||
<TooltipProvider>
|
<div className="flex items-center gap-2">
|
||||||
<Tooltip>
|
{wallet.comingSoon && (
|
||||||
<TooltipTrigger asChild>
|
<Badge variant="secondary" className="text-[10px] h-5">Soon</Badge>
|
||||||
<div>
|
)}
|
||||||
<Switch
|
<Switch
|
||||||
checked={storefront.enabledWallets[wallet.id]}
|
checked={storefront.enabledWallets[wallet.id]}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
@@ -374,18 +429,12 @@ export default function StorefrontPage() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
disabled={wallet.disabled}
|
disabled={wallet.disabled}
|
||||||
|
className="scale-90"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
|
||||||
{wallet.disabled && (
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Coming soon</p>
|
|
||||||
</TooltipContent>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
</div>
|
||||||
{storefront.enabledWallets[wallet.id] && !wallet.disabled && (
|
{storefront.enabledWallets[wallet.id] && !wallet.disabled && (
|
||||||
|
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }}>
|
||||||
<Input
|
<Input
|
||||||
value={storefront.wallets[wallet.id]}
|
value={storefront.wallets[wallet.id]}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -398,13 +447,15 @@ export default function StorefrontPage() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
placeholder={wallet.placeholder}
|
placeholder={wallet.placeholder}
|
||||||
className="font-mono text-sm h-8 bg-[#1C1C1C] border-zinc-800"
|
className="font-mono text-xs h-9 bg-background/50"
|
||||||
/>
|
/>
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ const getFileNameFromUrl = (url: string): string => {
|
|||||||
return 'attachment';
|
return 'attachment';
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL decode the filename (handle spaces and special characters)
|
|
||||||
try {
|
try {
|
||||||
fileName = decodeURIComponent(fileName);
|
fileName = decodeURIComponent(fileName);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -609,8 +608,8 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen w-full relative">
|
<div className="flex flex-col h-screen w-full relative">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"border-b bg-card z-10 flex items-center justify-between",
|
"border-b bg-background/80 backdrop-blur-md z-10 flex items-center justify-between sticky top-0",
|
||||||
isTouchDevice ? "h-20 px-3" : "h-16 px-4"
|
isTouchDevice ? "h-16 px-4" : "h-16 px-6"
|
||||||
)}>
|
)}>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -681,11 +680,10 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-w-[90%] rounded-lg chat-message",
|
"max-w-[85%] rounded-2xl p-4 shadow-sm",
|
||||||
isTouchDevice ? "p-4" : "p-3",
|
|
||||||
msg.sender === "vendor"
|
msg.sender === "vendor"
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground rounded-tr-none"
|
||||||
: "bg-muted"
|
: "bg-muted text-muted-foreground rounded-tl-none border border-border/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-2 mb-1">
|
<div className="flex items-center space-x-2 mb-1">
|
||||||
@@ -796,47 +794,36 @@ export default function ChatDetail({ chatId }: { chatId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"absolute bottom-0 left-0 right-0 border-t border-border bg-background",
|
"absolute bottom-0 left-0 right-0 px-4 pt-10 bg-gradient-to-t from-background via-background/95 to-transparent",
|
||||||
isTouchDevice ? "p-3" : "p-4",
|
"pb-[calc(1.5rem+env(safe-area-inset-bottom))]"
|
||||||
"pb-[env(safe-area-inset-bottom)]"
|
|
||||||
)}>
|
)}>
|
||||||
<form onSubmit={handleSendMessage} className="flex space-x-2">
|
<form onSubmit={handleSendMessage} className="flex space-x-2 max-w-4xl mx-auto items-end">
|
||||||
|
<div className="relative flex-1">
|
||||||
<Input
|
<Input
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
placeholder="Type your message..."
|
placeholder="Type your message..."
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 text-base transition-all duration-200 form-input",
|
"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]"
|
isTouchDevice ? "min-h-[52px] text-lg" : "min-h-[48px] text-base"
|
||||||
)}
|
)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
autoFocus
|
autoFocus
|
||||||
aria-label="Message input"
|
aria-label="Message input"
|
||||||
aria-describedby="message-help"
|
|
||||||
role="textbox"
|
role="textbox"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck="true"
|
|
||||||
maxLength={2000}
|
|
||||||
style={{
|
|
||||||
WebkitAppearance: 'none',
|
|
||||||
borderRadius: '0.5rem'
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={sending || !message.trim()}
|
disabled={sending || !message.trim()}
|
||||||
aria-label={sending ? "Sending message" : "Send message"}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-all duration-200 btn-chromebook",
|
"rounded-full shadow-md transition-all duration-200 bg-primary hover:bg-primary/90 text-primary-foreground",
|
||||||
isTouchDevice ? "min-h-[52px] min-w-[52px]" : "min-h-[48px] min-w-[48px]"
|
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>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<div id="message-help" className="sr-only">
|
<div id="message-help" className="sr-only">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
@@ -30,7 +32,8 @@ import {
|
|||||||
CheckCheck,
|
CheckCheck,
|
||||||
Search,
|
Search,
|
||||||
Volume2,
|
Volume2,
|
||||||
VolumeX
|
VolumeX,
|
||||||
|
MoreHorizontal
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -261,117 +264,167 @@ export default function ChatTable() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
className="h-9"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
) : (
|
) : (
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
)}
|
)}
|
||||||
<span className="ml-2">Refresh</span>
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button onClick={handleCreateChat} size="sm">
|
<Button onClick={handleCreateChat} size="sm" className="h-9">
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
New Chat
|
New Chat
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border">
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||||
|
<CardContent className="p-0">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader className="bg-muted/50">
|
||||||
<TableRow>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="w-[200px]">Customer</TableHead>
|
<TableHead className="w-[300px] pl-6">Customer</TableHead>
|
||||||
<TableHead>Last Activity</TableHead>
|
<TableHead>Last Activity</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right pr-6">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4} className="h-24 text-center">
|
<TableCell colSpan={4} className="h-32 text-center">
|
||||||
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : chats.length === 0 ? (
|
) : chats.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4} className="h-24 text-center">
|
<TableCell colSpan={4} className="h-32 text-center">
|
||||||
<div className="flex flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center">
|
||||||
<MessageCircle className="h-8 w-8 text-muted-foreground mb-2" />
|
<MessageCircle className="h-10 w-10 text-muted-foreground/50 mb-3" />
|
||||||
<p className="text-muted-foreground">No chats found</p>
|
<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>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
chats.map((chat) => (
|
chats.map((chat, index) => (
|
||||||
<TableRow
|
<motion.tr
|
||||||
key={chat._id}
|
key={chat._id}
|
||||||
className="cursor-pointer hover:bg-muted/50"
|
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)}
|
onClick={() => handleChatClick(chat._id)}
|
||||||
|
style={{ display: 'table-row' }} // Essential for table layout
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell className="pl-6 py-4">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-4">
|
||||||
<Avatar>
|
<div className="relative">
|
||||||
<AvatarFallback>
|
<Avatar className="h-10 w-10 border-2 border-background shadow-sm group-hover:scale-105 transition-transform duration-200">
|
||||||
<User className="h-4t w-4" />
|
<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>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
{unreadCounts.chatCounts[chat._id] > 0 && (
|
||||||
<div className="font-medium">
|
<span className="absolute -top-1 -right-1 h-3 w-3 bg-primary rounded-full ring-2 ring-background animate-pulse" />
|
||||||
{chat.telegramUsername ? `@${chat.telegramUsername}` : 'Customer'}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<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}
|
ID: {chat.buyerId}
|
||||||
</div>
|
</div>
|
||||||
{chat.orderId && (
|
{chat.orderId && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<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}
|
Order #{chat.orderId}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="py-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
{formatDistanceToNow(new Date(chat.lastUpdated), { addSuffix: true })}
|
{formatDistanceToNow(new Date(chat.lastUpdated), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(chat.lastUpdated).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="py-4">
|
||||||
{unreadCounts.chatCounts[chat._id] > 0 ? (
|
{unreadCounts.chatCounts[chat._id] > 0 ? (
|
||||||
<Badge variant="destructive" className="ml-1">
|
<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)]">
|
||||||
{unreadCounts.chatCounts[chat._id]} new
|
<span className="relative flex h-2 w-2">
|
||||||
</Badge>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline">Read</Badge>
|
<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>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right pr-6 py-4">
|
||||||
<div className="flex justify-end space-x-2">
|
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="secondary"
|
||||||
size="icon"
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 rounded-full"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleChatClick(chat._id);
|
handleChatClick(chat._id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<ArrowRightCircle className="h-4 w-4" />
|
||||||
|
<span className="sr-only">View</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</motion.tr>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Pagination controls */}
|
{/* Pagination controls */}
|
||||||
{!loading && chats.length > 0 && (
|
{
|
||||||
|
!loading && chats.length > 0 && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Showing {chats.length} of {totalChats} chats
|
Showing {chats.length} of {totalChats} chats
|
||||||
|
|||||||
@@ -2,16 +2,20 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import OrderStats from "./order-stats"
|
import OrderStats from "./order-stats"
|
||||||
|
import QuickActions from "./quick-actions"
|
||||||
|
import RecentActivity from "./recent-activity"
|
||||||
import { getGreeting } from "@/lib/utils/general"
|
import { getGreeting } from "@/lib/utils/general"
|
||||||
import { statsConfig } from "@/config/dashboard"
|
import { statsConfig } from "@/config/dashboard"
|
||||||
import { getRandomQuote } from "@/config/quotes"
|
import { getRandomQuote } from "@/config/quotes"
|
||||||
import type { OrderStatsData } from "@/lib/types"
|
import type { OrderStatsData } from "@/lib/types"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { useToast } from "@/components/ui/use-toast"
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { clientFetch } from "@/lib/api"
|
import { clientFetch } from "@/lib/api"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
interface ContentProps {
|
interface ContentProps {
|
||||||
username: string
|
username: string
|
||||||
@@ -34,70 +38,84 @@ export default function Content({ username, orderStats }: ContentProps) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// Initialize with a random quote from the quotes config
|
|
||||||
const [randomQuote, setRandomQuote] = useState(getRandomQuote());
|
const [randomQuote, setRandomQuote] = useState(getRandomQuote());
|
||||||
|
|
||||||
// Fetch top-selling products data
|
|
||||||
const fetchTopProducts = async () => {
|
const fetchTopProducts = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const data = await clientFetch('/orders/top-products');
|
const data = await clientFetch('/orders/top-products');
|
||||||
setTopProducts(data);
|
setTopProducts(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error fetching top products:", err);
|
console.error("Error fetching top products:", err);
|
||||||
setError(err instanceof Error ? err.message : "Failed to fetch top products");
|
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 {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize greeting and fetch data on component mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGreeting(getGreeting());
|
setGreeting(getGreeting());
|
||||||
fetchTopProducts();
|
fetchTopProducts();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Retry fetching top products data
|
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
fetchTopProducts();
|
fetchTopProducts();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold text-foreground">
|
<h1 className="text-4xl font-bold tracking-tight text-foreground">
|
||||||
{greeting}, {username}!
|
{greeting}, <span className="text-primary">{username}</span>!
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1 italic text-sm">
|
<p className="text-muted-foreground mt-2 max-w-2xl text-lg">
|
||||||
"{randomQuote.text}" — <span className="font-medium">{randomQuote.author}</span>
|
"{randomQuote.text}" — <span className="font-medium">{randomQuote.author}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* 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">
|
<section className="space-y-4">
|
||||||
{statsConfig.map((stat) => (
|
<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
|
<OrderStats
|
||||||
key={stat.title}
|
key={stat.title}
|
||||||
title={stat.title}
|
title={stat.title}
|
||||||
value={orderStats[stat.key as keyof OrderStatsData].toLocaleString()}
|
value={orderStats[stat.key as keyof OrderStatsData]?.toLocaleString() || "0"}
|
||||||
icon={stat.icon}
|
icon={stat.icon}
|
||||||
|
index={index}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
||||||
|
{/* Recent Activity Section */}
|
||||||
|
<div className="xl:col-span-1">
|
||||||
|
<RecentActivity />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Best Selling Products Section */}
|
{/* Best Selling Products Section */}
|
||||||
<div className="mt-8">
|
<div className="xl:col-span-2">
|
||||||
<Card>
|
<Card className="h-full border-border/40 bg-background/50 backdrop-blur-sm">
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Your Best Selling Products</CardTitle>
|
<CardTitle>Top Performing Listings</CardTitle>
|
||||||
<CardDescription>Products with the highest sales from your store</CardDescription>
|
<CardDescription>Your products with the highest sales volume</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<Button
|
<Button
|
||||||
@@ -114,43 +132,42 @@ export default function Content({ username, orderStats }: ContentProps) {
|
|||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
// Loading skeleton
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{[...Array(5)].map((_, i) => (
|
{[...Array(5)].map((_, i) => (
|
||||||
<div key={i} className="flex items-center gap-4">
|
<div key={i} className="flex items-center gap-4">
|
||||||
<Skeleton className="h-12 w-12 rounded-md" />
|
<Skeleton className="h-14 w-14 rounded-xl" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 flex-1">
|
||||||
<Skeleton className="h-4 w-40" />
|
<Skeleton className="h-4 w-1/2" />
|
||||||
<Skeleton className="h-4 w-20" />
|
<Skeleton className="h-3 w-1/4" />
|
||||||
</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>
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
// Error state
|
<div className="py-12 text-center">
|
||||||
<div className="py-8 text-center">
|
<div className="text-muted-foreground mb-4">Failed to load product insights</div>
|
||||||
<div className="text-muted-foreground mb-4">Failed to load products</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : topProducts.length === 0 ? (
|
) : topProducts.length === 0 ? (
|
||||||
// Empty state
|
<div className="py-12 text-center">
|
||||||
<div className="py-8 text-center">
|
<ShoppingCart className="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||||
<ShoppingCart className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
<h3 className="text-lg font-medium mb-1">Begin your sales journey</h3>
|
||||||
<h3 className="text-lg font-medium mb-2">No products sold yet</h3>
|
<p className="text-muted-foreground text-sm max-w-xs mx-auto">
|
||||||
<p className="text-muted-foreground">
|
Your top performing listings will materialize here as you receive orders.
|
||||||
Your best-selling products will appear here after you make some sales.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// Data view
|
<div className="space-y-1">
|
||||||
<div className="space-y-4">
|
{topProducts.map((product, index) => (
|
||||||
{topProducts.map((product) => (
|
<motion.div
|
||||||
<div key={product.id} className="flex items-center gap-4 py-2 border-b last:border-0">
|
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"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="h-12 w-12 bg-cover bg-center rounded-md border flex-shrink-0 flex items-center justify-center overflow-hidden"
|
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={{
|
style={{
|
||||||
backgroundImage: product.image
|
backgroundImage: product.image
|
||||||
? `url(/api/products/${product.id}/image)`
|
? `url(/api/products/${product.id}/image)`
|
||||||
@@ -158,16 +175,22 @@ export default function Content({ username, orderStats }: ContentProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!product.image && (
|
{!product.image && (
|
||||||
<ShoppingCart className="h-6 w-6 text-muted-foreground" />
|
<ShoppingCart className="h-6 w-6 text-muted-foreground/50" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-grow min-w-0">
|
<div className="flex-grow min-w-0">
|
||||||
<h4 className="font-medium truncate">{product.name}</h4>
|
<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>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="font-medium">{product.count} sold</div>
|
<div className="text-xl font-bold">{product.count}</div>
|
||||||
</div>
|
<div className="text-xs text-muted-foreground font-medium uppercase tracking-tighter">Units Sold</div>
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -175,6 +198,7 @@ export default function Content({ username, orderStats }: ContentProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,37 @@
|
|||||||
import type { LucideIcon } from "lucide-react"
|
import type { LucideIcon } from "lucide-react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
|
||||||
interface OrderStatsProps {
|
interface OrderStatsProps {
|
||||||
title: string
|
title: string
|
||||||
value: string
|
value: string
|
||||||
icon: LucideIcon
|
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 (
|
return (
|
||||||
<Card>
|
<motion.div
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="relative z-10">
|
||||||
<div className="text-2xl font-bold">{value}</div>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</motion.div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
75
components/dashboard/quick-actions.tsx
Normal file
75
components/dashboard/quick-actions.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
components/dashboard/recent-activity.tsx
Normal file
119
components/dashboard/recent-activity.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
components/orders/order-timeline.tsx
Normal file
86
components/orders/order-timeline.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -278,39 +280,40 @@ export default function OrderTable() {
|
|||||||
const statusConfig: Record<OrderStatus, StatusConfig> = {
|
const statusConfig: Record<OrderStatus, StatusConfig> = {
|
||||||
acknowledged: {
|
acknowledged: {
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
color: "text-white",
|
color: "text-purple-100",
|
||||||
bgColor: "bg-purple-600"
|
bgColor: "bg-purple-600/90 shadow-[0_0_10px_rgba(147,51,234,0.3)]"
|
||||||
},
|
},
|
||||||
paid: {
|
paid: {
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
color: "text-white",
|
color: "text-emerald-100",
|
||||||
bgColor: "bg-emerald-600"
|
bgColor: "bg-emerald-600/90 shadow-[0_0_10px_rgba(16,185,129,0.3)]",
|
||||||
|
animate: "animate-pulse"
|
||||||
},
|
},
|
||||||
unpaid: {
|
unpaid: {
|
||||||
icon: XCircle,
|
icon: XCircle,
|
||||||
color: "text-white",
|
color: "text-amber-100",
|
||||||
bgColor: "bg-red-500"
|
bgColor: "bg-amber-500/90"
|
||||||
},
|
},
|
||||||
confirming: {
|
confirming: {
|
||||||
icon: Loader2,
|
icon: Loader2,
|
||||||
color: "text-white",
|
color: "text-blue-100",
|
||||||
bgColor: "bg-yellow-500",
|
bgColor: "bg-blue-500/90",
|
||||||
animate: "animate-spin"
|
animate: "animate-spin"
|
||||||
},
|
},
|
||||||
shipped: {
|
shipped: {
|
||||||
icon: Truck,
|
icon: Truck,
|
||||||
color: "text-white",
|
color: "text-indigo-100",
|
||||||
bgColor: "bg-blue-600"
|
bgColor: "bg-indigo-600/90 shadow-[0_0_10px_rgba(79,70,229,0.3)]"
|
||||||
},
|
},
|
||||||
completed: {
|
completed: {
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
color: "text-white",
|
color: "text-green-100",
|
||||||
bgColor: "bg-green-600"
|
bgColor: "bg-green-600/90"
|
||||||
},
|
},
|
||||||
cancelled: {
|
cancelled: {
|
||||||
icon: XCircle,
|
icon: XCircle,
|
||||||
color: "text-white",
|
color: "text-gray-100",
|
||||||
bgColor: "bg-gray-500"
|
bgColor: "bg-gray-600/90"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -391,9 +394,9 @@ export default function OrderTable() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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 */}
|
{/* 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 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">
|
<div className="flex flex-col sm:flex-row gap-2 sm:items-center w-full lg:w-auto">
|
||||||
<StatusFilter
|
<StatusFilter
|
||||||
@@ -413,6 +416,7 @@ export default function OrderTable() {
|
|||||||
disabled={exporting}
|
disabled={exporting}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="bg-background/50 border-border/50"
|
||||||
>
|
>
|
||||||
{exporting ? (
|
{exporting ? (
|
||||||
<>
|
<>
|
||||||
@@ -432,8 +436,8 @@ export default function OrderTable() {
|
|||||||
<div className="flex items-center gap-2 self-end lg:self-auto">
|
<div className="flex items-center gap-2 self-end lg:self-auto">
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button disabled={selectedOrders.size === 0 || isShipping}>
|
<Button disabled={selectedOrders.size === 0 || isShipping} className="shadow-md">
|
||||||
<Truck className="mr-2 h-5 w-5" />
|
<Truck className="mr-2 h-4 w-4" />
|
||||||
{isShipping ? (
|
{isShipping ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
@@ -462,49 +466,57 @@ export default function OrderTable() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="relative">
|
<CardContent className="p-0 relative min-h-[400px]">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="absolute inset-0 bg-background/50 flex items-center justify-center">
|
<div className="absolute inset-0 bg-background/50 backdrop-blur-[1px] flex items-center justify-center z-50">
|
||||||
<Loader2 className="h-6 w-6 animate-spin" />
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="max-h-[calc(100vh-300px)] overflow-auto">
|
<div className="max-h-[calc(100vh-350px)] 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">
|
<Table>
|
||||||
<TableHeader className="bg-black/60 sticky top-0 z-10">
|
<TableHeader className="bg-muted/50 sticky top-0 z-20">
|
||||||
<TableRow>
|
<TableRow className="hover:bg-transparent border-border/50">
|
||||||
<TableHead className="w-12">
|
<TableHead className="w-12">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0}
|
checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0}
|
||||||
onCheckedChange={toggleAll}
|
onCheckedChange={toggleAll}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="cursor-pointer" onClick={() => handleSort("orderId")}>
|
<TableHead className="cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("orderId")}>
|
||||||
Order ID <ArrowUpDown className="ml-2 inline h-4 w-4" />
|
Order ID <ArrowUpDown className="ml-2 inline h-3 w-3" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="cursor-pointer" onClick={() => handleSort("totalPrice")}>
|
<TableHead className="cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("totalPrice")}>
|
||||||
Total <ArrowUpDown className="ml-2 inline h-4 w-4" />
|
Total <ArrowUpDown className="ml-2 inline h-3 w-3" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden lg:table-cell">Promotion</TableHead>
|
<TableHead className="hidden lg:table-cell">Promotion</TableHead>
|
||||||
<TableHead className="cursor-pointer" onClick={() => handleSort("status")}>
|
<TableHead className="cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("status")}>
|
||||||
Status <ArrowUpDown className="ml-2 inline h-4 w-4" />
|
Status <ArrowUpDown className="ml-2 inline h-3 w-3" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden md:table-cell cursor-pointer" onClick={() => handleSort("orderDate")}>
|
<TableHead className="hidden md:table-cell cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("orderDate")}>
|
||||||
Order Date <ArrowUpDown className="ml-2 inline h-4 w-4" />
|
Date <ArrowUpDown className="ml-2 inline h-3 w-3" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden xl:table-cell cursor-pointer" onClick={() => handleSort("paidAt")}>
|
<TableHead className="hidden xl:table-cell cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("paidAt")}>
|
||||||
Paid At <ArrowUpDown className="ml-2 inline h-4 w-4" />
|
Paid At <ArrowUpDown className="ml-2 inline h-3 w-3" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden lg:table-cell">Buyer</TableHead>
|
<TableHead className="hidden lg:table-cell">Buyer</TableHead>
|
||||||
<TableHead className="w-24 text-center">Actions</TableHead>
|
<TableHead className="w-24 text-center">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{paginatedOrders.map((order) => {
|
<AnimatePresence mode="popLayout">
|
||||||
|
{paginatedOrders.map((order, index) => {
|
||||||
const StatusIcon = statusConfig[order.status as keyof typeof statusConfig]?.icon || XCircle;
|
const StatusIcon = statusConfig[order.status as keyof typeof statusConfig]?.icon || XCircle;
|
||||||
const underpaidInfo = getUnderpaidInfo(order);
|
const underpaidInfo = getUnderpaidInfo(order);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={order._id}>
|
<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>
|
<TableCell>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedOrders.has(order._id)}
|
checked={selectedOrders.has(order._id)}
|
||||||
@@ -512,13 +524,14 @@ export default function OrderTable() {
|
|||||||
disabled={order.status !== "paid" && order.status !== "acknowledged"}
|
disabled={order.status !== "paid" && order.status !== "acknowledged"}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>#{order.orderId}</TableCell>
|
<TableCell className="font-mono text-sm font-medium">#{order.orderId}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span>£{order.totalPrice.toFixed(2)}</span>
|
<span className="font-medium">£{order.totalPrice.toFixed(2)}</span>
|
||||||
{underpaidInfo && (
|
{underpaidInfo && (
|
||||||
<span className="text-xs text-red-400">
|
<span className="text-[10px] text-destructive flex items-center gap-1">
|
||||||
Missing: £{underpaidInfo.missingGbp.toFixed(2)} ({underpaidInfo.missing.toFixed(8)} LTC)
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
-£{underpaidInfo.missingGbp.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -527,19 +540,14 @@ export default function OrderTable() {
|
|||||||
{order.promotionCode ? (
|
{order.promotionCode ? (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Tag className="h-3 w-3 text-green-500" />
|
<Tag className="h-3 w-3 text-emerald-500" />
|
||||||
<span className="text-xs font-mono bg-green-100 text-green-800 px-2 py-0.5 rounded">
|
<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}
|
{order.promotionCode}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-xs text-green-600">
|
<div className="flex items-center gap-1 text-[10px] text-emerald-600/80">
|
||||||
<Percent className="h-3 w-3" />
|
<Percent className="h-2.5 w-2.5" />
|
||||||
<span>-£{(order.discountAmount || 0).toFixed(2)}</span>
|
<span>-£{(order.discountAmount || 0).toFixed(2)}</span>
|
||||||
{order.subtotalBeforeDiscount && order.subtotalBeforeDiscount > 0 && (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
(was £{order.subtotalBeforeDiscount.toFixed(2)})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -548,48 +556,47 @@ export default function OrderTable() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className={`inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm ${
|
<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 || ""}`}>
|
||||||
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, {
|
{React.createElement(statusConfig[order.status as OrderStatus]?.icon || XCircle, {
|
||||||
className: `h-4 w-4 ${statusConfig[order.status as OrderStatus]?.animate || ""}`
|
className: `h-3.5 w-3.5 ${statusConfig[order.status as OrderStatus]?.animate || ""}`
|
||||||
})}
|
})}
|
||||||
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
|
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
|
||||||
</div>
|
</div>
|
||||||
{isOrderUnderpaid(order) && (
|
{isOrderUnderpaid(order) && (
|
||||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-red-600 text-white">
|
<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">
|
||||||
<AlertTriangle className="h-3 w-3" />
|
|
||||||
{underpaidInfo?.percentage}%
|
{underpaidInfo?.percentage}%
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden md:table-cell">
|
<TableCell className="hidden md:table-cell text-sm text-muted-foreground">
|
||||||
{new Date(order.orderDate).toLocaleDateString("en-GB", {
|
{new Date(order.orderDate).toLocaleDateString("en-GB", {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false
|
|
||||||
})}
|
})}
|
||||||
|
<span className="ml-1 opacity-50 text-[10px]">
|
||||||
|
{new Date(order.orderDate).toLocaleTimeString("en-GB", { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden xl:table-cell">
|
<TableCell className="hidden xl:table-cell text-sm text-muted-foreground">
|
||||||
{order.paidAt ? new Date(order.paidAt).toLocaleDateString("en-GB", {
|
{order.paidAt ? new Date(order.paidAt).toLocaleDateString("en-GB", {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit'
|
||||||
hour12: false
|
|
||||||
}) : "-"}
|
}) : "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden lg:table-cell">
|
<TableCell className="hidden lg:table-cell">
|
||||||
{order.telegramUsername ? `@${order.telegramUsername}` : "-"}
|
{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>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-foreground" asChild>
|
||||||
<Link href={`/dashboard/orders/${order._id}`}>
|
<Link href={`/dashboard/orders/${order._id}`}>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -598,27 +605,29 @@ export default function OrderTable() {
|
|||||||
{(order.telegramBuyerId || order.telegramUsername) && (
|
{(order.telegramBuyerId || order.telegramUsername) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-primary"
|
||||||
asChild
|
asChild
|
||||||
title={`Chat with customer${order.telegramUsername ? ` @${order.telegramUsername}` : ''}`}
|
title={`Chat with customer${order.telegramUsername ? ` @${order.telegramUsername}` : ''}`}
|
||||||
>
|
>
|
||||||
<Link href={`/dashboard/chats/new?buyerId=${order.telegramBuyerId || order.telegramUsername}`}>
|
<Link href={`/dashboard/chats/new?buyerId=${order.telegramBuyerId || order.telegramUsername}`}>
|
||||||
<MessageCircle className="h-4 w-4 text-primary" />
|
<MessageCircle className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</motion.tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* 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">
|
<div className="text-sm text-muted-foreground">
|
||||||
Page {currentPage} of {totalPages} ({totalOrders} total)
|
Page {currentPage} of {totalPages} ({totalOrders} total)
|
||||||
</div>
|
</div>
|
||||||
@@ -628,8 +637,9 @@ export default function OrderTable() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlePageChange(currentPage - 1)}
|
onClick={() => handlePageChange(currentPage - 1)}
|
||||||
disabled={currentPage === 1 || loading}
|
disabled={currentPage === 1 || loading}
|
||||||
|
className="h-8"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-3 w-3 mr-1" />
|
||||||
Previous
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -637,14 +647,15 @@ export default function OrderTable() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlePageChange(currentPage + 1)}
|
onClick={() => handlePageChange(currentPage + 1)}
|
||||||
disabled={currentPage >= totalPages || loading}
|
disabled={currentPage >= totalPages || loading}
|
||||||
|
className="h-8"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-3 w-3 ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"commitHash": "a05787a",
|
"commitHash": "7b95589",
|
||||||
"buildTime": "2026-01-12T05:47:06.100Z"
|
"buildTime": "2026-01-12T06:32:31.897Z"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user