Compare commits
17 Commits
73adbe5d07
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6e6cd0757 | ||
|
|
9acd18955e | ||
|
|
318927cd0c | ||
|
|
a6b7286b45 | ||
|
|
d78e6c0725 | ||
|
|
3f9d28bf1b | ||
|
|
064cd7a486 | ||
|
|
6cd658c4cb | ||
|
|
6997838bf7 | ||
|
|
e369741b2d | ||
|
|
7ddcd7afb6 | ||
|
|
3ffb64cf9a | ||
|
|
e9737c8b24 | ||
|
|
244014f33a | ||
|
|
1186952ed8 | ||
|
|
0bb1497db6 | ||
|
|
688f519fd6 |
@@ -1,8 +1,9 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Package, AlertTriangle } from "lucide-react";
|
import { Package, AlertTriangle, CheckCircle2, XCircle, DollarSign } from "lucide-react";
|
||||||
import { fetchServer } from "@/lib/api";
|
import { fetchServer } from "@/lib/api";
|
||||||
import OrdersTable from "@/components/admin/OrdersTable";
|
import OrdersTable from "@/components/admin/OrdersTable";
|
||||||
|
import { MotionWrapper } from "@/components/ui/motion-wrapper";
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -26,7 +27,6 @@ interface SystemStats {
|
|||||||
chats: number;
|
chats: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default async function AdminOrdersPage() {
|
export default async function AdminOrdersPage() {
|
||||||
let orders: Order[] = [];
|
let orders: Order[] = [];
|
||||||
let systemStats: SystemStats | null = null;
|
let systemStats: SystemStats | null = null;
|
||||||
@@ -46,14 +46,14 @@ export default async function AdminOrdersPage() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 p-1">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Recent Orders</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Recent Orders</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">Monitor and manage platform orders</p>
|
<p className="text-muted-foreground mt-2">Monitor and manage platform orders</p>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<Card className="border-destructive/50 bg-destructive/10">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="text-center text-red-500">
|
<div className="text-center text-destructive">
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -62,146 +62,182 @@ export default async function AdminOrdersPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const acknowledgedOrders = orders.filter(o => o.status === 'acknowledged');
|
const acknowledgedOrders = orders.filter(o => o.status === 'acknowledged');
|
||||||
const paidOrders = orders.filter(o => o.status === 'paid');
|
const paidOrders = orders.filter(o => o.status === 'paid');
|
||||||
const completedOrders = orders.filter(o => o.status === 'completed');
|
const completedOrders = orders.filter(o => o.status === 'completed');
|
||||||
const cancelledOrders = orders.filter(o => o.status === 'cancelled');
|
const cancelledOrders = orders.filter(o => o.status === 'cancelled');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8 p-1">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Recent Orders</h1>
|
<h1 className="text-3xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">
|
||||||
<p className="text-sm text-muted-foreground mt-1">Monitor and manage platform orders</p>
|
Recent Orders
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-2 text-lg">
|
||||||
|
Monitor and manage platform transaction activity
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
<MotionWrapper>
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div className="space-y-8">
|
||||||
<Card>
|
{/* Stats Cards */}
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
<CardTitle className="text-sm font-medium">Total Orders</CardTitle>
|
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm hover:shadow-md transition-all duration-300">
|
||||||
<Package className="h-4 w-4 text-muted-foreground" />
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
</CardHeader>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Total Orders</CardTitle>
|
||||||
<CardContent>
|
<div className="p-2 rounded-lg bg-primary/10">
|
||||||
<div className="text-2xl font-bold">{systemStats?.orders || 0}</div>
|
<Package className="h-4 w-4 text-primary" />
|
||||||
<p className="text-xs text-muted-foreground">All platform orders</p>
|
</div>
|
||||||
</CardContent>
|
</CardHeader>
|
||||||
</Card>
|
<CardContent>
|
||||||
<Card>
|
<div className="text-2xl font-bold">{systemStats?.orders || 0}</div>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<div className="flex items-center mt-1">
|
||||||
<CardTitle className="text-sm font-medium">Acknowledged</CardTitle>
|
<div className="h-1.5 w-1.5 rounded-full bg-primary mr-2" />
|
||||||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
<p className="text-xs text-muted-foreground">Lifetime volume</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
</CardContent>
|
||||||
<div className="text-2xl font-bold">{acknowledgedOrders.length}</div>
|
</Card>
|
||||||
<p className="text-xs text-muted-foreground">Vendor accepted</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Paid</CardTitle>
|
|
||||||
<Package className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{paidOrders.length}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">Payment received</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Completed</CardTitle>
|
|
||||||
<Package className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{completedOrders.length}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">Successfully delivered</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Orders Table with Pagination */}
|
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm hover:shadow-md transition-all duration-300">
|
||||||
<OrdersTable orders={orders} />
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Acknowledged</CardTitle>
|
||||||
|
<div className="p-2 rounded-lg bg-purple-500/10">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{acknowledgedOrders.length}</div>
|
||||||
|
<div className="flex items-center mt-1">
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-purple-500 mr-2" />
|
||||||
|
<p className="text-xs text-muted-foreground">Vendor pending</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Order Analytics */}
|
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm hover:shadow-md transition-all duration-300">
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<Card>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Paid</CardTitle>
|
||||||
<CardHeader>
|
<div className="p-2 rounded-lg bg-emerald-500/10">
|
||||||
<CardTitle>Order Status Distribution</CardTitle>
|
<DollarSign className="h-4 w-4 text-emerald-500" />
|
||||||
<CardDescription>Breakdown of recent orders by status</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="w-3 h-3 bg-purple-500 rounded-full"></div>
|
|
||||||
<span className="text-sm">Acknowledged</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">
|
</CardHeader>
|
||||||
{orders.length > 0 ? Math.round((acknowledgedOrders.length / orders.length) * 100) : 0}%
|
<CardContent>
|
||||||
</span>
|
<div className="text-2xl font-bold">{paidOrders.length}</div>
|
||||||
</div>
|
<div className="flex items-center mt-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="h-1.5 w-1.5 rounded-full bg-emerald-500 mr-2" />
|
||||||
<div className="flex items-center space-x-2">
|
<p className="text-xs text-muted-foreground">Processing</p>
|
||||||
<div className="w-3 h-3 bg-emerald-500 rounded-full"></div>
|
|
||||||
<span className="text-sm">Paid</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">
|
</CardContent>
|
||||||
{orders.length > 0 ? Math.round((paidOrders.length / orders.length) * 100) : 0}%
|
</Card>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
|
||||||
<span className="text-sm">Completed</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{orders.length > 0 ? Math.round((completedOrders.length / orders.length) * 100) : 0}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
|
|
||||||
<span className="text-sm">Cancelled</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{orders.length > 0 ? Math.round((cancelledOrders.length / orders.length) * 100) : 0}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm hover:shadow-md transition-all duration-300">
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle>Order Summary</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Completed</CardTitle>
|
||||||
<CardDescription>Recent order activity breakdown</CardDescription>
|
<div className="p-2 rounded-lg bg-blue-500/10">
|
||||||
</CardHeader>
|
<CheckCircle2 className="h-4 w-4 text-blue-500" />
|
||||||
<CardContent>
|
</div>
|
||||||
<div className="space-y-4">
|
</CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<CardContent>
|
||||||
<span className="text-sm">Total Recent Orders</span>
|
<div className="text-2xl font-bold">{completedOrders.length}</div>
|
||||||
<span className="text-sm font-medium">{orders.length}</span>
|
<div className="flex items-center mt-1">
|
||||||
</div>
|
<div className="h-1.5 w-1.5 rounded-full bg-blue-500 mr-2" />
|
||||||
<div className="flex items-center justify-between">
|
<p className="text-xs text-muted-foreground">Delivered</p>
|
||||||
<span className="text-sm">Acknowledged</span>
|
</div>
|
||||||
<span className="text-sm font-medium">{acknowledgedOrders.length}</span>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
<div className="flex items-center justify-between">
|
</div>
|
||||||
<span className="text-sm">Paid</span>
|
|
||||||
<span className="text-sm font-medium">{paidOrders.length}</span>
|
{/* Orders Table with Pagination */}
|
||||||
</div>
|
<div className="bg-background/50 backdrop-blur-sm rounded-xl border border-border/40 overflow-hidden shadow-sm">
|
||||||
<div className="flex items-center justify-between">
|
<OrdersTable orders={orders} />
|
||||||
<span className="text-sm">Completed</span>
|
</div>
|
||||||
<span className="text-sm font-medium">{completedOrders.length}</span>
|
|
||||||
</div>
|
{/* Order Analytics */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<span className="text-sm">Cancelled</span>
|
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm">
|
||||||
<span className="text-sm font-medium">{cancelledOrders.length}</span>
|
<CardHeader>
|
||||||
</div>
|
<CardTitle>Status Distribution</CardTitle>
|
||||||
</div>
|
<CardDescription>Breakdown of active orders</CardDescription>
|
||||||
</CardContent>
|
</CardHeader>
|
||||||
</Card>
|
<CardContent>
|
||||||
</div>
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-muted/30 transition-colors">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-2 h-12 bg-purple-500 rounded-full"></div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">Acknowledged</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Waiting for shipment</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="font-bold">
|
||||||
|
{orders.length > 0 ? Math.round((acknowledgedOrders.length / orders.length) * 100) : 0}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-muted/30 transition-colors">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-2 h-12 bg-emerald-500 rounded-full"></div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">Paid</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Payment confirmed</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="font-bold">
|
||||||
|
{orders.length > 0 ? Math.round((paidOrders.length / orders.length) * 100) : 0}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-muted/30 transition-colors">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-2 h-12 bg-blue-500 rounded-full"></div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">Completed</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Successfully concluded</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="font-bold">
|
||||||
|
{orders.length > 0 ? Math.round((completedOrders.length / orders.length) * 100) : 0}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Activity Summary</CardTitle>
|
||||||
|
<CardDescription>Recent volume breakdown</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between pb-4 border-b border-border/40">
|
||||||
|
<span className="text-sm text-muted-foreground">Total Displayed Orders</span>
|
||||||
|
<span className="text-xl font-bold">{orders.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs uppercase text-muted-foreground tracking-wider">Active</span>
|
||||||
|
<p className="text-lg font-semibold">{acknowledgedOrders.length + paidOrders.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs uppercase text-muted-foreground tracking-wider">Finished</span>
|
||||||
|
<p className="text-lg font-semibold">{completedOrders.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs uppercase text-muted-foreground tracking-wider">Voided</span>
|
||||||
|
<p className="text-lg font-semibold text-destructive">{cancelledOrders.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs uppercase text-muted-foreground tracking-wider">Success Rate</span>
|
||||||
|
<p className="text-lg font-semibold text-green-500">
|
||||||
|
{orders.length > 0 ? Math.round((completedOrders.length / (orders.length - (acknowledgedOrders.length + paidOrders.length))) * 100) || 100 : 0}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MotionWrapper>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -403,11 +403,12 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||||
<TabsList>
|
<TabsList className="bg-muted/20 p-1 border border-border/40 backdrop-blur-sm h-auto">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="analytics"
|
value="analytics"
|
||||||
onMouseEnter={() => handleTabHover("analytics")}
|
onMouseEnter={() => handleTabHover("analytics")}
|
||||||
onFocus={() => handleTabFocus("analytics")}
|
onFocus={() => handleTabFocus("analytics")}
|
||||||
|
className="data-[state=active]:bg-background/80 data-[state=active]:backdrop-blur-sm data-[state=active]:text-foreground data-[state=active]:shadow-sm px-6 py-2 transition-all"
|
||||||
>
|
>
|
||||||
Analytics
|
Analytics
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -415,6 +416,7 @@ export default function AdminPage() {
|
|||||||
value="management"
|
value="management"
|
||||||
onMouseEnter={() => handleTabHover("management")}
|
onMouseEnter={() => handleTabHover("management")}
|
||||||
onFocus={() => handleTabFocus("management")}
|
onFocus={() => handleTabFocus("management")}
|
||||||
|
className="data-[state=active]:bg-background/80 data-[state=active]:backdrop-blur-sm data-[state=active]:text-foreground data-[state=active]:shadow-sm px-6 py-2 transition-all"
|
||||||
>
|
>
|
||||||
Management
|
Management
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Server, Database, Cpu, HardDrive, Activity } from "lucide-react";
|
import { Server, Database, Cpu, HardDrive, Activity, Zap } from "lucide-react";
|
||||||
import { fetchServer } from "@/lib/api";
|
import { fetchServer } from "@/lib/api";
|
||||||
import SystemStatusCard from "@/components/admin/SystemStatusCard";
|
import SystemStatusCard from "@/components/admin/SystemStatusCard";
|
||||||
|
import { MotionWrapper } from "@/components/ui/motion-wrapper";
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -35,17 +36,19 @@ export default async function AdminStatusPage() {
|
|||||||
console.error("Failed to fetch system status:", err);
|
console.error("Failed to fetch system status:", err);
|
||||||
error = "Failed to load system status";
|
error = "Failed to load system status";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8 p-1">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">System Status</h1>
|
<h1 className="text-3xl font-bold tracking-tight">System Status</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">Monitor system health and performance metrics</p>
|
<p className="text-muted-foreground mt-2">Monitor system health and real-time performance metrics</p>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<Card className="border-destructive/50 bg-destructive/10">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="text-center text-red-500">
|
<div className="flex items-center gap-3 text-destructive">
|
||||||
<p>{error}</p>
|
<Activity className="h-5 w-5" />
|
||||||
|
<p className="font-medium">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -71,112 +74,172 @@ export default async function AdminStatusPage() {
|
|||||||
Math.round((systemStatus.memory.heapUsed / systemStatus.memory.heapTotal) * 100) : 0;
|
Math.round((systemStatus.memory.heapUsed / systemStatus.memory.heapTotal) * 100) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8 p-1">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">System Status</h1>
|
<h1 className="text-3xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">
|
||||||
<p className="text-sm text-muted-foreground mt-1">Monitor system health and performance metrics</p>
|
System Status
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-2 text-lg">
|
||||||
|
Monitor system health and real-time performance metrics
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SystemStatusCard />
|
<MotionWrapper>
|
||||||
|
<div className="space-y-8">
|
||||||
|
<SystemStatusCard />
|
||||||
|
|
||||||
<div className="grid gap-4 lg:gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{/* Server Status */}
|
{/* Server Status */}
|
||||||
<Card>
|
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm hover:shadow-md transition-all duration-300">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Server Status</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Server Uptime</CardTitle>
|
||||||
<Server className="h-4 w-4 text-muted-foreground" />
|
<div className="p-2 rounded-lg bg-green-500/10">
|
||||||
</CardHeader>
|
<Server className="h-4 w-4 text-green-500" />
|
||||||
<CardContent>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
</CardHeader>
|
||||||
<Badge variant="default" className="bg-green-500">Online</Badge>
|
<CardContent>
|
||||||
<span className="text-sm text-muted-foreground">
|
<div className="flex items-center space-x-2">
|
||||||
{systemStatus ? formatUptime(systemStatus.uptimeSeconds) : 'N/A'}
|
<span className="text-2xl font-bold">
|
||||||
</span>
|
{systemStatus ? formatUptime(systemStatus.uptimeSeconds) : 'N/A'}
|
||||||
</div>
|
</span>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
</div>
|
||||||
Last checked: {new Date().toLocaleTimeString()}
|
<div className="flex items-center mt-2 space-x-2">
|
||||||
</p>
|
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||||
</CardContent>
|
Online
|
||||||
</Card>
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Checked: {new Date().toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Database Status */}
|
{/* Database Status */}
|
||||||
<Card>
|
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm hover:shadow-md transition-all duration-300">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Database</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Database Health</CardTitle>
|
||||||
<Database className="h-4 w-4 text-muted-foreground" />
|
<div className="p-2 rounded-lg bg-blue-500/10">
|
||||||
</CardHeader>
|
<Database className="h-4 w-4 text-blue-500" />
|
||||||
<CardContent>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
</CardHeader>
|
||||||
<Badge variant="default" className="bg-green-500">Connected</Badge>
|
<CardContent>
|
||||||
<span className="text-sm text-muted-foreground">
|
<div className="flex items-center space-x-2">
|
||||||
{systemStatus ? `${systemStatus.counts.vendors + systemStatus.counts.orders + systemStatus.counts.products} records` : 'N/A'}
|
<span className="text-2xl font-bold">
|
||||||
</span>
|
{systemStatus ? `${(systemStatus.counts.vendors + systemStatus.counts.orders + systemStatus.counts.products).toLocaleString()}` : '0'}
|
||||||
</div>
|
</span>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<span className="text-sm text-muted-foreground self-end mb-1">records</span>
|
||||||
Total collections: 4
|
</div>
|
||||||
</p>
|
<div className="flex items-center mt-2 space-x-2">
|
||||||
</CardContent>
|
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">
|
||||||
</Card>
|
Connected
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
4 active collections
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Memory Usage */}
|
{/* Memory Usage */}
|
||||||
<Card>
|
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm hover:shadow-md transition-all duration-300">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Memory</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Memory Usage</CardTitle>
|
||||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
<div className="p-2 rounded-lg bg-purple-500/10">
|
||||||
</CardHeader>
|
<HardDrive className="h-4 w-4 text-purple-500" />
|
||||||
<CardContent>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
</CardHeader>
|
||||||
<Badge variant={memoryUsagePercent > 80 ? "destructive" : memoryUsagePercent > 60 ? "secondary" : "outline"}>
|
<CardContent>
|
||||||
{memoryUsagePercent}%
|
<div className="flex items-center space-x-2">
|
||||||
</Badge>
|
<span className="text-2xl font-bold">
|
||||||
<span className="text-sm text-muted-foreground">
|
{systemStatus ? formatBytes(systemStatus.memory.heapUsed) : 'N/A'}
|
||||||
{systemStatus ? formatBytes(systemStatus.memory.heapUsed) : 'N/A'}
|
</span>
|
||||||
</span>
|
<span className="text-sm text-muted-foreground self-end mb-1">used</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<div className="flex items-center mt-2 space-x-2">
|
||||||
Total: {systemStatus ? formatBytes(systemStatus.memory.heapTotal) : 'N/A'}
|
<Badge variant="outline" className={`
|
||||||
</p>
|
${memoryUsagePercent > 80 ? 'bg-red-500/10 text-red-500 border-red-500/20' :
|
||||||
</CardContent>
|
memoryUsagePercent > 60 ? 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20' :
|
||||||
</Card>
|
'bg-purple-500/10 text-purple-500 border-purple-500/20'}
|
||||||
|
`}>
|
||||||
|
{memoryUsagePercent}% Load
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Total: {systemStatus ? formatBytes(systemStatus.memory.heapTotal) : 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Platform Stats */}
|
{/* Platform Stats */}
|
||||||
<Card>
|
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm hover:shadow-md transition-all duration-300">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Platform Stats</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Platform Activity</CardTitle>
|
||||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
<div className="p-2 rounded-lg bg-orange-500/10">
|
||||||
</CardHeader>
|
<Activity className="h-4 w-4 text-orange-500" />
|
||||||
<CardContent>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
</CardHeader>
|
||||||
<Badge variant="default" className="bg-green-500">Active</Badge>
|
<CardContent>
|
||||||
<span className="text-sm text-muted-foreground">
|
<div className="flex items-center space-x-2">
|
||||||
{systemStatus ? `${systemStatus.counts.vendors} vendors` : 'N/A'}
|
<span className="text-2xl font-bold">
|
||||||
</span>
|
{systemStatus ? systemStatus.counts.vendors : '0'}
|
||||||
</div>
|
</span>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<span className="text-sm text-muted-foreground self-end mb-1">Active Vendors</span>
|
||||||
{systemStatus ? `${systemStatus.counts.orders} orders, ${systemStatus.counts.products} products` : 'N/A'}
|
</div>
|
||||||
</p>
|
<div className="flex items-center mt-2 space-x-2">
|
||||||
</CardContent>
|
<Badge variant="outline" className="bg-orange-500/10 text-orange-500 border-orange-500/20">
|
||||||
</Card>
|
Live
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{systemStatus ? `${systemStatus.counts.orders} orders` : '0 orders'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Node.js Version */}
|
{/* Runtime Info */}
|
||||||
<Card>
|
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm hover:shadow-md transition-all duration-300 md:col-span-2 lg:col-span-2">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Runtime</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Runtime Environment</CardTitle>
|
||||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
<div className="p-2 rounded-lg bg-zinc-500/10">
|
||||||
</CardHeader>
|
<Cpu className="h-4 w-4 text-zinc-500" />
|
||||||
<CardContent>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
</CardHeader>
|
||||||
<Badge variant="outline">
|
<CardContent>
|
||||||
{systemStatus ? `Node ${systemStatus.versions.node}` : 'N/A'}
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
</Badge>
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-sm text-muted-foreground">Runtime</span>
|
<div>
|
||||||
</div>
|
<p className="text-2xl font-bold">Node.js</p>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<p className="text-xs text-muted-foreground mt-1">Runtime</p>
|
||||||
{systemStatus ? `V8: ${systemStatus.versions.v8}` : 'N/A'}
|
</div>
|
||||||
</p>
|
<Badge variant="secondary" className="text-sm h-7">
|
||||||
</CardContent>
|
{systemStatus ? `v${systemStatus.versions.node}` : 'N/A'}
|
||||||
</Card>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="h-8 w-px bg-border/50 hidden sm:block" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">V8</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Engine</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="text-sm h-7">
|
||||||
|
{systemStatus ? systemStatus.versions.v8 : 'N/A'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-8 w-px bg-border/50 hidden sm:block" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/30 px-3 py-1.5 rounded-md">
|
||||||
|
<Zap className="h-3.5 w-3.5 text-yellow-500" />
|
||||||
|
<span>Performance Optimized</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MotionWrapper>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ export default function AdminUsersPage() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [users, setUsers] = useState<TelegramUser[]>([]);
|
const [users, setUsers] = useState<TelegramUser[]>([]);
|
||||||
|
// State for browser detection
|
||||||
|
// Browser detection
|
||||||
|
const [isFirefox, setIsFirefox] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ua = navigator.userAgent.toLowerCase();
|
||||||
|
setIsFirefox(ua.includes("firefox") && !ua.includes("chrome"));
|
||||||
|
}, []);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pagination, setPagination] = useState<PaginationResponse['pagination'] | null>(null);
|
const [pagination, setPagination] = useState<PaginationResponse['pagination'] | null>(null);
|
||||||
@@ -198,20 +206,14 @@ export default function AdminUsersPage() {
|
|||||||
<AnimatePresence mode="popLayout">
|
<AnimatePresence mode="popLayout">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} className="h-32 text-center">
|
<TableCell colSpan={8} className="h-24 text-center">
|
||||||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||||
<Loader2 className="h-8 w-8 animate-spin opacity-25" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<p>Loading users...</p>
|
Loading users...
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : users.length === 0 ? (
|
) : users.length > 0 ? (
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={8} className="h-32 text-center text-muted-foreground">
|
|
||||||
{searchQuery ? "No users found matching your search" : "No users found"}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
users.map((user, index) => (
|
users.map((user, index) => (
|
||||||
<motion.tr
|
<motion.tr
|
||||||
key={user.telegramUserId}
|
key={user.telegramUserId}
|
||||||
@@ -219,84 +221,75 @@ export default function AdminUsersPage() {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.2, delay: index * 0.03 }}
|
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||||
className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
|
className={`group border-b border-border/50 transition-colors ${user.isBlocked ? "bg-destructive/5 hover:bg-destructive/10" : "hover:bg-muted/40"}`}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell className="font-mono text-xs">{user.telegramUserId}</TableCell>
|
||||||
<div className="font-mono text-xs text-muted-foreground/80">{user.telegramUserId}</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="font-medium flex items-center gap-2">
|
|
||||||
{user.telegramUsername !== "Unknown" ? (
|
|
||||||
<>
|
|
||||||
<span className="text-blue-500/80">@</span>
|
|
||||||
{user.telegramUsername}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground italic">Unknown</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium">{user.totalOrders}</span>
|
<span className="font-medium">@{user.telegramUsername || "Unknown"}</span>
|
||||||
{user.completedOrders > 0 && (
|
{user.isBlocked && (
|
||||||
<Badge variant="outline" className="text-[10px] h-5 px-1.5 bg-green-500/10 text-green-600 border-green-200 dark:border-green-900">
|
<Badge variant="destructive" className="h-5 px-1.5 text-[10px]">Blocked</Badge>
|
||||||
{user.completedOrders} done
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>{user.totalOrders}</TableCell>
|
||||||
|
<TableCell>{formatCurrency(user.totalSpent)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="font-medium tabular-nums">{formatCurrency(user.totalSpent)}</span>
|
<div className="flex flex-col gap-1 text-xs">
|
||||||
|
<span className="text-emerald-500">{user.completedOrders} Completed</span>
|
||||||
|
<span className="text-muted-foreground">{user.paidOrders - user.completedOrders} Pending</span>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
{user.isBlocked ? (
|
{user.firstOrderDate ? new Date(user.firstOrderDate).toLocaleDateString() : "-"}
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Badge variant="destructive" className="items-center gap-1">
|
|
||||||
<Ban className="h-3 w-3" />
|
|
||||||
Blocked
|
|
||||||
</Badge>
|
|
||||||
</TooltipTrigger>
|
|
||||||
{user.blockedReason && (
|
|
||||||
<TooltipContent>
|
|
||||||
<p className="max-w-xs">{user.blockedReason}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
) : user.totalOrders > 0 ? (
|
|
||||||
<Badge variant="default" className="bg-green-600 hover:bg-green-700">Active</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="secondary">No Orders</Badge>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
{user.firstOrderDate
|
{user.lastOrderDate ? new Date(user.lastOrderDate).toLocaleDateString() : "-"}
|
||||||
? new Date(user.firstOrderDate).toLocaleDateString()
|
|
||||||
: '-'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
|
||||||
{user.lastOrderDate
|
|
||||||
? new Date(user.lastOrderDate).toLocaleDateString()
|
|
||||||
: '-'}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
{!user.isBlocked ? (
|
{user.isBlocked ? (
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10">
|
<TooltipProvider>
|
||||||
<Ban className="h-4 w-4" />
|
<Tooltip>
|
||||||
</Button>
|
<TooltipTrigger asChild>
|
||||||
|
<Button size="sm" variant="outline" className="h-8 border-emerald-500/20 text-emerald-500 hover:bg-emerald-500/10 hover:text-emerald-400">
|
||||||
|
<UserCheck className="h-4 w-4 mr-1" />
|
||||||
|
Unblock
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Unblock this user</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-green-600 hover:bg-green-500/10">
|
<TooltipProvider>
|
||||||
<UserCheck className="h-4 w-4" />
|
<Tooltip>
|
||||||
</Button>
|
<TooltipTrigger asChild>
|
||||||
|
<Button size="sm" variant="outline" className="h-8 border-destructive/20 text-destructive hover:bg-destructive/10 hover:text-destructive">
|
||||||
|
<Ban className="h-4 w-4 mr-1" />
|
||||||
|
Block
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Block access to the store</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</motion.tr>
|
</motion.tr>
|
||||||
))
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="h-32 text-center text-muted-foreground">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2">
|
||||||
|
<Users className="h-8 w-8 opacity-20" />
|
||||||
|
<p>No users found</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
317
app/dashboard/admin/vendors/page.tsx
vendored
317
app/dashboard/admin/vendors/page.tsx
vendored
@@ -6,10 +6,20 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Search, MoreHorizontal, UserCheck, UserX, Mail, Loader2, Store, Shield, ShieldAlert, Clock, Calendar } from "lucide-react";
|
import { Search, MoreHorizontal, UserCheck, UserX, Mail, Loader2, Store, Shield, ShieldAlert, Clock, Calendar, Pencil, Plus } from "lucide-react";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { fetchClient } from "@/lib/api-client";
|
import { fetchClient } from "@/lib/api-client";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
interface Vendor {
|
interface Vendor {
|
||||||
_id: string;
|
_id: string;
|
||||||
@@ -38,9 +48,73 @@ export default function AdminVendorsPage() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [vendors, setVendors] = useState<Vendor[]>([]);
|
const [vendors, setVendors] = useState<Vendor[]>([]);
|
||||||
|
// State for browser detection
|
||||||
|
const [isFirefox, setIsFirefox] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ua = navigator.userAgent.toLowerCase();
|
||||||
|
setIsFirefox(ua.includes("firefox") && !ua.includes("chrome"));
|
||||||
|
}, []);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pagination, setPagination] = useState<PaginationResponse['pagination'] | null>(null);
|
const [pagination, setPagination] = useState<PaginationResponse['pagination'] | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [isEditStoreOpen, setIsEditStoreOpen] = useState(false);
|
||||||
|
const [editingVendor, setEditingVendor] = useState<Vendor | null>(null);
|
||||||
|
const [newStoreId, setNewStoreId] = useState("");
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
|
||||||
|
const handleToggleStatus = async (vendor: Vendor) => {
|
||||||
|
try {
|
||||||
|
await fetchClient(`/admin/vendors/${vendor._id}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { isActive: !vendor.isActive }
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: `Vendor ${vendor.isActive ? 'suspended' : 'activated'} successfully`,
|
||||||
|
});
|
||||||
|
fetchVendors();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message || "Failed to update vendor status",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditStore = (vendor: Vendor) => {
|
||||||
|
setEditingVendor(vendor);
|
||||||
|
setNewStoreId(vendor.storeId || "");
|
||||||
|
setIsEditStoreOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveStoreId = async () => {
|
||||||
|
if (!editingVendor) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUpdating(true);
|
||||||
|
await fetchClient(`/admin/vendors/${editingVendor._id}/store-id`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { storeId: newStoreId }
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Store ID updated successfully",
|
||||||
|
});
|
||||||
|
setIsEditStoreOpen(false);
|
||||||
|
fetchVendors(); // Refresh list
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: error.message || "Failed to update store ID",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchVendors = useCallback(async () => {
|
const fetchVendors = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -173,8 +247,8 @@ export default function AdminVendorsPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<AnimatePresence mode="popLayout">
|
{isFirefox ? (
|
||||||
{loading ? (
|
loading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="h-32 text-center">
|
<TableCell colSpan={6} className="h-32 text-center">
|
||||||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||||
@@ -195,7 +269,6 @@ export default function AdminVendorsPage() {
|
|||||||
key={vendor._id}
|
key={vendor._id}
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2, delay: index * 0.03 }}
|
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||||
className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
|
className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
|
||||||
>
|
>
|
||||||
@@ -209,9 +282,29 @@ export default function AdminVendorsPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{vendor.storeId ? (
|
{vendor.storeId ? (
|
||||||
<span className="font-mono text-xs">{vendor.storeId}</span>
|
<div className="flex items-center gap-2 group/store">
|
||||||
|
<span className="font-mono text-xs">{vendor.storeId}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 opacity-0 group-hover/store:opacity-100 transition-opacity"
|
||||||
|
onClick={() => handleEditStore(vendor)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground italic text-xs">No store</span>
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground italic text-xs">No store</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-muted-foreground hover:text-primary"
|
||||||
|
onClick={() => handleEditStore(vendor)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -243,22 +336,173 @@ export default function AdminVendorsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex items-center justify-end space-x-1">
|
<DropdownMenu>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-primary">
|
<DropdownMenuTrigger asChild>
|
||||||
<UserCheck className="h-4 w-4" />
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
</Button>
|
<span className="sr-only">Open menu</span>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10">
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
<UserX className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
</DropdownMenuTrigger>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground">
|
<DropdownMenuContent align="end">
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
</Button>
|
<DropdownMenuItem
|
||||||
</div>
|
onClick={() => navigator.clipboard.writeText(vendor._id)}
|
||||||
|
>
|
||||||
|
Copy Vendor ID
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className={vendor.isActive ? "text-red-600" : "text-green-600"}
|
||||||
|
onClick={() => handleToggleStatus(vendor)}
|
||||||
|
>
|
||||||
|
{vendor.isActive ? (
|
||||||
|
<>
|
||||||
|
<UserX className="mr-2 h-4 w-4" />
|
||||||
|
Suspend Vendor
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserCheck className="mr-2 h-4 w-4" />
|
||||||
|
Activate Vendor
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</motion.tr>
|
</motion.tr>
|
||||||
))
|
))
|
||||||
)}
|
)
|
||||||
</AnimatePresence>
|
) : (
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="h-32 text-center">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin opacity-25" />
|
||||||
|
<p>Loading vendors...</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : filteredVendors.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
||||||
|
{searchQuery.trim() ? "No vendors found matching your search" : "No vendors found"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredVendors.map((vendor, index) => (
|
||||||
|
<motion.tr
|
||||||
|
key={vendor._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>
|
||||||
|
<div className="font-medium flex items-center gap-2">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
||||||
|
{vendor.username.substring(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
{vendor.username}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{vendor.storeId ? (
|
||||||
|
<div className="flex items-center gap-2 group/store">
|
||||||
|
<span className="font-mono text-xs">{vendor.storeId}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 opacity-0 group-hover/store:opacity-100 transition-opacity"
|
||||||
|
onClick={() => handleEditStore(vendor)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground italic text-xs">No store</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-muted-foreground hover:text-primary"
|
||||||
|
onClick={() => handleEditStore(vendor)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={vendor.isActive ? "default" : "destructive"}
|
||||||
|
className={vendor.isActive ? "bg-green-600 hover:bg-green-700" : ""}
|
||||||
|
>
|
||||||
|
{vendor.isActive ? "Active" : "Suspended"}
|
||||||
|
</Badge>
|
||||||
|
{vendor.isAdmin && (
|
||||||
|
<Badge variant="outline" className="border-blue-200 text-blue-700 bg-blue-50 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-900">
|
||||||
|
<Shield className="h-3 w-3 mr-1" />
|
||||||
|
Admin
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Calendar className="h-3.5 w-3.5 opacity-70" />
|
||||||
|
{vendor.createdAt ? new Date(vendor.createdAt).toLocaleDateString() : 'N/A'}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Clock className="h-3.5 w-3.5 opacity-70" />
|
||||||
|
{vendor.lastLogin ? new Date(vendor.lastLogin).toLocaleDateString() : 'Never'}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">Open menu</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigator.clipboard.writeText(vendor._id)}
|
||||||
|
>
|
||||||
|
Copy Vendor ID
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className={vendor.isActive ? "text-red-600" : "text-green-600"}
|
||||||
|
onClick={() => handleToggleStatus(vendor)}
|
||||||
|
>
|
||||||
|
{vendor.isActive ? (
|
||||||
|
<>
|
||||||
|
<UserX className="mr-2 h-4 w-4" />
|
||||||
|
Suspend Vendor
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserCheck className="mr-2 h-4 w-4" />
|
||||||
|
Activate Vendor
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</motion.tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
@@ -291,6 +535,39 @@ export default function AdminVendorsPage() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
|
<Dialog open={isEditStoreOpen} onOpenChange={setIsEditStoreOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Update Vendor Store</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter the Store ID to assign to vendor <span className="font-semibold text-foreground">{editingVendor?.username}</span>.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="storeId">Store ID</Label>
|
||||||
|
<Input
|
||||||
|
id="storeId"
|
||||||
|
value={newStoreId}
|
||||||
|
onChange={(e) => setNewStoreId(e.target.value)}
|
||||||
|
placeholder="Enter 24-character Store ID"
|
||||||
|
className="col-span-3 font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Ensure the Store ID corresponds to an existing store in the system.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsEditStoreOpen(false)} disabled={updating}>Cancel</Button>
|
||||||
|
<Button onClick={saveStoreId} disabled={updating || !newStoreId || newStoreId.length < 24}>
|
||||||
|
{updating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,10 +263,10 @@ export default function CategoriesPage() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={`group flex items-center p-2 rounded-md transition-all duration-200 border border-transparent
|
className={`group flex items-center p-3 rounded-xl transition-all duration-200 border mb-2
|
||||||
${isEditing ? 'bg-muted/50 border-primary/20' : ''}
|
${isEditing ? 'bg-indigo-500/10 border-indigo-500/30' : ''}
|
||||||
${isOver ? 'bg-muted border-primary/20' : 'hover:bg-muted/50 hover:border-border/50'}
|
${isOver ? 'bg-indigo-500/20 border-indigo-500/50 scale-[1.02]' : 'bg-black/40 border-white/5 hover:bg-black/60 hover:border-white/10 hover:shadow-lg'}
|
||||||
${isDragging ? 'opacity-50' : 'opacity-100'}`}
|
${isDragging ? 'opacity-30' : 'opacity-100'} backdrop-blur-sm`}
|
||||||
style={{ marginLeft: `${level * 24}px` }}
|
style={{ marginLeft: `${level * 24}px` }}
|
||||||
data-handler-id={handlerId}
|
data-handler-id={handlerId}
|
||||||
>
|
>
|
||||||
@@ -384,51 +384,53 @@ export default function CategoriesPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 xl:grid-cols-5 gap-6 lg:gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 xl:grid-cols-5 gap-6 lg:gap-8">
|
||||||
{/* Add Category Card */}
|
{/* Add Category Card */}
|
||||||
<Card className="lg:col-span-2 border-border/40 bg-background/50 backdrop-blur-sm shadow-sm h-fit sticky top-6">
|
<Card className="lg:col-span-2 border-white/10 bg-black/40 backdrop-blur-xl shadow-xl h-fit sticky top-6 rounded-xl overflow-hidden">
|
||||||
<CardHeader className="bg-muted/20 border-b border-border/40 pb-4">
|
<CardHeader className="bg-white/[0.02] border-b border-white/5 pb-4">
|
||||||
<CardTitle className="text-lg font-medium flex items-center">
|
<CardTitle className="text-lg font-bold flex items-center text-white">
|
||||||
<Plus className="mr-2 h-4 w-4 text-primary" />
|
<div className="p-2 mr-3 rounded-lg bg-indigo-500/10 border border-indigo-500/20">
|
||||||
|
<Plus className="h-4 w-4 text-indigo-400" />
|
||||||
|
</div>
|
||||||
Add New Category
|
Add New Category
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription className="text-zinc-400">
|
||||||
Create a new category or subcategory
|
Create a new category or subcategory
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium leading-none">
|
<label className="text-sm font-medium leading-none text-zinc-300">
|
||||||
Category Name
|
Category Name
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={newCategoryName}
|
value={newCategoryName}
|
||||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||||
placeholder="e.g. Electronics, Clothing..."
|
placeholder="e.g. Electronics, Clothing..."
|
||||||
className="h-10 border-border/50 bg-background/50 focus:bg-background transition-colors"
|
className="h-10 border-white/10 bg-black/20 focus:bg-black/40 transition-colors text-white placeholder:text-zinc-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium leading-none">
|
<label className="text-sm font-medium leading-none text-zinc-300">
|
||||||
Parent Category
|
Parent Category
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedParentId || "none"}
|
value={selectedParentId || "none"}
|
||||||
onValueChange={setSelectedParentId}
|
onValueChange={setSelectedParentId}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-10 border-border/50 bg-background/50 focus:bg-background transition-colors">
|
<SelectTrigger className="h-10 border-white/10 bg-black/20 focus:bg-black/40 transition-colors text-white">
|
||||||
<SelectValue placeholder="Select parent category" />
|
<SelectValue placeholder="Select parent category" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent className="bg-zinc-900 border-white/10 text-white">
|
||||||
<SelectItem value="none">No parent (root category)</SelectItem>
|
<SelectItem value="none" className="focus:bg-zinc-800">No parent (root category)</SelectItem>
|
||||||
{categories.map((cat) => (
|
{categories.map((cat) => (
|
||||||
<SelectItem key={cat._id} value={cat._id}>
|
<SelectItem key={cat._id} value={cat._id} className="focus:bg-zinc-800">
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleAddCategory} className="w-full mt-2" size="lg">
|
<Button onClick={handleAddCategory} className="w-full mt-2 bg-indigo-600 hover:bg-indigo-700 text-white border-0" size="lg">
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Add Category
|
Add Category
|
||||||
</Button>
|
</Button>
|
||||||
@@ -437,14 +439,14 @@ export default function CategoriesPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Category List Card */}
|
{/* Category List Card */}
|
||||||
<Card className="lg:col-span-3 border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
<Card className="lg:col-span-3 border-none bg-transparent shadow-none">
|
||||||
<CardHeader className="bg-muted/20 border-b border-border/40 pb-4">
|
<CardHeader className="pl-0 pt-0 pb-4">
|
||||||
<CardTitle className="text-lg font-medium">Structure</CardTitle>
|
<CardTitle className="text-lg font-bold text-white">Structure</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription className="text-zinc-400">
|
||||||
Drag and drop to reorder categories
|
Drag and drop to reorder categories
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="p-0">
|
||||||
<DndProvider backend={HTML5Backend}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
<div className="space-y-2 min-h-[300px]">
|
<div className="space-y-2 min-h-[300px]">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ import {
|
|||||||
X,
|
X,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Calendar,
|
Calendar,
|
||||||
ShoppingBag
|
ShoppingBag,
|
||||||
|
Truck,
|
||||||
|
CheckCircle,
|
||||||
} 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";
|
||||||
@@ -60,6 +62,13 @@ import {
|
|||||||
export default function CustomerManagementPage() {
|
export default function CustomerManagementPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [customers, setCustomers] = useState<CustomerStats[]>([]);
|
const [customers, setCustomers] = useState<CustomerStats[]>([]);
|
||||||
|
// State for browser detection
|
||||||
|
const [isFirefox, setIsFirefox] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ua = navigator.userAgent.toLowerCase();
|
||||||
|
setIsFirefox(ua.includes("firefox") && !ua.includes("chrome"));
|
||||||
|
}, []);
|
||||||
const [filteredCustomers, setFilteredCustomers] = useState<CustomerStats[]>([]);
|
const [filteredCustomers, setFilteredCustomers] = useState<CustomerStats[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -336,13 +345,12 @@ export default function CustomerManagementPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<AnimatePresence mode="popLayout">
|
{isFirefox ? (
|
||||||
{filteredCustomers.map((customer, index) => (
|
filteredCustomers.map((customer, index) => (
|
||||||
<motion.tr
|
<motion.tr
|
||||||
key={customer.userId}
|
key={customer.userId}
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2, delay: index * 0.03 }}
|
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" : ""}`}
|
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)}
|
||||||
@@ -409,8 +417,84 @@ export default function CustomerManagementPage() {
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</motion.tr>
|
</motion.tr>
|
||||||
))}
|
))
|
||||||
</AnimatePresence>
|
) : (
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{filteredCustomers.map((customer, index) => (
|
||||||
|
<motion.tr
|
||||||
|
key={customer.userId}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||||
|
className={`cursor-pointer group hover:bg-muted/50 border-b border-border/50 transition-colors ${!customer.hasOrders ? "bg-primary/5 hover:bg-primary/10" : ""}`}
|
||||||
|
onClick={() => setSelectedCustomer(customer)}
|
||||||
|
>
|
||||||
|
<TableCell className="py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`h-9 w-9 rounded-full flex items-center justify-center text-xs font-medium ${!customer.hasOrders ? 'bg-primary/20 text-primary' : 'bg-muted text-muted-foreground'
|
||||||
|
}`}>
|
||||||
|
{customer.telegramUsername ? customer.telegramUsername.substring(0, 2).toUpperCase() : 'ID'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium flex items-center gap-2">
|
||||||
|
@{customer.telegramUsername || "Unknown"}
|
||||||
|
{!customer.hasOrders && (
|
||||||
|
<Badge variant="outline" className="h-5 px-1.5 text-[10px] bg-primary/10 text-primary border-primary/20">
|
||||||
|
New
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground flex items-center font-mono mt-0.5">
|
||||||
|
<span className="opacity-50 select-none">ID:</span>
|
||||||
|
<span className="ml-1">{customer.telegramUserId}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge variant="secondary" className="font-mono font-normal">
|
||||||
|
{customer.totalOrders}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center font-mono text-sm">
|
||||||
|
{formatCurrency(customer.totalSpent)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-sm text-muted-foreground">
|
||||||
|
{customer.lastOrderDate ? (
|
||||||
|
<div className="flex items-center justify-center gap-1.5">
|
||||||
|
<Calendar className="h-3 w-3 opacity-70" />
|
||||||
|
{formatDate(customer.lastOrderDate).split(",")[0]}
|
||||||
|
</div>
|
||||||
|
) : "Never"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{customer.hasOrders ? (
|
||||||
|
<div className="flex justify-center flex-wrap gap-1">
|
||||||
|
{customer.ordersByStatus.paid > 0 && (
|
||||||
|
<Badge className="bg-blue-500/15 text-blue-500 hover:bg-blue-500/25 border-blue-500/20 h-5 px-1.5 text-[10px]">
|
||||||
|
{customer.ordersByStatus.paid} Paid
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{customer.ordersByStatus.completed > 0 && (
|
||||||
|
<Badge className="bg-emerald-500/15 text-emerald-500 hover:bg-emerald-500/25 border-emerald-500/20 h-5 px-1.5 text-[10px]">
|
||||||
|
{customer.ordersByStatus.completed} Done
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{customer.ordersByStatus.shipped > 0 && (
|
||||||
|
<Badge className="bg-amber-500/15 text-amber-500 hover:bg-amber-500/25 border-amber-500/20 h-5 px-1.5 text-[10px]">
|
||||||
|
{customer.ordersByStatus.shipped} Ship
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground italic">No activity</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</motion.tr>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
@@ -469,117 +553,166 @@ export default function CustomerManagementPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Customer Details Dialog */}
|
{/* Customer Details Dialog */}
|
||||||
{selectedCustomer && (
|
<AnimatePresence>
|
||||||
<Dialog open={!!selectedCustomer} onOpenChange={(open) => !open && setSelectedCustomer(null)}>
|
{selectedCustomer && (
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-2xl w-full overflow-y-auto max-h-[90vh] z-[80]">
|
<Dialog open={!!selectedCustomer} onOpenChange={(open) => !open && setSelectedCustomer(null)}>
|
||||||
<DialogHeader>
|
<DialogContent className="max-w-[95vw] sm:max-w-2xl w-full overflow-y-auto max-h-[90vh] z-[80]">
|
||||||
<DialogTitle className="text-base">
|
<DialogHeader>
|
||||||
Customer Details
|
<DialogTitle className="text-lg flex items-center gap-3">
|
||||||
</DialogTitle>
|
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-lg">
|
||||||
</DialogHeader>
|
{selectedCustomer.telegramUsername ? selectedCustomer.telegramUsername.substring(0, 1).toUpperCase() : 'U'}
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 py-4">
|
<div>
|
||||||
{/* Customer Information */}
|
<div className="font-bold">Customer Details</div>
|
||||||
<div>
|
<div className="text-sm font-normal text-muted-foreground flex items-center gap-2">
|
||||||
<div className="mb-4">
|
@{selectedCustomer.telegramUsername || "Unknown"}
|
||||||
<h3 className="text-sm font-medium mb-2">Customer Information</h3>
|
<span className="w-1 h-1 rounded-full bg-primary" />
|
||||||
<div className="space-y-3">
|
<span className="font-mono text-xs opacity-70">ID: {selectedCustomer.telegramUserId}</span>
|
||||||
<div className="flex justify-between items-center text-sm">
|
|
||||||
<div className="text-muted-foreground">Username:</div>
|
|
||||||
<div className="font-medium">@{selectedCustomer.telegramUsername || "Unknown"}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center text-sm">
|
|
||||||
<div className="text-muted-foreground">Telegram ID:</div>
|
|
||||||
<div className="font-medium">{selectedCustomer.telegramUserId}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center text-sm">
|
|
||||||
<div className="text-muted-foreground">Chat ID:</div>
|
|
||||||
<div className="font-medium">{selectedCustomer.chatId}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Customer Information */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider pl-1">Contact Info</h3>
|
||||||
|
<div className="rounded-xl border border-border p-4 space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center text-sm group">
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4 opacity-50" />
|
||||||
|
Username
|
||||||
|
</div>
|
||||||
|
<div className="font-medium group-hover:text-primary transition-colors">@{selectedCustomer.telegramUsername || "Unknown"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center text-sm group">
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2">
|
||||||
|
<CreditCard className="h-4 w-4 opacity-50" />
|
||||||
|
User ID
|
||||||
|
</div>
|
||||||
|
<div className="font-medium font-mono">{selectedCustomer.telegramUserId}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center text-sm group">
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2">
|
||||||
|
<MessageCircle className="h-4 w-4 opacity-50" />
|
||||||
|
Chat ID
|
||||||
|
</div>
|
||||||
|
<div className="font-medium font-mono">{selectedCustomer.chatId}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
window.open(`https://t.me/${selectedCustomer.telegramUsername || selectedCustomer.telegramUserId}`, '_blank');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-4 w-4 mr-2" />
|
||||||
|
Open Telegram Chat
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Order Statistics */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider pl-1">Lifetime Stats</h3>
|
||||||
|
<div className="rounded-xl border border-border p-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="bg-emerald-500/10 rounded-lg p-3 border border-emerald-500/20 min-w-0">
|
||||||
|
<div className="text-xs text-emerald-400/70 uppercase font-medium mb-1 whitespace-nowrap">Total Spent</div>
|
||||||
|
<div className="text-xl font-bold text-emerald-400 truncate">{formatCurrency(selectedCustomer.totalSpent)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-500/10 rounded-lg p-3 border border-blue-500/20 min-w-0">
|
||||||
|
<div className="text-xs text-blue-400/70 uppercase font-medium mb-1 whitespace-nowrap">Total Orders</div>
|
||||||
|
<div className="text-xl font-bold text-blue-400">{selectedCustomer.totalOrders}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 pt-2 border-t border-white/5">
|
||||||
|
<div className="flex justify-between items-center text-sm">
|
||||||
|
<div className="text-muted-foreground text-xs">First Order</div>
|
||||||
|
<div className="font-medium text-white/70 text-xs bg-white/5 px-2 py-1 rounded">
|
||||||
|
{formatDate(selectedCustomer.firstOrderDate)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center text-sm">
|
||||||
|
<div className="text-muted-foreground text-xs">Last Activity</div>
|
||||||
|
<div className="font-medium text-white/70 text-xs bg-white/5 px-2 py-1 rounded">
|
||||||
|
{formatDate(selectedCustomer.lastOrderDate)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Order Status Breakdown */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider pl-1">Order History Breakdown</h3>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div className="bg-blue-500/5 hover:bg-blue-500/10 transition-colors rounded-xl border border-blue-500/20 p-4 text-center group">
|
||||||
|
<ShoppingBag className="h-5 w-5 text-blue-500 mx-auto mb-2 opacity-70 group-hover:opacity-100 transition-opacity" />
|
||||||
|
<p className="text-2xl font-bold text-white mb-1">{selectedCustomer.ordersByStatus.paid}</p>
|
||||||
|
<p className="text-xs font-medium text-blue-400/70 uppercase">Paid</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-purple-500/5 hover:bg-purple-500/10 transition-colors rounded-xl border border-purple-500/20 p-4 text-center group">
|
||||||
|
<Loader2 className="h-5 w-5 text-purple-500 mx-auto mb-2 opacity-70 group-hover:opacity-100 transition-opacity" />
|
||||||
|
<p className="text-2xl font-bold text-white mb-1">{selectedCustomer.ordersByStatus.acknowledged}</p>
|
||||||
|
<p className="text-xs font-medium text-purple-400/70 uppercase">Processing</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-500/5 hover:bg-amber-500/10 transition-colors rounded-xl border border-amber-500/20 p-4 text-center group">
|
||||||
|
<Truck className="h-5 w-5 text-amber-500 mx-auto mb-2 opacity-70 group-hover:opacity-100 transition-opacity" />
|
||||||
|
<p className="text-2xl font-bold text-white mb-1">{selectedCustomer.ordersByStatus.shipped}</p>
|
||||||
|
<p className="text-xs font-medium text-amber-400/70 uppercase">Shipped</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-emerald-500/5 hover:bg-emerald-500/10 transition-colors rounded-xl border border-emerald-500/20 p-4 text-center group">
|
||||||
|
<CheckCircle className="h-5 w-5 text-emerald-500 mx-auto mb-2 opacity-70 group-hover:opacity-100 transition-opacity" />
|
||||||
|
<p className="text-2xl font-bold text-white mb-1">{selectedCustomer.ordersByStatus.completed}</p>
|
||||||
|
<p className="text-xs font-medium text-emerald-400/70 uppercase">Completed</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="pt-4 border-t">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
onClick={() => setSelectedCustomer(null)}
|
||||||
className="w-full"
|
className=""
|
||||||
onClick={() => {
|
>
|
||||||
window.open(`https://t.me/${selectedCustomer.telegramUsername || selectedCustomer.telegramUserId}`, '_blank');
|
Close Profile
|
||||||
}}
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => router.push(`/dashboard/storefront/chat/new?userId=${selectedCustomer.telegramUserId}`)}
|
||||||
|
className=""
|
||||||
>
|
>
|
||||||
<MessageCircle className="h-4 w-4 mr-2" />
|
<MessageCircle className="h-4 w-4 mr-2" />
|
||||||
Open Telegram Chat
|
Message Customer
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
{/* Order Statistics */}
|
</Dialog>
|
||||||
<div>
|
)}
|
||||||
<h3 className="text-sm font-medium mb-2">Order Statistics</h3>
|
</AnimatePresence>
|
||||||
<div className="bg-background rounded-lg border border-border p-4 space-y-3">
|
|
||||||
<div className="flex justify-between items-center text-sm">
|
|
||||||
<div className="text-muted-foreground">Total Orders:</div>
|
|
||||||
<div className="font-medium">{selectedCustomer.totalOrders}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center text-sm">
|
|
||||||
<div className="text-muted-foreground">Total Spent:</div>
|
|
||||||
<div className="font-medium">{formatCurrency(selectedCustomer.totalSpent)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center text-sm">
|
|
||||||
<div className="text-muted-foreground">First Order:</div>
|
|
||||||
<div className="font-medium">
|
|
||||||
{formatDate(selectedCustomer.firstOrderDate)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center text-sm">
|
|
||||||
<div className="text-muted-foreground">Last Order:</div>
|
|
||||||
<div className="font-medium">
|
|
||||||
{formatDate(selectedCustomer.lastOrderDate)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Order Status Breakdown */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<h3 className="text-sm font-medium mb-2">Order Status Breakdown</h3>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
|
||||||
<div className="bg-blue-500/10 rounded-lg border border-blue-500/20 p-3">
|
|
||||||
<p className="text-sm text-muted-foreground">Paid</p>
|
|
||||||
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.paid}</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-purple-500/10 rounded-lg border border-purple-500/20 p-3">
|
|
||||||
<p className="text-sm text-muted-foreground">Acknowledged</p>
|
|
||||||
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.acknowledged}</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-amber-500/10 rounded-lg border border-amber-500/20 p-3">
|
|
||||||
<p className="text-sm text-muted-foreground">Shipped</p>
|
|
||||||
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.shipped}</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-green-500/10 rounded-lg border border-green-500/20 p-3">
|
|
||||||
<p className="text-sm text-muted-foreground">Completed</p>
|
|
||||||
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.completed}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setSelectedCustomer(null)}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => router.push(`/dashboard/storefront/chat/new?userId=${selectedCustomer.telegramUserId}`)}
|
|
||||||
>
|
|
||||||
<MessageCircle className="h-4 w-4 mr-2" />
|
|
||||||
Start Chat
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
99
app/page.tsx
99
app/page.tsx
@@ -1,77 +1,43 @@
|
|||||||
import { getPlatformStatsServer } from "@/lib/server-api";
|
import { getPlatformStatsServer } from "@/lib/server-api";
|
||||||
import { HomeNavbar } from "@/components/home-navbar";
|
import { HomeNavbar } from "@/components/home-navbar";
|
||||||
import { Suspense } from "react";
|
import { Shield, LineChart, Zap, ArrowRight, Sparkles } from "lucide-react";
|
||||||
import { Shield, LineChart, Zap, ArrowRight, CheckCircle2, Sparkles } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AnimatedStatsSection } from "@/components/animated-stats-section";
|
import { AnimatedStatsSection } from "@/components/animated-stats-section";
|
||||||
import { isDecember } from "@/lib/utils/christmas";
|
|
||||||
import { MotionWrapper } from "@/components/ui/motion-wrapper";
|
import { MotionWrapper } from "@/components/ui/motion-wrapper";
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
const PY_20 = 20;
|
|
||||||
const PY_32 = 32;
|
|
||||||
const PX_6 = 6;
|
|
||||||
const PX_10 = 10;
|
|
||||||
|
|
||||||
function formatNumberValue(num: number): string {
|
|
||||||
return new Intl.NumberFormat().format(Math.round(num));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format currency
|
|
||||||
function formatCurrencyValue(amount: number): string {
|
|
||||||
return new Intl.NumberFormat('en-GB', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'GBP',
|
|
||||||
maximumFractionDigits: 0
|
|
||||||
}).format(amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is a server component
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
try {
|
try {
|
||||||
const stats = await getPlatformStatsServer();
|
const stats = await getPlatformStatsServer();
|
||||||
const isDec = isDecember();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col min-h-screen bg-black text-white ${isDec ? 'christmas-theme' : ''}`}>
|
<div className="relative flex flex-col min-h-screen bg-black text-white">
|
||||||
<div className={`absolute inset-0 bg-gradient-to-br pointer-events-none scale-100 ${isDec
|
<div className="absolute inset-0 bg-gradient-to-br from-indigo-500/10 via-purple-500/5 to-transparent pointer-events-none scale-100" />
|
||||||
? 'from-red-500/10 via-green-500/5 to-transparent'
|
|
||||||
: 'from-[#D53F8C]/10 via-[#D53F8C]/3 to-transparent'
|
|
||||||
}`} />
|
|
||||||
<HomeNavbar />
|
<HomeNavbar />
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative overflow-hidden">
|
<section className="relative overflow-hidden">
|
||||||
<div className="relative flex flex-col items-center px-4 py-20 md:py-32 mx-auto max-w-7xl">
|
<div className="relative flex flex-col items-center px-4 py-20 md:py-32 mx-auto max-w-7xl">
|
||||||
<div className="flex flex-col items-center text-center space-y-6 max-w-3xl">
|
<div className="flex flex-col items-center text-center space-y-6 max-w-3xl">
|
||||||
<div className={`inline-flex items-center px-4 py-2 rounded-full border mb-4 ${isDec
|
<div className="inline-flex items-center px-4 py-2 rounded-full border border-indigo-500/20 bg-indigo-500/10 mb-4">
|
||||||
? 'bg-red-500/10 border-red-500/20'
|
<Sparkles className="h-4 w-4 mr-2 text-indigo-400" />
|
||||||
: 'bg-[#D53F8C]/10 border-[#D53F8C]/20'
|
<span className="text-sm font-medium text-indigo-400">
|
||||||
}`}>
|
|
||||||
<Sparkles className={`h-4 w-4 mr-2 ${isDec ? 'text-red-400' : 'text-[#D53F8C]'}`} />
|
|
||||||
<span className={`text-sm font-medium ${isDec ? 'text-red-400' : 'text-[#D53F8C]'}`}>
|
|
||||||
Secure Crypto Payments
|
Secure Crypto Payments
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl md:text-6xl font-bold tracking-tight">
|
<h1 className="text-4xl md:text-6xl font-bold tracking-tight">
|
||||||
The Future of <span className={isDec ? 'text-red-400' : 'text-[#D53F8C]'}>E-commerce</span> Management
|
The Future of <span className="text-indigo-500">E-commerce</span> Management
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg md:text-xl text-zinc-400 max-w-2xl">
|
<p className="text-lg md:text-xl text-zinc-400 max-w-2xl">
|
||||||
{isDec
|
Streamline your online business with our all-in-one platform. Secure payments, order tracking, and analytics in one place.
|
||||||
? 'Spread joy this holiday season with our all-in-one platform. Secure payments, order tracking, and analytics wrapped up in one beautiful package. 🎄'
|
|
||||||
: 'Streamline your online business with our all-in-one platform. Secure payments, order tracking, and analytics in one place.'
|
|
||||||
}
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-4 mt-4">
|
<div className="flex flex-col sm:flex-row gap-4 mt-4">
|
||||||
<Link href="/dashboard">
|
<Link href="/dashboard">
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
className={`gap-2 text-white border-0 h-12 px-8 ${isDec
|
className="gap-2 text-white border-0 h-12 px-8 bg-indigo-600 hover:bg-indigo-700"
|
||||||
? 'bg-gradient-to-r from-red-500 to-green-500 hover:from-red-600 hover:to-green-600'
|
|
||||||
: 'bg-[#D53F8C] hover:bg-[#B83280]'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
Get Started
|
Get Started
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
@@ -103,35 +69,21 @@ export default async function Home() {
|
|||||||
title: "Lightning Fast",
|
title: "Lightning Fast",
|
||||||
description: "Optimized for speed with real-time updates and instant notifications."
|
description: "Optimized for speed with real-time updates and instant notifications."
|
||||||
}
|
}
|
||||||
].map((feature, i) => {
|
].map((feature, i) => (
|
||||||
const christmasColors = ['from-red-500/5', 'from-green-500/5', 'from-yellow-500/5'];
|
<div
|
||||||
const christmasBorders = ['border-red-500/30', 'border-green-500/30', 'border-yellow-500/30'];
|
key={i}
|
||||||
const christmasIcons = ['text-red-400', 'text-green-400', 'text-yellow-400'];
|
className="group relative overflow-hidden rounded-xl bg-gradient-to-b from-zinc-800/30 to-transparent p-6 border border-zinc-800 transition-all duration-300 hover:scale-[1.02] hover:shadow-lg"
|
||||||
const christmasBgs = ['bg-red-500/10', 'bg-green-500/10', 'bg-yellow-500/10'];
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-indigo-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
return (
|
<div className="relative">
|
||||||
<div
|
<div className="h-12 w-12 flex items-center justify-center rounded-lg mb-4 bg-indigo-500/10">
|
||||||
key={i}
|
<feature.icon className="h-6 w-6 text-indigo-500" />
|
||||||
className={`group relative overflow-hidden rounded-xl bg-gradient-to-b p-6 border transition-all duration-300 hover:scale-[1.02] hover:shadow-lg ${isDec
|
|
||||||
? `from-zinc-800/30 to-transparent ${christmasBorders[i % 3]}`
|
|
||||||
: 'from-zinc-800/30 to-transparent border-zinc-800'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 bg-gradient-to-b to-transparent opacity-0 group-hover:opacity-100 transition-opacity ${isDec ? christmasColors[i % 3] : 'from-[#D53F8C]/5'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<div className="relative">
|
|
||||||
<div className={`h-12 w-12 flex items-center justify-center rounded-lg mb-4 ${isDec ? christmasBgs[i % 3] : 'bg-[#D53F8C]/10'
|
|
||||||
}`}>
|
|
||||||
<feature.icon className={`h-6 w-6 ${isDec ? christmasIcons[i % 3] : 'text-[#D53F8C]'}`} />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold text-white mb-2">{feature.title}</h3>
|
|
||||||
<p className="text-sm text-zinc-400">{feature.description}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">{feature.title}</h3>
|
||||||
|
<p className="text-sm text-zinc-400">{feature.description}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</MotionWrapper>
|
</MotionWrapper>
|
||||||
|
|
||||||
@@ -145,13 +97,6 @@ export default async function Home() {
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="relative py-12 px-4 mt-auto">
|
<footer className="relative py-12 px-4 mt-auto">
|
||||||
<div className="max-w-7xl mx-auto flex flex-col items-center">
|
<div className="max-w-7xl mx-auto flex flex-col items-center">
|
||||||
{isDec && (
|
|
||||||
<div className="flex items-center gap-2 mb-4 text-red-400 animate-twinkle">
|
|
||||||
<span className="text-xl">🎄</span>
|
|
||||||
<span className="text-sm font-medium">Happy Holidays from da ember team!</span>
|
|
||||||
<span className="text-xl">🎄</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="text-sm text-zinc-500">
|
<div className="text-sm text-zinc-500">
|
||||||
© {new Date().getFullYear()} Ember. All rights reserved.
|
© {new Date().getFullYear()} Ember. All rights reserved.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,47 +1,104 @@
|
|||||||
"use client";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { fetchClient } from "@/lib/api-client";
|
import { fetchClient } from "@/lib/api-client";
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Copy, Check, Ticket, Loader2, RefreshCw } from "lucide-react";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
export default function InviteVendorCard() {
|
export default function InviteVendorCard() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
|
||||||
const [code, setCode] = useState<string | null>(null);
|
const [code, setCode] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
async function handleInvite() {
|
async function handleInvite() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMessage(null);
|
|
||||||
setCode(null);
|
setCode(null);
|
||||||
|
setCopied(false);
|
||||||
try {
|
try {
|
||||||
const res = await fetchClient<{ code: string }>("/admin/invitations", { method: "POST" });
|
const res = await fetchClient<{ code: string }>("/admin/invitations", { method: "POST" });
|
||||||
setMessage("Invitation created");
|
|
||||||
setCode(res.code);
|
setCode(res.code);
|
||||||
|
toast({
|
||||||
|
title: "Invitation Created",
|
||||||
|
description: "New vendor invitation code generated successfully.",
|
||||||
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setMessage(e?.message || "Failed to send invitation");
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: e?.message || "Failed to generate invitation",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = () => {
|
||||||
|
if (!code) return;
|
||||||
|
navigator.clipboard.writeText(code);
|
||||||
|
setCopied(true);
|
||||||
|
toast({
|
||||||
|
title: "Copied",
|
||||||
|
description: "Invitation code copied to clipboard",
|
||||||
|
});
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border/60 bg-background p-4 h-full min-h-[200px]">
|
<Card className="h-full border-border/40 bg-background/50 backdrop-blur-sm shadow-sm flex flex-col">
|
||||||
<h2 className="font-medium">Invite Vendor</h2>
|
<CardHeader className="pb-3">
|
||||||
<p className="text-sm text-muted-foreground mt-1">Generate a new invitation code</p>
|
<div className="flex items-center justify-between">
|
||||||
<div className="mt-4 space-y-3">
|
<CardTitle className="text-base font-medium flex items-center gap-2">
|
||||||
<button
|
<Ticket className="h-4 w-4 text-primary" />
|
||||||
onClick={handleInvite}
|
Invite Vendor
|
||||||
className="inline-flex items-center rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground disabled:opacity-60"
|
</CardTitle>
|
||||||
disabled={loading}
|
</div>
|
||||||
>
|
<CardDescription>Generate a one-time invitation code.</CardDescription>
|
||||||
{loading ? "Generating..." : "Generate Invite Code"}
|
</CardHeader>
|
||||||
</button>
|
<CardContent className="flex-1 flex flex-col justify-center gap-4">
|
||||||
{message && <p className="text-xs text-muted-foreground">{message}</p>}
|
{code ? (
|
||||||
{code && (
|
<div className="space-y-3 animate-in fade-in zoom-in-95 duration-300">
|
||||||
<div className="text-sm">
|
<div className="p-3 rounded-md bg-muted/50 border border-border/50 text-center relative group">
|
||||||
Code: <span className="font-mono px-1.5 py-0.5 rounded bg-muted">{code}</span>
|
<span className="font-mono text-xl font-bold tracking-widest text-primary">{code}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-1 top-1 h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
>
|
||||||
|
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-center text-muted-foreground">
|
||||||
|
Share this code with the new vendor. It expires in 7 days.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-2 text-sm text-muted-foreground/80">
|
||||||
|
Click generate to create a new code.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
<CardFooter className="pt-0">
|
||||||
|
<Button
|
||||||
|
onClick={handleInvite}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-primary/90 hover:bg-primary shadow-sm"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{code ? <RefreshCw className="mr-2 h-4 w-4" /> : <Ticket className="mr-2 h-4 w-4" />}
|
||||||
|
{code ? "Generate Another" : "Generate Code"}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ export function AnimatedStatsSection({ stats }: AnimatedStatsProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto grid max-w-5xl grid-cols-1 gap-6 md:grid-cols-3">
|
<div className="mx-auto grid max-w-5xl grid-cols-1 gap-6 md:grid-cols-3">
|
||||||
<div className="group relative overflow-hidden flex flex-col items-center text-center rounded-xl bg-gradient-to-b from-zinc-900/50 to-black border border-[#1C1C1C] p-6">
|
<div className="group relative overflow-hidden flex flex-col items-center text-center rounded-xl bg-gradient-to-b from-zinc-900/50 to-black border border-[#1C1C1C] p-6">
|
||||||
<div className="absolute inset-0 bg-[#D53F8C]/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
<div className="absolute inset-0 bg-indigo-500/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
<div className="relative flex flex-col items-center">
|
<div className="relative flex flex-col items-center">
|
||||||
<Package className="h-6 w-6 text-[#D53F8C]" />
|
<Package className="h-6 w-6 text-indigo-500" />
|
||||||
<div className="mt-4 text-3xl font-bold text-white">
|
<div className="mt-4 text-3xl font-bold text-white">
|
||||||
<AnimatedCounter
|
<AnimatedCounter
|
||||||
value={stats.orders.completed}
|
value={stats.orders.completed}
|
||||||
@@ -40,9 +40,9 @@ export function AnimatedStatsSection({ stats }: AnimatedStatsProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="group relative overflow-hidden flex flex-col items-center text-center rounded-xl bg-gradient-to-b from-zinc-900/50 to-black border border-[#1C1C1C] p-6">
|
<div className="group relative overflow-hidden flex flex-col items-center text-center rounded-xl bg-gradient-to-b from-zinc-900/50 to-black border border-[#1C1C1C] p-6">
|
||||||
<div className="absolute inset-0 bg-[#D53F8C]/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
<div className="absolute inset-0 bg-indigo-500/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
<div className="relative flex flex-col items-center">
|
<div className="relative flex flex-col items-center">
|
||||||
<Users className="h-6 w-6 text-[#D53F8C]" />
|
<Users className="h-6 w-6 text-indigo-500" />
|
||||||
<div className="mt-4 text-3xl font-bold text-white">
|
<div className="mt-4 text-3xl font-bold text-white">
|
||||||
<AnimatedCounter
|
<AnimatedCounter
|
||||||
value={stats.vendors.total}
|
value={stats.vendors.total}
|
||||||
@@ -60,9 +60,9 @@ export function AnimatedStatsSection({ stats }: AnimatedStatsProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="group relative overflow-hidden flex flex-col items-center text-center rounded-xl bg-gradient-to-b from-zinc-900/50 to-black border border-[#1C1C1C] p-6">
|
<div className="group relative overflow-hidden flex flex-col items-center text-center rounded-xl bg-gradient-to-b from-zinc-900/50 to-black border border-[#1C1C1C] p-6">
|
||||||
<div className="absolute inset-0 bg-[#D53F8C]/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
<div className="absolute inset-0 bg-indigo-500/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
<div className="relative flex flex-col items-center">
|
<div className="relative flex flex-col items-center">
|
||||||
<CreditCard className="h-6 w-6 text-[#D53F8C]" />
|
<CreditCard className="h-6 w-6 text-indigo-500" />
|
||||||
<div className="mt-4 text-3xl font-bold text-white">
|
<div className="mt-4 text-3xl font-bold text-white">
|
||||||
<AnimatedCounter
|
<AnimatedCounter
|
||||||
value={stats.transactions.volume}
|
value={stats.transactions.volume}
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ export default function ChatTable() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<AnimatePresence mode="popLayout">
|
<AnimatePresence>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4} className="h-32 text-center">
|
<TableCell colSpan={4} className="h-32 text-center">
|
||||||
@@ -328,10 +328,10 @@ export default function ChatTable() {
|
|||||||
chats.map((chat, index) => (
|
chats.map((chat, index) => (
|
||||||
<motion.tr
|
<motion.tr
|
||||||
key={chat._id}
|
key={chat._id}
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="group cursor-pointer hover:bg-muted/30 transition-colors border-b border-border/50 last:border-0"
|
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
|
style={{ display: 'table-row' }} // Essential for table layout
|
||||||
@@ -469,8 +469,8 @@ export default function ChatTable() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import { useState, useEffect } from "react"
|
|||||||
import OrderStats from "./order-stats"
|
import OrderStats from "./order-stats"
|
||||||
import QuickActions from "./quick-actions"
|
import QuickActions from "./quick-actions"
|
||||||
import RecentActivity from "./recent-activity"
|
import RecentActivity from "./recent-activity"
|
||||||
|
import { WidgetSettings } from "./widget-settings"
|
||||||
|
import { WidgetSettingsModal } from "./widget-settings-modal"
|
||||||
|
import { DashboardEditor } from "./dashboard-editor"
|
||||||
|
import { DraggableWidget } from "./draggable-widget"
|
||||||
|
import RevenueWidget from "./revenue-widget"
|
||||||
|
import LowStockWidget from "./low-stock-widget"
|
||||||
|
import RecentCustomersWidget from "./recent-customers-widget"
|
||||||
|
import PendingChatsWidget from "./pending-chats-widget"
|
||||||
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"
|
||||||
@@ -16,6 +24,7 @@ import { Skeleton } from "@/components/ui/skeleton"
|
|||||||
import { clientFetch } from "@/lib/api"
|
import { clientFetch } from "@/lib/api"
|
||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { useWidgetLayout, WidgetConfig } from "@/hooks/useWidgetLayout"
|
||||||
|
|
||||||
interface ContentProps {
|
interface ContentProps {
|
||||||
username: string
|
username: string
|
||||||
@@ -25,7 +34,7 @@ interface ContentProps {
|
|||||||
interface TopProduct {
|
interface TopProduct {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
price: number;
|
price: number | number[];
|
||||||
image: string;
|
image: string;
|
||||||
count: number;
|
count: number;
|
||||||
revenue: number;
|
revenue: number;
|
||||||
@@ -37,8 +46,16 @@ export default function Content({ username, orderStats }: ContentProps) {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { widgets, toggleWidget, moveWidget, reorderWidgets, resetLayout, isWidgetVisible, updateWidgetSettings } = useWidgetLayout();
|
||||||
|
const [configuredWidget, setConfiguredWidget] = useState<WidgetConfig | null>(null);
|
||||||
|
|
||||||
const [randomQuote, setRandomQuote] = useState(getRandomQuote());
|
// Initialize with a default quote to match server-side rendering, then randomize on client
|
||||||
|
const [randomQuote, setRandomQuote] = useState({ text: "Loading wisdom...", author: "..." });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Determine quote on client-side to avoid hydration mismatch
|
||||||
|
setRandomQuote(getRandomQuote());
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchTopProducts = async () => {
|
const fetchTopProducts = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -53,15 +70,151 @@ export default function Content({ username, orderStats }: ContentProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRetry = () => {
|
||||||
|
fetchTopProducts();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderWidget = (widget: WidgetConfig) => {
|
||||||
|
switch (widget.id) {
|
||||||
|
case "quick-actions":
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Quick Actions</h2>
|
||||||
|
<QuickActions />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
case "overview":
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Overview</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{statsConfig.map((stat, index) => (
|
||||||
|
<OrderStats
|
||||||
|
key={stat.title}
|
||||||
|
title={stat.title}
|
||||||
|
value={orderStats[stat.key as keyof OrderStatsData]?.toLocaleString() || "0"}
|
||||||
|
icon={stat.icon}
|
||||||
|
index={index}
|
||||||
|
filterStatus={stat.filterStatus}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
case "recent-activity":
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Recent Activity</h2>
|
||||||
|
<RecentActivity />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
case "top-products":
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Top Performing Listings</h2>
|
||||||
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Top Performing Listings</CardTitle>
|
||||||
|
<CardDescription>Your products with the highest sales volume</CardDescription>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRetry}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<RefreshCcw className="h-3 w-3" />
|
||||||
|
<span>Retry</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-4">
|
||||||
|
<Skeleton className="h-14 w-14 rounded-xl" />
|
||||||
|
<div className="space-y-2 flex-1">
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
<Skeleton className="h-3 w-1/4" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<div className="text-muted-foreground mb-4">Failed to load product insights</div>
|
||||||
|
</div>
|
||||||
|
) : topProducts.length === 0 ? (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<ShoppingCart className="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-1">Begin your sales journey</h3>
|
||||||
|
<p className="text-muted-foreground text-sm max-w-xs mx-auto">
|
||||||
|
Your top performing listings will materialize here as you receive orders.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{topProducts.map((product, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={product.id}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.1 + index * 0.05 }}
|
||||||
|
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-14 w-14 bg-muted bg-cover bg-center rounded-xl border flex-shrink-0 flex items-center justify-center overflow-hidden group-hover:scale-105 transition-transform"
|
||||||
|
style={{
|
||||||
|
backgroundImage: product.image
|
||||||
|
? `url(/api/products/${product.id}/image)`
|
||||||
|
: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!product.image && (
|
||||||
|
<ShoppingCart className="h-6 w-6 text-muted-foreground/50" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow min-w-0">
|
||||||
|
<h4 className="font-semibold text-lg truncate group-hover:text-primary transition-colors">{product.name}</h4>
|
||||||
|
<div className="flex items-center gap-3 mt-0.5">
|
||||||
|
<span className="text-sm text-muted-foreground font-medium">£{(Number(Array.isArray(product.price) ? product.price[0] : product.price) || 0).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xl font-bold">{product.count}</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-medium uppercase tracking-tighter mb-1">Units Sold</div>
|
||||||
|
<div className="text-sm font-semibold text-primary">£{product.revenue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
case "revenue-chart":
|
||||||
|
return <RevenueWidget settings={widget.settings} />;
|
||||||
|
case "low-stock":
|
||||||
|
return <LowStockWidget settings={widget.settings} />;
|
||||||
|
case "recent-customers":
|
||||||
|
return <RecentCustomersWidget settings={widget.settings} />;
|
||||||
|
case "pending-chats":
|
||||||
|
return <PendingChatsWidget settings={widget.settings} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGreeting(getGreeting());
|
setGreeting(getGreeting());
|
||||||
fetchTopProducts();
|
fetchTopProducts();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRetry = () => {
|
|
||||||
fetchTopProducts();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10 pb-10">
|
<div className="space-y-10 pb-10">
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -73,131 +226,56 @@ export default function Content({ username, orderStats }: ContentProps) {
|
|||||||
<h1 className="text-4xl font-bold tracking-tight text-foreground">
|
<h1 className="text-4xl font-bold tracking-tight text-foreground">
|
||||||
{greeting}, <span className="text-primary">{username}</span>!
|
{greeting}, <span className="text-primary">{username}</span>!
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-2 max-w-2xl text-lg">
|
<p className="text-muted-foreground mt-2 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 className="flex items-center gap-2">
|
||||||
|
<WidgetSettings
|
||||||
|
widgets={widgets}
|
||||||
|
onToggle={toggleWidget}
|
||||||
|
onMove={moveWidget}
|
||||||
|
onReset={resetLayout}
|
||||||
|
onConfigure={(widget) => setConfiguredWidget(widget)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Quick ActionsSection */}
|
<DashboardEditor
|
||||||
<section className="space-y-4">
|
widgets={widgets}
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Quick Actions</h2>
|
isEditMode={false}
|
||||||
<QuickActions />
|
onToggleEditMode={() => { }}
|
||||||
</section>
|
onReorder={reorderWidgets}
|
||||||
|
onReset={resetLayout}
|
||||||
|
>
|
||||||
|
<div className="space-y-10">
|
||||||
|
{widgets.map((widget) => {
|
||||||
|
if (!widget.visible) return null;
|
||||||
|
|
||||||
{/* Order Statistics */}
|
return (
|
||||||
<section className="space-y-4">
|
<DraggableWidget
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Overview</h2>
|
key={widget.id}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
widget={widget}
|
||||||
{statsConfig.map((stat, index) => (
|
isEditMode={false}
|
||||||
<OrderStats
|
onConfigure={() => setConfiguredWidget(widget)}
|
||||||
key={stat.title}
|
onToggleVisibility={() => toggleWidget(widget.id)}
|
||||||
title={stat.title}
|
>
|
||||||
value={orderStats[stat.key as keyof OrderStatsData]?.toLocaleString() || "0"}
|
{renderWidget(widget)}
|
||||||
icon={stat.icon}
|
</DraggableWidget>
|
||||||
index={index}
|
);
|
||||||
/>
|
})}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</DashboardEditor>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
{/* Widget Settings Modal */}
|
||||||
{/* Recent Activity Section */}
|
<WidgetSettingsModal
|
||||||
<div className="xl:col-span-1">
|
widget={configuredWidget}
|
||||||
<RecentActivity />
|
open={!!configuredWidget}
|
||||||
</div>
|
onOpenChange={(open) => !open && setConfiguredWidget(null)}
|
||||||
|
onSave={(widgetId, settings) => {
|
||||||
{/* Best Selling Products Section */}
|
updateWidgetSettings(widgetId, settings);
|
||||||
<div className="xl:col-span-2">
|
}}
|
||||||
<Card className="h-full border-border/40 bg-background/50 backdrop-blur-sm">
|
/>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Top Performing Listings</CardTitle>
|
|
||||||
<CardDescription>Your products with the highest sales volume</CardDescription>
|
|
||||||
</div>
|
|
||||||
{error && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRetry}
|
|
||||||
className="flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<RefreshCcw className="h-3 w-3" />
|
|
||||||
<span>Retry</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{[...Array(5)].map((_, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-4">
|
|
||||||
<Skeleton className="h-14 w-14 rounded-xl" />
|
|
||||||
<div className="space-y-2 flex-1">
|
|
||||||
<Skeleton className="h-4 w-1/2" />
|
|
||||||
<Skeleton className="h-3 w-1/4" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-4 w-16" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="py-12 text-center">
|
|
||||||
<div className="text-muted-foreground mb-4">Failed to load product insights</div>
|
|
||||||
</div>
|
|
||||||
) : topProducts.length === 0 ? (
|
|
||||||
<div className="py-12 text-center">
|
|
||||||
<ShoppingCart className="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" />
|
|
||||||
<h3 className="text-lg font-medium mb-1">Begin your sales journey</h3>
|
|
||||||
<p className="text-muted-foreground text-sm max-w-xs mx-auto">
|
|
||||||
Your top performing listings will materialize here as you receive orders.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{topProducts.map((product, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={product.id}
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.1 + index * 0.05 }}
|
|
||||||
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="h-14 w-14 bg-muted bg-cover bg-center rounded-xl border flex-shrink-0 flex items-center justify-center overflow-hidden group-hover:scale-105 transition-transform"
|
|
||||||
style={{
|
|
||||||
backgroundImage: product.image
|
|
||||||
? `url(/api/products/${product.id}/image)`
|
|
||||||
: 'none'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!product.image && (
|
|
||||||
<ShoppingCart className="h-6 w-6 text-muted-foreground/50" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-grow min-w-0">
|
|
||||||
<h4 className="font-semibold text-lg truncate group-hover:text-primary transition-colors">{product.name}</h4>
|
|
||||||
<div className="flex items-center gap-3 mt-0.5">
|
|
||||||
<span className="text-sm text-muted-foreground font-medium">£{product.price.toFixed(2)}</span>
|
|
||||||
<span className="h-1 w-1 rounded-full bg-muted-foreground/30" />
|
|
||||||
<span className="text-xs text-muted-foreground">ID: {product.id.slice(-6)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-xl font-bold">{product.count}</div>
|
|
||||||
<div className="text-xs text-muted-foreground font-medium uppercase tracking-tighter">Units Sold</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
128
components/dashboard/dashboard-editor.tsx
Normal file
128
components/dashboard/dashboard-editor.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useState } from "react"
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
DragEndEvent,
|
||||||
|
DragOverlay,
|
||||||
|
DragStartEvent,
|
||||||
|
} from "@dnd-kit/core"
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
arrayMove,
|
||||||
|
} from "@dnd-kit/sortable"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Edit3, X, Check, RotateCcw } from "lucide-react"
|
||||||
|
import { WidgetConfig } from "@/hooks/useWidgetLayout"
|
||||||
|
import { motion, AnimatePresence } from "framer-motion"
|
||||||
|
|
||||||
|
interface DashboardEditorProps {
|
||||||
|
widgets: WidgetConfig[]
|
||||||
|
isEditMode: boolean
|
||||||
|
onToggleEditMode: () => void
|
||||||
|
onReorder: (activeId: string, overId: string) => void
|
||||||
|
onReset: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardEditor({
|
||||||
|
widgets,
|
||||||
|
isEditMode,
|
||||||
|
onToggleEditMode,
|
||||||
|
onReorder,
|
||||||
|
onReset,
|
||||||
|
children
|
||||||
|
}: DashboardEditorProps) {
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
|
setActiveId(event.active.id as string)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event
|
||||||
|
|
||||||
|
if (over && active.id !== over.id) {
|
||||||
|
onReorder(active.id as string, over.id as string)
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragCancel = () => {
|
||||||
|
setActiveId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={widgets.map(w => w.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
|
{/* Edit Mode Banner */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isEditMode && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 50 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 50 }}
|
||||||
|
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 bg-primary text-primary-foreground px-4 py-2.5 rounded-full shadow-lg border border-primary-foreground/20">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Editing Dashboard • Drag widgets to reorder
|
||||||
|
</span>
|
||||||
|
<div className="h-4 w-px bg-primary-foreground/30" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 hover:bg-primary-foreground/20"
|
||||||
|
onClick={onReset}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-3"
|
||||||
|
onClick={onToggleEditMode}
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</DndContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
112
components/dashboard/draggable-widget.tsx
Normal file
112
components/dashboard/draggable-widget.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from "react"
|
||||||
|
import { useSortable } from "@dnd-kit/sortable"
|
||||||
|
import { CSS } from "@dnd-kit/utilities"
|
||||||
|
import { GripVertical, Settings, X, Eye, EyeOff } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils/styles"
|
||||||
|
import { WidgetConfig } from "@/hooks/useWidgetLayout"
|
||||||
|
|
||||||
|
interface DraggableWidgetProps {
|
||||||
|
widget: WidgetConfig
|
||||||
|
children: React.ReactNode
|
||||||
|
isEditMode: boolean
|
||||||
|
onRemove?: () => void
|
||||||
|
onConfigure?: () => void
|
||||||
|
onToggleVisibility?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DraggableWidget({
|
||||||
|
widget,
|
||||||
|
children,
|
||||||
|
isEditMode,
|
||||||
|
onRemove,
|
||||||
|
onConfigure,
|
||||||
|
onToggleVisibility
|
||||||
|
}: DraggableWidgetProps) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: widget.id })
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
zIndex: isDragging ? 1000 : 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
"relative group",
|
||||||
|
isEditMode && "ring-2 ring-primary ring-offset-2 ring-offset-background rounded-lg",
|
||||||
|
isDragging && "z-50 shadow-2xl"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isEditMode && (
|
||||||
|
<>
|
||||||
|
{/* Edit Mode Overlay */}
|
||||||
|
<div className="absolute inset-0 rounded-lg border-2 border-dashed border-primary/30 pointer-events-none z-10 group-hover:border-primary/60 transition-colors" />
|
||||||
|
|
||||||
|
{/* Drag Handle */}
|
||||||
|
<div
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="absolute top-2 left-2 z-20 cursor-grab active:cursor-grabbing bg-background/90 backdrop-blur-sm rounded-md p-1.5 shadow-md border border-border opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Widget Title Badge */}
|
||||||
|
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20 bg-primary text-primary-foreground text-xs font-medium px-2 py-1 rounded-full shadow-md opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{widget.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="absolute top-2 right-2 z-20 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{onConfigure && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shadow-md"
|
||||||
|
onClick={onConfigure}
|
||||||
|
>
|
||||||
|
<Settings className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onToggleVisibility && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shadow-md"
|
||||||
|
onClick={onToggleVisibility}
|
||||||
|
>
|
||||||
|
{widget.visible ? (
|
||||||
|
<EyeOff className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Widget Content */}
|
||||||
|
<div className={cn(
|
||||||
|
"h-full transition-transform",
|
||||||
|
isDragging && "scale-[1.02]"
|
||||||
|
)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
167
components/dashboard/low-stock-widget.tsx
Normal file
167
components/dashboard/low-stock-widget.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { AlertCircle, Package, ArrowRight, ShoppingCart } from "lucide-react"
|
||||||
|
import { clientFetch } from "@/lib/api"
|
||||||
|
import Image from "next/image"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
interface LowStockWidgetProps {
|
||||||
|
settings?: {
|
||||||
|
threshold?: number
|
||||||
|
itemCount?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LowStockProduct {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
currentStock: number
|
||||||
|
unitType: string
|
||||||
|
image?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LowStockWidget({ settings }: LowStockWidgetProps) {
|
||||||
|
const threshold = settings?.threshold || 5
|
||||||
|
const itemCount = settings?.itemCount || 5
|
||||||
|
const [products, setProducts] = useState<LowStockProduct[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchLowStock = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
// Implementation: We'll use the product-performance API and filter locally
|
||||||
|
// or a dedicated stock-report API if available.
|
||||||
|
// For now, let's use the product-performance endpoint which has stock info.
|
||||||
|
const response = await clientFetch('/analytics/product-performance')
|
||||||
|
|
||||||
|
const lowStockProducts = response
|
||||||
|
.filter((p: any) => p.currentStock <= threshold)
|
||||||
|
.sort((a: any, b: any) => a.currentStock - b.currentStock)
|
||||||
|
.slice(0, itemCount)
|
||||||
|
.map((p: any) => ({
|
||||||
|
id: p.productId,
|
||||||
|
name: p.name,
|
||||||
|
currentStock: p.currentStock,
|
||||||
|
unitType: p.unitType,
|
||||||
|
image: p.image
|
||||||
|
}))
|
||||||
|
|
||||||
|
setProducts(lowStockProducts)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching low stock data:", err)
|
||||||
|
setError("Failed to load inventory data")
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLowStock()
|
||||||
|
}, [threshold, itemCount])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<Skeleton className="h-5 w-32 mb-1" />
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-4">
|
||||||
|
<Skeleton className="h-12 w-12 rounded-lg" />
|
||||||
|
<div className="space-y-2 flex-1">
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="h-3 w-1/4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm overflow-hidden flex flex-col h-full">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-5 w-5 text-amber-500" />
|
||||||
|
Low Stock Alerts
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Inventory checks (Threshold: {threshold})
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Link href="/dashboard/stock">
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs">
|
||||||
|
Manage
|
||||||
|
<ArrowRight className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4 flex-grow">
|
||||||
|
{error ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||||
|
<Package className="h-10 w-10 text-muted-foreground/20 mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : products.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div className="h-12 w-12 rounded-full bg-green-500/10 flex items-center justify-center mb-4">
|
||||||
|
<Package className="h-6 w-6 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-medium">All systems go</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 max-w-xs">
|
||||||
|
No products currently under your threshold of {threshold} units.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{products.map((product) => (
|
||||||
|
<div
|
||||||
|
key={product.id}
|
||||||
|
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="h-12 w-12 relative rounded-lg border bg-muted overflow-hidden flex-shrink-0">
|
||||||
|
{product.image ? (
|
||||||
|
<Image
|
||||||
|
src={`/api/products/${product.id}/image`}
|
||||||
|
alt={product.name}
|
||||||
|
fill
|
||||||
|
className="object-cover group-hover:scale-110 transition-transform"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full flex items-center justify-center">
|
||||||
|
<ShoppingCart className="h-5 w-5 text-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow min-w-0">
|
||||||
|
<h4 className="font-semibold text-sm truncate">{product.name}</h4>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span className="text-[10px] uppercase font-mono tracking-wider text-muted-foreground bg-muted-foreground/10 px-1.5 py-0.5 rounded">
|
||||||
|
ID: {product.id.slice(-6)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className={`text-sm font-bold ${product.currentStock === 0 ? 'text-destructive' : 'text-amber-500'}`}>
|
||||||
|
{product.currentStock} {product.unitType}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground font-medium uppercase tracking-tighter">Remaining</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,36 +1,60 @@
|
|||||||
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"
|
import { motion } from "framer-motion"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
interface OrderStatsProps {
|
interface OrderStatsProps {
|
||||||
title: string
|
title: string
|
||||||
value: string
|
value: string
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
index?: number
|
index?: number
|
||||||
|
/** Status to filter by when clicking (e.g., "paid", "shipped") */
|
||||||
|
filterStatus?: string
|
||||||
|
/** Custom href if not using filterStatus */
|
||||||
|
href?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OrderStats({ title, value, icon: Icon, index = 0 }: OrderStatsProps) {
|
export default function OrderStats({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
index = 0,
|
||||||
|
filterStatus,
|
||||||
|
href
|
||||||
|
}: OrderStatsProps) {
|
||||||
|
const linkHref = href || (filterStatus ? `/dashboard/orders?status=${filterStatus}` : undefined)
|
||||||
|
|
||||||
|
const CardWrapper = linkHref ? Link : "div"
|
||||||
|
const wrapperProps = linkHref ? { href: linkHref } : {}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
transition={{ delay: index * 0.05 }}
|
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">
|
<CardWrapper {...wrapperProps as any}>
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
<Card className={`relative overflow-hidden group border-border/40 bg-background/50 backdrop-blur-sm hover:bg-background/80 transition-all duration-300 ${linkHref ? "cursor-pointer hover:border-primary/30" : ""}`}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
<CardTitle className="text-sm font-medium tracking-tight text-muted-foreground group-hover:text-foreground transition-colors">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||||
{title}
|
<CardTitle className="text-sm font-medium tracking-tight text-muted-foreground group-hover:text-foreground transition-colors">
|
||||||
</CardTitle>
|
{title}
|
||||||
<div className="p-2 rounded-lg bg-muted group-hover:bg-primary/10 group-hover:text-primary transition-all duration-300">
|
</CardTitle>
|
||||||
<Icon className="h-4 w-4" />
|
<div className="p-2 rounded-lg bg-muted group-hover:bg-primary/10 group-hover:text-primary transition-all duration-300">
|
||||||
</div>
|
<Icon className="h-4 w-4" />
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="relative z-10">
|
</CardHeader>
|
||||||
<div className="text-3xl font-bold tracking-tight">{value}</div>
|
<CardContent className="relative z-10">
|
||||||
<div className="mt-1 h-1 w-0 bg-primary/20 group-hover:w-full transition-all duration-500 rounded-full" />
|
<div className="text-3xl font-bold tracking-tight">{value}</div>
|
||||||
</CardContent>
|
<div className="mt-1 h-1 w-0 bg-primary/20 group-hover:w-full transition-all duration-500 rounded-full" />
|
||||||
</Card>
|
{linkHref && (
|
||||||
|
<div className="mt-2 text-xs text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
Click to view →
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</CardWrapper>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
172
components/dashboard/pending-chats-widget.tsx
Normal file
172
components/dashboard/pending-chats-widget.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { MessageSquare, MessageCircle, ArrowRight, Clock } from "lucide-react"
|
||||||
|
import { clientFetch, getCookie } from "@/lib/api"
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { RelativeTime } from "@/components/ui/relative-time"
|
||||||
|
|
||||||
|
interface PendingChatsWidgetProps {
|
||||||
|
settings?: {
|
||||||
|
showPreview?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Chat {
|
||||||
|
id: string
|
||||||
|
buyerId: string
|
||||||
|
telegramUsername?: string
|
||||||
|
lastUpdated: string
|
||||||
|
unreadCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PendingChatsWidget({ settings }: PendingChatsWidgetProps) {
|
||||||
|
const showPreview = settings?.showPreview !== false
|
||||||
|
const [chats, setChats] = useState<Chat[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const getVendorIdFromToken = () => {
|
||||||
|
const authToken = getCookie("Authorization") || ""
|
||||||
|
if (!authToken) return null
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(authToken.split(".")[1]))
|
||||||
|
return payload.id
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchChats = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const vendorId = getVendorIdFromToken()
|
||||||
|
if (!vendorId) {
|
||||||
|
setError("Please login to view chats")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await clientFetch(`/chats/vendor/${vendorId}/batch?page=1&limit=5`)
|
||||||
|
|
||||||
|
const chatCounts = response.unreadCounts?.chatCounts || {}
|
||||||
|
const pendingChats = (response.chats || [])
|
||||||
|
.filter((c: any) => chatCounts[c._id] > 0)
|
||||||
|
.map((c: any) => ({
|
||||||
|
id: c._id,
|
||||||
|
buyerId: c.buyerId,
|
||||||
|
telegramUsername: c.telegramUsername,
|
||||||
|
lastUpdated: c.lastUpdated,
|
||||||
|
unreadCount: chatCounts[c._id] || 0
|
||||||
|
}))
|
||||||
|
|
||||||
|
setChats(pendingChats)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching chats:", err)
|
||||||
|
setError("Failed to load chats")
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchChats()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<Skeleton className="h-5 w-32 mb-1" />
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-4">
|
||||||
|
<Skeleton className="h-10 w-10 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>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm overflow-hidden flex flex-col h-full">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-5 w-5 text-emerald-500" />
|
||||||
|
Pending Chats
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Unanswered customer messages
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Link href="/dashboard/chats">
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs">
|
||||||
|
Inbox
|
||||||
|
<ArrowRight className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4 flex-grow">
|
||||||
|
{error ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||||
|
<MessageCircle className="h-10 w-10 text-muted-foreground/20 mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : chats.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div className="h-12 w-12 rounded-full bg-emerald-500/10 flex items-center justify-center mb-4">
|
||||||
|
<MessageCircle className="h-6 w-6 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-medium">All caught up!</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 max-w-xs">
|
||||||
|
No pending customer chats that require your attention.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{chats.map((chat) => (
|
||||||
|
<Link
|
||||||
|
key={chat.id}
|
||||||
|
href={`/dashboard/chats/${chat.id}`}
|
||||||
|
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Avatar className="h-10 w-10 border shadow-sm group-hover:scale-105 transition-transform">
|
||||||
|
<AvatarFallback className="bg-emerald-500/10 text-emerald-600 text-xs font-bold">
|
||||||
|
{(chat.telegramUsername || chat.buyerId).slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 h-3 w-3 bg-emerald-500 rounded-full ring-2 ring-background border border-background shadow-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-grow min-w-0">
|
||||||
|
<h4 className="font-semibold text-sm truncate group-hover:text-primary transition-colors">
|
||||||
|
{chat.telegramUsername ? `@${chat.telegramUsername}` : `Customer ${chat.buyerId.slice(-6)}`}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
<RelativeTime date={new Date(chat.lastUpdated)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-emerald-500 text-emerald-foreground text-[10px] font-bold px-1.5 py-0.5 rounded-full shadow-sm ring-2 ring-emerald-500/10">
|
||||||
|
{chat.unreadCount}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,26 +1,33 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, ChangeEvent } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
import {
|
import {
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
Package,
|
|
||||||
BarChart3,
|
|
||||||
Settings,
|
|
||||||
MessageSquare,
|
|
||||||
Truck,
|
Truck,
|
||||||
Tag,
|
BarChart3,
|
||||||
Users
|
MessageSquare,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import dynamic from "next/dynamic"
|
||||||
|
import { Product } from "@/models/products"
|
||||||
|
import { Category } from "@/models/categories"
|
||||||
|
import { clientFetch } from "@/lib/api"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
const ProductModal = dynamic(() => import("@/components/modals/product-modal").then(mod => ({ default: mod.ProductModal })), {
|
||||||
|
loading: () => null
|
||||||
|
});
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
{
|
{
|
||||||
title: "Add Product",
|
title: "Add Product",
|
||||||
icon: PlusCircle,
|
icon: PlusCircle,
|
||||||
href: "/dashboard/products/new",
|
href: "/dashboard/products/new", // Fallback text
|
||||||
color: "bg-blue-500/10 text-blue-500",
|
color: "bg-blue-500/10 text-blue-500",
|
||||||
description: "Create a new listing"
|
description: "Create a new listing",
|
||||||
|
action: "modal"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Process Orders",
|
title: "Process Orders",
|
||||||
@@ -46,30 +53,167 @@ const actions = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function QuickActions() {
|
export default function QuickActions() {
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [productData, setProductData] = useState<Product>({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
unitType: "pcs",
|
||||||
|
category: "",
|
||||||
|
pricing: [{ minQuantity: 1, pricePerUnit: 0 }],
|
||||||
|
image: null,
|
||||||
|
costPerUnit: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch categories on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
try {
|
||||||
|
const data = await clientFetch('/categories');
|
||||||
|
setCategories(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch categories:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchCategories();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
setProductData({ ...productData, [e.target.name]: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTieredPricingChange = (e: ChangeEvent<HTMLInputElement>, index: number) => {
|
||||||
|
const updatedPricing = [...productData.pricing];
|
||||||
|
const name = e.target.name as "minQuantity" | "pricePerUnit";
|
||||||
|
updatedPricing[index][name] = e.target.valueAsNumber || 0;
|
||||||
|
setProductData({ ...productData, pricing: updatedPricing });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddTier = () => {
|
||||||
|
setProductData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pricing: [...prev.pricing, { minQuantity: 1, pricePerUnit: 0 }],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveTier = (index: number) => {
|
||||||
|
setProductData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pricing: prev.pricing.filter((_, i) => i !== index),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveProduct = async (data: Product, file?: File | null) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Prepare the product data
|
||||||
|
const payload = {
|
||||||
|
...data,
|
||||||
|
stockTracking: data.stockTracking ?? true,
|
||||||
|
currentStock: data.currentStock ?? 0,
|
||||||
|
lowStockThreshold: data.lowStockThreshold ?? 10,
|
||||||
|
stockStatus: data.stockStatus ?? 'out_of_stock'
|
||||||
|
};
|
||||||
|
|
||||||
|
const productResponse = await clientFetch("/products", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/products/${productResponse._id}/image`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${document.cookie.split("; ").find((row) => row.startsWith("Authorization="))?.split("=")[1]}`,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setModalOpen(false);
|
||||||
|
setProductData({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
unitType: "pcs",
|
||||||
|
category: "",
|
||||||
|
pricing: [{ minQuantity: 1, pricePerUnit: 0 }],
|
||||||
|
image: null,
|
||||||
|
costPerUnit: 0,
|
||||||
|
});
|
||||||
|
toast.success("Product added successfully");
|
||||||
|
|
||||||
|
// Optional: trigger a refresh of products or stats if needed
|
||||||
|
// currently just closing modal
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Failed to save product");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<>
|
||||||
{actions.map((action, index) => (
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<motion.div
|
{actions.map((action, index) => {
|
||||||
key={action.title}
|
const isModalAction = action.action === "modal";
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
const CardContentWrapper = () => (
|
||||||
transition={{ delay: index * 0.1 }}
|
<Card className="h-full border-none bg-black/40 backdrop-blur-xl hover:bg-black/60 transition-all duration-300 group overflow-hidden relative">
|
||||||
whileHover={{ scale: 1.02 }}
|
<div className="absolute inset-0 border border-white/10 rounded-xl pointer-events-none group-hover:border-white/20 transition-colors" />
|
||||||
whileTap={{ scale: 0.98 }}
|
<div className={`absolute inset-0 bg-gradient-to-br ${action.color.split(' ')[0].replace('/10', '/5')} opacity-0 group-hover:opacity-100 transition-opacity duration-500`} />
|
||||||
>
|
|
||||||
<Link href={action.href}>
|
<CardContent className="p-6 flex flex-col items-center text-center relative z-10">
|
||||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer group h-full">
|
<div className={`p-4 rounded-2xl ${action.color.replace('bg-', 'bg-opacity-10 bg-')} mb-4 group-hover:scale-110 transition-transform duration-300 shadow-lg shadow-black/20`}>
|
||||||
<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" />
|
<action.icon className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-semibold text-lg">{action.title}</h3>
|
<h3 className="font-bold text-lg text-white group-hover:text-primary transition-colors">{action.title}</h3>
|
||||||
<p className="text-sm text-muted-foreground mt-1">{action.description}</p>
|
<p className="text-sm text-zinc-400 mt-1">{action.description}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
);
|
||||||
</motion.div>
|
|
||||||
))}
|
return (
|
||||||
</div>
|
<motion.div
|
||||||
|
key={action.title}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.1 }}
|
||||||
|
whileHover={{ y: -5 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
{isModalAction ? (
|
||||||
|
<div onClick={() => setModalOpen(true)} className="cursor-pointer h-full">
|
||||||
|
<CardContentWrapper />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link href={action.href} className="h-full block">
|
||||||
|
<CardContentWrapper />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProductModal
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
onSave={handleSaveProduct}
|
||||||
|
productData={productData}
|
||||||
|
categories={categories}
|
||||||
|
editing={false}
|
||||||
|
handleChange={handleChange}
|
||||||
|
handleTieredPricingChange={handleTieredPricingChange}
|
||||||
|
handleAddTier={handleAddTier}
|
||||||
|
handleRemoveTier={handleRemoveTier}
|
||||||
|
setProductData={setProductData}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ShoppingBag, CreditCard, Truck, MessageSquare, AlertCircle } from "luci
|
|||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
import { clientFetch } from "@/lib/api"
|
import { clientFetch } from "@/lib/api"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { formatDistanceToNow } from "date-fns"
|
import { RelativeTime } from "@/components/ui/relative-time"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
||||||
interface ActivityItem {
|
interface ActivityItem {
|
||||||
@@ -25,7 +25,7 @@ export default function RecentActivity() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchRecentOrders() {
|
async function fetchRecentOrders() {
|
||||||
try {
|
try {
|
||||||
const data = await clientFetch("/orders?limit=5&sortBy=orderDate&sortOrder=desc");
|
const data = await clientFetch("/orders?limit=10&sortBy=orderDate&sortOrder=desc");
|
||||||
setActivities(data.orders || []);
|
setActivities(data.orders || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch recent activity:", error);
|
console.error("Failed to fetch recent activity:", error);
|
||||||
@@ -100,7 +100,7 @@ export default function RecentActivity() {
|
|||||||
Order #{item.orderId}
|
Order #{item.orderId}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{formatDistanceToNow(new Date(item.orderDate), { addSuffix: true })}
|
<RelativeTime date={item.orderDate} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
154
components/dashboard/recent-customers-widget.tsx
Normal file
154
components/dashboard/recent-customers-widget.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Users, User, ArrowRight, DollarSign } from "lucide-react"
|
||||||
|
import { getCustomerInsightsWithStore, formatGBP } from "@/lib/api"
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
interface RecentCustomersWidgetProps {
|
||||||
|
settings?: {
|
||||||
|
itemCount?: number
|
||||||
|
showSpent?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Customer {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
username?: string
|
||||||
|
orderCount: number
|
||||||
|
totalSpent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecentCustomersWidget({ settings }: RecentCustomersWidgetProps) {
|
||||||
|
const itemCount = settings?.itemCount || 5
|
||||||
|
const showSpent = settings?.showSpent !== false
|
||||||
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchCustomers = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
// The API returns topCustomers, but we'll use 'recent' sorting to show new engagement
|
||||||
|
const response = await getCustomerInsightsWithStore(1, itemCount, "recent")
|
||||||
|
|
||||||
|
const mappedCustomers = (response.topCustomers || []).map((c: any) => ({
|
||||||
|
id: c._id,
|
||||||
|
name: c.displayName || c.username || `Customer ${c._id.slice(-4)}`,
|
||||||
|
username: c.username,
|
||||||
|
orderCount: c.orderCount || 0,
|
||||||
|
totalSpent: c.totalSpent || 0
|
||||||
|
}))
|
||||||
|
|
||||||
|
setCustomers(mappedCustomers)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching customers:", err)
|
||||||
|
setError("Failed to load customer data")
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCustomers()
|
||||||
|
}, [itemCount])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<Skeleton className="h-5 w-32 mb-1" />
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-4">
|
||||||
|
<Skeleton className="h-10 w-10 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>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm overflow-hidden flex flex-col h-full">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5 text-indigo-500" />
|
||||||
|
Recent Customers
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Latest and newest connections
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Link href="/dashboard/customers">
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs">
|
||||||
|
View All
|
||||||
|
<ArrowRight className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-4 flex-grow">
|
||||||
|
{error ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||||
|
<User className="h-10 w-10 text-muted-foreground/20 mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : customers.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div className="h-12 w-12 rounded-full bg-indigo-500/10 flex items-center justify-center mb-4">
|
||||||
|
<Users className="h-6 w-6 text-indigo-500" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-medium">No customers yet</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 max-w-xs">
|
||||||
|
This widget will populate once people start browsing and buying.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{customers.map((customer) => (
|
||||||
|
<div
|
||||||
|
key={customer.id}
|
||||||
|
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
|
||||||
|
>
|
||||||
|
<Avatar className="h-10 w-10 border shadow-sm group-hover:scale-105 transition-transform">
|
||||||
|
<AvatarFallback className="bg-indigo-500/10 text-indigo-600 text-xs font-bold">
|
||||||
|
{customer.name.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-grow min-w-0">
|
||||||
|
<h4 className="font-semibold text-sm truncate group-hover:text-primary transition-colors">{customer.name}</h4>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{customer.orderCount} order{customer.orderCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showSpent && (
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm font-bold text-foreground">
|
||||||
|
{formatGBP(customer.totalSpent)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground font-medium uppercase tracking-tighter">Total Spent</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
190
components/dashboard/revenue-widget.tsx
Normal file
190
components/dashboard/revenue-widget.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { TrendingUp, DollarSign, RefreshCcw } from "lucide-react"
|
||||||
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
||||||
|
import { getRevenueTrendsWithStore, type RevenueData, formatGBP } from "@/lib/api"
|
||||||
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
|
|
||||||
|
interface RevenueWidgetProps {
|
||||||
|
settings?: {
|
||||||
|
days?: number
|
||||||
|
showComparison?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChartDataPoint {
|
||||||
|
date: string
|
||||||
|
revenue: number
|
||||||
|
orders: number
|
||||||
|
formattedDate: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RevenueWidget({ settings }: RevenueWidgetProps) {
|
||||||
|
const days = settings?.days || 7
|
||||||
|
const [data, setData] = useState<RevenueData[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const fetchRevenueData = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const response = await getRevenueTrendsWithStore(days.toString())
|
||||||
|
setData(Array.isArray(response) ? response : [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching revenue data:", err)
|
||||||
|
setError("Failed to load revenue data")
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRevenueData()
|
||||||
|
}, [days])
|
||||||
|
|
||||||
|
const chartData: ChartDataPoint[] = data.map(item => {
|
||||||
|
const date = new Date(Date.UTC(item._id.year, item._id.month - 1, item._id.day))
|
||||||
|
return {
|
||||||
|
date: date.toISOString().split('T')[0],
|
||||||
|
revenue: item.revenue || 0,
|
||||||
|
orders: item.orders || 0,
|
||||||
|
formattedDate: date.toLocaleDateString('en-GB', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: 'UTC'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Summary stats
|
||||||
|
const totalRevenue = data.reduce((sum, item) => sum + (item.revenue || 0), 0)
|
||||||
|
const totalOrders = data.reduce((sum, item) => sum + (item.orders || 0), 0)
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload }: any) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const data = payload[0].payload
|
||||||
|
return (
|
||||||
|
<div className="bg-background/95 backdrop-blur-md p-3 border border-border shadow-xl rounded-xl">
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">{data.formattedDate}</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-bold text-primary">
|
||||||
|
Revenue: {formatGBP(data.revenue)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Orders: <span className="font-medium text-foreground">{data.orders}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<Skeleton className="h-5 w-32 mb-1" />
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-[250px] w-full rounded-xl" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm overflow-hidden flex flex-col h-full">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-5 w-5 text-primary" />
|
||||||
|
Revenue Insights
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Performance over the last {days} days
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<Button variant="ghost" size="icon" onClick={fetchRevenueData} className="h-8 w-8">
|
||||||
|
<RefreshCcw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-grow pt-4">
|
||||||
|
{error ? (
|
||||||
|
<div className="h-[300px] flex flex-col items-center justify-center text-center p-6">
|
||||||
|
<DollarSign className="h-12 w-12 text-muted-foreground/20 mb-4" />
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">Could not load revenue trends</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchRevenueData}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
) : chartData.length === 0 ? (
|
||||||
|
<div className="h-[300px] flex flex-col items-center justify-center text-center p-6">
|
||||||
|
<DollarSign className="h-12 w-12 text-muted-foreground/20 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium">No revenue data</h3>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-xs mt-2">
|
||||||
|
Start making sales to see your revenue trends here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="h-[300px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={chartData} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorRevenueWidget" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" opacity={0.5} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="formattedDate"
|
||||||
|
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
minTickGap={30}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(value) => `£${value >= 1000 ? (value / 1000).toFixed(1) + 'k' : value}`}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="revenue"
|
||||||
|
stroke="hsl(var(--primary))"
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#colorRevenueWidget)"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
activeDot={{ r: 6, strokeWidth: 0, fill: "hsl(var(--primary))" }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 pb-2">
|
||||||
|
<div className="p-4 rounded-2xl bg-primary/5 border border-primary/10">
|
||||||
|
<div className="text-sm text-muted-foreground font-medium mb-1">Total Revenue</div>
|
||||||
|
<div className="text-2xl font-bold text-primary">{formatGBP(totalRevenue)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-2xl bg-muted/50 border border-border">
|
||||||
|
<div className="text-sm text-muted-foreground font-medium mb-1">Total Orders</div>
|
||||||
|
<div className="text-2xl font-bold">{totalOrders}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
271
components/dashboard/widget-settings-modal.tsx
Normal file
271
components/dashboard/widget-settings-modal.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { WidgetConfig } from "@/hooks/useWidgetLayout"
|
||||||
|
import { Settings2 } from "lucide-react"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
|
||||||
|
interface WidgetSettingsModalProps {
|
||||||
|
widget: WidgetConfig | null
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSave: (widgetId: string, settings: Record<string, any>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WidgetSettingsModal({ widget, open, onOpenChange, onSave }: WidgetSettingsModalProps) {
|
||||||
|
const [localSettings, setLocalSettings] = useState<Record<string, any>>({})
|
||||||
|
|
||||||
|
// Initialize local settings when widget changes
|
||||||
|
const handleOpenChange = (isOpen: boolean) => {
|
||||||
|
if (isOpen && widget) {
|
||||||
|
setLocalSettings({ ...widget.settings })
|
||||||
|
}
|
||||||
|
onOpenChange(isOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (widget) {
|
||||||
|
onSave(widget.id, localSettings)
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSetting = (key: string, value: any) => {
|
||||||
|
setLocalSettings(prev => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!widget) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Settings2 className="h-5 w-5" />
|
||||||
|
{widget.title} Settings
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Customize how this widget displays on your dashboard.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="max-h-[60vh] -mr-4 pr-4">
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Recent Activity Settings */}
|
||||||
|
{widget.id === "recent-activity" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="itemCount">Number of items</Label>
|
||||||
|
<Select
|
||||||
|
value={String(localSettings.itemCount || 10)}
|
||||||
|
onValueChange={(v) => updateSetting("itemCount", parseInt(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-24">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="5">5</SelectItem>
|
||||||
|
<SelectItem value="10">10</SelectItem>
|
||||||
|
<SelectItem value="15">15</SelectItem>
|
||||||
|
<SelectItem value="20">20</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top Products Settings */}
|
||||||
|
{widget.id === "top-products" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="itemCount">Number of products</Label>
|
||||||
|
<Select
|
||||||
|
value={String(localSettings.itemCount || 5)}
|
||||||
|
onValueChange={(v) => updateSetting("itemCount", parseInt(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-24">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="3">3</SelectItem>
|
||||||
|
<SelectItem value="5">5</SelectItem>
|
||||||
|
<SelectItem value="10">10</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="showRevenue">Show revenue</Label>
|
||||||
|
<Switch
|
||||||
|
id="showRevenue"
|
||||||
|
checked={localSettings.showRevenue ?? true}
|
||||||
|
onCheckedChange={(checked) => updateSetting("showRevenue", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Revenue Chart Settings */}
|
||||||
|
{widget.id === "revenue-chart" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="days">Time period</Label>
|
||||||
|
<Select
|
||||||
|
value={String(localSettings.days || 7)}
|
||||||
|
onValueChange={(v) => updateSetting("days", parseInt(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="7">Last 7 days</SelectItem>
|
||||||
|
<SelectItem value="14">Last 14 days</SelectItem>
|
||||||
|
<SelectItem value="30">Last 30 days</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="showComparison">Show comparison</Label>
|
||||||
|
<Switch
|
||||||
|
id="showComparison"
|
||||||
|
checked={localSettings.showComparison ?? false}
|
||||||
|
onCheckedChange={(checked) => updateSetting("showComparison", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Low Stock Settings */}
|
||||||
|
{widget.id === "low-stock" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="threshold">Stock threshold</Label>
|
||||||
|
<Input
|
||||||
|
id="threshold"
|
||||||
|
type="number"
|
||||||
|
className="w-24"
|
||||||
|
value={localSettings.threshold || 5}
|
||||||
|
onChange={(e) => updateSetting("threshold", parseInt(e.target.value) || 5)}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="itemCount">Max items to show</Label>
|
||||||
|
<Select
|
||||||
|
value={String(localSettings.itemCount || 5)}
|
||||||
|
onValueChange={(v) => updateSetting("itemCount", parseInt(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-24">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="3">3</SelectItem>
|
||||||
|
<SelectItem value="5">5</SelectItem>
|
||||||
|
<SelectItem value="10">10</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent Customers Settings */}
|
||||||
|
{widget.id === "recent-customers" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="itemCount">Number of customers</Label>
|
||||||
|
<Select
|
||||||
|
value={String(localSettings.itemCount || 5)}
|
||||||
|
onValueChange={(v) => updateSetting("itemCount", parseInt(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-24">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="3">3</SelectItem>
|
||||||
|
<SelectItem value="5">5</SelectItem>
|
||||||
|
<SelectItem value="10">10</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="showSpent">Show amount spent</Label>
|
||||||
|
<Switch
|
||||||
|
id="showSpent"
|
||||||
|
checked={localSettings.showSpent ?? true}
|
||||||
|
onCheckedChange={(checked) => updateSetting("showSpent", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending Chats Settings */}
|
||||||
|
{widget.id === "pending-chats" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="showPreview">Show message preview</Label>
|
||||||
|
<Switch
|
||||||
|
id="showPreview"
|
||||||
|
checked={localSettings.showPreview ?? true}
|
||||||
|
onCheckedChange={(checked) => updateSetting("showPreview", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overview Settings */}
|
||||||
|
{widget.id === "overview" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="showChange">Show percentage change</Label>
|
||||||
|
<Switch
|
||||||
|
id="showChange"
|
||||||
|
checked={localSettings.showChange ?? false}
|
||||||
|
onCheckedChange={(checked) => updateSetting("showChange", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Actions - no settings */}
|
||||||
|
{widget.id === "quick-actions" && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
This widget has no customizable settings.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
components/dashboard/widget-settings.tsx
Normal file
101
components/dashboard/widget-settings.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { Settings2, ChevronUp, ChevronDown, RotateCcw, Eye, EyeOff, Cog } from "lucide-react"
|
||||||
|
import { WidgetConfig } from "@/hooks/useWidgetLayout"
|
||||||
|
|
||||||
|
interface WidgetSettingsProps {
|
||||||
|
widgets: WidgetConfig[]
|
||||||
|
onToggle: (id: string) => void
|
||||||
|
onMove: (id: string, direction: "up" | "down") => void
|
||||||
|
onReset: () => void
|
||||||
|
onConfigure?: (widget: WidgetConfig) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WidgetSettings({ widgets, onToggle, onMove, onReset, onConfigure }: WidgetSettingsProps) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="h-8 gap-2">
|
||||||
|
<Settings2 className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Customize</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-64">
|
||||||
|
<DropdownMenuLabel className="flex items-center justify-between">
|
||||||
|
Dashboard Widgets
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={onReset}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3 mr-1" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{widgets.map((widget, index) => (
|
||||||
|
<div key={widget.id} className="flex items-center gap-1 px-2 py-1.5 hover:bg-muted/50 rounded-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => onToggle(widget.id)}
|
||||||
|
className="flex-1 flex items-center gap-2 text-sm hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{widget.visible ? (
|
||||||
|
<Eye className="h-4 w-4 text-primary" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className={widget.visible ? "" : "text-muted-foreground line-through"}>
|
||||||
|
{widget.title}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{widget.settings && onConfigure && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onConfigure(widget)
|
||||||
|
}}
|
||||||
|
title="Configure widget"
|
||||||
|
>
|
||||||
|
<Cog className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => onMove(widget.id, "up")}
|
||||||
|
disabled={index === 0}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => onMove(widget.id, "down")}
|
||||||
|
disabled={index === widgets.length - 1}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { LogIn } from "lucide-react";
|
import { LogIn } from "lucide-react";
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export function HomeNavbar() {
|
export function HomeNavbar() {
|
||||||
@@ -27,8 +26,8 @@ export function HomeNavbar() {
|
|||||||
Log In
|
Log In
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/auth/login">
|
<Link href="/dashboard">
|
||||||
<Button className="bg-[#D53F8C] hover:bg-[#B83280] text-white border-0">Get Started</Button>
|
<Button className="bg-indigo-600 hover:bg-indigo-700 text-white border-0">Get Started</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
@@ -78,11 +77,11 @@ export function HomeNavbar() {
|
|||||||
Log In
|
Log In
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/auth/register"
|
href="/dashboard"
|
||||||
className="text-sm p-2 hover:bg-gray-900 rounded-md text-gray-300"
|
className="text-sm p-2 hover:bg-gray-900 rounded-md text-gray-300"
|
||||||
onClick={() => setMenuOpen(false)}
|
onClick={() => setMenuOpen(false)}
|
||||||
>
|
>
|
||||||
Create Account
|
Get Started
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { TrendingUp, TrendingDown, Calculator, DollarSign } from "lucide-react";
|
import { TrendingUp, TrendingDown, Calculator, DollarSign, Loader2, Info } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { apiRequest } from "@/lib/api";
|
import { apiRequest } from "@/lib/api";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
interface ProfitAnalysisModalProps {
|
interface ProfitAnalysisModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -69,7 +70,11 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
|
|||||||
|
|
||||||
const formatCurrency = (amount: number | null) => {
|
const formatCurrency = (amount: number | null) => {
|
||||||
if (amount === null) return "N/A";
|
if (amount === null) return "N/A";
|
||||||
return `£${amount.toFixed(2)}`;
|
return new Intl.NumberFormat('en-GB', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'GBP',
|
||||||
|
minimumFractionDigits: 2
|
||||||
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatPercentage = (percentage: number | null) => {
|
const formatPercentage = (percentage: number | null) => {
|
||||||
@@ -79,7 +84,7 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
|
|||||||
|
|
||||||
const getProfitColor = (profit: number | null) => {
|
const getProfitColor = (profit: number | null) => {
|
||||||
if (profit === null) return "text-muted-foreground";
|
if (profit === null) return "text-muted-foreground";
|
||||||
return profit >= 0 ? "text-green-600" : "text-red-600";
|
return profit >= 0 ? "text-emerald-500" : "text-rose-500";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getProfitIcon = (profit: number | null) => {
|
const getProfitIcon = (profit: number | null) => {
|
||||||
@@ -87,17 +92,33 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
|
|||||||
return profit >= 0 ? TrendingUp : TrendingDown;
|
return profit >= 0 ? TrendingUp : TrendingDown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Variants for staggered animations
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: { opacity: 1, y: 0 }
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onClose}>
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-4xl">
|
<DialogContent className="max-w-4xl bg-background/80 backdrop-blur-md border-border/50">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Profit Analysis - {productName}</DialogTitle>
|
<DialogTitle>Profit Analysis - {productName}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
|
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" />
|
||||||
<p className="text-muted-foreground">Loading profit analysis...</p>
|
<p className="text-muted-foreground">Calculating metrics...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -108,7 +129,7 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
|
|||||||
if (!profitData) {
|
if (!profitData) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onClose}>
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-4xl">
|
<DialogContent className="max-w-4xl bg-background/80 backdrop-blur-md border-border/50">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Profit Analysis - {productName}</DialogTitle>
|
<DialogTitle>Profit Analysis - {productName}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -122,92 +143,110 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onClose}>
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto bg-black/80 backdrop-blur-xl border-white/10 shadow-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||||
<DollarSign className="h-5 w-5" />
|
<div className="p-2 rounded-lg bg-indigo-500/10 border border-indigo-500/20">
|
||||||
Profit Analysis - {productName}
|
<DollarSign className="h-5 w-5 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<span>Profit Analysis: <span className="text-muted-foreground font-normal">{productName}</span></span>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<motion.div
|
||||||
|
className="space-y-6 py-4"
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
>
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
{profitData.summary.hasCostData ? (
|
{profitData.summary.hasCostData ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<Card>
|
<motion.div variants={itemVariants}>
|
||||||
<CardHeader className="pb-2">
|
<Card className="bg-emerald-500/5 border-emerald-500/20 backdrop-blur-sm hover:bg-emerald-500/10 transition-colors">
|
||||||
<CardTitle className="text-sm font-medium">Average Profit</CardTitle>
|
<CardHeader className="pb-2">
|
||||||
</CardHeader>
|
<CardTitle className="text-sm font-medium text-emerald-400">Average Profit</CardTitle>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<div className="text-2xl font-bold text-green-600">
|
<CardContent>
|
||||||
{formatCurrency(profitData.summary.averageProfit)}
|
<div className="text-3xl font-bold text-emerald-500">
|
||||||
</div>
|
{formatCurrency(profitData.summary.averageProfit)}
|
||||||
<p className="text-xs text-muted-foreground">Per unit sold</p>
|
</div>
|
||||||
</CardContent>
|
<p className="text-xs text-emerald-400/60 mt-1">Per unit sold</p>
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<Card>
|
<motion.div variants={itemVariants}>
|
||||||
<CardHeader className="pb-2">
|
<Card className="bg-blue-500/5 border-blue-500/20 backdrop-blur-sm hover:bg-blue-500/10 transition-colors">
|
||||||
<CardTitle className="text-sm font-medium">Average Profit Margin</CardTitle>
|
<CardHeader className="pb-2">
|
||||||
</CardHeader>
|
<CardTitle className="text-sm font-medium text-blue-400">Avg. Margin</CardTitle>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
<CardContent>
|
||||||
{formatPercentage(profitData.summary.averageProfitMargin)}
|
<div className="text-3xl font-bold text-blue-500">
|
||||||
</div>
|
{formatPercentage(profitData.summary.averageProfitMargin)}
|
||||||
<p className="text-xs text-muted-foreground">Of selling price</p>
|
</div>
|
||||||
</CardContent>
|
<p className="text-xs text-blue-400/60 mt-1">Of selling price</p>
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<Card>
|
<motion.div variants={itemVariants}>
|
||||||
<CardHeader className="pb-2">
|
<Card className="bg-indigo-500/5 border-indigo-500/20 backdrop-blur-sm hover:bg-indigo-500/10 transition-colors">
|
||||||
<CardTitle className="text-sm font-medium">Average Markup</CardTitle>
|
<CardHeader className="pb-2">
|
||||||
</CardHeader>
|
<CardTitle className="text-sm font-medium text-indigo-400">Avg. Markup</CardTitle>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<div className="text-2xl font-bold text-purple-600">
|
<CardContent>
|
||||||
{formatPercentage(profitData.summary.averageMarkup)}
|
<div className="text-3xl font-bold text-indigo-500">
|
||||||
</div>
|
{formatPercentage(profitData.summary.averageMarkup)}
|
||||||
<p className="text-xs text-muted-foreground">On cost price</p>
|
</div>
|
||||||
</CardContent>
|
<p className="text-xs text-indigo-400/60 mt-1">On cost price</p>
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<motion.div variants={itemVariants}>
|
||||||
<CardContent className="pt-6">
|
<Card className="border-dashed border-2 border-muted bg-muted/20">
|
||||||
<div className="text-center">
|
<CardContent className="pt-6">
|
||||||
<Calculator className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
<div className="text-center py-6">
|
||||||
<h3 className="text-lg font-medium mb-2">No Cost Data Available</h3>
|
<Calculator className="h-12 w-12 text-muted-foreground mx-auto mb-4 opacity-50" />
|
||||||
<p className="text-muted-foreground mb-4">
|
<h3 className="text-lg font-medium mb-2">Missing Cost Data</h3>
|
||||||
Add a cost per unit to this product to see profit calculations.
|
<p className="text-muted-foreground mb-4 max-w-sm mx-auto">
|
||||||
</p>
|
Add a generic "Cost Per Unit" to this product to see detailed profit calculations.
|
||||||
<Badge variant="outline">Cost Per Unit: {formatCurrency(profitData.costPerUnit)}</Badge>
|
</p>
|
||||||
</div>
|
<Badge variant="outline" className="text-sm py-1 px-3">
|
||||||
</CardContent>
|
Current Cost: {formatCurrency(profitData.costPerUnit)}
|
||||||
</Card>
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cost Information */}
|
{/* Cost Information */}
|
||||||
<Card>
|
<motion.div variants={itemVariants}>
|
||||||
<CardHeader>
|
<Card className="bg-white/5 border-white/10 backdrop-blur-sm">
|
||||||
<CardTitle className="text-base">Cost Information</CardTitle>
|
<CardContent className="p-4">
|
||||||
</CardHeader>
|
<div className="flex items-center justify-between">
|
||||||
<CardContent>
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="p-2 rounded-md bg-muted/50">
|
||||||
<span className="text-sm font-medium">Cost Per Unit:</span>
|
<Info className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-lg font-semibold">{formatCurrency(profitData.costPerUnit)}</span>
|
</div>
|
||||||
</div>
|
<span className="text-sm font-medium text-muted-foreground">Base Cost Per Unit</span>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
<span className="text-xl font-bold text-white">{formatCurrency(profitData.costPerUnit)}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* Pricing Tier Analysis */}
|
{/* Pricing Tier Analysis */}
|
||||||
<Card>
|
<motion.div variants={itemVariants} className="space-y-3">
|
||||||
<CardHeader>
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider pl-1">Tier Breakdown</h3>
|
||||||
<CardTitle className="text-base">Pricing Tier Analysis</CardTitle>
|
<div className="space-y-3">
|
||||||
</CardHeader>
|
{profitData.profitMargins
|
||||||
<CardContent>
|
.sort((a, b) => a.minQuantity - b.minQuantity)
|
||||||
<div className="space-y-4">
|
.map((tier, index) => {
|
||||||
{profitData.profitMargins
|
|
||||||
.sort((a, b) => a.minQuantity - b.minQuantity)
|
|
||||||
.map((tier, index) => {
|
|
||||||
const ProfitIcon = getProfitIcon(tier.profit);
|
const ProfitIcon = getProfitIcon(tier.profit);
|
||||||
|
|
||||||
const totalProfitForMinQty = tier.profit !== null ? tier.profit * tier.minQuantity : null;
|
const totalProfitForMinQty = tier.profit !== null ? tier.profit * tier.minQuantity : null;
|
||||||
@@ -215,61 +254,83 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
|
|||||||
const totalCostForMinQty = profitData.costPerUnit * tier.minQuantity;
|
const totalCostForMinQty = profitData.costPerUnit * tier.minQuantity;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<motion.div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex items-center justify-between p-4 border rounded-lg"
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.2 + (index * 0.1) }}
|
||||||
|
className="relative overflow-hidden group rounded-xl border border-white/5 bg-white/5 hover:bg-white/10 transition-all duration-300"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className={`absolute left-0 top-0 bottom-0 w-1 ${tier.profit && tier.profit >= 0 ? 'bg-emerald-500' : 'bg-rose-500'}`} />
|
||||||
<ProfitIcon className={`h-5 w-5 ${getProfitColor(tier.profit)}`} />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">
|
|
||||||
{tier.minQuantity}+ units @ {formatCurrency(tier.pricePerUnit)}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Revenue for {tier.minQuantity} units: {formatCurrency(totalRevenueForMinQty)}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Cost for {tier.minQuantity} units: {formatCurrency(totalCostForMinQty)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-right space-y-1">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between p-4 pl-6 gap-4">
|
||||||
<div className={`font-medium ${getProfitColor(totalProfitForMinQty)}`}>
|
<div className="space-y-1">
|
||||||
Total Profit: {formatCurrency(totalProfitForMinQty)}
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">
|
||||||
|
{tier.minQuantity}+ UNITS
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground text-sm">at</span>
|
||||||
|
<span className="font-bold text-white text-lg">{formatCurrency(tier.pricePerUnit)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 text-xs text-muted-foreground mt-2">
|
||||||
|
<span>Rev: <span className="text-white">{formatCurrency(totalRevenueForMinQty)}</span></span>
|
||||||
|
<span className="w-px h-4 bg-white/10" />
|
||||||
|
<span>Cost: <span className="text-white">{formatCurrency(totalCostForMinQty)}</span></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Per unit: {formatCurrency(tier.profit)}
|
<div className="flex items-center justify-between sm:justify-end gap-6 sm:w-auto w-full pt-2 sm:pt-0 border-t sm:border-0 border-white/5">
|
||||||
</div>
|
<div className="text-right">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-[10px] uppercase text-muted-foreground font-medium mb-0.5">Margin</div>
|
||||||
Margin: {formatPercentage(tier.profitMargin)} |
|
<div className={`font-mono font-bold ${tier.profit && tier.profit >= 50 ? 'text-emerald-400' : 'text-blue-400'}`}>
|
||||||
Markup: {formatPercentage(tier.markup)}
|
{formatPercentage(tier.profitMargin)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right pl-4 border-l border-white/10">
|
||||||
|
<div className="text-[10px] uppercase text-muted-foreground font-medium mb-0.5">Net Profit</div>
|
||||||
|
<div className={`text-xl font-bold flex items-center justify-end gap-1 ${getProfitColor(tier.profit)}`}>
|
||||||
|
{tier.profit && tier.profit > 0 ? '+' : ''}{formatCurrency(tier.profit)}
|
||||||
|
</div>
|
||||||
|
<div className={`text-[10px] ${getProfitColor(totalProfitForMinQty)} opacity-80`}>
|
||||||
|
Total: {formatCurrency(totalProfitForMinQty)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</motion.div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Help Text */}
|
{/* Help Text */}
|
||||||
<Card className="bg-muted/50">
|
<motion.div variants={itemVariants}>
|
||||||
<CardContent className="pt-6">
|
<div className="bg-indigo-500/5 rounded-lg border border-indigo-500/10 p-4">
|
||||||
<div className="space-y-2 text-sm">
|
<h4 className="flex items-center gap-2 text-sm font-medium text-indigo-300 mb-2">
|
||||||
<h4 className="font-medium">Understanding the Metrics:</h4>
|
<Info className="h-4 w-4" />
|
||||||
<ul className="space-y-1 text-muted-foreground">
|
Quick Guide
|
||||||
<li><strong>Profit:</strong> Selling price minus cost price</li>
|
</h4>
|
||||||
<li><strong>Profit Margin:</strong> Profit as a percentage of selling price</li>
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs text-muted-foreground">
|
||||||
<li><strong>Markup:</strong> Profit as a percentage of cost price</li>
|
<div>
|
||||||
</ul>
|
<span className="text-indigo-200 font-semibold block mb-0.5">Profit</span>
|
||||||
|
Selling Price - Cost Price
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-indigo-200 font-semibold block mb-0.5">Margin</span>
|
||||||
|
(Profit / Selling Price) × 100
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-indigo-200 font-semibold block mb-0.5">Markup</span>
|
||||||
|
(Profit / Cost Price) × 100
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</motion.div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="flex justify-end pt-4">
|
<div className="flex justify-end pt-2">
|
||||||
<Button onClick={onClose}>Close</Button>
|
<Button onClick={onClose} variant="secondary" className="hover:bg-white/20">Close Analysis</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default function UnifiedNotifications() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-80">
|
<DropdownMenuContent align="end" className="w-80" collisionPadding={10}>
|
||||||
<div className="p-2 border-b">
|
<div className="p-2 border-b">
|
||||||
<Tabs defaultValue="all" value={activeTab} onValueChange={setActiveTab}>
|
<Tabs defaultValue="all" value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
|||||||
@@ -3,6 +3,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 { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -129,9 +130,12 @@ const PageSizeSelector = ({ currentSize, onChange, options }: { currentSize: num
|
|||||||
|
|
||||||
|
|
||||||
export default function OrderTable() {
|
export default function OrderTable() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const initialStatus = searchParams?.get("status") || "all";
|
||||||
|
|
||||||
const [orders, setOrders] = useState<Order[]>([]);
|
const [orders, setOrders] = useState<Order[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState(initialStatus);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [totalOrders, setTotalOrders] = useState(0);
|
const [totalOrders, setTotalOrders] = useState(0);
|
||||||
@@ -157,6 +161,15 @@ export default function OrderTable() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetch orders with server-side pagination
|
// Fetch orders with server-side pagination
|
||||||
|
// State for browser detection
|
||||||
|
// Browser detection
|
||||||
|
const [isFirefox, setIsFirefox] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ua = navigator.userAgent.toLowerCase();
|
||||||
|
setIsFirefox(ua.includes("firefox") && !ua.includes("chrome"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchOrders = useCallback(async () => {
|
const fetchOrders = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -240,37 +253,56 @@ export default function OrderTable() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const orderIdsToShip = Array.from(selectedOrders);
|
||||||
|
|
||||||
|
// Store previous state for rollback
|
||||||
|
const previousOrders = [...orders];
|
||||||
|
|
||||||
|
// Optimistic update - immediately mark orders as shipped in UI
|
||||||
|
setOrders(prev =>
|
||||||
|
prev.map(order =>
|
||||||
|
selectedOrders.has(order._id)
|
||||||
|
? { ...order, status: "shipped" as const }
|
||||||
|
: order
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setSelectedOrders(new Set());
|
||||||
|
|
||||||
|
// Show optimistic toast
|
||||||
|
toast.success(`Marking ${orderIdsToShip.length} order(s) as shipped...`, { id: "shipping-optimistic" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsShipping(true);
|
setIsShipping(true);
|
||||||
const response = await clientFetch("/orders/mark-shipped", {
|
const response = await clientFetch("/orders/mark-shipped", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ orderIds: Array.from(selectedOrders) })
|
body: JSON.stringify({ orderIds: orderIdsToShip })
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only update orders that were successfully marked as shipped
|
// Handle partial success/failure
|
||||||
if (response.success && response.success.orders) {
|
if (response.success && response.success.orders) {
|
||||||
const successfulOrderIds = new Set(response.success.orders.map((o: any) => o.id));
|
const successfulOrderIds = new Set(response.success.orders.map((o: any) => o.id));
|
||||||
|
|
||||||
setOrders(prev =>
|
// If some orders failed, revert those specifically
|
||||||
prev.map(order =>
|
|
||||||
successfulOrderIds.has(order._id)
|
|
||||||
? { ...order, status: "shipped" }
|
|
||||||
: order
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.failed && response.failed.count > 0) {
|
if (response.failed && response.failed.count > 0) {
|
||||||
toast.warning(`${response.failed.count} orders could not be marked as shipped`);
|
setOrders(prev =>
|
||||||
}
|
prev.map(order => {
|
||||||
|
if (orderIdsToShip.includes(order._id) && !successfulOrderIds.has(order._id)) {
|
||||||
if (response.success.count > 0) {
|
// Find original status from previousOrders
|
||||||
toast.success(`${response.success.count} orders marked as shipped`);
|
const originalOrder = previousOrders.find(o => o._id === order._id);
|
||||||
|
return originalOrder || order;
|
||||||
|
}
|
||||||
|
return order;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
toast.warning(`${response.failed.count} order(s) could not be marked as shipped`, { id: "shipping-optimistic" });
|
||||||
|
} else if (response.success.count > 0) {
|
||||||
|
toast.success(`${response.success.count} order(s) marked as shipped!`, { id: "shipping-optimistic" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedOrders(new Set());
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to update orders");
|
// Revert all changes on error
|
||||||
|
setOrders(previousOrders);
|
||||||
|
toast.error("Failed to update orders - changes reverted", { id: "shipping-optimistic" });
|
||||||
console.error("Shipping error:", error);
|
console.error("Shipping error:", error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsShipping(false);
|
setIsShipping(false);
|
||||||
@@ -416,7 +448,7 @@ export default function OrderTable() {
|
|||||||
disabled={exporting}
|
disabled={exporting}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-background/50 border-border/50"
|
className="bg-background/50 border-border/50 hover:bg-muted/50 transition-colors"
|
||||||
>
|
>
|
||||||
{exporting ? (
|
{exporting ? (
|
||||||
<>
|
<>
|
||||||
@@ -468,68 +500,72 @@ export default function OrderTable() {
|
|||||||
{/* Table */}
|
{/* Table */}
|
||||||
<CardContent className="p-0 relative min-h-[400px]">
|
<CardContent className="p-0 relative min-h-[400px]">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="absolute inset-0 bg-background/50 backdrop-blur-[1px] flex items-center justify-center z-50">
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-[2px] flex items-center justify-center z-50">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-indigo-500" />
|
||||||
|
<span className="text-zinc-400 text-sm font-medium">Loading orders...</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="max-h-[calc(100vh-350px)] overflow-auto">
|
<div className="max-h-[calc(100vh-350px)] overflow-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="bg-muted/50 sticky top-0 z-20">
|
<TableHeader className="bg-muted/30 sticky top-0 z-20">
|
||||||
<TableRow className="hover:bg-transparent border-border/50">
|
<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}
|
||||||
|
className="border-white/20 data-[state=checked]:bg-indigo-500 data-[state=checked]:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("orderId")}>
|
<TableHead className="cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("orderId")}>
|
||||||
Order ID <ArrowUpDown className="ml-2 inline h-3 w-3" />
|
Order ID <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("totalPrice")}>
|
<TableHead className="cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("totalPrice")}>
|
||||||
Total <ArrowUpDown className="ml-2 inline h-3 w-3" />
|
Total <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden lg:table-cell">Promotion</TableHead>
|
<TableHead className="hidden lg:table-cell text-zinc-400">Promotion</TableHead>
|
||||||
<TableHead className="cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("status")}>
|
<TableHead className="cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("status")}>
|
||||||
Status <ArrowUpDown className="ml-2 inline h-3 w-3" />
|
Status <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden md:table-cell cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("orderDate")}>
|
<TableHead className="hidden md:table-cell cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("orderDate")}>
|
||||||
Date <ArrowUpDown className="ml-2 inline h-3 w-3" />
|
Date <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden xl:table-cell cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("paidAt")}>
|
<TableHead className="hidden xl:table-cell cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("paidAt")}>
|
||||||
Paid At <ArrowUpDown className="ml-2 inline h-3 w-3" />
|
Paid At <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden lg:table-cell">Buyer</TableHead>
|
<TableHead className="hidden lg:table-cell text-zinc-400">Buyer</TableHead>
|
||||||
<TableHead className="w-24 text-center">Actions</TableHead>
|
<TableHead className="w-24 text-center text-zinc-400">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<AnimatePresence mode="popLayout">
|
{isFirefox ? (
|
||||||
{paginatedOrders.map((order, index) => {
|
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 (
|
||||||
<motion.tr
|
<motion.tr
|
||||||
key={order._id}
|
key={order._id}
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
transition={{ duration: 0.2 }}
|
||||||
transition={{ duration: 0.2, delay: index * 0.03 }}
|
className="group hover:bg-muted/50 border-b border-border/50 transition-colors"
|
||||||
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)}
|
||||||
onCheckedChange={() => toggleSelection(order._id)}
|
onCheckedChange={() => toggleSelection(order._id)}
|
||||||
disabled={order.status !== "paid" && order.status !== "acknowledged"}
|
disabled={order.status !== "paid" && order.status !== "acknowledged"}
|
||||||
|
className="border-white/20 data-[state=checked]:bg-indigo-500 data-[state=checked]:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-sm font-medium">#{order.orderId}</TableCell>
|
<TableCell className="font-mono text-sm font-medium text-zinc-300">#{order.orderId}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">£{order.totalPrice.toFixed(2)}</span>
|
<span className="font-medium text-zinc-200">£{order.totalPrice.toFixed(2)}</span>
|
||||||
{underpaidInfo && (
|
{underpaidInfo && (
|
||||||
<span className="text-[10px] text-destructive flex items-center gap-1">
|
<span className="text-[10px] text-red-400 flex items-center gap-1">
|
||||||
<AlertTriangle className="h-3 w-3" />
|
<AlertTriangle className="h-3 w-3" />
|
||||||
-£{underpaidInfo.missingGbp.toFixed(2)}
|
-£{underpaidInfo.missingGbp.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
@@ -540,18 +576,18 @@ 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-emerald-500" />
|
<Tag className="h-3 w-3 text-emerald-400" />
|
||||||
<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">
|
<span className="text-[10px] font-mono bg-emerald-500/10 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-[10px] text-emerald-600/80">
|
<div className="flex items-center gap-1 text-[10px] text-emerald-400/80">
|
||||||
<Percent className="h-2.5 w-2.5" />
|
<Percent className="h-2.5 w-2.5" />
|
||||||
<span>-£{(order.discountAmount || 0).toFixed(2)}</span>
|
<span>-£{(order.discountAmount || 0).toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">-</span>
|
<span className="text-xs text-zinc-600">-</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -563,13 +599,13 @@ export default function OrderTable() {
|
|||||||
{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-[10px] bg-destructive/10 text-destructive border border-destructive/20 font-medium">
|
<div className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-red-500/10 text-red-400 border border-red-500/20 font-medium">
|
||||||
{underpaidInfo?.percentage}%
|
{underpaidInfo?.percentage}%
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden md:table-cell text-sm text-muted-foreground">
|
<TableCell className="hidden md:table-cell text-sm text-zinc-400">
|
||||||
{new Date(order.orderDate).toLocaleDateString("en-GB", {
|
{new Date(order.orderDate).toLocaleDateString("en-GB", {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -579,7 +615,7 @@ export default function OrderTable() {
|
|||||||
{new Date(order.orderDate).toLocaleTimeString("en-GB", { hour: '2-digit', minute: '2-digit' })}
|
{new Date(order.orderDate).toLocaleTimeString("en-GB", { hour: '2-digit', minute: '2-digit' })}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden xl:table-cell text-sm text-muted-foreground">
|
<TableCell className="hidden xl:table-cell text-sm text-zinc-400">
|
||||||
{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',
|
||||||
@@ -591,12 +627,12 @@ export default function OrderTable() {
|
|||||||
{order.telegramUsername ? (
|
{order.telegramUsername ? (
|
||||||
<span className="text-sm font-medium text-primary">@{order.telegramUsername}</span>
|
<span className="text-sm font-medium text-primary">@{order.telegramUsername}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground italic">Guest</span>
|
<span className="text-xs text-zinc-500 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="icon" className="h-8 w-8 text-muted-foreground hover:text-foreground" asChild>
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-zinc-400 hover:text-white hover:bg-white/10" 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>
|
||||||
@@ -606,7 +642,7 @@ export default function OrderTable() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-primary"
|
className="h-8 w-8 text-zinc-400 hover:text-indigo-400 hover:bg-indigo-500/10"
|
||||||
asChild
|
asChild
|
||||||
title={`Chat with customer${order.telegramUsername ? ` @${order.telegramUsername}` : ''}`}
|
title={`Chat with customer${order.telegramUsername ? ` @${order.telegramUsername}` : ''}`}
|
||||||
>
|
>
|
||||||
@@ -619,16 +655,137 @@ export default function OrderTable() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</motion.tr>
|
</motion.tr>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
</AnimatePresence>
|
) : (
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{paginatedOrders.map((order, index) => {
|
||||||
|
const StatusIcon = statusConfig[order.status as keyof typeof statusConfig]?.icon || XCircle;
|
||||||
|
const underpaidInfo = getUnderpaidInfo(order);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.tr
|
||||||
|
key={order._id}
|
||||||
|
layout
|
||||||
|
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/50 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"}
|
||||||
|
className="border-white/20 data-[state=checked]:bg-indigo-500 data-[state=checked]:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm font-medium text-zinc-300">#{order.orderId}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium text-zinc-200">£{order.totalPrice.toFixed(2)}</span>
|
||||||
|
{underpaidInfo && (
|
||||||
|
<span className="text-[10px] text-red-400 flex items-center gap-1">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
-£{underpaidInfo.missingGbp.toFixed(2)}
|
||||||
|
</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-emerald-400" />
|
||||||
|
<span className="text-[10px] font-mono bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5 rounded border border-emerald-500/20">
|
||||||
|
{order.promotionCode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-emerald-400/80">
|
||||||
|
<Percent className="h-2.5 w-2.5" />
|
||||||
|
<span>-£{(order.discountAmount || 0).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-zinc-600">-</span>
|
||||||
|
)}
|
||||||
|
</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-red-500/10 text-red-400 border border-red-500/20 font-medium">
|
||||||
|
{underpaidInfo?.percentage}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell text-sm text-zinc-400">
|
||||||
|
{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-zinc-400">
|
||||||
|
{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-zinc-500 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-zinc-400 hover:text-white hover:bg-white/10" asChild>
|
||||||
|
<Link href={`/dashboard/orders/${order._id}`}>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{(order.telegramBuyerId || order.telegramUsername) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-zinc-400 hover:text-indigo-400 hover:bg-indigo-500/10"
|
||||||
|
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>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
<div className="flex items-center justify-between px-4 py-4 border-t border-border/50 bg-background/50">
|
<div className="flex items-center justify-between px-4 py-4 border-t border-white/5 bg-white/[0.02]">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-zinc-500">
|
||||||
Page {currentPage} of {totalPages} ({totalOrders} total)
|
Page {currentPage} of {totalPages} ({totalOrders} total)
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -637,7 +794,7 @@ 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"
|
className="h-8 bg-transparent border-white/10 hover:bg-white/5 text-zinc-400 hover:text-white"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-3 w-3 mr-1" />
|
<ChevronLeft className="h-3 w-3 mr-1" />
|
||||||
Previous
|
Previous
|
||||||
@@ -647,7 +804,7 @@ 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"
|
className="h-8 bg-transparent border-white/10 hover:bg-white/5 text-zinc-400 hover:text-white"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
<ChevronRight className="h-3 w-3 ml-1" />
|
<ChevronRight className="h-3 w-3 ml-1" />
|
||||||
@@ -656,6 +813,6 @@ export default function OrderTable() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import Image from "next/image";
|
||||||
import {
|
import {
|
||||||
Edit,
|
Edit,
|
||||||
Trash,
|
Trash,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
Archive
|
Archive
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
import { Product } from "@/models/products";
|
import { Product } from "@/models/products";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
@@ -92,10 +94,13 @@ const ProductTable = ({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-8 w-8 rounded bg-muted/50 flex items-center justify-center text-muted-foreground overflow-hidden relative">
|
<div className="h-8 w-8 rounded bg-muted/50 flex items-center justify-center text-muted-foreground overflow-hidden relative">
|
||||||
{getProductImageUrl(product) ? (
|
{getProductImageUrl(product) ? (
|
||||||
<img
|
<Image
|
||||||
src={getProductImageUrl(product)!}
|
src={getProductImageUrl(product)!}
|
||||||
alt={product.name}
|
alt={product.name}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
|
unoptimized={getProductImageUrl(product)?.startsWith('data:')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs font-bold">{product.name.charAt(0).toUpperCase()}</span>
|
<span className="text-xs font-bold">{product.name.charAt(0).toUpperCase()}</span>
|
||||||
@@ -191,6 +196,15 @@ const ProductTable = ({
|
|||||||
</motion.tr>
|
</motion.tr>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
// Browser detection
|
||||||
|
const [isFirefox, setIsFirefox] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ua = navigator.userAgent.toLowerCase();
|
||||||
|
setIsFirefox(ua.includes("firefox") && !ua.includes("chrome"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const renderTableHeader = () => (
|
const renderTableHeader = () => (
|
||||||
<TableHeader className="bg-muted/50 sticky top-0 z-10">
|
<TableHeader className="bg-muted/50 sticky top-0 z-10">
|
||||||
<TableRow className="hover:bg-transparent border-border/50">
|
<TableRow className="hover:bg-transparent border-border/50">
|
||||||
@@ -211,12 +225,14 @@ const ProductTable = ({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Enabled Products Table */}
|
{/* Enabled Products Table */}
|
||||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
<Card className="border-white/10 bg-black/40 backdrop-blur-xl shadow-2xl overflow-hidden rounded-xl">
|
||||||
<CardHeader className="py-4 px-6 border-b border-border/50 bg-muted/30">
|
<CardHeader className="py-4 px-6 border-b border-white/5 bg-white/[0.02]">
|
||||||
<CardTitle className="text-lg font-medium flex items-center gap-2">
|
<CardTitle className="text-lg font-bold flex items-center gap-3 text-white">
|
||||||
<CheckCircle className="h-5 w-5 text-primary" />
|
<div className="p-2 rounded-lg bg-indigo-500/10 border border-indigo-500/20">
|
||||||
|
<CheckCircle className="h-5 w-5 text-indigo-400" />
|
||||||
|
</div>
|
||||||
Active Products
|
Active Products
|
||||||
<Badge variant="secondary" className="ml-2 bg-background/80 backdrop-blur-sm">
|
<Badge variant="secondary" className="ml-2 bg-indigo-500/10 text-indigo-300 border-indigo-500/20 hover:bg-indigo-500/20">
|
||||||
{sortedEnabledProducts.length}
|
{sortedEnabledProducts.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -226,11 +242,14 @@ const ProductTable = ({
|
|||||||
<Table>
|
<Table>
|
||||||
{renderTableHeader()}
|
{renderTableHeader()}
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<AnimatePresence mode="popLayout">
|
{isFirefox ? (
|
||||||
{loading ? (
|
loading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
||||||
Loading products...
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent" />
|
||||||
|
<span>Loading products...</span>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : sortedEnabledProducts.length > 0 ? (
|
) : sortedEnabledProducts.length > 0 ? (
|
||||||
@@ -239,13 +258,37 @@ const ProductTable = ({
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
||||||
<div className="flex flex-col items-center justify-center gap-2">
|
<div className="flex flex-col items-center justify-center gap-2">
|
||||||
<PackageX className="h-8 w-8 opacity-50" />
|
<PackageX className="h-8 w-8 opacity-20" />
|
||||||
<p>No active products found</p>
|
<p>No active products found</p>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)
|
||||||
</AnimatePresence>
|
) : (
|
||||||
|
<AnimatePresence>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent" />
|
||||||
|
<span>Loading products...</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : sortedEnabledProducts.length > 0 ? (
|
||||||
|
sortedEnabledProducts.map((product, index) => renderProductRow(product, index))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2">
|
||||||
|
<PackageX className="h-8 w-8 opacity-20" />
|
||||||
|
<p>No active products found</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,12 +297,12 @@ const ProductTable = ({
|
|||||||
|
|
||||||
{/* Disabled Products Section */}
|
{/* Disabled Products Section */}
|
||||||
{!loading && disabledProducts.length > 0 && (
|
{!loading && disabledProducts.length > 0 && (
|
||||||
<Card className="border-border/40 bg-background/30 backdrop-blur-sm shadow-sm overflow-hidden opacity-90">
|
<Card className="border-white/5 bg-black/20 backdrop-blur-sm shadow-none overflow-hidden opacity-80 hover:opacity-100 transition-opacity">
|
||||||
<CardHeader className="py-4 px-6 border-b border-border/50 bg-muted/20">
|
<CardHeader className="py-4 px-6 border-b border-white/5 bg-white/[0.01]">
|
||||||
<CardTitle className="text-lg font-medium flex items-center gap-2 text-muted-foreground">
|
<CardTitle className="text-lg font-medium flex items-center gap-2 text-zinc-400">
|
||||||
<Archive className="h-5 w-5" />
|
<Archive className="h-5 w-5" />
|
||||||
Archived / Disabled
|
Archived / Disabled
|
||||||
<Badge variant="outline" className="ml-2">
|
<Badge variant="outline" className="ml-2 border-white/10 text-zinc-500">
|
||||||
{sortedDisabledProducts.length}
|
{sortedDisabledProducts.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -269,7 +312,7 @@ const ProductTable = ({
|
|||||||
<Table>
|
<Table>
|
||||||
{renderTableHeader()}
|
{renderTableHeader()}
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<AnimatePresence mode="popLayout">
|
<AnimatePresence>
|
||||||
{sortedDisabledProducts.map((product, index) =>
|
{sortedDisabledProducts.map((product, index) =>
|
||||||
renderProductRow(product, index, true),
|
renderProductRow(product, index, true),
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -26,6 +27,14 @@ export const ShippingTable: React.FC<ShippingTableProps> = ({
|
|||||||
onEditShipping,
|
onEditShipping,
|
||||||
onDeleteShipping,
|
onDeleteShipping,
|
||||||
}) => {
|
}) => {
|
||||||
|
// Browser detection
|
||||||
|
const [isFirefox, setIsFirefox] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ua = navigator.userAgent.toLowerCase();
|
||||||
|
setIsFirefox(ua.includes("firefox") && !ua.includes("chrome"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||||
<CardHeader className="py-4 px-6 border-b border-border/50 bg-muted/30">
|
<CardHeader className="py-4 px-6 border-b border-border/50 bg-muted/30">
|
||||||
@@ -45,8 +54,8 @@ export const ShippingTable: React.FC<ShippingTableProps> = ({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<AnimatePresence mode="popLayout">
|
{isFirefox ? (
|
||||||
{loading ? (
|
loading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={3} className="h-24 text-center">
|
<TableCell colSpan={3} className="h-24 text-center">
|
||||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||||
@@ -61,7 +70,6 @@ export const ShippingTable: React.FC<ShippingTableProps> = ({
|
|||||||
key={method._id}
|
key={method._id}
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||||
className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
|
className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
|
||||||
>
|
>
|
||||||
@@ -100,13 +108,76 @@ export const ShippingTable: React.FC<ShippingTableProps> = ({
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={3} className="h-32 text-center text-muted-foreground">
|
<TableCell colSpan={3} className="h-32 text-center text-muted-foreground">
|
||||||
<div className="flex flex-col items-center justify-center gap-2">
|
<div className="flex flex-col items-center justify-center gap-2">
|
||||||
<PackageX className="h-8 w-8 opacity-50" />
|
<PackageX className="h-8 w-8 opacity-20" />
|
||||||
<p>No shipping methods found</p>
|
<p>No shipping methods found</p>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)
|
||||||
</AnimatePresence>
|
) : (
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} className="h-24 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||||
|
<Skeleton className="h-4 w-4 rounded-full" />
|
||||||
|
Loading methods...
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : shippingMethods.length > 0 ? (
|
||||||
|
shippingMethods.map((method, index) => (
|
||||||
|
<motion.tr
|
||||||
|
key={method._id}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||||
|
className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium pl-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-8 w-8 rounded bg-primary/10 flex items-center justify-center">
|
||||||
|
<Truck className="h-4 w-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
{method.name}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center font-mono">£{method.price}</TableCell>
|
||||||
|
<TableCell className="text-right pr-6">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||||
|
onClick={() => onEditShipping(method)}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
|
onClick={() => onDeleteShipping(method._id ?? "")}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</motion.tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} className="h-32 text-center text-muted-foreground">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2">
|
||||||
|
<PackageX className="h-8 w-8 opacity-20" />
|
||||||
|
<p>No shipping methods found</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
138
components/ui/empty-state.tsx
Normal file
138
components/ui/empty-state.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Package,
|
||||||
|
ShoppingBag,
|
||||||
|
Users,
|
||||||
|
Truck,
|
||||||
|
MessageCircle,
|
||||||
|
Plus,
|
||||||
|
Share2,
|
||||||
|
LucideIcon
|
||||||
|
} from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon?: LucideIcon
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
actionLabel?: string
|
||||||
|
actionHref?: string
|
||||||
|
actionOnClick?: () => void
|
||||||
|
secondaryActionLabel?: string
|
||||||
|
secondaryActionHref?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EmptyState - Reusable component for empty tables/lists
|
||||||
|
* Shows an icon, title, description, and optional action button
|
||||||
|
*/
|
||||||
|
export function EmptyState({
|
||||||
|
icon: Icon = Package,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actionLabel,
|
||||||
|
actionHref,
|
||||||
|
actionOnClick,
|
||||||
|
secondaryActionLabel,
|
||||||
|
secondaryActionHref,
|
||||||
|
className = ""
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col items-center justify-center py-12 px-4 text-center ${className}`}>
|
||||||
|
<div className="h-16 w-16 rounded-full bg-muted/50 flex items-center justify-center mb-4">
|
||||||
|
<Icon className="h-8 w-8 text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium mb-2">{title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-sm mb-6">{description}</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{actionLabel && (
|
||||||
|
actionHref ? (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={actionHref}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
{actionLabel}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : actionOnClick ? (
|
||||||
|
<Button onClick={actionOnClick}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
{actionLabel}
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
{secondaryActionLabel && secondaryActionHref && (
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={secondaryActionHref}>
|
||||||
|
<Share2 className="h-4 w-4 mr-2" />
|
||||||
|
{secondaryActionLabel}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preset empty states for common scenarios
|
||||||
|
export function OrdersEmptyState() {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={ShoppingBag}
|
||||||
|
title="No orders yet"
|
||||||
|
description="When customers place orders, they'll appear here. Share your store link to start selling!"
|
||||||
|
secondaryActionLabel="Share Store"
|
||||||
|
secondaryActionHref="/dashboard/storefront"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductsEmptyState({ onAddProduct }: { onAddProduct?: () => void }) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={Package}
|
||||||
|
title="No products yet"
|
||||||
|
description="Add your first product to start selling. You can add products manually or import from a file."
|
||||||
|
actionLabel="Add Product"
|
||||||
|
actionOnClick={onAddProduct}
|
||||||
|
actionHref={onAddProduct ? undefined : "/dashboard/products/add"}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomersEmptyState() {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={Users}
|
||||||
|
title="No customers yet"
|
||||||
|
description="Once customers interact with your store, they'll appear here. Share your store link to attract customers!"
|
||||||
|
secondaryActionLabel="Share Store"
|
||||||
|
secondaryActionHref="/dashboard/storefront"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShippingEmptyState({ onAddMethod }: { onAddMethod?: () => void }) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={Truck}
|
||||||
|
title="No shipping methods"
|
||||||
|
description="Add shipping methods so customers know how they'll receive their orders."
|
||||||
|
actionLabel="Add Shipping Method"
|
||||||
|
actionOnClick={onAddMethod}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatsEmptyState() {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={MessageCircle}
|
||||||
|
title="No conversations yet"
|
||||||
|
description="Customer chats will appear here when they message you through Telegram."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
components/ui/relative-time.tsx
Normal file
114
components/ui/relative-time.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
import { format, formatDistanceToNow, isToday, isYesterday, differenceInMinutes } from "date-fns"
|
||||||
|
|
||||||
|
interface RelativeTimeProps {
|
||||||
|
date: Date | string | null | undefined
|
||||||
|
className?: string
|
||||||
|
showTooltip?: boolean
|
||||||
|
updateInterval?: number // ms, for auto-updating recent times
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RelativeTime - Displays time as "2 hours ago" with full date on hover
|
||||||
|
* Auto-updates for times less than 1 hour old
|
||||||
|
*/
|
||||||
|
export function RelativeTime({
|
||||||
|
date,
|
||||||
|
className = "",
|
||||||
|
showTooltip = true,
|
||||||
|
updateInterval = 60000 // Update every minute
|
||||||
|
}: RelativeTimeProps) {
|
||||||
|
const [, forceUpdate] = React.useReducer(x => x + 1, 0)
|
||||||
|
|
||||||
|
const parsedDate = React.useMemo(() => {
|
||||||
|
if (!date) return null
|
||||||
|
return typeof date === "string" ? new Date(date) : date
|
||||||
|
}, [date])
|
||||||
|
|
||||||
|
// Auto-update for recent times
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!parsedDate) return
|
||||||
|
|
||||||
|
const minutesAgo = differenceInMinutes(new Date(), parsedDate)
|
||||||
|
|
||||||
|
// Only auto-update if within the last hour
|
||||||
|
if (minutesAgo < 60) {
|
||||||
|
const interval = setInterval(forceUpdate, updateInterval)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [parsedDate, updateInterval])
|
||||||
|
|
||||||
|
if (!parsedDate || isNaN(parsedDate.getTime())) {
|
||||||
|
return <span className={className}>-</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatRelative = (d: Date): string => {
|
||||||
|
const now = new Date()
|
||||||
|
const minutesAgo = differenceInMinutes(now, d)
|
||||||
|
|
||||||
|
// Just now (< 1 minute)
|
||||||
|
if (minutesAgo < 1) return "Just now"
|
||||||
|
|
||||||
|
// Minutes ago (< 60 minutes)
|
||||||
|
if (minutesAgo < 60) return `${minutesAgo}m ago`
|
||||||
|
|
||||||
|
// Hours ago (< 24 hours and today)
|
||||||
|
if (isToday(d)) {
|
||||||
|
const hoursAgo = Math.floor(minutesAgo / 60)
|
||||||
|
return `${hoursAgo}h ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yesterday
|
||||||
|
if (isYesterday(d)) return "Yesterday"
|
||||||
|
|
||||||
|
// Use formatDistanceToNow for older dates
|
||||||
|
return formatDistanceToNow(d, { addSuffix: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullDate = format(parsedDate, "dd MMM yyyy, HH:mm")
|
||||||
|
const relativeText = formatRelative(parsedDate)
|
||||||
|
|
||||||
|
if (!showTooltip) {
|
||||||
|
return <span className={className}>{relativeText}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={300}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className={`cursor-default ${className}`}>{relativeText}</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="text-xs">
|
||||||
|
{fullDate}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to get relative time string without component
|
||||||
|
*/
|
||||||
|
export function getRelativeTimeString(date: Date | string | null | undefined): string {
|
||||||
|
if (!date) return "-"
|
||||||
|
const d = typeof date === "string" ? new Date(date) : date
|
||||||
|
if (isNaN(d.getTime())) return "-"
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const minutesAgo = differenceInMinutes(now, d)
|
||||||
|
|
||||||
|
if (minutesAgo < 1) return "Just now"
|
||||||
|
if (minutesAgo < 60) return `${minutesAgo}m ago`
|
||||||
|
if (isToday(d)) return `${Math.floor(minutesAgo / 60)}h ago`
|
||||||
|
if (isYesterday(d)) return "Yesterday"
|
||||||
|
|
||||||
|
return formatDistanceToNow(d, { addSuffix: true })
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Package, Clock, CheckCircle, AlertTriangle } from "lucide-react"
|
import { Package, Clock, CheckCircle, AlertTriangle } from "lucide-react"
|
||||||
|
|
||||||
export const statsConfig = [
|
export const statsConfig = [
|
||||||
{ title: "Total Orders", key: "totalOrders", icon: Package },
|
{ title: "Total Orders", key: "totalOrders", icon: Package, filterStatus: "all" },
|
||||||
{ title: "Completed Orders", key: "completedOrders", icon: CheckCircle },
|
{ title: "Completed Orders", key: "completedOrders", icon: CheckCircle, filterStatus: "completed" },
|
||||||
{ title: "Pending Orders", key: "ongoingOrders", icon: Clock },
|
{ title: "Pending Orders", key: "ongoingOrders", icon: Clock, filterStatus: "paid" },
|
||||||
{ title: "Cancelled Orders", key: "cancelledOrders", icon: AlertTriangle },
|
{ title: "Cancelled Orders", key: "cancelledOrders", icon: AlertTriangle, filterStatus: "cancelled" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
165
hooks/useFilterState.ts
Normal file
165
hooks/useFilterState.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react"
|
||||||
|
import { usePathname, useSearchParams, useRouter } from "next/navigation"
|
||||||
|
import { DateRange } from "react-day-picker"
|
||||||
|
|
||||||
|
interface FilterState {
|
||||||
|
searchQuery?: string
|
||||||
|
statusFilter?: string
|
||||||
|
dateRange?: DateRange
|
||||||
|
page?: number
|
||||||
|
itemsPerPage?: number
|
||||||
|
sortColumn?: string
|
||||||
|
sortDirection?: "asc" | "desc"
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFilterStateOptions {
|
||||||
|
/** Unique key for this page's filter state */
|
||||||
|
storageKey: string
|
||||||
|
/** Initialize from URL params on mount */
|
||||||
|
syncWithUrl?: boolean
|
||||||
|
/** Default values */
|
||||||
|
defaults?: Partial<FilterState>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useFilterState - Persist filter state across navigation
|
||||||
|
* Uses sessionStorage to remember filters per page
|
||||||
|
*/
|
||||||
|
export function useFilterState({
|
||||||
|
storageKey,
|
||||||
|
syncWithUrl = false,
|
||||||
|
defaults = {}
|
||||||
|
}: UseFilterStateOptions) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const fullKey = `filterState:${storageKey}`
|
||||||
|
|
||||||
|
// Initialize state from sessionStorage or URL params
|
||||||
|
const getInitialState = (): FilterState => {
|
||||||
|
if (typeof window === "undefined") return defaults
|
||||||
|
|
||||||
|
// First try URL params if syncWithUrl is enabled
|
||||||
|
if (syncWithUrl && searchParams) {
|
||||||
|
const urlState: FilterState = {}
|
||||||
|
const status = searchParams.get("status")
|
||||||
|
const search = searchParams.get("search")
|
||||||
|
const page = searchParams.get("page")
|
||||||
|
|
||||||
|
if (status) urlState.statusFilter = status
|
||||||
|
if (search) urlState.searchQuery = search
|
||||||
|
if (page) urlState.page = parseInt(page)
|
||||||
|
|
||||||
|
if (Object.keys(urlState).length > 0) {
|
||||||
|
return { ...defaults, ...urlState }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then try sessionStorage
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem(fullKey)
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored)
|
||||||
|
// Restore dateRange as Date objects
|
||||||
|
if (parsed.dateRange) {
|
||||||
|
if (parsed.dateRange.from) parsed.dateRange.from = new Date(parsed.dateRange.from)
|
||||||
|
if (parsed.dateRange.to) parsed.dateRange.to = new Date(parsed.dateRange.to)
|
||||||
|
}
|
||||||
|
return { ...defaults, ...parsed }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to load filter state from storage:", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
const [filterState, setFilterState] = useState<FilterState>(getInitialState)
|
||||||
|
|
||||||
|
// Save to sessionStorage whenever state changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(fullKey, JSON.stringify(filterState))
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to save filter state to storage:", e)
|
||||||
|
}
|
||||||
|
}, [filterState, fullKey])
|
||||||
|
|
||||||
|
// Update URL if syncWithUrl is enabled
|
||||||
|
useEffect(() => {
|
||||||
|
if (!syncWithUrl) return
|
||||||
|
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (filterState.statusFilter && filterState.statusFilter !== "all") {
|
||||||
|
params.set("status", filterState.statusFilter)
|
||||||
|
}
|
||||||
|
if (filterState.searchQuery) {
|
||||||
|
params.set("search", filterState.searchQuery)
|
||||||
|
}
|
||||||
|
if (filterState.page && filterState.page > 1) {
|
||||||
|
params.set("page", filterState.page.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = params.toString()
|
||||||
|
const newUrl = queryString ? `${pathname}?${queryString}` : pathname
|
||||||
|
|
||||||
|
// Only update if URL would change
|
||||||
|
const currentQuery = searchParams?.toString() || ""
|
||||||
|
if (queryString !== currentQuery) {
|
||||||
|
router.replace(newUrl, { scroll: false })
|
||||||
|
}
|
||||||
|
}, [filterState, syncWithUrl, pathname, router, searchParams])
|
||||||
|
|
||||||
|
// Convenience setters
|
||||||
|
const setSearchQuery = useCallback((query: string) => {
|
||||||
|
setFilterState(prev => ({ ...prev, searchQuery: query, page: 1 }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setStatusFilter = useCallback((status: string) => {
|
||||||
|
setFilterState(prev => ({ ...prev, statusFilter: status, page: 1 }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setDateRange = useCallback((range: DateRange | undefined) => {
|
||||||
|
setFilterState(prev => ({ ...prev, dateRange: range, page: 1 }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setPage = useCallback((page: number) => {
|
||||||
|
setFilterState(prev => ({ ...prev, page }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setItemsPerPage = useCallback((count: number) => {
|
||||||
|
setFilterState(prev => ({ ...prev, itemsPerPage: count, page: 1 }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setSort = useCallback((column: string, direction: "asc" | "desc") => {
|
||||||
|
setFilterState(prev => ({ ...prev, sortColumn: column, sortDirection: direction }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearFilters = useCallback(() => {
|
||||||
|
setFilterState(defaults)
|
||||||
|
}, [defaults])
|
||||||
|
|
||||||
|
const hasActiveFilters = Boolean(
|
||||||
|
filterState.searchQuery ||
|
||||||
|
(filterState.statusFilter && filterState.statusFilter !== "all") ||
|
||||||
|
filterState.dateRange?.from
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...filterState,
|
||||||
|
setFilterState,
|
||||||
|
setSearchQuery,
|
||||||
|
setStatusFilter,
|
||||||
|
setDateRange,
|
||||||
|
setPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
setSort,
|
||||||
|
clearFilters,
|
||||||
|
hasActiveFilters
|
||||||
|
}
|
||||||
|
}
|
||||||
188
hooks/useWidgetLayout.ts
Normal file
188
hooks/useWidgetLayout.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react"
|
||||||
|
|
||||||
|
// Per-widget settings types
|
||||||
|
export interface RecentActivitySettings {
|
||||||
|
itemCount: number // 5, 10, 15
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopProductsSettings {
|
||||||
|
itemCount: number // 3, 5, 10
|
||||||
|
showRevenue: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverviewSettings {
|
||||||
|
showChange: boolean // Show % change from previous period
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevenueChartSettings {
|
||||||
|
days: number // 7, 14, 30
|
||||||
|
showComparison: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LowStockSettings {
|
||||||
|
threshold: number // Show items with stock below this
|
||||||
|
itemCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecentCustomersSettings {
|
||||||
|
itemCount: number
|
||||||
|
showSpent: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingChatsSettings {
|
||||||
|
showPreview: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WidgetSettings =
|
||||||
|
| { type: "quick-actions" }
|
||||||
|
| { type: "overview"; settings: OverviewSettings }
|
||||||
|
| { type: "recent-activity"; settings: RecentActivitySettings }
|
||||||
|
| { type: "top-products"; settings: TopProductsSettings }
|
||||||
|
| { type: "revenue-chart"; settings: RevenueChartSettings }
|
||||||
|
| { type: "low-stock"; settings: LowStockSettings }
|
||||||
|
| { type: "recent-customers"; settings: RecentCustomersSettings }
|
||||||
|
| { type: "pending-chats"; settings: PendingChatsSettings }
|
||||||
|
|
||||||
|
export interface WidgetConfig {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
visible: boolean
|
||||||
|
order: number
|
||||||
|
settings?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_WIDGETS: WidgetConfig[] = [
|
||||||
|
{ id: "quick-actions", title: "Quick Actions", visible: true, order: 0 },
|
||||||
|
{ id: "overview", title: "Overview", visible: true, order: 1, settings: { showChange: false } },
|
||||||
|
{ id: "recent-activity", title: "Recent Activity", visible: true, order: 2, settings: { itemCount: 10 } },
|
||||||
|
{ id: "top-products", title: "Top Products", visible: true, order: 3, settings: { itemCount: 5, showRevenue: true } },
|
||||||
|
{ id: "revenue-chart", title: "Revenue Chart", visible: false, order: 4, settings: { days: 7, showComparison: false } },
|
||||||
|
{ id: "low-stock", title: "Low Stock Alerts", visible: false, order: 5, settings: { threshold: 5, itemCount: 5 } },
|
||||||
|
{ id: "recent-customers", title: "Recent Customers", visible: false, order: 6, settings: { itemCount: 5, showSpent: true } },
|
||||||
|
{ id: "pending-chats", title: "Pending Chats", visible: false, order: 7, settings: { showPreview: true } },
|
||||||
|
]
|
||||||
|
|
||||||
|
const STORAGE_KEY = "dashboard-widget-layout-v4"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useWidgetLayout - Persist and manage dashboard widget visibility, order, and settings
|
||||||
|
*/
|
||||||
|
export function useWidgetLayout() {
|
||||||
|
const [widgets, setWidgets] = useState<WidgetConfig[]>(DEFAULT_WIDGETS)
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
|
|
||||||
|
// Load from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored) as WidgetConfig[]
|
||||||
|
// Merge with defaults to handle new widgets added in future
|
||||||
|
const merged = DEFAULT_WIDGETS.map(defaultWidget => {
|
||||||
|
const savedWidget = parsed.find(w => w.id === defaultWidget.id)
|
||||||
|
return savedWidget
|
||||||
|
? { ...defaultWidget, ...savedWidget, settings: { ...defaultWidget.settings, ...savedWidget.settings } }
|
||||||
|
: defaultWidget
|
||||||
|
})
|
||||||
|
setWidgets(merged.sort((a, b) => a.order - b.order))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to load widget layout:", e)
|
||||||
|
}
|
||||||
|
setIsLoaded(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Save to localStorage whenever widgets change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoaded || typeof window === "undefined") return
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(widgets))
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to save widget layout:", e)
|
||||||
|
}
|
||||||
|
}, [widgets, isLoaded])
|
||||||
|
|
||||||
|
const toggleWidget = useCallback((widgetId: string) => {
|
||||||
|
setWidgets(prev =>
|
||||||
|
prev.map(w => w.id === widgetId ? { ...w, visible: !w.visible } : w)
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const moveWidget = useCallback((widgetId: string, direction: "up" | "down") => {
|
||||||
|
setWidgets(prev => {
|
||||||
|
const index = prev.findIndex(w => w.id === widgetId)
|
||||||
|
if (index === -1) return prev
|
||||||
|
|
||||||
|
const newIndex = direction === "up" ? index - 1 : index + 1
|
||||||
|
if (newIndex < 0 || newIndex >= prev.length) return prev
|
||||||
|
|
||||||
|
const newWidgets = [...prev]
|
||||||
|
const [widget] = newWidgets.splice(index, 1)
|
||||||
|
newWidgets.splice(newIndex, 0, widget)
|
||||||
|
|
||||||
|
// Update order values
|
||||||
|
return newWidgets.map((w, i) => ({ ...w, order: i }))
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateWidgetSettings = useCallback((widgetId: string, newSettings: Record<string, any>) => {
|
||||||
|
setWidgets(prev =>
|
||||||
|
prev.map(w => w.id === widgetId
|
||||||
|
? { ...w, settings: { ...w.settings, ...newSettings } }
|
||||||
|
: w
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
|
const getWidgetSettings = useCallback(<T extends Record<string, any>>(widgetId: string): T | undefined => {
|
||||||
|
return widgets.find(w => w.id === widgetId)?.settings as T | undefined
|
||||||
|
}, [widgets])
|
||||||
|
|
||||||
|
const resetLayout = useCallback(() => {
|
||||||
|
setWidgets(DEFAULT_WIDGETS)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const getVisibleWidgets = useCallback(() => {
|
||||||
|
return widgets.filter(w => w.visible).sort((a, b) => a.order - b.order)
|
||||||
|
}, [widgets])
|
||||||
|
|
||||||
|
const isWidgetVisible = useCallback((widgetId: string) => {
|
||||||
|
return widgets.find(w => w.id === widgetId)?.visible ?? true
|
||||||
|
}, [widgets])
|
||||||
|
|
||||||
|
// Reorder widgets by moving activeId to overId's position
|
||||||
|
const reorderWidgets = useCallback((activeId: string, overId: string) => {
|
||||||
|
setWidgets(prev => {
|
||||||
|
const oldIndex = prev.findIndex(w => w.id === activeId)
|
||||||
|
const newIndex = prev.findIndex(w => w.id === overId)
|
||||||
|
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return prev
|
||||||
|
|
||||||
|
const newWidgets = [...prev]
|
||||||
|
const [removed] = newWidgets.splice(oldIndex, 1)
|
||||||
|
newWidgets.splice(newIndex, 0, removed)
|
||||||
|
|
||||||
|
// Update order values
|
||||||
|
return newWidgets.map((w, i) => ({ ...w, order: i }))
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
widgets,
|
||||||
|
toggleWidget,
|
||||||
|
moveWidget,
|
||||||
|
reorderWidgets,
|
||||||
|
updateWidgetSettings,
|
||||||
|
getWidgetSettings,
|
||||||
|
resetLayout,
|
||||||
|
getVisibleWidgets,
|
||||||
|
isWidgetVisible,
|
||||||
|
isLoaded
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ export {
|
|||||||
getCustomerInsightsWithStore,
|
getCustomerInsightsWithStore,
|
||||||
getOrderAnalyticsWithStore,
|
getOrderAnalyticsWithStore,
|
||||||
getStoreIdForUser,
|
getStoreIdForUser,
|
||||||
|
formatGBP,
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
type AnalyticsOverview,
|
type AnalyticsOverview,
|
||||||
|
|||||||
@@ -190,10 +190,12 @@ export const getCustomerInsights = async (
|
|||||||
storeId?: string,
|
storeId?: string,
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 10,
|
limit: number = 10,
|
||||||
|
sortBy: string = "spent",
|
||||||
): Promise<CustomerInsights> => {
|
): Promise<CustomerInsights> => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: page.toString(),
|
page: page.toString(),
|
||||||
limit: limit.toString(),
|
limit: limit.toString(),
|
||||||
|
sort: sortBy,
|
||||||
});
|
});
|
||||||
if (storeId) params.append("storeId", storeId);
|
if (storeId) params.append("storeId", storeId);
|
||||||
|
|
||||||
@@ -272,9 +274,10 @@ export const getProductPerformanceWithStore = async (): Promise<
|
|||||||
export const getCustomerInsightsWithStore = async (
|
export const getCustomerInsightsWithStore = async (
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 10,
|
limit: number = 10,
|
||||||
|
sortBy: string = "spent",
|
||||||
): Promise<CustomerInsights> => {
|
): Promise<CustomerInsights> => {
|
||||||
const storeId = getStoreIdForUser();
|
const storeId = getStoreIdForUser();
|
||||||
return getCustomerInsights(storeId, page, limit);
|
return getCustomerInsights(storeId, page, limit, sortBy);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getOrderAnalyticsWithStore = async (
|
export const getOrderAnalyticsWithStore = async (
|
||||||
|
|||||||
296
package-lock.json
generated
296
package-lock.json
generated
@@ -1,13 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "my-v0-project",
|
"name": "my-v0-project",
|
||||||
"version": "2.2.0",
|
"version": "2.2.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "my-v0-project",
|
"name": "my-v0-project",
|
||||||
"version": "2.2.0",
|
"version": "2.2.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.2",
|
"@radix-ui/react-accordion": "^1.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||||
@@ -34,6 +37,7 @@
|
|||||||
"@radix-ui/react-toggle": "^1.1.1",
|
"@radix-ui/react-toggle": "^1.1.1",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.1",
|
"@radix-ui/react-toggle-group": "^1.1.1",
|
||||||
"@radix-ui/react-tooltip": "^1.1.6",
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"axios": "^1.8.1",
|
"axios": "^1.8.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -42,12 +46,15 @@
|
|||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
"embla-carousel-react": "8.5.1",
|
"embla-carousel-react": "8.5.1",
|
||||||
"form-data": "^4.0.2",
|
"form-data": "^4.0.2",
|
||||||
|
"framer-motion": "^12.25.0",
|
||||||
"input-otp": "1.4.1",
|
"input-otp": "1.4.1",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
"next": "^16.1.1",
|
"next": "^16.1.1",
|
||||||
"next-themes": "latest",
|
"next-themes": "latest",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
"react-countup": "^6.5.3",
|
||||||
"react-day-picker": "8.10.1",
|
"react-day-picker": "8.10.1",
|
||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
@@ -55,6 +62,8 @@
|
|||||||
"react-hook-form": "^7.54.1",
|
"react-hook-form": "^7.54.1",
|
||||||
"react-markdown": "^10.0.0",
|
"react-markdown": "^10.0.0",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
|
"react-window": "^2.2.4",
|
||||||
|
"react-window-infinite-loader": "^2.0.0",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
@@ -63,7 +72,6 @@
|
|||||||
"zod": "^3.25.0"
|
"zod": "^3.25.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@distube/ytdl-core": "^4.16.12",
|
|
||||||
"@next/bundle-analyzer": "^16.1.1",
|
"@next/bundle-analyzer": "^16.1.1",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@types/lodash": "^4.17.16",
|
"@types/lodash": "^4.17.16",
|
||||||
@@ -383,26 +391,53 @@
|
|||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@distube/ytdl-core": {
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
"version": "4.16.12",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@distube/ytdl-core/-/ytdl-core-4.16.12.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
"integrity": "sha512-/NR8Jur1Q4E2oD+DJta7uwWu7SkqdEkhwERt7f4iune70zg7ZlLLTOHs1+jgg3uD2jQjpdk7RGC16FqstG4RsA==",
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"http-cookie-agent": "^7.0.1",
|
"tslib": "^2.0.0"
|
||||||
"https-proxy-agent": "^7.0.6",
|
|
||||||
"m3u8stream": "^0.8.6",
|
|
||||||
"miniget": "^4.2.3",
|
|
||||||
"sax": "^1.4.1",
|
|
||||||
"tough-cookie": "^5.1.2",
|
|
||||||
"undici": "^7.8.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"peerDependencies": {
|
||||||
"node": ">=20.18.1"
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"peerDependencies": {
|
||||||
"url": "https://github.com/distubejs/ytdl-core?sponsor"
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
@@ -2831,6 +2866,31 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-virtual": {
|
||||||
|
"version": "3.13.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz",
|
||||||
|
"integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/virtual-core": "3.13.18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/virtual-core": {
|
||||||
|
"version": "3.13.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
|
||||||
|
"integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.9.0",
|
"version": "0.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
|
||||||
@@ -3473,16 +3533,6 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/agent-base": {
|
|
||||||
"version": "7.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
|
||||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
@@ -4248,6 +4298,11 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/countup.js": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/countup.js/-/countup.js-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-llqrvyXztRFPp6+i8jx25phHWcVWhrHO4Nlt0uAOSKHB8778zzQswa4MU3qKBvkXfJKftRYFJuVHez67lyKdHg=="
|
||||||
|
},
|
||||||
"node_modules/cross-env": {
|
"node_modules/cross-env": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
||||||
@@ -5646,6 +5701,32 @@
|
|||||||
"url": "https://github.com/sponsors/rawify"
|
"url": "https://github.com/sponsors/rawify"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/framer-motion": {
|
||||||
|
"version": "12.26.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.26.0.tgz",
|
||||||
|
"integrity": "sha512-yFatQro5/mNKVqBT/IAMq9v27z4dJsjKklnsCu7mdp2mrn78UW3mkG4qfmmLxHzh6WMts1o+A4FH4Iiomt/jFQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-dom": "^12.24.11",
|
||||||
|
"motion-utils": "^12.24.10",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/is-prop-valid": "*",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/is-prop-valid": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -6082,45 +6163,6 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/http-cookie-agent": {
|
|
||||||
"version": "7.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/http-cookie-agent/-/http-cookie-agent-7.0.3.tgz",
|
|
||||||
"integrity": "sha512-EeZo7CGhfqPW6R006rJa4QtZZUpBygDa2HZH3DJqsTzTjyRE6foDBVQIv/pjVsxHC8z2GIdbB1Hvn9SRorP3WQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"agent-base": "^7.1.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/3846masa"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"tough-cookie": "^4.0.0 || ^5.0.0 || ^6.0.0",
|
|
||||||
"undici": "^7.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"undici": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/https-proxy-agent": {
|
|
||||||
"version": "7.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
|
||||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"agent-base": "^7.1.2",
|
|
||||||
"debug": "4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -6985,20 +7027,6 @@
|
|||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/m3u8stream": {
|
|
||||||
"version": "0.8.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/m3u8stream/-/m3u8stream-0.8.6.tgz",
|
|
||||||
"integrity": "sha512-LZj8kIVf9KCphiHmH7sbFQTVe4tOemb202fWwvJwR9W5ENW/1hxJN6ksAWGhQgSBSa3jyWhnjKU1Fw1GaOdbyA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"miniget": "^4.2.2",
|
|
||||||
"sax": "^1.2.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -7648,16 +7676,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/miniget": {
|
|
||||||
"version": "4.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/miniget/-/miniget-4.2.3.tgz",
|
|
||||||
"integrity": "sha512-SjbDPDICJ1zT+ZvQwK0hUcRY4wxlhhNpHL9nJOB2MEAXRGagTljsO8MEDzQMTFf0Q8g4QNi8P9lEm/g7e+qgzA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
@@ -7691,6 +7709,19 @@
|
|||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/motion-dom": {
|
||||||
|
"version": "12.24.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.24.11.tgz",
|
||||||
|
"integrity": "sha512-DlWOmsXMJrV8lzZyd+LKjG2CXULUs++bkq8GZ2Sr0R0RRhs30K2wtY+LKiTjhmJU3W61HK+rB0GLz6XmPvTA1A==",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-utils": "^12.24.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/motion-utils": {
|
||||||
|
"version": "12.24.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.24.10.tgz",
|
||||||
|
"integrity": "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww=="
|
||||||
|
},
|
||||||
"node_modules/mrmime": {
|
"node_modules/mrmime": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||||
@@ -8427,6 +8458,17 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-countup": {
|
||||||
|
"version": "6.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-countup/-/react-countup-6.5.3.tgz",
|
||||||
|
"integrity": "sha512-udnqVQitxC7QWADSPDOxVWULkLvKUWrDapn5i53HE4DPRVgs+Y5rr4bo25qEl8jSh+0l2cToJgGMx+clxPM3+w==",
|
||||||
|
"dependencies": {
|
||||||
|
"countup.js": "^2.8.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">= 16.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-day-picker": {
|
"node_modules/react-day-picker": {
|
||||||
"version": "8.10.1",
|
"version": "8.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
|
||||||
@@ -8651,6 +8693,24 @@
|
|||||||
"react-dom": ">=16.6.0"
|
"react-dom": ">=16.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-window": {
|
||||||
|
"version": "2.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.5.tgz",
|
||||||
|
"integrity": "sha512-6viWvPSZvVuMIe9hrl4IIZoVfO/npiqOb03m4Z9w+VihmVzBbiudUrtUqDpsWdKvd/Ai31TCR25CBcFFAUm28w==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-window-infinite-loader": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-window-infinite-loader/-/react-window-infinite-loader-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-dioOyvShGheEqqFHcPNKCixCOc2evwb2VEt9sitfJfTZ1hir8m6b8W0CNBvcUj+8Y8IeWu4yb88DI7k88aYTQQ==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -8958,13 +9018,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sax": {
|
|
||||||
"version": "1.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
|
|
||||||
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0"
|
|
||||||
},
|
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.25.0",
|
"version": "0.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
|
||||||
@@ -9699,26 +9752,6 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tldts": {
|
|
||||||
"version": "6.1.86",
|
|
||||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
|
|
||||||
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"tldts-core": "^6.1.86"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"tldts": "bin/cli.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tldts-core": {
|
|
||||||
"version": "6.1.86",
|
|
||||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
|
|
||||||
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@@ -9742,19 +9775,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tough-cookie": {
|
|
||||||
"version": "5.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
|
||||||
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"tldts": "^6.1.32"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/trim-lines": {
|
"node_modules/trim-lines": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||||
@@ -9975,16 +9995,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
|
||||||
"version": "7.16.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz",
|
|
||||||
"integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.18.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.20.0",
|
"version": "6.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
"analyze": "ANALYZE=true next build"
|
"analyze": "ANALYZE=true next build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.2",
|
"@radix-ui/react-accordion": "^1.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"commitHash": "7b95589",
|
"commitHash": "a6b7286",
|
||||||
"buildTime": "2026-01-12T06:32:31.897Z"
|
"buildTime": "2026-01-12T10:20:09.966Z"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user