Compare commits

...

15 Commits

Author SHA1 Message Date
g
a6e6cd0757 Remove widget resizing and edit mode from dashboard
Some checks failed
Build Frontend / build (push) Failing after 7s
Eliminated the ability to resize dashboard widgets by removing colSpan from WidgetConfig, related UI, and logic. Removed edit mode functionality and the EditDashboardButton, simplifying the dashboard layout and widget management. Updated drag-and-drop strategy to vertical list and incremented the storage key version.
2026-01-12 11:13:25 +00:00
g
9acd18955e Add scroll area to widget settings modal
Some checks failed
Build Frontend / build (push) Failing after 6s
Wrapped the widget settings modal content in a ScrollArea to improve usability when there are many settings, preventing overflow and keeping the modal compact.
2026-01-12 10:41:52 +00:00
g
318927cd0c Add modular dashboard widgets and layout editor
Some checks failed
Build Frontend / build (push) Failing after 7s
Introduces a modular dashboard system with draggable, configurable widgets including revenue, low stock, recent customers, and pending chats. Adds a dashboard editor for layout customization, widget visibility, and settings. Refactors dashboard content to use the new widget system and improves UI consistency and interactivity.
2026-01-12 10:39:50 +00:00
g
a6b7286b45 Refactor customer dialog styles for consistency
Updated the customer profile dialog to use more consistent and theme-based styling classes, replacing hardcoded colors and gradients with utility classes. Improved layout and text handling for better responsiveness and readability, and simplified button styles for maintainability.
2026-01-12 09:40:08 +00:00
g
d78e6c0725 Update order-table.tsx
All checks were successful
Build Frontend / build (push) Successful in 1m9s
2026-01-12 09:02:51 +00:00
g
3f9d28bf1b Improve browser detection and table UX for Firefox
All checks were successful
Build Frontend / build (push) Successful in 1m10s
Standardizes browser detection logic across admin and storefront pages to more accurately identify Firefox. Updates table rendering logic to provide better compatibility and fallback for Firefox, including conditional use of AnimatePresence and improved loading/empty states. Refines table UI styles for consistency and accessibility.
2026-01-12 08:59:04 +00:00
g
064cd7a486 Update page.tsx
All checks were successful
Build Frontend / build (push) Successful in 1m8s
2026-01-12 08:33:54 +00:00
g
6cd658c4cb Revamp OrderTable UI and add Firefox animation fallback
All checks were successful
Build Frontend / build (push) Successful in 1m8s
Updated the OrderTable component with a new dark-themed UI, improved color schemes, and enhanced button and table styles. Added browser detection to provide a simplified animation experience for Firefox users, ensuring compatibility and smoother rendering. Improved loading state visuals and refined table header and cell styling for better readability.
2026-01-12 08:28:36 +00:00
g
6997838bf7 Revamp dashboard UI with improved dark theme styles
All checks were successful
Build Frontend / build (push) Successful in 1m11s
Updated category, quick actions, and product table components to use enhanced dark theme styling, including new background colors, borders, gradients, and shadow effects. Improved visual hierarchy, contrast, and hover states for better user experience and consistency across dashboard elements.
2026-01-12 08:19:59 +00:00
g
e369741b2d Enhance customer and profit analysis dialogs UI/UX
All checks were successful
Build Frontend / build (push) Successful in 1m14s
Revamps the customer details dialog with improved layout, animations, and clearer stats breakdown. Upgrades the profit analysis modal with animated cards, clearer tier breakdown, and improved cost/margin/profit explanations. Also increases recent activity fetch limit, fixes quote hydration in dashboard content, and minor animation tweak in order table.
2026-01-12 08:12:36 +00:00
g
7ddcd7afb6 Update UI styles and dashboard product display
All checks were successful
Build Frontend / build (push) Successful in 1m10s
Refined color scheme in AnimatedStatsSection to use indigo instead of pink, and improved gradient backgrounds. In dashboard/content.tsx, updated TopProduct price to support arrays and display revenue per product. UnifiedNotifications received minor style and layout adjustments for better consistency and usability.
2026-01-12 08:03:19 +00:00
g
3ffb64cf9a admin
All checks were successful
Build Frontend / build (push) Successful in 1m6s
2026-01-12 07:52:36 +00:00
g
e9737c8b24 Refactor UI to remove Christmas theme and improve actions
All checks were successful
Build Frontend / build (push) Successful in 1m11s
Removed all Christmas-specific theming and logic from the home page and navbar, standardizing colors to indigo. Updated QuickActions to open a modal for adding products instead of navigating to a new page, including logic for product creation and category fetching. Simplified ChatTable row animations and fixed minor layout issues. Updated button styles and mobile menu links for consistency.
2026-01-12 07:43:33 +00:00
g
244014f33a Improve admin UI and vendor invite experience
All checks were successful
Build Frontend / build (push) Successful in 1m7s
Enhanced the admin dashboard tab styling for better clarity. Refactored InviteVendorCard with improved UI, feedback, and clipboard copy functionality. Fixed vendor store ID update to send raw object instead of JSON string. Ensured product price formatting is robust against non-numeric values.
2026-01-12 07:33:16 +00:00
g
1186952ed8 Update page.tsx
All checks were successful
Build Frontend / build (push) Successful in 1m10s
2026-01-12 07:25:05 +00:00
39 changed files with 4081 additions and 1172 deletions

View File

@@ -1,8 +1,9 @@
import React from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Package, AlertTriangle } from "lucide-react";
import { Package, AlertTriangle, CheckCircle2, XCircle, DollarSign } from "lucide-react";
import { fetchServer } from "@/lib/api";
import OrdersTable from "@/components/admin/OrdersTable";
import { MotionWrapper } from "@/components/ui/motion-wrapper";
export const dynamic = 'force-dynamic';
@@ -26,7 +27,6 @@ interface SystemStats {
chats: number;
}
export default async function AdminOrdersPage() {
let orders: Order[] = [];
let systemStats: SystemStats | null = null;
@@ -46,14 +46,14 @@ export default async function AdminOrdersPage() {
if (error) {
return (
<div className="space-y-6">
<div className="space-y-6 p-1">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Recent Orders</h1>
<p className="text-sm text-muted-foreground mt-1">Monitor and manage platform orders</p>
<h1 className="text-3xl font-bold tracking-tight">Recent Orders</h1>
<p className="text-muted-foreground mt-2">Monitor and manage platform orders</p>
</div>
<Card>
<Card className="border-destructive/50 bg-destructive/10">
<CardContent className="pt-6">
<div className="text-center text-red-500">
<div className="text-center text-destructive">
<p>{error}</p>
</div>
</CardContent>
@@ -62,146 +62,182 @@ export default async function AdminOrdersPage() {
);
}
const acknowledgedOrders = orders.filter(o => o.status === 'acknowledged');
const paidOrders = orders.filter(o => o.status === 'paid');
const completedOrders = orders.filter(o => o.status === 'completed');
const cancelledOrders = orders.filter(o => o.status === 'cancelled');
return (
<div className="space-y-6">
<div className="space-y-8 p-1">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Recent Orders</h1>
<p className="text-sm text-muted-foreground mt-1">Monitor and manage platform orders</p>
<h1 className="text-3xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">
Recent Orders
</h1>
<p className="text-muted-foreground mt-2 text-lg">
Monitor and manage platform transaction activity
</p>
</div>
<MotionWrapper>
<div className="space-y-8">
{/* Stats Cards */}
<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">
<CardTitle className="text-sm font-medium">Total Orders</CardTitle>
<Package className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium text-muted-foreground">Total Orders</CardTitle>
<div className="p-2 rounded-lg bg-primary/10">
<Package className="h-4 w-4 text-primary" />
</div>
</CardHeader>
<CardContent>
<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>
</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">
<CardTitle className="text-sm font-medium">Acknowledged</CardTitle>
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium text-muted-foreground">Acknowledged</CardTitle>
<div className="p-2 rounded-lg bg-purple-500/10">
<AlertTriangle className="h-4 w-4 text-purple-500" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{acknowledgedOrders.length}</div>
<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>
</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">
<CardTitle className="text-sm font-medium">Paid</CardTitle>
<Package className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium text-muted-foreground">Paid</CardTitle>
<div className="p-2 rounded-lg bg-emerald-500/10">
<DollarSign className="h-4 w-4 text-emerald-500" />
</div>
</CardHeader>
<CardContent>
<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>
</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">
<CardTitle className="text-sm font-medium">Completed</CardTitle>
<Package className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium text-muted-foreground">Completed</CardTitle>
<div className="p-2 rounded-lg bg-blue-500/10">
<CheckCircle2 className="h-4 w-4 text-blue-500" />
</div>
</CardHeader>
<CardContent>
<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>
</Card>
</div>
{/* 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} />
</div>
{/* Order Analytics */}
<div className="grid gap-6 md:grid-cols-2">
<Card>
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm">
<CardHeader>
<CardTitle>Order Status Distribution</CardTitle>
<CardDescription>Breakdown of recent orders by status</CardDescription>
<CardTitle>Status Distribution</CardTitle>
<CardDescription>Breakdown of active orders</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-purple-500 rounded-full"></div>
<span className="text-sm">Acknowledged</span>
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-muted/30 transition-colors">
<div className="flex items-center space-x-3">
<div className="w-2 h-12 bg-purple-500 rounded-full"></div>
<div>
<p className="font-medium text-sm">Acknowledged</p>
<p className="text-xs text-muted-foreground">Waiting for shipment</p>
</div>
<span className="text-sm font-medium">
</div>
<span className="font-bold">
{orders.length > 0 ? Math.round((acknowledgedOrders.length / orders.length) * 100) : 0}%
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-emerald-500 rounded-full"></div>
<span className="text-sm">Paid</span>
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-muted/30 transition-colors">
<div className="flex items-center space-x-3">
<div className="w-2 h-12 bg-emerald-500 rounded-full"></div>
<div>
<p className="font-medium text-sm">Paid</p>
<p className="text-xs text-muted-foreground">Payment confirmed</p>
</div>
<span className="text-sm font-medium">
</div>
<span className="font-bold">
{orders.length > 0 ? Math.round((paidOrders.length / orders.length) * 100) : 0}%
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
<span className="text-sm">Completed</span>
<div className="flex items-center justify-between p-2 rounded-lg hover:bg-muted/30 transition-colors">
<div className="flex items-center space-x-3">
<div className="w-2 h-12 bg-blue-500 rounded-full"></div>
<div>
<p className="font-medium text-sm">Completed</p>
<p className="text-xs text-muted-foreground">Successfully concluded</p>
</div>
<span className="text-sm font-medium">
</div>
<span className="font-bold">
{orders.length > 0 ? Math.round((completedOrders.length / orders.length) * 100) : 0}%
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
<span className="text-sm">Cancelled</span>
</div>
<span className="text-sm font-medium">
{orders.length > 0 ? Math.round((cancelledOrders.length / orders.length) * 100) : 0}%
</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<Card className="bg-background/50 backdrop-blur-sm border-border/40 shadow-sm">
<CardHeader>
<CardTitle>Order Summary</CardTitle>
<CardDescription>Recent order activity breakdown</CardDescription>
<CardTitle>Activity Summary</CardTitle>
<CardDescription>Recent volume breakdown</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm">Total Recent Orders</span>
<span className="text-sm font-medium">{orders.length}</span>
<div className="space-y-6">
<div className="flex items-center justify-between pb-4 border-b border-border/40">
<span className="text-sm text-muted-foreground">Total Displayed Orders</span>
<span className="text-xl font-bold">{orders.length}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Acknowledged</span>
<span className="text-sm font-medium">{acknowledgedOrders.length}</span>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<span className="text-xs uppercase text-muted-foreground tracking-wider">Active</span>
<p className="text-lg font-semibold">{acknowledgedOrders.length + paidOrders.length}</p>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Paid</span>
<span className="text-sm font-medium">{paidOrders.length}</span>
<div className="space-y-1">
<span className="text-xs uppercase text-muted-foreground tracking-wider">Finished</span>
<p className="text-lg font-semibold">{completedOrders.length}</p>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Completed</span>
<span className="text-sm font-medium">{completedOrders.length}</span>
<div className="space-y-1">
<span className="text-xs uppercase text-muted-foreground tracking-wider">Voided</span>
<p className="text-lg font-semibold text-destructive">{cancelledOrders.length}</p>
</div>
<div className="space-y-1">
<span className="text-xs uppercase text-muted-foreground tracking-wider">Success Rate</span>
<p className="text-lg font-semibold text-green-500">
{orders.length > 0 ? Math.round((completedOrders.length / (orders.length - (acknowledgedOrders.length + paidOrders.length))) * 100) || 100 : 0}%
</p>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Cancelled</span>
<span className="text-sm font-medium">{cancelledOrders.length}</span>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</MotionWrapper>
</div>
);
}

View File

@@ -403,11 +403,12 @@ export default function AdminPage() {
</div>
<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
value="analytics"
onMouseEnter={() => handleTabHover("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
</TabsTrigger>
@@ -415,6 +416,7 @@ export default function AdminPage() {
value="management"
onMouseEnter={() => handleTabHover("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
</TabsTrigger>

View File

@@ -1,9 +1,10 @@
import React from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Server, Database, Cpu, HardDrive, Activity } from "lucide-react";
import { Server, Database, Cpu, HardDrive, Activity, Zap } from "lucide-react";
import { fetchServer } from "@/lib/api";
import SystemStatusCard from "@/components/admin/SystemStatusCard";
import { MotionWrapper } from "@/components/ui/motion-wrapper";
export const dynamic = 'force-dynamic';
@@ -35,17 +36,19 @@ export default async function AdminStatusPage() {
console.error("Failed to fetch system status:", err);
error = "Failed to load system status";
}
if (error) {
return (
<div className="space-y-6">
<div className="space-y-8 p-1">
<div>
<h1 className="text-2xl font-semibold tracking-tight">System Status</h1>
<p className="text-sm text-muted-foreground mt-1">Monitor system health and performance metrics</p>
<h1 className="text-3xl font-bold tracking-tight">System Status</h1>
<p className="text-muted-foreground mt-2">Monitor system health and real-time performance metrics</p>
</div>
<Card>
<Card className="border-destructive/50 bg-destructive/10">
<CardContent className="pt-6">
<div className="text-center text-red-500">
<p>{error}</p>
<div className="flex items-center gap-3 text-destructive">
<Activity className="h-5 w-5" />
<p className="font-medium">{error}</p>
</div>
</CardContent>
</Card>
@@ -71,112 +74,172 @@ export default async function AdminStatusPage() {
Math.round((systemStatus.memory.heapUsed / systemStatus.memory.heapTotal) * 100) : 0;
return (
<div className="space-y-6">
<div className="space-y-8 p-1">
<div>
<h1 className="text-2xl font-semibold tracking-tight">System Status</h1>
<p className="text-sm text-muted-foreground mt-1">Monitor system health and performance metrics</p>
<h1 className="text-3xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/70">
System Status
</h1>
<p className="text-muted-foreground mt-2 text-lg">
Monitor system health and real-time performance metrics
</p>
</div>
<MotionWrapper>
<div className="space-y-8">
<SystemStatusCard />
<div className="grid gap-4 lg:gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{/* Server Status */}
<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">
<CardTitle className="text-sm font-medium">Server Status</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium text-muted-foreground">Server Uptime</CardTitle>
<div className="p-2 rounded-lg bg-green-500/10">
<Server className="h-4 w-4 text-green-500" />
</div>
</CardHeader>
<CardContent>
<div className="flex items-center space-x-2">
<Badge variant="default" className="bg-green-500">Online</Badge>
<span className="text-sm text-muted-foreground">
<span className="text-2xl font-bold">
{systemStatus ? formatUptime(systemStatus.uptimeSeconds) : 'N/A'}
</span>
</div>
<p className="text-xs text-muted-foreground mt-2">
Last checked: {new Date().toLocaleTimeString()}
</p>
<div className="flex items-center mt-2 space-x-2">
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
Online
</Badge>
<span className="text-xs text-muted-foreground">
Checked: {new Date().toLocaleTimeString()}
</span>
</div>
</CardContent>
</Card>
{/* 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">
<CardTitle className="text-sm font-medium">Database</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium text-muted-foreground">Database Health</CardTitle>
<div className="p-2 rounded-lg bg-blue-500/10">
<Database className="h-4 w-4 text-blue-500" />
</div>
</CardHeader>
<CardContent>
<div className="flex items-center space-x-2">
<Badge variant="default" className="bg-green-500">Connected</Badge>
<span className="text-sm text-muted-foreground">
{systemStatus ? `${systemStatus.counts.vendors + systemStatus.counts.orders + systemStatus.counts.products} records` : 'N/A'}
<span className="text-2xl font-bold">
{systemStatus ? `${(systemStatus.counts.vendors + systemStatus.counts.orders + systemStatus.counts.products).toLocaleString()}` : '0'}
</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>
</div>
<p className="text-xs text-muted-foreground mt-2">
Total collections: 4
</p>
</CardContent>
</Card>
{/* 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">
<CardTitle className="text-sm font-medium">Memory</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium text-muted-foreground">Memory Usage</CardTitle>
<div className="p-2 rounded-lg bg-purple-500/10">
<HardDrive className="h-4 w-4 text-purple-500" />
</div>
</CardHeader>
<CardContent>
<div className="flex items-center space-x-2">
<Badge variant={memoryUsagePercent > 80 ? "destructive" : memoryUsagePercent > 60 ? "secondary" : "outline"}>
{memoryUsagePercent}%
</Badge>
<span className="text-sm text-muted-foreground">
<span className="text-2xl font-bold">
{systemStatus ? formatBytes(systemStatus.memory.heapUsed) : 'N/A'}
</span>
<span className="text-sm text-muted-foreground self-end mb-1">used</span>
</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'}
</p>
</span>
</div>
</CardContent>
</Card>
{/* 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">
<CardTitle className="text-sm font-medium">Platform Stats</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium text-muted-foreground">Platform Activity</CardTitle>
<div className="p-2 rounded-lg bg-orange-500/10">
<Activity className="h-4 w-4 text-orange-500" />
</div>
</CardHeader>
<CardContent>
<div className="flex items-center space-x-2">
<Badge variant="default" className="bg-green-500">Active</Badge>
<span className="text-sm text-muted-foreground">
{systemStatus ? `${systemStatus.counts.vendors} vendors` : 'N/A'}
<span className="text-2xl font-bold">
{systemStatus ? systemStatus.counts.vendors : '0'}
</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>
</div>
<p className="text-xs text-muted-foreground mt-2">
{systemStatus ? `${systemStatus.counts.orders} orders, ${systemStatus.counts.products} products` : 'N/A'}
</p>
</CardContent>
</Card>
{/* Node.js Version */}
<Card>
{/* Runtime Info */}
<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">
<CardTitle className="text-sm font-medium">Runtime</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium text-muted-foreground">Runtime Environment</CardTitle>
<div className="p-2 rounded-lg bg-zinc-500/10">
<Cpu className="h-4 w-4 text-zinc-500" />
</div>
</CardHeader>
<CardContent>
<div className="flex items-center space-x-2">
<Badge variant="outline">
{systemStatus ? `Node ${systemStatus.versions.node}` : 'N/A'}
</Badge>
<span className="text-sm text-muted-foreground">Runtime</span>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div>
<p className="text-2xl font-bold">Node.js</p>
<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>
<p className="text-xs text-muted-foreground mt-2">
{systemStatus ? `V8: ${systemStatus.versions.v8}` : 'N/A'}
</p>
</CardContent>
</Card>
</div>
</div>
</MotionWrapper>
</div>
);
}

View File

@@ -50,6 +50,14 @@ export default function AdminUsersPage() {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
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 [page, setPage] = useState(1);
const [pagination, setPagination] = useState<PaginationResponse['pagination'] | null>(null);
@@ -198,20 +206,14 @@ export default function AdminUsersPage() {
<AnimatePresence mode="popLayout">
{loading ? (
<TableRow>
<TableCell colSpan={8} 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 users...</p>
<TableCell colSpan={8} className="h-24 text-center">
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading users...
</div>
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="h-32 text-center text-muted-foreground">
{searchQuery ? "No users found matching your search" : "No users found"}
</TableCell>
</TableRow>
) : (
) : users.length > 0 ? (
users.map((user, index) => (
<motion.tr
key={user.telegramUserId}
@@ -219,84 +221,75 @@ export default function AdminUsersPage() {
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"
className={`group border-b border-border/50 transition-colors ${user.isBlocked ? "bg-destructive/5 hover:bg-destructive/10" : "hover:bg-muted/40"}`}
>
<TableCell>
<div className="font-mono text-xs text-muted-foreground/80">{user.telegramUserId}</div>
</TableCell>
<TableCell>
<div className="font-medium flex items-center gap-2">
{user.telegramUsername !== "Unknown" ? (
<>
<span className="text-blue-500/80">@</span>
{user.telegramUsername}
</>
) : (
<span className="text-muted-foreground italic">Unknown</span>
)}
</div>
</TableCell>
<TableCell className="font-mono text-xs">{user.telegramUserId}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-medium">{user.totalOrders}</span>
{user.completedOrders > 0 && (
<Badge variant="outline" className="text-[10px] h-5 px-1.5 bg-green-500/10 text-green-600 border-green-200 dark:border-green-900">
{user.completedOrders} done
</Badge>
<span className="font-medium">@{user.telegramUsername || "Unknown"}</span>
{user.isBlocked && (
<Badge variant="destructive" className="h-5 px-1.5 text-[10px]">Blocked</Badge>
)}
</div>
</TableCell>
<TableCell>{user.totalOrders}</TableCell>
<TableCell>{formatCurrency(user.totalSpent)}</TableCell>
<TableCell>
<span className="font-medium tabular-nums">{formatCurrency(user.totalSpent)}</span>
<div className="flex flex-col gap-1 text-xs">
<span className="text-emerald-500">{user.completedOrders} Completed</span>
<span className="text-muted-foreground">{user.paidOrders - user.completedOrders} Pending</span>
</div>
</TableCell>
<TableCell>
<TableCell 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 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="destructive" className="items-center gap-1">
<Ban className="h-3 w-3" />
Blocked
</Badge>
<Button size="sm" variant="outline" className="h-8 border-emerald-500/20 text-emerald-500 hover:bg-emerald-500/10 hover:text-emerald-400">
<UserCheck className="h-4 w-4 mr-1" />
Unblock
</Button>
</TooltipTrigger>
{user.blockedReason && (
<TooltipContent>
<p className="max-w-xs">{user.blockedReason}</p>
<p>Unblock this user</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
) : user.totalOrders > 0 ? (
<Badge variant="default" className="bg-green-600 hover:bg-green-700">Active</Badge>
) : (
<Badge variant="secondary">No Orders</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{user.firstOrderDate
? new Date(user.firstOrderDate).toLocaleDateString()
: '-'}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{user.lastOrderDate
? new Date(user.lastOrderDate).toLocaleDateString()
: '-'}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{!user.isBlocked ? (
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10">
<Ban className="h-4 w-4" />
</Button>
) : (
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-green-600 hover:bg-green-500/10">
<UserCheck className="h-4 w-4" />
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button size="sm" variant="outline" className="h-8 border-destructive/20 text-destructive hover:bg-destructive/10 hover:text-destructive">
<Ban className="h-4 w-4 mr-1" />
Block
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Block access to the store</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</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>
)}
</AnimatePresence>
</TableBody>

View File

@@ -12,6 +12,14 @@ import { Label } from "@/components/ui/label";
import { fetchClient } from "@/lib/api-client";
import { useToast } from "@/hooks/use-toast";
import { motion, AnimatePresence } from "framer-motion";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface Vendor {
_id: string;
@@ -40,6 +48,13 @@ export default function AdminVendorsPage() {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
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 [pagination, setPagination] = useState<PaginationResponse['pagination'] | null>(null);
const [searchQuery, setSearchQuery] = useState("");
@@ -48,6 +63,26 @@ export default function AdminVendorsPage() {
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 || "");
@@ -61,7 +96,7 @@ export default function AdminVendorsPage() {
setUpdating(true);
await fetchClient(`/admin/vendors/${editingVendor._id}/store-id`, {
method: 'PUT',
body: JSON.stringify({ storeId: newStoreId })
body: { storeId: newStoreId }
});
toast({
@@ -212,6 +247,133 @@ export default function AdminVendorsPage() {
</TableRow>
</TableHeader>
<TableBody>
{isFirefox ? (
loading ? (
<TableRow>
<TableCell colSpan={6} className="h-32 text-center">
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin opacity-25" />
<p>Loading vendors...</p>
</div>
</TableCell>
</TableRow>
) : filteredVendors.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
{searchQuery.trim() ? "No vendors found matching your search" : "No vendors found"}
</TableCell>
</TableRow>
) : (
filteredVendors.map((vendor, index) => (
<motion.tr
key={vendor._id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
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 mode="popLayout">
{loading ? (
<TableRow>
@@ -302,22 +464,45 @@ export default function AdminVendorsPage() {
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end space-x-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-primary">
<UserCheck className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10">
<UserX className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground">
<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>
</div>
</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>
</Table>
</div>
@@ -350,7 +535,6 @@ export default function AdminVendorsPage() {
)}
</CardContent>
</Card>
</Card>
<Dialog open={isEditStoreOpen} onOpenChange={setIsEditStoreOpen}>
<DialogContent className="sm:max-w-[425px]">

View File

@@ -263,10 +263,10 @@ export default function CategoriesPage() {
>
<div
ref={ref}
className={`group flex items-center p-2 rounded-md transition-all duration-200 border border-transparent
${isEditing ? 'bg-muted/50 border-primary/20' : ''}
${isOver ? 'bg-muted border-primary/20' : 'hover:bg-muted/50 hover:border-border/50'}
${isDragging ? 'opacity-50' : 'opacity-100'}`}
className={`group flex items-center p-3 rounded-xl transition-all duration-200 border mb-2
${isEditing ? 'bg-indigo-500/10 border-indigo-500/30' : ''}
${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-30' : 'opacity-100'} backdrop-blur-sm`}
style={{ marginLeft: `${level * 24}px` }}
data-handler-id={handlerId}
>
@@ -384,51 +384,53 @@ export default function CategoriesPage() {
<div className="grid grid-cols-1 lg:grid-cols-3 xl:grid-cols-5 gap-6 lg:gap-8">
{/* Add Category Card */}
<Card className="lg:col-span-2 border-border/40 bg-background/50 backdrop-blur-sm shadow-sm h-fit sticky top-6">
<CardHeader className="bg-muted/20 border-b border-border/40 pb-4">
<CardTitle className="text-lg font-medium flex items-center">
<Plus className="mr-2 h-4 w-4 text-primary" />
<Card className="lg:col-span-2 border-white/10 bg-black/40 backdrop-blur-xl shadow-xl h-fit sticky top-6 rounded-xl overflow-hidden">
<CardHeader className="bg-white/[0.02] border-b border-white/5 pb-4">
<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>
<CardDescription className="text-zinc-400">
Create a new category or subcategory
</CardDescription>
</CardHeader>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium leading-none">
<label className="text-sm font-medium leading-none text-zinc-300">
Category Name
</label>
<Input
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
placeholder="e.g. Electronics, Clothing..."
className="h-10 border-border/50 bg-background/50 focus:bg-background transition-colors"
className="h-10 border-white/10 bg-black/20 focus:bg-black/40 transition-colors text-white placeholder:text-zinc-600"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium leading-none">
<label className="text-sm font-medium leading-none text-zinc-300">
Parent Category
</label>
<Select
value={selectedParentId || "none"}
onValueChange={setSelectedParentId}
>
<SelectTrigger className="h-10 border-border/50 bg-background/50 focus:bg-background transition-colors">
<SelectTrigger className="h-10 border-white/10 bg-black/20 focus:bg-black/40 transition-colors text-white">
<SelectValue placeholder="Select parent category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No parent (root category)</SelectItem>
<SelectContent className="bg-zinc-900 border-white/10 text-white">
<SelectItem value="none" className="focus:bg-zinc-800">No parent (root category)</SelectItem>
{categories.map((cat) => (
<SelectItem key={cat._id} value={cat._id}>
<SelectItem key={cat._id} value={cat._id} className="focus:bg-zinc-800">
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={handleAddCategory} className="w-full mt-2" size="lg">
<Button onClick={handleAddCategory} className="w-full mt-2 bg-indigo-600 hover:bg-indigo-700 text-white border-0" size="lg">
<Plus className="h-4 w-4 mr-2" />
Add Category
</Button>
@@ -437,14 +439,14 @@ export default function CategoriesPage() {
</Card>
{/* Category List Card */}
<Card className="lg:col-span-3 border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
<CardHeader className="bg-muted/20 border-b border-border/40 pb-4">
<CardTitle className="text-lg font-medium">Structure</CardTitle>
<CardDescription>
<Card className="lg:col-span-3 border-none bg-transparent shadow-none">
<CardHeader className="pl-0 pt-0 pb-4">
<CardTitle className="text-lg font-bold text-white">Structure</CardTitle>
<CardDescription className="text-zinc-400">
Drag and drop to reorder categories
</CardDescription>
</CardHeader>
<CardContent className="pt-6">
<CardContent className="p-0">
<DndProvider backend={HTML5Backend}>
<div className="space-y-2 min-h-[300px]">
{loading ? (

View File

@@ -43,7 +43,9 @@ import {
X,
CreditCard,
Calendar,
ShoppingBag
ShoppingBag,
Truck,
CheckCircle,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
@@ -60,6 +62,13 @@ import {
export default function CustomerManagementPage() {
const router = useRouter();
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 [searchQuery, setSearchQuery] = useState("");
const [loading, setLoading] = useState(true);
@@ -336,6 +345,80 @@ export default function CustomerManagementPage() {
</TableRow>
</TableHeader>
<TableBody>
{isFirefox ? (
filteredCustomers.map((customer, index) => (
<motion.tr
key={customer.userId}
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)}
>
<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 mode="popLayout">
{filteredCustomers.map((customer, index) => (
<motion.tr
@@ -411,6 +494,7 @@ export default function CustomerManagementPage() {
</motion.tr>
))}
</AnimatePresence>
)}
</TableBody>
</Table>
</div>
@@ -469,33 +553,58 @@ export default function CustomerManagementPage() {
</Card>
{/* Customer Details Dialog */}
<AnimatePresence>
{selectedCustomer && (
<Dialog open={!!selectedCustomer} onOpenChange={(open) => !open && setSelectedCustomer(null)}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl w-full overflow-y-auto max-h-[90vh] z-[80]">
<DialogHeader>
<DialogTitle className="text-base">
Customer Details
<DialogTitle className="text-lg flex items-center gap-3">
<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>
</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 */}
<div>
<div className="mb-4">
<h3 className="text-sm font-medium mb-2">Customer Information</h3>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="space-y-4"
>
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider pl-1">Contact Info</h3>
<div className="rounded-xl border border-border p-4 space-y-4">
<div className="space-y-3">
<div className="flex justify-between items-center text-sm">
<div className="text-muted-foreground">Username:</div>
<div className="font-medium">@{selectedCustomer.telegramUsername || "Unknown"}</div>
<div className="flex justify-between items-center text-sm group">
<div className="text-muted-foreground flex items-center gap-2">
<Users className="h-4 w-4 opacity-50" />
Username
</div>
<div className="flex justify-between items-center text-sm">
<div className="text-muted-foreground">Telegram ID:</div>
<div className="font-medium">{selectedCustomer.telegramUserId}</div>
<div className="font-medium group-hover:text-primary transition-colors">@{selectedCustomer.telegramUsername || "Unknown"}</div>
</div>
<div className="flex justify-between items-center text-sm">
<div className="text-muted-foreground">Chat ID:</div>
<div className="font-medium">{selectedCustomer.chatId}</div>
<div className="flex justify-between items-center text-sm group">
<div className="text-muted-foreground flex items-center gap-2">
<CreditCard className="h-4 w-4 opacity-50" />
User ID
</div>
<div className="font-medium font-mono">{selectedCustomer.telegramUserId}</div>
</div>
<div className="flex justify-between items-center text-sm group">
<div className="text-muted-foreground flex items-center gap-2">
<MessageCircle className="h-4 w-4 opacity-50" />
Chat ID
</div>
<div className="font-medium font-mono">{selectedCustomer.chatId}</div>
</div>
</div>
@@ -511,75 +620,99 @@ export default function CustomerManagementPage() {
Open Telegram Chat
</Button>
</div>
</motion.div>
{/* Order Statistics */}
<div>
<h3 className="text-sm font-medium mb-2">Order Statistics</h3>
<div className="bg-background rounded-lg border border-border p-4 space-y-3">
<div className="flex justify-between items-center text-sm">
<div className="text-muted-foreground">Total Orders:</div>
<div className="font-medium">{selectedCustomer.totalOrders}</div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="space-y-4"
>
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider pl-1">Lifetime Stats</h3>
<div className="rounded-xl border border-border p-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="bg-emerald-500/10 rounded-lg p-3 border border-emerald-500/20 min-w-0">
<div className="text-xs text-emerald-400/70 uppercase font-medium mb-1 whitespace-nowrap">Total Spent</div>
<div className="text-xl font-bold text-emerald-400 truncate">{formatCurrency(selectedCustomer.totalSpent)}</div>
</div>
<div className="flex justify-between items-center text-sm">
<div className="text-muted-foreground">Total Spent:</div>
<div className="font-medium">{formatCurrency(selectedCustomer.totalSpent)}</div>
<div className="bg-blue-500/10 rounded-lg p-3 border border-blue-500/20 min-w-0">
<div className="text-xs text-blue-400/70 uppercase font-medium mb-1 whitespace-nowrap">Total Orders</div>
<div className="text-xl font-bold text-blue-400">{selectedCustomer.totalOrders}</div>
</div>
</div>
<div className="space-y-2 pt-2 border-t border-white/5">
<div className="flex justify-between items-center text-sm">
<div className="text-muted-foreground">First Order:</div>
<div className="font-medium">
<div className="text-muted-foreground text-xs">First Order</div>
<div className="font-medium text-white/70 text-xs bg-white/5 px-2 py-1 rounded">
{formatDate(selectedCustomer.firstOrderDate)}
</div>
</div>
<div className="flex justify-between items-center text-sm">
<div className="text-muted-foreground">Last Order:</div>
<div className="font-medium">
<div className="text-muted-foreground text-xs">Last Activity</div>
<div className="font-medium text-white/70 text-xs bg-white/5 px-2 py-1 rounded">
{formatDate(selectedCustomer.lastOrderDate)}
</div>
</div>
</div>
</div>
</motion.div>
</div>
{/* Order Status Breakdown */}
<div className="mb-4">
<h3 className="text-sm font-medium mb-2">Order Status Breakdown</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-blue-500/10 rounded-lg border border-blue-500/20 p-3">
<p className="text-sm text-muted-foreground">Paid</p>
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.paid}</p>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="space-y-4"
>
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider pl-1">Order History Breakdown</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="bg-blue-500/5 hover:bg-blue-500/10 transition-colors rounded-xl border border-blue-500/20 p-4 text-center group">
<ShoppingBag className="h-5 w-5 text-blue-500 mx-auto mb-2 opacity-70 group-hover:opacity-100 transition-opacity" />
<p className="text-2xl font-bold text-white mb-1">{selectedCustomer.ordersByStatus.paid}</p>
<p className="text-xs font-medium text-blue-400/70 uppercase">Paid</p>
</div>
<div className="bg-purple-500/10 rounded-lg border border-purple-500/20 p-3">
<p className="text-sm text-muted-foreground">Acknowledged</p>
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.acknowledged}</p>
<div className="bg-purple-500/5 hover:bg-purple-500/10 transition-colors rounded-xl border border-purple-500/20 p-4 text-center group">
<Loader2 className="h-5 w-5 text-purple-500 mx-auto mb-2 opacity-70 group-hover:opacity-100 transition-opacity" />
<p className="text-2xl font-bold text-white mb-1">{selectedCustomer.ordersByStatus.acknowledged}</p>
<p className="text-xs font-medium text-purple-400/70 uppercase">Processing</p>
</div>
<div className="bg-amber-500/10 rounded-lg border border-amber-500/20 p-3">
<p className="text-sm text-muted-foreground">Shipped</p>
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.shipped}</p>
<div className="bg-amber-500/5 hover:bg-amber-500/10 transition-colors rounded-xl border border-amber-500/20 p-4 text-center group">
<Truck className="h-5 w-5 text-amber-500 mx-auto mb-2 opacity-70 group-hover:opacity-100 transition-opacity" />
<p className="text-2xl font-bold text-white mb-1">{selectedCustomer.ordersByStatus.shipped}</p>
<p className="text-xs font-medium text-amber-400/70 uppercase">Shipped</p>
</div>
<div className="bg-green-500/10 rounded-lg border border-green-500/20 p-3">
<p className="text-sm text-muted-foreground">Completed</p>
<p className="text-xl font-semibold">{selectedCustomer.ordersByStatus.completed}</p>
<div className="bg-emerald-500/5 hover:bg-emerald-500/10 transition-colors rounded-xl border border-emerald-500/20 p-4 text-center group">
<CheckCircle className="h-5 w-5 text-emerald-500 mx-auto mb-2 opacity-70 group-hover:opacity-100 transition-opacity" />
<p className="text-2xl font-bold text-white mb-1">{selectedCustomer.ordersByStatus.completed}</p>
<p className="text-xs font-medium text-emerald-400/70 uppercase">Completed</p>
</div>
</div>
</motion.div>
</div>
<DialogFooter>
<DialogFooter className="pt-4 border-t">
<Button
variant="outline"
variant="ghost"
onClick={() => setSelectedCustomer(null)}
className=""
>
Close
Close Profile
</Button>
<Button
onClick={() => router.push(`/dashboard/storefront/chat/new?userId=${selectedCustomer.telegramUserId}`)}
className=""
>
<MessageCircle className="h-4 w-4 mr-2" />
Start Chat
Message Customer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</AnimatePresence>
</div>
</Layout>
);

View File

@@ -1,77 +1,43 @@
import { getPlatformStatsServer } from "@/lib/server-api";
import { HomeNavbar } from "@/components/home-navbar";
import { Suspense } from "react";
import { Shield, LineChart, Zap, ArrowRight, CheckCircle2, Sparkles } from "lucide-react";
import { Shield, LineChart, Zap, ArrowRight, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { AnimatedStatsSection } from "@/components/animated-stats-section";
import { isDecember } from "@/lib/utils/christmas";
import { MotionWrapper } from "@/components/ui/motion-wrapper";
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() {
try {
const stats = await getPlatformStatsServer();
const isDec = isDecember();
return (
<div className={`flex flex-col min-h-screen bg-black text-white ${isDec ? 'christmas-theme' : ''}`}>
<div className={`absolute inset-0 bg-gradient-to-br pointer-events-none scale-100 ${isDec
? 'from-red-500/10 via-green-500/5 to-transparent'
: 'from-[#D53F8C]/10 via-[#D53F8C]/3 to-transparent'
}`} />
<div className="relative flex flex-col min-h-screen bg-black text-white">
<div className="absolute inset-0 bg-gradient-to-br from-indigo-500/10 via-purple-500/5 to-transparent pointer-events-none scale-100" />
<HomeNavbar />
{/* Hero Section */}
<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="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
? 'bg-red-500/10 border-red-500/20'
: 'bg-[#D53F8C]/10 border-[#D53F8C]/20'
}`}>
<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]'}`}>
<div className="inline-flex items-center px-4 py-2 rounded-full border border-indigo-500/20 bg-indigo-500/10 mb-4">
<Sparkles className="h-4 w-4 mr-2 text-indigo-400" />
<span className="text-sm font-medium text-indigo-400">
Secure Crypto Payments
</span>
</div>
<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>
<p className="text-lg md:text-xl text-zinc-400 max-w-2xl">
{isDec
? '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.'
}
Streamline your online business with our all-in-one platform. Secure payments, order tracking, and analytics in one place.
</p>
<div className="flex flex-col sm:flex-row gap-4 mt-4">
<Link href="/dashboard">
<Button
size="lg"
className={`gap-2 text-white border-0 h-12 px-8 ${isDec
? 'bg-gradient-to-r from-red-500 to-green-500 hover:from-red-600 hover:to-green-600'
: 'bg-[#D53F8C] hover:bg-[#B83280]'
}`}
className="gap-2 text-white border-0 h-12 px-8 bg-indigo-600 hover:bg-indigo-700"
>
Get Started
<ArrowRight className="h-4 w-4" />
@@ -103,35 +69,21 @@ export default async function Home() {
title: "Lightning Fast",
description: "Optimized for speed with real-time updates and instant notifications."
}
].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 (
].map((feature, i) => (
<div
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
? `from-zinc-800/30 to-transparent ${christmasBorders[i % 3]}`
: 'from-zinc-800/30 to-transparent border-zinc-800'
}`}
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"
>
<div
className={`absolute inset-0 bg-gradient-to-b to-transparent opacity-0 group-hover:opacity-100 transition-opacity ${isDec ? christmasColors[i % 3] : 'from-[#D53F8C]/5'
}`}
/>
<div className="absolute inset-0 bg-gradient-to-b from-indigo-500/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="relative">
<div className={`h-12 w-12 flex items-center justify-center rounded-lg mb-4 ${isDec ? christmasBgs[i % 3] : 'bg-[#D53F8C]/10'
}`}>
<feature.icon className={`h-6 w-6 ${isDec ? christmasIcons[i % 3] : 'text-[#D53F8C]'}`} />
<div 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" />
</div>
<h3 className="text-lg font-semibold text-white mb-2">{feature.title}</h3>
<p className="text-sm text-zinc-400">{feature.description}</p>
</div>
</div>
);
})}
))}
</div>
</MotionWrapper>
@@ -145,13 +97,6 @@ export default async function Home() {
{/* Footer */}
<footer className="relative py-12 px-4 mt-auto">
<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">
© {new Date().getFullYear()} Ember. All rights reserved.
</div>

View File

@@ -1,47 +1,104 @@
"use client";
import { useState } from "react";
import { fetchClient } from "@/lib/api-client";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Copy, Check, Ticket, Loader2, RefreshCw } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
export default function InviteVendorCard() {
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [code, setCode] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const { toast } = useToast();
async function handleInvite() {
setLoading(true);
setMessage(null);
setCode(null);
setCopied(false);
try {
const res = await fetchClient<{ code: string }>("/admin/invitations", { method: "POST" });
setMessage("Invitation created");
setCode(res.code);
toast({
title: "Invitation Created",
description: "New vendor invitation code generated successfully.",
});
} catch (e: any) {
setMessage(e?.message || "Failed to send invitation");
toast({
title: "Error",
description: e?.message || "Failed to generate invitation",
variant: "destructive",
});
} finally {
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 (
<div className="rounded-lg border border-border/60 bg-background p-4 h-full min-h-[200px]">
<h2 className="font-medium">Invite Vendor</h2>
<p className="text-sm text-muted-foreground mt-1">Generate a new invitation code</p>
<div className="mt-4 space-y-3">
<button
onClick={handleInvite}
className="inline-flex items-center rounded-md bg-primary px-3 py-2 text-sm text-primary-foreground disabled:opacity-60"
disabled={loading}
<Card className="h-full border-border/40 bg-background/50 backdrop-blur-sm shadow-sm flex flex-col">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-medium flex items-center gap-2">
<Ticket className="h-4 w-4 text-primary" />
Invite Vendor
</CardTitle>
</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"}
</button>
{message && <p className="text-xs text-muted-foreground">{message}</p>}
{code && (
<div className="text-sm">
Code: <span className="font-mono px-1.5 py-0.5 rounded bg-muted">{code}</span>
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<p className="text-xs text-center text-muted-foreground">
Share this code with the new vendor. It expires in 7 days.
</p>
</div>
) : (
<div className="text-center py-2 text-sm text-muted-foreground/80">
Click generate to create a new code.
</div>
)}
</div>
</div>
</CardContent>
<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>
);
}

View File

@@ -20,9 +20,9 @@ export function AnimatedStatsSection({ stats }: AnimatedStatsProps) {
return (
<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="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">
<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">
<AnimatedCounter
value={stats.orders.completed}
@@ -40,9 +40,9 @@ export function AnimatedStatsSection({ stats }: AnimatedStatsProps) {
</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="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">
<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">
<AnimatedCounter
value={stats.vendors.total}
@@ -60,9 +60,9 @@ export function AnimatedStatsSection({ stats }: AnimatedStatsProps) {
</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="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">
<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">
<AnimatedCounter
value={stats.transactions.volume}

View File

@@ -304,7 +304,7 @@ export default function ChatTable() {
</TableRow>
</TableHeader>
<TableBody>
<AnimatePresence mode="popLayout">
<AnimatePresence>
{loading ? (
<TableRow>
<TableCell colSpan={4} className="h-32 text-center">
@@ -328,10 +328,10 @@ export default function ChatTable() {
chats.map((chat, index) => (
<motion.tr
key={chat._id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, delay: index * 0.05 }}
transition={{ duration: 0.2 }}
className="group cursor-pointer hover:bg-muted/30 transition-colors border-b border-border/50 last:border-0"
onClick={() => handleChatClick(chat._id)}
style={{ display: 'table-row' }} // Essential for table layout

View File

@@ -4,6 +4,14 @@ import { useState, useEffect } from "react"
import OrderStats from "./order-stats"
import QuickActions from "./quick-actions"
import RecentActivity from "./recent-activity"
import { WidgetSettings } from "./widget-settings"
import { WidgetSettingsModal } from "./widget-settings-modal"
import { DashboardEditor } from "./dashboard-editor"
import { DraggableWidget } from "./draggable-widget"
import RevenueWidget from "./revenue-widget"
import LowStockWidget from "./low-stock-widget"
import RecentCustomersWidget from "./recent-customers-widget"
import PendingChatsWidget from "./pending-chats-widget"
import { getGreeting } from "@/lib/utils/general"
import { statsConfig } from "@/config/dashboard"
import { getRandomQuote } from "@/config/quotes"
@@ -16,6 +24,7 @@ import { Skeleton } from "@/components/ui/skeleton"
import { clientFetch } from "@/lib/api"
import { motion } from "framer-motion"
import Link from "next/link"
import { useWidgetLayout, WidgetConfig } from "@/hooks/useWidgetLayout"
interface ContentProps {
username: string
@@ -25,7 +34,7 @@ interface ContentProps {
interface TopProduct {
id: string;
name: string;
price: number;
price: number | number[];
image: string;
count: number;
revenue: number;
@@ -37,8 +46,16 @@ export default function Content({ username, orderStats }: ContentProps) {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { toast } = useToast();
const { widgets, toggleWidget, moveWidget, reorderWidgets, resetLayout, isWidgetVisible, updateWidgetSettings } = useWidgetLayout();
const [configuredWidget, setConfiguredWidget] = useState<WidgetConfig | null>(null);
const [randomQuote, setRandomQuote] = useState(getRandomQuote());
// Initialize with a default quote to match server-side rendering, then randomize on client
const [randomQuote, setRandomQuote] = useState({ text: "Loading wisdom...", author: "..." });
useEffect(() => {
// Determine quote on client-side to avoid hydration mismatch
setRandomQuote(getRandomQuote());
}, []);
const fetchTopProducts = async () => {
try {
@@ -53,41 +70,21 @@ export default function Content({ username, orderStats }: ContentProps) {
}
};
useEffect(() => {
setGreeting(getGreeting());
fetchTopProducts();
}, []);
const handleRetry = () => {
fetchTopProducts();
};
const renderWidget = (widget: WidgetConfig) => {
switch (widget.id) {
case "quick-actions":
return (
<div className="space-y-10 pb-10">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col md:flex-row md:items-end md:justify-between gap-4"
>
<div>
<h1 className="text-4xl font-bold tracking-tight text-foreground">
{greeting}, <span className="text-primary">{username}</span>!
</h1>
<p className="text-muted-foreground mt-2 max-w-2xl text-lg">
"{randomQuote.text}" <span className="font-medium">{randomQuote.author}</span>
</p>
</div>
<div className="flex items-center gap-2">
</div>
</motion.div>
{/* Quick ActionsSection */}
<section className="space-y-4">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Quick Actions</h2>
<QuickActions />
</section>
{/* Order Statistics */}
);
case "overview":
return (
<section className="space-y-4">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Overview</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
@@ -98,20 +95,24 @@ export default function Content({ username, orderStats }: ContentProps) {
value={orderStats[stat.key as keyof OrderStatsData]?.toLocaleString() || "0"}
icon={stat.icon}
index={index}
filterStatus={stat.filterStatus}
/>
))}
</div>
</section>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
{/* Recent Activity Section */}
<div className="xl:col-span-1">
);
case "recent-activity":
return (
<section className="space-y-4">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Recent Activity</h2>
<RecentActivity />
</div>
{/* Best Selling Products Section */}
<div className="xl:col-span-2">
<Card className="h-full border-border/40 bg-background/50 backdrop-blur-sm">
</section>
);
case "top-products":
return (
<section className="space-y-4">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Top Performing Listings</h2>
<Card className="border-border/40 bg-background/50 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div>
<CardTitle>Top Performing Listings</CardTitle>
@@ -129,7 +130,6 @@ export default function Content({ username, orderStats }: ContentProps) {
</Button>
)}
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
@@ -181,14 +181,13 @@ export default function Content({ username, orderStats }: ContentProps) {
<div className="flex-grow min-w-0">
<h4 className="font-semibold text-lg truncate group-hover:text-primary transition-colors">{product.name}</h4>
<div className="flex items-center gap-3 mt-0.5">
<span className="text-sm text-muted-foreground font-medium">£{product.price.toFixed(2)}</span>
<span className="h-1 w-1 rounded-full bg-muted-foreground/30" />
<span className="text-xs text-muted-foreground">ID: {product.id.slice(-6)}</span>
<span className="text-sm text-muted-foreground font-medium">£{(Number(Array.isArray(product.price) ? product.price[0] : product.price) || 0).toFixed(2)}</span>
</div>
</div>
<div className="text-right">
<div className="text-xl font-bold">{product.count}</div>
<div className="text-xs text-muted-foreground font-medium uppercase tracking-tighter">Units Sold</div>
<div className="text-xs text-muted-foreground font-medium uppercase tracking-tighter mb-1">Units Sold</div>
<div className="text-sm font-semibold text-primary">£{product.revenue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
</div>
</motion.div>
))}
@@ -196,8 +195,87 @@ export default function Content({ username, orderStats }: ContentProps) {
)}
</CardContent>
</Card>
</section>
);
case "revenue-chart":
return <RevenueWidget settings={widget.settings} />;
case "low-stock":
return <LowStockWidget settings={widget.settings} />;
case "recent-customers":
return <RecentCustomersWidget settings={widget.settings} />;
case "pending-chats":
return <PendingChatsWidget settings={widget.settings} />;
default:
return null;
}
};
useEffect(() => {
setGreeting(getGreeting());
fetchTopProducts();
}, []);
return (
<div className="space-y-10 pb-10">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col md:flex-row md:items-end md:justify-between gap-4"
>
<div>
<h1 className="text-4xl font-bold tracking-tight text-foreground">
{greeting}, <span className="text-primary">{username}</span>!
</h1>
<p className="text-muted-foreground mt-2 text-lg">
"{randomQuote.text}" <span className="font-medium">{randomQuote.author}</span>
</p>
</div>
<div className="flex items-center gap-2">
<WidgetSettings
widgets={widgets}
onToggle={toggleWidget}
onMove={moveWidget}
onReset={resetLayout}
onConfigure={(widget) => setConfiguredWidget(widget)}
/>
</div>
</motion.div>
<DashboardEditor
widgets={widgets}
isEditMode={false}
onToggleEditMode={() => { }}
onReorder={reorderWidgets}
onReset={resetLayout}
>
<div className="space-y-10">
{widgets.map((widget) => {
if (!widget.visible) return null;
return (
<DraggableWidget
key={widget.id}
widget={widget}
isEditMode={false}
onConfigure={() => setConfiguredWidget(widget)}
onToggleVisibility={() => toggleWidget(widget.id)}
>
{renderWidget(widget)}
</DraggableWidget>
);
})}
</div>
</DashboardEditor>
{/* Widget Settings Modal */}
<WidgetSettingsModal
widget={configuredWidget}
open={!!configuredWidget}
onOpenChange={(open) => !open && setConfiguredWidget(null)}
onSave={(widgetId, settings) => {
updateWidgetSettings(widgetId, settings);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,128 @@
"use client"
import React, { useState } from "react"
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
DragOverlay,
DragStartEvent,
} from "@dnd-kit/core"
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable"
import { Button } from "@/components/ui/button"
import { Edit3, X, Check, RotateCcw } from "lucide-react"
import { WidgetConfig } from "@/hooks/useWidgetLayout"
import { motion, AnimatePresence } from "framer-motion"
interface DashboardEditorProps {
widgets: WidgetConfig[]
isEditMode: boolean
onToggleEditMode: () => void
onReorder: (activeId: string, overId: string) => void
onReset: () => void
children: React.ReactNode
}
export function DashboardEditor({
widgets,
isEditMode,
onToggleEditMode,
onReorder,
onReset,
children
}: DashboardEditorProps) {
const [activeId, setActiveId] = useState<string | null>(null)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string)
}
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
onReorder(active.id as string, over.id as string)
}
setActiveId(null)
}
const handleDragCancel = () => {
setActiveId(null)
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext
items={widgets.map(w => w.id)}
strategy={verticalListSortingStrategy}
>
{children}
</SortableContext>
{/* Edit Mode Banner */}
<AnimatePresence>
{isEditMode && (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 50 }}
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50"
>
<div className="flex items-center gap-3 bg-primary text-primary-foreground px-4 py-2.5 rounded-full shadow-lg border border-primary-foreground/20">
<span className="text-sm font-medium">
Editing Dashboard Drag widgets to reorder
</span>
<div className="h-4 w-px bg-primary-foreground/30" />
<Button
variant="ghost"
size="sm"
className="h-7 px-2 hover:bg-primary-foreground/20"
onClick={onReset}
>
<RotateCcw className="h-3.5 w-3.5 mr-1" />
Reset
</Button>
<Button
variant="secondary"
size="sm"
className="h-7 px-3"
onClick={onToggleEditMode}
>
<Check className="h-3.5 w-3.5 mr-1" />
Done
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</DndContext>
)
}

View File

@@ -0,0 +1,112 @@
"use client"
import React, { useState, useEffect, useRef } from "react"
import { useSortable } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { GripVertical, Settings, X, Eye, EyeOff } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils/styles"
import { WidgetConfig } from "@/hooks/useWidgetLayout"
interface DraggableWidgetProps {
widget: WidgetConfig
children: React.ReactNode
isEditMode: boolean
onRemove?: () => void
onConfigure?: () => void
onToggleVisibility?: () => void
}
export function DraggableWidget({
widget,
children,
isEditMode,
onRemove,
onConfigure,
onToggleVisibility
}: DraggableWidgetProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: widget.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 1000 : 1,
}
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"relative group",
isEditMode && "ring-2 ring-primary ring-offset-2 ring-offset-background rounded-lg",
isDragging && "z-50 shadow-2xl"
)}
>
{isEditMode && (
<>
{/* Edit Mode Overlay */}
<div className="absolute inset-0 rounded-lg border-2 border-dashed border-primary/30 pointer-events-none z-10 group-hover:border-primary/60 transition-colors" />
{/* Drag Handle */}
<div
{...attributes}
{...listeners}
className="absolute top-2 left-2 z-20 cursor-grab active:cursor-grabbing bg-background/90 backdrop-blur-sm rounded-md p-1.5 shadow-md border border-border opacity-0 group-hover:opacity-100 transition-opacity"
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
{/* Widget Title Badge */}
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20 bg-primary text-primary-foreground text-xs font-medium px-2 py-1 rounded-full shadow-md opacity-0 group-hover:opacity-100 transition-opacity">
{widget.title}
</div>
{/* Action Buttons */}
<div className="absolute top-2 right-2 z-20 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{onConfigure && (
<Button
variant="secondary"
size="icon"
className="h-7 w-7 shadow-md"
onClick={onConfigure}
>
<Settings className="h-3.5 w-3.5" />
</Button>
)}
{onToggleVisibility && (
<Button
variant="secondary"
size="icon"
className="h-7 w-7 shadow-md"
onClick={onToggleVisibility}
>
{widget.visible ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
</>
)}
{/* Widget Content */}
<div className={cn(
"h-full transition-transform",
isDragging && "scale-[1.02]"
)}>
{children}
</div>
</div>
)
}

View File

@@ -0,0 +1,167 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { AlertCircle, Package, ArrowRight, ShoppingCart } from "lucide-react"
import { clientFetch } from "@/lib/api"
import Image from "next/image"
import Link from "next/link"
interface LowStockWidgetProps {
settings?: {
threshold?: number
itemCount?: number
}
}
interface LowStockProduct {
id: string
name: string
currentStock: number
unitType: string
image?: string
}
export default function LowStockWidget({ settings }: LowStockWidgetProps) {
const threshold = settings?.threshold || 5
const itemCount = settings?.itemCount || 5
const [products, setProducts] = useState<LowStockProduct[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchLowStock = async () => {
try {
setIsLoading(true)
setError(null)
// Implementation: We'll use the product-performance API and filter locally
// or a dedicated stock-report API if available.
// For now, let's use the product-performance endpoint which has stock info.
const response = await clientFetch('/analytics/product-performance')
const lowStockProducts = response
.filter((p: any) => p.currentStock <= threshold)
.sort((a: any, b: any) => a.currentStock - b.currentStock)
.slice(0, itemCount)
.map((p: any) => ({
id: p.productId,
name: p.name,
currentStock: p.currentStock,
unitType: p.unitType,
image: p.image
}))
setProducts(lowStockProducts)
} catch (err) {
console.error("Error fetching low stock data:", err)
setError("Failed to load inventory data")
} finally {
setIsLoading(false)
}
}
useEffect(() => {
fetchLowStock()
}, [threshold, itemCount])
if (isLoading) {
return (
<Card className="border-border/40 bg-background/50 backdrop-blur-sm">
<CardHeader className="pb-2">
<Skeleton className="h-5 w-32 mb-1" />
<Skeleton className="h-4 w-48" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-12 w-12 rounded-lg" />
<div className="space-y-2 flex-1">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/4" />
</div>
</div>
))}
</CardContent>
</Card>
)
}
return (
<Card className="border-border/40 bg-background/50 backdrop-blur-sm overflow-hidden flex flex-col h-full">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-amber-500" />
Low Stock Alerts
</CardTitle>
<CardDescription>
Inventory checks (Threshold: {threshold})
</CardDescription>
</div>
<Link href="/dashboard/stock">
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs">
Manage
<ArrowRight className="h-3 w-3" />
</Button>
</Link>
</CardHeader>
<CardContent className="pt-4 flex-grow">
{error ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<Package className="h-10 w-10 text-muted-foreground/20 mb-3" />
<p className="text-sm text-muted-foreground">{error}</p>
</div>
) : products.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="h-12 w-12 rounded-full bg-green-500/10 flex items-center justify-center mb-4">
<Package className="h-6 w-6 text-green-500" />
</div>
<h3 className="font-medium">All systems go</h3>
<p className="text-sm text-muted-foreground mt-1 max-w-xs">
No products currently under your threshold of {threshold} units.
</p>
</div>
) : (
<div className="space-y-1">
{products.map((product) => (
<div
key={product.id}
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
>
<div className="h-12 w-12 relative rounded-lg border bg-muted overflow-hidden flex-shrink-0">
{product.image ? (
<Image
src={`/api/products/${product.id}/image`}
alt={product.name}
fill
className="object-cover group-hover:scale-110 transition-transform"
/>
) : (
<div className="h-full w-full flex items-center justify-center">
<ShoppingCart className="h-5 w-5 text-muted-foreground/40" />
</div>
)}
</div>
<div className="flex-grow min-w-0">
<h4 className="font-semibold text-sm truncate">{product.name}</h4>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] uppercase font-mono tracking-wider text-muted-foreground bg-muted-foreground/10 px-1.5 py-0.5 rounded">
ID: {product.id.slice(-6)}
</span>
</div>
</div>
<div className="text-right">
<div className={`text-sm font-bold ${product.currentStock === 0 ? 'text-destructive' : 'text-amber-500'}`}>
{product.currentStock} {product.unitType}
</div>
<div className="text-[10px] text-muted-foreground font-medium uppercase tracking-tighter">Remaining</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -1,22 +1,40 @@
import type { LucideIcon } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { motion } from "framer-motion"
import Link from "next/link"
interface OrderStatsProps {
title: string
value: string
icon: LucideIcon
index?: number
/** Status to filter by when clicking (e.g., "paid", "shipped") */
filterStatus?: string
/** Custom href if not using filterStatus */
href?: string
}
export default function OrderStats({ title, value, icon: Icon, index = 0 }: OrderStatsProps) {
export default function OrderStats({
title,
value,
icon: Icon,
index = 0,
filterStatus,
href
}: OrderStatsProps) {
const linkHref = href || (filterStatus ? `/dashboard/orders?status=${filterStatus}` : undefined)
const CardWrapper = linkHref ? Link : "div"
const wrapperProps = linkHref ? { href: linkHref } : {}
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.05 }}
>
<Card className="relative overflow-hidden group border-border/40 bg-background/50 backdrop-blur-sm hover:bg-background/80 transition-all duration-300">
<CardWrapper {...wrapperProps as any}>
<Card className={`relative overflow-hidden group border-border/40 bg-background/50 backdrop-blur-sm hover:bg-background/80 transition-all duration-300 ${linkHref ? "cursor-pointer hover:border-primary/30" : ""}`}>
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
<CardTitle className="text-sm font-medium tracking-tight text-muted-foreground group-hover:text-foreground transition-colors">
@@ -29,8 +47,14 @@ export default function OrderStats({ title, value, icon: Icon, index = 0 }: Orde
<CardContent className="relative z-10">
<div className="text-3xl font-bold tracking-tight">{value}</div>
<div className="mt-1 h-1 w-0 bg-primary/20 group-hover:w-full transition-all duration-500 rounded-full" />
{linkHref && (
<div className="mt-2 text-xs text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity">
Click to view
</div>
)}
</CardContent>
</Card>
</CardWrapper>
</motion.div>
)
}

View File

@@ -0,0 +1,172 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { MessageSquare, MessageCircle, ArrowRight, Clock } from "lucide-react"
import { clientFetch, getCookie } from "@/lib/api"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import Link from "next/link"
import { RelativeTime } from "@/components/ui/relative-time"
interface PendingChatsWidgetProps {
settings?: {
showPreview?: boolean
}
}
interface Chat {
id: string
buyerId: string
telegramUsername?: string
lastUpdated: string
unreadCount: number
}
export default function PendingChatsWidget({ settings }: PendingChatsWidgetProps) {
const showPreview = settings?.showPreview !== false
const [chats, setChats] = useState<Chat[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const getVendorIdFromToken = () => {
const authToken = getCookie("Authorization") || ""
if (!authToken) return null
try {
const payload = JSON.parse(atob(authToken.split(".")[1]))
return payload.id
} catch {
return null
}
}
const fetchChats = async () => {
try {
setIsLoading(true)
setError(null)
const vendorId = getVendorIdFromToken()
if (!vendorId) {
setError("Please login to view chats")
return
}
const response = await clientFetch(`/chats/vendor/${vendorId}/batch?page=1&limit=5`)
const chatCounts = response.unreadCounts?.chatCounts || {}
const pendingChats = (response.chats || [])
.filter((c: any) => chatCounts[c._id] > 0)
.map((c: any) => ({
id: c._id,
buyerId: c.buyerId,
telegramUsername: c.telegramUsername,
lastUpdated: c.lastUpdated,
unreadCount: chatCounts[c._id] || 0
}))
setChats(pendingChats)
} catch (err) {
console.error("Error fetching chats:", err)
setError("Failed to load chats")
} finally {
setIsLoading(false)
}
}
useEffect(() => {
fetchChats()
}, [])
if (isLoading) {
return (
<Card className="border-border/40 bg-background/50 backdrop-blur-sm">
<CardHeader className="pb-2">
<Skeleton className="h-5 w-32 mb-1" />
<Skeleton className="h-4 w-48" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2].map((i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-2 flex-1">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/4" />
</div>
</div>
))}
</CardContent>
</Card>
)
}
return (
<Card className="border-border/40 bg-background/50 backdrop-blur-sm overflow-hidden flex flex-col h-full">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-emerald-500" />
Pending Chats
</CardTitle>
<CardDescription>
Unanswered customer messages
</CardDescription>
</div>
<Link href="/dashboard/chats">
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs">
Inbox
<ArrowRight className="h-3 w-3" />
</Button>
</Link>
</CardHeader>
<CardContent className="pt-4 flex-grow">
{error ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<MessageCircle className="h-10 w-10 text-muted-foreground/20 mb-3" />
<p className="text-sm text-muted-foreground">{error}</p>
</div>
) : chats.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="h-12 w-12 rounded-full bg-emerald-500/10 flex items-center justify-center mb-4">
<MessageCircle className="h-6 w-6 text-emerald-500" />
</div>
<h3 className="font-medium">All caught up!</h3>
<p className="text-sm text-muted-foreground mt-1 max-w-xs">
No pending customer chats that require your attention.
</p>
</div>
) : (
<div className="space-y-1">
{chats.map((chat) => (
<Link
key={chat.id}
href={`/dashboard/chats/${chat.id}`}
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
>
<div className="relative">
<Avatar className="h-10 w-10 border shadow-sm group-hover:scale-105 transition-transform">
<AvatarFallback className="bg-emerald-500/10 text-emerald-600 text-xs font-bold">
{(chat.telegramUsername || chat.buyerId).slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="absolute -top-0.5 -right-0.5 h-3 w-3 bg-emerald-500 rounded-full ring-2 ring-background border border-background shadow-sm" />
</div>
<div className="flex-grow min-w-0">
<h4 className="font-semibold text-sm truncate group-hover:text-primary transition-colors">
{chat.telegramUsername ? `@${chat.telegramUsername}` : `Customer ${chat.buyerId.slice(-6)}`}
</h4>
<div className="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<RelativeTime date={new Date(chat.lastUpdated)} />
</div>
</div>
<div className="bg-emerald-500 text-emerald-foreground text-[10px] font-bold px-1.5 py-0.5 rounded-full shadow-sm ring-2 ring-emerald-500/10">
{chat.unreadCount}
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -1,26 +1,33 @@
"use client"
import { useState, useEffect, ChangeEvent } from "react"
import Link from "next/link"
import { motion } from "framer-motion"
import {
PlusCircle,
Package,
BarChart3,
Settings,
MessageSquare,
Truck,
Tag,
Users
BarChart3,
MessageSquare,
} from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
import dynamic from "next/dynamic"
import { Product } from "@/models/products"
import { Category } from "@/models/categories"
import { clientFetch } from "@/lib/api"
import { toast } from "sonner"
const ProductModal = dynamic(() => import("@/components/modals/product-modal").then(mod => ({ default: mod.ProductModal })), {
loading: () => null
});
const actions = [
{
title: "Add Product",
icon: PlusCircle,
href: "/dashboard/products/new",
href: "/dashboard/products/new", // Fallback text
color: "bg-blue-500/10 text-blue-500",
description: "Create a new listing"
description: "Create a new listing",
action: "modal"
},
{
title: "Process Orders",
@@ -46,30 +53,167 @@ const actions = [
]
export default function QuickActions() {
const [modalOpen, setModalOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
const [productData, setProductData] = useState<Product>({
name: "",
description: "",
unitType: "pcs",
category: "",
pricing: [{ minQuantity: 1, pricePerUnit: 0 }],
image: null,
costPerUnit: 0,
});
// Fetch categories on mount
useEffect(() => {
const fetchCategories = async () => {
try {
const data = await clientFetch('/categories');
setCategories(data);
} catch (error) {
console.error("Failed to fetch categories:", error);
}
};
fetchCategories();
}, []);
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setProductData({ ...productData, [e.target.name]: e.target.value });
};
const handleTieredPricingChange = (e: ChangeEvent<HTMLInputElement>, index: number) => {
const updatedPricing = [...productData.pricing];
const name = e.target.name as "minQuantity" | "pricePerUnit";
updatedPricing[index][name] = e.target.valueAsNumber || 0;
setProductData({ ...productData, pricing: updatedPricing });
};
const handleAddTier = () => {
setProductData((prev) => ({
...prev,
pricing: [...prev.pricing, { minQuantity: 1, pricePerUnit: 0 }],
}));
};
const handleRemoveTier = (index: number) => {
setProductData((prev) => ({
...prev,
pricing: prev.pricing.filter((_, i) => i !== index),
}));
};
const handleSaveProduct = async (data: Product, file?: File | null) => {
try {
setLoading(true);
// Prepare the product data
const payload = {
...data,
stockTracking: data.stockTracking ?? true,
currentStock: data.currentStock ?? 0,
lowStockThreshold: data.lowStockThreshold ?? 10,
stockStatus: data.stockStatus ?? 'out_of_stock'
};
const productResponse = await clientFetch("/products", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (file) {
const formData = new FormData();
formData.append("file", file);
await fetch(`${process.env.NEXT_PUBLIC_API_URL}/products/${productResponse._id}/image`, {
method: "PUT",
headers: {
Authorization: `Bearer ${document.cookie.split("; ").find((row) => row.startsWith("Authorization="))?.split("=")[1]}`,
},
body: formData,
});
}
setModalOpen(false);
setProductData({
name: "",
description: "",
unitType: "pcs",
category: "",
pricing: [{ minQuantity: 1, pricePerUnit: 0 }],
image: null,
costPerUnit: 0,
});
toast.success("Product added successfully");
// Optional: trigger a refresh of products or stats if needed
// currently just closing modal
} catch (error) {
console.error(error);
toast.error("Failed to save product");
} finally {
setLoading(false);
}
};
return (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{actions.map((action, index) => (
{actions.map((action, index) => {
const isModalAction = action.action === "modal";
const CardContentWrapper = () => (
<Card className="h-full border-none bg-black/40 backdrop-blur-xl hover:bg-black/60 transition-all duration-300 group overflow-hidden relative">
<div className="absolute inset-0 border border-white/10 rounded-xl pointer-events-none group-hover:border-white/20 transition-colors" />
<div className={`absolute inset-0 bg-gradient-to-br ${action.color.split(' ')[0].replace('/10', '/5')} opacity-0 group-hover:opacity-100 transition-opacity duration-500`} />
<CardContent className="p-6 flex flex-col items-center text-center relative z-10">
<div className={`p-4 rounded-2xl ${action.color.replace('bg-', 'bg-opacity-10 bg-')} mb-4 group-hover:scale-110 transition-transform duration-300 shadow-lg shadow-black/20`}>
<action.icon className="h-6 w-6" />
</div>
<h3 className="font-bold text-lg text-white group-hover:text-primary transition-colors">{action.title}</h3>
<p className="text-sm text-zinc-400 mt-1">{action.description}</p>
</CardContent>
</Card>
);
return (
<motion.div
key={action.title}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.02 }}
whileHover={{ y: -5 }}
whileTap={{ scale: 0.98 }}
>
<Link href={action.href}>
<Card className="hover:border-primary/50 transition-colors cursor-pointer group h-full">
<CardContent className="p-6 flex flex-col items-center text-center">
<div className={`p-3 rounded-xl ${action.color} mb-4 group-hover:scale-110 transition-transform`}>
<action.icon className="h-6 w-6" />
{isModalAction ? (
<div onClick={() => setModalOpen(true)} className="cursor-pointer h-full">
<CardContentWrapper />
</div>
<h3 className="font-semibold text-lg">{action.title}</h3>
<p className="text-sm text-muted-foreground mt-1">{action.description}</p>
</CardContent>
</Card>
) : (
<Link href={action.href} className="h-full block">
<CardContentWrapper />
</Link>
)}
</motion.div>
))}
);
})}
</div>
<ProductModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onSave={handleSaveProduct}
productData={productData}
categories={categories}
editing={false}
handleChange={handleChange}
handleTieredPricingChange={handleTieredPricingChange}
handleAddTier={handleAddTier}
handleRemoveTier={handleRemoveTier}
setProductData={setProductData}
/>
</>
)
}

View File

@@ -6,7 +6,7 @@ import { ShoppingBag, CreditCard, Truck, MessageSquare, AlertCircle } from "luci
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { clientFetch } from "@/lib/api"
import { Skeleton } from "@/components/ui/skeleton"
import { formatDistanceToNow } from "date-fns"
import { RelativeTime } from "@/components/ui/relative-time"
import Link from "next/link"
interface ActivityItem {
@@ -25,7 +25,7 @@ export default function RecentActivity() {
useEffect(() => {
async function fetchRecentOrders() {
try {
const data = await clientFetch("/orders?limit=5&sortBy=orderDate&sortOrder=desc");
const data = await clientFetch("/orders?limit=10&sortBy=orderDate&sortOrder=desc");
setActivities(data.orders || []);
} catch (error) {
console.error("Failed to fetch recent activity:", error);
@@ -100,7 +100,7 @@ export default function RecentActivity() {
Order #{item.orderId}
</Link>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(item.orderDate), { addSuffix: true })}
<RelativeTime date={item.orderDate} />
</span>
</div>
<p className="text-sm text-muted-foreground">

View File

@@ -0,0 +1,154 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Users, User, ArrowRight, DollarSign } from "lucide-react"
import { getCustomerInsightsWithStore, formatGBP } from "@/lib/api"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import Link from "next/link"
interface RecentCustomersWidgetProps {
settings?: {
itemCount?: number
showSpent?: boolean
}
}
interface Customer {
id: string
name: string
username?: string
orderCount: number
totalSpent: number
}
export default function RecentCustomersWidget({ settings }: RecentCustomersWidgetProps) {
const itemCount = settings?.itemCount || 5
const showSpent = settings?.showSpent !== false
const [customers, setCustomers] = useState<Customer[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchCustomers = async () => {
try {
setIsLoading(true)
setError(null)
// The API returns topCustomers, but we'll use 'recent' sorting to show new engagement
const response = await getCustomerInsightsWithStore(1, itemCount, "recent")
const mappedCustomers = (response.topCustomers || []).map((c: any) => ({
id: c._id,
name: c.displayName || c.username || `Customer ${c._id.slice(-4)}`,
username: c.username,
orderCount: c.orderCount || 0,
totalSpent: c.totalSpent || 0
}))
setCustomers(mappedCustomers)
} catch (err) {
console.error("Error fetching customers:", err)
setError("Failed to load customer data")
} finally {
setIsLoading(false)
}
}
useEffect(() => {
fetchCustomers()
}, [itemCount])
if (isLoading) {
return (
<Card className="border-border/40 bg-background/50 backdrop-blur-sm">
<CardHeader className="pb-2">
<Skeleton className="h-5 w-32 mb-1" />
<Skeleton className="h-4 w-48" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-2 flex-1">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/4" />
</div>
</div>
))}
</CardContent>
</Card>
)
}
return (
<Card className="border-border/40 bg-background/50 backdrop-blur-sm overflow-hidden flex flex-col h-full">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5 text-indigo-500" />
Recent Customers
</CardTitle>
<CardDescription>
Latest and newest connections
</CardDescription>
</div>
<Link href="/dashboard/customers">
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs">
View All
<ArrowRight className="h-3 w-3" />
</Button>
</Link>
</CardHeader>
<CardContent className="pt-4 flex-grow">
{error ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<User className="h-10 w-10 text-muted-foreground/20 mb-3" />
<p className="text-sm text-muted-foreground">{error}</p>
</div>
) : customers.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="h-12 w-12 rounded-full bg-indigo-500/10 flex items-center justify-center mb-4">
<Users className="h-6 w-6 text-indigo-500" />
</div>
<h3 className="font-medium">No customers yet</h3>
<p className="text-sm text-muted-foreground mt-1 max-w-xs">
This widget will populate once people start browsing and buying.
</p>
</div>
) : (
<div className="space-y-1">
{customers.map((customer) => (
<div
key={customer.id}
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
>
<Avatar className="h-10 w-10 border shadow-sm group-hover:scale-105 transition-transform">
<AvatarFallback className="bg-indigo-500/10 text-indigo-600 text-xs font-bold">
{customer.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-grow min-w-0">
<h4 className="font-semibold text-sm truncate group-hover:text-primary transition-colors">{customer.name}</h4>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">
{customer.orderCount} order{customer.orderCount !== 1 ? 's' : ''}
</span>
</div>
</div>
{showSpent && (
<div className="text-right">
<div className="text-sm font-bold text-foreground">
{formatGBP(customer.totalSpent)}
</div>
<div className="text-[10px] text-muted-foreground font-medium uppercase tracking-tighter">Total Spent</div>
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,190 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { TrendingUp, DollarSign, RefreshCcw } from "lucide-react"
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
import { getRevenueTrendsWithStore, type RevenueData, formatGBP } from "@/lib/api"
import { useToast } from "@/components/ui/use-toast"
interface RevenueWidgetProps {
settings?: {
days?: number
showComparison?: boolean
}
}
interface ChartDataPoint {
date: string
revenue: number
orders: number
formattedDate: string
}
export default function RevenueWidget({ settings }: RevenueWidgetProps) {
const days = settings?.days || 7
const [data, setData] = useState<RevenueData[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const { toast } = useToast()
const fetchRevenueData = async () => {
try {
setIsLoading(true)
setError(null)
const response = await getRevenueTrendsWithStore(days.toString())
setData(Array.isArray(response) ? response : [])
} catch (err) {
console.error("Error fetching revenue data:", err)
setError("Failed to load revenue data")
} finally {
setIsLoading(false)
}
}
useEffect(() => {
fetchRevenueData()
}, [days])
const chartData: ChartDataPoint[] = data.map(item => {
const date = new Date(Date.UTC(item._id.year, item._id.month - 1, item._id.day))
return {
date: date.toISOString().split('T')[0],
revenue: item.revenue || 0,
orders: item.orders || 0,
formattedDate: date.toLocaleDateString('en-GB', {
month: 'short',
day: 'numeric',
timeZone: 'UTC'
})
}
})
// Summary stats
const totalRevenue = data.reduce((sum, item) => sum + (item.revenue || 0), 0)
const totalOrders = data.reduce((sum, item) => sum + (item.orders || 0), 0)
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload
return (
<div className="bg-background/95 backdrop-blur-md p-3 border border-border shadow-xl rounded-xl">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">{data.formattedDate}</p>
<div className="space-y-1">
<p className="text-sm font-bold text-primary">
Revenue: {formatGBP(data.revenue)}
</p>
<p className="text-xs text-muted-foreground">
Orders: <span className="font-medium text-foreground">{data.orders}</span>
</p>
</div>
</div>
)
}
return null
}
if (isLoading) {
return (
<Card className="border-border/40 bg-background/50 backdrop-blur-sm">
<CardHeader className="pb-2">
<Skeleton className="h-5 w-32 mb-1" />
<Skeleton className="h-4 w-48" />
</CardHeader>
<CardContent>
<Skeleton className="h-[250px] w-full rounded-xl" />
</CardContent>
</Card>
)
}
return (
<Card className="border-border/40 bg-background/50 backdrop-blur-sm overflow-hidden flex flex-col h-full">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-primary" />
Revenue Insights
</CardTitle>
<CardDescription>
Performance over the last {days} days
</CardDescription>
</div>
{error && (
<Button variant="ghost" size="icon" onClick={fetchRevenueData} className="h-8 w-8">
<RefreshCcw className="h-4 w-4" />
</Button>
)}
</CardHeader>
<CardContent className="flex-grow pt-4">
{error ? (
<div className="h-[300px] flex flex-col items-center justify-center text-center p-6">
<DollarSign className="h-12 w-12 text-muted-foreground/20 mb-4" />
<p className="text-sm text-muted-foreground mb-4">Could not load revenue trends</p>
<Button variant="outline" size="sm" onClick={fetchRevenueData}>Retry</Button>
</div>
) : chartData.length === 0 ? (
<div className="h-[300px] flex flex-col items-center justify-center text-center p-6">
<DollarSign className="h-12 w-12 text-muted-foreground/20 mb-4" />
<h3 className="text-lg font-medium">No revenue data</h3>
<p className="text-sm text-muted-foreground max-w-xs mt-2">
Start making sales to see your revenue trends here.
</p>
</div>
) : (
<div className="space-y-6">
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="colorRevenueWidget" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" opacity={0.5} />
<XAxis
dataKey="formattedDate"
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
axisLine={false}
tickLine={false}
minTickGap={30}
/>
<YAxis
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
axisLine={false}
tickLine={false}
tickFormatter={(value) => `£${value >= 1000 ? (value / 1000).toFixed(1) + 'k' : value}`}
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="revenue"
stroke="hsl(var(--primary))"
fillOpacity={1}
fill="url(#colorRevenueWidget)"
strokeWidth={2.5}
activeDot={{ r: 6, strokeWidth: 0, fill: "hsl(var(--primary))" }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<div className="grid grid-cols-2 gap-4 pb-2">
<div className="p-4 rounded-2xl bg-primary/5 border border-primary/10">
<div className="text-sm text-muted-foreground font-medium mb-1">Total Revenue</div>
<div className="text-2xl font-bold text-primary">{formatGBP(totalRevenue)}</div>
</div>
<div className="p-4 rounded-2xl bg-muted/50 border border-border">
<div className="text-sm text-muted-foreground font-medium mb-1">Total Orders</div>
<div className="text-2xl font-bold">{totalOrders}</div>
</div>
</div>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,271 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { WidgetConfig } from "@/hooks/useWidgetLayout"
import { Settings2 } from "lucide-react"
import { ScrollArea } from "@/components/ui/scroll-area"
interface WidgetSettingsModalProps {
widget: WidgetConfig | null
open: boolean
onOpenChange: (open: boolean) => void
onSave: (widgetId: string, settings: Record<string, any>) => void
}
export function WidgetSettingsModal({ widget, open, onOpenChange, onSave }: WidgetSettingsModalProps) {
const [localSettings, setLocalSettings] = useState<Record<string, any>>({})
// Initialize local settings when widget changes
const handleOpenChange = (isOpen: boolean) => {
if (isOpen && widget) {
setLocalSettings({ ...widget.settings })
}
onOpenChange(isOpen)
}
const handleSave = () => {
if (widget) {
onSave(widget.id, localSettings)
onOpenChange(false)
}
}
const updateSetting = (key: string, value: any) => {
setLocalSettings(prev => ({ ...prev, [key]: value }))
}
if (!widget) return null
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="h-5 w-5" />
{widget.title} Settings
</DialogTitle>
<DialogDescription>
Customize how this widget displays on your dashboard.
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh] -mr-4 pr-4">
<div className="space-y-6 py-4">
<div className="space-y-4">
{/* Recent Activity Settings */}
{widget.id === "recent-activity" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="itemCount">Number of items</Label>
<Select
value={String(localSettings.itemCount || 10)}
onValueChange={(v) => updateSetting("itemCount", parseInt(v))}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="15">15</SelectItem>
<SelectItem value="20">20</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{/* Top Products Settings */}
{widget.id === "top-products" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="itemCount">Number of products</Label>
<Select
value={String(localSettings.itemCount || 5)}
onValueChange={(v) => updateSetting("itemCount", parseInt(v))}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">3</SelectItem>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="showRevenue">Show revenue</Label>
<Switch
id="showRevenue"
checked={localSettings.showRevenue ?? true}
onCheckedChange={(checked) => updateSetting("showRevenue", checked)}
/>
</div>
</div>
)}
{/* Revenue Chart Settings */}
{widget.id === "revenue-chart" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="days">Time period</Label>
<Select
value={String(localSettings.days || 7)}
onValueChange={(v) => updateSetting("days", parseInt(v))}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="14">Last 14 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="showComparison">Show comparison</Label>
<Switch
id="showComparison"
checked={localSettings.showComparison ?? false}
onCheckedChange={(checked) => updateSetting("showComparison", checked)}
/>
</div>
</div>
)}
{/* Low Stock Settings */}
{widget.id === "low-stock" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="threshold">Stock threshold</Label>
<Input
id="threshold"
type="number"
className="w-24"
value={localSettings.threshold || 5}
onChange={(e) => updateSetting("threshold", parseInt(e.target.value) || 5)}
min={1}
max={100}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="itemCount">Max items to show</Label>
<Select
value={String(localSettings.itemCount || 5)}
onValueChange={(v) => updateSetting("itemCount", parseInt(v))}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">3</SelectItem>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{/* Recent Customers Settings */}
{widget.id === "recent-customers" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="itemCount">Number of customers</Label>
<Select
value={String(localSettings.itemCount || 5)}
onValueChange={(v) => updateSetting("itemCount", parseInt(v))}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">3</SelectItem>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="showSpent">Show amount spent</Label>
<Switch
id="showSpent"
checked={localSettings.showSpent ?? true}
onCheckedChange={(checked) => updateSetting("showSpent", checked)}
/>
</div>
</div>
)}
{/* Pending Chats Settings */}
{widget.id === "pending-chats" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="showPreview">Show message preview</Label>
<Switch
id="showPreview"
checked={localSettings.showPreview ?? true}
onCheckedChange={(checked) => updateSetting("showPreview", checked)}
/>
</div>
</div>
)}
{/* Overview Settings */}
{widget.id === "overview" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="showChange">Show percentage change</Label>
<Switch
id="showChange"
checked={localSettings.showChange ?? false}
onCheckedChange={(checked) => updateSetting("showChange", checked)}
/>
</div>
</div>
)}
{/* Quick Actions - no settings */}
{widget.id === "quick-actions" && (
<p className="text-sm text-muted-foreground text-center py-4">
This widget has no customizable settings.
</p>
)}
</div>
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave}>
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,101 @@
"use client"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
} from "@/components/ui/dropdown-menu"
import { Settings2, ChevronUp, ChevronDown, RotateCcw, Eye, EyeOff, Cog } from "lucide-react"
import { WidgetConfig } from "@/hooks/useWidgetLayout"
interface WidgetSettingsProps {
widgets: WidgetConfig[]
onToggle: (id: string) => void
onMove: (id: string, direction: "up" | "down") => void
onReset: () => void
onConfigure?: (widget: WidgetConfig) => void
}
export function WidgetSettings({ widgets, onToggle, onMove, onReset, onConfigure }: WidgetSettingsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 gap-2">
<Settings2 className="h-4 w-4" />
<span className="hidden sm:inline">Customize</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel className="flex items-center justify-between">
Dashboard Widgets
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={onReset}
>
<RotateCcw className="h-3 w-3 mr-1" />
Reset
</Button>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{widgets.map((widget, index) => (
<div key={widget.id} className="flex items-center gap-1 px-2 py-1.5 hover:bg-muted/50 rounded-sm">
<button
onClick={() => onToggle(widget.id)}
className="flex-1 flex items-center gap-2 text-sm hover:text-foreground transition-colors"
>
{widget.visible ? (
<Eye className="h-4 w-4 text-primary" />
) : (
<EyeOff className="h-4 w-4 text-muted-foreground" />
)}
<span className={widget.visible ? "" : "text-muted-foreground line-through"}>
{widget.title}
</span>
</button>
<div className="flex items-center gap-0.5">
{widget.settings && onConfigure && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation()
onConfigure(widget)
}}
title="Configure widget"
>
<Cog className="h-3 w-3" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => onMove(widget.id, "up")}
disabled={index === 0}
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => onMove(widget.id, "down")}
disabled={index === widgets.length - 1}
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -3,7 +3,6 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { LogIn } from "lucide-react";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { useState } from "react";
export function HomeNavbar() {
@@ -27,8 +26,8 @@ export function HomeNavbar() {
Log In
</Button>
</Link>
<Link href="/auth/login">
<Button className="bg-[#D53F8C] hover:bg-[#B83280] text-white border-0">Get Started</Button>
<Link href="/dashboard">
<Button className="bg-indigo-600 hover:bg-indigo-700 text-white border-0">Get Started</Button>
</Link>
</nav>
<div className="md:hidden">
@@ -78,11 +77,11 @@ export function HomeNavbar() {
Log In
</Link>
<Link
href="/auth/register"
href="/dashboard"
className="text-sm p-2 hover:bg-gray-900 rounded-md text-gray-300"
onClick={() => setMenuOpen(false)}
>
Create Account
Get Started
</Link>
</div>
</div>

View File

@@ -5,9 +5,10 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { TrendingUp, TrendingDown, Calculator, DollarSign } from "lucide-react";
import { TrendingUp, TrendingDown, Calculator, DollarSign, Loader2, Info } from "lucide-react";
import { toast } from "sonner";
import { apiRequest } from "@/lib/api";
import { motion, AnimatePresence } from "framer-motion";
interface ProfitAnalysisModalProps {
open: boolean;
@@ -69,7 +70,11 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
const formatCurrency = (amount: number | null) => {
if (amount === null) return "N/A";
return `£${amount.toFixed(2)}`;
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP',
minimumFractionDigits: 2
}).format(amount);
};
const formatPercentage = (percentage: number | null) => {
@@ -79,7 +84,7 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
const getProfitColor = (profit: number | null) => {
if (profit === null) return "text-muted-foreground";
return profit >= 0 ? "text-green-600" : "text-red-600";
return profit >= 0 ? "text-emerald-500" : "text-rose-500";
};
const getProfitIcon = (profit: number | null) => {
@@ -87,17 +92,33 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
return profit >= 0 ? TrendingUp : TrendingDown;
};
// Variants for staggered animations
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 }
};
if (loading) {
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-4xl">
<DialogContent className="max-w-4xl bg-background/80 backdrop-blur-md border-border/50">
<DialogHeader>
<DialogTitle>Profit Analysis - {productName}</DialogTitle>
</DialogHeader>
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
<p className="text-muted-foreground">Loading profit analysis...</p>
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" />
<p className="text-muted-foreground">Calculating metrics...</p>
</div>
</div>
</DialogContent>
@@ -108,7 +129,7 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
if (!profitData) {
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-4xl">
<DialogContent className="max-w-4xl bg-background/80 backdrop-blur-md border-border/50">
<DialogHeader>
<DialogTitle>Profit Analysis - {productName}</DialogTitle>
</DialogHeader>
@@ -122,89 +143,107 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto bg-black/80 backdrop-blur-xl border-white/10 shadow-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<DollarSign className="h-5 w-5" />
Profit Analysis - {productName}
<DialogTitle className="flex items-center gap-2 text-xl">
<div className="p-2 rounded-lg bg-indigo-500/10 border border-indigo-500/20">
<DollarSign className="h-5 w-5 text-indigo-400" />
</div>
<span>Profit Analysis: <span className="text-muted-foreground font-normal">{productName}</span></span>
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
<motion.div
className="space-y-6 py-4"
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* Summary Cards */}
{profitData.summary.hasCostData ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<motion.div variants={itemVariants}>
<Card className="bg-emerald-500/5 border-emerald-500/20 backdrop-blur-sm hover:bg-emerald-500/10 transition-colors">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Average Profit</CardTitle>
<CardTitle className="text-sm font-medium text-emerald-400">Average Profit</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
<div className="text-3xl font-bold text-emerald-500">
{formatCurrency(profitData.summary.averageProfit)}
</div>
<p className="text-xs text-muted-foreground">Per unit sold</p>
<p className="text-xs text-emerald-400/60 mt-1">Per unit sold</p>
</CardContent>
</Card>
</motion.div>
<Card>
<motion.div variants={itemVariants}>
<Card className="bg-blue-500/5 border-blue-500/20 backdrop-blur-sm hover:bg-blue-500/10 transition-colors">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Average Profit Margin</CardTitle>
<CardTitle className="text-sm font-medium text-blue-400">Avg. Margin</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">
<div className="text-3xl font-bold text-blue-500">
{formatPercentage(profitData.summary.averageProfitMargin)}
</div>
<p className="text-xs text-muted-foreground">Of selling price</p>
<p className="text-xs text-blue-400/60 mt-1">Of selling price</p>
</CardContent>
</Card>
</motion.div>
<Card>
<motion.div variants={itemVariants}>
<Card className="bg-indigo-500/5 border-indigo-500/20 backdrop-blur-sm hover:bg-indigo-500/10 transition-colors">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Average Markup</CardTitle>
<CardTitle className="text-sm font-medium text-indigo-400">Avg. Markup</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-purple-600">
<div className="text-3xl font-bold text-indigo-500">
{formatPercentage(profitData.summary.averageMarkup)}
</div>
<p className="text-xs text-muted-foreground">On cost price</p>
<p className="text-xs text-indigo-400/60 mt-1">On cost price</p>
</CardContent>
</Card>
</motion.div>
</div>
) : (
<Card>
<motion.div variants={itemVariants}>
<Card className="border-dashed border-2 border-muted bg-muted/20">
<CardContent className="pt-6">
<div className="text-center">
<Calculator className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Cost Data Available</h3>
<p className="text-muted-foreground mb-4">
Add a cost per unit to this product to see profit calculations.
<div className="text-center py-6">
<Calculator className="h-12 w-12 text-muted-foreground mx-auto mb-4 opacity-50" />
<h3 className="text-lg font-medium mb-2">Missing Cost Data</h3>
<p className="text-muted-foreground mb-4 max-w-sm mx-auto">
Add a generic "Cost Per Unit" to this product to see detailed profit calculations.
</p>
<Badge variant="outline">Cost Per Unit: {formatCurrency(profitData.costPerUnit)}</Badge>
<Badge variant="outline" className="text-sm py-1 px-3">
Current Cost: {formatCurrency(profitData.costPerUnit)}
</Badge>
</div>
</CardContent>
</Card>
</motion.div>
)}
{/* Cost Information */}
<Card>
<CardHeader>
<CardTitle className="text-base">Cost Information</CardTitle>
</CardHeader>
<CardContent>
<motion.div variants={itemVariants}>
<Card className="bg-white/5 border-white/10 backdrop-blur-sm">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Cost Per Unit:</span>
<span className="text-lg font-semibold">{formatCurrency(profitData.costPerUnit)}</span>
<div className="flex items-center gap-3">
<div className="p-2 rounded-md bg-muted/50">
<Info className="h-4 w-4 text-muted-foreground" />
</div>
<span className="text-sm font-medium text-muted-foreground">Base Cost Per Unit</span>
</div>
<span className="text-xl font-bold text-white">{formatCurrency(profitData.costPerUnit)}</span>
</div>
</CardContent>
</Card>
</motion.div>
{/* Pricing Tier Analysis */}
<Card>
<CardHeader>
<CardTitle className="text-base">Pricing Tier Analysis</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<motion.div variants={itemVariants} className="space-y-3">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider pl-1">Tier Breakdown</h3>
<div className="space-y-3">
{profitData.profitMargins
.sort((a, b) => a.minQuantity - b.minQuantity)
.map((tier, index) => {
@@ -215,61 +254,83 @@ export const ProfitAnalysisModal: React.FC<ProfitAnalysisModalProps> = ({
const totalCostForMinQty = profitData.costPerUnit * tier.minQuantity;
return (
<div
<motion.div
key={index}
className="flex items-center justify-between p-4 border rounded-lg"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 + (index * 0.1) }}
className="relative overflow-hidden group rounded-xl border border-white/5 bg-white/5 hover:bg-white/10 transition-all duration-300"
>
<div className="flex items-center gap-3">
<ProfitIcon className={`h-5 w-5 ${getProfitColor(tier.profit)}`} />
<div>
<p className="font-medium">
{tier.minQuantity}+ units @ {formatCurrency(tier.pricePerUnit)}
</p>
<p className="text-sm text-muted-foreground">
Revenue for {tier.minQuantity} units: {formatCurrency(totalRevenueForMinQty)}
</p>
<p className="text-sm text-muted-foreground">
Cost for {tier.minQuantity} units: {formatCurrency(totalCostForMinQty)}
</p>
<div className={`absolute left-0 top-0 bottom-0 w-1 ${tier.profit && tier.profit >= 0 ? 'bg-emerald-500' : 'bg-rose-500'}`} />
<div className="flex flex-col sm:flex-row sm:items-center justify-between p-4 pl-6 gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="font-mono text-xs">
{tier.minQuantity}+ UNITS
</Badge>
<span className="text-muted-foreground text-sm">at</span>
<span className="font-bold text-white text-lg">{formatCurrency(tier.pricePerUnit)}</span>
</div>
<div className="flex gap-4 text-xs text-muted-foreground mt-2">
<span>Rev: <span className="text-white">{formatCurrency(totalRevenueForMinQty)}</span></span>
<span className="w-px h-4 bg-white/10" />
<span>Cost: <span className="text-white">{formatCurrency(totalCostForMinQty)}</span></span>
</div>
</div>
<div className="text-right space-y-1">
<div className={`font-medium ${getProfitColor(totalProfitForMinQty)}`}>
Total Profit: {formatCurrency(totalProfitForMinQty)}
<div className="flex items-center justify-between sm:justify-end gap-6 sm:w-auto w-full pt-2 sm:pt-0 border-t sm:border-0 border-white/5">
<div className="text-right">
<div className="text-[10px] uppercase text-muted-foreground font-medium mb-0.5">Margin</div>
<div className={`font-mono font-bold ${tier.profit && tier.profit >= 50 ? 'text-emerald-400' : 'text-blue-400'}`}>
{formatPercentage(tier.profitMargin)}
</div>
<div className="text-sm text-muted-foreground">
Per unit: {formatCurrency(tier.profit)}
</div>
<div className="text-sm text-muted-foreground">
Margin: {formatPercentage(tier.profitMargin)} |
Markup: {formatPercentage(tier.markup)}
<div className="text-right pl-4 border-l border-white/10">
<div className="text-[10px] uppercase text-muted-foreground font-medium mb-0.5">Net Profit</div>
<div className={`text-xl font-bold flex items-center justify-end gap-1 ${getProfitColor(tier.profit)}`}>
{tier.profit && tier.profit > 0 ? '+' : ''}{formatCurrency(tier.profit)}
</div>
<div className={`text-[10px] ${getProfitColor(totalProfitForMinQty)} opacity-80`}>
Total: {formatCurrency(totalProfitForMinQty)}
</div>
</div>
</div>
</div>
</motion.div>
);
})}
</div>
</CardContent>
</Card>
</motion.div>
{/* Help Text */}
<Card className="bg-muted/50">
<CardContent className="pt-6">
<div className="space-y-2 text-sm">
<h4 className="font-medium">Understanding the Metrics:</h4>
<ul className="space-y-1 text-muted-foreground">
<li><strong>Profit:</strong> Selling price minus cost price</li>
<li><strong>Profit Margin:</strong> Profit as a percentage of selling price</li>
<li><strong>Markup:</strong> Profit as a percentage of cost price</li>
</ul>
<motion.div variants={itemVariants}>
<div className="bg-indigo-500/5 rounded-lg border border-indigo-500/10 p-4">
<h4 className="flex items-center gap-2 text-sm font-medium text-indigo-300 mb-2">
<Info className="h-4 w-4" />
Quick Guide
</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs text-muted-foreground">
<div>
<span className="text-indigo-200 font-semibold block mb-0.5">Profit</span>
Selling Price - Cost Price
</div>
</CardContent>
</Card>
<div>
<span className="text-indigo-200 font-semibold block mb-0.5">Margin</span>
(Profit / Selling Price) × 100
</div>
<div>
<span className="text-indigo-200 font-semibold block mb-0.5">Markup</span>
(Profit / Cost Price) × 100
</div>
</div>
</div>
</motion.div>
</motion.div>
<div className="flex justify-end pt-4">
<Button onClick={onClose}>Close</Button>
<div className="flex justify-end pt-2">
<Button onClick={onClose} variant="secondary" className="hover:bg-white/20">Close Analysis</Button>
</div>
</DialogContent>
</Dialog>

View File

@@ -58,7 +58,7 @@ export default function UnifiedNotifications() {
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuContent align="end" className="w-80" collisionPadding={10}>
<div className="p-2 border-b">
<Tabs defaultValue="all" value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3">

View File

@@ -3,6 +3,7 @@
import { useState, useEffect, useCallback } from "react";
import React from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useSearchParams } from "next/navigation";
import {
Table,
TableBody,
@@ -129,9 +130,12 @@ const PageSizeSelector = ({ currentSize, onChange, options }: { currentSize: num
export default function OrderTable() {
const searchParams = useSearchParams();
const initialStatus = searchParams?.get("status") || "all";
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState(initialStatus);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalOrders, setTotalOrders] = useState(0);
@@ -157,6 +161,15 @@ export default function OrderTable() {
}, []);
// Fetch orders with server-side pagination
// State for browser detection
// Browser detection
const [isFirefox, setIsFirefox] = useState(false);
useEffect(() => {
const ua = navigator.userAgent.toLowerCase();
setIsFirefox(ua.includes("firefox") && !ua.includes("chrome"));
}, []);
const fetchOrders = useCallback(async () => {
try {
setLoading(true);
@@ -240,37 +253,56 @@ export default function OrderTable() {
return;
}
const orderIdsToShip = Array.from(selectedOrders);
// Store previous state for rollback
const previousOrders = [...orders];
// Optimistic update - immediately mark orders as shipped in UI
setOrders(prev =>
prev.map(order =>
selectedOrders.has(order._id)
? { ...order, status: "shipped" as const }
: order
)
);
setSelectedOrders(new Set());
// Show optimistic toast
toast.success(`Marking ${orderIdsToShip.length} order(s) as shipped...`, { id: "shipping-optimistic" });
try {
setIsShipping(true);
const response = await clientFetch("/orders/mark-shipped", {
method: "POST",
body: JSON.stringify({ orderIds: Array.from(selectedOrders) })
body: JSON.stringify({ orderIds: orderIdsToShip })
});
// Only update orders that were successfully marked as shipped
// Handle partial success/failure
if (response.success && response.success.orders) {
const successfulOrderIds = new Set(response.success.orders.map((o: any) => o.id));
setOrders(prev =>
prev.map(order =>
successfulOrderIds.has(order._id)
? { ...order, status: "shipped" }
: order
)
);
// If some orders failed, revert those specifically
if (response.failed && response.failed.count > 0) {
toast.warning(`${response.failed.count} orders could not be marked as shipped`);
setOrders(prev =>
prev.map(order => {
if (orderIdsToShip.includes(order._id) && !successfulOrderIds.has(order._id)) {
// Find original status from previousOrders
const originalOrder = previousOrders.find(o => o._id === order._id);
return originalOrder || order;
}
if (response.success.count > 0) {
toast.success(`${response.success.count} orders marked as shipped`);
return order;
})
);
toast.warning(`${response.failed.count} order(s) could not be marked as shipped`, { id: "shipping-optimistic" });
} else if (response.success.count > 0) {
toast.success(`${response.success.count} order(s) marked as shipped!`, { id: "shipping-optimistic" });
}
}
setSelectedOrders(new Set());
} catch (error) {
toast.error("Failed to update orders");
// Revert all changes on error
setOrders(previousOrders);
toast.error("Failed to update orders - changes reverted", { id: "shipping-optimistic" });
console.error("Shipping error:", error);
} finally {
setIsShipping(false);
@@ -416,7 +448,7 @@ export default function OrderTable() {
disabled={exporting}
variant="outline"
size="sm"
className="bg-background/50 border-border/50"
className="bg-background/50 border-border/50 hover:bg-muted/50 transition-colors"
>
{exporting ? (
<>
@@ -468,68 +500,72 @@ export default function OrderTable() {
{/* Table */}
<CardContent className="p-0 relative min-h-[400px]">
{loading && (
<div className="absolute inset-0 bg-background/50 backdrop-blur-[1px] flex items-center justify-center z-50">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="absolute inset-0 bg-black/60 backdrop-blur-[2px] flex items-center justify-center z-50">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-indigo-500" />
<span className="text-zinc-400 text-sm font-medium">Loading orders...</span>
</div>
</div>
)}
<div className="max-h-[calc(100vh-350px)] overflow-auto">
<Table>
<TableHeader className="bg-muted/50 sticky top-0 z-20">
<TableHeader className="bg-muted/30 sticky top-0 z-20">
<TableRow className="hover:bg-transparent border-border/50">
<TableHead className="w-12">
<Checkbox
checked={selectedOrders.size === paginatedOrders.length && paginatedOrders.length > 0}
onCheckedChange={toggleAll}
className="border-white/20 data-[state=checked]:bg-indigo-500 data-[state=checked]:border-indigo-500"
/>
</TableHead>
<TableHead className="cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("orderId")}>
Order ID <ArrowUpDown className="ml-2 inline h-3 w-3" />
<TableHead className="cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("orderId")}>
Order ID <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
</TableHead>
<TableHead className="cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("totalPrice")}>
Total <ArrowUpDown className="ml-2 inline h-3 w-3" />
<TableHead className="cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("totalPrice")}>
Total <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
</TableHead>
<TableHead className="hidden lg:table-cell">Promotion</TableHead>
<TableHead className="cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("status")}>
Status <ArrowUpDown className="ml-2 inline h-3 w-3" />
<TableHead className="hidden lg:table-cell text-zinc-400">Promotion</TableHead>
<TableHead className="cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("status")}>
Status <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
</TableHead>
<TableHead className="hidden md:table-cell cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("orderDate")}>
Date <ArrowUpDown className="ml-2 inline h-3 w-3" />
<TableHead className="hidden md:table-cell cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("orderDate")}>
Date <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
</TableHead>
<TableHead className="hidden xl:table-cell cursor-pointer hover:text-primary transition-colors" onClick={() => handleSort("paidAt")}>
Paid At <ArrowUpDown className="ml-2 inline h-3 w-3" />
<TableHead className="hidden xl:table-cell cursor-pointer hover:text-indigo-400 transition-colors text-zinc-400" onClick={() => handleSort("paidAt")}>
Paid At <ArrowUpDown className="ml-2 inline h-3 w-3 opacity-50" />
</TableHead>
<TableHead className="hidden lg:table-cell">Buyer</TableHead>
<TableHead className="w-24 text-center">Actions</TableHead>
<TableHead className="hidden lg:table-cell text-zinc-400">Buyer</TableHead>
<TableHead className="w-24 text-center text-zinc-400">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<AnimatePresence mode="popLayout">
{paginatedOrders.map((order, index) => {
{isFirefox ? (
paginatedOrders.map((order, index) => {
const StatusIcon = statusConfig[order.status as keyof typeof statusConfig]?.icon || XCircle;
const underpaidInfo = getUnderpaidInfo(order);
return (
<motion.tr
key={order._id}
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"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
className="group hover:bg-muted/50 border-b border-border/50 transition-colors"
>
<TableCell>
<Checkbox
checked={selectedOrders.has(order._id)}
onCheckedChange={() => toggleSelection(order._id)}
disabled={order.status !== "paid" && order.status !== "acknowledged"}
className="border-white/20 data-[state=checked]:bg-indigo-500 data-[state=checked]:border-indigo-500"
/>
</TableCell>
<TableCell className="font-mono text-sm font-medium">#{order.orderId}</TableCell>
<TableCell className="font-mono text-sm font-medium text-zinc-300">#{order.orderId}</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">£{order.totalPrice.toFixed(2)}</span>
<span className="font-medium text-zinc-200">£{order.totalPrice.toFixed(2)}</span>
{underpaidInfo && (
<span className="text-[10px] text-destructive flex items-center gap-1">
<span className="text-[10px] text-red-400 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
-£{underpaidInfo.missingGbp.toFixed(2)}
</span>
@@ -540,18 +576,18 @@ export default function OrderTable() {
{order.promotionCode ? (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<Tag className="h-3 w-3 text-emerald-500" />
<span className="text-[10px] font-mono bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-1.5 py-0.5 rounded border border-emerald-500/20">
<Tag className="h-3 w-3 text-emerald-400" />
<span className="text-[10px] font-mono bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5 rounded border border-emerald-500/20">
{order.promotionCode}
</span>
</div>
<div className="flex items-center gap-1 text-[10px] text-emerald-600/80">
<div className="flex items-center gap-1 text-[10px] text-emerald-400/80">
<Percent className="h-2.5 w-2.5" />
<span>-£{(order.discountAmount || 0).toFixed(2)}</span>
</div>
</div>
) : (
<span className="text-xs text-muted-foreground">-</span>
<span className="text-xs text-zinc-600">-</span>
)}
</TableCell>
<TableCell>
@@ -563,13 +599,13 @@ export default function OrderTable() {
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
</div>
{isOrderUnderpaid(order) && (
<div className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-destructive/10 text-destructive border border-destructive/20 font-medium">
<div className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-red-500/10 text-red-400 border border-red-500/20 font-medium">
{underpaidInfo?.percentage}%
</div>
)}
</div>
</TableCell>
<TableCell className="hidden md:table-cell text-sm text-muted-foreground">
<TableCell className="hidden md:table-cell text-sm text-zinc-400">
{new Date(order.orderDate).toLocaleDateString("en-GB", {
day: '2-digit',
month: 'short',
@@ -579,7 +615,7 @@ export default function OrderTable() {
{new Date(order.orderDate).toLocaleTimeString("en-GB", { hour: '2-digit', minute: '2-digit' })}
</span>
</TableCell>
<TableCell className="hidden xl:table-cell text-sm text-muted-foreground">
<TableCell className="hidden xl:table-cell text-sm text-zinc-400">
{order.paidAt ? new Date(order.paidAt).toLocaleDateString("en-GB", {
day: '2-digit',
month: 'short',
@@ -591,12 +627,12 @@ export default function OrderTable() {
{order.telegramUsername ? (
<span className="text-sm font-medium text-primary">@{order.telegramUsername}</span>
) : (
<span className="text-xs text-muted-foreground italic">Guest</span>
<span className="text-xs text-zinc-500 italic">Guest</span>
)}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-foreground" asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-zinc-400 hover:text-white hover:bg-white/10" asChild>
<Link href={`/dashboard/orders/${order._id}`}>
<Eye className="h-4 w-4" />
</Link>
@@ -606,7 +642,127 @@ export default function OrderTable() {
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-primary"
className="h-8 w-8 text-zinc-400 hover:text-indigo-400 hover:bg-indigo-500/10"
asChild
title={`Chat with customer${order.telegramUsername ? ` @${order.telegramUsername}` : ''}`}
>
<Link href={`/dashboard/chats/new?buyerId=${order.telegramBuyerId || order.telegramUsername}`}>
<MessageCircle className="h-4 w-4" />
</Link>
</Button>
)}
</div>
</TableCell>
</motion.tr>
);
})
) : (
<AnimatePresence mode="popLayout">
{paginatedOrders.map((order, index) => {
const StatusIcon = statusConfig[order.status as keyof typeof statusConfig]?.icon || XCircle;
const underpaidInfo = getUnderpaidInfo(order);
return (
<motion.tr
key={order._id}
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, delay: index * 0.03 }}
className="group hover:bg-muted/50 border-b border-border/50 transition-colors"
>
<TableCell>
<Checkbox
checked={selectedOrders.has(order._id)}
onCheckedChange={() => toggleSelection(order._id)}
disabled={order.status !== "paid" && order.status !== "acknowledged"}
className="border-white/20 data-[state=checked]:bg-indigo-500 data-[state=checked]:border-indigo-500"
/>
</TableCell>
<TableCell className="font-mono text-sm font-medium text-zinc-300">#{order.orderId}</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium text-zinc-200">£{order.totalPrice.toFixed(2)}</span>
{underpaidInfo && (
<span className="text-[10px] text-red-400 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
-£{underpaidInfo.missingGbp.toFixed(2)}
</span>
)}
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
{order.promotionCode ? (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<Tag className="h-3 w-3 text-emerald-400" />
<span className="text-[10px] font-mono bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5 rounded border border-emerald-500/20">
{order.promotionCode}
</span>
</div>
<div className="flex items-center gap-1 text-[10px] text-emerald-400/80">
<Percent className="h-2.5 w-2.5" />
<span>-£{(order.discountAmount || 0).toFixed(2)}</span>
</div>
</div>
) : (
<span className="text-xs text-zinc-600">-</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border shadow-sm ${statusConfig[order.status as OrderStatus]?.bgColor || "bg-muted text-muted-foreground border-border"} ${statusConfig[order.status as OrderStatus]?.color || ""}`}>
{React.createElement(statusConfig[order.status as OrderStatus]?.icon || XCircle, {
className: `h-3.5 w-3.5 ${statusConfig[order.status as OrderStatus]?.animate || ""}`
})}
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
</div>
{isOrderUnderpaid(order) && (
<div className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-red-500/10 text-red-400 border border-red-500/20 font-medium">
{underpaidInfo?.percentage}%
</div>
)}
</div>
</TableCell>
<TableCell className="hidden md:table-cell text-sm text-zinc-400">
{new Date(order.orderDate).toLocaleDateString("en-GB", {
day: '2-digit',
month: 'short',
year: 'numeric',
})}
<span className="ml-1 opacity-50 text-[10px]">
{new Date(order.orderDate).toLocaleTimeString("en-GB", { hour: '2-digit', minute: '2-digit' })}
</span>
</TableCell>
<TableCell className="hidden xl:table-cell text-sm text-zinc-400">
{order.paidAt ? new Date(order.paidAt).toLocaleDateString("en-GB", {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit'
}) : "-"}
</TableCell>
<TableCell className="hidden lg:table-cell">
{order.telegramUsername ? (
<span className="text-sm font-medium text-primary">@{order.telegramUsername}</span>
) : (
<span className="text-xs text-zinc-500 italic">Guest</span>
)}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-zinc-400 hover:text-white hover:bg-white/10" asChild>
<Link href={`/dashboard/orders/${order._id}`}>
<Eye className="h-4 w-4" />
</Link>
</Button>
{(order.telegramBuyerId || order.telegramUsername) && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-zinc-400 hover:text-indigo-400 hover:bg-indigo-500/10"
asChild
title={`Chat with customer${order.telegramUsername ? ` @${order.telegramUsername}` : ''}`}
>
@@ -621,14 +777,15 @@ export default function OrderTable() {
);
})}
</AnimatePresence>
)}
</TableBody>
</Table>
</div>
</CardContent>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-4 border-t border-border/50 bg-background/50">
<div className="text-sm text-muted-foreground">
<div className="flex items-center justify-between px-4 py-4 border-t border-white/5 bg-white/[0.02]">
<div className="text-sm text-zinc-500">
Page {currentPage} of {totalPages} ({totalOrders} total)
</div>
<div className="flex gap-2">
@@ -637,7 +794,7 @@ export default function OrderTable() {
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || loading}
className="h-8"
className="h-8 bg-transparent border-white/10 hover:bg-white/5 text-zinc-400 hover:text-white"
>
<ChevronLeft className="h-3 w-3 mr-1" />
Previous
@@ -647,7 +804,7 @@ export default function OrderTable() {
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || loading}
className="h-8"
className="h-8 bg-transparent border-white/10 hover:bg-white/5 text-zinc-400 hover:text-white"
>
Next
<ChevronRight className="h-3 w-3 ml-1" />

View File

@@ -6,6 +6,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import Image from "next/image";
import {
Edit,
Trash,
@@ -18,6 +19,7 @@ import {
Archive
} from "lucide-react";
import { Button } from "@/components/ui/button";
import React, { useState, useEffect } from "react";
import { Product } from "@/models/products";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
@@ -92,10 +94,13 @@ const ProductTable = ({
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded bg-muted/50 flex items-center justify-center text-muted-foreground overflow-hidden relative">
{getProductImageUrl(product) ? (
<img
<Image
src={getProductImageUrl(product)!}
alt={product.name}
width={32}
height={32}
className="h-full w-full object-cover"
unoptimized={getProductImageUrl(product)?.startsWith('data:')}
/>
) : (
<span className="text-xs font-bold">{product.name.charAt(0).toUpperCase()}</span>
@@ -191,6 +196,15 @@ const ProductTable = ({
</motion.tr>
);
const [searchQuery, setSearchQuery] = useState("");
// Browser detection
const [isFirefox, setIsFirefox] = useState(false);
useEffect(() => {
const ua = navigator.userAgent.toLowerCase();
setIsFirefox(ua.includes("firefox") && !ua.includes("chrome"));
}, []);
const renderTableHeader = () => (
<TableHeader className="bg-muted/50 sticky top-0 z-10">
<TableRow className="hover:bg-transparent border-border/50">
@@ -211,12 +225,14 @@ const ProductTable = ({
return (
<div className="space-y-8">
{/* Enabled Products Table */}
<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">
<CardTitle className="text-lg font-medium flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-primary" />
<Card className="border-white/10 bg-black/40 backdrop-blur-xl shadow-2xl overflow-hidden rounded-xl">
<CardHeader className="py-4 px-6 border-b border-white/5 bg-white/[0.02]">
<CardTitle className="text-lg font-bold flex items-center gap-3 text-white">
<div className="p-2 rounded-lg bg-indigo-500/10 border border-indigo-500/20">
<CheckCircle className="h-5 w-5 text-indigo-400" />
</div>
Active Products
<Badge variant="secondary" className="ml-2 bg-background/80 backdrop-blur-sm">
<Badge variant="secondary" className="ml-2 bg-indigo-500/10 text-indigo-300 border-indigo-500/20 hover:bg-indigo-500/20">
{sortedEnabledProducts.length}
</Badge>
</CardTitle>
@@ -226,11 +242,14 @@ const ProductTable = ({
<Table>
{renderTableHeader()}
<TableBody>
<AnimatePresence mode="popLayout">
{loading ? (
{isFirefox ? (
loading ? (
<TableRow>
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
Loading products...
<div className="flex flex-col items-center gap-2">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent" />
<span>Loading products...</span>
</div>
</TableCell>
</TableRow>
) : sortedEnabledProducts.length > 0 ? (
@@ -239,13 +258,37 @@ const ProductTable = ({
<TableRow>
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
<div className="flex flex-col items-center justify-center gap-2">
<PackageX className="h-8 w-8 opacity-50" />
<PackageX className="h-8 w-8 opacity-20" />
<p>No active products found</p>
</div>
</TableCell>
</TableRow>
)
) : (
<AnimatePresence>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
<div className="flex flex-col items-center gap-2">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent" />
<span>Loading products...</span>
</div>
</TableCell>
</TableRow>
) : sortedEnabledProducts.length > 0 ? (
sortedEnabledProducts.map((product, index) => renderProductRow(product, index))
) : (
<TableRow>
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
<div className="flex flex-col items-center justify-center gap-2">
<PackageX className="h-8 w-8 opacity-20" />
<p>No active products found</p>
</div>
</TableCell>
</TableRow>
)}
</AnimatePresence>
)}
</TableBody>
</Table>
</div>
@@ -254,12 +297,12 @@ const ProductTable = ({
{/* Disabled Products Section */}
{!loading && disabledProducts.length > 0 && (
<Card className="border-border/40 bg-background/30 backdrop-blur-sm shadow-sm overflow-hidden opacity-90">
<CardHeader className="py-4 px-6 border-b border-border/50 bg-muted/20">
<CardTitle className="text-lg font-medium flex items-center gap-2 text-muted-foreground">
<Card className="border-white/5 bg-black/20 backdrop-blur-sm shadow-none overflow-hidden opacity-80 hover:opacity-100 transition-opacity">
<CardHeader className="py-4 px-6 border-b border-white/5 bg-white/[0.01]">
<CardTitle className="text-lg font-medium flex items-center gap-2 text-zinc-400">
<Archive className="h-5 w-5" />
Archived / Disabled
<Badge variant="outline" className="ml-2">
<Badge variant="outline" className="ml-2 border-white/10 text-zinc-500">
{sortedDisabledProducts.length}
</Badge>
</CardTitle>
@@ -269,7 +312,7 @@ const ProductTable = ({
<Table>
{renderTableHeader()}
<TableBody>
<AnimatePresence mode="popLayout">
<AnimatePresence>
{sortedDisabledProducts.map((product, index) =>
renderProductRow(product, index, true),
)}

View File

@@ -1,3 +1,4 @@
import React, { useState, useEffect } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
@@ -26,6 +27,14 @@ export const ShippingTable: React.FC<ShippingTableProps> = ({
onEditShipping,
onDeleteShipping,
}) => {
// Browser detection
const [isFirefox, setIsFirefox] = useState(false);
useEffect(() => {
const ua = navigator.userAgent.toLowerCase();
setIsFirefox(ua.includes("firefox") && !ua.includes("chrome"));
}, []);
return (
<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">
@@ -45,6 +54,67 @@ export const ShippingTable: React.FC<ShippingTableProps> = ({
</TableRow>
</TableHeader>
<TableBody>
{isFirefox ? (
loading ? (
<TableRow>
<TableCell colSpan={3} className="h-24 text-center">
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<Skeleton className="h-4 w-4 rounded-full" />
Loading methods...
</div>
</TableCell>
</TableRow>
) : shippingMethods.length > 0 ? (
shippingMethods.map((method, index) => (
<motion.tr
key={method._id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.05 }}
className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
>
<TableCell className="font-medium pl-6">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded bg-primary/10 flex items-center justify-center">
<Truck className="h-4 w-4 text-primary" />
</div>
{method.name}
</div>
</TableCell>
<TableCell className="text-center font-mono">£{method.price}</TableCell>
<TableCell className="text-right pr-6">
<div className="flex justify-end gap-1">
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
onClick={() => onEditShipping(method)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
onClick={() => onDeleteShipping(method._id ?? "")}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</TableCell>
</motion.tr>
))
) : (
<TableRow>
<TableCell colSpan={3} className="h-32 text-center text-muted-foreground">
<div className="flex flex-col items-center justify-center gap-2">
<PackageX className="h-8 w-8 opacity-20" />
<p>No shipping methods found</p>
</div>
</TableCell>
</TableRow>
)
) : (
<AnimatePresence mode="popLayout">
{loading ? (
<TableRow>
@@ -100,13 +170,14 @@ export const ShippingTable: React.FC<ShippingTableProps> = ({
<TableRow>
<TableCell colSpan={3} className="h-32 text-center text-muted-foreground">
<div className="flex flex-col items-center justify-center gap-2">
<PackageX className="h-8 w-8 opacity-50" />
<PackageX className="h-8 w-8 opacity-20" />
<p>No shipping methods found</p>
</div>
</TableCell>
</TableRow>
)}
</AnimatePresence>
)}
</TableBody>
</Table>
</div>

View File

@@ -0,0 +1,138 @@
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
Package,
ShoppingBag,
Users,
Truck,
MessageCircle,
Plus,
Share2,
LucideIcon
} from "lucide-react"
import Link from "next/link"
interface EmptyStateProps {
icon?: LucideIcon
title: string
description: string
actionLabel?: string
actionHref?: string
actionOnClick?: () => void
secondaryActionLabel?: string
secondaryActionHref?: string
className?: string
}
/**
* EmptyState - Reusable component for empty tables/lists
* Shows an icon, title, description, and optional action button
*/
export function EmptyState({
icon: Icon = Package,
title,
description,
actionLabel,
actionHref,
actionOnClick,
secondaryActionLabel,
secondaryActionHref,
className = ""
}: EmptyStateProps) {
return (
<div className={`flex flex-col items-center justify-center py-12 px-4 text-center ${className}`}>
<div className="h-16 w-16 rounded-full bg-muted/50 flex items-center justify-center mb-4">
<Icon className="h-8 w-8 text-muted-foreground/50" />
</div>
<h3 className="text-lg font-medium mb-2">{title}</h3>
<p className="text-sm text-muted-foreground max-w-sm mb-6">{description}</p>
<div className="flex items-center gap-3">
{actionLabel && (
actionHref ? (
<Button asChild>
<Link href={actionHref}>
<Plus className="h-4 w-4 mr-2" />
{actionLabel}
</Link>
</Button>
) : actionOnClick ? (
<Button onClick={actionOnClick}>
<Plus className="h-4 w-4 mr-2" />
{actionLabel}
</Button>
) : null
)}
{secondaryActionLabel && secondaryActionHref && (
<Button variant="outline" asChild>
<Link href={secondaryActionHref}>
<Share2 className="h-4 w-4 mr-2" />
{secondaryActionLabel}
</Link>
</Button>
)}
</div>
</div>
)
}
// Preset empty states for common scenarios
export function OrdersEmptyState() {
return (
<EmptyState
icon={ShoppingBag}
title="No orders yet"
description="When customers place orders, they'll appear here. Share your store link to start selling!"
secondaryActionLabel="Share Store"
secondaryActionHref="/dashboard/storefront"
/>
)
}
export function ProductsEmptyState({ onAddProduct }: { onAddProduct?: () => void }) {
return (
<EmptyState
icon={Package}
title="No products yet"
description="Add your first product to start selling. You can add products manually or import from a file."
actionLabel="Add Product"
actionOnClick={onAddProduct}
actionHref={onAddProduct ? undefined : "/dashboard/products/add"}
/>
)
}
export function CustomersEmptyState() {
return (
<EmptyState
icon={Users}
title="No customers yet"
description="Once customers interact with your store, they'll appear here. Share your store link to attract customers!"
secondaryActionLabel="Share Store"
secondaryActionHref="/dashboard/storefront"
/>
)
}
export function ShippingEmptyState({ onAddMethod }: { onAddMethod?: () => void }) {
return (
<EmptyState
icon={Truck}
title="No shipping methods"
description="Add shipping methods so customers know how they'll receive their orders."
actionLabel="Add Shipping Method"
actionOnClick={onAddMethod}
/>
)
}
export function ChatsEmptyState() {
return (
<EmptyState
icon={MessageCircle}
title="No conversations yet"
description="Customer chats will appear here when they message you through Telegram."
/>
)
}

View File

@@ -0,0 +1,114 @@
"use client"
import * as React from "react"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { format, formatDistanceToNow, isToday, isYesterday, differenceInMinutes } from "date-fns"
interface RelativeTimeProps {
date: Date | string | null | undefined
className?: string
showTooltip?: boolean
updateInterval?: number // ms, for auto-updating recent times
}
/**
* RelativeTime - Displays time as "2 hours ago" with full date on hover
* Auto-updates for times less than 1 hour old
*/
export function RelativeTime({
date,
className = "",
showTooltip = true,
updateInterval = 60000 // Update every minute
}: RelativeTimeProps) {
const [, forceUpdate] = React.useReducer(x => x + 1, 0)
const parsedDate = React.useMemo(() => {
if (!date) return null
return typeof date === "string" ? new Date(date) : date
}, [date])
// Auto-update for recent times
React.useEffect(() => {
if (!parsedDate) return
const minutesAgo = differenceInMinutes(new Date(), parsedDate)
// Only auto-update if within the last hour
if (minutesAgo < 60) {
const interval = setInterval(forceUpdate, updateInterval)
return () => clearInterval(interval)
}
}, [parsedDate, updateInterval])
if (!parsedDate || isNaN(parsedDate.getTime())) {
return <span className={className}>-</span>
}
const formatRelative = (d: Date): string => {
const now = new Date()
const minutesAgo = differenceInMinutes(now, d)
// Just now (< 1 minute)
if (minutesAgo < 1) return "Just now"
// Minutes ago (< 60 minutes)
if (minutesAgo < 60) return `${minutesAgo}m ago`
// Hours ago (< 24 hours and today)
if (isToday(d)) {
const hoursAgo = Math.floor(minutesAgo / 60)
return `${hoursAgo}h ago`
}
// Yesterday
if (isYesterday(d)) return "Yesterday"
// Use formatDistanceToNow for older dates
return formatDistanceToNow(d, { addSuffix: true })
}
const fullDate = format(parsedDate, "dd MMM yyyy, HH:mm")
const relativeText = formatRelative(parsedDate)
if (!showTooltip) {
return <span className={className}>{relativeText}</span>
}
return (
<TooltipProvider>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<span className={`cursor-default ${className}`}>{relativeText}</span>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{fullDate}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
/**
* Utility function to get relative time string without component
*/
export function getRelativeTimeString(date: Date | string | null | undefined): string {
if (!date) return "-"
const d = typeof date === "string" ? new Date(date) : date
if (isNaN(d.getTime())) return "-"
const now = new Date()
const minutesAgo = differenceInMinutes(now, d)
if (minutesAgo < 1) return "Just now"
if (minutesAgo < 60) return `${minutesAgo}m ago`
if (isToday(d)) return `${Math.floor(minutesAgo / 60)}h ago`
if (isYesterday(d)) return "Yesterday"
return formatDistanceToNow(d, { addSuffix: true })
}

View File

@@ -1,9 +1,9 @@
import { Package, Clock, CheckCircle, AlertTriangle } from "lucide-react"
export const statsConfig = [
{ title: "Total Orders", key: "totalOrders", icon: Package },
{ title: "Completed Orders", key: "completedOrders", icon: CheckCircle },
{ title: "Pending Orders", key: "ongoingOrders", icon: Clock },
{ title: "Cancelled Orders", key: "cancelledOrders", icon: AlertTriangle },
{ title: "Total Orders", key: "totalOrders", icon: Package, filterStatus: "all" },
{ title: "Completed Orders", key: "completedOrders", icon: CheckCircle, filterStatus: "completed" },
{ title: "Pending Orders", key: "ongoingOrders", icon: Clock, filterStatus: "paid" },
{ title: "Cancelled Orders", key: "cancelledOrders", icon: AlertTriangle, filterStatus: "cancelled" },
]

165
hooks/useFilterState.ts Normal file
View File

@@ -0,0 +1,165 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { usePathname, useSearchParams, useRouter } from "next/navigation"
import { DateRange } from "react-day-picker"
interface FilterState {
searchQuery?: string
statusFilter?: string
dateRange?: DateRange
page?: number
itemsPerPage?: number
sortColumn?: string
sortDirection?: "asc" | "desc"
}
interface UseFilterStateOptions {
/** Unique key for this page's filter state */
storageKey: string
/** Initialize from URL params on mount */
syncWithUrl?: boolean
/** Default values */
defaults?: Partial<FilterState>
}
/**
* useFilterState - Persist filter state across navigation
* Uses sessionStorage to remember filters per page
*/
export function useFilterState({
storageKey,
syncWithUrl = false,
defaults = {}
}: UseFilterStateOptions) {
const pathname = usePathname()
const searchParams = useSearchParams()
const router = useRouter()
const fullKey = `filterState:${storageKey}`
// Initialize state from sessionStorage or URL params
const getInitialState = (): FilterState => {
if (typeof window === "undefined") return defaults
// First try URL params if syncWithUrl is enabled
if (syncWithUrl && searchParams) {
const urlState: FilterState = {}
const status = searchParams.get("status")
const search = searchParams.get("search")
const page = searchParams.get("page")
if (status) urlState.statusFilter = status
if (search) urlState.searchQuery = search
if (page) urlState.page = parseInt(page)
if (Object.keys(urlState).length > 0) {
return { ...defaults, ...urlState }
}
}
// Then try sessionStorage
try {
const stored = sessionStorage.getItem(fullKey)
if (stored) {
const parsed = JSON.parse(stored)
// Restore dateRange as Date objects
if (parsed.dateRange) {
if (parsed.dateRange.from) parsed.dateRange.from = new Date(parsed.dateRange.from)
if (parsed.dateRange.to) parsed.dateRange.to = new Date(parsed.dateRange.to)
}
return { ...defaults, ...parsed }
}
} catch (e) {
console.warn("Failed to load filter state from storage:", e)
}
return defaults
}
const [filterState, setFilterState] = useState<FilterState>(getInitialState)
// Save to sessionStorage whenever state changes
useEffect(() => {
if (typeof window === "undefined") return
try {
sessionStorage.setItem(fullKey, JSON.stringify(filterState))
} catch (e) {
console.warn("Failed to save filter state to storage:", e)
}
}, [filterState, fullKey])
// Update URL if syncWithUrl is enabled
useEffect(() => {
if (!syncWithUrl) return
const params = new URLSearchParams()
if (filterState.statusFilter && filterState.statusFilter !== "all") {
params.set("status", filterState.statusFilter)
}
if (filterState.searchQuery) {
params.set("search", filterState.searchQuery)
}
if (filterState.page && filterState.page > 1) {
params.set("page", filterState.page.toString())
}
const queryString = params.toString()
const newUrl = queryString ? `${pathname}?${queryString}` : pathname
// Only update if URL would change
const currentQuery = searchParams?.toString() || ""
if (queryString !== currentQuery) {
router.replace(newUrl, { scroll: false })
}
}, [filterState, syncWithUrl, pathname, router, searchParams])
// Convenience setters
const setSearchQuery = useCallback((query: string) => {
setFilterState(prev => ({ ...prev, searchQuery: query, page: 1 }))
}, [])
const setStatusFilter = useCallback((status: string) => {
setFilterState(prev => ({ ...prev, statusFilter: status, page: 1 }))
}, [])
const setDateRange = useCallback((range: DateRange | undefined) => {
setFilterState(prev => ({ ...prev, dateRange: range, page: 1 }))
}, [])
const setPage = useCallback((page: number) => {
setFilterState(prev => ({ ...prev, page }))
}, [])
const setItemsPerPage = useCallback((count: number) => {
setFilterState(prev => ({ ...prev, itemsPerPage: count, page: 1 }))
}, [])
const setSort = useCallback((column: string, direction: "asc" | "desc") => {
setFilterState(prev => ({ ...prev, sortColumn: column, sortDirection: direction }))
}, [])
const clearFilters = useCallback(() => {
setFilterState(defaults)
}, [defaults])
const hasActiveFilters = Boolean(
filterState.searchQuery ||
(filterState.statusFilter && filterState.statusFilter !== "all") ||
filterState.dateRange?.from
)
return {
...filterState,
setFilterState,
setSearchQuery,
setStatusFilter,
setDateRange,
setPage,
setItemsPerPage,
setSort,
clearFilters,
hasActiveFilters
}
}

188
hooks/useWidgetLayout.ts Normal file
View File

@@ -0,0 +1,188 @@
"use client"
import { useState, useEffect, useCallback } from "react"
// Per-widget settings types
export interface RecentActivitySettings {
itemCount: number // 5, 10, 15
}
export interface TopProductsSettings {
itemCount: number // 3, 5, 10
showRevenue: boolean
}
export interface OverviewSettings {
showChange: boolean // Show % change from previous period
}
export interface RevenueChartSettings {
days: number // 7, 14, 30
showComparison: boolean
}
export interface LowStockSettings {
threshold: number // Show items with stock below this
itemCount: number
}
export interface RecentCustomersSettings {
itemCount: number
showSpent: boolean
}
export interface PendingChatsSettings {
showPreview: boolean
}
export type WidgetSettings =
| { type: "quick-actions" }
| { type: "overview"; settings: OverviewSettings }
| { type: "recent-activity"; settings: RecentActivitySettings }
| { type: "top-products"; settings: TopProductsSettings }
| { type: "revenue-chart"; settings: RevenueChartSettings }
| { type: "low-stock"; settings: LowStockSettings }
| { type: "recent-customers"; settings: RecentCustomersSettings }
| { type: "pending-chats"; settings: PendingChatsSettings }
export interface WidgetConfig {
id: string
title: string
visible: boolean
order: number
settings?: Record<string, any>
}
const DEFAULT_WIDGETS: WidgetConfig[] = [
{ id: "quick-actions", title: "Quick Actions", visible: true, order: 0 },
{ id: "overview", title: "Overview", visible: true, order: 1, settings: { showChange: false } },
{ id: "recent-activity", title: "Recent Activity", visible: true, order: 2, settings: { itemCount: 10 } },
{ id: "top-products", title: "Top Products", visible: true, order: 3, settings: { itemCount: 5, showRevenue: true } },
{ id: "revenue-chart", title: "Revenue Chart", visible: false, order: 4, settings: { days: 7, showComparison: false } },
{ id: "low-stock", title: "Low Stock Alerts", visible: false, order: 5, settings: { threshold: 5, itemCount: 5 } },
{ id: "recent-customers", title: "Recent Customers", visible: false, order: 6, settings: { itemCount: 5, showSpent: true } },
{ id: "pending-chats", title: "Pending Chats", visible: false, order: 7, settings: { showPreview: true } },
]
const STORAGE_KEY = "dashboard-widget-layout-v4"
/**
* useWidgetLayout - Persist and manage dashboard widget visibility, order, and settings
*/
export function useWidgetLayout() {
const [widgets, setWidgets] = useState<WidgetConfig[]>(DEFAULT_WIDGETS)
const [isLoaded, setIsLoaded] = useState(false)
// Load from localStorage on mount
useEffect(() => {
if (typeof window === "undefined") return
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsed = JSON.parse(stored) as WidgetConfig[]
// Merge with defaults to handle new widgets added in future
const merged = DEFAULT_WIDGETS.map(defaultWidget => {
const savedWidget = parsed.find(w => w.id === defaultWidget.id)
return savedWidget
? { ...defaultWidget, ...savedWidget, settings: { ...defaultWidget.settings, ...savedWidget.settings } }
: defaultWidget
})
setWidgets(merged.sort((a, b) => a.order - b.order))
}
} catch (e) {
console.warn("Failed to load widget layout:", e)
}
setIsLoaded(true)
}, [])
// Save to localStorage whenever widgets change
useEffect(() => {
if (!isLoaded || typeof window === "undefined") return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(widgets))
} catch (e) {
console.warn("Failed to save widget layout:", e)
}
}, [widgets, isLoaded])
const toggleWidget = useCallback((widgetId: string) => {
setWidgets(prev =>
prev.map(w => w.id === widgetId ? { ...w, visible: !w.visible } : w)
)
}, [])
const moveWidget = useCallback((widgetId: string, direction: "up" | "down") => {
setWidgets(prev => {
const index = prev.findIndex(w => w.id === widgetId)
if (index === -1) return prev
const newIndex = direction === "up" ? index - 1 : index + 1
if (newIndex < 0 || newIndex >= prev.length) return prev
const newWidgets = [...prev]
const [widget] = newWidgets.splice(index, 1)
newWidgets.splice(newIndex, 0, widget)
// Update order values
return newWidgets.map((w, i) => ({ ...w, order: i }))
})
}, [])
const updateWidgetSettings = useCallback((widgetId: string, newSettings: Record<string, any>) => {
setWidgets(prev =>
prev.map(w => w.id === widgetId
? { ...w, settings: { ...w.settings, ...newSettings } }
: w
)
)
}, [])
const getWidgetSettings = useCallback(<T extends Record<string, any>>(widgetId: string): T | undefined => {
return widgets.find(w => w.id === widgetId)?.settings as T | undefined
}, [widgets])
const resetLayout = useCallback(() => {
setWidgets(DEFAULT_WIDGETS)
}, [])
const getVisibleWidgets = useCallback(() => {
return widgets.filter(w => w.visible).sort((a, b) => a.order - b.order)
}, [widgets])
const isWidgetVisible = useCallback((widgetId: string) => {
return widgets.find(w => w.id === widgetId)?.visible ?? true
}, [widgets])
// Reorder widgets by moving activeId to overId's position
const reorderWidgets = useCallback((activeId: string, overId: string) => {
setWidgets(prev => {
const oldIndex = prev.findIndex(w => w.id === activeId)
const newIndex = prev.findIndex(w => w.id === overId)
if (oldIndex === -1 || newIndex === -1) return prev
const newWidgets = [...prev]
const [removed] = newWidgets.splice(oldIndex, 1)
newWidgets.splice(newIndex, 0, removed)
// Update order values
return newWidgets.map((w, i) => ({ ...w, order: i }))
})
}, [])
return {
widgets,
toggleWidget,
moveWidget,
reorderWidgets,
updateWidgetSettings,
getWidgetSettings,
resetLayout,
getVisibleWidgets,
isWidgetVisible,
isLoaded
}
}

View File

@@ -61,6 +61,7 @@ export {
getCustomerInsightsWithStore,
getOrderAnalyticsWithStore,
getStoreIdForUser,
formatGBP,
// Types
type AnalyticsOverview,

View File

@@ -190,10 +190,12 @@ export const getCustomerInsights = async (
storeId?: string,
page: number = 1,
limit: number = 10,
sortBy: string = "spent",
): Promise<CustomerInsights> => {
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
sort: sortBy,
});
if (storeId) params.append("storeId", storeId);
@@ -272,9 +274,10 @@ export const getProductPerformanceWithStore = async (): Promise<
export const getCustomerInsightsWithStore = async (
page: number = 1,
limit: number = 10,
sortBy: string = "spent",
): Promise<CustomerInsights> => {
const storeId = getStoreIdForUser();
return getCustomerInsights(storeId, page, limit);
return getCustomerInsights(storeId, page, limit, sortBy);
};
export const getOrderAnalyticsWithStore = async (

296
package-lock.json generated
View File

@@ -1,13 +1,16 @@
{
"name": "my-v0-project",
"version": "2.2.0",
"version": "2.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "my-v0-project",
"version": "2.2.0",
"version": "2.2.1",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4",
@@ -34,6 +37,7 @@
"@radix-ui/react-toggle": "^1.1.1",
"@radix-ui/react-toggle-group": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
"@tanstack/react-virtual": "^3.13.18",
"autoprefixer": "^10.4.20",
"axios": "^1.8.1",
"class-variance-authority": "^0.7.1",
@@ -42,12 +46,15 @@
"date-fns": "4.1.0",
"embla-carousel-react": "8.5.1",
"form-data": "^4.0.2",
"framer-motion": "^12.25.0",
"input-otp": "1.4.1",
"jwt-decode": "^4.0.0",
"lodash": "^4.17.21",
"lucide-react": "^0.454.0",
"next": "^16.1.1",
"next-themes": "latest",
"react": "^19.0.0",
"react-countup": "^6.5.3",
"react-day-picker": "8.10.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
@@ -55,6 +62,8 @@
"react-hook-form": "^7.54.1",
"react-markdown": "^10.0.0",
"react-resizable-panels": "^2.1.7",
"react-window": "^2.2.4",
"react-window-infinite-loader": "^2.0.0",
"recharts": "^2.15.0",
"sonner": "^1.7.4",
"tailwind-merge": "^2.5.5",
@@ -63,7 +72,6 @@
"zod": "^3.25.0"
},
"devDependencies": {
"@distube/ytdl-core": "^4.16.12",
"@next/bundle-analyzer": "^16.1.1",
"@tailwindcss/typography": "^0.5.16",
"@types/lodash": "^4.17.16",
@@ -383,26 +391,53 @@
"node": ">=10.0.0"
}
},
"node_modules/@distube/ytdl-core": {
"version": "4.16.12",
"resolved": "https://registry.npmjs.org/@distube/ytdl-core/-/ytdl-core-4.16.12.tgz",
"integrity": "sha512-/NR8Jur1Q4E2oD+DJta7uwWu7SkqdEkhwERt7f4iune70zg7ZlLLTOHs1+jgg3uD2jQjpdk7RGC16FqstG4RsA==",
"dev": true,
"license": "MIT",
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"dependencies": {
"http-cookie-agent": "^7.0.1",
"https-proxy-agent": "^7.0.6",
"m3u8stream": "^0.8.6",
"miniget": "^4.2.3",
"sax": "^1.4.1",
"tough-cookie": "^5.1.2",
"undici": "^7.8.0"
"tslib": "^2.0.0"
},
"engines": {
"node": ">=20.18.1"
"peerDependencies": {
"react": ">=16.8.0"
}
},
"funding": {
"url": "https://github.com/distubejs/ytdl-core?sponsor"
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
@@ -2831,6 +2866,31 @@
"node": ">=4"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.18",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz",
"integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==",
"dependencies": {
"@tanstack/virtual-core": "3.13.18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.18",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
"integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
@@ -3473,16 +3533,6 @@
"node": ">=0.4.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -4248,6 +4298,11 @@
"dev": true,
"license": "MIT"
},
"node_modules/countup.js": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/countup.js/-/countup.js-2.9.0.tgz",
"integrity": "sha512-llqrvyXztRFPp6+i8jx25phHWcVWhrHO4Nlt0uAOSKHB8778zzQswa4MU3qKBvkXfJKftRYFJuVHez67lyKdHg=="
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
@@ -5646,6 +5701,32 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.26.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.26.0.tgz",
"integrity": "sha512-yFatQro5/mNKVqBT/IAMq9v27z4dJsjKklnsCu7mdp2mrn78UW3mkG4qfmmLxHzh6WMts1o+A4FH4Iiomt/jFQ==",
"dependencies": {
"motion-dom": "^12.24.11",
"motion-utils": "^12.24.10",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -6082,45 +6163,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/http-cookie-agent": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/http-cookie-agent/-/http-cookie-agent-7.0.3.tgz",
"integrity": "sha512-EeZo7CGhfqPW6R006rJa4QtZZUpBygDa2HZH3DJqsTzTjyRE6foDBVQIv/pjVsxHC8z2GIdbB1Hvn9SRorP3WQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.4"
},
"engines": {
"node": ">=20.0.0"
},
"funding": {
"url": "https://github.com/sponsors/3846masa"
},
"peerDependencies": {
"tough-cookie": "^4.0.0 || ^5.0.0 || ^6.0.0",
"undici": "^7.0.0"
},
"peerDependenciesMeta": {
"undici": {
"optional": true
}
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -6985,20 +7027,6 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/m3u8stream": {
"version": "0.8.6",
"resolved": "https://registry.npmjs.org/m3u8stream/-/m3u8stream-0.8.6.tgz",
"integrity": "sha512-LZj8kIVf9KCphiHmH7sbFQTVe4tOemb202fWwvJwR9W5ENW/1hxJN6ksAWGhQgSBSa3jyWhnjKU1Fw1GaOdbyA==",
"dev": true,
"license": "MIT",
"dependencies": {
"miniget": "^4.2.2",
"sax": "^1.2.4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -7648,16 +7676,6 @@
"node": ">= 0.6"
}
},
"node_modules/miniget": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/miniget/-/miniget-4.2.3.tgz",
"integrity": "sha512-SjbDPDICJ1zT+ZvQwK0hUcRY4wxlhhNpHL9nJOB2MEAXRGagTljsO8MEDzQMTFf0Q8g4QNi8P9lEm/g7e+qgzA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -7691,6 +7709,19 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/motion-dom": {
"version": "12.24.11",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.24.11.tgz",
"integrity": "sha512-DlWOmsXMJrV8lzZyd+LKjG2CXULUs++bkq8GZ2Sr0R0RRhs30K2wtY+LKiTjhmJU3W61HK+rB0GLz6XmPvTA1A==",
"dependencies": {
"motion-utils": "^12.24.10"
}
},
"node_modules/motion-utils": {
"version": "12.24.10",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.24.10.tgz",
"integrity": "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww=="
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -8427,6 +8458,17 @@
"node": ">=0.10.0"
}
},
"node_modules/react-countup": {
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/react-countup/-/react-countup-6.5.3.tgz",
"integrity": "sha512-udnqVQitxC7QWADSPDOxVWULkLvKUWrDapn5i53HE4DPRVgs+Y5rr4bo25qEl8jSh+0l2cToJgGMx+clxPM3+w==",
"dependencies": {
"countup.js": "^2.8.0"
},
"peerDependencies": {
"react": ">= 16.3.0"
}
},
"node_modules/react-day-picker": {
"version": "8.10.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
@@ -8651,6 +8693,24 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/react-window": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.5.tgz",
"integrity": "sha512-6viWvPSZvVuMIe9hrl4IIZoVfO/npiqOb03m4Z9w+VihmVzBbiudUrtUqDpsWdKvd/Ai31TCR25CBcFFAUm28w==",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/react-window-infinite-loader": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-window-infinite-loader/-/react-window-infinite-loader-2.0.0.tgz",
"integrity": "sha512-dioOyvShGheEqqFHcPNKCixCOc2evwb2VEt9sitfJfTZ1hir8m6b8W0CNBvcUj+8Y8IeWu4yb88DI7k88aYTQQ==",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -8958,13 +9018,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sax": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/scheduler": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
@@ -9699,26 +9752,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -9742,19 +9775,6 @@
"node": ">=6"
}
},
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -9975,16 +9995,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/undici": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz",
"integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",

View File

@@ -17,6 +17,9 @@
"analyze": "ANALYZE=true next build"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4",

View File

@@ -1,4 +1,4 @@
{
"commitHash": "7b95589",
"buildTime": "2026-01-12T06:32:31.897Z"
"commitHash": "a6b7286",
"buildTime": "2026-01-12T10:20:09.966Z"
}