From 318927cd0c88e0d5be96661bc10fcf73183c53c6 Mon Sep 17 00:00:00 2001 From: g Date: Mon, 12 Jan 2026 10:39:50 +0000 Subject: [PATCH] Add modular dashboard widgets and layout editor 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. --- components/dashboard/content.tsx | 319 +++++++++++------- components/dashboard/dashboard-editor.tsx | 157 +++++++++ components/dashboard/draggable-widget.tsx | 121 +++++++ components/dashboard/low-stock-widget.tsx | 167 +++++++++ components/dashboard/order-stats.tsx | 56 ++- components/dashboard/pending-chats-widget.tsx | 172 ++++++++++ components/dashboard/recent-activity.tsx | 4 +- .../dashboard/recent-customers-widget.tsx | 154 +++++++++ components/dashboard/revenue-widget.tsx | 190 +++++++++++ .../dashboard/widget-settings-modal.tsx | 291 ++++++++++++++++ components/dashboard/widget-settings.tsx | 101 ++++++ components/tables/order-table.tsx | 61 ++-- components/tables/product-table.tsx | 6 +- components/ui/empty-state.tsx | 138 ++++++++ components/ui/relative-time.tsx | 114 +++++++ config/dashboard.ts | 8 +- hooks/useFilterState.ts | 165 +++++++++ hooks/useWidgetLayout.ts | 195 +++++++++++ lib/api.ts | 23 +- lib/services/analytics-service.ts | 5 +- package-lock.json | 296 ++++++++-------- package.json | 5 +- public/git-info.json | 4 +- 23 files changed, 2435 insertions(+), 317 deletions(-) create mode 100644 components/dashboard/dashboard-editor.tsx create mode 100644 components/dashboard/draggable-widget.tsx create mode 100644 components/dashboard/low-stock-widget.tsx create mode 100644 components/dashboard/pending-chats-widget.tsx create mode 100644 components/dashboard/recent-customers-widget.tsx create mode 100644 components/dashboard/revenue-widget.tsx create mode 100644 components/dashboard/widget-settings-modal.tsx create mode 100644 components/dashboard/widget-settings.tsx create mode 100644 components/ui/empty-state.tsx create mode 100644 components/ui/relative-time.tsx create mode 100644 hooks/useFilterState.ts create mode 100644 hooks/useWidgetLayout.ts diff --git a/components/dashboard/content.tsx b/components/dashboard/content.tsx index e8933d7..7681540 100644 --- a/components/dashboard/content.tsx +++ b/components/dashboard/content.tsx @@ -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, EditDashboardButton } 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 @@ -37,6 +46,9 @@ export default function Content({ username, orderStats }: ContentProps) { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const { toast } = useToast(); + const { widgets, toggleWidget, moveWidget, reorderWidgets, resetLayout, isWidgetVisible, updateWidgetSettings, updateWidgetColSpan } = useWidgetLayout(); + const [configuredWidget, setConfiguredWidget] = useState(null); + const [isEditMode, setIsEditMode] = useState(false); // Initialize with a default quote to match server-side rendering, then randomize on client const [randomQuote, setRandomQuote] = useState({ text: "Loading wisdom...", author: "..." }); @@ -59,15 +71,151 @@ export default function Content({ username, orderStats }: ContentProps) { } }; + const handleRetry = () => { + fetchTopProducts(); + }; + + const renderWidget = (widget: WidgetConfig) => { + switch (widget.id) { + case "quick-actions": + return ( +
+

Quick Actions

+ +
+ ); + case "overview": + return ( +
+

Overview

+
+ {statsConfig.map((stat, index) => ( + + ))} +
+
+ ); + case "recent-activity": + return ( +
+

Recent Activity

+ +
+ ); + case "top-products": + return ( +
+

Top Performing Listings

+ + +
+ Top Performing Listings + Your products with the highest sales volume +
+ {error && ( + + )} +
+ + {isLoading ? ( +
+ {[...Array(5)].map((_, i) => ( +
+ +
+ + +
+ +
+ ))} +
+ ) : error ? ( +
+
Failed to load product insights
+
+ ) : topProducts.length === 0 ? ( +
+ +

Begin your sales journey

+

+ Your top performing listings will materialize here as you receive orders. +

+
+ ) : ( +
+ {topProducts.map((product, index) => ( + +
+ {!product.image && ( + + )} +
+
+

{product.name}

+
+ £{(Number(Array.isArray(product.price) ? product.price[0] : product.price) || 0).toFixed(2)} +
+
+
+
{product.count}
+
Units Sold
+
£{product.revenue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
+
+ ))} +
+ )} +
+
+
+ ); + case "revenue-chart": + return ; + case "low-stock": + return ; + case "recent-customers": + return ; + case "pending-chats": + return ; + default: + return null; + } + }; + useEffect(() => { setGreeting(getGreeting()); fetchTopProducts(); }, []); - const handleRetry = () => { - fetchTopProducts(); - }; - return (
+ setIsEditMode(!isEditMode)} + /> + setConfiguredWidget(widget)} + />
- {/* Quick ActionsSection */} -
-

Quick Actions

- -
+ setIsEditMode(false)} + onReorder={reorderWidgets} + onReset={resetLayout} + > +
+ {widgets.map((widget) => { + if (!widget.visible && !isEditMode) return null; - {/* Order Statistics */} -
-

Overview

-
- {statsConfig.map((stat, index) => ( - - ))} + return ( + setConfiguredWidget(widget)} + onToggleVisibility={() => toggleWidget(widget.id)} + > + {!widget.visible && isEditMode ? ( +
+ {renderWidget(widget)} +
+ ) : ( + renderWidget(widget) + )} +
+ ); + })}
-
+ -
- {/* Recent Activity Section */} -
- -
- - {/* Best Selling Products Section */} -
- - -
- Top Performing Listings - Your products with the highest sales volume -
- {error && ( - - )} -
- - - {isLoading ? ( -
- {[...Array(5)].map((_, i) => ( -
- -
- - -
- -
- ))} -
- ) : error ? ( -
-
Failed to load product insights
-
- ) : topProducts.length === 0 ? ( -
- -

Begin your sales journey

-

- Your top performing listings will materialize here as you receive orders. -

-
- ) : ( -
- {topProducts.map((product, index) => ( - -
- {!product.image && ( - - )} -
-
-

{product.name}

-
- £{(Number(Array.isArray(product.price) ? product.price[0] : product.price) || 0).toFixed(2)} -
-
-
-
{product.count}
-
Units Sold
-
£{product.revenue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
-
-
- ))} -
- )} -
-
-
-
+ {/* Widget Settings Modal */} + !open && setConfiguredWidget(null)} + onSave={(widgetId, settings, colSpan) => { + updateWidgetSettings(widgetId, settings); + if (colSpan !== undefined) updateWidgetColSpan(widgetId, colSpan); + }} + />
); } diff --git a/components/dashboard/dashboard-editor.tsx b/components/dashboard/dashboard-editor.tsx new file mode 100644 index 0000000..bdd6765 --- /dev/null +++ b/components/dashboard/dashboard-editor.tsx @@ -0,0 +1,157 @@ +"use client" + +import React, { useState } from "react" +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, + DragOverlay, + DragStartEvent, +} from "@dnd-kit/core" +import { + SortableContext, + sortableKeyboardCoordinates, + rectSortingStrategy, + 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(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 ( + + w.id)} + strategy={rectSortingStrategy} + > + {children} + + + {/* Edit Mode Banner */} + + {isEditMode && ( + +
+ + Editing Dashboard • Drag widgets to reorder + +
+ + +
+ + )} + + + ) +} + +// Edit button component to add to the header +export function EditDashboardButton({ + isEditMode, + onToggle +}: { + isEditMode: boolean + onToggle: () => void +}) { + return ( + + ) +} diff --git a/components/dashboard/draggable-widget.tsx b/components/dashboard/draggable-widget.tsx new file mode 100644 index 0000000..e4291b5 --- /dev/null +++ b/components/dashboard/draggable-widget.tsx @@ -0,0 +1,121 @@ +"use client" + +import React 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, + } + + const colSpanClasses = { + 1: "lg:col-span-1", + 2: "lg:col-span-2", + 3: "lg:col-span-3", + 4: "lg:col-span-4", + }[widget.colSpan || 4] || "lg:col-span-4" + + return ( +
+ {isEditMode && ( + <> + {/* Edit Mode Overlay */} +
+ + {/* Drag Handle */} +
+ +
+ + {/* Widget Title Badge */} +
+ {widget.title} +
+ + {/* Action Buttons */} +
+ {onConfigure && ( + + )} + {onToggleVisibility && ( + + )} +
+ + )} + + {/* Widget Content */} +
+ {children} +
+
+ ) +} diff --git a/components/dashboard/low-stock-widget.tsx b/components/dashboard/low-stock-widget.tsx new file mode 100644 index 0000000..6230911 --- /dev/null +++ b/components/dashboard/low-stock-widget.tsx @@ -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([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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 ( + + + + + + + {[1, 2, 3].map((i) => ( +
+ +
+ + +
+
+ ))} +
+
+ ) + } + + return ( + + +
+ + + Low Stock Alerts + + + Inventory checks (Threshold: {threshold}) + +
+ + + +
+ + {error ? ( +
+ +

{error}

+
+ ) : products.length === 0 ? ( +
+
+ +
+

All systems go

+

+ No products currently under your threshold of {threshold} units. +

+
+ ) : ( +
+ {products.map((product) => ( +
+
+ {product.image ? ( + {product.name} + ) : ( +
+ +
+ )} +
+
+

{product.name}

+
+ + ID: {product.id.slice(-6)} + +
+
+
+
+ {product.currentStock} {product.unitType} +
+
Remaining
+
+
+ ))} +
+ )} +
+
+ ) +} diff --git a/components/dashboard/order-stats.tsx b/components/dashboard/order-stats.tsx index 8cd04d7..abe454b 100644 --- a/components/dashboard/order-stats.tsx +++ b/components/dashboard/order-stats.tsx @@ -1,36 +1,60 @@ 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 ( - -
- - - {title} - -
- -
-
- -
{value}
-
- - + + +
+ + + {title} + +
+ +
+
+ +
{value}
+
+ {linkHref && ( +
+ Click to view → +
+ )} + + + ) } diff --git a/components/dashboard/pending-chats-widget.tsx b/components/dashboard/pending-chats-widget.tsx new file mode 100644 index 0000000..e56567d --- /dev/null +++ b/components/dashboard/pending-chats-widget.tsx @@ -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([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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 ( + + + + + + + {[1, 2].map((i) => ( +
+ +
+ + +
+
+ ))} +
+
+ ) + } + + return ( + + +
+ + + Pending Chats + + + Unanswered customer messages + +
+ + + +
+ + {error ? ( +
+ +

{error}

+
+ ) : chats.length === 0 ? ( +
+
+ +
+

All caught up!

+

+ No pending customer chats that require your attention. +

+
+ ) : ( +
+ {chats.map((chat) => ( + +
+ + + {(chat.telegramUsername || chat.buyerId).slice(0, 2).toUpperCase()} + + + +
+
+

+ {chat.telegramUsername ? `@${chat.telegramUsername}` : `Customer ${chat.buyerId.slice(-6)}`} +

+
+ + +
+
+
+ {chat.unreadCount} +
+ + ))} +
+ )} +
+
+ ) +} diff --git a/components/dashboard/recent-activity.tsx b/components/dashboard/recent-activity.tsx index a4273cb..8c9cfc8 100644 --- a/components/dashboard/recent-activity.tsx +++ b/components/dashboard/recent-activity.tsx @@ -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 { @@ -100,7 +100,7 @@ export default function RecentActivity() { Order #{item.orderId} - {formatDistanceToNow(new Date(item.orderDate), { addSuffix: true })} +

diff --git a/components/dashboard/recent-customers-widget.tsx b/components/dashboard/recent-customers-widget.tsx new file mode 100644 index 0000000..19f9612 --- /dev/null +++ b/components/dashboard/recent-customers-widget.tsx @@ -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([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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 ( + + + + + + + {[1, 2, 3].map((i) => ( +

+ +
+ + +
+
+ ))} +
+ + ) + } + + return ( + + +
+ + + Recent Customers + + + Latest and newest connections + +
+ + + +
+ + {error ? ( +
+ +

{error}

+
+ ) : customers.length === 0 ? ( +
+
+ +
+

No customers yet

+

+ This widget will populate once people start browsing and buying. +

+
+ ) : ( +
+ {customers.map((customer) => ( +
+ + + {customer.name.slice(0, 2).toUpperCase()} + + +
+

{customer.name}

+
+ + {customer.orderCount} order{customer.orderCount !== 1 ? 's' : ''} + +
+
+ {showSpent && ( +
+
+ {formatGBP(customer.totalSpent)} +
+
Total Spent
+
+ )} +
+ ))} +
+ )} +
+
+ ) +} diff --git a/components/dashboard/revenue-widget.tsx b/components/dashboard/revenue-widget.tsx new file mode 100644 index 0000000..a55a330 --- /dev/null +++ b/components/dashboard/revenue-widget.tsx @@ -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([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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 ( +
+

{data.formattedDate}

+
+

+ Revenue: {formatGBP(data.revenue)} +

+

+ Orders: {data.orders} +

+
+
+ ) + } + return null + } + + if (isLoading) { + return ( + + + + + + + + + + ) + } + + return ( + + +
+ + + Revenue Insights + + + Performance over the last {days} days + +
+ {error && ( + + )} +
+ + {error ? ( +
+ +

Could not load revenue trends

+ +
+ ) : chartData.length === 0 ? ( +
+ +

No revenue data

+

+ Start making sales to see your revenue trends here. +

+
+ ) : ( +
+
+ + + + + + + + + + + `£${value >= 1000 ? (value / 1000).toFixed(1) + 'k' : value}`} + /> + } /> + + + +
+ +
+
+
Total Revenue
+
{formatGBP(totalRevenue)}
+
+
+
Total Orders
+
{totalOrders}
+
+
+
+ )} +
+
+ ) +} diff --git a/components/dashboard/widget-settings-modal.tsx b/components/dashboard/widget-settings-modal.tsx new file mode 100644 index 0000000..dfdf51f --- /dev/null +++ b/components/dashboard/widget-settings-modal.tsx @@ -0,0 +1,291 @@ +"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" + +interface WidgetSettingsModalProps { + widget: WidgetConfig | null + open: boolean + onOpenChange: (open: boolean) => void + onSave: (widgetId: string, settings: Record, colSpan: number) => void +} + +export function WidgetSettingsModal({ widget, open, onOpenChange, onSave }: WidgetSettingsModalProps) { + const [localSettings, setLocalSettings] = useState>({}) + const [localColSpan, setLocalColSpan] = useState(4) + + // Initialize local settings when widget changes + const handleOpenChange = (isOpen: boolean) => { + if (isOpen && widget) { + setLocalSettings({ ...widget.settings }) + setLocalColSpan(widget.colSpan || 4) + } + onOpenChange(isOpen) + } + + const handleSave = () => { + if (widget) { + onSave(widget.id, localSettings, localColSpan) + onOpenChange(false) + } + } + + const updateSetting = (key: string, value: any) => { + setLocalSettings(prev => ({ ...prev, [key]: value })) + } + + if (!widget) return null + + return ( + + + + + + {widget.title} Settings + + + Customize how this widget displays on your dashboard. + + + +
+ {/* Resize Selection */} +
+ +
+ + +
+
+ +
+ {/* Recent Activity Settings */} + {widget.id === "recent-activity" && ( +
+
+ + +
+
+ )} + + {/* Top Products Settings */} + {widget.id === "top-products" && ( +
+
+ + +
+
+ + updateSetting("showRevenue", checked)} + /> +
+
+ )} + + {/* Revenue Chart Settings */} + {widget.id === "revenue-chart" && ( +
+
+ + +
+
+ + updateSetting("showComparison", checked)} + /> +
+
+ )} + + {/* Low Stock Settings */} + {widget.id === "low-stock" && ( +
+
+ + updateSetting("threshold", parseInt(e.target.value) || 5)} + min={1} + max={100} + /> +
+
+ + +
+
+ )} + + {/* Recent Customers Settings */} + {widget.id === "recent-customers" && ( +
+
+ + +
+
+ + updateSetting("showSpent", checked)} + /> +
+
+ )} + + {/* Pending Chats Settings */} + {widget.id === "pending-chats" && ( +
+
+ + updateSetting("showPreview", checked)} + /> +
+
+ )} + + {/* Overview Settings */} + {widget.id === "overview" && ( +
+
+ + updateSetting("showChange", checked)} + /> +
+
+ )} + + {/* Quick Actions - no settings */} + {widget.id === "quick-actions" && ( +

+ This widget has no customizable settings. +

+ )} +
+
+ + + + + +
+
+ ) +} diff --git a/components/dashboard/widget-settings.tsx b/components/dashboard/widget-settings.tsx new file mode 100644 index 0000000..d21ffd4 --- /dev/null +++ b/components/dashboard/widget-settings.tsx @@ -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 ( + + + + + + + Dashboard Widgets + + + + {widgets.map((widget, index) => ( +
+ +
+ {widget.settings && onConfigure && ( + + )} + + +
+
+ ))} +
+
+ ) +} diff --git a/components/tables/order-table.tsx b/components/tables/order-table.tsx index fe00e03..c5bde0f 100644 --- a/components/tables/order-table.tsx +++ b/components/tables/order-table.tsx @@ -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([]); 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); @@ -249,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`); - } - - if (response.success.count > 0) { - toast.success(`${response.success.count} orders 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; + } + 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); diff --git a/components/tables/product-table.tsx b/components/tables/product-table.tsx index be736cc..442e665 100644 --- a/components/tables/product-table.tsx +++ b/components/tables/product-table.tsx @@ -6,6 +6,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import Image from "next/image"; import { Edit, Trash, @@ -93,10 +94,13 @@ const ProductTable = ({
{getProductImageUrl(product) ? ( - {product.name} ) : ( {product.name.charAt(0).toUpperCase()} diff --git a/components/ui/empty-state.tsx b/components/ui/empty-state.tsx new file mode 100644 index 0000000..81695d5 --- /dev/null +++ b/components/ui/empty-state.tsx @@ -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 ( +
+
+ +
+

{title}

+

{description}

+
+ {actionLabel && ( + actionHref ? ( + + ) : actionOnClick ? ( + + ) : null + )} + {secondaryActionLabel && secondaryActionHref && ( + + )} +
+
+ ) +} + +// Preset empty states for common scenarios +export function OrdersEmptyState() { + return ( + + ) +} + +export function ProductsEmptyState({ onAddProduct }: { onAddProduct?: () => void }) { + return ( + + ) +} + +export function CustomersEmptyState() { + return ( + + ) +} + +export function ShippingEmptyState({ onAddMethod }: { onAddMethod?: () => void }) { + return ( + + ) +} + +export function ChatsEmptyState() { + return ( + + ) +} diff --git a/components/ui/relative-time.tsx b/components/ui/relative-time.tsx new file mode 100644 index 0000000..86d9f5a --- /dev/null +++ b/components/ui/relative-time.tsx @@ -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 - + } + + 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 {relativeText} + } + + return ( + + + + {relativeText} + + + {fullDate} + + + + ) +} + +/** + * 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 }) +} diff --git a/config/dashboard.ts b/config/dashboard.ts index 7dfe61d..a60129b 100644 --- a/config/dashboard.ts +++ b/config/dashboard.ts @@ -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" }, ] diff --git a/hooks/useFilterState.ts b/hooks/useFilterState.ts new file mode 100644 index 0000000..bc46b6f --- /dev/null +++ b/hooks/useFilterState.ts @@ -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 +} + +/** + * 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(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 + } +} diff --git a/hooks/useWidgetLayout.ts b/hooks/useWidgetLayout.ts new file mode 100644 index 0000000..b2b0b22 --- /dev/null +++ b/hooks/useWidgetLayout.ts @@ -0,0 +1,195 @@ +"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 + colSpan: number // 1, 2, 3, 4 (full) + settings?: Record +} + +const DEFAULT_WIDGETS: WidgetConfig[] = [ + { id: "quick-actions", title: "Quick Actions", visible: true, order: 0, colSpan: 4 }, + { id: "overview", title: "Overview", visible: true, order: 1, colSpan: 4, settings: { showChange: false } }, + { id: "recent-activity", title: "Recent Activity", visible: true, order: 2, colSpan: 2, settings: { itemCount: 10 } }, + { id: "top-products", title: "Top Products", visible: true, order: 3, colSpan: 2, settings: { itemCount: 5, showRevenue: true } }, + { id: "revenue-chart", title: "Revenue Chart", visible: false, order: 4, colSpan: 2, settings: { days: 7, showComparison: false } }, + { id: "low-stock", title: "Low Stock Alerts", visible: false, order: 5, colSpan: 2, settings: { threshold: 5, itemCount: 5 } }, + { id: "recent-customers", title: "Recent Customers", visible: false, order: 6, colSpan: 2, settings: { itemCount: 5, showSpent: true } }, + { id: "pending-chats", title: "Pending Chats", visible: false, order: 7, colSpan: 2, settings: { showPreview: true } }, +] + +const STORAGE_KEY = "dashboard-widget-layout-v3" + +/** + * useWidgetLayout - Persist and manage dashboard widget visibility, order, and settings + */ +export function useWidgetLayout() { + const [widgets, setWidgets] = useState(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) => { + setWidgets(prev => + prev.map(w => w.id === widgetId + ? { ...w, settings: { ...w.settings, ...newSettings } } + : w + ) + ) + }, []) + + const updateWidgetColSpan = useCallback((widgetId: string, colSpan: number) => { + setWidgets(prev => + prev.map(w => w.id === widgetId ? { ...w, colSpan } : w) + ) + }, []) + + const getWidgetSettings = useCallback(>(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, + updateWidgetColSpan, + getWidgetSettings, + resetLayout, + getVisibleWidgets, + isWidgetVisible, + isLoaded + } +} diff --git a/lib/api.ts b/lib/api.ts index ad0a165..5ab537e 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -5,14 +5,14 @@ export { fetchClient, getAuthToken, getCookie, - + // Customer API getCustomers, getCustomerDetails, - + // Orders API exportOrdersToCSV, - + // Types type CustomerStats, type CustomerResponse, @@ -28,7 +28,7 @@ export { uploadProductImage, getProductStock, updateProductStock, - + // Types type Product, type ProductsResponse, @@ -42,7 +42,7 @@ export { createShippingOption, updateShippingOption, deleteShippingOption, - + // Types type ShippingOption, type ShippingOptionsResponse, @@ -61,7 +61,8 @@ export { getCustomerInsightsWithStore, getOrderAnalyticsWithStore, getStoreIdForUser, - + formatGBP, + // Types type AnalyticsOverview, type RevenueData, @@ -91,9 +92,9 @@ export { } from './services/stats-service'; // Re-export server API functions -export { - fetchServer, - getCustomersServer, +export { + fetchServer, + getCustomersServer, getCustomerDetailsServer, getPlatformStatsServer, getAnalyticsOverviewServer, @@ -127,11 +128,11 @@ export const apiRequest = async (endpoint: string, method = 'GET', data: any = n headers: { 'Content-Type': 'application/json' }, body: data ? JSON.stringify(data) : undefined, }; - + if (token) { options.headers.Authorization = `Bearer ${token}`; } - + return clientFetch(endpoint, options); }; diff --git a/lib/services/analytics-service.ts b/lib/services/analytics-service.ts index a7374f2..d59c507 100644 --- a/lib/services/analytics-service.ts +++ b/lib/services/analytics-service.ts @@ -190,10 +190,12 @@ export const getCustomerInsights = async ( storeId?: string, page: number = 1, limit: number = 10, + sortBy: string = "spent", ): Promise => { 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 => { const storeId = getStoreIdForUser(); - return getCustomerInsights(storeId, page, limit); + return getCustomerInsights(storeId, page, limit, sortBy); }; export const getOrderAnalyticsWithStore = async ( diff --git a/package-lock.json b/package-lock.json index 2df5898..3a859a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" }, - "funding": { - "url": "https://github.com/distubejs/ytdl-core?sponsor" + "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", diff --git a/package.json b/package.json index a305ad0..41e4c1a 100644 --- a/package.json +++ b/package.json @@ -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", @@ -91,4 +94,4 @@ "tailwindcss": "^3.4.17", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/public/git-info.json b/public/git-info.json index 170c1e6..be281d7 100644 --- a/public/git-info.json +++ b/public/git-info.json @@ -1,4 +1,4 @@ { - "commitHash": "064cd7a", - "buildTime": "2026-01-12T08:43:31.133Z" + "commitHash": "a6b7286", + "buildTime": "2026-01-12T10:20:09.966Z" } \ No newline at end of file