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.
295 lines
12 KiB
TypeScript
295 lines
12 KiB
TypeScript
"use client"
|
|
|
|
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"
|
|
import type { OrderStatsData } from "@/lib/types"
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { ShoppingCart, RefreshCcw, ArrowRight } from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { useToast } from "@/components/ui/use-toast"
|
|
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
|
|
orderStats: OrderStatsData
|
|
}
|
|
|
|
interface TopProduct {
|
|
id: string;
|
|
name: string;
|
|
price: number | number[];
|
|
image: string;
|
|
count: number;
|
|
revenue: number;
|
|
}
|
|
|
|
export default function Content({ username, orderStats }: ContentProps) {
|
|
const [greeting, setGreeting] = useState("");
|
|
const [topProducts, setTopProducts] = useState<TopProduct[]>([]);
|
|
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: "..." });
|
|
|
|
useEffect(() => {
|
|
// Determine quote on client-side to avoid hydration mismatch
|
|
setRandomQuote(getRandomQuote());
|
|
}, []);
|
|
|
|
const fetchTopProducts = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
const data = await clientFetch('/orders/top-products');
|
|
setTopProducts(data);
|
|
} catch (err) {
|
|
console.error("Error fetching top products:", err);
|
|
setError(err instanceof Error ? err.message : "Failed to fetch top products");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
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();
|
|
}, []);
|
|
|
|
return (
|
|
<div className="space-y-10 pb-10">
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="flex flex-col md:flex-row md:items-end md:justify-between gap-4"
|
|
>
|
|
<div>
|
|
<h1 className="text-4xl font-bold tracking-tight text-foreground">
|
|
{greeting}, <span className="text-primary">{username}</span>!
|
|
</h1>
|
|
<p className="text-muted-foreground mt-2 text-lg">
|
|
"{randomQuote.text}" — <span className="font-medium">{randomQuote.author}</span>
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<EditDashboardButton
|
|
isEditMode={isEditMode}
|
|
onToggle={() => setIsEditMode(!isEditMode)}
|
|
/>
|
|
<WidgetSettings
|
|
widgets={widgets}
|
|
onToggle={toggleWidget}
|
|
onMove={moveWidget}
|
|
onReset={resetLayout}
|
|
onConfigure={(widget) => setConfiguredWidget(widget)}
|
|
/>
|
|
</div>
|
|
</motion.div>
|
|
|
|
<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;
|
|
|
|
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>
|
|
</DashboardEditor>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|
|
|