Add modular dashboard widgets and layout editor
Some checks failed
Build Frontend / build (push) Failing after 7s
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.
This commit is contained in:
@@ -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<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
const { widgets, toggleWidget, moveWidget, reorderWidgets, resetLayout, isWidgetVisible, updateWidgetSettings, updateWidgetColSpan } = useWidgetLayout();
|
||||
const [configuredWidget, setConfiguredWidget] = useState<WidgetConfig | null>(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 (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Quick Actions</h2>
|
||||
<QuickActions />
|
||||
</section>
|
||||
);
|
||||
case "overview":
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Overview</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{statsConfig.map((stat, index) => (
|
||||
<OrderStats
|
||||
key={stat.title}
|
||||
title={stat.title}
|
||||
value={orderStats[stat.key as keyof OrderStatsData]?.toLocaleString() || "0"}
|
||||
icon={stat.icon}
|
||||
index={index}
|
||||
filterStatus={stat.filterStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
case "recent-activity":
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Recent Activity</h2>
|
||||
<RecentActivity />
|
||||
</section>
|
||||
);
|
||||
case "top-products":
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Top Performing Listings</h2>
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div>
|
||||
<CardTitle>Top Performing Listings</CardTitle>
|
||||
<CardDescription>Your products with the highest sales volume</CardDescription>
|
||||
</div>
|
||||
{error && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRetry}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<RefreshCcw className="h-3 w-3" />
|
||||
<span>Retry</span>
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-14 w-14 rounded-xl" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-12 text-center">
|
||||
<div className="text-muted-foreground mb-4">Failed to load product insights</div>
|
||||
</div>
|
||||
) : topProducts.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<ShoppingCart className="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-1">Begin your sales journey</h3>
|
||||
<p className="text-muted-foreground text-sm max-w-xs mx-auto">
|
||||
Your top performing listings will materialize here as you receive orders.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{topProducts.map((product, index) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 + index * 0.05 }}
|
||||
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<div
|
||||
className="h-14 w-14 bg-muted bg-cover bg-center rounded-xl border flex-shrink-0 flex items-center justify-center overflow-hidden group-hover:scale-105 transition-transform"
|
||||
style={{
|
||||
backgroundImage: product.image
|
||||
? `url(/api/products/${product.id}/image)`
|
||||
: 'none'
|
||||
}}
|
||||
>
|
||||
{!product.image && (
|
||||
<ShoppingCart className="h-6 w-6 text-muted-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-grow min-w-0">
|
||||
<h4 className="font-semibold text-lg truncate group-hover:text-primary transition-colors">{product.name}</h4>
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
<span className="text-sm text-muted-foreground font-medium">£{(Number(Array.isArray(product.price) ? product.price[0] : product.price) || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-bold">{product.count}</div>
|
||||
<div className="text-xs text-muted-foreground font-medium uppercase tracking-tighter mb-1">Units Sold</div>
|
||||
<div className="text-sm font-semibold text-primary">£{product.revenue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
case "revenue-chart":
|
||||
return <RevenueWidget settings={widget.settings} />;
|
||||
case "low-stock":
|
||||
return <LowStockWidget settings={widget.settings} />;
|
||||
case "recent-customers":
|
||||
return <RecentCustomersWidget settings={widget.settings} />;
|
||||
case "pending-chats":
|
||||
return <PendingChatsWidget settings={widget.settings} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setGreeting(getGreeting());
|
||||
fetchTopProducts();
|
||||
}, []);
|
||||
|
||||
const handleRetry = () => {
|
||||
fetchTopProducts();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-10 pb-10">
|
||||
<motion.div
|
||||
@@ -84,125 +232,62 @@ export default function Content({ username, orderStats }: ContentProps) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<EditDashboardButton
|
||||
isEditMode={isEditMode}
|
||||
onToggle={() => setIsEditMode(!isEditMode)}
|
||||
/>
|
||||
<WidgetSettings
|
||||
widgets={widgets}
|
||||
onToggle={toggleWidget}
|
||||
onMove={moveWidget}
|
||||
onReset={resetLayout}
|
||||
onConfigure={(widget) => setConfiguredWidget(widget)}
|
||||
/>
|
||||
</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>
|
||||
<DashboardEditor
|
||||
widgets={widgets}
|
||||
isEditMode={isEditMode}
|
||||
onToggleEditMode={() => setIsEditMode(false)}
|
||||
onReorder={reorderWidgets}
|
||||
onReset={resetLayout}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 auto-rows-min">
|
||||
{widgets.map((widget) => {
|
||||
if (!widget.visible && !isEditMode) return null;
|
||||
|
||||
{/* Order Statistics */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Overview</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{statsConfig.map((stat, index) => (
|
||||
<OrderStats
|
||||
key={stat.title}
|
||||
title={stat.title}
|
||||
value={orderStats[stat.key as keyof OrderStatsData]?.toLocaleString() || "0"}
|
||||
icon={stat.icon}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
return (
|
||||
<DraggableWidget
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
isEditMode={isEditMode}
|
||||
onConfigure={() => setConfiguredWidget(widget)}
|
||||
onToggleVisibility={() => toggleWidget(widget.id)}
|
||||
>
|
||||
{!widget.visible && isEditMode ? (
|
||||
<div className="opacity-40 grayscale pointer-events-none h-full">
|
||||
{renderWidget(widget)}
|
||||
</div>
|
||||
) : (
|
||||
renderWidget(widget)
|
||||
)}
|
||||
</DraggableWidget>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</DashboardEditor>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
||||
{/* Recent Activity Section */}
|
||||
<div className="xl:col-span-1">
|
||||
<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">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div>
|
||||
<CardTitle>Top Performing Listings</CardTitle>
|
||||
<CardDescription>Your products with the highest sales volume</CardDescription>
|
||||
</div>
|
||||
{error && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRetry}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<RefreshCcw className="h-3 w-3" />
|
||||
<span>Retry</span>
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-14 w-14 rounded-xl" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-12 text-center">
|
||||
<div className="text-muted-foreground mb-4">Failed to load product insights</div>
|
||||
</div>
|
||||
) : topProducts.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<ShoppingCart className="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-1">Begin your sales journey</h3>
|
||||
<p className="text-muted-foreground text-sm max-w-xs mx-auto">
|
||||
Your top performing listings will materialize here as you receive orders.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{topProducts.map((product, index) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 + index * 0.05 }}
|
||||
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<div
|
||||
className="h-14 w-14 bg-muted bg-cover bg-center rounded-xl border flex-shrink-0 flex items-center justify-center overflow-hidden group-hover:scale-105 transition-transform"
|
||||
style={{
|
||||
backgroundImage: product.image
|
||||
? `url(/api/products/${product.id}/image)`
|
||||
: 'none'
|
||||
}}
|
||||
>
|
||||
{!product.image && (
|
||||
<ShoppingCart className="h-6 w-6 text-muted-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-grow min-w-0">
|
||||
<h4 className="font-semibold text-lg truncate group-hover:text-primary transition-colors">{product.name}</h4>
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
<span className="text-sm text-muted-foreground font-medium">£{(Number(Array.isArray(product.price) ? product.price[0] : product.price) || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-bold">{product.count}</div>
|
||||
<div className="text-xs text-muted-foreground font-medium uppercase tracking-tighter mb-1">Units Sold</div>
|
||||
<div className="text-sm font-semibold text-primary">£{product.revenue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
{/* Widget Settings Modal */}
|
||||
<WidgetSettingsModal
|
||||
widget={configuredWidget}
|
||||
open={!!configuredWidget}
|
||||
onOpenChange={(open) => !open && setConfiguredWidget(null)}
|
||||
onSave={(widgetId, settings, colSpan) => {
|
||||
updateWidgetSettings(widgetId, settings);
|
||||
if (colSpan !== undefined) updateWidgetColSpan(widgetId, colSpan);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
157
components/dashboard/dashboard-editor.tsx
Normal file
157
components/dashboard/dashboard-editor.tsx
Normal file
@@ -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<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={rectSortingStrategy}
|
||||
>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
// Edit button component to add to the header
|
||||
export function EditDashboardButton({
|
||||
isEditMode,
|
||||
onToggle
|
||||
}: {
|
||||
isEditMode: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant={isEditMode ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-8 gap-2"
|
||||
onClick={onToggle}
|
||||
>
|
||||
{isEditMode ? (
|
||||
<>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Cancel</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Edit Layout</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
121
components/dashboard/draggable-widget.tsx
Normal file
121
components/dashboard/draggable-widget.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"relative group",
|
||||
colSpanClasses,
|
||||
"md:col-span-2", // Default to 2 columns on tablet
|
||||
isEditMode && "ring-2 ring-primary ring-offset-2 ring-offset-background rounded-lg",
|
||||
isDragging && "z-50 shadow-2xl"
|
||||
)}
|
||||
>
|
||||
{isEditMode && (
|
||||
<>
|
||||
{/* Edit Mode Overlay */}
|
||||
<div className="absolute inset-0 rounded-lg border-2 border-dashed border-primary/30 pointer-events-none z-10 group-hover:border-primary/60 transition-colors" />
|
||||
|
||||
{/* Drag Handle */}
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="absolute top-2 left-2 z-20 cursor-grab active:cursor-grabbing bg-background/90 backdrop-blur-sm rounded-md p-1.5 shadow-md border border-border opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Widget Title Badge */}
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20 bg-primary text-primary-foreground text-xs font-medium px-2 py-1 rounded-full shadow-md opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{widget.title}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="absolute top-2 right-2 z-20 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onConfigure && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-7 w-7 shadow-md"
|
||||
onClick={onConfigure}
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{onToggleVisibility && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-7 w-7 shadow-md"
|
||||
onClick={onToggleVisibility}
|
||||
>
|
||||
{widget.visible ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Widget Content */}
|
||||
<div className={cn(
|
||||
"h-full transition-transform",
|
||||
isDragging && "scale-[1.02]"
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
167
components/dashboard/low-stock-widget.tsx
Normal file
167
components/dashboard/low-stock-widget.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { AlertCircle, Package, ArrowRight, ShoppingCart } from "lucide-react"
|
||||
import { clientFetch } from "@/lib/api"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
|
||||
interface LowStockWidgetProps {
|
||||
settings?: {
|
||||
threshold?: number
|
||||
itemCount?: number
|
||||
}
|
||||
}
|
||||
|
||||
interface LowStockProduct {
|
||||
id: string
|
||||
name: string
|
||||
currentStock: number
|
||||
unitType: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
export default function LowStockWidget({ settings }: LowStockWidgetProps) {
|
||||
const threshold = settings?.threshold || 5
|
||||
const itemCount = settings?.itemCount || 5
|
||||
const [products, setProducts] = useState<LowStockProduct[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchLowStock = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
// Implementation: We'll use the product-performance API and filter locally
|
||||
// or a dedicated stock-report API if available.
|
||||
// For now, let's use the product-performance endpoint which has stock info.
|
||||
const response = await clientFetch('/analytics/product-performance')
|
||||
|
||||
const lowStockProducts = response
|
||||
.filter((p: any) => p.currentStock <= threshold)
|
||||
.sort((a: any, b: any) => a.currentStock - b.currentStock)
|
||||
.slice(0, itemCount)
|
||||
.map((p: any) => ({
|
||||
id: p.productId,
|
||||
name: p.name,
|
||||
currentStock: p.currentStock,
|
||||
unitType: p.unitType,
|
||||
image: p.image
|
||||
}))
|
||||
|
||||
setProducts(lowStockProducts)
|
||||
} catch (err) {
|
||||
console.error("Error fetching low stock data:", err)
|
||||
setError("Failed to load inventory data")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchLowStock()
|
||||
}, [threshold, itemCount])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-5 w-32 mb-1" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-12 w-12 rounded-lg" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm overflow-hidden flex flex-col h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-amber-500" />
|
||||
Low Stock Alerts
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Inventory checks (Threshold: {threshold})
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Link href="/dashboard/stock">
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs">
|
||||
Manage
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 flex-grow">
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||
<Package className="h-10 w-10 text-muted-foreground/20 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-green-500/10 flex items-center justify-center mb-4">
|
||||
<Package className="h-6 w-6 text-green-500" />
|
||||
</div>
|
||||
<h3 className="font-medium">All systems go</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-xs">
|
||||
No products currently under your threshold of {threshold} units.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{products.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<div className="h-12 w-12 relative rounded-lg border bg-muted overflow-hidden flex-shrink-0">
|
||||
{product.image ? (
|
||||
<Image
|
||||
src={`/api/products/${product.id}/image`}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover group-hover:scale-110 transition-transform"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<ShoppingCart className="h-5 w-5 text-muted-foreground/40" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-grow min-w-0">
|
||||
<h4 className="font-semibold text-sm truncate">{product.name}</h4>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-[10px] uppercase font-mono tracking-wider text-muted-foreground bg-muted-foreground/10 px-1.5 py-0.5 rounded">
|
||||
ID: {product.id.slice(-6)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`text-sm font-bold ${product.currentStock === 0 ? 'text-destructive' : 'text-amber-500'}`}>
|
||||
{product.currentStock} {product.unitType}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground font-medium uppercase tracking-tighter">Remaining</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +1,60 @@
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import { 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">
|
||||
<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">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<div className="p-2 rounded-lg bg-muted group-hover:bg-primary/10 group-hover:text-primary transition-all duration-300">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<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" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<div className="p-2 rounded-lg bg-muted group-hover:bg-primary/10 group-hover:text-primary transition-all duration-300">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
172
components/dashboard/pending-chats-widget.tsx
Normal file
172
components/dashboard/pending-chats-widget.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { MessageSquare, MessageCircle, ArrowRight, Clock } from "lucide-react"
|
||||
import { clientFetch, getCookie } from "@/lib/api"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import Link from "next/link"
|
||||
import { RelativeTime } from "@/components/ui/relative-time"
|
||||
|
||||
interface PendingChatsWidgetProps {
|
||||
settings?: {
|
||||
showPreview?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface Chat {
|
||||
id: string
|
||||
buyerId: string
|
||||
telegramUsername?: string
|
||||
lastUpdated: string
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
export default function PendingChatsWidget({ settings }: PendingChatsWidgetProps) {
|
||||
const showPreview = settings?.showPreview !== false
|
||||
const [chats, setChats] = useState<Chat[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const getVendorIdFromToken = () => {
|
||||
const authToken = getCookie("Authorization") || ""
|
||||
if (!authToken) return null
|
||||
try {
|
||||
const payload = JSON.parse(atob(authToken.split(".")[1]))
|
||||
return payload.id
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchChats = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
const vendorId = getVendorIdFromToken()
|
||||
if (!vendorId) {
|
||||
setError("Please login to view chats")
|
||||
return
|
||||
}
|
||||
|
||||
const response = await clientFetch(`/chats/vendor/${vendorId}/batch?page=1&limit=5`)
|
||||
|
||||
const chatCounts = response.unreadCounts?.chatCounts || {}
|
||||
const pendingChats = (response.chats || [])
|
||||
.filter((c: any) => chatCounts[c._id] > 0)
|
||||
.map((c: any) => ({
|
||||
id: c._id,
|
||||
buyerId: c.buyerId,
|
||||
telegramUsername: c.telegramUsername,
|
||||
lastUpdated: c.lastUpdated,
|
||||
unreadCount: chatCounts[c._id] || 0
|
||||
}))
|
||||
|
||||
setChats(pendingChats)
|
||||
} catch (err) {
|
||||
console.error("Error fetching chats:", err)
|
||||
setError("Failed to load chats")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchChats()
|
||||
}, [])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-5 w-32 mb-1" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm overflow-hidden flex flex-col h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-emerald-500" />
|
||||
Pending Chats
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Unanswered customer messages
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Link href="/dashboard/chats">
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs">
|
||||
Inbox
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 flex-grow">
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||
<MessageCircle className="h-10 w-10 text-muted-foreground/20 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
) : chats.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-emerald-500/10 flex items-center justify-center mb-4">
|
||||
<MessageCircle className="h-6 w-6 text-emerald-500" />
|
||||
</div>
|
||||
<h3 className="font-medium">All caught up!</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-xs">
|
||||
No pending customer chats that require your attention.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{chats.map((chat) => (
|
||||
<Link
|
||||
key={chat.id}
|
||||
href={`/dashboard/chats/${chat.id}`}
|
||||
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<div className="relative">
|
||||
<Avatar className="h-10 w-10 border shadow-sm group-hover:scale-105 transition-transform">
|
||||
<AvatarFallback className="bg-emerald-500/10 text-emerald-600 text-xs font-bold">
|
||||
{(chat.telegramUsername || chat.buyerId).slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="absolute -top-0.5 -right-0.5 h-3 w-3 bg-emerald-500 rounded-full ring-2 ring-background border border-background shadow-sm" />
|
||||
</div>
|
||||
<div className="flex-grow min-w-0">
|
||||
<h4 className="font-semibold text-sm truncate group-hover:text-primary transition-colors">
|
||||
{chat.telegramUsername ? `@${chat.telegramUsername}` : `Customer ${chat.buyerId.slice(-6)}`}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
<RelativeTime date={new Date(chat.lastUpdated)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-emerald-500 text-emerald-foreground text-[10px] font-bold px-1.5 py-0.5 rounded-full shadow-sm ring-2 ring-emerald-500/10">
|
||||
{chat.unreadCount}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
</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">
|
||||
|
||||
154
components/dashboard/recent-customers-widget.tsx
Normal file
154
components/dashboard/recent-customers-widget.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Users, User, ArrowRight, DollarSign } from "lucide-react"
|
||||
import { getCustomerInsightsWithStore, formatGBP } from "@/lib/api"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import Link from "next/link"
|
||||
|
||||
interface RecentCustomersWidgetProps {
|
||||
settings?: {
|
||||
itemCount?: number
|
||||
showSpent?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface Customer {
|
||||
id: string
|
||||
name: string
|
||||
username?: string
|
||||
orderCount: number
|
||||
totalSpent: number
|
||||
}
|
||||
|
||||
export default function RecentCustomersWidget({ settings }: RecentCustomersWidgetProps) {
|
||||
const itemCount = settings?.itemCount || 5
|
||||
const showSpent = settings?.showSpent !== false
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchCustomers = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
// The API returns topCustomers, but we'll use 'recent' sorting to show new engagement
|
||||
const response = await getCustomerInsightsWithStore(1, itemCount, "recent")
|
||||
|
||||
const mappedCustomers = (response.topCustomers || []).map((c: any) => ({
|
||||
id: c._id,
|
||||
name: c.displayName || c.username || `Customer ${c._id.slice(-4)}`,
|
||||
username: c.username,
|
||||
orderCount: c.orderCount || 0,
|
||||
totalSpent: c.totalSpent || 0
|
||||
}))
|
||||
|
||||
setCustomers(mappedCustomers)
|
||||
} catch (err) {
|
||||
console.error("Error fetching customers:", err)
|
||||
setError("Failed to load customer data")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers()
|
||||
}, [itemCount])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-5 w-32 mb-1" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm overflow-hidden flex flex-col h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-indigo-500" />
|
||||
Recent Customers
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Latest and newest connections
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Link href="/dashboard/customers">
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-xs">
|
||||
View All
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4 flex-grow">
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||
<User className="h-10 w-10 text-muted-foreground/20 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
) : customers.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="h-12 w-12 rounded-full bg-indigo-500/10 flex items-center justify-center mb-4">
|
||||
<Users className="h-6 w-6 text-indigo-500" />
|
||||
</div>
|
||||
<h3 className="font-medium">No customers yet</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-xs">
|
||||
This widget will populate once people start browsing and buying.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{customers.map((customer) => (
|
||||
<div
|
||||
key={customer.id}
|
||||
className="flex items-center gap-4 p-3 rounded-xl hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<Avatar className="h-10 w-10 border shadow-sm group-hover:scale-105 transition-transform">
|
||||
<AvatarFallback className="bg-indigo-500/10 text-indigo-600 text-xs font-bold">
|
||||
{customer.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-grow min-w-0">
|
||||
<h4 className="font-semibold text-sm truncate group-hover:text-primary transition-colors">{customer.name}</h4>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{customer.orderCount} order{customer.orderCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{showSpent && (
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-bold text-foreground">
|
||||
{formatGBP(customer.totalSpent)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground font-medium uppercase tracking-tighter">Total Spent</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
190
components/dashboard/revenue-widget.tsx
Normal file
190
components/dashboard/revenue-widget.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { TrendingUp, DollarSign, RefreshCcw } from "lucide-react"
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
||||
import { getRevenueTrendsWithStore, type RevenueData, formatGBP } from "@/lib/api"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
interface RevenueWidgetProps {
|
||||
settings?: {
|
||||
days?: number
|
||||
showComparison?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface ChartDataPoint {
|
||||
date: string
|
||||
revenue: number
|
||||
orders: number
|
||||
formattedDate: string
|
||||
}
|
||||
|
||||
export default function RevenueWidget({ settings }: RevenueWidgetProps) {
|
||||
const days = settings?.days || 7
|
||||
const [data, setData] = useState<RevenueData[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { toast } = useToast()
|
||||
|
||||
const fetchRevenueData = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
const response = await getRevenueTrendsWithStore(days.toString())
|
||||
setData(Array.isArray(response) ? response : [])
|
||||
} catch (err) {
|
||||
console.error("Error fetching revenue data:", err)
|
||||
setError("Failed to load revenue data")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchRevenueData()
|
||||
}, [days])
|
||||
|
||||
const chartData: ChartDataPoint[] = data.map(item => {
|
||||
const date = new Date(Date.UTC(item._id.year, item._id.month - 1, item._id.day))
|
||||
return {
|
||||
date: date.toISOString().split('T')[0],
|
||||
revenue: item.revenue || 0,
|
||||
orders: item.orders || 0,
|
||||
formattedDate: date.toLocaleDateString('en-GB', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Summary stats
|
||||
const totalRevenue = data.reduce((sum, item) => sum + (item.revenue || 0), 0)
|
||||
const totalOrders = data.reduce((sum, item) => sum + (item.orders || 0), 0)
|
||||
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload
|
||||
return (
|
||||
<div className="bg-background/95 backdrop-blur-md p-3 border border-border shadow-xl rounded-xl">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">{data.formattedDate}</p>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-bold text-primary">
|
||||
Revenue: {formatGBP(data.revenue)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Orders: <span className="font-medium text-foreground">{data.orders}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-5 w-32 mb-1" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-[250px] w-full rounded-xl" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm overflow-hidden flex flex-col h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-primary" />
|
||||
Revenue Insights
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Performance over the last {days} days
|
||||
</CardDescription>
|
||||
</div>
|
||||
{error && (
|
||||
<Button variant="ghost" size="icon" onClick={fetchRevenueData} className="h-8 w-8">
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-grow pt-4">
|
||||
{error ? (
|
||||
<div className="h-[300px] flex flex-col items-center justify-center text-center p-6">
|
||||
<DollarSign className="h-12 w-12 text-muted-foreground/20 mb-4" />
|
||||
<p className="text-sm text-muted-foreground mb-4">Could not load revenue trends</p>
|
||||
<Button variant="outline" size="sm" onClick={fetchRevenueData}>Retry</Button>
|
||||
</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-[300px] flex flex-col items-center justify-center text-center p-6">
|
||||
<DollarSign className="h-12 w-12 text-muted-foreground/20 mb-4" />
|
||||
<h3 className="text-lg font-medium">No revenue data</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-xs mt-2">
|
||||
Start making sales to see your revenue trends here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorRevenueWidget" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" opacity={0.5} />
|
||||
<XAxis
|
||||
dataKey="formattedDate"
|
||||
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
minTickGap={30}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(value) => `£${value >= 1000 ? (value / 1000).toFixed(1) + 'k' : value}`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
stroke="hsl(var(--primary))"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorRevenueWidget)"
|
||||
strokeWidth={2.5}
|
||||
activeDot={{ r: 6, strokeWidth: 0, fill: "hsl(var(--primary))" }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pb-2">
|
||||
<div className="p-4 rounded-2xl bg-primary/5 border border-primary/10">
|
||||
<div className="text-sm text-muted-foreground font-medium mb-1">Total Revenue</div>
|
||||
<div className="text-2xl font-bold text-primary">{formatGBP(totalRevenue)}</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-2xl bg-muted/50 border border-border">
|
||||
<div className="text-sm text-muted-foreground font-medium mb-1">Total Orders</div>
|
||||
<div className="text-2xl font-bold">{totalOrders}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
291
components/dashboard/widget-settings-modal.tsx
Normal file
291
components/dashboard/widget-settings-modal.tsx
Normal file
@@ -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<string, any>, colSpan: number) => void
|
||||
}
|
||||
|
||||
export function WidgetSettingsModal({ widget, open, onOpenChange, onSave }: WidgetSettingsModalProps) {
|
||||
const [localSettings, setLocalSettings] = useState<Record<string, any>>({})
|
||||
const [localColSpan, setLocalColSpan] = useState<number>(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 (
|
||||
<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>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Resize Selection */}
|
||||
<div className="space-y-3 pb-6 border-b border-border/40">
|
||||
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Widget Display</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="colSpan" className="text-sm font-medium">Widget Width</Label>
|
||||
<Select
|
||||
value={String(localColSpan)}
|
||||
onValueChange={(v) => setLocalColSpan(parseInt(v))}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Small (1/4)</SelectItem>
|
||||
<SelectItem value="2">Medium (1/2)</SelectItem>
|
||||
<SelectItem value="3">Large (3/4)</SelectItem>
|
||||
<SelectItem value="4">Full Width</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
101
components/dashboard/widget-settings.tsx
Normal file
101
components/dashboard/widget-settings.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuCheckboxItem,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Settings2, ChevronUp, ChevronDown, RotateCcw, Eye, EyeOff, Cog } from "lucide-react"
|
||||
import { WidgetConfig } from "@/hooks/useWidgetLayout"
|
||||
|
||||
interface WidgetSettingsProps {
|
||||
widgets: WidgetConfig[]
|
||||
onToggle: (id: string) => void
|
||||
onMove: (id: string, direction: "up" | "down") => void
|
||||
onReset: () => void
|
||||
onConfigure?: (widget: WidgetConfig) => void
|
||||
}
|
||||
|
||||
export function WidgetSettings({ widgets, onToggle, onMove, onReset, onConfigure }: WidgetSettingsProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 gap-2">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Customize</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuLabel className="flex items-center justify-between">
|
||||
Dashboard Widgets
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={onReset}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{widgets.map((widget, index) => (
|
||||
<div key={widget.id} className="flex items-center gap-1 px-2 py-1.5 hover:bg-muted/50 rounded-sm">
|
||||
<button
|
||||
onClick={() => onToggle(widget.id)}
|
||||
className="flex-1 flex items-center gap-2 text-sm hover:text-foreground transition-colors"
|
||||
>
|
||||
{widget.visible ? (
|
||||
<Eye className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className={widget.visible ? "" : "text-muted-foreground line-through"}>
|
||||
{widget.title}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{widget.settings && onConfigure && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onConfigure(widget)
|
||||
}}
|
||||
title="Configure widget"
|
||||
>
|
||||
<Cog className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => onMove(widget.id, "up")}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => onMove(widget.id, "down")}
|
||||
disabled={index === widgets.length - 1}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -3,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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = ({
|
||||
<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>
|
||||
|
||||
138
components/ui/empty-state.tsx
Normal file
138
components/ui/empty-state.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Package,
|
||||
ShoppingBag,
|
||||
Users,
|
||||
Truck,
|
||||
MessageCircle,
|
||||
Plus,
|
||||
Share2,
|
||||
LucideIcon
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon
|
||||
title: string
|
||||
description: string
|
||||
actionLabel?: string
|
||||
actionHref?: string
|
||||
actionOnClick?: () => void
|
||||
secondaryActionLabel?: string
|
||||
secondaryActionHref?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* EmptyState - Reusable component for empty tables/lists
|
||||
* Shows an icon, title, description, and optional action button
|
||||
*/
|
||||
export function EmptyState({
|
||||
icon: Icon = Package,
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
actionHref,
|
||||
actionOnClick,
|
||||
secondaryActionLabel,
|
||||
secondaryActionHref,
|
||||
className = ""
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center py-12 px-4 text-center ${className}`}>
|
||||
<div className="h-16 w-16 rounded-full bg-muted/50 flex items-center justify-center mb-4">
|
||||
<Icon className="h-8 w-8 text-muted-foreground/50" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm mb-6">{description}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{actionLabel && (
|
||||
actionHref ? (
|
||||
<Button asChild>
|
||||
<Link href={actionHref}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{actionLabel}
|
||||
</Link>
|
||||
</Button>
|
||||
) : actionOnClick ? (
|
||||
<Button onClick={actionOnClick}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{actionLabel}
|
||||
</Button>
|
||||
) : null
|
||||
)}
|
||||
{secondaryActionLabel && secondaryActionHref && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={secondaryActionHref}>
|
||||
<Share2 className="h-4 w-4 mr-2" />
|
||||
{secondaryActionLabel}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Preset empty states for common scenarios
|
||||
export function OrdersEmptyState() {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={ShoppingBag}
|
||||
title="No orders yet"
|
||||
description="When customers place orders, they'll appear here. Share your store link to start selling!"
|
||||
secondaryActionLabel="Share Store"
|
||||
secondaryActionHref="/dashboard/storefront"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProductsEmptyState({ onAddProduct }: { onAddProduct?: () => void }) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="No products yet"
|
||||
description="Add your first product to start selling. You can add products manually or import from a file."
|
||||
actionLabel="Add Product"
|
||||
actionOnClick={onAddProduct}
|
||||
actionHref={onAddProduct ? undefined : "/dashboard/products/add"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CustomersEmptyState() {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No customers yet"
|
||||
description="Once customers interact with your store, they'll appear here. Share your store link to attract customers!"
|
||||
secondaryActionLabel="Share Store"
|
||||
secondaryActionHref="/dashboard/storefront"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ShippingEmptyState({ onAddMethod }: { onAddMethod?: () => void }) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Truck}
|
||||
title="No shipping methods"
|
||||
description="Add shipping methods so customers know how they'll receive their orders."
|
||||
actionLabel="Add Shipping Method"
|
||||
actionOnClick={onAddMethod}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatsEmptyState() {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={MessageCircle}
|
||||
title="No conversations yet"
|
||||
description="Customer chats will appear here when they message you through Telegram."
|
||||
/>
|
||||
)
|
||||
}
|
||||
114
components/ui/relative-time.tsx
Normal file
114
components/ui/relative-time.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { format, formatDistanceToNow, isToday, isYesterday, differenceInMinutes } from "date-fns"
|
||||
|
||||
interface RelativeTimeProps {
|
||||
date: Date | string | null | undefined
|
||||
className?: string
|
||||
showTooltip?: boolean
|
||||
updateInterval?: number // ms, for auto-updating recent times
|
||||
}
|
||||
|
||||
/**
|
||||
* RelativeTime - Displays time as "2 hours ago" with full date on hover
|
||||
* Auto-updates for times less than 1 hour old
|
||||
*/
|
||||
export function RelativeTime({
|
||||
date,
|
||||
className = "",
|
||||
showTooltip = true,
|
||||
updateInterval = 60000 // Update every minute
|
||||
}: RelativeTimeProps) {
|
||||
const [, forceUpdate] = React.useReducer(x => x + 1, 0)
|
||||
|
||||
const parsedDate = React.useMemo(() => {
|
||||
if (!date) return null
|
||||
return typeof date === "string" ? new Date(date) : date
|
||||
}, [date])
|
||||
|
||||
// Auto-update for recent times
|
||||
React.useEffect(() => {
|
||||
if (!parsedDate) return
|
||||
|
||||
const minutesAgo = differenceInMinutes(new Date(), parsedDate)
|
||||
|
||||
// Only auto-update if within the last hour
|
||||
if (minutesAgo < 60) {
|
||||
const interval = setInterval(forceUpdate, updateInterval)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [parsedDate, updateInterval])
|
||||
|
||||
if (!parsedDate || isNaN(parsedDate.getTime())) {
|
||||
return <span className={className}>-</span>
|
||||
}
|
||||
|
||||
const formatRelative = (d: Date): string => {
|
||||
const now = new Date()
|
||||
const minutesAgo = differenceInMinutes(now, d)
|
||||
|
||||
// Just now (< 1 minute)
|
||||
if (minutesAgo < 1) return "Just now"
|
||||
|
||||
// Minutes ago (< 60 minutes)
|
||||
if (minutesAgo < 60) return `${minutesAgo}m ago`
|
||||
|
||||
// Hours ago (< 24 hours and today)
|
||||
if (isToday(d)) {
|
||||
const hoursAgo = Math.floor(minutesAgo / 60)
|
||||
return `${hoursAgo}h ago`
|
||||
}
|
||||
|
||||
// Yesterday
|
||||
if (isYesterday(d)) return "Yesterday"
|
||||
|
||||
// Use formatDistanceToNow for older dates
|
||||
return formatDistanceToNow(d, { addSuffix: true })
|
||||
}
|
||||
|
||||
const fullDate = format(parsedDate, "dd MMM yyyy, HH:mm")
|
||||
const relativeText = formatRelative(parsedDate)
|
||||
|
||||
if (!showTooltip) {
|
||||
return <span className={className}>{relativeText}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={`cursor-default ${className}`}>{relativeText}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{fullDate}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to get relative time string without component
|
||||
*/
|
||||
export function getRelativeTimeString(date: Date | string | null | undefined): string {
|
||||
if (!date) return "-"
|
||||
const d = typeof date === "string" ? new Date(date) : date
|
||||
if (isNaN(d.getTime())) return "-"
|
||||
|
||||
const now = new Date()
|
||||
const minutesAgo = differenceInMinutes(now, d)
|
||||
|
||||
if (minutesAgo < 1) return "Just now"
|
||||
if (minutesAgo < 60) return `${minutesAgo}m ago`
|
||||
if (isToday(d)) return `${Math.floor(minutesAgo / 60)}h ago`
|
||||
if (isYesterday(d)) return "Yesterday"
|
||||
|
||||
return formatDistanceToNow(d, { addSuffix: true })
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Package, Clock, CheckCircle, AlertTriangle } from "lucide-react"
|
||||
|
||||
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
165
hooks/useFilterState.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { usePathname, useSearchParams, useRouter } from "next/navigation"
|
||||
import { DateRange } from "react-day-picker"
|
||||
|
||||
interface FilterState {
|
||||
searchQuery?: string
|
||||
statusFilter?: string
|
||||
dateRange?: DateRange
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
sortColumn?: string
|
||||
sortDirection?: "asc" | "desc"
|
||||
}
|
||||
|
||||
interface UseFilterStateOptions {
|
||||
/** Unique key for this page's filter state */
|
||||
storageKey: string
|
||||
/** Initialize from URL params on mount */
|
||||
syncWithUrl?: boolean
|
||||
/** Default values */
|
||||
defaults?: Partial<FilterState>
|
||||
}
|
||||
|
||||
/**
|
||||
* useFilterState - Persist filter state across navigation
|
||||
* Uses sessionStorage to remember filters per page
|
||||
*/
|
||||
export function useFilterState({
|
||||
storageKey,
|
||||
syncWithUrl = false,
|
||||
defaults = {}
|
||||
}: UseFilterStateOptions) {
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
|
||||
const fullKey = `filterState:${storageKey}`
|
||||
|
||||
// Initialize state from sessionStorage or URL params
|
||||
const getInitialState = (): FilterState => {
|
||||
if (typeof window === "undefined") return defaults
|
||||
|
||||
// First try URL params if syncWithUrl is enabled
|
||||
if (syncWithUrl && searchParams) {
|
||||
const urlState: FilterState = {}
|
||||
const status = searchParams.get("status")
|
||||
const search = searchParams.get("search")
|
||||
const page = searchParams.get("page")
|
||||
|
||||
if (status) urlState.statusFilter = status
|
||||
if (search) urlState.searchQuery = search
|
||||
if (page) urlState.page = parseInt(page)
|
||||
|
||||
if (Object.keys(urlState).length > 0) {
|
||||
return { ...defaults, ...urlState }
|
||||
}
|
||||
}
|
||||
|
||||
// Then try sessionStorage
|
||||
try {
|
||||
const stored = sessionStorage.getItem(fullKey)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored)
|
||||
// Restore dateRange as Date objects
|
||||
if (parsed.dateRange) {
|
||||
if (parsed.dateRange.from) parsed.dateRange.from = new Date(parsed.dateRange.from)
|
||||
if (parsed.dateRange.to) parsed.dateRange.to = new Date(parsed.dateRange.to)
|
||||
}
|
||||
return { ...defaults, ...parsed }
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to load filter state from storage:", e)
|
||||
}
|
||||
|
||||
return defaults
|
||||
}
|
||||
|
||||
const [filterState, setFilterState] = useState<FilterState>(getInitialState)
|
||||
|
||||
// Save to sessionStorage whenever state changes
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(fullKey, JSON.stringify(filterState))
|
||||
} catch (e) {
|
||||
console.warn("Failed to save filter state to storage:", e)
|
||||
}
|
||||
}, [filterState, fullKey])
|
||||
|
||||
// Update URL if syncWithUrl is enabled
|
||||
useEffect(() => {
|
||||
if (!syncWithUrl) return
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (filterState.statusFilter && filterState.statusFilter !== "all") {
|
||||
params.set("status", filterState.statusFilter)
|
||||
}
|
||||
if (filterState.searchQuery) {
|
||||
params.set("search", filterState.searchQuery)
|
||||
}
|
||||
if (filterState.page && filterState.page > 1) {
|
||||
params.set("page", filterState.page.toString())
|
||||
}
|
||||
|
||||
const queryString = params.toString()
|
||||
const newUrl = queryString ? `${pathname}?${queryString}` : pathname
|
||||
|
||||
// Only update if URL would change
|
||||
const currentQuery = searchParams?.toString() || ""
|
||||
if (queryString !== currentQuery) {
|
||||
router.replace(newUrl, { scroll: false })
|
||||
}
|
||||
}, [filterState, syncWithUrl, pathname, router, searchParams])
|
||||
|
||||
// Convenience setters
|
||||
const setSearchQuery = useCallback((query: string) => {
|
||||
setFilterState(prev => ({ ...prev, searchQuery: query, page: 1 }))
|
||||
}, [])
|
||||
|
||||
const setStatusFilter = useCallback((status: string) => {
|
||||
setFilterState(prev => ({ ...prev, statusFilter: status, page: 1 }))
|
||||
}, [])
|
||||
|
||||
const setDateRange = useCallback((range: DateRange | undefined) => {
|
||||
setFilterState(prev => ({ ...prev, dateRange: range, page: 1 }))
|
||||
}, [])
|
||||
|
||||
const setPage = useCallback((page: number) => {
|
||||
setFilterState(prev => ({ ...prev, page }))
|
||||
}, [])
|
||||
|
||||
const setItemsPerPage = useCallback((count: number) => {
|
||||
setFilterState(prev => ({ ...prev, itemsPerPage: count, page: 1 }))
|
||||
}, [])
|
||||
|
||||
const setSort = useCallback((column: string, direction: "asc" | "desc") => {
|
||||
setFilterState(prev => ({ ...prev, sortColumn: column, sortDirection: direction }))
|
||||
}, [])
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setFilterState(defaults)
|
||||
}, [defaults])
|
||||
|
||||
const hasActiveFilters = Boolean(
|
||||
filterState.searchQuery ||
|
||||
(filterState.statusFilter && filterState.statusFilter !== "all") ||
|
||||
filterState.dateRange?.from
|
||||
)
|
||||
|
||||
return {
|
||||
...filterState,
|
||||
setFilterState,
|
||||
setSearchQuery,
|
||||
setStatusFilter,
|
||||
setDateRange,
|
||||
setPage,
|
||||
setItemsPerPage,
|
||||
setSort,
|
||||
clearFilters,
|
||||
hasActiveFilters
|
||||
}
|
||||
}
|
||||
195
hooks/useWidgetLayout.ts
Normal file
195
hooks/useWidgetLayout.ts
Normal file
@@ -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<string, any>
|
||||
}
|
||||
|
||||
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<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 updateWidgetColSpan = useCallback((widgetId: string, colSpan: number) => {
|
||||
setWidgets(prev =>
|
||||
prev.map(w => w.id === widgetId ? { ...w, colSpan } : 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,
|
||||
updateWidgetColSpan,
|
||||
getWidgetSettings,
|
||||
resetLayout,
|
||||
getVisibleWidgets,
|
||||
isWidgetVisible,
|
||||
isLoaded
|
||||
}
|
||||
}
|
||||
23
lib/api.ts
23
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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
296
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"commitHash": "064cd7a",
|
||||
"buildTime": "2026-01-12T08:43:31.133Z"
|
||||
"commitHash": "a6b7286",
|
||||
"buildTime": "2026-01-12T10:20:09.966Z"
|
||||
}
|
||||
Reference in New Issue
Block a user