Compare commits
33 Commits
a0605e47de
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38779c2033 | ||
|
|
600ba1e10e | ||
|
|
66964a3218 | ||
|
|
a07ca55a1e | ||
|
|
4c15f433d9 | ||
|
|
1242b8fd46 | ||
|
|
fe01f31538 | ||
|
|
a6e6cd0757 | ||
|
|
9acd18955e | ||
|
|
318927cd0c | ||
|
|
a6b7286b45 | ||
|
|
d78e6c0725 | ||
|
|
3f9d28bf1b | ||
|
|
064cd7a486 | ||
|
|
6cd658c4cb | ||
|
|
6997838bf7 | ||
|
|
e369741b2d | ||
|
|
7ddcd7afb6 | ||
|
|
3ffb64cf9a | ||
|
|
e9737c8b24 | ||
|
|
244014f33a | ||
|
|
1186952ed8 | ||
|
|
0bb1497db6 | ||
|
|
688f519fd6 | ||
|
|
73adbe5d07 | ||
|
|
63c833b510 | ||
|
|
bfd31f9d35 | ||
|
|
f7e768f6d6 | ||
|
|
7c7db0fc09 | ||
|
|
211cdc71f9 | ||
|
|
7b95589867 | ||
|
|
c209dd60fc | ||
|
|
a05787a091 |
@@ -4,9 +4,9 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/common/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/common/label";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Loader2, ArrowRight } from "lucide-react";
|
import { Loader2, ArrowRight } from "lucide-react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import React, { Suspense, lazy } from "react";
|
import React, { Suspense, lazy, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
|
||||||
// Use lazy loading for the form component
|
// Use lazy loading for the form component
|
||||||
@@ -30,6 +31,10 @@ function LoginLoading() {
|
|||||||
|
|
||||||
// Main page component
|
// Main page component
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Removed prefetch to avoid middleware redirects when unauthenticated
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex items-center justify-center min-h-screen overflow-hidden">
|
<div className="relative flex items-center justify-center min-h-screen overflow-hidden">
|
||||||
<AuthBackground />
|
<AuthBackground />
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/common/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/common/label";
|
||||||
import { Loader2, ArrowRight } from "lucide-react";
|
import { Loader2, ArrowRight } from "lucide-react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { toast } from "@/hooks/use-toast";
|
import { toast } from "@/lib/hooks/use-toast";
|
||||||
|
|
||||||
// Matches LoginPage background
|
// Matches LoginPage background
|
||||||
const AuthBackground = () => (
|
const AuthBackground = () => (
|
||||||
@@ -164,3 +164,5 @@ export default function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
import { useState, useEffect, Suspense } from "react";
|
import { useState, useEffect, Suspense } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { fetchClient } from "@/lib/api-client";
|
import { fetchClient } from "@/lib/api/api-client";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/common/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/common/label";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
interface Vendor {
|
interface Vendor {
|
||||||
@@ -193,3 +193,5 @@ export default function ResetPasswordPage() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/common/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/common/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/common/textarea";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/common/select";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/common/badge";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/common/table";
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/common/alert-dialog";
|
||||||
import { UserX, Shield, Search, Ban, Unlock, Loader2 } from "lucide-react";
|
import { UserX, Shield, Search, Ban, Unlock, Loader2 } from "lucide-react";
|
||||||
import { fetchClient } from "@/lib/api-client";
|
import { fetchClient } from "@/lib/api/api-client";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/lib/hooks/use-toast";
|
||||||
|
|
||||||
interface BlockedUser {
|
interface BlockedUser {
|
||||||
_id: string;
|
_id: string;
|
||||||
@@ -446,3 +446,4 @@ export default function AdminBanPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
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/common/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/common/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/common/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/common/textarea";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/common/select";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/common/badge";
|
||||||
import { UserPlus, Mail, Copy, Check } from "lucide-react";
|
import { UserPlus, Mail, Copy, Check } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -206,3 +206,4 @@ export default function AdminInvitePage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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/common/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/common/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,184 @@ 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>
|
||||||
|
|
||||||
|
<MotionWrapper>
|
||||||
|
<div className="space-y-8">
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
<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">Total Orders</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Total Orders</CardTitle>
|
||||||
<Package className="h-4 w-4 text-muted-foreground" />
|
<div className="p-2 rounded-lg bg-primary/10">
|
||||||
|
<Package className="h-4 w-4 text-primary" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{systemStats?.orders || 0}</div>
|
<div className="text-2xl font-bold">{systemStats?.orders || 0}</div>
|
||||||
<p className="text-xs text-muted-foreground">All platform orders</p>
|
<div className="flex items-center mt-1">
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-primary mr-2" />
|
||||||
|
<p className="text-xs text-muted-foreground">Lifetime volume</p>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<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">Acknowledged</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Acknowledged</CardTitle>
|
||||||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
<div className="p-2 rounded-lg bg-purple-500/10">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-purple-500" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{acknowledgedOrders.length}</div>
|
<div className="text-2xl font-bold">{acknowledgedOrders.length}</div>
|
||||||
<p className="text-xs text-muted-foreground">Vendor accepted</p>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<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">Paid</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Paid</CardTitle>
|
||||||
<Package className="h-4 w-4 text-muted-foreground" />
|
<div className="p-2 rounded-lg bg-emerald-500/10">
|
||||||
|
<DollarSign className="h-4 w-4 text-emerald-500" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{paidOrders.length}</div>
|
<div className="text-2xl font-bold">{paidOrders.length}</div>
|
||||||
<p className="text-xs text-muted-foreground">Payment received</p>
|
<div className="flex items-center mt-1">
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-emerald-500 mr-2" />
|
||||||
|
<p className="text-xs text-muted-foreground">Processing</p>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<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">Completed</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Completed</CardTitle>
|
||||||
<Package className="h-4 w-4 text-muted-foreground" />
|
<div className="p-2 rounded-lg bg-blue-500/10">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-blue-500" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{completedOrders.length}</div>
|
<div className="text-2xl font-bold">{completedOrders.length}</div>
|
||||||
<p className="text-xs text-muted-foreground">Successfully delivered</p>
|
<div className="flex items-center mt-1">
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-blue-500 mr-2" />
|
||||||
|
<p className="text-xs text-muted-foreground">Delivered</p>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Orders Table with Pagination */}
|
{/* Orders Table with Pagination */}
|
||||||
|
<div className="bg-background/50 backdrop-blur-sm rounded-xl border border-border/40 overflow-hidden shadow-sm">
|
||||||
<OrdersTable orders={orders} />
|
<OrdersTable orders={orders} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Order Analytics */}
|
{/* Order Analytics */}
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<Card>
|
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Order Status Distribution</CardTitle>
|
<CardTitle>Status Distribution</CardTitle>
|
||||||
<CardDescription>Breakdown of recent orders by status</CardDescription>
|
<CardDescription>Breakdown of active orders</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-muted/30 transition-colors">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="w-3 h-3 bg-purple-500 rounded-full"></div>
|
<div className="w-2 h-12 bg-purple-500 rounded-full"></div>
|
||||||
<span className="text-sm">Acknowledged</span>
|
<div>
|
||||||
|
<p className="font-medium text-sm">Acknowledged</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Waiting for shipment</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">
|
</div>
|
||||||
|
<span className="font-bold">
|
||||||
{orders.length > 0 ? Math.round((acknowledgedOrders.length / orders.length) * 100) : 0}%
|
{orders.length > 0 ? Math.round((acknowledgedOrders.length / orders.length) * 100) : 0}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-muted/30 transition-colors">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="w-3 h-3 bg-emerald-500 rounded-full"></div>
|
<div className="w-2 h-12 bg-emerald-500 rounded-full"></div>
|
||||||
<span className="text-sm">Paid</span>
|
<div>
|
||||||
|
<p className="font-medium text-sm">Paid</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Payment confirmed</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">
|
</div>
|
||||||
|
<span className="font-bold">
|
||||||
{orders.length > 0 ? Math.round((paidOrders.length / orders.length) * 100) : 0}%
|
{orders.length > 0 ? Math.round((paidOrders.length / orders.length) * 100) : 0}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-muted/30 transition-colors">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
<div className="w-2 h-12 bg-blue-500 rounded-full"></div>
|
||||||
<span className="text-sm">Completed</span>
|
<div>
|
||||||
|
<p className="font-medium text-sm">Completed</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Successfully concluded</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">
|
</div>
|
||||||
|
<span className="font-bold">
|
||||||
{orders.length > 0 ? Math.round((completedOrders.length / orders.length) * 100) : 0}%
|
{orders.length > 0 ? Math.round((completedOrders.length / orders.length) * 100) : 0}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Order Summary</CardTitle>
|
<CardTitle>Activity Summary</CardTitle>
|
||||||
<CardDescription>Recent order activity breakdown</CardDescription>
|
<CardDescription>Recent volume breakdown</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between pb-4 border-b border-border/40">
|
||||||
<span className="text-sm">Total Recent Orders</span>
|
<span className="text-sm text-muted-foreground">Total Displayed Orders</span>
|
||||||
<span className="text-sm font-medium">{orders.length}</span>
|
<span className="text-xl font-bold">{orders.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<span className="text-sm">Acknowledged</span>
|
<div className="space-y-1">
|
||||||
<span className="text-sm font-medium">{acknowledgedOrders.length}</span>
|
<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>
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-1">
|
||||||
<span className="text-sm">Paid</span>
|
<span className="text-xs uppercase text-muted-foreground tracking-wider">Finished</span>
|
||||||
<span className="text-sm font-medium">{paidOrders.length}</span>
|
<p className="text-lg font-semibold">{completedOrders.length}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-1">
|
||||||
<span className="text-sm">Completed</span>
|
<span className="text-xs uppercase text-muted-foreground tracking-wider">Voided</span>
|
||||||
<span className="text-sm font-medium">{completedOrders.length}</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 className="flex items-center justify-between">
|
|
||||||
<span className="text-sm">Cancelled</span>
|
|
||||||
<span className="text-sm font-medium">{cancelledOrders.length}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</MotionWrapper>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import React, { Suspense, lazy, useState, useEffect, Component, ReactNode } from "react";
|
import React, { Suspense, lazy, useState, useEffect, Component, ReactNode } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/common/tabs";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/common/card";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/common/alert";
|
||||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
// Error Boundary Component
|
// Error Boundary Component
|
||||||
@@ -183,7 +183,7 @@ function AdminComponentSkeleton({ showSlowWarning = false }: { showSlowWarning?:
|
|||||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
|
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
|
||||||
<Card
|
<Card
|
||||||
key={i}
|
key={i}
|
||||||
className="overflow-hidden animate-in fade-in slide-in-from-bottom-4"
|
className="overflow-hidden animate-in fade-in slide-in-from-bottom-4 border-border/40 bg-background/50 backdrop-blur-sm shadow-sm"
|
||||||
style={{
|
style={{
|
||||||
animationDelay: `${i * 50}ms`,
|
animationDelay: `${i * 50}ms`,
|
||||||
animationDuration: '400ms',
|
animationDuration: '400ms',
|
||||||
@@ -288,7 +288,7 @@ function ManagementCardsSkeleton({ showSlowWarning = false }: { showSlowWarning?
|
|||||||
{[1, 2, 3, 4].map((i) => (
|
{[1, 2, 3, 4].map((i) => (
|
||||||
<Card
|
<Card
|
||||||
key={i}
|
key={i}
|
||||||
className="overflow-hidden animate-in fade-in slide-in-from-bottom-4"
|
className="overflow-hidden animate-in fade-in slide-in-from-bottom-4 border-border/40 bg-background/50 backdrop-blur-sm shadow-sm"
|
||||||
style={{
|
style={{
|
||||||
animationDelay: `${i * 75}ms`,
|
animationDelay: `${i * 75}ms`,
|
||||||
animationDuration: '400ms',
|
animationDuration: '400ms',
|
||||||
@@ -355,7 +355,7 @@ export default function AdminPage() {
|
|||||||
const loadTime = performance.now() - startTime;
|
const loadTime = performance.now() - startTime;
|
||||||
console.log(`[Performance] AdminAnalytics prefetched in ${loadTime.toFixed(2)}ms`);
|
console.log(`[Performance] AdminAnalytics prefetched in ${loadTime.toFixed(2)}ms`);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => { });
|
||||||
} else if (tab === "management") {
|
} else if (tab === "management") {
|
||||||
// Prefetch management components
|
// Prefetch management components
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -368,7 +368,7 @@ export default function AdminPage() {
|
|||||||
const loadTime = performance.now() - startTime;
|
const loadTime = performance.now() - startTime;
|
||||||
console.log(`[Performance] Management components prefetched in ${loadTime.toFixed(2)}ms`);
|
console.log(`[Performance] Management components prefetched in ${loadTime.toFixed(2)}ms`);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
setPrefetchedTabs(prev => new Set(prev).add(tab));
|
setPrefetchedTabs(prev => new Set(prev).add(tab));
|
||||||
@@ -392,22 +392,23 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between animate-in fade-in slide-in-from-top-2 duration-300">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Admin Dashboard</h1>
|
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Admin Dashboard</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">Platform analytics and vendor management</p>
|
<p className="text-sm text-muted-foreground mt-1">Platform analytics and vendor management</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm" className="border-border/50 bg-background/50 backdrop-blur-sm hover:bg-background/80 transition-all">
|
||||||
<Link href="/dashboard">Back to Dashboard</Link>
|
<Link href="/dashboard">Back to Dashboard</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</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>
|
||||||
@@ -464,3 +466,4 @@ export default function AdminPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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/common/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/common/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/common/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,174 @@ 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>
|
||||||
|
|
||||||
|
<MotionWrapper>
|
||||||
|
<div className="space-y-8">
|
||||||
<SystemStatusCard />
|
<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">
|
||||||
|
<Server className="h-4 w-4 text-green-500" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Badge variant="default" className="bg-green-500">Online</Badge>
|
<span className="text-2xl font-bold">
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{systemStatus ? formatUptime(systemStatus.uptimeSeconds) : 'N/A'}
|
{systemStatus ? formatUptime(systemStatus.uptimeSeconds) : 'N/A'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<div className="flex items-center mt-2 space-x-2">
|
||||||
Last checked: {new Date().toLocaleTimeString()}
|
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||||
</p>
|
Online
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Checked: {new Date().toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
||||||
|
<Database className="h-4 w-4 text-blue-500" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Badge variant="default" className="bg-green-500">Connected</Badge>
|
<span className="text-2xl font-bold">
|
||||||
<span className="text-sm text-muted-foreground">
|
{systemStatus ? `${(systemStatus.counts.vendors + systemStatus.counts.orders + systemStatus.counts.products).toLocaleString()}` : '0'}
|
||||||
{systemStatus ? `${systemStatus.counts.vendors + systemStatus.counts.orders + systemStatus.counts.products} records` : 'N/A'}
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground self-end mb-1">records</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center mt-2 space-x-2">
|
||||||
|
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">
|
||||||
|
Connected
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
4 active collections
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
|
||||||
Total collections: 4
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
||||||
|
<HardDrive className="h-4 w-4 text-purple-500" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Badge variant={memoryUsagePercent > 80 ? "destructive" : memoryUsagePercent > 60 ? "secondary" : "outline"}>
|
<span className="text-2xl font-bold">
|
||||||
{memoryUsagePercent}%
|
|
||||||
</Badge>
|
|
||||||
<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">
|
||||||
|
<Badge variant="outline" className={`
|
||||||
|
${memoryUsagePercent > 80 ? 'bg-red-500/10 text-red-500 border-red-500/20' :
|
||||||
|
memoryUsagePercent > 60 ? 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20' :
|
||||||
|
'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'}
|
Total: {systemStatus ? formatBytes(systemStatus.memory.heapTotal) : 'N/A'}
|
||||||
</p>
|
</span>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
||||||
|
<Activity className="h-4 w-4 text-orange-500" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Badge variant="default" className="bg-green-500">Active</Badge>
|
<span className="text-2xl font-bold">
|
||||||
<span className="text-sm text-muted-foreground">
|
{systemStatus ? systemStatus.counts.vendors : '0'}
|
||||||
{systemStatus ? `${systemStatus.counts.vendors} vendors` : 'N/A'}
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground self-end mb-1">Active Vendors</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center mt-2 space-x-2">
|
||||||
|
<Badge variant="outline" className="bg-orange-500/10 text-orange-500 border-orange-500/20">
|
||||||
|
Live
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{systemStatus ? `${systemStatus.counts.orders} orders` : '0 orders'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
|
||||||
{systemStatus ? `${systemStatus.counts.orders} orders, ${systemStatus.counts.products} products` : 'N/A'}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
||||||
|
<Cpu className="h-4 w-4 text-zinc-500" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<Badge variant="outline">
|
<div className="flex items-center gap-4">
|
||||||
{systemStatus ? `Node ${systemStatus.versions.node}` : 'N/A'}
|
<div>
|
||||||
</Badge>
|
<p className="text-2xl font-bold">Node.js</p>
|
||||||
<span className="text-sm text-muted-foreground">Runtime</span>
|
<p className="text-xs text-muted-foreground mt-1">Runtime</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="text-sm h-7">
|
||||||
|
{systemStatus ? `v${systemStatus.versions.node}` : 'N/A'}
|
||||||
|
</Badge>
|
||||||
|
</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>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
|
||||||
{systemStatus ? `V8: ${systemStatus.versions.v8}` : 'N/A'}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</MotionWrapper>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/common/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/common/input";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/common/table";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/common/tooltip";
|
||||||
import { Search, Ban, UserCheck, Package, DollarSign, Loader2, Repeat } from "lucide-react";
|
import { Search, Ban, UserCheck, Package, DollarSign, Loader2, Repeat, Users, ShoppingBag, CreditCard, UserX } from "lucide-react";
|
||||||
import { fetchClient } from "@/lib/api-client";
|
import { fetchClient } from "@/lib/api/api-client";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/lib/hooks/use-toast";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
interface TelegramUser {
|
interface TelegramUser {
|
||||||
telegramUserId: string;
|
telegramUserId: string;
|
||||||
@@ -49,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);
|
||||||
@@ -88,123 +97,86 @@ export default function AdminUsersPage() {
|
|||||||
const totalSpent = users.reduce((sum, u) => sum + u.totalSpent, 0);
|
const totalSpent = users.reduce((sum, u) => sum + u.totalSpent, 0);
|
||||||
const totalOrders = users.reduce((sum, u) => sum + u.totalOrders, 0);
|
const totalOrders = users.reduce((sum, u) => sum + u.totalOrders, 0);
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
title: "Total Users",
|
||||||
|
value: users.length,
|
||||||
|
description: "Registered users",
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Users with Orders",
|
||||||
|
value: usersWithOrders.length,
|
||||||
|
description: `${users.length > 0 ? Math.round((usersWithOrders.length / users.length) * 100) : 0}% conversion rate`,
|
||||||
|
icon: ShoppingBag,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Total Revenue",
|
||||||
|
value: formatCurrency(totalSpent),
|
||||||
|
description: `${totalOrders} total orders`,
|
||||||
|
icon: DollarSign,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Returning",
|
||||||
|
value: returningCustomers.length,
|
||||||
|
description: `${usersWithOrders.length > 0 ? Math.round((returningCustomers.length / usersWithOrders.length) * 100) : 0}% of customers`,
|
||||||
|
icon: Repeat,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Blocked",
|
||||||
|
value: blockedUsers.length,
|
||||||
|
description: `${users.length > 0 ? Math.round((blockedUsers.length / users.length) * 100) : 0}% blocked rate`,
|
||||||
|
icon: UserX,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 animate-in fade-in duration-500">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Telegram Users</h1>
|
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Telegram Users</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">Manage Telegram user accounts and view statistics</p>
|
<p className="text-sm text-muted-foreground mt-1">Manage Telegram user accounts and view statistics</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="grid gap-4 md:grid-cols-5">
|
<div className="grid gap-4 md:grid-cols-5">
|
||||||
<Card>
|
{stats.map((stat, i) => (
|
||||||
|
<Card key={i} className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||||
<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">Total Users</CardTitle>
|
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
|
||||||
|
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-4">
|
<div className="h-12 flex items-center">
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground/50" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="animate-in fade-in slide-in-from-bottom-2 duration-500" style={{ animationDelay: `${i * 100}ms` }}>
|
||||||
<div className="text-2xl font-bold">{users.length}</div>
|
<div className="text-2xl font-bold">{stat.value}</div>
|
||||||
<p className="text-xs text-muted-foreground">Registered users</p>
|
<p className="text-xs text-muted-foreground">{stat.description}</p>
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Users with Orders</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center py-4">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-2xl font-bold">{usersWithOrders.length}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{users.length > 0 ? Math.round((usersWithOrders.length / users.length) * 100) : 0}% conversion rate
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center py-4">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-2xl font-bold">{formatCurrency(totalSpent)}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">{totalOrders} total orders</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Returning Customers</CardTitle>
|
|
||||||
<Repeat className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center py-4">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-2xl font-bold">{returningCustomers.length}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{usersWithOrders.length > 0 ? Math.round((returningCustomers.length / usersWithOrders.length) * 100) : 0}% of customers
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Blocked Users</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center py-4">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-2xl font-bold">{blockedUsers.length}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{users.length > 0 ? Math.round((blockedUsers.length / users.length) * 100) : 0}% blocked rate
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search and Filters */}
|
{/* Search and Filters */}
|
||||||
<Card>
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader className="pb-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>User Management</CardTitle>
|
<CardTitle className="text-lg font-medium">User Management</CardTitle>
|
||||||
<CardDescription>View and manage all Telegram user accounts</CardDescription>
|
<CardDescription>View and manage all Telegram user accounts</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search users..."
|
placeholder="Search users..."
|
||||||
className="pl-8 w-64"
|
className="pl-9 w-64 bg-background/50 border-border/50 focus:bg-background transition-colors"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearchQuery(e.target.value);
|
setSearchQuery(e.target.value);
|
||||||
@@ -216,19 +188,11 @@ export default function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
<div className="rounded-md border border-border/50 overflow-hidden">
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : users.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
{searchQuery ? "No users found matching your search" : "No users found"}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader className="bg-muted/30">
|
||||||
<TableRow>
|
<TableRow className="border-border/50 hover:bg-transparent">
|
||||||
<TableHead>User ID</TableHead>
|
<TableHead className="w-[100px]">User ID</TableHead>
|
||||||
<TableHead>Username</TableHead>
|
<TableHead>Username</TableHead>
|
||||||
<TableHead>Orders</TableHead>
|
<TableHead>Orders</TableHead>
|
||||||
<TableHead>Total Spent</TableHead>
|
<TableHead>Total Spent</TableHead>
|
||||||
@@ -239,88 +203,103 @@ export default function AdminUsersPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{users.map((user) => (
|
<AnimatePresence mode="popLayout">
|
||||||
<TableRow key={user.telegramUserId}>
|
{loading ? (
|
||||||
<TableCell>
|
<TableRow>
|
||||||
<div className="font-mono text-sm">{user.telegramUserId}</div>
|
<TableCell colSpan={8} className="h-24 text-center">
|
||||||
</TableCell>
|
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||||
<TableCell>
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<div className="font-medium">
|
Loading users...
|
||||||
{user.telegramUsername !== "Unknown" ? `@${user.telegramUsername}` : "Unknown"}
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : users.length > 0 ? (
|
||||||
|
users.map((user, index) => (
|
||||||
|
<motion.tr
|
||||||
|
key={user.telegramUserId}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||||
|
className={`group border-b border-border/50 transition-colors ${user.isBlocked ? "bg-destructive/5 hover:bg-destructive/10" : "hover:bg-muted/40"}`}
|
||||||
|
>
|
||||||
|
<TableCell className="font-mono text-xs">{user.telegramUserId}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center gap-2">
|
||||||
<Package className="h-4 w-4 text-muted-foreground" />
|
<span className="font-medium">@{user.telegramUsername || "Unknown"}</span>
|
||||||
<span>{user.totalOrders}</span>
|
{user.isBlocked && (
|
||||||
{user.completedOrders > 0 && (
|
<Badge variant="destructive" className="h-5 px-1.5 text-[10px]">Blocked</Badge>
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{user.completedOrders} completed
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>{user.totalOrders}</TableCell>
|
||||||
|
<TableCell>{formatCurrency(user.totalSpent)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex flex-col gap-1 text-xs">
|
||||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
<span className="text-emerald-500">{user.completedOrders} Completed</span>
|
||||||
<span className="font-medium">{formatCurrency(user.totalSpent)}</span>
|
<span className="text-muted-foreground">{user.paidOrders - user.completedOrders} Pending</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{user.firstOrderDate ? new Date(user.firstOrderDate).toLocaleDateString() : "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{user.lastOrderDate ? new Date(user.lastOrderDate).toLocaleDateString() : "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
{user.isBlocked ? (
|
{user.isBlocked ? (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Badge variant="destructive">
|
<Button size="sm" variant="outline" className="h-8 border-emerald-500/20 text-emerald-500 hover:bg-emerald-500/10 hover:text-emerald-400">
|
||||||
<Ban className="h-3 w-3 mr-1" />
|
<UserCheck className="h-4 w-4 mr-1" />
|
||||||
Blocked
|
Unblock
|
||||||
</Badge>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
{user.blockedReason && (
|
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p className="max-w-xs">{user.blockedReason}</p>
|
<p>Unblock this user</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
)}
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
) : user.totalOrders > 0 ? (
|
|
||||||
<Badge variant="default">Active</Badge>
|
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="secondary">No Orders</Badge>
|
<TooltipProvider>
|
||||||
)}
|
<Tooltip>
|
||||||
</TableCell>
|
<TooltipTrigger asChild>
|
||||||
<TableCell>
|
<Button size="sm" variant="outline" className="h-8 border-destructive/20 text-destructive hover:bg-destructive/10 hover:text-destructive">
|
||||||
{user.firstOrderDate
|
<Ban className="h-4 w-4 mr-1" />
|
||||||
? new Date(user.firstOrderDate).toLocaleDateString()
|
Block
|
||||||
: 'N/A'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{user.lastOrderDate
|
|
||||||
? new Date(user.lastOrderDate).toLocaleDateString()
|
|
||||||
: 'N/A'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex items-center justify-end space-x-2">
|
|
||||||
{!user.isBlocked ? (
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Ban className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<UserCheck className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Block access to the store</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
</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>
|
</TableRow>
|
||||||
))}
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{pagination && pagination.totalPages > 1 && (
|
{pagination && pagination.totalPages > 1 && (
|
||||||
<div className="mt-4 flex items-center justify-between">
|
<div className="mt-4 flex items-center justify-between border-t border-border/50 pt-4">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Showing page {pagination.page} of {pagination.totalPages} ({pagination.total} total users)
|
Showing page <span className="font-medium text-foreground">{pagination.page}</span> of {pagination.totalPages}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -328,6 +307,7 @@ export default function AdminUsersPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
disabled={!pagination.hasPrevPage}
|
disabled={!pagination.hasPrevPage}
|
||||||
|
className="h-8"
|
||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
@@ -336,6 +316,7 @@ export default function AdminUsersPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setPage(p => p + 1)}
|
onClick={() => setPage(p => p + 1)}
|
||||||
disabled={!pagination.hasNextPage}
|
disabled={!pagination.hasNextPage}
|
||||||
|
className="h-8"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
@@ -347,3 +328,5 @@ export default function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
496
app/dashboard/admin/vendors/page.tsx
vendored
496
app/dashboard/admin/vendors/page.tsx
vendored
@@ -1,14 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/common/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/common/input";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/common/table";
|
||||||
import { Search, MoreHorizontal, UserCheck, UserX, Mail, Loader2 } from "lucide-react";
|
import { Search, MoreHorizontal, UserCheck, UserX, Mail, Loader2, Store, Shield, ShieldAlert, Clock, Calendar, Pencil, Plus } from "lucide-react";
|
||||||
import { fetchClient } from "@/lib/api-client";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/common/dialog";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { Label } from "@/components/common/label";
|
||||||
|
import { fetchClient } from "@/lib/api/api-client";
|
||||||
|
import { useToast } from "@/lib/hooks/use-toast";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/common/dropdown-menu";
|
||||||
|
|
||||||
interface Vendor {
|
interface Vendor {
|
||||||
_id: string;
|
_id: string;
|
||||||
@@ -37,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 {
|
||||||
@@ -79,96 +154,90 @@ export default function AdminVendorsPage() {
|
|||||||
const adminVendors = vendors.filter(v => v.isAdmin);
|
const adminVendors = vendors.filter(v => v.isAdmin);
|
||||||
const totalVendors = pagination?.total || vendors.length;
|
const totalVendors = pagination?.total || vendors.length;
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
title: "Total Vendors",
|
||||||
|
value: totalVendors,
|
||||||
|
description: "Registered vendors",
|
||||||
|
icon: Store,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Active Vendors",
|
||||||
|
value: activeVendors.length,
|
||||||
|
description: `${vendors.length > 0 ? Math.round((activeVendors.length / vendors.length) * 100) : 0}% active rate`,
|
||||||
|
icon: UserCheck,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Suspended",
|
||||||
|
value: suspendedVendors.length,
|
||||||
|
description: `${vendors.length > 0 ? Math.round((suspendedVendors.length / vendors.length) * 100) : 0}% suspension rate`,
|
||||||
|
icon: UserX,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Admin Users",
|
||||||
|
value: adminVendors.length,
|
||||||
|
description: "Administrative access",
|
||||||
|
icon: ShieldAlert,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 animate-in fade-in duration-500">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">All Vendors</h1>
|
<h1 className="text-2xl font-semibold tracking-tight text-foreground">All Vendors</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">Manage vendor accounts and permissions</p>
|
<p className="text-sm text-muted-foreground mt-1">Manage vendor accounts and permissions</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
<Card>
|
{stats.map((stat, i) => (
|
||||||
|
<Card key={i} className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||||
<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">Total Vendors</CardTitle>
|
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
|
||||||
|
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{totalVendors}</div>
|
<div className="animate-in fade-in slide-in-from-bottom-2 duration-500" style={{ animationDelay: `${i * 100}ms` }}>
|
||||||
<p className="text-xs text-muted-foreground">Registered vendors</p>
|
<div className="text-2xl font-bold">{stat.value}</div>
|
||||||
</CardContent>
|
<p className="text-xs text-muted-foreground">{stat.description}</p>
|
||||||
</Card>
|
</div>
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Active Vendors</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{activeVendors.length}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{vendors.length > 0 ? Math.round((activeVendors.length / vendors.length) * 100) : 0}% active rate
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Suspended</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{suspendedVendors.length}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{vendors.length > 0 ? Math.round((suspendedVendors.length / vendors.length) * 100) : 0}% suspension rate
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Admin Users</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{adminVendors.length}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">Administrative access</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search and Filters */}
|
{/* Search and Filters */}
|
||||||
<Card>
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader className="pb-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Vendor Management</CardTitle>
|
<CardTitle className="text-lg font-medium">Vendor Management</CardTitle>
|
||||||
<CardDescription>View and manage all vendor accounts</CardDescription>
|
<CardDescription>View and manage all vendor accounts</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search vendors..."
|
placeholder="Search vendors..."
|
||||||
className="pl-8 w-64"
|
className="pl-9 w-64 bg-background/50 border-border/50 focus:bg-background transition-colors"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm" className="bg-background/50 border-border/50 hover:bg-background transition-colors">
|
||||||
<Mail className="h-4 w-4 mr-2" />
|
<Mail className="h-4 w-4 mr-2" />
|
||||||
Send Message
|
Message
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
<div className="rounded-md border border-border/50 overflow-hidden">
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
) : filteredVendors.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
{searchQuery.trim() ? "No vendors found matching your search" : "No vendors found"}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader className="bg-muted/30">
|
||||||
<TableRow>
|
<TableRow className="border-border/50 hover:bg-transparent">
|
||||||
<TableHead>Vendor</TableHead>
|
<TableHead>Vendor</TableHead>
|
||||||
<TableHead>Store</TableHead>
|
<TableHead>Store</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
@@ -178,53 +247,269 @@ export default function AdminVendorsPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredVendors.map((vendor) => (
|
{isFirefox ? (
|
||||||
<TableRow key={vendor._id}>
|
loading ? (
|
||||||
<TableCell>
|
<TableRow>
|
||||||
<div className="font-medium">{vendor.username}</div>
|
<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>
|
</TableCell>
|
||||||
<TableCell>{vendor.storeId || 'No store'}</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 }}
|
||||||
|
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||||
|
className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
|
||||||
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-col space-y-1">
|
<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
|
<Badge
|
||||||
variant={vendor.isActive ? "default" : "destructive"}
|
variant={vendor.isActive ? "default" : "destructive"}
|
||||||
|
className={vendor.isActive ? "bg-green-600 hover:bg-green-700" : ""}
|
||||||
>
|
>
|
||||||
{vendor.isActive ? "active" : "suspended"}
|
{vendor.isActive ? "Active" : "Suspended"}
|
||||||
</Badge>
|
</Badge>
|
||||||
{vendor.isAdmin && (
|
{vendor.isAdmin && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<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
|
Admin
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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'}
|
{vendor.createdAt ? new Date(vendor.createdAt).toLocaleDateString() : 'N/A'}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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'}
|
{vendor.lastLogin ? new Date(vendor.lastLogin).toLocaleDateString() : 'Never'}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex items-center justify-end space-x-2">
|
<DropdownMenu>
|
||||||
<Button variant="outline" size="sm">
|
<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="outline" size="sm">
|
|
||||||
<UserX className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</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 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>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</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>
|
||||||
{pagination && pagination.totalPages > 1 && (
|
{pagination && pagination.totalPages > 1 && (
|
||||||
<div className="mt-4 flex items-center justify-between text-sm text-muted-foreground">
|
<div className="mt-4 flex items-center justify-between border-t border-border/50 pt-4 text-sm text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
Page {pagination.page} of {pagination.totalPages} ({pagination.total} total)
|
Page <span className="font-medium text-foreground">{pagination.page}</span> of {pagination.totalPages}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -232,6 +517,7 @@ export default function AdminVendorsPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
disabled={!pagination.hasPrevPage || loading}
|
disabled={!pagination.hasPrevPage || loading}
|
||||||
|
className="h-8"
|
||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
@@ -240,16 +526,50 @@ export default function AdminVendorsPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setPage(p => p + 1)}
|
onClick={() => setPage(p => p + 1)}
|
||||||
disabled={!pagination.hasNextPage || loading}
|
disabled={!pagination.hasNextPage || loading}
|
||||||
|
className="h-8"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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 >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/common/card";
|
||||||
import Layout from "@/components/layout/layout";
|
import Layout from "@/components/layout/layout";
|
||||||
import { SnowLoader } from "@/components/snow-loader";
|
import { SnowLoader } from "@/components/snow-loader";
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Dashboard from "@/components/dashboard/dashboard";
|
|||||||
import AnalyticsDashboard from '@/components/analytics/AnalyticsDashboard';
|
import AnalyticsDashboard from '@/components/analytics/AnalyticsDashboard';
|
||||||
import AnalyticsDashboardSkeleton from '@/components/analytics/AnalyticsDashboardSkeleton';
|
import AnalyticsDashboardSkeleton from '@/components/analytics/AnalyticsDashboardSkeleton';
|
||||||
import StoreSelector from '@/components/analytics/StoreSelector';
|
import StoreSelector from '@/components/analytics/StoreSelector';
|
||||||
import { getAnalyticsOverviewServer } from '@/lib/server-api';
|
import { getAnalyticsOverviewServer } from '@/lib/api/server-api';
|
||||||
import { fetchServer } from '@/lib/api';
|
import { fetchServer } from '@/lib/api';
|
||||||
import { performance } from 'perf_hooks';
|
import { performance } from 'perf_hooks';
|
||||||
import { Info, GitCommit, User, Zap, BarChart3 } from 'lucide-react';
|
import { Info, GitCommit, User, Zap, BarChart3 } from 'lucide-react';
|
||||||
@@ -103,7 +103,7 @@ export default async function AnalyticsPage({
|
|||||||
|
|
||||||
// If it's a 401/403 error, redirect to login
|
// If it's a 401/403 error, redirect to login
|
||||||
if (error instanceof Error && error.message.includes('401')) {
|
if (error instanceof Error && error.message.includes('401')) {
|
||||||
redirect('/login');
|
redirect('/auth/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's a 400 error (missing storeId for staff), show store selector
|
// If it's a 400 error (missing storeId for staff), show store selector
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Dashboard from "@/components/dashboard/dashboard";
|
import Dashboard from "@/components/dashboard/dashboard";
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import { Wallet, Bitcoin, Coins, DollarSign, ArrowUpRight } from "lucide-react";
|
import { Wallet, Bitcoin, Coins, DollarSign, ArrowUpRight } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -166,3 +166,4 @@ export default function BalancePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import Layout from "@/components/layout/layout";
|
import Layout from "@/components/layout/layout";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/common/input";
|
||||||
import { Plus, Pencil, Trash2, ChevronRight, ChevronDown, MoveVertical, FolderTree } from "lucide-react";
|
import { Plus, Pencil, Trash2, ChevronRight, ChevronDown, MoveVertical, FolderTree } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/common/select";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -22,10 +22,11 @@ import {
|
|||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/common/alert-dialog";
|
||||||
import { apiRequest } from "@/lib/api";
|
import { apiRequest } from "@/lib/api";
|
||||||
import type { Category } from "@/models/categories";
|
import type { Category } from "@/lib/models/categories";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/common/card";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
// Drag and Drop imports
|
// Drag and Drop imports
|
||||||
import { DndProvider, useDrag, useDrop, DropTargetMonitor } from 'react-dnd';
|
import { DndProvider, useDrag, useDrop, DropTargetMonitor } from 'react-dnd';
|
||||||
@@ -49,6 +50,7 @@ export default function CategoriesPage() {
|
|||||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||||
const [categoryToDelete, setCategoryToDelete] = useState<Category | null>(null);
|
const [categoryToDelete, setCategoryToDelete] = useState<Category | null>(null);
|
||||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Get root categories sorted by order
|
// Get root categories sorted by order
|
||||||
const rootCategories = categories
|
const rootCategories = categories
|
||||||
@@ -67,10 +69,13 @@ export default function CategoriesPage() {
|
|||||||
|
|
||||||
const fetchCategories = async () => {
|
const fetchCategories = async () => {
|
||||||
try {
|
try {
|
||||||
|
setLoading(true);
|
||||||
const fetchedCategories = await apiRequest("/categories", "GET");
|
const fetchedCategories = await apiRequest("/categories", "GET");
|
||||||
setCategories(fetchedCategories);
|
setCategories(fetchedCategories);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to fetch categories");
|
toast.error("Failed to fetch categories");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -249,30 +254,38 @@ export default function CategoriesPage() {
|
|||||||
drag(drop(ref));
|
drag(drop(ref));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={category._id} className="space-y-1">
|
<motion.div
|
||||||
|
key={category._id}
|
||||||
|
initial={{ opacity: 0, y: 5 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="space-y-1"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={`group flex items-center p-2 rounded-md transition-colors
|
className={`group flex items-center p-3 rounded-xl transition-all duration-200 border mb-2
|
||||||
${isEditing ? 'bg-gray-100 dark:bg-gray-800' : ''}
|
${isEditing ? 'bg-indigo-500/10 border-indigo-500/30' : ''}
|
||||||
${isOver ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-100 dark:hover:bg-gray-800'}
|
${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}
|
||||||
>
|
>
|
||||||
<div className="cursor-grab mr-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
<div className="cursor-grab mr-2 text-muted-foreground/40 hover:text-muted-foreground transition-colors">
|
||||||
<MoveVertical className="h-4 w-4" />
|
<MoveVertical className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasSubcategories && (
|
{hasSubcategories ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleExpand(category._id)}
|
onClick={() => toggleExpand(category._id)}
|
||||||
className="mr-1 focus:outline-none"
|
className="mr-1 focus:outline-none p-0.5 rounded-sm hover:bg-muted text-muted-foreground transition-colors"
|
||||||
>
|
>
|
||||||
{isExpanded ?
|
{isExpanded ?
|
||||||
<ChevronDown className="h-4 w-4 text-muted-foreground" /> :
|
<ChevronDown className="h-4 w-4" /> :
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="w-6 h-5" /> // Spacer
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 flex items-center space-x-2">
|
<div className="flex-1 flex items-center space-x-2">
|
||||||
@@ -280,7 +293,7 @@ export default function CategoriesPage() {
|
|||||||
<Input
|
<Input
|
||||||
value={editingCategory?.name || ""}
|
value={editingCategory?.name || ""}
|
||||||
onChange={(e) => setEditingCategory(prev => prev ? { ...prev, name: e.target.value } : prev)}
|
onChange={(e) => setEditingCategory(prev => prev ? { ...prev, name: e.target.value } : prev)}
|
||||||
className="h-8 max-w-[200px]"
|
className="h-8 max-w-[200px] border-primary/30 focus-visible:ring-primary/20"
|
||||||
autoFocus
|
autoFocus
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && editingCategory) {
|
if (e.key === 'Enter' && editingCategory) {
|
||||||
@@ -300,7 +313,7 @@ export default function CategoriesPage() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-8 px-2 text-green-600 hover:text-green-700 hover:bg-green-50"
|
className="h-8 px-2 text-green-600 hover:text-green-700 hover:bg-green-500/10"
|
||||||
onClick={() => editingCategory && handleUpdateCategory(category._id, editingCategory.name)}
|
onClick={() => editingCategory && handleUpdateCategory(category._id, editingCategory.name)}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
@@ -317,83 +330,107 @@ export default function CategoriesPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-8 w-8 p-0 text-blue-500 hover:text-blue-600 hover:bg-blue-50"
|
className="h-8 w-8 text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||||
onClick={() => setEditingCategory(category)}
|
onClick={() => setEditingCategory(category)}
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-8 w-8 p-0 text-red-500 hover:text-red-600 hover:bg-red-50"
|
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
onClick={() => setCategoryToDelete(category)}
|
onClick={() => setCategoryToDelete(category)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isExpanded && subcategories.map(subcat =>
|
<AnimatePresence>
|
||||||
|
{isExpanded && hasSubcategories && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
{subcategories.map(subcat =>
|
||||||
<CategoryItem key={subcat._id} category={subcat} level={level + 1} />
|
<CategoryItem key={subcat._id} category={subcat} level={level + 1} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6">
|
<div className="space-y-8 animate-in fade-in duration-500">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white flex items-center">
|
<div>
|
||||||
<FolderTree className="mr-2 h-6 w-6" />
|
<h1 className="text-2xl font-semibold text-foreground flex items-center">
|
||||||
|
<FolderTree className="mr-3 h-6 w-6 text-primary" />
|
||||||
Categories
|
Categories
|
||||||
</h1>
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mt-1">
|
||||||
|
Manage your product categories and hierarchy
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 xl:grid-cols-5 gap-4 lg:gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 xl:grid-cols-5 gap-6 lg:gap-8">
|
||||||
{/* Add Category Card - Takes up 2 columns */}
|
{/* Add Category Card */}
|
||||||
<Card className="lg:col-span-2">
|
<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>
|
<CardHeader className="bg-white/[0.02] border-b border-white/5 pb-4">
|
||||||
<CardTitle className="text-lg font-medium">Add New Category</CardTitle>
|
<CardTitle className="text-lg font-bold flex items-center text-white">
|
||||||
|
<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
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-zinc-400">
|
||||||
|
Create a new category or subcategory
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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 peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
<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="Enter category name"
|
placeholder="e.g. Electronics, Clothing..."
|
||||||
className="h-9"
|
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 peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
<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-9">
|
<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">
|
<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>
|
||||||
@@ -401,22 +438,34 @@ export default function CategoriesPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Category List Card - Takes up 3 columns */}
|
{/* Category List Card */}
|
||||||
<Card className="lg:col-span-3">
|
<Card className="lg:col-span-3 border-none bg-transparent shadow-none">
|
||||||
<CardHeader>
|
<CardHeader className="pl-0 pt-0 pb-4">
|
||||||
<CardTitle className="text-lg font-medium">Category List</CardTitle>
|
<CardTitle className="text-lg font-bold text-white">Structure</CardTitle>
|
||||||
|
<CardDescription className="text-zinc-400">
|
||||||
|
Drag and drop to reorder categories
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
<DndProvider backend={HTML5Backend}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
<div className="space-y-1">
|
<div className="space-y-2 min-h-[300px]">
|
||||||
{rootCategories.length === 0 ? (
|
{loading ? (
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground animate-pulse">
|
||||||
No categories yet. Add your first category above.
|
<FolderTree className="h-10 w-10 mb-3 opacity-20" />
|
||||||
</p>
|
<p>Loading categories...</p>
|
||||||
|
</div>
|
||||||
|
) : rootCategories.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground border-2 border-dashed border-border/40 rounded-xl bg-muted/20">
|
||||||
|
<FolderTree className="h-10 w-10 mb-3 opacity-20" />
|
||||||
|
<p>No categories yet</p>
|
||||||
|
<p className="text-xs opacity-60 mt-1">Add your first category to get started</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
rootCategories.map(category => (
|
<div className="space-y-1">
|
||||||
|
{rootCategories.map(category => (
|
||||||
<CategoryItem key={category._id} category={category} />
|
<CategoryItem key={category._id} category={category} />
|
||||||
))
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DndProvider>
|
</DndProvider>
|
||||||
@@ -425,24 +474,25 @@ export default function CategoriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
{categoryToDelete && (
|
|
||||||
<AlertDialog open={!!categoryToDelete} onOpenChange={() => setCategoryToDelete(null)}>
|
<AlertDialog open={!!categoryToDelete} onOpenChange={() => setCategoryToDelete(null)}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will permanently delete the category "{categoryToDelete.name}".
|
This will permanently delete the category <span className="font-medium text-foreground">"{categoryToDelete?.name}"</span>.
|
||||||
This action cannot be undone.
|
This action cannot be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleDeleteConfirm}>Delete</AlertDialogAction>
|
<AlertDialogAction onClick={handleDeleteConfirm} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||||
|
Delete Category
|
||||||
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Layout from "@/components/layout/layout";
|
import Layout from "@/components/layout/layout";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { SnowLoader } from "@/components/snow-loader";
|
import { SnowLoader } from "@/components/snow-loader";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
import Dashboard from "@/components/dashboard/dashboard";
|
import Dashboard from "@/components/dashboard/dashboard";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Metadata, Viewport } from "next";
|
import { Metadata, Viewport } from "next";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
import Dashboard from "@/components/dashboard/dashboard";
|
import Dashboard from "@/components/dashboard/dashboard";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { useRouter } from "next/navigation";
|
|||||||
import Dashboard from "@/components/dashboard/dashboard";
|
import Dashboard from "@/components/dashboard/dashboard";
|
||||||
import { MessageCircle, AlertCircle, RefreshCw } from "lucide-react";
|
import { MessageCircle, AlertCircle, RefreshCw } from "lucide-react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/common/card";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/common/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
|
|
||||||
// Error Boundary Component
|
// Error Boundary Component
|
||||||
interface ErrorBoundaryState {
|
interface ErrorBoundaryState {
|
||||||
@@ -163,19 +163,6 @@ function ChatTableSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatsPage() {
|
export default function ChatsPage() {
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const authToken = document.cookie
|
|
||||||
.split("; ")
|
|
||||||
.find((row) => row.startsWith("Authorization="))
|
|
||||||
?.split("=")[1];
|
|
||||||
|
|
||||||
if (!authToken) {
|
|
||||||
router.push("/login");
|
|
||||||
}
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dashboard>
|
<Dashboard>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Component, ReactNode, useState, useEffect, Suspense } from "react";
|
import { Component, ReactNode, useState, useEffect, Suspense } from "react";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/common/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/common/card";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { sidebarConfig } from "@/config/sidebar";
|
||||||
|
|
||||||
// Error Boundary Component
|
// Error Boundary Component
|
||||||
interface ErrorBoundaryState {
|
interface ErrorBoundaryState {
|
||||||
@@ -247,6 +249,26 @@ function DashboardContentSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardContentWrapper({ children }: { children: ReactNode }) {
|
export default function DashboardContentWrapper({ children }: { children: ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Prefetch main dashboard routes for snappier navigation
|
||||||
|
const prefetchRoutes = async () => {
|
||||||
|
// Small delay to prioritize initial page load
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
|
sidebarConfig.forEach(section => {
|
||||||
|
section.items.forEach(item => {
|
||||||
|
if (item.href && item.href !== "/dashboard") {
|
||||||
|
router.prefetch(item.href);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
prefetchRoutes();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary componentName="Dashboard Content">
|
<ErrorBoundary componentName="Dashboard Content">
|
||||||
<SuspenseWithTimeout
|
<SuspenseWithTimeout
|
||||||
@@ -260,3 +282,4 @@ export default function DashboardContentWrapper({ children }: { children: ReactN
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
import Layout from "@/components/layout/layout";
|
import Layout from "@/components/layout/layout";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { SnowLoader } from "@/components/snow-loader";
|
import { SnowLoader } from "@/components/snow-loader";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
import Layout from "@/components/layout/layout";
|
import Layout from "@/components/layout/layout";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { SnowLoader } from "@/components/snow-loader";
|
import { SnowLoader } from "@/components/snow-loader";
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { fetchData } from '@/lib/api';
|
|||||||
import { clientFetch } from '@/lib/api';
|
import { clientFetch } from '@/lib/api';
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/common/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/common/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/common/textarea";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -15,13 +15,13 @@ import {
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/common/table";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/common/card";
|
||||||
import { Clipboard, Truck, Package, ArrowRight, ChevronDown, AlertTriangle, Copy, Loader2, RefreshCw, MessageCircle } from "lucide-react";
|
import { Clipboard, Truck, Package, ArrowRight, ChevronDown, AlertTriangle, Copy, Loader2, RefreshCw, MessageCircle } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -36,9 +36,11 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/common/alert-dialog";
|
||||||
import Layout from "@/components/layout/layout";
|
import Layout from "@/components/layout/layout";
|
||||||
import { cacheUtils } from '@/lib/api-client';
|
import { cacheUtils } from '@/lib/api/api-client';
|
||||||
|
import OrderTimeline from "@/components/orders/order-timeline";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
interface Order {
|
interface Order {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
@@ -432,7 +434,7 @@ export default function OrderDetailsPage() {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setProductNames(prev => {
|
setProductNames(prev => {
|
||||||
const newMap = {...prev};
|
const newMap = { ...prev };
|
||||||
productIds.forEach(id => {
|
productIds.forEach(id => {
|
||||||
if (!newMap[id] || newMap[id] === "Loading...") {
|
if (!newMap[id] || newMap[id] === "Loading...") {
|
||||||
newMap[id] = "Unknown Product (Deleted)";
|
newMap[id] = "Unknown Product (Deleted)";
|
||||||
@@ -791,10 +793,26 @@ export default function OrderDetailsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
{/* Order Timeline */}
|
||||||
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||||
|
<CardHeader className="pb-0">
|
||||||
|
<CardTitle className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Order Lifecycle</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<OrderTimeline
|
||||||
|
status={order?.status || ''}
|
||||||
|
orderDate={order?.orderDate || ''}
|
||||||
|
paidAt={order?.paidAt}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<div className="grid grid-cols-3 gap-6">
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="grid grid-cols-3 gap-6"
|
||||||
|
>
|
||||||
{/* Left Column - Order Details */}
|
{/* Left Column - Order Details */}
|
||||||
<div className="col-span-2 space-y-6">
|
<div className="col-span-2 space-y-6">
|
||||||
{/* Products Card */}
|
{/* Products Card */}
|
||||||
@@ -1168,8 +1186,7 @@ export default function OrderDetailsPage() {
|
|||||||
{[...Array(5)].map((_, i) => (
|
{[...Array(5)].map((_, i) => (
|
||||||
<svg
|
<svg
|
||||||
key={i}
|
key={i}
|
||||||
className={`w-4 h-4 ${
|
className={`w-4 h-4 ${i < (order?.review?.stars || 0)
|
||||||
i < (order?.review?.stars || 0)
|
|
||||||
? "text-yellow-400"
|
? "text-yellow-400"
|
||||||
: "text-zinc-600"
|
: "text-zinc-600"
|
||||||
}`}
|
}`}
|
||||||
@@ -1198,10 +1215,10 @@ export default function OrderDetailsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Shipping Dialog removed; use inline tracking input above */}
|
{/* Shipping Dialog removed; use inline tracking input above */}
|
||||||
</div>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,10 +5,10 @@ import { useRouter } from "next/navigation";
|
|||||||
import Dashboard from "@/components/dashboard/dashboard";
|
import Dashboard from "@/components/dashboard/dashboard";
|
||||||
import { Package, AlertCircle, RefreshCw } from "lucide-react";
|
import { Package, AlertCircle, RefreshCw } from "lucide-react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/common/card";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/common/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
|
|
||||||
// Error Boundary Component
|
// Error Boundary Component
|
||||||
interface ErrorBoundaryState {
|
interface ErrorBoundaryState {
|
||||||
@@ -163,19 +163,6 @@ function OrderTableSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function OrdersPage() {
|
export default function OrdersPage() {
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const authToken = document.cookie
|
|
||||||
.split("; ")
|
|
||||||
.find((row) => row.startsWith("Authorization="))
|
|
||||||
?.split("=")[1];
|
|
||||||
|
|
||||||
if (!authToken) {
|
|
||||||
router.push("/login");
|
|
||||||
}
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dashboard>
|
<Dashboard>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import packageJson from '../../package.json';
|
|||||||
import { getGitInfo, getShortGitHash } from '@/lib/utils/git';
|
import { getGitInfo, getShortGitHash } from '@/lib/utils/git';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/common/skeleton';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/common/card';
|
||||||
import DashboardContentWrapper from './dashboard-content-wrapper';
|
import DashboardContentWrapper from './dashboard-content-wrapper';
|
||||||
|
|
||||||
// Loading skeleton for the dashboard content
|
// Loading skeleton for the dashboard content
|
||||||
@@ -183,3 +183,4 @@ export default async function DashboardPage() {
|
|||||||
</Dashboard>
|
</Dashboard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
import { useState, useEffect, ChangeEvent, Suspense } from "react";
|
import { useState, useEffect, ChangeEvent, Suspense } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Layout from "@/components/layout/layout";
|
import Layout from "@/components/layout/layout";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/common/input";
|
||||||
import { Product } from "@/models/products";
|
import { Product } from "@/lib/models/products";
|
||||||
import { Plus, Upload, Search, RefreshCw, Package2 } from "lucide-react";
|
import { Plus, Upload, Search, RefreshCw, Package2 } from "lucide-react";
|
||||||
import { clientFetch } from "@/lib/api";
|
import { clientFetch } from "@/lib/api";
|
||||||
import { Category } from "@/models/categories";
|
import { Category } from "@/lib/models/categories";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/common/card";
|
||||||
|
|
||||||
// Lazy load heavy components with error handling
|
// Lazy load heavy components with error handling
|
||||||
const ProductTable = dynamic(() => import("@/components/tables/product-table").catch((err) => {
|
const ProductTable = dynamic(() => import("@/components/tables/product-table").catch((err) => {
|
||||||
@@ -45,7 +45,7 @@ const ProfitAnalysisModal = dynamic(() => import("@/components/modals/profit-ana
|
|||||||
|
|
||||||
function ProductTableSkeleton() {
|
function ProductTableSkeleton() {
|
||||||
return (
|
return (
|
||||||
<Card className="animate-in fade-in duration-500">
|
<Card className="animate-in fade-in duration-500 border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Skeleton className="h-6 w-32" />
|
<Skeleton className="h-6 w-32" />
|
||||||
@@ -152,20 +152,10 @@ export default function ProductsPage() {
|
|||||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||||
const [addProductOpen, setAddProductOpen] = useState(false);
|
const [addProductOpen, setAddProductOpen] = useState(false);
|
||||||
const [profitAnalysisOpen, setProfitAnalysisOpen] = useState(false);
|
const [profitAnalysisOpen, setProfitAnalysisOpen] = useState(false);
|
||||||
const [selectedProductForAnalysis, setSelectedProductForAnalysis] = useState<{id: string, name: string} | null>(null);
|
const [selectedProductForAnalysis, setSelectedProductForAnalysis] = useState<{ id: string, name: string } | null>(null);
|
||||||
|
|
||||||
// Fetch products and categories
|
// Fetch products and categories
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const authToken = document.cookie
|
|
||||||
.split("; ")
|
|
||||||
.find((row) => row.startsWith("Authorization="))
|
|
||||||
?.split("=")[1];
|
|
||||||
|
|
||||||
if (!authToken) {
|
|
||||||
router.push("/login");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchDataAsync = async () => {
|
const fetchDataAsync = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -194,7 +184,7 @@ export default function ProductsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchDataAsync();
|
fetchDataAsync();
|
||||||
}, [router]);
|
}, []);
|
||||||
|
|
||||||
const handleAddTier = () => {
|
const handleAddTier = () => {
|
||||||
setProductData((prev) => ({
|
setProductData((prev) => ({
|
||||||
@@ -552,3 +542,5 @@ export default function ProductsPage() {
|
|||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { useState, useEffect, ChangeEvent, Suspense } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Layout from "@/components/layout/layout";
|
import Layout from "@/components/layout/layout";
|
||||||
import { Edit, Plus, Trash, Truck } from "lucide-react";
|
import { Edit, Plus, Trash, Truck } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
import {
|
import {
|
||||||
fetchShippingMethods,
|
fetchShippingMethods,
|
||||||
addShippingMethod,
|
addShippingMethod,
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
ShippingData
|
ShippingData
|
||||||
} from "@/lib/services/shipping-service";
|
} from "@/lib/services/shipping-service";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/common/card";
|
||||||
|
|
||||||
// Lazy load components with error handling
|
// Lazy load components with error handling
|
||||||
const ShippingModal = dynamic(() => import("@/components/modals/shipping-modal").then(mod => ({ default: mod.ShippingModal })).catch((err) => {
|
const ShippingModal = dynamic(() => import("@/components/modals/shipping-modal").then(mod => ({ default: mod.ShippingModal })).catch((err) => {
|
||||||
@@ -54,7 +54,7 @@ const ShippingTable = dynamic(() => import("@/components/tables/shipping-table")
|
|||||||
// Loading skeleton for shipping table
|
// Loading skeleton for shipping table
|
||||||
function ShippingTableSkeleton() {
|
function ShippingTableSkeleton() {
|
||||||
return (
|
return (
|
||||||
<Card className="animate-in fade-in duration-500 relative">
|
<Card className="animate-in fade-in duration-500 relative border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||||
{/* Subtle loading indicator */}
|
{/* Subtle loading indicator */}
|
||||||
<div className="absolute top-0 left-0 right-0 h-1 bg-muted overflow-hidden rounded-t-lg">
|
<div className="absolute top-0 left-0 right-0 h-1 bg-muted overflow-hidden rounded-t-lg">
|
||||||
<div className="h-full bg-primary w-1/3"
|
<div className="h-full bg-primary w-1/3"
|
||||||
@@ -142,11 +142,6 @@ export default function ShippingPage() {
|
|||||||
.find((row) => row.startsWith("Authorization="))
|
.find((row) => row.startsWith("Authorization="))
|
||||||
?.split("=")[1];
|
?.split("=")[1];
|
||||||
|
|
||||||
if (!authToken) {
|
|
||||||
router.push("/login");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchedMethods: ShippingMethod[] = await fetchShippingMethods(
|
const fetchedMethods: ShippingMethod[] = await fetchShippingMethods(
|
||||||
authToken
|
authToken
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,21 +3,25 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Layout from "@/components/layout/layout";
|
import Layout from "@/components/layout/layout";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/common/table";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/common/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/common/switch";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/common/card";
|
||||||
|
import { Badge } from "@/components/common/badge";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator
|
||||||
|
} from "@/components/common/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/common/popover";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -28,14 +32,15 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/common/alert-dialog";
|
||||||
import { Product } from "@/models/products";
|
import { Product } from "@/lib/models/products";
|
||||||
import { Package, RefreshCw, ChevronDown, CheckSquare, XSquare, Boxes, Download, Calendar } from "lucide-react";
|
import { Package, RefreshCw, ChevronDown, CheckSquare, XSquare, Boxes, Download, Calendar, Search, Filter, Save, X, Edit2 } from "lucide-react";
|
||||||
import { clientFetch } from "@/lib/api";
|
import { clientFetch } from "@/lib/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DatePicker, DateRangePicker, DateRangeDisplay, MonthPicker } from "@/components/ui/date-picker";
|
import { DatePicker, DateRangePicker, DateRangeDisplay, MonthPicker } from "@/components/common/date-picker";
|
||||||
import { DateRange } from "react-day-picker";
|
import { DateRange } from "react-day-picker";
|
||||||
import { addDays, startOfDay, endOfDay, format, isSameDay } from "date-fns";
|
import { addDays, startOfDay, endOfDay, format, isSameDay } from "date-fns";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
interface StockData {
|
interface StockData {
|
||||||
currentStock: number;
|
currentStock: number;
|
||||||
@@ -67,16 +72,6 @@ export default function StockManagementPage() {
|
|||||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const authToken = document.cookie
|
|
||||||
.split("; ")
|
|
||||||
.find((row) => row.startsWith("Authorization="))
|
|
||||||
?.split("=")[1];
|
|
||||||
|
|
||||||
if (!authToken) {
|
|
||||||
router.push("/login");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchDataAsync = async () => {
|
const fetchDataAsync = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await clientFetch<Product[]>('api/products');
|
const response = await clientFetch<Product[]>('api/products');
|
||||||
@@ -100,7 +95,7 @@ export default function StockManagementPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchDataAsync();
|
fetchDataAsync();
|
||||||
}, [router]);
|
}, []);
|
||||||
|
|
||||||
const handleEditStock = (productId: string) => {
|
const handleEditStock = (productId: string) => {
|
||||||
setEditingStock({
|
setEditingStock({
|
||||||
@@ -379,6 +374,26 @@ export default function StockManagementPage() {
|
|||||||
return 'In stock';
|
return 'In stock';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStatusBadgeVariant = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'Out of stock': return 'destructive';
|
||||||
|
case 'Low stock': return 'warning'; // Custom variant or use secondary/outline
|
||||||
|
case 'In stock': return 'default'; // often maps to primary which might be blue/black
|
||||||
|
default: return 'secondary';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper for badging - if your Badge component doesn't support 'warning' directly, use className overrides
|
||||||
|
const StatusBadge = ({ status }: { status: string }) => {
|
||||||
|
let styles = "font-medium border-transparent shadow-none";
|
||||||
|
if (status === 'Out of stock') styles += " bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400";
|
||||||
|
else if (status === 'Low stock') styles += " bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400";
|
||||||
|
else if (status === 'In stock') styles += " bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400";
|
||||||
|
else styles += " bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400";
|
||||||
|
|
||||||
|
return <Badge className={styles} variant="outline">{status}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
const filteredProducts = products.filter(product => {
|
const filteredProducts = products.filter(product => {
|
||||||
if (!searchTerm) return true;
|
if (!searchTerm) return true;
|
||||||
|
|
||||||
@@ -392,31 +407,39 @@ export default function StockManagementPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 animate-in fade-in duration-500">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white flex items-center">
|
<div>
|
||||||
<Boxes className="mr-2 h-6 w-6" />
|
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||||
|
<Boxes className="h-6 w-6 text-primary" />
|
||||||
Stock Management
|
Stock Management
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-3">
|
<p className="text-muted-foreground text-sm mt-1">
|
||||||
|
Track inventory levels and manage stock status
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search products..."
|
placeholder="Search products..."
|
||||||
className="w-64"
|
className="pl-9 w-full sm:w-64 bg-background/50 border-border/50 focus:bg-background transition-colors"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Report Type Selector */}
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" className="gap-2">
|
<Button variant="outline" size="icon" className="h-10 w-10 border-border/50 bg-background/50">
|
||||||
<Calendar className="h-4 w-4" />
|
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||||
{reportType.charAt(0).toUpperCase() + reportType.slice(1)} Report
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Filter Reports</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => setReportType('daily')}>
|
<DropdownMenuItem onClick={() => setReportType('daily')}>
|
||||||
Daily Report
|
Daily Report
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -433,12 +456,13 @@ export default function StockManagementPage() {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{/* Date Selection based on report type */}
|
{/* Date Selection based on report type */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
{reportType === 'daily' && (
|
{reportType === 'daily' && (
|
||||||
<DatePicker
|
<DatePicker
|
||||||
date={exportDate ? new Date(exportDate) : undefined}
|
date={exportDate ? new Date(exportDate) : undefined}
|
||||||
onDateChange={(date) => setExportDate(date ? date.toISOString().split('T')[0] : '')}
|
onDateChange={(date) => setExportDate(date ? date.toISOString().split('T')[0] : '')}
|
||||||
placeholder="Select export date"
|
placeholder="Select export date"
|
||||||
className="w-auto"
|
className="w-auto border-border/50 bg-background/50"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -447,7 +471,7 @@ export default function StockManagementPage() {
|
|||||||
dateRange={exportDateRange}
|
dateRange={exportDateRange}
|
||||||
onDateRangeChange={setExportDateRange}
|
onDateRangeChange={setExportDateRange}
|
||||||
placeholder="Select date range"
|
placeholder="Select date range"
|
||||||
className="w-auto"
|
className="w-auto border-border/50 bg-background/50"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -456,28 +480,29 @@ export default function StockManagementPage() {
|
|||||||
selectedMonth={selectedMonth}
|
selectedMonth={selectedMonth}
|
||||||
onMonthChange={(date) => setSelectedMonth(date || new Date())}
|
onMonthChange={(date) => setSelectedMonth(date || new Date())}
|
||||||
placeholder="Select month"
|
placeholder="Select month"
|
||||||
className="w-auto"
|
className="w-auto border-border/50 bg-background/50"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleExportStock}
|
onClick={handleExportStock}
|
||||||
disabled={isExporting}
|
disabled={isExporting}
|
||||||
className="gap-2"
|
className="gap-2 border-border/50 bg-background/50 hover:bg-background transition-colors"
|
||||||
>
|
>
|
||||||
{isExporting ? (
|
{isExporting ? (
|
||||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{isExporting ? 'Exporting...' : 'Export CSV'}
|
Export
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{selectedProducts.length > 0 && (
|
{selectedProducts.length > 0 && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" className="gap-2">
|
<Button variant="default" className="gap-2">
|
||||||
<Package className="h-4 w-4" />
|
<Package className="h-4 w-4" />
|
||||||
Bulk Actions
|
Bulk Actions
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" />
|
||||||
@@ -486,11 +511,11 @@ export default function StockManagementPage() {
|
|||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => handleBulkAction('enable')}>
|
<DropdownMenuItem onClick={() => handleBulkAction('enable')}>
|
||||||
<CheckSquare className="h-4 w-4 mr-2" />
|
<CheckSquare className="h-4 w-4 mr-2" />
|
||||||
Enable Stock Tracking
|
Enable Tracking
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handleBulkAction('disable')}>
|
<DropdownMenuItem onClick={() => handleBulkAction('disable')}>
|
||||||
<XSquare className="h-4 w-4 mr-2" />
|
<XSquare className="h-4 w-4 mr-2" />
|
||||||
Disable Stock Tracking
|
Disable Tracking
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -498,52 +523,77 @@ export default function StockManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border">
|
<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 flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg font-medium">Inventory Data</CardTitle>
|
||||||
|
<CardDescription>Manage stock levels and tracking for {products.length} products</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground bg-background/50 px-3 py-1 rounded-full border border-border/50">
|
||||||
|
{filteredProducts.length} items
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader className="bg-muted/50">
|
||||||
<TableRow>
|
<TableRow className="border-border/50 hover:bg-transparent">
|
||||||
<TableHead className="w-12">
|
<TableHead className="w-12 pl-6">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedProducts.length === products.length}
|
checked={selectedProducts.length === products.length && products.length > 0}
|
||||||
onChange={toggleSelectAll}
|
onChange={toggleSelectAll}
|
||||||
className="rounded border-gray-300"
|
className="rounded border-gray-300 dark:border-zinc-700 bg-background focus:ring-primary/20"
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>Product</TableHead>
|
<TableHead>Product</TableHead>
|
||||||
<TableHead>Stock Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead>Current Stock</TableHead>
|
<TableHead>Current Stock</TableHead>
|
||||||
<TableHead>Track Stock</TableHead>
|
<TableHead>Tracking</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right pr-6">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="text-center py-8">
|
<TableCell colSpan={6} className="text-center py-12">
|
||||||
<RefreshCw className="h-6 w-6 animate-spin inline-block" />
|
<div className="flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||||
<span className="ml-2">Loading products...</span>
|
<RefreshCw className="h-8 w-8 animate-spin opacity-20" />
|
||||||
|
<p>Loading products...</p>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : filteredProducts.length === 0 ? (
|
) : filteredProducts.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="text-center py-8">
|
<TableCell colSpan={6} className="text-center py-12">
|
||||||
No products found
|
<div className="flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||||
|
<Boxes className="h-10 w-10 opacity-20" />
|
||||||
|
<p>No products found matching your search</p>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
filteredProducts.map((product) => (
|
filteredProducts.map((product, index) => (
|
||||||
<TableRow key={product._id}>
|
<motion.tr
|
||||||
<TableCell>
|
key={product._id}
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||||
|
className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
|
||||||
|
>
|
||||||
|
<TableCell className="pl-6">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedProducts.includes(product._id || '')}
|
checked={selectedProducts.includes(product._id || '')}
|
||||||
onChange={() => toggleSelectProduct(product._id || '')}
|
onChange={() => toggleSelectProduct(product._id || '')}
|
||||||
className="rounded border-gray-300"
|
className="rounded border-gray-300 dark:border-zinc-700 bg-background focus:ring-primary/20"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{product.name}</TableCell>
|
<TableCell className="font-medium">{product.name}</TableCell>
|
||||||
<TableCell>{getStockStatus(product)}</TableCell>
|
<TableCell>
|
||||||
|
<StatusBadge status={getStockStatus(product)} />
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{editingStock[product._id || ''] ? (
|
{editingStock[product._id || ''] ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -551,37 +601,62 @@ export default function StockManagementPage() {
|
|||||||
type="number"
|
type="number"
|
||||||
value={stockValues[product._id || ''] || 0}
|
value={stockValues[product._id || ''] || 0}
|
||||||
onChange={(e) => handleStockChange(product._id || '', parseInt(e.target.value) || 0)}
|
onChange={(e) => handleStockChange(product._id || '', parseInt(e.target.value) || 0)}
|
||||||
className="w-24"
|
className="w-20 h-8 font-mono bg-background"
|
||||||
/>
|
/>
|
||||||
<Button size="sm" onClick={() => handleSaveStock(product)}>Save</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span>{product.currentStock || 0}</span>
|
<span className="font-mono text-sm">{product.currentStock || 0}</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Switch
|
<Switch
|
||||||
checked={product.stockTracking || false}
|
checked={product.stockTracking || false}
|
||||||
onCheckedChange={() => handleToggleStockTracking(product)}
|
onCheckedChange={() => handleToggleStockTracking(product)}
|
||||||
|
className="data-[state=checked]:bg-primary"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right pr-6">
|
||||||
{!editingStock[product._id || ''] && (
|
<div className="flex justify-end gap-1">
|
||||||
|
{editingStock[product._id || ''] ? (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
size="icon"
|
||||||
size="sm"
|
variant="ghost"
|
||||||
|
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-100 dark:hover:bg-green-900/20"
|
||||||
|
onClick={() => handleSaveStock(product)}
|
||||||
|
>
|
||||||
|
<Save 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"
|
||||||
|
onClick={() => setEditingStock({ ...editingStock, [product._id || '']: false })}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-primary hover:bg-primary/10"
|
||||||
onClick={() => handleEditStock(product._id || '')}
|
onClick={() => handleEditStock(product._id || '')}
|
||||||
>
|
>
|
||||||
Edit Stock
|
<Edit2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</motion.tr>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AlertDialog open={isConfirmDialogOpen} onOpenChange={setIsConfirmDialogOpen}>
|
<AlertDialog open={isConfirmDialogOpen} onOpenChange={setIsConfirmDialogOpen}>
|
||||||
@@ -589,15 +664,18 @@ export default function StockManagementPage() {
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Confirm Bulk Action</AlertDialogTitle>
|
<AlertDialogTitle>Confirm Bulk Action</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Are you sure you want to {bulkAction} stock tracking for {selectedProducts.length} selected products?
|
Are you sure you want to {bulkAction} stock tracking for <span className="font-medium text-foreground">{selectedProducts.length}</span> selected products?
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel onClick={() => setBulkAction(null)}>Cancel</AlertDialogCancel>
|
<AlertDialogCancel onClick={() => setBulkAction(null)}>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={executeBulkAction}>Continue</AlertDialogAction>
|
<AlertDialogAction onClick={executeBulkAction} className="bg-primary text-primary-foreground">
|
||||||
|
Continue
|
||||||
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState, useCallback } from "react";
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
import { getCustomers, type CustomerStats } from "@/lib/api";
|
import { getCustomers, type CustomerStats } from "@/lib/api";
|
||||||
import { formatCurrency } from "@/utils/format";
|
import { formatCurrency } from "@/lib/utils/format";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import Layout from "@/components/layout/layout";
|
import Layout from "@/components/layout/layout";
|
||||||
@@ -13,14 +13,14 @@ import {
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/common/table";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/common/select";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -28,8 +28,8 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/common/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -40,21 +40,35 @@ import {
|
|||||||
UserPlus,
|
UserPlus,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Search,
|
Search,
|
||||||
X
|
X,
|
||||||
|
CreditCard,
|
||||||
|
Calendar,
|
||||||
|
ShoppingBag,
|
||||||
|
Truck,
|
||||||
|
CheckCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/common/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/common/input";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/common/card";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/common/dropdown-menu";
|
||||||
|
|
||||||
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);
|
||||||
@@ -112,16 +126,6 @@ export default function CustomerManagementPage() {
|
|||||||
fetchCustomers();
|
fetchCustomers();
|
||||||
}, [fetchCustomers]);
|
}, [fetchCustomers]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const authToken = document.cookie
|
|
||||||
.split("; ")
|
|
||||||
.find((row) => row.startsWith("Authorization="))
|
|
||||||
?.split("=")[1];
|
|
||||||
|
|
||||||
if (!authToken) {
|
|
||||||
router.push("/login");
|
|
||||||
}
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
// Add filter function to filter customers when search query changes
|
// Add filter function to filter customers when search query changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -184,13 +188,13 @@ export default function CustomerManagementPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-black/40 border border-zinc-800 rounded-md overflow-hidden">
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||||
<div className="p-4 border-b border-zinc-800 bg-black/60 flex flex-col md:flex-row md:justify-between md:items-center gap-4">
|
<div className="p-4 border-b border-border/50 bg-muted/30 flex flex-col md:flex-row md:justify-between md:items-center gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-sm font-medium text-gray-400">Show:</div>
|
<div className="text-sm font-medium text-muted-foreground">Show:</div>
|
||||||
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
|
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
|
||||||
<SelectTrigger className="w-[70px]">
|
<SelectTrigger className="w-[70px] bg-background/50 border-border/50">
|
||||||
<SelectValue placeholder="25" />
|
<SelectValue placeholder="25" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -204,26 +208,26 @@ export default function CustomerManagementPage() {
|
|||||||
|
|
||||||
<div className="relative flex-1 max-w-md">
|
<div className="relative flex-1 max-w-md">
|
||||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
<Search className="h-4 w-4 text-gray-400" />
|
<Search className="h-4 w-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by username or Telegram ID..."
|
placeholder="Search by username or Telegram ID..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-10 pr-10 py-2 w-full bg-black/40 border-zinc-700 text-white"
|
className="pl-10 pr-10 py-2 w-full bg-background/50 border-border/50 focus:ring-primary/20 transition-all duration-300"
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-3"
|
className="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||||
onClick={clearSearch}
|
onClick={clearSearch}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 text-gray-400 hover:text-gray-200" />
|
<X className="h-4 w-4 text-muted-foreground hover:text-foreground" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-gray-400 whitespace-nowrap">
|
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
{loading
|
{loading
|
||||||
? "Loading..."
|
? "Loading..."
|
||||||
: searchQuery
|
: searchQuery
|
||||||
@@ -232,31 +236,25 @@ export default function CustomerManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="p-0">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-8 bg-black/60">
|
<div className="p-8">
|
||||||
{/* Loading indicator */}
|
{/* Loading indicator */}
|
||||||
<div className="absolute top-0 left-0 right-0 h-1 bg-muted overflow-hidden">
|
<div className="absolute top-[69px] left-0 right-0 h-0.5 bg-muted overflow-hidden">
|
||||||
<div className="h-full bg-primary w-1/3"
|
<div className="h-full bg-primary w-1/3 animate-shimmer"
|
||||||
style={{
|
style={{
|
||||||
background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)',
|
background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)',
|
||||||
backgroundSize: '200% 100%',
|
backgroundSize: '200% 100%',
|
||||||
animation: 'shimmer 2s ease-in-out infinite',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table skeleton */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-4 pb-4 border-b border-zinc-800">
|
<div className="flex items-center gap-4 pb-4 border-b border-border/50">
|
||||||
{['Customer', 'Orders', 'Total Spent', 'Last Order', 'Status'].map((header, i) => (
|
{['Customer', 'Orders', 'Total Spent', 'Last Order', 'Status'].map((header, i) => (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
key={i}
|
key={i}
|
||||||
className="h-4 w-20 flex-1 animate-in fade-in"
|
className="h-4 w-20 flex-1"
|
||||||
style={{
|
|
||||||
animationDelay: `${i * 50}ms`,
|
|
||||||
animationDuration: '300ms',
|
|
||||||
animationFillMode: 'both',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -264,12 +262,7 @@ export default function CustomerManagementPage() {
|
|||||||
{[...Array(5)].map((_, i) => (
|
{[...Array(5)].map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex items-center gap-4 pb-4 border-b border-zinc-800 last:border-b-0 animate-in fade-in"
|
className="flex items-center gap-4 pb-4 border-b border-border/50 last:border-b-0"
|
||||||
style={{
|
|
||||||
animationDelay: `${250 + i * 50}ms`,
|
|
||||||
animationDuration: '300ms',
|
|
||||||
animationFillMode: 'both',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 flex-1">
|
<div className="flex items-center gap-3 flex-1">
|
||||||
<Skeleton className="h-10 w-10 rounded-full" />
|
<Skeleton className="h-10 w-10 rounded-full" />
|
||||||
@@ -287,114 +280,219 @@ export default function CustomerManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : filteredCustomers.length === 0 ? (
|
) : filteredCustomers.length === 0 ? (
|
||||||
<div className="p-8 text-center bg-black/60">
|
<div className="p-12 text-center">
|
||||||
<Users className="h-12 w-12 mx-auto text-gray-500 mb-4" />
|
<div className="w-16 h-16 bg-muted/50 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<h3 className="text-lg font-medium mb-2 text-white">
|
<Users className="h-8 w-8 text-muted-foreground" />
|
||||||
{searchQuery ? "No customers matching your search" : "No customers found"}
|
</div>
|
||||||
|
<h3 className="text-lg font-medium mb-2 text-foreground">
|
||||||
|
{searchQuery ? "No matching customers" : "No customers yet"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-500">
|
<p className="text-muted-foreground max-w-sm mx-auto mb-6">
|
||||||
{searchQuery
|
{searchQuery
|
||||||
? "Try a different search term or clear the search"
|
? "We couldn't find any customers matching your search criteria."
|
||||||
: "Once you have customers placing orders, they will appear here."}
|
: "Once you have customers placing orders, they will appear here."}
|
||||||
</p>
|
</p>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<Button variant="outline" size="sm" onClick={clearSearch} className="mt-4">
|
<Button variant="outline" size="sm" onClick={clearSearch}>
|
||||||
Clear search
|
Clear Search
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table className="[&_tr]:border-b [&_tr]:border-zinc-800 [&_tr:last-child]:border-b-0 [&_td]:border-r [&_td]:border-zinc-800 [&_td:last-child]:border-r-0 [&_th]:border-r [&_th]:border-zinc-800 [&_th:last-child]:border-r-0 [&_tr:hover]:bg-zinc-900/70">
|
<Table>
|
||||||
<TableHeader className="bg-black/60 sticky top-0 z-10">
|
<TableHeader className="bg-muted/50">
|
||||||
<TableRow>
|
<TableRow className="hover:bg-transparent border-border/50">
|
||||||
<TableHead className="w-[180px] text-gray-300">Customer</TableHead>
|
<TableHead className="w-[200px]">Customer</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer w-[100px] text-gray-300 text-center"
|
className="cursor-pointer w-[100px] text-center hover:text-primary transition-colors"
|
||||||
onClick={() => handleSort("totalOrders")}
|
onClick={() => handleSort("totalOrders")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center gap-1">
|
||||||
Orders
|
Orders
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<ArrowUpDown className="h-3 w-3" />
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer w-[150px] text-gray-300 text-center"
|
className="cursor-pointer w-[150px] text-center hover:text-primary transition-colors"
|
||||||
onClick={() => handleSort("totalSpent")}
|
onClick={() => handleSort("totalSpent")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center gap-1">
|
||||||
Total Spent
|
Total Spent
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<ArrowUpDown className="h-3 w-3" />
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="cursor-pointer w-[180px] text-gray-300 text-center"
|
className="cursor-pointer w-[180px] text-center hover:text-primary transition-colors"
|
||||||
onClick={() => handleSort("lastOrderDate")}
|
onClick={() => handleSort("lastOrderDate")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center gap-1">
|
||||||
Last Order
|
Last Order
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<ArrowUpDown className="h-3 w-3" />
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[250px] text-gray-300 text-center">Status</TableHead>
|
<TableHead className="w-[250px] text-center">Status</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredCustomers.map((customer) => (
|
{isFirefox ? (
|
||||||
<TableRow
|
filteredCustomers.map((customer, index) => (
|
||||||
|
<motion.tr
|
||||||
key={customer.userId}
|
key={customer.userId}
|
||||||
className={`cursor-pointer ${!customer.hasOrders ? "bg-black/30" : ""}`}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||||
|
className={`cursor-pointer group hover:bg-muted/50 border-b border-border/50 transition-colors ${!customer.hasOrders ? "bg-primary/5 hover:bg-primary/10" : ""}`}
|
||||||
onClick={() => setSelectedCustomer(customer)}
|
onClick={() => setSelectedCustomer(customer)}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell className="py-3">
|
||||||
<div className="font-medium text-gray-100">
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`h-9 w-9 rounded-full flex items-center justify-center text-xs font-medium ${!customer.hasOrders ? 'bg-primary/20 text-primary' : 'bg-muted text-muted-foreground'
|
||||||
|
}`}>
|
||||||
|
{customer.telegramUsername ? customer.telegramUsername.substring(0, 2).toUpperCase() : 'ID'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium flex items-center gap-2">
|
||||||
@{customer.telegramUsername || "Unknown"}
|
@{customer.telegramUsername || "Unknown"}
|
||||||
{!customer.hasOrders && (
|
{!customer.hasOrders && (
|
||||||
<Badge variant="outline" className="ml-2 bg-purple-900/30 text-purple-300 border-purple-700">
|
<Badge variant="outline" className="h-5 px-1.5 text-[10px] bg-primary/10 text-primary border-primary/20">
|
||||||
<UserPlus className="h-3 w-3 mr-1" />
|
|
||||||
New
|
New
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-400">ID: {customer.telegramUserId}</div>
|
<div className="text-xs text-muted-foreground flex items-center font-mono mt-0.5">
|
||||||
|
<span className="opacity-50 select-none">ID:</span>
|
||||||
|
<span className="ml-1">{customer.telegramUserId}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<Badge className="bg-gray-700 text-white hover:bg-gray-600">{customer.totalOrders}</Badge>
|
<Badge variant="secondary" className="font-mono font-normal">
|
||||||
|
{customer.totalOrders}
|
||||||
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium text-gray-100 text-center">
|
<TableCell className="text-center font-mono text-sm">
|
||||||
{formatCurrency(customer.totalSpent)}
|
{formatCurrency(customer.totalSpent)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-gray-100 text-center">
|
<TableCell className="text-center text-sm text-muted-foreground">
|
||||||
{customer.lastOrderDate ? formatDate(customer.lastOrderDate) : "N/A"}
|
{customer.lastOrderDate ? (
|
||||||
|
<div className="flex items-center justify-center gap-1.5">
|
||||||
|
<Calendar className="h-3 w-3 opacity-70" />
|
||||||
|
{formatDate(customer.lastOrderDate).split(",")[0]}
|
||||||
|
</div>
|
||||||
|
) : "Never"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
{customer.hasOrders ? (
|
{customer.hasOrders ? (
|
||||||
<div className="flex justify-center space-x-1">
|
<div className="flex justify-center flex-wrap gap-1">
|
||||||
<Badge className="bg-blue-500 text-white hover:bg-blue-600">
|
{customer.ordersByStatus.paid > 0 && (
|
||||||
|
<Badge className="bg-blue-500/15 text-blue-500 hover:bg-blue-500/25 border-blue-500/20 h-5 px-1.5 text-[10px]">
|
||||||
{customer.ordersByStatus.paid} Paid
|
{customer.ordersByStatus.paid} Paid
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge className="bg-green-500 text-white hover:bg-green-600">
|
)}
|
||||||
{customer.ordersByStatus.completed} Completed
|
{customer.ordersByStatus.completed > 0 && (
|
||||||
</Badge>
|
<Badge className="bg-emerald-500/15 text-emerald-500 hover:bg-emerald-500/25 border-emerald-500/20 h-5 px-1.5 text-[10px]">
|
||||||
<Badge className="bg-amber-500 text-white hover:bg-amber-600">
|
{customer.ordersByStatus.completed} Done
|
||||||
{customer.ordersByStatus.shipped} Shipped
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" className="bg-gray-800 text-gray-300 border-gray-700">
|
|
||||||
No orders yet
|
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{customer.ordersByStatus.shipped > 0 && (
|
||||||
|
<Badge className="bg-amber-500/15 text-amber-500 hover:bg-amber-500/25 border-amber-500/20 h-5 px-1.5 text-[10px]">
|
||||||
|
{customer.ordersByStatus.shipped} Ship
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground italic">No activity</span>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</motion.tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<AnimatePresence 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>
|
||||||
)}
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
<div className="p-4 border-t border-zinc-800 bg-black/40 flex justify-between items-center">
|
<div className="p-4 border-t border-border/50 bg-background/50 flex justify-between items-center">
|
||||||
<div className="text-sm text-gray-400">
|
<div className="text-sm text-muted-foreground">
|
||||||
Page {page} of {totalPages}
|
Page {page} of {totalPages}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -403,74 +501,100 @@ export default function CustomerManagementPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlePageChange(Math.max(1, page - 1))}
|
onClick={() => handlePageChange(Math.max(1, page - 1))}
|
||||||
disabled={page === 1 || loading}
|
disabled={page === 1 || loading}
|
||||||
|
className="h-8"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
<ChevronLeft className="h-3 w-3 mr-1" />
|
||||||
Previous
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{totalPages > 2 && (
|
{totalPages > 2 ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm" className="h-8 px-2">
|
||||||
<span className="sr-only">Go to page</span>
|
<MoreHorizontal className="h-3 w-3" />
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="center" className="bg-black/90 border-zinc-800 max-h-60 overflow-y-auto">
|
<DropdownMenuContent align="center" className="max-h-60 overflow-y-auto">
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => (
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={pageNum}
|
key={pageNum}
|
||||||
onClick={() => handlePageChange(pageNum)}
|
onClick={() => handlePageChange(pageNum)}
|
||||||
className={`cursor-pointer ${pageNum === page ? 'bg-zinc-800 text-white' : 'text-gray-300'}`}
|
className={pageNum === page ? 'bg-primary/10 text-primary' : ''}
|
||||||
>
|
>
|
||||||
Page {pageNum}
|
Page {pageNum}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlePageChange(Math.min(totalPages, page + 1))}
|
onClick={() => handlePageChange(Math.min(totalPages, page + 1))}
|
||||||
disabled={page === totalPages || loading}
|
disabled={page === totalPages || loading}
|
||||||
|
className="h-8"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
<ChevronRight className="h-4 w-4 ml-1" />
|
<ChevronRight className="h-3 w-3 ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* Customer Details Dialog */}
|
{/* Customer Details Dialog */}
|
||||||
|
<AnimatePresence>
|
||||||
{selectedCustomer && (
|
{selectedCustomer && (
|
||||||
<Dialog open={!!selectedCustomer} onOpenChange={(open) => !open && setSelectedCustomer(null)}>
|
<Dialog open={!!selectedCustomer} onOpenChange={(open) => !open && setSelectedCustomer(null)}>
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-2xl w-full overflow-y-auto max-h-[90vh] z-[80]">
|
<DialogContent className="max-w-[95vw] sm:max-w-2xl w-full overflow-y-auto max-h-[90vh] z-[80]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-base">
|
<DialogTitle className="text-lg flex items-center gap-3">
|
||||||
Customer Details
|
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-lg">
|
||||||
|
{selectedCustomer.telegramUsername ? selectedCustomer.telegramUsername.substring(0, 1).toUpperCase() : 'U'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold">Customer Details</div>
|
||||||
|
<div className="text-sm font-normal text-muted-foreground flex items-center gap-2">
|
||||||
|
@{selectedCustomer.telegramUsername || "Unknown"}
|
||||||
|
<span className="w-1 h-1 rounded-full bg-primary" />
|
||||||
|
<span className="font-mono text-xs opacity-70">ID: {selectedCustomer.telegramUserId}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 py-4">
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Customer Information */}
|
{/* Customer Information */}
|
||||||
<div>
|
<motion.div
|
||||||
<div className="mb-4">
|
initial={{ opacity: 0, y: 10 }}
|
||||||
<h3 className="text-sm font-medium mb-2">Customer Information</h3>
|
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="space-y-3">
|
||||||
<div className="flex justify-between items-center text-sm">
|
<div className="flex justify-between items-center text-sm group">
|
||||||
<div className="text-muted-foreground">Username:</div>
|
<div className="text-muted-foreground flex items-center gap-2">
|
||||||
<div className="font-medium">@{selectedCustomer.telegramUsername || "Unknown"}</div>
|
<Users className="h-4 w-4 opacity-50" />
|
||||||
|
Username
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center text-sm">
|
<div className="font-medium group-hover:text-primary transition-colors">@{selectedCustomer.telegramUsername || "Unknown"}</div>
|
||||||
<div className="text-muted-foreground">Telegram ID:</div>
|
|
||||||
<div className="font-medium">{selectedCustomer.telegramUserId}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center text-sm">
|
<div className="flex justify-between items-center text-sm group">
|
||||||
<div className="text-muted-foreground">Chat ID:</div>
|
<div className="text-muted-foreground flex items-center gap-2">
|
||||||
<div className="font-medium">{selectedCustomer.chatId}</div>
|
<CreditCard className="h-4 w-4 opacity-50" />
|
||||||
|
User ID
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -486,76 +610,101 @@ export default function CustomerManagementPage() {
|
|||||||
Open Telegram Chat
|
Open Telegram Chat
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* Order Statistics */}
|
{/* Order Statistics */}
|
||||||
<div>
|
<motion.div
|
||||||
<h3 className="text-sm font-medium mb-2">Order Statistics</h3>
|
initial={{ opacity: 0, y: 10 }}
|
||||||
<div className="bg-background rounded-lg border border-border p-4 space-y-3">
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<div className="flex justify-between items-center text-sm">
|
transition={{ delay: 0.2 }}
|
||||||
<div className="text-muted-foreground">Total Orders:</div>
|
className="space-y-4"
|
||||||
<div className="font-medium">{selectedCustomer.totalOrders}</div>
|
>
|
||||||
|
<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>
|
||||||
<div className="flex justify-between items-center text-sm">
|
<div className="bg-blue-500/10 rounded-lg p-3 border border-blue-500/20 min-w-0">
|
||||||
<div className="text-muted-foreground">Total Spent:</div>
|
<div className="text-xs text-blue-400/70 uppercase font-medium mb-1 whitespace-nowrap">Total Orders</div>
|
||||||
<div className="font-medium">{formatCurrency(selectedCustomer.totalSpent)}</div>
|
<div className="text-xl font-bold text-blue-400">{selectedCustomer.totalOrders}</div>
|
||||||
</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="flex justify-between items-center text-sm">
|
||||||
<div className="text-muted-foreground">First Order:</div>
|
<div className="text-muted-foreground text-xs">First Order</div>
|
||||||
<div className="font-medium">
|
<div className="font-medium text-white/70 text-xs bg-white/5 px-2 py-1 rounded">
|
||||||
{formatDate(selectedCustomer.firstOrderDate)}
|
{formatDate(selectedCustomer.firstOrderDate)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center text-sm">
|
<div className="flex justify-between items-center text-sm">
|
||||||
<div className="text-muted-foreground">Last Order:</div>
|
<div className="text-muted-foreground text-xs">Last Activity</div>
|
||||||
<div className="font-medium">
|
<div className="font-medium text-white/70 text-xs bg-white/5 px-2 py-1 rounded">
|
||||||
{formatDate(selectedCustomer.lastOrderDate)}
|
{formatDate(selectedCustomer.lastOrderDate)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Order Status Breakdown */}
|
{/* Order Status Breakdown */}
|
||||||
<div className="mb-4">
|
<motion.div
|
||||||
<h3 className="text-sm font-medium mb-2">Order Status Breakdown</h3>
|
initial={{ opacity: 0, y: 10 }}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<div className="bg-blue-500/10 rounded-lg border border-blue-500/20 p-3">
|
transition={{ delay: 0.3 }}
|
||||||
<p className="text-sm text-muted-foreground">Paid</p>
|
className="space-y-4"
|
||||||
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.paid}</p>
|
>
|
||||||
|
<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>
|
||||||
<div className="bg-purple-500/10 rounded-lg border border-purple-500/20 p-3">
|
<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">
|
||||||
<p className="text-sm text-muted-foreground">Acknowledged</p>
|
<Loader2 className="h-5 w-5 text-purple-500 mx-auto mb-2 opacity-70 group-hover:opacity-100 transition-opacity" />
|
||||||
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.acknowledged}</p>
|
<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>
|
||||||
<div className="bg-amber-500/10 rounded-lg border border-amber-500/20 p-3">
|
<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">
|
||||||
<p className="text-sm text-muted-foreground">Shipped</p>
|
<Truck className="h-5 w-5 text-amber-500 mx-auto mb-2 opacity-70 group-hover:opacity-100 transition-opacity" />
|
||||||
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.shipped}</p>
|
<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>
|
||||||
<div className="bg-green-500/10 rounded-lg border border-green-500/20 p-3">
|
<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">
|
||||||
<p className="text-sm text-muted-foreground">Completed</p>
|
<CheckCircle className="h-5 w-5 text-emerald-500 mx-auto mb-2 opacity-70 group-hover:opacity-100 transition-opacity" />
|
||||||
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.completed}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter className="pt-4 border-t">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
onClick={() => setSelectedCustomer(null)}
|
onClick={() => setSelectedCustomer(null)}
|
||||||
|
className=""
|
||||||
>
|
>
|
||||||
Close
|
Close Profile
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => router.push(`/dashboard/storefront/chat/new?userId=${selectedCustomer.telegramUserId}`)}
|
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" />
|
||||||
Start Chat
|
Message Customer
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,23 +3,27 @@
|
|||||||
import { useState, useEffect, ChangeEvent } from "react";
|
import { useState, useEffect, ChangeEvent } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Layout from "@/components/layout/layout";
|
import Layout from "@/components/layout/layout";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/common/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/common/textarea";
|
||||||
import { Save, Send, Key, MessageSquare, Shield, Globe, Wallet } from "lucide-react";
|
import { Save, Send, Key, MessageSquare, Shield, Globe, Wallet, RefreshCw } from "lucide-react";
|
||||||
import { apiRequest } from "@/lib/api";
|
import { apiRequest } from "@/lib/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import BroadcastDialog from "@/components/modals/broadcast-dialog";
|
import BroadcastDialog from "@/components/modals/broadcast-dialog";
|
||||||
import Dashboard from "@/components/dashboard/dashboard";
|
import Dashboard from "@/components/dashboard/dashboard";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/common/card";
|
||||||
|
import { Label } from "@/components/common/label";
|
||||||
|
import { Badge } from "@/components/common/badge";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/common/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/common/switch";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/common/tooltip";
|
||||||
|
|
||||||
const SHIPPING_REGIONS = [
|
const SHIPPING_REGIONS = [
|
||||||
{ value: "UK", label: "United Kingdom", emoji: "🇬🇧" },
|
{ value: "UK", label: "United Kingdom", emoji: "🇬🇧" },
|
||||||
@@ -102,16 +106,6 @@ export default function StorefrontPage() {
|
|||||||
const [saving, setSaving] = useState<boolean>(false);
|
const [saving, setSaving] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const authToken = document.cookie
|
|
||||||
.split("; ")
|
|
||||||
.find((row) => row.startsWith("Authorization="))
|
|
||||||
?.split("=")[1];
|
|
||||||
|
|
||||||
if (!authToken) {
|
|
||||||
router.push("/login");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchStorefront = async () => {
|
const fetchStorefront = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -166,17 +160,61 @@ export default function StorefrontPage() {
|
|||||||
return (
|
return (
|
||||||
<Dashboard>
|
<Dashboard>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-4">
|
||||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white flex items-center">
|
<div className="p-3 rounded-xl bg-primary/10 text-primary">
|
||||||
<Globe className="mr-2 h-6 w-6" />
|
<Globe className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-foreground">
|
||||||
Storefront Settings
|
Storefront Settings
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-2">
|
<p className="text-muted-foreground">
|
||||||
<TooltipProvider>
|
Manage your shop's appearance, policies, and configuration
|
||||||
<Tooltip>
|
</p>
|
||||||
<TooltipTrigger asChild>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setBroadcastOpen(true)}
|
||||||
|
className="gap-2 h-10"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
Broadcast
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={saveStorefront}
|
||||||
|
disabled={saving}
|
||||||
|
className="gap-2 h-10 min-w-[120px]"
|
||||||
|
>
|
||||||
|
{saving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
|
{saving ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Main Column */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
|
||||||
|
{/* Store Status Card */}
|
||||||
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}>
|
||||||
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden relative">
|
||||||
|
<div className={`absolute top-0 left-0 w-1 h-full ${storefront.isEnabled ? 'bg-emerald-500' : 'bg-destructive'}`} />
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle>Store Status</CardTitle>
|
||||||
|
<CardDescription>Control your store's visibility to customers</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant={storefront.isEnabled ? "default" : "destructive"} className="h-6">
|
||||||
|
{storefront.isEnabled ? "Open for Business" : "Store Closed"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pb-6">
|
||||||
|
<div className="flex items-center gap-4 p-4 rounded-lg bg-card border border-border/50">
|
||||||
<Switch
|
<Switch
|
||||||
checked={storefront.isEnabled}
|
checked={storefront.isEnabled}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
@@ -185,183 +223,190 @@ export default function StorefrontPage() {
|
|||||||
isEnabled: checked,
|
isEnabled: checked,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
className="data-[state=checked]:bg-emerald-500"
|
||||||
/>
|
/>
|
||||||
<span className={`text-sm font-medium ${storefront.isEnabled ? 'text-emerald-400' : 'text-zinc-400'}`}>
|
<div>
|
||||||
{storefront.isEnabled ? 'Store Open' : 'Store Closed'}
|
<h4 className="font-medium text-sm">
|
||||||
</span>
|
{storefront.isEnabled ? 'Your store is currently online' : 'Your store is currently offline'}
|
||||||
</div>
|
</h4>
|
||||||
</TooltipTrigger>
|
<p className="text-xs text-muted-foreground">
|
||||||
<TooltipContent>
|
{storefront.isEnabled
|
||||||
<p>{storefront.isEnabled ? 'Click to close store' : 'Click to open store'}</p>
|
? 'Customers can browse listings and place orders normally.'
|
||||||
</TooltipContent>
|
: 'Customers will see a maintenance page. No new orders can be placed.'}
|
||||||
</Tooltip>
|
</p>
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
</CardContent>
|
||||||
<Button
|
</Card>
|
||||||
variant="outline"
|
</motion.div>
|
||||||
onClick={() => setBroadcastOpen(true)}
|
|
||||||
className="gap-2"
|
{/* Welcome & Policy */}
|
||||||
size="sm"
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
>
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}>
|
||||||
<Send className="h-4 w-4" />
|
<Card className="h-full border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||||
Broadcast
|
<CardHeader>
|
||||||
</Button>
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
<Button
|
<MessageSquare className="h-4 w-4 text-primary" />
|
||||||
onClick={saveStorefront}
|
Welcome Message
|
||||||
disabled={saving}
|
</CardTitle>
|
||||||
className="gap-2"
|
</CardHeader>
|
||||||
size="sm"
|
<CardContent>
|
||||||
>
|
<Textarea
|
||||||
<Save className="h-4 w-4" />
|
value={storefront.welcomeMessage}
|
||||||
{saving ? "Saving..." : "Save Changes"}
|
onChange={(e) => setStorefront(prev => ({ ...prev, welcomeMessage: e.target.value }))}
|
||||||
</Button>
|
placeholder="Enter the welcome message for new customers..."
|
||||||
</div>
|
className="min-h-[180px] bg-background/50 border-border/50 resize-none focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}>
|
||||||
|
<Card className="h-full border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<Shield className="h-4 w-4 text-orange-400" />
|
||||||
|
Store Policy
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
value={storefront.storePolicy}
|
||||||
|
onChange={(e) => setStorefront(prev => ({ ...prev, storePolicy: e.target.value }))}
|
||||||
|
placeholder="Enter your store's policies, terms, and conditions..."
|
||||||
|
className="min-h-[180px] bg-background/50 border-border/50 resize-none focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 lg:gap-6">
|
|
||||||
{/* Security Settings */}
|
{/* Security Settings */}
|
||||||
<div className="space-y-3">
|
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }}>
|
||||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<CardHeader>
|
||||||
<Shield className="h-4 w-4 text-purple-400" />
|
<CardTitle className="flex items-center gap-2">
|
||||||
<h2 className="text-base font-medium text-zinc-100">
|
<Key className="h-5 w-5 text-purple-400" />
|
||||||
Security
|
Security Configuration
|
||||||
</h2>
|
</CardTitle>
|
||||||
</div>
|
<CardDescription>Manage keys and access tokens for your store security</CardDescription>
|
||||||
<div className="space-y-3">
|
</CardHeader>
|
||||||
<div>
|
<CardContent className="space-y-6">
|
||||||
<label className="text-xs font-medium mb-1 block text-zinc-400">PGP Public Key</label>
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">PGP Public Key</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={storefront.pgpKey}
|
value={storefront.pgpKey}
|
||||||
onChange={(e) => setStorefront(prev => ({ ...prev, pgpKey: e.target.value }))}
|
onChange={(e) => setStorefront(prev => ({ ...prev, pgpKey: e.target.value }))}
|
||||||
placeholder="Enter your PGP public key"
|
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----..."
|
||||||
className="font-mono text-sm h-24 bg-[#1C1C1C] border-zinc-800 resize-none"
|
className="font-mono text-xs h-32 bg-zinc-950/50 border-zinc-800/50 resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium mb-1 block text-zinc-400">Telegram Bot Token</label>
|
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Telegram Bot Token</Label>
|
||||||
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={storefront.telegramToken}
|
value={storefront.telegramToken}
|
||||||
onChange={(e) => setStorefront(prev => ({ ...prev, telegramToken: e.target.value }))}
|
onChange={(e) => setStorefront(prev => ({ ...prev, telegramToken: e.target.value }))}
|
||||||
placeholder="Enter your Telegram bot token"
|
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||||
className="bg-[#1C1C1C] border-zinc-800 h-8 text-sm"
|
className="bg-background/50 border-border/50 font-mono text-sm pl-10"
|
||||||
/>
|
/>
|
||||||
|
<div className="absolute left-3 top-2.5 text-muted-foreground">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">Used for notifications and bot integration.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar Column */}
|
||||||
|
<div className="space-y-6">
|
||||||
{/* Shipping Settings */}
|
{/* Shipping Settings */}
|
||||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
<motion.div initial={{ opacity: 0, x: 10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.4 }}>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
<Globe className="h-4 w-4 text-blue-400" />
|
<Globe className="h-4 w-4 text-blue-400" />
|
||||||
<h2 className="text-base font-medium text-zinc-100">
|
Shipping & Logistics
|
||||||
Shipping
|
</CardTitle>
|
||||||
</h2>
|
</CardHeader>
|
||||||
</div>
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="space-y-2">
|
||||||
<div>
|
<Label>Ships From</Label>
|
||||||
<label className="text-xs font-medium mb-1 block text-zinc-400">Ships From</label>
|
|
||||||
<Select
|
<Select
|
||||||
value={storefront.shipsFrom}
|
value={storefront.shipsFrom}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setStorefront((prev) => ({ ...prev, shipsFrom: value as any }))
|
setStorefront((prev) => ({ ...prev, shipsFrom: value as any }))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-[#1C1C1C] border-zinc-800 h-8 text-sm">
|
<SelectTrigger className="bg-background/50 border-border/50">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SHIPPING_REGIONS.map((region) => (
|
{SHIPPING_REGIONS.map((region) => (
|
||||||
<SelectItem key={region.value} value={region.value}>
|
<SelectItem key={region.value} value={region.value}>
|
||||||
{region.emoji} {region.label}
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">{region.emoji}</span>
|
||||||
|
{region.label}
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium mb-1 block text-zinc-400">Ships To</label>
|
<Label>Ships To</Label>
|
||||||
<Select
|
<Select
|
||||||
value={storefront.shipsTo}
|
value={storefront.shipsTo}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setStorefront((prev) => ({ ...prev, shipsTo: value as any }))
|
setStorefront((prev) => ({ ...prev, shipsTo: value as any }))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-[#1C1C1C] border-zinc-800 h-8 text-sm">
|
<SelectTrigger className="bg-background/50 border-border/50">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SHIPPING_REGIONS.map((region) => (
|
{SHIPPING_REGIONS.map((region) => (
|
||||||
<SelectItem key={region.value} value={region.value}>
|
<SelectItem key={region.value} value={region.value}>
|
||||||
{region.emoji} {region.label}
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">{region.emoji}</span>
|
||||||
|
{region.label}
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Messaging and Payments */}
|
{/* Payment Methods */}
|
||||||
<div className="space-y-3">
|
<motion.div initial={{ opacity: 0, x: 10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.5 }}>
|
||||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<CardHeader>
|
||||||
<MessageSquare className="h-4 w-4 text-emerald-400" />
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
<h2 className="text-base font-medium text-zinc-100">
|
<Wallet className="h-4 w-4 text-yellow-500" />
|
||||||
Welcome Message
|
Crypto Wallets
|
||||||
</h2>
|
</CardTitle>
|
||||||
</div>
|
</CardHeader>
|
||||||
<Textarea
|
<CardContent className="space-y-4">
|
||||||
value={storefront.welcomeMessage}
|
|
||||||
onChange={(e) => setStorefront(prev => ({ ...prev, welcomeMessage: e.target.value }))}
|
|
||||||
placeholder="Enter the welcome message for new customers"
|
|
||||||
className="h-36 bg-[#1C1C1C] border-zinc-800 text-sm resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Shield className="h-4 w-4 text-orange-400" />
|
|
||||||
<h2 className="text-base font-medium text-zinc-100">
|
|
||||||
Store Policy
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
value={storefront.storePolicy}
|
|
||||||
onChange={(e) => setStorefront(prev => ({ ...prev, storePolicy: e.target.value }))}
|
|
||||||
placeholder="Enter your store's policies, terms, and conditions"
|
|
||||||
className="h-48 bg-[#1C1C1C] border-zinc-800 text-sm resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-[#0F0F12] rounded-lg p-4 border border-zinc-800">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Wallet className="h-4 w-4 text-yellow-400" />
|
|
||||||
<h2 className="text-base font-medium text-zinc-100">
|
|
||||||
Payment Methods
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{WALLET_OPTIONS.map((wallet) => (
|
{WALLET_OPTIONS.map((wallet) => (
|
||||||
<div key={wallet.id} className="space-y-1.5">
|
<div key={wallet.id} className="p-3 rounded-lg border border-border/50 bg-card/30 hover:bg-card/50 transition-colors">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<label className="text-xs font-medium flex items-center gap-2 text-zinc-400">
|
<label className="text-sm font-medium flex items-center gap-2">
|
||||||
<span>{wallet.emoji}</span>
|
<span className="text-lg">{wallet.emoji}</span>
|
||||||
{wallet.name}
|
{wallet.name}
|
||||||
{wallet.comingSoon && (
|
|
||||||
<span className="text-[10px] bg-purple-900/50 text-purple-400 px-1.5 py-0.5 rounded">
|
|
||||||
Coming Soon
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
<TooltipProvider>
|
<div className="flex items-center gap-2">
|
||||||
<Tooltip>
|
{wallet.comingSoon && (
|
||||||
<TooltipTrigger asChild>
|
<Badge variant="secondary" className="text-[10px] h-5">Soon</Badge>
|
||||||
<div>
|
)}
|
||||||
<Switch
|
<Switch
|
||||||
checked={storefront.enabledWallets[wallet.id]}
|
checked={storefront.enabledWallets[wallet.id]}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
@@ -374,18 +419,12 @@ export default function StorefrontPage() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
disabled={wallet.disabled}
|
disabled={wallet.disabled}
|
||||||
|
className="scale-90"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
|
||||||
{wallet.disabled && (
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Coming soon</p>
|
|
||||||
</TooltipContent>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
</div>
|
||||||
{storefront.enabledWallets[wallet.id] && !wallet.disabled && (
|
{storefront.enabledWallets[wallet.id] && !wallet.disabled && (
|
||||||
|
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }}>
|
||||||
<Input
|
<Input
|
||||||
value={storefront.wallets[wallet.id]}
|
value={storefront.wallets[wallet.id]}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -398,19 +437,23 @@ export default function StorefrontPage() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
placeholder={wallet.placeholder}
|
placeholder={wallet.placeholder}
|
||||||
className="font-mono text-sm h-8 bg-[#1C1C1C] border-zinc-800"
|
className="font-mono text-xs h-9 bg-background/50"
|
||||||
/>
|
/>
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BroadcastDialog open={broadcastOpen} setOpen={setBroadcastOpen} />
|
<BroadcastDialog open={broadcastOpen} setOpen={setBroadcastOpen} />
|
||||||
|
|
||||||
</Dashboard>
|
</Dashboard >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
119
app/globals.css
119
app/globals.css
@@ -16,6 +16,7 @@ body {
|
|||||||
0% {
|
0% {
|
||||||
background-position: -200% 0;
|
background-position: -200% 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
background-position: 200% 0;
|
background-position: 200% 0;
|
||||||
}
|
}
|
||||||
@@ -61,7 +62,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Larger touch targets for interactive elements */
|
/* Larger touch targets for interactive elements */
|
||||||
button, input, textarea, [role="button"] {
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
[role="button"] {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,7 +94,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Improved focus visibility */
|
/* Improved focus visibility */
|
||||||
input:focus, textarea:focus, button:focus {
|
input:focus,
|
||||||
|
textarea:focus,
|
||||||
|
button:focus {
|
||||||
outline: 2px solid hsl(var(--ring));
|
outline: 2px solid hsl(var(--ring));
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
@@ -102,6 +108,7 @@ body {
|
|||||||
|
|
||||||
/* Chromebook-specific optimizations */
|
/* Chromebook-specific optimizations */
|
||||||
@media screen and (max-width: 1366px) and (min-resolution: 1.5dppx) {
|
@media screen and (max-width: 1366px) and (min-resolution: 1.5dppx) {
|
||||||
|
|
||||||
/* Chromebook display optimizations */
|
/* Chromebook display optimizations */
|
||||||
.text-sm {
|
.text-sm {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -114,23 +121,28 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Better touch targets for Chromebooks */
|
/* Better touch targets for Chromebooks */
|
||||||
button, input, textarea, [role="button"], [role="tab"] {
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
[role="button"],
|
||||||
|
[role="tab"] {
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
min-width: 48px;
|
min-width: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Improved spacing for Chromebook screens */
|
/* Improved spacing for Chromebook screens */
|
||||||
.space-y-2 > * + * {
|
.space-y-2>*+* {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.space-y-4 > * + * {
|
.space-y-4>*+* {
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Chromebook touch screen optimizations */
|
/* Chromebook touch screen optimizations */
|
||||||
@media (pointer: coarse) and (hover: none) {
|
@media (pointer: coarse) and (hover: none) {
|
||||||
|
|
||||||
/* Larger touch targets */
|
/* Larger touch targets */
|
||||||
.touch-target {
|
.touch-target {
|
||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
@@ -138,7 +150,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Better spacing for touch interactions */
|
/* Better spacing for touch interactions */
|
||||||
.space-y-2 > * + * {
|
.space-y-2>*+* {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,13 +160,16 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Better input field sizing */
|
/* Better input field sizing */
|
||||||
input, textarea {
|
input,
|
||||||
|
textarea {
|
||||||
padding: 0.875rem;
|
padding: 0.875rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Enhanced focus states for touch */
|
/* Enhanced focus states for touch */
|
||||||
button:focus-visible, input:focus-visible, textarea:focus-visible {
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
textarea:focus-visible {
|
||||||
outline: 3px solid hsl(var(--ring));
|
outline: 3px solid hsl(var(--ring));
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
@@ -162,6 +177,7 @@ body {
|
|||||||
|
|
||||||
/* Chromebook keyboard navigation improvements */
|
/* Chromebook keyboard navigation improvements */
|
||||||
@media (hover: hover) and (pointer: fine) {
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
|
||||||
/* Better hover states for mouse/trackpad */
|
/* Better hover states for mouse/trackpad */
|
||||||
button:hover:not(:disabled) {
|
button:hover:not(:disabled) {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
@@ -169,7 +185,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Improved focus indicators */
|
/* Improved focus indicators */
|
||||||
button:focus-visible, input:focus-visible, textarea:focus-visible {
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
textarea:focus-visible {
|
||||||
outline: 2px solid hsl(var(--ring));
|
outline: 2px solid hsl(var(--ring));
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
box-shadow: 0 0 0 4px hsl(var(--ring) / 0.2);
|
box-shadow: 0 0 0 4px hsl(var(--ring) / 0.2);
|
||||||
@@ -178,6 +196,7 @@ body {
|
|||||||
|
|
||||||
/* Chromebook display scaling fixes */
|
/* Chromebook display scaling fixes */
|
||||||
@media screen and (min-resolution: 1.5dppx) {
|
@media screen and (min-resolution: 1.5dppx) {
|
||||||
|
|
||||||
/* Prevent text from being too small on high-DPI displays */
|
/* Prevent text from being too small on high-DPI displays */
|
||||||
html {
|
html {
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
@@ -254,8 +273,17 @@ body {
|
|||||||
|
|
||||||
/* Christmas-themed animations */
|
/* Christmas-themed animations */
|
||||||
@keyframes twinkle {
|
@keyframes twinkle {
|
||||||
0%, 100% { opacity: 1; transform: scale(1); }
|
|
||||||
50% { opacity: 0.3; transform: scale(0.8); }
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.3;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes snowflake {
|
@keyframes snowflake {
|
||||||
@@ -263,6 +291,7 @@ body {
|
|||||||
transform: translateY(-100vh) rotate(0deg);
|
transform: translateY(-100vh) rotate(0deg);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
transform: translateY(100vh) rotate(360deg);
|
transform: translateY(100vh) rotate(360deg);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -270,10 +299,13 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes sparkle {
|
@keyframes sparkle {
|
||||||
0%, 100% {
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0) rotate(0deg);
|
transform: scale(0) rotate(0deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1) rotate(180deg);
|
transform: scale(1) rotate(180deg);
|
||||||
@@ -281,9 +313,12 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes glow {
|
@keyframes glow {
|
||||||
0%, 100% {
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
box-shadow: 0 0 5px hsl(var(--christmas-red)), 0 0 10px hsl(var(--christmas-red)), 0 0 15px hsl(var(--christmas-red));
|
box-shadow: 0 0 5px hsl(var(--christmas-red)), 0 0 10px hsl(var(--christmas-red)), 0 0 15px hsl(var(--christmas-red));
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
box-shadow: 0 0 10px hsl(var(--christmas-green)), 0 0 20px hsl(var(--christmas-green)), 0 0 30px hsl(var(--christmas-green));
|
box-shadow: 0 0 10px hsl(var(--christmas-green)), 0 0 20px hsl(var(--christmas-green)), 0 0 30px hsl(var(--christmas-green));
|
||||||
}
|
}
|
||||||
@@ -376,6 +411,42 @@ body {
|
|||||||
.christmas-theme *:focus-visible {
|
.christmas-theme *:focus-visible {
|
||||||
outline-color: hsl(var(--christmas-red));
|
outline-color: hsl(var(--christmas-red));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Premium UI Utilities */
|
||||||
|
.glass-morphism {
|
||||||
|
@apply bg-background/60 backdrop-blur-md border border-border/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .glass-morphism {
|
||||||
|
@apply bg-black/40 backdrop-blur-xl border-white/5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-card {
|
||||||
|
@apply transition-all duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-card:hover {
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
|
||||||
|
border-color: hsl(var(--primary) / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .premium-card {
|
||||||
|
@apply bg-card;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .premium-card:hover {
|
||||||
|
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6);
|
||||||
|
border-color: hsl(var(--primary) / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-gradient {
|
||||||
|
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-primary/60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gradient-premium {
|
||||||
|
background: radial-gradient(circle at top left, hsl(var(--primary) / 0.05), transparent),
|
||||||
|
radial-gradient(circle at bottom right, hsl(var(--primary) / 0.02), transparent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -410,26 +481,27 @@ body {
|
|||||||
--christmas-green: 142 76% 36%;
|
--christmas-green: 142 76% 36%;
|
||||||
--christmas-gold: 43 96% 56%;
|
--christmas-gold: 43 96% 56%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 0 0% 3.9%;
|
--background: 240 10% 2%;
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 0 0% 98%;
|
||||||
--card: 0 0% 3.9%;
|
--card: 240 10% 3%;
|
||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 0 0% 98%;
|
||||||
--popover: 0 0% 3.9%;
|
--popover: 240 10% 2%;
|
||||||
--popover-foreground: 0 0% 98%;
|
--popover-foreground: 0 0% 98%;
|
||||||
--primary: 0 0% 98%;
|
--primary: 0 0% 98%;
|
||||||
--primary-foreground: 0 0% 9%;
|
--primary-foreground: 0 0% 9%;
|
||||||
--secondary: 0 0% 14.9%;
|
--secondary: 240 4% 10%;
|
||||||
--secondary-foreground: 0 0% 98%;
|
--secondary-foreground: 0 0% 98%;
|
||||||
--muted: 0 0% 14.9%;
|
--muted: 240 4% 10%;
|
||||||
--muted-foreground: 0 0% 63.9%;
|
--muted-foreground: 240 5% 64.9%;
|
||||||
--accent: 0 0% 14.9%;
|
--accent: 240 4% 10%;
|
||||||
--accent-foreground: 0 0% 98%;
|
--accent-foreground: 0 0% 98%;
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 0 0% 14.9%;
|
--border: 240 4% 12%;
|
||||||
--input: 0 0% 14.9%;
|
--input: 240 4% 12%;
|
||||||
--ring: 0 0% 83.1%;
|
--ring: 240 5% 83.1%;
|
||||||
--chart-1: 220 70% 50%;
|
--chart-1: 220 70% 50%;
|
||||||
--chart-2: 160 60% 45%;
|
--chart-2: 160 60% 45%;
|
||||||
--chart-3: 30 80% 55%;
|
--chart-3: 30 80% 55%;
|
||||||
@@ -464,6 +536,7 @@ body {
|
|||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Metadata, Viewport } from "next";
|
import { Metadata, Viewport } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/common/button";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -38,3 +38,4 @@ export default function NotFound() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
96
app/page.tsx
96
app/page.tsx
@@ -1,77 +1,43 @@
|
|||||||
import { getPlatformStatsServer } from "@/lib/server-api";
|
import { getPlatformStatsServer } from "@/lib/api/server-api";
|
||||||
import { HomeNavbar } from "@/components/home-navbar";
|
import { HomeNavbar } from "@/components/layout/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/common/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/common/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'];
|
|
||||||
const christmasBorders = ['border-red-500/30', 'border-green-500/30', 'border-yellow-500/30'];
|
|
||||||
const christmasIcons = ['text-red-400', 'text-green-400', 'text-yellow-400'];
|
|
||||||
const christmasBgs = ['bg-red-500/10', 'bg-green-500/10', 'bg-yellow-500/10'];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
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
|
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"
|
||||||
? `from-zinc-800/30 to-transparent ${christmasBorders[i % 3]}`
|
|
||||||
: 'from-zinc-800/30 to-transparent border-zinc-800'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div
|
<div className="absolute inset-0 bg-gradient-to-b from-indigo-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
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="relative">
|
||||||
<div className={`h-12 w-12 flex items-center justify-center rounded-lg mb-4 ${isDec ? christmasBgs[i % 3] : 'bg-[#D53F8C]/10'
|
<div className="h-12 w-12 flex items-center justify-center rounded-lg mb-4 bg-indigo-500/10">
|
||||||
}`}>
|
<feature.icon className="h-6 w-6 text-indigo-500" />
|
||||||
<feature.icon className={`h-6 w-6 ${isDec ? christmasIcons[i % 3] : 'text-[#D53F8C]'}`} />
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-white mb-2">{feature.title}</h3>
|
<h3 className="text-lg font-semibold text-white mb-2">{feature.title}</h3>
|
||||||
<p className="text-sm text-zinc-400">{feature.description}</p>
|
<p className="text-sm text-zinc-400">{feature.description}</p>
|
||||||
</div>
|
</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>
|
||||||
@@ -164,3 +109,6 @@ export default async function Home() {
|
|||||||
return <div>Error loading page</div>;
|
return <div>Error loading page</div>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useKeepOnline } from "@/hooks/useKeepOnline";
|
import { useKeepOnline } from "@/lib/hooks/useKeepOnline";
|
||||||
|
|
||||||
const KeepOnline = () => {
|
const KeepOnline = () => {
|
||||||
useKeepOnline({
|
useKeepOnline({
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
166
components/admin/AdminStatCard.tsx
Normal file
166
components/admin/AdminStatCard.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
|
import { Area, AreaChart, ResponsiveContainer, Tooltip, TooltipProps } from "recharts";
|
||||||
|
import { TrendIndicator } from "./TrendIndicator";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { LucideIcon } from "lucide-react";
|
||||||
|
import { formatGBP } from "@/lib/utils/format";
|
||||||
|
|
||||||
|
interface ChartDataPoint {
|
||||||
|
formattedDate: string;
|
||||||
|
value: number;
|
||||||
|
orders?: number;
|
||||||
|
revenue?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminStatCardProps {
|
||||||
|
title: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
iconColorClass: string;
|
||||||
|
iconBgClass: string;
|
||||||
|
value: string | number;
|
||||||
|
subtext?: React.ReactNode;
|
||||||
|
trend?: {
|
||||||
|
current: number;
|
||||||
|
previous: number;
|
||||||
|
};
|
||||||
|
loading?: boolean;
|
||||||
|
chartData?: ChartDataPoint[];
|
||||||
|
chartColor: string;
|
||||||
|
chartGradientId: string;
|
||||||
|
tooltipPrefix?: string; // "£" or ""
|
||||||
|
hideChart?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label, prefix = "" }: TooltipProps<any, any> & { prefix?: string }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const data = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div className="bg-[#050505]/90 p-3 rounded-lg shadow-xl border border-white/10 backdrop-blur-md ring-1 ring-white/5">
|
||||||
|
<p className="font-bold text-[10px] uppercase tracking-wide text-muted-foreground mb-2 border-b border-white/5 pb-1">
|
||||||
|
{data.formattedDate || label}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-[11px] font-semibold text-primary">
|
||||||
|
{prefix === "£" ? "Revenue" : "Count"}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-bold text-foreground tabular-nums">
|
||||||
|
{prefix}{prefix === "£" ? (data.value || 0).toFixed(2) : data.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdminStatCard({
|
||||||
|
title,
|
||||||
|
icon: Icon,
|
||||||
|
iconColorClass,
|
||||||
|
iconBgClass,
|
||||||
|
value,
|
||||||
|
subtext,
|
||||||
|
trend,
|
||||||
|
loading,
|
||||||
|
chartData,
|
||||||
|
chartColor,
|
||||||
|
chartGradientId,
|
||||||
|
tooltipPrefix = "",
|
||||||
|
hideChart = false,
|
||||||
|
children,
|
||||||
|
}: AdminStatCardProps) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden h-full">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-8 w-8 rounded-md" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-8 w-32 mb-2" />
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-4 w-12 ml-auto" />
|
||||||
|
</div>
|
||||||
|
{!hideChart && <Skeleton className="h-14 w-full rounded-md" />}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden hover:shadow-md transition-shadow duration-300 h-full flex flex-col">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
<div className={cn("p-2 rounded-md", iconBgClass)}>
|
||||||
|
<Icon className={cn("h-4 w-4", iconColorClass)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 flex flex-col">
|
||||||
|
<div className="text-2xl font-bold">{value}</div>
|
||||||
|
|
||||||
|
<div className="flex items-center text-xs text-muted-foreground mt-1">
|
||||||
|
{subtext}
|
||||||
|
{trend && (
|
||||||
|
<div className="ml-auto">
|
||||||
|
<TrendIndicator current={trend.current} previous={trend.previous} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children && <div className="mt-2">{children}</div>}
|
||||||
|
|
||||||
|
{!hideChart && (
|
||||||
|
chartData && chartData.length > 0 ? (
|
||||||
|
<div className="mt-auto pt-4 h-[72px] -mx-2">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart
|
||||||
|
data={chartData}
|
||||||
|
margin={{ top: 5, right: 0, left: 0, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={chartGradientId} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={chartColor} stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor={chartColor} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke={chartColor}
|
||||||
|
fillOpacity={1}
|
||||||
|
fill={`url(#${chartGradientId})`}
|
||||||
|
strokeWidth={2}
|
||||||
|
activeDot={{ r: 4, strokeWidth: 0, fill: chartColor }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
content={<CustomTooltip prefix={tooltipPrefix} />}
|
||||||
|
cursor={{ stroke: 'rgba(255,255,255,0.1)', strokeWidth: 1 }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 h-14 flex items-center justify-center text-xs text-muted-foreground bg-muted/20 rounded">
|
||||||
|
No chart data
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fill space if chart is hidden but we want structure consistency */}
|
||||||
|
{hideChart && <div className="mt-auto pt-4" />}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { fetchClient } from "@/lib/api-client";
|
import { fetchClient } from "@/lib/api/api-client";
|
||||||
|
|
||||||
export default function BanUserCard() {
|
export default function BanUserCard() {
|
||||||
const [telegramUserId, setTelegramUserId] = useState("");
|
const [telegramUserId, setTelegramUserId] = useState("");
|
||||||
@@ -70,3 +70,4 @@ export default function BanUserCard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { fetchClient } from "@/lib/api-client";
|
import { fetchClient } from "@/lib/api/api-client";
|
||||||
|
|
||||||
interface Invitation {
|
interface Invitation {
|
||||||
_id: string;
|
_id: string;
|
||||||
@@ -88,3 +88,4 @@ export default function InvitationsListCard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,48 +1,107 @@
|
|||||||
"use client";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { fetchClient } from "@/lib/api-client";
|
import { fetchClient } from "@/lib/api/api-client";
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
|
import { Button } from "@/components/common/button";
|
||||||
|
import { Copy, Check, Ticket, Loader2, RefreshCw } from "lucide-react";
|
||||||
|
import { useToast } from "@/lib/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>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 flex flex-col justify-center gap-4">
|
||||||
|
{code ? (
|
||||||
|
<div className="space-y-3 animate-in fade-in zoom-in-95 duration-300">
|
||||||
|
<div className="p-3 rounded-md bg-muted/50 border border-border/50 text-center relative group">
|
||||||
|
<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}
|
||||||
>
|
>
|
||||||
{loading ? "Generating..." : "Generate Invite Code"}
|
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||||
</button>
|
</Button>
|
||||||
{message && <p className="text-xs text-muted-foreground">{message}</p>}
|
</div>
|
||||||
{code && (
|
<p className="text-xs text-center text-muted-foreground">
|
||||||
<div className="text-sm">
|
Share this code with the new vendor. It expires in 7 days.
|
||||||
Code: <span className="font-mono px-1.5 py-0.5 rounded bg-muted">{code}</span>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import {
|
|||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/common/dialog";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/common/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/common/select";
|
||||||
import { fetchClient } from "@/lib/api-client";
|
import { fetchClient } from "@/lib/api/api-client";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Package, User, Calendar, DollarSign, MapPin, Truck, CheckCircle, XCircle, Clock, Wallet, Copy, ExternalLink } from "lucide-react";
|
import { Package, User, Calendar, DollarSign, MapPin, Truck, CheckCircle, XCircle, Clock, Wallet, Copy, ExternalLink } from "lucide-react";
|
||||||
|
|
||||||
@@ -562,3 +562,5 @@ export default function OrderDetailsModal({ orderId, open, onOpenChange }: Order
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/common/input";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/common/table";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/common/select";
|
||||||
import { Search, Filter, Eye, ChevronLeft, ChevronRight } from "lucide-react";
|
import { Search, Filter, Eye, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { List } from 'react-window';
|
import { List } from 'react-window';
|
||||||
import OrderDetailsModal from "./OrderDetailsModal";
|
import OrderDetailsModal from "./OrderDetailsModal";
|
||||||
@@ -366,3 +366,4 @@ export default function OrdersTable({ orders, enableModal = true }: OrdersTableP
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { fetchClient } from "@/lib/api-client";
|
import { fetchClient } from "@/lib/api/api-client";
|
||||||
|
|
||||||
interface OrderItem {
|
interface OrderItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -99,3 +99,4 @@ export default function RecentOrdersCard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { fetchClient } from "@/lib/api-client";
|
import { fetchClient } from "@/lib/api/api-client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
|
|
||||||
interface Status {
|
interface Status {
|
||||||
uptimeSeconds: number;
|
uptimeSeconds: number;
|
||||||
@@ -151,3 +151,5 @@ export default function SystemStatusCard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
31
components/admin/TrendIndicator.tsx
Normal file
31
components/admin/TrendIndicator.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { TrendingDown, TrendingUp } from "lucide-react";
|
||||||
|
|
||||||
|
// Trend indicator component for metric cards
|
||||||
|
export const TrendIndicator = ({
|
||||||
|
current,
|
||||||
|
previous,
|
||||||
|
}: {
|
||||||
|
current: number;
|
||||||
|
previous: number;
|
||||||
|
}) => {
|
||||||
|
if (!current || !previous) return null;
|
||||||
|
|
||||||
|
const percentChange = ((current - previous) / previous) * 100;
|
||||||
|
|
||||||
|
if (Math.abs(percentChange) < 0.1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center text-xs font-medium ${percentChange >= 0 ? "text-green-500" : "text-red-500"}`}
|
||||||
|
>
|
||||||
|
{percentChange >= 0 ? (
|
||||||
|
<TrendingUp className="h-3 w-3 mr-1" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="h-3 w-3 mr-1" />
|
||||||
|
)}
|
||||||
|
{Math.abs(percentChange).toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { fetchClient } from "@/lib/api-client";
|
import { fetchClient } from "@/lib/api/api-client";
|
||||||
|
|
||||||
interface Vendor {
|
interface Vendor {
|
||||||
_id: string;
|
_id: string;
|
||||||
@@ -137,3 +137,4 @@ export default function VendorsCard() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ import {
|
|||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/common/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/common/tabs";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/common/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/common/select";
|
||||||
import {
|
import {
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
@@ -32,21 +32,21 @@ import {
|
|||||||
EyeOff,
|
EyeOff,
|
||||||
Calculator,
|
Calculator,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/lib/hooks/use-toast";
|
||||||
import MetricsCard from "./MetricsCard";
|
import MetricsCard from "./MetricsCard";
|
||||||
import {
|
import {
|
||||||
getAnalyticsOverviewWithStore,
|
getAnalyticsOverviewWithStore,
|
||||||
type AnalyticsOverview,
|
type AnalyticsOverview,
|
||||||
} from "@/lib/services/analytics-service";
|
} from "@/lib/services/analytics-service";
|
||||||
import { formatGBP } from "@/utils/format";
|
import { formatGBP, formatNumber } from "@/lib/utils/format";
|
||||||
import { MetricsCardSkeleton } from "./SkeletonLoaders";
|
import { MetricsCardSkeleton } from "./SkeletonLoaders";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
import { DateRangePicker } from "@/components/ui/date-picker";
|
import { DateRangePicker } from "@/components/common/date-picker";
|
||||||
import { DateRange } from "react-day-picker";
|
import { DateRange } from "react-day-picker";
|
||||||
import { addDays, startOfDay, endOfDay } from "date-fns";
|
import { addDays, startOfDay, endOfDay } from "date-fns";
|
||||||
import type { DateRange as ProfitDateRange } from "@/lib/services/profit-analytics-service";
|
import type { DateRange as ProfitDateRange } from "@/lib/services/profit-analytics-service";
|
||||||
import { MotionWrapper } from "@/components/ui/motion-wrapper";
|
import { MotionWrapper } from "@/components/common/motion-wrapper";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
const RevenueChart = dynamic(() => import("./RevenueChart"), {
|
const RevenueChart = dynamic(() => import("./RevenueChart"), {
|
||||||
@@ -170,7 +170,7 @@ export default function AnalyticsDashboard({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Total Orders",
|
title: "Total Orders",
|
||||||
value: maskValue(data.orders.total.toLocaleString()),
|
value: maskValue(formatNumber(data.orders.total)),
|
||||||
description: "All-time orders",
|
description: "All-time orders",
|
||||||
icon: ShoppingCart,
|
icon: ShoppingCart,
|
||||||
trend: data.orders.completed > 0 ? ("up" as const) : ("neutral" as const),
|
trend: data.orders.completed > 0 ? ("up" as const) : ("neutral" as const),
|
||||||
@@ -178,7 +178,7 @@ export default function AnalyticsDashboard({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Unique Customers",
|
title: "Unique Customers",
|
||||||
value: maskValue(data.customers.unique.toLocaleString()),
|
value: maskValue(formatNumber(data.customers.unique)),
|
||||||
description: "Total customers",
|
description: "Total customers",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
trend: "neutral" as const,
|
trend: "neutral" as const,
|
||||||
@@ -186,7 +186,7 @@ export default function AnalyticsDashboard({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Products",
|
title: "Products",
|
||||||
value: maskValue(data.products.total.toLocaleString()),
|
value: maskValue(formatNumber(data.products.total)),
|
||||||
description: "Active products",
|
description: "Active products",
|
||||||
icon: Package,
|
icon: Package,
|
||||||
trend: "neutral" as const,
|
trend: "neutral" as const,
|
||||||
@@ -195,54 +195,116 @@ export default function AnalyticsDashboard({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10 pb-20">
|
||||||
{/* Header with Privacy Toggle */}
|
{/* Header with Integrated Toolbar */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-bold tracking-tight">
|
<h2 className="text-3xl font-bold tracking-tight">
|
||||||
Analytics Dashboard
|
Analytics Dashboard
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground mt-1">
|
||||||
Overview of your store's performance and metrics.
|
Real-time performance metrics and AI-driven insights.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
|
<div className="flex items-center gap-2 p-1.5 glass-morphism rounded-2xl border border-white/5 shadow-2xl backdrop-blur-xl ring-1 ring-white/5">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setHideNumbers(!hideNumbers)}
|
onClick={() => setHideNumbers(!hideNumbers)}
|
||||||
className="flex items-center gap-2"
|
className={`flex items-center gap-2 rounded-xl transition-all font-medium px-4 ${hideNumbers ? 'bg-primary text-primary-foreground shadow-lg' : 'hover:bg-white/5'}`}
|
||||||
>
|
>
|
||||||
{hideNumbers ? (
|
{hideNumbers ? (
|
||||||
<>
|
<>
|
||||||
<EyeOff className="h-4 w-4" />
|
<EyeOff className="h-4 w-4" />
|
||||||
Show Numbers
|
<span className="hidden sm:inline">Numbers Hidden</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4 text-primary/70" />
|
||||||
Hide Numbers
|
<span className="hidden sm:inline">Hide Numbers</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<div className="w-px h-5 bg-white/10 mx-1" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={refreshData}
|
onClick={refreshData}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2 rounded-xl hover:bg-white/5 font-medium px-4"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
|
className={`h-4 w-4 ${isLoading ? "animate-spin text-primary" : "text-primary/70"}`}
|
||||||
/>
|
/>
|
||||||
Refresh
|
<span className="hidden sm:inline">Refresh Data</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MotionWrapper className="space-y-12">
|
||||||
|
{/* Analytics Tabs Setup */}
|
||||||
|
<Tabs defaultValue="overview" className="space-y-10">
|
||||||
|
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-6 pb-2">
|
||||||
|
<TabsList className="bg-transparent h-auto p-0 flex flex-wrap gap-2 lg:gap-4">
|
||||||
|
<TabsTrigger
|
||||||
|
value="overview"
|
||||||
|
className="px-4 py-2 rounded-xl data-[state=active]:bg-primary/5 data-[state=active]:text-primary data-[state=active]:shadow-none border border-transparent data-[state=active]:border-primary/20 flex items-center gap-2 transition-all"
|
||||||
|
>
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
Overview
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="financials"
|
||||||
|
className="px-4 py-2 rounded-xl data-[state=active]:bg-primary/5 data-[state=active]:text-primary data-[state=active]:shadow-none border border-transparent data-[state=active]:border-primary/20 flex items-center gap-2 transition-all"
|
||||||
|
>
|
||||||
|
<DollarSign className="h-4 w-4" />
|
||||||
|
Financials
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="performance"
|
||||||
|
className="px-4 py-2 rounded-xl data-[state=active]:bg-primary/5 data-[state=active]:text-primary data-[state=active]:shadow-none border border-transparent data-[state=active]:border-primary/20 flex items-center gap-2 transition-all"
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
Performance
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="ai"
|
||||||
|
className="px-4 py-2 rounded-xl data-[state=active]:bg-primary/5 data-[state=active]:text-primary data-[state=active]:shadow-none border border-transparent data-[state=active]:border-primary/20 flex items-center gap-2 transition-all"
|
||||||
|
>
|
||||||
|
<Calculator className="h-4 w-4" />
|
||||||
|
AI Insights
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Contextual Time Range Selector */}
|
||||||
|
<div className="flex items-center gap-3 bg-muted/30 p-1 rounded-xl border border-border/20">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground px-2">Range</span>
|
||||||
|
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||||
|
<SelectTrigger className="w-[130px] h-8 border-none bg-transparent shadow-none focus:ring-0">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl border-border/40">
|
||||||
|
<SelectItem value="7">Last 7 days</SelectItem>
|
||||||
|
<SelectItem value="30">Last 30 days</SelectItem>
|
||||||
|
<SelectItem value="90">Last 90 days</SelectItem>
|
||||||
|
<SelectItem value="180">Last 180 days</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="overview" className="space-y-10 outline-none">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||||
|
className="space-y-10"
|
||||||
|
>
|
||||||
{/* Key Metrics Cards */}
|
{/* Key Metrics Cards */}
|
||||||
<MotionWrapper className="space-y-10">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 lg:gap-8">
|
|
||||||
{isLoading
|
{isLoading
|
||||||
? [...Array(4)].map((_, i) => <MetricsCardSkeleton key={i} />)
|
? [...Array(4)].map((_, i) => <MetricsCardSkeleton key={i} />)
|
||||||
: metrics.map((metric) => (
|
: metrics.map((metric) => (
|
||||||
@@ -250,156 +312,88 @@ export default function AnalyticsDashboard({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
{/* Completion Rate Card */}
|
{/* Completion Rate Card */}
|
||||||
<motion.div>
|
<Card className="lg:col-span-1 glass-morphism premium-card">
|
||||||
<Card className="hover:shadow-xl hover:border-indigo-500/30 transition-all duration-300">
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
<Activity className="h-5 w-5" />
|
<Activity className="h-5 w-5 text-emerald-500" />
|
||||||
Order Completion Rate
|
Order Completion
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Percentage of orders that have been successfully completed
|
Successfully processed orders
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center gap-4">
|
<Skeleton className="h-24 w-full rounded-2xl" />
|
||||||
<div className="h-12 w-16 bg-muted/20 rounded animate-pulse" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="w-full bg-muted/20 rounded-full h-2 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="h-6 w-16 bg-muted/20 rounded animate-pulse" />
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-4">
|
<div className="space-y-6">
|
||||||
<div className="text-3xl font-bold">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-4xl font-extrabold tracking-tight">
|
||||||
{hideNumbers ? "**%" : `${data.orders.completionRate}%`}
|
{hideNumbers ? "**%" : `${data.orders.completionRate}%`}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<Badge variant="outline" className="bg-emerald-500/10 text-emerald-600 border-emerald-500/20 px-3 py-1 text-xs font-bold">
|
||||||
<div className="w-full bg-secondary rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
|
||||||
style={{
|
|
||||||
width: hideNumbers
|
|
||||||
? "0%"
|
|
||||||
: `${data.orders.completionRate}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{hideNumbers
|
{hideNumbers
|
||||||
? "** / **"
|
? "** / **"
|
||||||
: `${data.orders.completed} / ${data.orders.total}`}
|
: `${data.orders.completed} / ${data.orders.total}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-full bg-secondary/50 rounded-full h-3 overflow-hidden border border-border/20">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: hideNumbers ? "0%" : `${data.orders.completionRate}%` }}
|
||||||
|
transition={{ duration: 1, ease: "circOut" }}
|
||||||
|
className="bg-gradient-to-r from-emerald-500 to-teal-400 h-full rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Time Period Selector */}
|
{/* Growth Chart Snippet (Simplified) */}
|
||||||
<div className="flex flex-col sm:flex-row gap-4 sm:items-center sm:justify-between">
|
<div className="lg:col-span-2 min-w-0">
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold">Time Period</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Revenue, Profit, and Orders tabs use time filtering. Products and
|
|
||||||
Customers show all-time data.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
|
||||||
<SelectTrigger className="w-32">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="7">Last 7 days</SelectItem>
|
|
||||||
<SelectItem value="30">Last 30 days</SelectItem>
|
|
||||||
<SelectItem value="90">Last 90 days</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Analytics Tabs */}
|
|
||||||
<div className="space-y-8">
|
|
||||||
<Tabs defaultValue="growth" className="space-y-8">
|
|
||||||
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-3 lg:grid-cols-7">
|
|
||||||
<TabsTrigger value="growth" className="flex items-center gap-2">
|
|
||||||
<Activity className="h-4 w-4" />
|
|
||||||
Growth
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="revenue" className="flex items-center gap-2">
|
|
||||||
<TrendingUp className="h-4 w-4" />
|
|
||||||
Revenue
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="profit" className="flex items-center gap-2">
|
|
||||||
<Calculator className="h-4 w-4" />
|
|
||||||
Profit
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="products" className="flex items-center gap-2">
|
|
||||||
<Package className="h-4 w-4" />
|
|
||||||
Products
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="customers" className="flex items-center gap-2">
|
|
||||||
<Users className="h-4 w-4" />
|
|
||||||
Customers
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="orders" className="flex items-center gap-2">
|
|
||||||
<BarChart3 className="h-4 w-4" />
|
|
||||||
Orders
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="predictions" className="flex items-center gap-2">
|
|
||||||
<TrendingUp className="h-4 w-4" />
|
|
||||||
Predictions
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="growth" className="space-y-6">
|
|
||||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}>
|
|
||||||
<Suspense fallback={<ChartSkeleton />}>
|
<Suspense fallback={<ChartSkeleton />}>
|
||||||
<GrowthAnalyticsChart hideNumbers={hideNumbers} />
|
<GrowthAnalyticsChart hideNumbers={hideNumbers} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="revenue" className="space-y-6">
|
<TabsContent value="financials" className="space-y-8 outline-none">
|
||||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}>
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="grid grid-cols-1 xl:grid-cols-2 gap-8"
|
||||||
|
>
|
||||||
<Suspense fallback={<ChartSkeleton />}>
|
<Suspense fallback={<ChartSkeleton />}>
|
||||||
|
<div className="min-w-0">
|
||||||
<RevenueChart timeRange={timeRange} hideNumbers={hideNumbers} />
|
<RevenueChart timeRange={timeRange} hideNumbers={hideNumbers} />
|
||||||
|
</div>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</motion.div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="profit" className="space-y-6">
|
<div className="space-y-8">
|
||||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}>
|
<Card className="glass-morphism">
|
||||||
{/* Date Range Selector for Profit Calculator */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Date Range</CardTitle>
|
<CardTitle className="text-lg">Profit Range</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Select a custom date range for profit calculations
|
Custom date selection for analysis
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-col sm:flex-row gap-4 sm:items-center">
|
|
||||||
<DateRangePicker
|
<DateRangePicker
|
||||||
dateRange={profitDateRange}
|
dateRange={profitDateRange}
|
||||||
onDateRangeChange={setProfitDateRange}
|
onDateRangeChange={setProfitDateRange}
|
||||||
placeholder="Select date range"
|
placeholder="Select date range"
|
||||||
showPresets={true}
|
showPresets={true}
|
||||||
className="w-full sm:w-auto"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
{profitDateRange?.from && profitDateRange?.to && (
|
|
||||||
<div className="text-sm text-muted-foreground flex items-center">
|
|
||||||
<span>
|
|
||||||
{profitDateRange.from.toLocaleDateString()} -{" "}
|
|
||||||
{profitDateRange.to.toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Suspense fallback={<ChartSkeleton />}>
|
<Suspense fallback={<ChartSkeleton />}>
|
||||||
<ProfitAnalyticsChart
|
<ProfitAnalyticsChart
|
||||||
dateRange={
|
dateRange={
|
||||||
@@ -413,43 +407,49 @@ export default function AnalyticsDashboard({
|
|||||||
hideNumbers={hideNumbers}
|
hideNumbers={hideNumbers}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="products" className="space-y-6">
|
<TabsContent value="performance" className="space-y-8 outline-none">
|
||||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}>
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="grid grid-cols-1 lg:grid-cols-2 gap-8"
|
||||||
|
>
|
||||||
<Suspense fallback={<ChartSkeleton />}>
|
<Suspense fallback={<ChartSkeleton />}>
|
||||||
|
<div className="min-w-0">
|
||||||
<ProductPerformanceChart />
|
<ProductPerformanceChart />
|
||||||
|
</div>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</motion.div>
|
<div className="space-y-8 min-w-0">
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="customers" className="space-y-6">
|
|
||||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}>
|
|
||||||
<Suspense fallback={<ChartSkeleton />}>
|
|
||||||
<CustomerInsightsChart />
|
|
||||||
</Suspense>
|
|
||||||
</motion.div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="orders" className="space-y-6">
|
|
||||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}>
|
|
||||||
<Suspense fallback={<ChartSkeleton />}>
|
<Suspense fallback={<ChartSkeleton />}>
|
||||||
<OrderAnalyticsChart timeRange={timeRange} />
|
<OrderAnalyticsChart timeRange={timeRange} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
<Suspense fallback={<ChartSkeleton />}>
|
||||||
|
<CustomerInsightsChart />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="predictions" className="space-y-6">
|
<TabsContent value="ai" className="space-y-8 outline-none">
|
||||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}>
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
className="min-w-0"
|
||||||
|
>
|
||||||
<Suspense fallback={<ChartSkeleton />}>
|
<Suspense fallback={<ChartSkeleton />}>
|
||||||
<PredictionsChart timeRange={parseInt(timeRange)} />
|
<PredictionsChart timeRange={parseInt(timeRange)} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
|
||||||
</MotionWrapper>
|
</MotionWrapper>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/common/tabs";
|
||||||
import { MetricsCardSkeleton } from './SkeletonLoaders';
|
import { MetricsCardSkeleton } from './SkeletonLoaders';
|
||||||
import {
|
import {
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/common/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/common/select";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/lib/hooks/use-toast";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
import { Users, Crown, UserPlus, UserCheck, Star, ChevronLeft, ChevronRight } from "lucide-react";
|
import { Users, Crown, UserPlus, UserCheck, Star, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { getCustomerInsightsWithStore, type CustomerInsights } from "@/lib/services/analytics-service";
|
import { getCustomerInsightsWithStore, type CustomerInsights } from "@/lib/services/analytics-service";
|
||||||
import { formatGBP } from "@/utils/format";
|
import { formatGBP } from "@/lib/utils/format";
|
||||||
import { CustomerInsightsSkeleton } from './SkeletonLoaders';
|
import { CustomerInsightsSkeleton } from './SkeletonLoaders';
|
||||||
|
|
||||||
export default function CustomerInsightsChart() {
|
export default function CustomerInsightsChart() {
|
||||||
@@ -306,3 +306,4 @@ export default function CustomerInsightsChart() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/common/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/lib/hooks/use-toast";
|
||||||
import { RefreshCw } from "lucide-react";
|
import { RefreshCw } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
getGrowthAnalyticsWithStore,
|
getGrowthAnalyticsWithStore,
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Area,
|
Area,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
import { formatGBP, formatNumber } from "@/lib/utils/format";
|
||||||
|
|
||||||
interface GrowthAnalyticsChartProps {
|
interface GrowthAnalyticsChartProps {
|
||||||
hideNumbers?: boolean;
|
hideNumbers?: boolean;
|
||||||
@@ -63,14 +64,6 @@ export default function GrowthAnalyticsChart({
|
|||||||
fetchGrowthData();
|
fetchGrowthData();
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
|
||||||
if (hideNumbers) return "£***";
|
|
||||||
return new Intl.NumberFormat("en-GB", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "GBP",
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -115,9 +108,7 @@ export default function GrowthAnalyticsChart({
|
|||||||
Total Orders
|
Total Orders
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{hideNumbers
|
{hideNumbers ? "***" : formatNumber(growthData.cumulative.orders)}
|
||||||
? "***"
|
|
||||||
: growthData.cumulative.orders.toLocaleString()}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -127,7 +118,7 @@ export default function GrowthAnalyticsChart({
|
|||||||
Total Revenue
|
Total Revenue
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold text-green-600">
|
<div className="text-2xl font-bold text-green-600">
|
||||||
{formatCurrency(growthData.cumulative.revenue)}
|
{hideNumbers ? "£***" : formatGBP(growthData.cumulative.revenue)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -137,9 +128,7 @@ export default function GrowthAnalyticsChart({
|
|||||||
Customers
|
Customers
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{hideNumbers
|
{hideNumbers ? "***" : formatNumber(growthData.cumulative.customers)}
|
||||||
? "***"
|
|
||||||
: growthData.cumulative.customers.toLocaleString()}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -149,9 +138,7 @@ export default function GrowthAnalyticsChart({
|
|||||||
Products
|
Products
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{hideNumbers
|
{hideNumbers ? "***" : formatNumber(growthData.cumulative.products)}
|
||||||
? "***"
|
|
||||||
: growthData.cumulative.products.toLocaleString()}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -161,7 +148,7 @@ export default function GrowthAnalyticsChart({
|
|||||||
Avg Order Value
|
Avg Order Value
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{formatCurrency(growthData.cumulative.avgOrderValue)}
|
{hideNumbers ? "£***" : formatGBP(growthData.cumulative.avgOrderValue)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -183,7 +170,7 @@ export default function GrowthAnalyticsChart({
|
|||||||
</div>
|
</div>
|
||||||
) : growthData?.monthly && growthData.monthly.length > 0 ? (
|
) : growthData?.monthly && growthData.monthly.length > 0 ? (
|
||||||
<div className="h-80">
|
<div className="h-80">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer key={growthData?.monthly?.length || 0} width="100%" height="100%">
|
||||||
<ComposedChart
|
<ComposedChart
|
||||||
data={growthData.monthly.map((m) => ({
|
data={growthData.monthly.map((m) => ({
|
||||||
...m,
|
...m,
|
||||||
@@ -236,16 +223,16 @@ export default function GrowthAnalyticsChart({
|
|||||||
Orders:{" "}
|
Orders:{" "}
|
||||||
{hideNumbers
|
{hideNumbers
|
||||||
? "***"
|
? "***"
|
||||||
: data.orders.toLocaleString()}
|
: formatNumber(data.orders)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-green-600">
|
<p className="text-sm text-green-600">
|
||||||
Revenue: {formatCurrency(data.revenue)}
|
Revenue: {hideNumbers ? "£***" : formatGBP(data.revenue)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-purple-600">
|
<p className="text-sm text-purple-600">
|
||||||
Customers:{" "}
|
Customers:{" "}
|
||||||
{hideNumbers
|
{hideNumbers
|
||||||
? "***"
|
? "***"
|
||||||
: data.customers.toLocaleString()}
|
: formatNumber(data.customers)}
|
||||||
</p>
|
</p>
|
||||||
{data.newCustomers !== undefined && (
|
{data.newCustomers !== undefined && (
|
||||||
<p className="text-sm text-cyan-600">
|
<p className="text-sm text-cyan-600">
|
||||||
@@ -327,16 +314,16 @@ export default function GrowthAnalyticsChart({
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right p-2">
|
<td className="text-right p-2">
|
||||||
{hideNumbers ? "***" : month.orders.toLocaleString()}
|
{hideNumbers ? "***" : formatNumber(month.orders)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right p-2 text-green-600">
|
<td className="text-right p-2 text-green-600">
|
||||||
{formatCurrency(month.revenue)}
|
{hideNumbers ? "£***" : formatGBP(month.revenue)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right p-2">
|
<td className="text-right p-2">
|
||||||
{hideNumbers ? "***" : month.customers.toLocaleString()}
|
{hideNumbers ? "***" : formatNumber(month.customers)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right p-2">
|
<td className="text-right p-2">
|
||||||
{formatCurrency(month.avgOrderValue)}
|
{hideNumbers ? "£***" : formatGBP(month.avgOrderValue)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right p-2">
|
<td className="text-right p-2">
|
||||||
{hideNumbers ? "***" : (month.newCustomers ?? 0)}
|
{hideNumbers ? "***" : (month.newCustomers ?? 0)}
|
||||||
@@ -352,3 +339,5 @@ export default function GrowthAnalyticsChart({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||||
import { LucideIcon } from "lucide-react";
|
import { LucideIcon } from "lucide-react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
@@ -25,43 +25,89 @@ export default function MetricsCard({
|
|||||||
const getTrendIcon = () => {
|
const getTrendIcon = () => {
|
||||||
switch (trend) {
|
switch (trend) {
|
||||||
case "up":
|
case "up":
|
||||||
return <TrendingUp className="h-4 w-4 text-green-500" />;
|
return <TrendingUp className="h-4 w-4 text-emerald-500" />;
|
||||||
case "down":
|
case "down":
|
||||||
return <TrendingDown className="h-4 w-4 text-red-500" />;
|
return <TrendingDown className="h-4 w-4 text-rose-500" />;
|
||||||
default:
|
default:
|
||||||
return <Minus className="h-4 w-4 text-gray-500" />;
|
return <Minus className="h-4 w-4 text-muted-foreground" />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTrendColor = () => {
|
const getTrendColor = () => {
|
||||||
switch (trend) {
|
switch (trend) {
|
||||||
case "up":
|
case "up":
|
||||||
return "text-green-600";
|
return "text-emerald-400 bg-emerald-500/10 border-emerald-500/10";
|
||||||
case "down":
|
case "down":
|
||||||
return "text-red-600";
|
return "text-rose-400 bg-rose-500/10 border-rose-500/10";
|
||||||
default:
|
default:
|
||||||
return "text-gray-600";
|
return "text-blue-400 bg-blue-500/10 border-blue-500/10";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCategoryColor = () => {
|
||||||
|
const t = title.toLowerCase();
|
||||||
|
if (t.includes("revenue") || t.includes("profit")) return "amber";
|
||||||
|
if (t.includes("order")) return "blue";
|
||||||
|
if (t.includes("customer")) return "indigo";
|
||||||
|
if (t.includes("product") || t.includes("inventory")) return "purple";
|
||||||
|
return "primary";
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryColor = getCategoryColor();
|
||||||
|
|
||||||
|
const getIconContainerColor = () => {
|
||||||
|
switch (categoryColor) {
|
||||||
|
case "amber": return "bg-amber-500/15 text-amber-500 border-amber-500/20";
|
||||||
|
case "blue": return "bg-blue-500/15 text-blue-500 border-blue-500/20";
|
||||||
|
case "indigo": return "bg-indigo-500/15 text-indigo-500 border-indigo-500/20";
|
||||||
|
case "purple": return "bg-purple-500/15 text-purple-500 border-purple-500/20";
|
||||||
|
default: return "bg-primary/15 text-primary border-primary/20";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBadgeColor = () => {
|
||||||
|
switch (categoryColor) {
|
||||||
|
case "amber": return "bg-amber-500/10 text-amber-400/80 border-amber-500/20";
|
||||||
|
case "blue": return "bg-blue-500/10 text-blue-400/80 border-blue-500/20";
|
||||||
|
case "indigo": return "bg-indigo-500/10 text-indigo-400/80 border-indigo-500/20";
|
||||||
|
case "purple": return "bg-purple-500/10 text-purple-400/80 border-purple-500/20";
|
||||||
|
default: return "bg-primary/10 text-primary/60 border-primary/20";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div>
|
<motion.div
|
||||||
<Card className="hover:shadow-xl hover:border-indigo-500/30 transition-all duration-300">
|
whileHover={{ y: -4 }}
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
>
|
||||||
|
<Card className="glass-morphism premium-card relative overflow-hidden group border-white/5">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4 relative z-10">
|
||||||
|
<CardTitle className="text-[10px] font-bold tracking-wider text-white/40 uppercase">
|
||||||
{title}
|
{title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
<div className={`p-2.5 rounded-2xl border ${getIconContainerColor()} transition-all duration-300 group-hover:scale-105`}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{value}</div>
|
<CardContent className="relative z-10">
|
||||||
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center gap-1 mt-2">
|
<div className="text-3xl font-bold text-white">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-white/40">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-6 pt-5">
|
||||||
|
<div className={`flex items-center gap-1.5 px-3 py-1 rounded-full border ${getTrendColor()} transition-all duration-300`}>
|
||||||
{getTrendIcon()}
|
{getTrendIcon()}
|
||||||
<span className={`text-xs ${getTrendColor()}`}>
|
<span className="text-[10px] font-bold uppercase tracking-wide">
|
||||||
{trendValue}
|
{trend === "up" ? "+" : ""}{trendValue}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/common/badge";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/lib/hooks/use-toast";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
import { BarChart3, Clock, CheckCircle, XCircle, AlertCircle, AlertTriangle } from "lucide-react";
|
import { BarChart3, Clock, CheckCircle, XCircle, AlertCircle, AlertTriangle } from "lucide-react";
|
||||||
import { getOrderAnalyticsWithStore, type OrderAnalytics } from "@/lib/services/analytics-service";
|
import { getOrderAnalyticsWithStore, type OrderAnalytics } from "@/lib/services/analytics-service";
|
||||||
import { formatGBP } from "@/utils/format";
|
import { formatGBP } from "@/lib/utils/format";
|
||||||
import { ChartSkeleton } from './SkeletonLoaders';
|
import { ChartSkeleton } from './SkeletonLoaders';
|
||||||
|
|
||||||
interface OrderAnalyticsChartProps {
|
interface OrderAnalyticsChartProps {
|
||||||
@@ -202,3 +202,4 @@ export default function OrderAnalyticsChart({ timeRange }: OrderAnalyticsChartPr
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,16 +7,16 @@ import {
|
|||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/common/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/common/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/common/select";
|
||||||
import {
|
import {
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
@@ -32,8 +32,8 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
Download,
|
Download,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/lib/hooks/use-toast";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
import CountUp from "react-countup";
|
import CountUp from "react-countup";
|
||||||
import {
|
import {
|
||||||
getPredictionsOverviewWithStore,
|
getPredictionsOverviewWithStore,
|
||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
type PredictionsOverview,
|
type PredictionsOverview,
|
||||||
type StockPredictionsResponse,
|
type StockPredictionsResponse,
|
||||||
} from "@/lib/services/analytics-service";
|
} from "@/lib/services/analytics-service";
|
||||||
import { formatGBP } from "@/utils/format";
|
import { formatGBP } from "@/lib/utils/format";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -49,7 +49,7 @@ import {
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/common/table";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import {
|
import {
|
||||||
AreaChart,
|
AreaChart,
|
||||||
@@ -65,9 +65,9 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/common/tooltip";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/common/alert";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/common/slider";
|
||||||
|
|
||||||
interface PredictionsChartProps {
|
interface PredictionsChartProps {
|
||||||
timeRange?: number;
|
timeRange?: number;
|
||||||
@@ -164,6 +164,13 @@ export default function PredictionsChart({
|
|||||||
setSimulationFactor(0);
|
setSimulationFactor(0);
|
||||||
}, [timeRange]);
|
}, [timeRange]);
|
||||||
|
|
||||||
|
// Auto-adjust daysAhead if it exceeds historical timeRange
|
||||||
|
useEffect(() => {
|
||||||
|
if (daysAhead > timeRange) {
|
||||||
|
setDaysAhead(timeRange);
|
||||||
|
}
|
||||||
|
}, [timeRange, daysAhead]);
|
||||||
|
|
||||||
// Switch predictions when daysAhead changes (instant, from batch)
|
// Switch predictions when daysAhead changes (instant, from batch)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (batchData) {
|
if (batchData) {
|
||||||
@@ -322,10 +329,21 @@ export default function PredictionsChart({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="7">7 days</SelectItem>
|
<SelectItem value="7">7 days</SelectItem>
|
||||||
<SelectItem value="14">14 days</SelectItem>
|
<SelectItem value="14" disabled={timeRange < 14}>
|
||||||
<SelectItem value="30">30 days</SelectItem>
|
14 days {timeRange < 14 && "(Needs 14d history)"}
|
||||||
<SelectItem value="60">60 days</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="90">90 days</SelectItem>
|
<SelectItem value="30" disabled={timeRange < 30}>
|
||||||
|
30 days {timeRange < 30 && "(Needs 30d history)"}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="60" disabled={timeRange < 60}>
|
||||||
|
60 days {timeRange < 60 && "(Needs 60d history)"}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="90" disabled={timeRange < 90}>
|
||||||
|
90 days {timeRange < 90 && "(Needs 90d history)"}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="180" disabled={timeRange < 180}>
|
||||||
|
180 days {timeRange < 180 && "(Needs 180d history)"}
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button
|
<Button
|
||||||
@@ -386,7 +404,7 @@ export default function PredictionsChart({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent side="bottom" className="z-[100]">
|
||||||
<p>Predicted daily average revenue for the next {daysAhead} days</p>
|
<p>Predicted daily average revenue for the next {daysAhead} days</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -409,7 +427,7 @@ export default function PredictionsChart({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent side="bottom" className="z-[100]">
|
||||||
<p>Based on data consistency, historical accuracy, and model agreement</p>
|
<p>Based on data consistency, historical accuracy, and model agreement</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -428,8 +446,16 @@ export default function PredictionsChart({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent side="bottom" className="z-[100] max-w-xs">
|
||||||
<p>Predictions generated using a Deep Learning Ensemble Model</p>
|
<div className="space-y-1.5">
|
||||||
|
<p className="font-semibold">Deep Learning Ensemble Model</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
This percentage indicates how well the AI has learned your specific sales patterns.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground border-t pt-1.5">
|
||||||
|
Scores above 90% are optimal—100% is avoided to prevent "memorizing" the past and ensure the model remains flexible for future shifts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -461,7 +487,7 @@ export default function PredictionsChart({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent side="bottom" className="z-[100]">
|
||||||
<p>Direction of the recent sales trend (slope analysis)</p>
|
<p>Direction of the recent sales trend (slope analysis)</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -504,7 +530,7 @@ export default function PredictionsChart({
|
|||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
|
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent side="bottom" className="z-[100]">
|
||||||
<p>Technical details about the active prediction model</p>
|
<p>Technical details about the active prediction model</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -521,7 +547,7 @@ export default function PredictionsChart({
|
|||||||
Hybrid Ensemble (Deep Learning)
|
Hybrid Ensemble (Deep Learning)
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent side="bottom" className="z-[100]">
|
||||||
<p>Combines LSTM Neural Networks with Statistical Methods (Holt-Winters, ARIMA)</p>
|
<p>Combines LSTM Neural Networks with Statistical Methods (Holt-Winters, ARIMA)</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -561,20 +587,39 @@ export default function PredictionsChart({
|
|||||||
{/* Daily Predictions Chart */}
|
{/* Daily Predictions Chart */}
|
||||||
{predictions?.sales?.dailyPredictions &&
|
{predictions?.sales?.dailyPredictions &&
|
||||||
predictions?.sales?.dailyPredictions.length > 0 && (
|
predictions?.sales?.dailyPredictions.length > 0 && (
|
||||||
<Card>
|
<Card className="glass-morphism border-primary/10 overflow-hidden">
|
||||||
<CardHeader className="pb-3 flex flex-row items-center justify-between space-y-0">
|
<CardHeader className="pb-6 bg-muted/5">
|
||||||
<CardTitle className="text-sm font-medium">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
Daily Revenue Forecast
|
<div>
|
||||||
|
<CardTitle className="text-xl font-bold flex items-center gap-2 tracking-tight">
|
||||||
|
<Zap className="h-5 w-5 text-amber-500 fill-amber-500/20" />
|
||||||
|
Scenario Lab
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex items-center gap-4">
|
<CardDescription className="text-muted-foreground/80 font-medium">
|
||||||
<div className="flex flex-col items-end">
|
Adjust variables to see how traffic shifts impact your bottom line.
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
</CardDescription>
|
||||||
Simulate Traffic:{" "}
|
</div>
|
||||||
<span className={simulationFactor > 0 ? "text-green-600" : simulationFactor < 0 ? "text-red-600" : ""}>
|
|
||||||
{simulationFactor > 0 ? "+" : ""}
|
<div className="flex items-center gap-4 bg-black/40 p-2.5 rounded-2xl border border-white/5 shadow-2xl backdrop-blur-md">
|
||||||
{simulationFactor}%
|
<div className="flex flex-col items-start min-w-[150px]">
|
||||||
</span>
|
<div className="flex items-center gap-1.5 mb-1 ml-1">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-primary/40">
|
||||||
|
Traffic Simulation
|
||||||
</span>
|
</span>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3 w-3 text-primary/30 cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="max-w-[200px] z-[110] bg-black border-white/10 text-white p-2">
|
||||||
|
<p className="text-[11px] leading-relaxed">
|
||||||
|
Simulate traffic growth or decline to see how it might impact your future revenue and order volume.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 w-full">
|
||||||
<Slider
|
<Slider
|
||||||
value={[simulationFactor]}
|
value={[simulationFactor]}
|
||||||
min={-50}
|
min={-50}
|
||||||
@@ -582,37 +627,64 @@ export default function PredictionsChart({
|
|||||||
step={10}
|
step={10}
|
||||||
onValueChange={(val) => setSimulationFactor(val[0])}
|
onValueChange={(val) => setSimulationFactor(val[0])}
|
||||||
onValueCommit={(val) => setCommittedSimulationFactor(val[0])}
|
onValueCommit={(val) => setCommittedSimulationFactor(val[0])}
|
||||||
className="w-[150px] mt-1.5"
|
className="w-full flex-1"
|
||||||
/>
|
/>
|
||||||
|
<Badge variant="outline" className={`ml-2 min-w-[50px] text-center font-bold border-2 ${simulationFactor > 0 ? "text-emerald-400 border-emerald-500/30 bg-emerald-500/10" : simulationFactor < 0 ? "text-rose-400 border-rose-500/30 bg-rose-500/10" : "text-primary/60"}`}>
|
||||||
|
{simulationFactor > 0 ? "+" : ""}{simulationFactor}%
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{simulationFactor !== 0 && (
|
</div>
|
||||||
|
|
||||||
|
{(simulationFactor !== 0 || committedSimulationFactor !== 0) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6"
|
className="h-9 w-9 hover:bg-white/10 rounded-xl transition-all"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSimulationFactor(0);
|
setSimulationFactor(0);
|
||||||
setCommittedSimulationFactor(0);
|
setCommittedSimulationFactor(0);
|
||||||
}}
|
}}
|
||||||
title="Reset simulation"
|
title="Reset Scenario"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-3 w-3" />
|
<RefreshCw className="h-4 w-4 text-primary/70" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={handleExportCSV} title="Export to CSV">
|
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExportCSV} className="rounded-xl border-white/10 hover:bg-white/5 font-bold px-4">
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
Export
|
Export Forecast
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="pt-8">
|
||||||
<div className="h-80 w-full mt-4 relative">
|
{/* Legend / Key */}
|
||||||
{isSimulating && (
|
<div className="flex items-center gap-8 mb-8 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60">
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50 z-10">
|
<div className="flex items-center gap-3">
|
||||||
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
<div className="w-2.5 h-2.5 rounded-full bg-[#8884d8]" />
|
||||||
|
Baseline Forecast
|
||||||
|
</div>
|
||||||
|
{committedSimulationFactor !== 0 && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full bg-[#10b981]" />
|
||||||
|
Simulated Scenario
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
</div>
|
||||||
|
|
||||||
|
<div className="h-80 w-full relative">
|
||||||
|
{isSimulating && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/60 backdrop-blur-sm z-20 transition-all rounded-xl">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="relative">
|
||||||
|
<RefreshCw className="h-10 w-10 animate-spin text-primary" />
|
||||||
|
<Zap className="h-4 w-4 text-amber-500 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-primary animate-pulse">Running Neural Simulation...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ResponsiveContainer key={`${daysAhead}-${timeRange}`} width="100%" height="100%">
|
||||||
<AreaChart
|
<AreaChart
|
||||||
data={chartData}
|
data={chartData}
|
||||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||||
@@ -622,7 +694,7 @@ export default function PredictionsChart({
|
|||||||
<stop
|
<stop
|
||||||
offset="5%"
|
offset="5%"
|
||||||
stopColor="#8884d8"
|
stopColor="#8884d8"
|
||||||
stopOpacity={0.6}
|
stopOpacity={0.3}
|
||||||
/>
|
/>
|
||||||
<stop
|
<stop
|
||||||
offset="95%"
|
offset="95%"
|
||||||
@@ -634,7 +706,7 @@ export default function PredictionsChart({
|
|||||||
<stop
|
<stop
|
||||||
offset="5%"
|
offset="5%"
|
||||||
stopColor="#10b981"
|
stopColor="#10b981"
|
||||||
stopOpacity={0.8}
|
stopOpacity={0.5}
|
||||||
/>
|
/>
|
||||||
<stop
|
<stop
|
||||||
offset="95%"
|
offset="95%"
|
||||||
@@ -643,50 +715,66 @@ export default function PredictionsChart({
|
|||||||
/>
|
/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border) / 0.4)" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="formattedDate"
|
dataKey="formattedDate"
|
||||||
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
|
dy={15}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(value) => `£${value}`}
|
tickFormatter={(value) => `£${value}`}
|
||||||
/>
|
/>
|
||||||
<RechartsTooltip
|
<RechartsTooltip
|
||||||
cursor={{ fill: "transparent", stroke: "hsl(var(--muted-foreground))", strokeDasharray: "3 3" }}
|
cursor={{ fill: "transparent", stroke: "hsl(var(--primary) / 0.05)", strokeWidth: 40 }}
|
||||||
content={({ active, payload }) => {
|
content={({ active, payload }) => {
|
||||||
if (active && payload?.length) {
|
if (active && payload?.length) {
|
||||||
const data = payload[0].payload;
|
const data = payload[0].payload;
|
||||||
return (
|
return (
|
||||||
<div className="bg-background border border-border p-3 rounded-lg shadow-lg">
|
<div className="bg-[#050505] p-5 rounded-2xl shadow-2xl border border-white/10 backdrop-blur-2xl ring-1 ring-white/5">
|
||||||
<p className="font-medium mb-2">{data.formattedDate}</p>
|
<p className="font-bold text-[10px] uppercase tracking-wide text-muted-foreground mb-4 border-b border-white/5 pb-3 px-1">{data.formattedDate}</p>
|
||||||
<p className="text-sm text-purple-600">
|
<div className="space-y-3">
|
||||||
Baseline: <span className="font-semibold">{formatGBP(data.baseline)}</span>
|
<div className="flex items-center justify-between gap-10">
|
||||||
</p>
|
<span className="text-[10px] font-bold text-muted-foreground/60 uppercase tracking-wide">Baseline:</span>
|
||||||
|
<span className="text-sm font-bold text-[#8884d8] tabular-nums">{formatGBP(data.baseline)}</span>
|
||||||
|
</div>
|
||||||
{committedSimulationFactor !== 0 && (
|
{committedSimulationFactor !== 0 && (
|
||||||
<p className="text-sm text-green-600">
|
<div className="flex items-center justify-between gap-10">
|
||||||
Simulated: <span className="font-semibold">{formatGBP(data.simulated)}</span>
|
<span className="text-[10px] font-bold text-muted-foreground/60 uppercase tracking-wide">Simulated:</span>
|
||||||
</p>
|
<div className="flex flex-col items-end">
|
||||||
|
<span className="text-sm font-bold text-emerald-400 tabular-nums">{formatGBP(data.simulated)}</span>
|
||||||
|
<span className={`text-[10px] font-bold mt-0.5 ${data.simulated > data.baseline ? 'text-emerald-500' : 'text-rose-500'}`}>
|
||||||
|
{data.simulated > data.baseline ? '▴' : '▾'} {Math.abs(((data.simulated / data.baseline - 1) * 100)).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex items-center justify-between gap-10 pt-3 border-t border-white/5">
|
||||||
|
<span className="text-[10px] font-bold text-muted-foreground/60 uppercase tracking-widest">Est. Orders:</span>
|
||||||
|
<span className="text-sm font-bold tabular-nums">
|
||||||
|
{Math.round(data.orders)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Always show baseline as solid line */}
|
{/* Always show baseline */}
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="baseline"
|
dataKey="baseline"
|
||||||
stroke="#8884d8"
|
stroke="#8884d8"
|
||||||
fillOpacity={committedSimulationFactor !== 0 ? 0.3 : 1}
|
fillOpacity={1}
|
||||||
fill="url(#colorBaseline)"
|
fill="url(#colorBaseline)"
|
||||||
strokeWidth={committedSimulationFactor !== 0 ? 1 : 2}
|
strokeWidth={3}
|
||||||
activeDot={{ r: 4, strokeWidth: 0 }}
|
dot={false}
|
||||||
|
activeDot={{ r: 4, strokeWidth: 0, fill: "#8884d8" }}
|
||||||
/>
|
/>
|
||||||
{/* Show simulated line when simulation is active */}
|
{/* Show simulated line when simulation is active */}
|
||||||
{committedSimulationFactor !== 0 && (
|
{committedSimulationFactor !== 0 && (
|
||||||
@@ -694,11 +782,12 @@ export default function PredictionsChart({
|
|||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="simulated"
|
dataKey="simulated"
|
||||||
stroke="#10b981"
|
stroke="#10b981"
|
||||||
fillOpacity={0.6}
|
fillOpacity={1}
|
||||||
fill="url(#colorSimulated)"
|
fill="url(#colorSimulated)"
|
||||||
|
strokeWidth={3}
|
||||||
strokeDasharray="5 5"
|
strokeDasharray="5 5"
|
||||||
strokeWidth={2}
|
dot={false}
|
||||||
activeDot={{ r: 5, strokeWidth: 0 }}
|
activeDot={{ r: 6, strokeWidth: 3, stroke: "#fff", fill: "#10b981" }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
@@ -844,3 +933,5 @@ export default function PredictionsChart({
|
|||||||
</Card >
|
</Card >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/common/table";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/common/badge";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/lib/hooks/use-toast";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
import { Package } from "lucide-react";
|
import { Package } from "lucide-react";
|
||||||
import { getProductPerformanceWithStore, type ProductPerformance } from "@/lib/services/analytics-service";
|
import { getProductPerformanceWithStore, type ProductPerformance } from "@/lib/services/analytics-service";
|
||||||
import { formatGBP } from "@/utils/format";
|
import { formatGBP, formatNumber } from "@/lib/utils/format";
|
||||||
import { TableSkeleton } from './SkeletonLoaders';
|
import { TableSkeleton } from './SkeletonLoaders';
|
||||||
|
|
||||||
export default function ProductPerformanceChart() {
|
export default function ProductPerformanceChart() {
|
||||||
@@ -137,7 +137,7 @@ export default function ProductPerformanceChart() {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right font-medium">
|
<TableCell className="text-right font-medium">
|
||||||
{parseInt(product.totalSold.toFixed(0)).toLocaleString()} {product.unitType}
|
{formatNumber(parseInt(product.totalSold.toFixed(0)))} {product.unitType}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right font-medium text-green-600">
|
<TableCell className="text-right font-medium text-green-600">
|
||||||
{formatGBP(product.totalRevenue)}
|
{formatGBP(product.totalRevenue)}
|
||||||
@@ -156,3 +156,4 @@ export default function ProductPerformanceChart() {
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/common/badge";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/common/alert";
|
||||||
import {
|
import {
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
@@ -11,12 +11,13 @@ import {
|
|||||||
PieChart,
|
PieChart,
|
||||||
Calculator,
|
Calculator,
|
||||||
Info,
|
Info,
|
||||||
AlertTriangle
|
AlertTriangle,
|
||||||
|
Package
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/lib/hooks/use-toast";
|
||||||
import { formatGBP } from "@/utils/format";
|
import { formatGBP } from "@/lib/utils/format";
|
||||||
import { getProfitOverview, type ProfitOverview, type DateRange } from "@/lib/services/profit-analytics-service";
|
import { getProfitOverview, type ProfitOverview, type DateRange } from "@/lib/services/profit-analytics-service";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
|
|
||||||
interface ProfitAnalyticsChartProps {
|
interface ProfitAnalyticsChartProps {
|
||||||
timeRange?: string;
|
timeRange?: string;
|
||||||
@@ -28,6 +29,7 @@ export default function ProfitAnalyticsChart({ timeRange, dateRange, hideNumbers
|
|||||||
const [data, setData] = useState<ProfitOverview | null>(null);
|
const [data, setData] = useState<ProfitOverview | null>(null);
|
||||||
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 [imageErrors, setImageErrors] = useState<Record<string, boolean>>({});
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const maskValue = (value: string): string => {
|
const maskValue = (value: string): string => {
|
||||||
@@ -237,8 +239,7 @@ export default function ProfitAnalyticsChart({ timeRange, dateRange, hideNumbers
|
|||||||
<CardTitle className="text-sm font-medium">Total Profit</CardTitle>
|
<CardTitle className="text-sm font-medium">Total Profit</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className={`text-2xl font-bold flex items-center gap-2 ${
|
<div className={`text-2xl font-bold flex items-center gap-2 ${profitDirection ? 'text-green-600' : 'text-red-600'
|
||||||
profitDirection ? 'text-green-600' : 'text-red-600'
|
|
||||||
}`}>
|
}`}>
|
||||||
{profitDirection ? (
|
{profitDirection ? (
|
||||||
<TrendingUp className="h-5 w-5" />
|
<TrendingUp className="h-5 w-5" />
|
||||||
@@ -327,15 +328,34 @@ export default function ProfitAnalyticsChart({ timeRange, dateRange, hideNumbers
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={product.productId}
|
key={product.productId}
|
||||||
className="flex items-center justify-between p-4 border rounded-lg"
|
className="flex items-center justify-between p-4 border rounded-lg transition-colors hover:bg-muted/30"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary font-semibold text-sm">
|
<div className="relative flex-shrink-0">
|
||||||
|
<div className="w-12 h-12 rounded-full overflow-hidden border-2 border-background shadow-sm bg-muted flex items-center justify-center">
|
||||||
|
{product.image && !imageErrors[product.productId] ? (
|
||||||
|
<img
|
||||||
|
src={`/api/products/${product.productId}/image`}
|
||||||
|
alt={product.productName}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={() => {
|
||||||
|
setImageErrors(prev => ({ ...prev, [product.productId]: true }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center w-full h-full bg-primary/10 text-primary font-bold text-lg">
|
||||||
|
{product.productName.charAt(0)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="absolute -top-1 -left-1 w-5 h-5 bg-primary text-[10px] text-primary-foreground flex items-center justify-center rounded-full font-bold border-2 border-background shadow-sm">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{product.productName}</p>
|
<p className="font-semibold">{product.productName}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground flex items-center gap-1">
|
||||||
|
<Package className="h-3 w-3" />
|
||||||
{product.totalQuantitySold} units sold
|
{product.totalQuantitySold} units sold
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -359,3 +379,5 @@ export default function ProfitAnalyticsChart({ timeRange, dateRange, hideNumbers
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/lib/hooks/use-toast";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
import { TrendingUp, DollarSign } from "lucide-react";
|
import { TrendingUp, DollarSign } from "lucide-react";
|
||||||
import { getRevenueTrendsWithStore, type RevenueData } from "@/lib/services/analytics-service";
|
import { getRevenueTrendsWithStore, type RevenueData } from "@/lib/services/analytics-service";
|
||||||
import { formatGBP } from "@/utils/format";
|
import { formatGBP } from "@/lib/utils/format";
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts';
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts';
|
||||||
import { ChartSkeleton } from './SkeletonLoaders';
|
import { ChartSkeleton } from './SkeletonLoaders';
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ export default function RevenueChart({ timeRange, hideNumbers = false }: Revenue
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Chart */}
|
{/* Chart */}
|
||||||
<div className="h-64">
|
<div className="h-64">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer key={timeRange} width="100%" height="100%">
|
||||||
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
|
||||||
@@ -240,3 +240,4 @@ export default function RevenueChart({ timeRange, hideNumbers = false }: Revenue
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/common/skeleton";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// Chart skeleton for revenue trends and order analytics
|
// Chart skeleton for revenue trends and order analytics
|
||||||
export function ChartSkeleton({
|
export function ChartSkeleton({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
showStats = false
|
showStats = false,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: any;
|
icon: any;
|
||||||
showStats?: boolean;
|
showStats?: boolean;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className={cn(className)}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-5 w-5" />
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/common/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/common/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/common/label";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/common/card";
|
||||||
import { Store, Search } from "lucide-react";
|
import { Store, Search } from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/lib/hooks/use-toast";
|
||||||
|
|
||||||
export default function StoreSelector() {
|
export default function StoreSelector() {
|
||||||
const [storeId, setStoreId] = useState('');
|
const [storeId, setStoreId] = useState('');
|
||||||
@@ -112,3 +112,4 @@ export default function StoreSelector() {
|
|||||||
</Card>
|
</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}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
|||||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils/styles";
|
import { cn } from "@/lib/utils/styles";
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/common/button"
|
||||||
|
|
||||||
const AlertDialog = AlertDialogPrimitive.Root
|
const AlertDialog = AlertDialogPrimitive.Root
|
||||||
|
|
||||||
@@ -139,3 +139,4 @@ export {
|
|||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5,7 +5,7 @@ import { ChevronLeft, ChevronRight } from "lucide-react"
|
|||||||
import { DayPicker } from "react-day-picker"
|
import { DayPicker } from "react-day-picker"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils/styles";
|
import { cn } from "@/lib/utils/styles";
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/common/button"
|
||||||
|
|
||||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||||
|
|
||||||
@@ -64,3 +64,4 @@ function Calendar({
|
|||||||
Calendar.displayName = "Calendar"
|
Calendar.displayName = "Calendar"
|
||||||
|
|
||||||
export { Calendar }
|
export { Calendar }
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ import useEmblaCarousel, {
|
|||||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils/styles";
|
import { cn } from "@/lib/utils/styles";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/common/button"
|
||||||
|
|
||||||
type CarouselApi = UseEmblaCarouselType[1]
|
type CarouselApi = UseEmblaCarouselType[1]
|
||||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||||
@@ -260,3 +260,4 @@ export {
|
|||||||
CarouselPrevious,
|
CarouselPrevious,
|
||||||
CarouselNext,
|
CarouselNext,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ import { Command as CommandPrimitive } from "cmdk"
|
|||||||
import { Search } from "lucide-react"
|
import { Search } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils/styles";
|
import { cn } from "@/lib/utils/styles";
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
import { Dialog, DialogContent } from "@/components/common/dialog"
|
||||||
|
|
||||||
const Command = React.forwardRef<
|
const Command = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive>,
|
React.ElementRef<typeof CommandPrimitive>,
|
||||||
@@ -15,7 +15,7 @@ const Command = React.forwardRef<
|
|||||||
<CommandPrimitive
|
<CommandPrimitive
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
"flex h-full w-full flex-col overflow-hidden bg-transparent text-popover-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -26,8 +26,8 @@ Command.displayName = CommandPrimitive.displayName
|
|||||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||||
return (
|
return (
|
||||||
<Dialog {...props}>
|
<Dialog {...props}>
|
||||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
<DialogContent className="overflow-hidden p-0 shadow-2xl border-white/5 bg-[#0a0a0a]/80 backdrop-blur-2xl sm:max-w-[600px] [&_button[data-radix-collection-item]]:hidden [&_button[class*='absolute']]:hidden">
|
||||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
<Command className="[&_[cmdk-group-heading]]:px-4 [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:text-primary/50 [&_[cmdk-group-heading]]:text-[10px] [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-widest [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-14 [&_[cmdk-item]]:px-3 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
{children}
|
{children}
|
||||||
</Command>
|
</Command>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -39,12 +39,12 @@ const CommandInput = React.forwardRef<
|
|||||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
<div className="flex items-center border-b border-white/5 px-4 bg-white/5" cmdk-input-wrapper="">
|
||||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
<Search className="mr-3 h-5 w-5 shrink-0 text-primary opacity-70" />
|
||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-14 w-full rounded-none bg-transparent py-4 text-base outline-none placeholder:text-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-50 border-none ring-0 focus:ring-0 focus:outline-none",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -60,7 +60,7 @@ const CommandList = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<CommandPrimitive.List
|
<CommandPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@@ -115,7 +115,7 @@ const CommandItem = React.forwardRef<
|
|||||||
<CommandPrimitive.Item
|
<CommandPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
"relative flex cursor-default gap-2 select-none items-center rounded-lg px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-primary/20 data-[selected=true]:text-primary data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 transition-colors duration-200",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -151,3 +151,4 @@ export {
|
|||||||
CommandShortcut,
|
CommandShortcut,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6,17 +6,17 @@ import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, X } from "lucide-r
|
|||||||
import { DateRange } from "react-day-picker"
|
import { DateRange } from "react-day-picker"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils/styles"
|
import { cn } from "@/lib/utils/styles"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/common/button"
|
||||||
import { Calendar } from "@/components/ui/calendar"
|
import { Calendar } from "@/components/common/calendar"
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover"
|
} from "@/components/common/popover"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/common/badge"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/common/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/common/label"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/common/select"
|
||||||
|
|
||||||
interface DatePickerProps {
|
interface DatePickerProps {
|
||||||
date?: Date
|
date?: Date
|
||||||
139
components/common/empty-state.tsx
Normal file
139
components/common/empty-state.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Button } from "@/components/common/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."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "react-hook-form"
|
} from "react-hook-form"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils/styles";
|
import { cn } from "@/lib/utils/styles";
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/common/label"
|
||||||
|
|
||||||
const Form = FormProvider
|
const Form = FormProvider
|
||||||
|
|
||||||
@@ -176,3 +176,4 @@ export {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
FormField,
|
FormField,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@ import * as React from "react"
|
|||||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils/styles";
|
import { cn } from "@/lib/utils/styles";
|
||||||
import { ButtonProps, buttonVariants } from "@/components/ui/button"
|
import { ButtonProps, buttonVariants } from "@/components/common/button"
|
||||||
|
|
||||||
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
||||||
<nav
|
<nav
|
||||||
@@ -115,3 +115,4 @@ export {
|
|||||||
PaginationNext,
|
PaginationNext,
|
||||||
PaginationPrevious,
|
PaginationPrevious,
|
||||||
}
|
}
|
||||||
|
|
||||||
115
components/common/relative-time.tsx
Normal file
115
components/common/relative-time.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/common/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 })
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,19 +5,19 @@ import { Slot } from "@radix-ui/react-slot"
|
|||||||
import { VariantProps, cva } from "class-variance-authority"
|
import { VariantProps, cva } from "class-variance-authority"
|
||||||
import { PanelLeft } from "lucide-react"
|
import { PanelLeft } from "lucide-react"
|
||||||
|
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/lib/hooks/use-mobile"
|
||||||
import { cn } from "@/lib/utils/styles";
|
import { cn } from "@/lib/utils/styles";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/common/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/common/input"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/common/separator"
|
||||||
import { Sheet, SheetContent } from "@/components/ui/sheet"
|
import { Sheet, SheetContent } from "@/components/common/sheet"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/common/skeleton"
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/common/tooltip"
|
||||||
|
|
||||||
const SIDEBAR_COOKIE_NAME = "sidebar:state"
|
const SIDEBAR_COOKIE_NAME = "sidebar:state"
|
||||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||||
@@ -761,3 +761,5 @@ export {
|
|||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user