All checks were successful
Build Frontend / build (push) Successful in 1m10s
Refined color scheme in AnimatedStatsSection to use indigo instead of pink, and improved gradient backgrounds. In dashboard/content.tsx, updated TopProduct price to support arrays and display revenue per product. UnifiedNotifications received minor style and layout adjustments for better consistency and usability.
204 lines
8.0 KiB
TypeScript
204 lines
8.0 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 { 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"
|
|
|
|
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 [randomQuote, setRandomQuote] = useState(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);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
setGreeting(getGreeting());
|
|
fetchTopProducts();
|
|
}, []);
|
|
|
|
const handleRetry = () => {
|
|
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">
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Quick ActionsSection */}
|
|
<section className="space-y-4">
|
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground ml-1">Quick Actions</h2>
|
|
<QuickActions />
|
|
</section>
|
|
|
|
{/* Order Statistics */}
|
|
<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}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<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>
|
|
</div>
|
|
);
|
|
}
|
|
|