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

Refactored dashboard pages for improved layout and visual consistency using Card components, motion animations, and updated color schemes. Added an OrderTimeline component to the order details page to visualize order lifecycle. Improved customer management page with better sorting, searching, and a detailed customer dialog. Updated storefront settings page with a modernized layout and clearer sectioning.
This commit is contained in:
g
2026-01-12 06:53:28 +00:00
parent 7b95589867
commit 211cdc71f9
12 changed files with 1793 additions and 1331 deletions

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from "react";
import React from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Table,
TableBody,
@@ -11,6 +12,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import {
Select,
SelectContent,
@@ -167,7 +169,7 @@ export default function OrderTable() {
});
const data = await clientFetch(`/orders?${queryParams}`);
console.log("Fetched orders with fresh data:", data.orders?.length || 0);
setOrders(data.orders || []);
setTotalPages(data.totalPages || 1);
@@ -244,11 +246,11 @@ export default function OrderTable() {
method: "POST",
body: JSON.stringify({ orderIds: Array.from(selectedOrders) })
});
// Only update orders that were successfully marked as shipped
if (response.success && response.success.orders) {
const successfulOrderIds = new Set(response.success.orders.map((o: any) => o.id));
setOrders(prev =>
prev.map(order =>
successfulOrderIds.has(order._id)
@@ -256,16 +258,16 @@ export default function OrderTable() {
: order
)
);
if (response.failed && response.failed.count > 0) {
toast.warning(`${response.failed.count} orders could not be marked as shipped`);
}
if (response.success.count > 0) {
toast.success(`${response.success.count} orders marked as shipped`);
}
}
setSelectedOrders(new Set());
} catch (error) {
toast.error("Failed to update orders");
@@ -276,68 +278,69 @@ export default function OrderTable() {
};
const statusConfig: Record<OrderStatus, StatusConfig> = {
acknowledged: {
icon: CheckCircle2,
color: "text-white",
bgColor: "bg-purple-600"
acknowledged: {
icon: CheckCircle2,
color: "text-purple-100",
bgColor: "bg-purple-600/90 shadow-[0_0_10px_rgba(147,51,234,0.3)]"
},
paid: {
icon: CheckCircle2,
color: "text-white",
bgColor: "bg-emerald-600"
paid: {
icon: CheckCircle2,
color: "text-emerald-100",
bgColor: "bg-emerald-600/90 shadow-[0_0_10px_rgba(16,185,129,0.3)]",
animate: "animate-pulse"
},
unpaid: {
icon: XCircle,
color: "text-white",
bgColor: "bg-red-500"
unpaid: {
icon: XCircle,
color: "text-amber-100",
bgColor: "bg-amber-500/90"
},
confirming: {
icon: Loader2,
color: "text-white",
bgColor: "bg-yellow-500",
animate: "animate-spin"
confirming: {
icon: Loader2,
color: "text-blue-100",
bgColor: "bg-blue-500/90",
animate: "animate-spin"
},
shipped: {
icon: Truck,
color: "text-white",
bgColor: "bg-blue-600"
shipped: {
icon: Truck,
color: "text-indigo-100",
bgColor: "bg-indigo-600/90 shadow-[0_0_10px_rgba(79,70,229,0.3)]"
},
completed: {
icon: CheckCircle2,
color: "text-white",
bgColor: "bg-green-600"
completed: {
icon: CheckCircle2,
color: "text-green-100",
bgColor: "bg-green-600/90"
},
cancelled: {
icon: XCircle,
color: "text-white",
bgColor: "bg-gray-500"
cancelled: {
icon: XCircle,
color: "text-gray-100",
bgColor: "bg-gray-600/90"
}
};
// Helper function to determine if order is underpaid
const isOrderUnderpaid = (order: Order) => {
// More robust check - only show underpaid if status allows it and underpayment exists
return order.underpaid === true &&
order.underpaymentAmount &&
order.underpaymentAmount > 0 &&
order.status !== "paid" &&
order.status !== "completed" &&
order.status !== "shipped" &&
order.status !== "cancelled";
return order.underpaid === true &&
order.underpaymentAmount &&
order.underpaymentAmount > 0 &&
order.status !== "paid" &&
order.status !== "completed" &&
order.status !== "shipped" &&
order.status !== "cancelled";
};
// Helper function to get underpaid display info
const getUnderpaidInfo = (order: Order) => {
if (!isOrderUnderpaid(order)) return null;
const received = order.lastBalanceReceived || 0;
const required = order.cryptoTotal || 0;
const missing = order.underpaymentAmount || 0;
// Calculate LTC to GBP exchange rate from order data
const ltcToGbpRate = required > 0 ? order.totalPrice / required : 0;
const missingGbp = missing * ltcToGbpRate;
return {
received,
required,
@@ -377,7 +380,7 @@ export default function OrderTable() {
useEffect(() => {
// Check if we have any underpaid orders
const hasUnderpaidOrders = orders.some(order => isOrderUnderpaid(order));
if (hasUnderpaidOrders) {
console.log("Found underpaid orders, setting up refresh interval");
const interval = setInterval(() => {
@@ -391,16 +394,16 @@ export default function OrderTable() {
return (
<div className="space-y-4">
<div className="border border-zinc-800 rounded-md bg-black/40 overflow-hidden">
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
{/* Filters header */}
<div className="p-4 border-b border-zinc-800 bg-black/60">
<div className="p-4 border-b border-border/50 bg-muted/30">
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4">
<div className="flex flex-col sm:flex-row gap-2 sm:items-center w-full lg:w-auto">
<StatusFilter
currentStatus={statusFilter}
onChange={setStatusFilter}
/>
<PageSizeSelector
currentSize={itemsPerPage}
onChange={(value) => handleItemsPerPageChange({ target: { value } } as React.ChangeEvent<HTMLSelectElement>)}
@@ -413,6 +416,7 @@ export default function OrderTable() {
disabled={exporting}
variant="outline"
size="sm"
className="bg-background/50 border-border/50"
>
{exporting ? (
<>
@@ -428,12 +432,12 @@ export default function OrderTable() {
</Button>
)}
</div>
<div className="flex items-center gap-2 self-end lg:self-auto">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button disabled={selectedOrders.size === 0 || isShipping}>
<Truck className="mr-2 h-5 w-5" />
<Button disabled={selectedOrders.size === 0 || isShipping} className="shadow-md">
<Truck className="mr-2 h-4 w-4" />
{isShipping ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
@@ -445,7 +449,7 @@ export default function OrderTable() {
<AlertDialogHeader>
<AlertDialogTitle>Mark Orders as Shipped</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to mark {selectedOrders.size} order{selectedOrders.size !== 1 ? 's' : ''} as shipped?
Are you sure you want to mark {selectedOrders.size} order{selectedOrders.size !== 1 ? 's' : ''} as shipped?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
@@ -462,163 +466,168 @@ export default function OrderTable() {
</div>
{/* Table */}
<div className="relative">
<CardContent className="p-0 relative min-h-[400px]">
{loading && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin" />
<div className="absolute inset-0 bg-background/50 backdrop-blur-[1px] flex items-center justify-center z-50">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)}
<div className="max-h-[calc(100vh-300px)] overflow-auto">
<Table className="[&_tr]:border-b [&_tr]:border-zinc-800 [&_tr:last-child]:border-b-0 [&_td]:border-r [&_td]:border-zinc-800 [&_td:last-child]:border-r-0 [&_th]:border-r [&_th]:border-zinc-800 [&_th:last-child]:border-r-0 [&_tr:hover]:bg-zinc-900/70">
<TableHeader className="bg-black/60 sticky top-0 z-10">
<TableRow>
<div className="max-h-[calc(100vh-350px)] overflow-auto">
<Table>
<TableHeader className="bg-muted/50 sticky top-0 z-20">
<TableRow className="hover:bg-transparent border-border/50">
<TableHead className="w-12">
<Checkbox
checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0}
onCheckedChange={toggleAll}
/>
</TableHead>
<TableHead className="cursor-pointer" onClick={() => handleSort("orderId")}>
Order ID <ArrowUpDown className="ml-2 inline h-4 w-4" />
<TableHead className="cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("orderId")}>
Order ID <ArrowUpDown className="ml-2 inline h-3 w-3" />
</TableHead>
<TableHead className="cursor-pointer" onClick={() => handleSort("totalPrice")}>
Total <ArrowUpDown className="ml-2 inline h-4 w-4" />
<TableHead className="cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("totalPrice")}>
Total <ArrowUpDown className="ml-2 inline h-3 w-3" />
</TableHead>
<TableHead className="hidden lg:table-cell">Promotion</TableHead>
<TableHead className="cursor-pointer" onClick={() => handleSort("status")}>
Status <ArrowUpDown className="ml-2 inline h-4 w-4" />
<TableHead className="cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("status")}>
Status <ArrowUpDown className="ml-2 inline h-3 w-3" />
</TableHead>
<TableHead className="hidden md:table-cell cursor-pointer" onClick={() => handleSort("orderDate")}>
Order Date <ArrowUpDown className="ml-2 inline h-4 w-4" />
<TableHead className="hidden md:table-cell cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("orderDate")}>
Date <ArrowUpDown className="ml-2 inline h-3 w-3" />
</TableHead>
<TableHead className="hidden xl:table-cell cursor-pointer" onClick={() => handleSort("paidAt")}>
Paid At <ArrowUpDown className="ml-2 inline h-4 w-4" />
<TableHead className="hidden xl:table-cell cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("paidAt")}>
Paid At <ArrowUpDown className="ml-2 inline h-3 w-3" />
</TableHead>
<TableHead className="hidden lg:table-cell">Buyer</TableHead>
<TableHead className="w-24 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedOrders.map((order) => {
const StatusIcon = statusConfig[order.status as keyof typeof statusConfig]?.icon || XCircle;
const underpaidInfo = getUnderpaidInfo(order);
<AnimatePresence mode="popLayout">
{paginatedOrders.map((order, index) => {
const StatusIcon = statusConfig[order.status as keyof typeof statusConfig]?.icon || XCircle;
const underpaidInfo = getUnderpaidInfo(order);
return (
<TableRow key={order._id}>
<TableCell>
<Checkbox
checked={selectedOrders.has(order._id)}
onCheckedChange={() => toggleSelection(order._id)}
disabled={order.status !== "paid" && order.status !== "acknowledged"}
/>
</TableCell>
<TableCell>#{order.orderId}</TableCell>
<TableCell>
<div className="flex flex-col">
<span>£{order.totalPrice.toFixed(2)}</span>
{underpaidInfo && (
<span className="text-xs text-red-400">
Missing: £{underpaidInfo.missingGbp.toFixed(2)} ({underpaidInfo.missing.toFixed(8)} LTC)
</span>
)}
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
{order.promotionCode ? (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<Tag className="h-3 w-3 text-green-500" />
<span className="text-xs font-mono bg-green-100 text-green-800 px-2 py-0.5 rounded">
{order.promotionCode}
return (
<motion.tr
key={order._id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, delay: index * 0.03 }}
className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
>
<TableCell>
<Checkbox
checked={selectedOrders.has(order._id)}
onCheckedChange={() => toggleSelection(order._id)}
disabled={order.status !== "paid" && order.status !== "acknowledged"}
/>
</TableCell>
<TableCell className="font-mono text-sm font-medium">#{order.orderId}</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">£{order.totalPrice.toFixed(2)}</span>
{underpaidInfo && (
<span className="text-[10px] text-destructive flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
-£{underpaidInfo.missingGbp.toFixed(2)}
</span>
</div>
<div className="flex items-center gap-1 text-xs text-green-600">
<Percent className="h-3 w-3" />
<span>-£{(order.discountAmount || 0).toFixed(2)}</span>
{order.subtotalBeforeDiscount && order.subtotalBeforeDiscount > 0 && (
<span className="text-muted-foreground">
(was £{order.subtotalBeforeDiscount.toFixed(2)})
)}
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
{order.promotionCode ? (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<Tag className="h-3 w-3 text-emerald-500" />
<span className="text-[10px] font-mono bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-1.5 py-0.5 rounded border border-emerald-500/20">
{order.promotionCode}
</span>
)}
</div>
</div>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className={`inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm ${
statusConfig[order.status as OrderStatus]?.bgColor || "bg-gray-500"
} ${statusConfig[order.status as OrderStatus]?.color || "text-white"}`}>
{React.createElement(statusConfig[order.status as OrderStatus]?.icon || XCircle, {
className: `h-4 w-4 ${statusConfig[order.status as OrderStatus]?.animate || ""}`
})}
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
</div>
{isOrderUnderpaid(order) && (
<div className="flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-red-600 text-white">
<AlertTriangle className="h-3 w-3" />
{underpaidInfo?.percentage}%
</div>
<div className="flex items-center gap-1 text-[10px] text-emerald-600/80">
<Percent className="h-2.5 w-2.5" />
<span>-£{(order.discountAmount || 0).toFixed(2)}</span>
</div>
</div>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</div>
</TableCell>
<TableCell className="hidden md:table-cell">
{new Date(order.orderDate).toLocaleDateString("en-GB", {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
})}
</TableCell>
<TableCell className="hidden xl:table-cell">
{order.paidAt ? new Date(order.paidAt).toLocaleDateString("en-GB", {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
}) : "-"}
</TableCell>
<TableCell className="hidden lg:table-cell">
{order.telegramUsername ? `@${order.telegramUsername}` : "-"}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="sm" asChild>
<Link href={`/dashboard/orders/${order._id}`}>
<Eye className="h-4 w-4" />
</Link>
</Button>
{(order.telegramBuyerId || order.telegramUsername) && (
<Button
variant="ghost"
size="sm"
asChild
title={`Chat with customer${order.telegramUsername ? ` @${order.telegramUsername}` : ''}`}
>
<Link href={`/dashboard/chats/new?buyerId=${order.telegramBuyerId || order.telegramUsername}`}>
<MessageCircle className="h-4 w-4 text-primary" />
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border shadow-sm ${statusConfig[order.status as OrderStatus]?.bgColor || "bg-muted text-muted-foreground border-border"} ${statusConfig[order.status as OrderStatus]?.color || ""}`}>
{React.createElement(statusConfig[order.status as OrderStatus]?.icon || XCircle, {
className: `h-3.5 w-3.5 ${statusConfig[order.status as OrderStatus]?.animate || ""}`
})}
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
</div>
{isOrderUnderpaid(order) && (
<div className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-destructive/10 text-destructive border border-destructive/20 font-medium">
{underpaidInfo?.percentage}%
</div>
)}
</div>
</TableCell>
<TableCell className="hidden md:table-cell text-sm text-muted-foreground">
{new Date(order.orderDate).toLocaleDateString("en-GB", {
day: '2-digit',
month: 'short',
year: 'numeric',
})}
<span className="ml-1 opacity-50 text-[10px]">
{new Date(order.orderDate).toLocaleTimeString("en-GB", { hour: '2-digit', minute: '2-digit' })}
</span>
</TableCell>
<TableCell className="hidden xl:table-cell text-sm text-muted-foreground">
{order.paidAt ? new Date(order.paidAt).toLocaleDateString("en-GB", {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit'
}) : "-"}
</TableCell>
<TableCell className="hidden lg:table-cell">
{order.telegramUsername ? (
<span className="text-sm font-medium text-primary">@{order.telegramUsername}</span>
) : (
<span className="text-xs text-muted-foreground italic">Guest</span>
)}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-foreground" asChild>
<Link href={`/dashboard/orders/${order._id}`}>
<Eye className="h-4 w-4" />
</Link>
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})}
{(order.telegramBuyerId || order.telegramUsername) && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-primary"
asChild
title={`Chat with customer${order.telegramUsername ? ` @${order.telegramUsername}` : ''}`}
>
<Link href={`/dashboard/chats/new?buyerId=${order.telegramBuyerId || order.telegramUsername}`}>
<MessageCircle className="h-4 w-4" />
</Link>
</Button>
)}
</div>
</TableCell>
</motion.tr>
);
})}
</AnimatePresence>
</TableBody>
</Table>
</div>
</div>
</CardContent>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-4 border-t border-zinc-800 bg-black/40">
<div className="flex items-center justify-between px-4 py-4 border-t border-border/50 bg-background/50">
<div className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages} ({totalOrders} total)
</div>
@@ -628,8 +637,9 @@ export default function OrderTable() {
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || loading}
className="h-8"
>
<ChevronLeft className="h-4 w-4" />
<ChevronLeft className="h-3 w-3 mr-1" />
Previous
</Button>
<Button
@@ -637,14 +647,15 @@ export default function OrderTable() {
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || loading}
className="h-8"
>
Next
<ChevronRight className="h-4 w-4" />
<ChevronRight className="h-3 w-3 ml-1" />
</Button>
</div>
</div>
</div>
</Card>
</div>
);
}