Spaces:
Running
Running
| "use client"; | |
| import { useEffect } from "react"; | |
| import { motion } from "framer-motion"; | |
| import { | |
| TrendingUp, TrendingDown, DollarSign, CreditCard, | |
| ArrowUpRight, ArrowDownRight, Sparkles, Shield, | |
| ChevronRight, Activity, AlertTriangle | |
| } from "lucide-react"; | |
| import { | |
| AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, | |
| ResponsiveContainer, PieChart, Pie, Cell | |
| } from "recharts"; | |
| import { useDashboardStore } from "@/lib/stores/dashboardStore"; | |
| import { useAuthStore } from "@/lib/stores/authStore"; | |
| import { useLanguageStore } from "@/lib/stores/languageStore"; | |
| import { useThemeStore } from "@/lib/stores/themeStore"; | |
| import Link from "next/link"; | |
| // ─── Category colors ────────────────────────────────────────────────────────── | |
| const CATEGORY_COLORS = [ | |
| "#3b82f6", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899", | |
| "#06b6d4", "#84cc16", "#f97316", "#6b7280", | |
| ]; | |
| // ─── Animation variants ─────────────────────────────────────────────────────── | |
| const containerVariants = { | |
| hidden: { opacity: 0 }, | |
| visible: { opacity: 1, transition: { staggerChildren: 0.08 } }, | |
| }; | |
| const itemVariants = { | |
| hidden: { opacity: 0, y: 20 }, | |
| visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: "easeOut" as const } }, | |
| }; | |
| // ─── Skeleton loader ────────────────────────────────────────────────────────── | |
| function Skeleton({ className }: { className?: string }) { | |
| return <div className={`shimmer rounded-lg ${className}`} />; | |
| } | |
| // ─── Custom Tooltip ─────────────────────────────────────────────────────────── | |
| interface TooltipProps { | |
| active?: boolean; | |
| payload?: Array<{ name: string; value: number; color?: string }>; | |
| label?: string; | |
| } | |
| function CustomTooltip({ active, payload, label }: TooltipProps) { | |
| if (!active || !payload?.length) return null; | |
| return ( | |
| <div className="glass-card p-3 text-xs space-y-1 min-w-[140px]"> | |
| <p className="text-adaptive-muted font-medium mb-2">{label}</p> | |
| {payload.map((entry) => ( | |
| <div key={entry.name} className="flex justify-between gap-4"> | |
| <span style={{ color: entry.color }}>{entry.name}</span> | |
| <span className="text-adaptive font-semibold">${entry.value.toLocaleString()}</span> | |
| </div> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| // ─── Stat Card ──────────────────────────────────────────────────────────────── | |
| interface StatCardProps { | |
| title: string; | |
| value: string; | |
| change?: string; | |
| positive?: boolean; | |
| icon: React.ElementType; | |
| gradient: string; | |
| lightGradient: string; | |
| iconColor: string; | |
| borderColor: string; | |
| lightBorderColor: string; | |
| loading?: boolean; | |
| isLight: boolean; | |
| } | |
| function StatCard({ | |
| title, value, change, positive, icon: Icon, | |
| gradient, lightGradient, iconColor, borderColor, lightBorderColor, loading, isLight | |
| }: StatCardProps) { | |
| return ( | |
| <motion.div | |
| variants={itemVariants} | |
| whileHover={{ scale: 1.02, y: -2 }} | |
| className={`relative overflow-hidden rounded-2xl border backdrop-blur-xl p-6 cursor-pointer group ${ | |
| isLight | |
| ? `bg-gradient-to-br ${lightGradient} ${lightBorderColor}` | |
| : `bg-gradient-to-br ${gradient} ${borderColor}` | |
| }`} | |
| > | |
| <div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-white/[0.03]" /> | |
| <div className="flex items-start justify-between"> | |
| <div> | |
| <p className="text-xs font-medium uppercase tracking-wider" style={{ color: "var(--fg-subtle)" }}> | |
| {title} | |
| </p> | |
| {loading ? ( | |
| <Skeleton className="mt-2 h-8 w-28" /> | |
| ) : ( | |
| <p className="mt-2 text-2xl font-bold tracking-tight" style={{ color: "var(--fg)" }}> | |
| {value} | |
| </p> | |
| )} | |
| </div> | |
| <div | |
| className="flex h-10 w-10 items-center justify-center rounded-xl border" | |
| style={{ background: "var(--card-bg)", borderColor: "var(--border-strong)" }} | |
| > | |
| <Icon className={`h-5 w-5 ${iconColor}`} /> | |
| </div> | |
| </div> | |
| {change && !loading && ( | |
| <div className="mt-4 flex items-center gap-1.5"> | |
| {positive ? ( | |
| <ArrowUpRight className="h-3.5 w-3.5 text-emerald-500" /> | |
| ) : ( | |
| <ArrowDownRight className="h-3.5 w-3.5 text-red-500" /> | |
| )} | |
| <span className={`text-xs font-semibold ${positive ? "text-emerald-500" : "text-red-500"}`}> | |
| {change} | |
| </span> | |
| <span className="text-xs" style={{ color: "var(--fg-subtle)" }}>vs last month</span> | |
| </div> | |
| )} | |
| </motion.div> | |
| ); | |
| } | |
| // ─── Main Page ──────────────────────────────────────────────────────────────── | |
| export default function Home() { | |
| const { data, isLoading, error, fetch } = useDashboardStore(); | |
| const { user } = useAuthStore(); | |
| const { t } = useLanguageStore(); | |
| const { theme } = useThemeStore(); | |
| const isLight = theme === "light"; | |
| // Recharts axis tick color | |
| const tickColor = isLight ? "#64748b" : "#71717a"; | |
| const gridColor = isLight ? "rgba(15,23,42,0.07)" : "rgba(255,255,255,0.05)"; | |
| useEffect(() => { | |
| fetch(); | |
| }, [fetch]); | |
| const firstName = user?.name?.split(" ")[0] || "there"; | |
| const greeting = () => { | |
| const h = new Date().getHours(); | |
| if (h < 12) return t("good_morning"); | |
| if (h < 17) return t("good_afternoon"); | |
| return t("good_evening"); | |
| }; | |
| const briefingText = data?.ai_briefing | |
| ? (typeof data.ai_briefing === "string" | |
| ? data.ai_briefing | |
| : data.ai_briefing.summary || data.ai_briefing.briefing || "Your financial health looks good today.") | |
| : null; | |
| const categoryData = (data?.spending_by_category || []).slice(0, 6).map((c, i) => ({ | |
| ...c, | |
| color: CATEGORY_COLORS[i % CATEGORY_COLORS.length], | |
| })); | |
| const totalCategorySpend = categoryData.reduce((s, c) => s + c.value, 0); | |
| return ( | |
| <motion.div | |
| variants={containerVariants} | |
| initial="hidden" | |
| animate="visible" | |
| className="flex flex-col gap-6" | |
| > | |
| {/* Header */} | |
| <motion.div variants={itemVariants}> | |
| <h1 className="text-3xl font-bold tracking-tight" style={{ color: "var(--fg)" }}> | |
| {greeting()}, <span className="gradient-text">{firstName}</span> 👋 | |
| </h1> | |
| <p className="mt-1 text-sm" style={{ color: "var(--fg-muted)" }}> | |
| {t("financial_overview")} | |
| </p> | |
| </motion.div> | |
| {/* Error banner */} | |
| {error && ( | |
| <motion.div | |
| variants={itemVariants} | |
| className={`flex items-center gap-3 rounded-2xl border px-5 py-3 ${ | |
| isLight | |
| ? "border-amber-300/60 bg-amber-50" | |
| : "border-amber-500/20 bg-amber-500/5" | |
| }`} | |
| > | |
| <AlertTriangle className="h-4 w-4 text-amber-500 flex-shrink-0" /> | |
| <p className="text-xs" style={{ color: "var(--fg-muted)" }}> | |
| <span className="text-amber-500 font-medium">{t("backend_offline")}</span> — {t("showing_cached")}{" "} | |
| <button | |
| onClick={() => fetch(undefined, true)} | |
| className="underline hover:text-emerald-500 transition-colors" | |
| > | |
| {t("retry")} | |
| </button> | |
| </p> | |
| </motion.div> | |
| )} | |
| {/* AI Briefing */} | |
| <motion.div | |
| variants={itemVariants} | |
| className={`relative overflow-hidden rounded-2xl border p-4 ${ | |
| isLight | |
| ? "border-emerald-300/50 bg-gradient-to-r from-emerald-50 via-cyan-50/50 to-blue-50" | |
| : "border-emerald-500/20 bg-gradient-to-r from-emerald-500/10 via-cyan-500/5 to-blue-500/10" | |
| }`} | |
| > | |
| <div className="relative flex items-center gap-3"> | |
| <div className={`flex h-9 w-9 items-center justify-center rounded-xl border flex-shrink-0 ${ | |
| isLight ? "bg-emerald-100 border-emerald-200" : "bg-emerald-500/20 border-emerald-500/30" | |
| }`}> | |
| <Sparkles className="h-4 w-4 text-emerald-500" /> | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| {isLoading ? ( | |
| <> | |
| <Skeleton className="h-4 w-64 mb-1" /> | |
| <Skeleton className="h-3 w-48" /> | |
| </> | |
| ) : ( | |
| <> | |
| <p className="text-sm font-medium" style={{ color: "var(--fg)" }}> | |
| {t("ai_daily_briefing")}{" "} | |
| <span className="text-emerald-500"> | |
| {briefingText?.slice(0, 100) || "Your financial health looks good today."} | |
| </span> | |
| </p> | |
| <p className="text-xs mt-0.5" style={{ color: "var(--fg-muted)" }}> | |
| {t("health_score")} {data?.health_score ?? "—"}/100 | |
| {data?.fraud_alert_count | |
| ? ` · ${data.fraud_alert_count} fraud alert(s)` | |
| : ` · ${t("no_fraud_alerts")}`} | |
| </p> | |
| </> | |
| )} | |
| </div> | |
| <Link href="/chat" className="flex items-center gap-1 text-xs text-emerald-500 hover:text-emerald-400 transition-colors whitespace-nowrap"> | |
| Ask AI <ChevronRight className="h-3 w-3" /> | |
| </Link> | |
| </div> | |
| </motion.div> | |
| {/* Stat Cards */} | |
| <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> | |
| <StatCard | |
| title={t("total_balance")} | |
| value={data ? `$${data.total_balance.toLocaleString("en-US", { minimumFractionDigits: 2 })}` : "$—"} | |
| icon={DollarSign} | |
| gradient="from-blue-500/20 to-cyan-500/20" | |
| lightGradient="from-blue-50 to-cyan-50" | |
| iconColor="text-blue-500" | |
| borderColor="border-blue-500/20" | |
| lightBorderColor="border-blue-200/70" | |
| loading={isLoading} | |
| isLight={isLight} | |
| /> | |
| <StatCard | |
| title={t("monthly_income")} | |
| value={data ? `$${data.monthly_income.toLocaleString("en-US", { minimumFractionDigits: 2 })}` : "$—"} | |
| icon={TrendingUp} | |
| gradient="from-emerald-500/20 to-teal-500/20" | |
| lightGradient="from-emerald-50 to-teal-50" | |
| iconColor="text-emerald-500" | |
| borderColor="border-emerald-500/20" | |
| lightBorderColor="border-emerald-200/70" | |
| loading={isLoading} | |
| isLight={isLight} | |
| /> | |
| <StatCard | |
| title={t("monthly_spend")} | |
| value={data ? `$${data.monthly_expenses.toLocaleString("en-US", { minimumFractionDigits: 2 })}` : "$—"} | |
| icon={CreditCard} | |
| gradient="from-orange-500/20 to-amber-500/20" | |
| lightGradient="from-orange-50 to-amber-50" | |
| iconColor="text-orange-500" | |
| borderColor="border-orange-500/20" | |
| lightBorderColor="border-orange-200/70" | |
| loading={isLoading} | |
| isLight={isLight} | |
| /> | |
| <StatCard | |
| title={t("savings_rate")} | |
| value={data ? `${data.savings_rate}%` : "—%"} | |
| change={data ? `${data.savings_rate > 20 ? "+" : ""}${(data.savings_rate - 20).toFixed(1)}% vs target` : undefined} | |
| positive={data ? data.savings_rate >= 20 : true} | |
| icon={Activity} | |
| gradient="from-purple-500/20 to-violet-500/20" | |
| lightGradient="from-purple-50 to-violet-50" | |
| iconColor="text-purple-500" | |
| borderColor="border-purple-500/20" | |
| lightBorderColor="border-purple-200/70" | |
| loading={isLoading} | |
| isLight={isLight} | |
| /> | |
| </div> | |
| {/* Charts Row */} | |
| <div className="grid gap-6 lg:grid-cols-7"> | |
| {/* Cash Flow Chart */} | |
| <motion.div variants={itemVariants} className="col-span-4 glass-card p-6"> | |
| <div className="flex items-center justify-between mb-6"> | |
| <div> | |
| <h2 className="text-base font-semibold" style={{ color: "var(--fg)" }}>{t("cash_flow")}</h2> | |
| <p className="text-xs mt-0.5" style={{ color: "var(--fg-muted)" }}>{t("income_vs_expenses")}</p> | |
| </div> | |
| <div className="flex gap-4 text-xs"> | |
| {[ | |
| { label: t("income"), color: "#10b981" }, | |
| { label: t("expenses"), color: "#f59e0b" }, | |
| { label: t("savings"), color: "#3b82f6" }, | |
| ].map((l) => ( | |
| <div key={l.label} className="flex items-center gap-1.5"> | |
| <div className="h-2 w-2 rounded-full" style={{ background: l.color }} /> | |
| <span style={{ color: "var(--fg-muted)" }}>{l.label}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| {isLoading ? ( | |
| <Skeleton className="h-[260px] w-full" /> | |
| ) : ( | |
| <ResponsiveContainer width="100%" height={260}> | |
| <AreaChart data={data?.cash_flow || []} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}> | |
| <defs> | |
| {[ | |
| { id: "incomeGrad", color: "#10b981" }, | |
| { id: "expenseGrad", color: "#f59e0b" }, | |
| { id: "savingsGrad", color: "#3b82f6" }, | |
| ].map(({ id, color }) => ( | |
| <linearGradient key={id} id={id} x1="0" y1="0" x2="0" y2="1"> | |
| <stop offset="5%" stopColor={color} stopOpacity={isLight ? 0.15 : 0.3} /> | |
| <stop offset="95%" stopColor={color} stopOpacity={0} /> | |
| </linearGradient> | |
| ))} | |
| </defs> | |
| <CartesianGrid strokeDasharray="3 3" stroke={gridColor} /> | |
| <XAxis dataKey="month" tick={{ fill: tickColor, fontSize: 11 }} axisLine={false} tickLine={false} /> | |
| <YAxis tick={{ fill: tickColor, fontSize: 11 }} axisLine={false} tickLine={false} /> | |
| <Tooltip content={<CustomTooltip />} /> | |
| <Area type="monotone" dataKey="income" stroke="#10b981" strokeWidth={2} fill="url(#incomeGrad)" /> | |
| <Area type="monotone" dataKey="expenses" stroke="#f59e0b" strokeWidth={2} fill="url(#expenseGrad)" /> | |
| <Area type="monotone" dataKey="savings" stroke="#3b82f6" strokeWidth={2} fill="url(#savingsGrad)" /> | |
| </AreaChart> | |
| </ResponsiveContainer> | |
| )} | |
| </motion.div> | |
| {/* Spending Breakdown */} | |
| <motion.div variants={itemVariants} className="col-span-3 glass-card p-6"> | |
| <div className="mb-4"> | |
| <h2 className="text-base font-semibold" style={{ color: "var(--fg)" }}>{t("spending_breakdown")}</h2> | |
| <p className="text-xs mt-0.5" style={{ color: "var(--fg-muted)" }}>{t("this_month_by_category")}</p> | |
| </div> | |
| {isLoading ? ( | |
| <Skeleton className="h-[160px] w-full mb-4" /> | |
| ) : ( | |
| <div className="flex items-center justify-center"> | |
| <ResponsiveContainer width="100%" height={160}> | |
| <PieChart> | |
| <Pie | |
| data={categoryData} | |
| cx="50%" cy="50%" | |
| innerRadius={50} outerRadius={75} | |
| paddingAngle={3} dataKey="value" | |
| > | |
| {categoryData.map((entry, index) => ( | |
| <Cell key={index} fill={entry.color} stroke="transparent" /> | |
| ))} | |
| </Pie> | |
| <Tooltip | |
| content={({ active, payload }) => | |
| active && payload?.length ? ( | |
| <div className="glass-card p-2 text-xs"> | |
| <span style={{ color: payload[0].payload.color }}>{payload[0].name}</span> | |
| <span className="ml-2 font-bold" style={{ color: "var(--fg)" }}> | |
| ${(payload[0].value as number).toLocaleString()} | |
| </span> | |
| </div> | |
| ) : null | |
| } | |
| /> | |
| </PieChart> | |
| </ResponsiveContainer> | |
| </div> | |
| )} | |
| <div className="mt-2 space-y-2"> | |
| {isLoading | |
| ? [1, 2, 3, 4].map((i) => <Skeleton key={i} className="h-5 w-full" />) | |
| : categoryData.map((cat) => ( | |
| <div key={cat.name} className="flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <div className="h-2 w-2 rounded-full flex-shrink-0" style={{ background: cat.color }} /> | |
| <span className="text-xs" style={{ color: "var(--fg-muted)" }}>{cat.name}</span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <div | |
| className="h-1 w-16 rounded-full overflow-hidden" | |
| style={{ background: "var(--border)" }} | |
| > | |
| <div | |
| className="h-full rounded-full" | |
| style={{ | |
| width: `${totalCategorySpend > 0 ? (cat.value / totalCategorySpend) * 100 : 0}%`, | |
| background: cat.color, | |
| }} | |
| /> | |
| </div> | |
| <span className="text-xs font-medium w-16 text-right" style={{ color: "var(--fg)" }}> | |
| ${cat.value.toLocaleString()} | |
| </span> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </motion.div> | |
| </div> | |
| {/* Recent Transactions */} | |
| <motion.div variants={itemVariants} className="glass-card p-6"> | |
| <div className="flex items-center justify-between mb-5"> | |
| <div> | |
| <h2 className="text-base font-semibold" style={{ color: "var(--fg)" }}>{t("recent_transactions")}</h2> | |
| <p className="text-xs mt-0.5" style={{ color: "var(--fg-muted)" }}>{t("latest_activity")}</p> | |
| </div> | |
| <Link href="/transactions" className="flex items-center gap-1 text-xs text-emerald-500 hover:text-emerald-400 transition-colors"> | |
| {t("view_all")} <ChevronRight className="h-3 w-3" /> | |
| </Link> | |
| </div> | |
| <div className="space-y-1"> | |
| {isLoading | |
| ? [1, 2, 3, 4, 5].map((i) => <Skeleton key={i} className="h-14 w-full rounded-xl" />) | |
| : (data?.recent_transactions || []).map((tx, i) => ( | |
| <motion.div | |
| key={tx.id} | |
| initial={{ opacity: 0, x: -10 }} | |
| animate={{ opacity: 1, x: 0 }} | |
| transition={{ delay: i * 0.05 }} | |
| whileHover={{ backgroundColor: isLight ? "rgba(15,23,42,0.03)" : "rgba(255,255,255,0.03)" }} | |
| className="flex items-center justify-between rounded-xl px-3 py-3 transition-colors cursor-pointer" | |
| > | |
| <div className="flex items-center gap-3"> | |
| <div | |
| className="flex h-9 w-9 items-center justify-center rounded-xl border text-sm font-bold" | |
| style={{ | |
| background: "var(--card-bg)", | |
| borderColor: "var(--border-strong)", | |
| color: "var(--fg-muted)", | |
| }} | |
| > | |
| {tx.merchant.charAt(0).toUpperCase()} | |
| </div> | |
| <div> | |
| <p className="text-sm font-medium" style={{ color: "var(--fg)" }}>{tx.merchant}</p> | |
| <p className="text-xs" style={{ color: "var(--fg-subtle)" }}> | |
| {tx.category} · {tx.timestamp ? new Date(tx.timestamp).toLocaleDateString() : ""} | |
| </p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <span className={`text-sm font-semibold ${tx.amount > 0 ? "text-emerald-500" : ""}`} | |
| style={tx.amount <= 0 ? { color: "var(--fg)" } : undefined} | |
| > | |
| {tx.amount > 0 ? "+" : ""}${Math.abs(tx.amount).toFixed(2)} | |
| </span> | |
| {tx.amount > 0 ? ( | |
| <TrendingUp className="h-3.5 w-3.5 text-emerald-500" /> | |
| ) : ( | |
| <TrendingDown className="h-3.5 w-3.5" style={{ color: "var(--fg-subtle)" }} /> | |
| )} | |
| </div> | |
| </motion.div> | |
| ))} | |
| </div> | |
| </motion.div> | |
| {/* Fraud Shield */} | |
| <motion.div | |
| variants={itemVariants} | |
| className={`flex items-center gap-3 rounded-2xl border px-5 py-3 ${ | |
| isLight | |
| ? "border-emerald-300/60 bg-emerald-50" | |
| : "border-emerald-500/20 bg-emerald-500/5" | |
| }`} | |
| > | |
| <Shield className="h-4 w-4 text-emerald-500 flex-shrink-0" /> | |
| <p className="text-xs" style={{ color: "var(--fg-muted)" }}> | |
| <span className="text-emerald-500 font-medium">{t("fraud_shield_active")}</span> | |
| {data?.fraud_alert_count | |
| ? ` — ${data.fraud_alert_count} ${t("alerts_require_attention")}` | |
| : ` — ${t("all_monitored")}`} | |
| </p> | |
| {data?.fraud_alert_count ? ( | |
| <Link href="/security" className="ml-auto text-xs text-red-500 hover:text-red-400 transition-colors whitespace-nowrap"> | |
| {t("review")} <ChevronRight className="h-3 w-3 inline" /> | |
| </Link> | |
| ) : null} | |
| </motion.div> | |
| </motion.div> | |
| ); | |
| } |