Some checks failed
Build Frontend / build (push) Failing after 7s
Eliminated the ability to resize dashboard widgets by removing colSpan from WidgetConfig, related UI, and logic. Removed edit mode functionality and the EditDashboardButton, simplifying the dashboard layout and widget management. Updated drag-and-drop strategy to vertical list and incremented the storage key version.
283 lines
11 KiB
TypeScript
283 lines
11 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 } 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 } = useWidgetLayout();
|
|
const [configuredWidget, setConfiguredWidget] = useState<WidgetConfig | null>(null);
|
|
|
|
// 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">
|
|
<WidgetSettings
|
|
widgets={widgets}
|
|
onToggle={toggleWidget}
|
|
onMove={moveWidget}
|
|
onReset={resetLayout}
|
|
onConfigure={(widget) => setConfiguredWidget(widget)}
|
|
/>
|
|
</div>
|
|
</motion.div>
|
|
|
|
<DashboardEditor
|
|
widgets={widgets}
|
|
isEditMode={false}
|
|
onToggleEditMode={() => { }}
|
|
onReorder={reorderWidgets}
|
|
onReset={resetLayout}
|
|
>
|
|
<div className="space-y-10">
|
|
{widgets.map((widget) => {
|
|
if (!widget.visible) return null;
|
|
|
|
return (
|
|
<DraggableWidget
|
|
key={widget.id}
|
|
widget={widget}
|
|
isEditMode={false}
|
|
onConfigure={() => setConfiguredWidget(widget)}
|
|
onToggleVisibility={() => toggleWidget(widget.id)}
|
|
>
|
|
{renderWidget(widget)}
|
|
</DraggableWidget>
|
|
);
|
|
})}
|
|
</div>
|
|
</DashboardEditor>
|
|
|
|
{/* Widget Settings Modal */}
|
|
<WidgetSettingsModal
|
|
widget={configuredWidget}
|
|
open={!!configuredWidget}
|
|
onOpenChange={(open) => !open && setConfiguredWidget(null)}
|
|
onSave={(widgetId, settings) => {
|
|
updateWidgetSettings(widgetId, settings);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|