"use client"; import { ArrowDownLeftIcon, CheckIcon, CreditCardIcon, GoalIcon, HomeIcon, LayoutGridIcon, type LucideIcon, MenuIcon, PiggyBankIcon, ReceiptTextIcon, RepeatIcon, SearchIcon, ShoppingBagIcon, ShoppingBasketIcon, TicketIcon, TramFrontIcon, UtensilsIcon, WalletIcon, XIcon, } from "lucide-react"; import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, } from "motion/react"; import { type PointerEvent, type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; export type MoneyCategory = "groceries" | "dining" | "transport" | "shopping" | "home" | "fun" | "income"; export interface MoneyAccount { id: string; name: string; kind: "checking" | "savings" | "credit"; mask: string; balance: number; } export interface MoneyTransaction { id: string; merchant: string; category: MoneyCategory; amount: number; accountId: string; date: string; time: string; } export interface MoneyBill { id: string; name: string; amount: number; due: string; category: MoneyCategory; autopay?: boolean; } export interface MoneyBudget { category: MoneyCategory; limit: number; } type MoneyView = "overview" | "transactions" | "bills" | "budgets"; export interface MoneyProps { className?: string; accounts?: MoneyAccount[]; transactions?: MoneyTransaction[]; bills?: MoneyBill[]; budgets?: MoneyBudget[]; today?: string; userName?: string; productName?: string; } const spring = { type: "spring", stiffness: 520, damping: 46, mass: 1 } as const; const roll = { type: "spring", stiffness: 210, damping: 30, mass: 1 } as const; const easeOut = [0.23, 1, 0.32, 1] as const; const press = "transition-[color,background-color,box-shadow,scale,opacity] duration-150 ease-out active:scale-[0.97]"; const card = "rounded-2xl border bg-card shadow-xs/5"; const categories: Record< MoneyCategory, { label: string; icon: LucideIcon; tile: string; dot: string; stroke: string } > = { groceries: { label: "Groceries", icon: ShoppingBasketIcon, tile: "bg-teal-500/12 text-teal-700 dark:bg-teal-400/14 dark:text-teal-300", dot: "bg-teal-500 dark:bg-teal-400", stroke: "stroke-teal-500 dark:stroke-teal-400", }, dining: { label: "Eating out", icon: UtensilsIcon, tile: "bg-orange-500/12 text-orange-700 dark:bg-orange-400/14 dark:text-orange-300", dot: "bg-orange-500 dark:bg-orange-400", stroke: "stroke-orange-500 dark:stroke-orange-400", }, transport: { label: "Transport", icon: TramFrontIcon, tile: "bg-sky-500/12 text-sky-700 dark:bg-sky-400/14 dark:text-sky-300", dot: "bg-sky-500 dark:bg-sky-400", stroke: "stroke-sky-500 dark:stroke-sky-400", }, shopping: { label: "Shopping", icon: ShoppingBagIcon, tile: "bg-violet-500/12 text-violet-700 dark:bg-violet-400/14 dark:text-violet-300", dot: "bg-violet-500 dark:bg-violet-400", stroke: "stroke-violet-500 dark:stroke-violet-400", }, home: { label: "Home & bills", icon: HomeIcon, tile: "bg-amber-500/14 text-amber-700 dark:bg-amber-400/14 dark:text-amber-300", dot: "bg-amber-500 dark:bg-amber-400", stroke: "stroke-amber-500 dark:stroke-amber-400", }, fun: { label: "Going out", icon: TicketIcon, tile: "bg-rose-500/12 text-rose-700 dark:bg-rose-400/14 dark:text-rose-300", dot: "bg-rose-500 dark:bg-rose-400", stroke: "stroke-rose-500 dark:stroke-rose-400", }, income: { label: "Income", icon: ArrowDownLeftIcon, tile: "bg-success/12 text-success-foreground dark:bg-success/16", dot: "bg-success", stroke: "stroke-success", }, }; const accountIcons: Record = { checking: WalletIcon, savings: PiggyBankIcon, credit: CreditCardIcon, }; const defaultAccounts: MoneyAccount[] = [ { id: "everyday", name: "Everyday", kind: "checking", mask: "4021", balance: 4218.4 }, { id: "savings", name: "Rainy day", kind: "savings", mask: "7730", balance: 18650 }, { id: "credit", name: "Credit card", kind: "credit", mask: "0915", balance: -1342.18 }, ]; const t = ( id: string, merchant: string, category: MoneyCategory, amount: number, date: string, time: string, accountId = "everyday", ): MoneyTransaction => ({ id, merchant, category, amount, date, time, accountId }); const defaultTransactions: MoneyTransaction[] = [ t("t1", "Ridely", "transport", -14.2, "2026-10-24", "6:05 pm", "credit"), t("t2", "Greenleaf Market", "groceries", -64.18, "2026-10-24", "12:40 pm"), t("t3", "Nimbus Coffee", "dining", -6.4, "2026-10-24", "8:12 am"), t("t4", "Pho Saigon Kitchen", "dining", -38.5, "2026-10-23", "7:48 pm", "credit"), t("t5", "Atlas Books", "shopping", -27.99, "2026-10-23", "1:15 pm"), t("t6", "Metro Transit", "transport", -2.9, "2026-10-23", "8:31 am"), t("t7", "Brightline Studio", "income", 3240, "2026-10-22", "9:00 am"), t("t8", "Fieldstone Grocers", "groceries", -41.72, "2026-10-22", "5:52 pm"), t("t9", "Cinema Lumière", "fun", -26, "2026-10-22", "8:20 pm", "credit"), t("t10", "Marlowe's Diner", "dining", -41.3, "2026-10-21", "12:30 pm"), t("t11", "Harbor Hardware", "home", -48.35, "2026-10-20", "11:04 am"), t("t12", "Sunday Supper Club", "dining", -92, "2026-10-19", "8:45 pm", "credit"), t("t13", "Nimbus Coffee", "dining", -5.8, "2026-10-19", "9:10 am"), t("t14", "Greenleaf Market", "groceries", -88.04, "2026-10-18", "10:22 am"), t("t15", "Spinwell Cycles", "transport", -34, "2026-10-18", "3:40 pm"), t("t16", "Pinball Parlour", "fun", -18, "2026-10-18", "9:15 pm"), t("t17", "Greenleaf Market", "groceries", -72.3, "2026-10-15", "6:02 pm"), t("t18", "Tidewater Ramen", "dining", -44.1, "2026-10-15", "7:30 pm", "credit"), t("t19", "Fieldstone Grocers", "groceries", -59.66, "2026-10-12", "11:48 am"), t("t20", "Corner Bakehouse", "dining", -12.75, "2026-10-12", "8:55 am"), t("t21", "Kiln & Co.", "shopping", -64, "2026-10-12", "2:10 pm", "credit"), t("t22", "Brightline Studio", "income", 3240, "2026-10-08", "9:00 am"), t("t23", "Greenleaf Market", "groceries", -66.4, "2026-10-08", "5:35 pm"), t("t24", "Velvet Room", "dining", -58.2, "2026-10-08", "10:40 pm", "credit"), t("t25", "Ticketpost", "fun", -68, "2026-10-07", "1:02 pm", "credit"), t("t26", "Northgate Pharmacy", "shopping", -18.45, "2026-10-05", "4:18 pm"), t("t27", "Metro Transit", "transport", -12.9, "2026-10-05", "7:58 am"), t("t28", "Corner Bakehouse", "dining", -9.6, "2026-10-05", "9:20 am"), t("t29", "Fieldstone Grocers", "groceries", -39.85, "2026-10-02", "6:44 pm"), t("t30", "Habitat Living", "home", -1450, "2026-10-01", "7:00 am"), ]; const defaultBills: MoneyBill[] = [ { id: "b1", name: "Northwind Energy", amount: 86.4, due: "2026-10-26", category: "home", autopay: true }, { id: "b2", name: "Fernway Fiber", amount: 55, due: "2026-10-28", category: "home" }, { id: "b3", name: "Reelhouse", amount: 12.99, due: "2026-10-30", category: "fun", autopay: true }, { id: "b4", name: "Habitat Living", amount: 1450, due: "2026-11-01", category: "home" }, { id: "b5", name: "Ironbridge Insurance", amount: 38.2, due: "2026-11-04", category: "home" }, ]; const defaultBudgets: MoneyBudget[] = [ { category: "groceries", limit: 600 }, { category: "dining", limit: 300 }, { category: "transport", limit: 150 }, { category: "fun", limit: 200 }, ]; const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); const usd0 = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }); const money = (n: number) => usd.format(n); const money0 = (n: number) => usd0.format(Math.round(n)); function parseDate(iso: string) { return new Date(`${iso}T12:00:00`); } function daysBetween(a: string, b: string) { return Math.round((parseDate(b).getTime() - parseDate(a).getTime()) / 86_400_000); } function initials(name: string) { const words = name.replace(/[^A-Za-zÀ-ÿ\s]/g, "").split(/\s+/).filter(Boolean); return (words.length > 1 ? words[0][0] + words[1][0] : (words[0] ?? "?").slice(0, 2)).toUpperCase(); } function seededNoise(seed: number) { const x = Math.sin(seed * 12.9898) * 43758.5453; return x - Math.floor(x); } function history(account: MoneyAccount, end: number, days = 30) { const seed = account.id.split("").reduce((s, c) => s + c.charCodeAt(0), 0); const out = new Array(days); let v = end; out[days - 1] = v; for (let i = days - 2; i >= 0; i--) { const daysAgo = days - 1 - i; let step: number; if (account.kind === "savings") step = (daysAgo === 15 ? 1000 : daysAgo === 1 ? 600 : 0) + 1 + seededNoise(seed + i) * 2; else if (account.kind === "credit") step = -(seededNoise(seed + i) * 70) + (daysAgo === 18 ? -900 : 0); else { step = -(seededNoise(seed + i) * 90 + 15); if (daysAgo === 2 || daysAgo === 16) step += 3240; if (daysAgo === 23) step -= 1450; if (daysAgo === 15) step -= 1000; if (daysAgo === 1) step -= 600; } v -= step; out[i] = v; } return out; } function Odometer({ value, format }: { value: number; format: (n: number) => string }) { const text = format(value); const chars = text.split(""); return ( {text} {chars.map((ch, i) => { const key = chars.length - i; return /\d/.test(ch) ? ( ) : ( {ch} ); })} ); } function Digit({ digit }: { digit: number }) { const reduce = useReducedMotion(); return ( 0 {Array.from({ length: 10 }, (_, n) => ( {n} ))} ); } function Tween({ value, format = money }: { value: number; format?: (n: number) => string }) { const reduce = useReducedMotion(); const mv = useMotionValue(value); const text = useTransform(mv, format); useEffect(() => { if (reduce) { mv.set(value); return; } const controls = animate(mv, value, { type: "spring", stiffness: 160, damping: 26 }); return () => controls.stop(); }, [value, reduce, mv]); return {text}; } function MerchantTile({ merchant, category }: { merchant: string; category: MoneyCategory }) { const c = categories[category]; const Icon = c.icon; return ( {initials(merchant)} ); } export function Money({ className, accounts: initialAccounts = defaultAccounts, transactions: initialTransactions = defaultTransactions, bills = defaultBills, budgets = defaultBudgets, today = "2026-10-24", userName = "Ines Moreau", productName = "Tally", }: MoneyProps) { const [accounts, setAccounts] = useState(initialAccounts); const [transactions, setTransactions] = useState(initialTransactions); const [accountId, setAccountId] = useState("all"); const [focusCat, setFocusCat] = useState(null); const [filterCat, setFilterCat] = useState(null); const [query, setQuery] = useState(""); const [billState, setBillState] = useState>({}); const [drawer, setDrawer] = useState(false); const [view, setView] = useState("overview"); const hasSwitchedView = useRef(false); const reduce = useReducedMotion(); const uid = useId().replace(/:/g, ""); const timers = useRef[]>([]); const drawerRef = useRef(null); const listRef = useRef(null); const scrollRef = useRef(null); useEffect(() => () => timers.current.forEach(clearTimeout), []); useEffect(() => { if (drawer) drawerRef.current?.focus(); }, [drawer]); const month = today.slice(0, 7); const monthName = parseDate(today).toLocaleDateString("en-US", { month: "long" }); const monthEnd = new Date(parseDate(today).getFullYear(), parseDate(today).getMonth() + 1, 0).getDate(); const daysLeft = monthEnd - parseDate(today).getDate(); const firstName = userName.split(" ")[0]; const total = accounts.reduce((s, a) => s + a.balance, 0); const selected = accounts.find((a) => a.id === accountId); const shown = selected ? selected.balance : total; const series = useMemo(() => { const per = accounts.map((a) => { const start = initialAccounts.find((b) => b.id === a.id) ?? a; const h = history(start, start.balance); h[h.length - 1] = a.balance; return h; }); if (selected) return per[accounts.indexOf(selected)]; return per[0].map((_, i) => per.reduce((s, p) => s + p[i], 0)); }, [accounts, selected, initialAccounts]); const since = shown - series[0]; const monthTx = transactions.filter((x) => x.date.startsWith(month)); const spendBy = (cat: MoneyCategory) => monthTx.filter((x) => x.category === cat && x.amount < 0).reduce((s, x) => s - x.amount, 0); const spendCats = (Object.keys(categories) as MoneyCategory[]) .filter((c) => c !== "income") .map((c) => ({ cat: c, value: spendBy(c) })) .filter((s) => s.value > 0) .sort((a, b) => b.value - a.value); const spent = spendCats.reduce((s, c) => s + c.value, 0); const income = monthTx.filter((x) => x.amount > 0).reduce((s, x) => s + x.amount, 0); const openBills = bills.filter((b) => billState[b.id] !== "paid"); const dueTotal = openBills.reduce((s, b) => s + b.amount, 0); const payBill = (bill: MoneyBill) => { if (billState[bill.id]) return; setBillState((s) => ({ ...s, [bill.id]: "paying" })); timers.current.push( setTimeout(() => { setBillState((s) => ({ ...s, [bill.id]: "paid" })); setAccounts((all) => all.map((a) => (a.kind === "checking" ? { ...a, balance: a.balance - bill.amount } : a))); const from = accounts.find((a) => a.kind === "checking")?.id ?? accounts[0]?.id ?? ""; setTransactions((all) => [ { id: `pay-${bill.id}`, merchant: bill.name, category: bill.category, amount: -bill.amount, accountId: from, date: today, time: "Just now", }, ...all, ]); }, 700), ); }; const q = query.trim().toLowerCase(); const visibleTx = transactions.filter( (x) => (!filterCat || x.category === filterCat) && (!selected || x.accountId === selected.id) && (!q || x.merchant.toLowerCase().includes(q) || categories[x.category].label.toLowerCase().includes(q)), ); const groups: { date: string; items: MoneyTransaction[] }[] = []; for (const x of visibleTx) { const g = groups.at(-1); if (g && g.date === x.date) g.items.push(x); else groups.push({ date: x.date, items: [x] }); } const dayLabel = (iso: string) => { const d = daysBetween(iso, today); if (d === 0) return "Today"; if (d === 1) return "Yesterday"; return parseDate(iso).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }); }; const scrollTo = (el: HTMLElement | null) => { const root = scrollRef.current; if (!root || !el) return; const top = el.getBoundingClientRect().top - root.getBoundingClientRect().top + root.scrollTop - 16; root.scrollTo({ top, behavior: reduce ? "auto" : "smooth" }); }; const go = (next: MoneyView) => { setDrawer(false); if (next === view) { scrollRef.current?.scrollTo({ top: 0, behavior: reduce ? "auto" : "smooth" }); return; } hasSwitchedView.current = true; setView(next); if (scrollRef.current) scrollRef.current.scrollTop = 0; }; const pickCategory = (cat: MoneyCategory) => { setFilterCat((c) => (c === cat && view !== "budgets" ? null : cat)); if (view === "budgets") { hasSwitchedView.current = true; setView("transactions"); if (scrollRef.current) scrollRef.current.scrollTop = 0; } else if (view === "overview") scrollTo(listRef.current); }; const nav: { id: MoneyView; label: string; icon: ReactNode; badge?: number }[] = [ { id: "overview", label: "Overview", icon: }, { id: "transactions", label: "Transactions", icon: }, { id: "bills", label: "Bills", icon: , badge: openBills.length || undefined }, { id: "budgets", label: "Budgets", icon: }, ]; const viewLabel = nav.find((n) => n.id === view)?.label ?? "Overview"; const sidebar = (where: "rail" | "drawer") => (
{productName}
Accounts {accounts.map((a) => { const Icon = accountIcons[a.kind]; const active = a.id === accountId; return ( ); })}
{initials(userName)}
{userName} Personal plan
); const pills = [{ id: "all", name: "All accounts" }, ...accounts.map((a) => ({ id: a.id, name: a.name }))]; const balanceSection = (
{pills.map((p) => { const active = p.id === accountId; return ( ); })}
{selected ? (selected.kind === "credit" ? "Card balance" : `${selected.name} balance`) : "Total balance"}
= 0 ? "success" : "error"}> `${n >= 0 ? "+" : "−"}${money(Math.abs(n))}`} value={since} /> in 30 days
); const spendingSection = (

Spending in {monthName}

Tap a category to filter
    {spendCats.map((s) => { const c = categories[s.cat]; const active = filterCat === s.cat; return (
  • ); })}
); const txSection = (

Transactions

{filterCat && ( setFilterCat(null)} transition={{ duration: 0.15, ease: easeOut }} type="button" > {categories[filterCat].label} )}
setQuery(e.target.value)} placeholder="Search merchants" type="search" value={query} />
{groups.length === 0 ? (

Nothing matches

Try another merchant, or clear the filter.

) : (
{groups.map((g) => { const net = g.items.reduce((s, x) => s + x.amount, 0); return (
{dayLabel(g.date)} {net > 0 ? "+" : "−"} {money(Math.abs(net))}
    {g.items.map((x) => { const acct = accounts.find((a) => a.id === x.accountId); return (
    {x.merchant} {categories[x.category].label} · {acct?.name ?? "Account"} · {x.time}
    0 && "text-success-foreground")}> {x.amount > 0 ? "+" : "−"} {money(Math.abs(x.amount))}
    ); })}
); })}
)}
); const budgetsSection = (

Budgets

{daysLeft} days to go
    {budgets.map((b) => (
  • pickCategory(b.category)} pace={(monthEnd - daysLeft) / monthEnd} spent={spendBy(b.category)} />
  • ))}

Where you’d be at an even pace

); const billsSection = (

Upcoming bills

{openBills.length ? ( <> {" "} due ) : ( "All paid" )}
    {bills.map((b) => { const state = billState[b.id]; const d = daysBetween(today, b.due); const due = parseDate(b.due); return (
  • {due.toLocaleDateString("en-US", { month: "short" })} {due.getDate()}
    {b.name} {state === "paid" ? "Paid today" : d === 0 ? "Due today" : d === 1 ? "Tomorrow" : `In ${d} days`} {b.autopay && state !== "paid" && ( <> · Autopay )}
    {money(b.amount)} payBill(b)} state={state} />
  • ); })}
); return (
{ if (e.key === "Escape" && drawer) setDrawer(false); }} > {drawer && ( <> setDrawer(false)} transition={{ duration: 0.2, ease: easeOut }} /> {sidebar("drawer")} )}
{productName} / {viewLabel} {initials(userName)}

{view === "overview" ? `Evening, ${firstName}` : viewLabel}

{view === "bills" ? ( <> {openBills.length ? `${openBills.length} ${openBills.length === 1 ? "bill" : "bills"} due in the next two weeks.` : "Nothing left to pay this cycle."} ) : view === "transactions" ? ( <> {visibleTx.length} {visibleTx.length === 1 ? "transaction" : "transactions"} {selected ? ` on ${selected.name}` : " across all accounts"}. ) : ( <> {daysLeft} days left in {monthName}. You’ve spent{" "} {" "} and brought in{" "} . )}

{view === "overview" ? (
{balanceSection} {spendingSection} {txSection}
{budgetsSection} {billsSection}
) : view === "transactions" ? (
{txSection}
) : view === "bills" ? (
{billsSection}
) : (
{budgetsSection} {spendingSection}
)}
); } function PayButton({ state, onPay, name }: { state?: "paying" | "paid"; onPay: () => void; name: string }) { const reduce = useReducedMotion(); return ( ); } function BalanceChart({ series, today }: { series: number[]; today: string }) { const reduce = useReducedMotion(); const gid = `bal-${useId().replace(/:/g, "")}`; const [hover, setHover] = useState(null); const W = 600; const H = 120; const pad = 10; const min = Math.min(...series); const max = Math.max(...series); const span = max - min || 1; const x = (i: number) => (i / (series.length - 1)) * W; const y = (v: number) => pad + (1 - (v - min) / span) * (H - pad - 30); const line = series .map((v, i) => { const px = x(i).toFixed(1); const py = y(v).toFixed(1); if (!i) return `M${px} ${py}`; const cx = ((x(i - 1) + x(i)) / 2).toFixed(1); return `C${cx} ${y(series[i - 1]).toFixed(1)} ${cx} ${py} ${px} ${py}`; }) .join(" "); const area = `${line} L${W} ${H} L0 ${H} Z`; const onMove = (e: PointerEvent) => { const r = e.currentTarget.getBoundingClientRect(); const i = Math.round(((e.clientX - r.left) / r.width) * (series.length - 1)); setHover(Math.max(0, Math.min(series.length - 1, i))); }; const dateFor = (i: number) => { const d = new Date(`${today}T12:00:00`); d.setDate(d.getDate() - (series.length - 1 - i)); return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); }; const morph = reduce ? { duration: 0 } : { duration: 0.45, ease: easeOut }; const hi = hover ?? null; return (
setHover(null)} onPointerMove={onMove} preserveAspectRatio="none" role="img" viewBox={`0 0 ${W} ${H}`} > {hi !== null && ( )}
{dateFor(0)} {dateFor(Math.floor((series.length - 1) / 2))} Today
{hi !== null && ( <> {dateFor(hi)}{" "} {money(series[hi])} )}
); } function Donut({ parts, total, focus, onFocus, onPick, }: { parts: { cat: MoneyCategory; value: number }[]; total: number; focus: MoneyCategory | null; onFocus: (c: MoneyCategory | null) => void; onPick: (c: MoneyCategory) => void; }) { const reduce = useReducedMotion(); const gap = 1.4; let start = 0; const focused = parts.find((p) => p.cat === focus); return (
{parts.map((p) => { const pct = (p.value / (total || 1)) * 100; const dash = Math.max(pct - gap, 0.5); const offset = -start; start += pct; return ( onPick(p.cat)} onPointerEnter={() => onFocus(p.cat)} onPointerLeave={() => onFocus(null)} pathLength={100} r="48" strokeWidth="14" style={{ pointerEvents: "stroke" }} transition={reduce ? { duration: 0 } : { duration: 0.35, ease: easeOut }} /> ); })}
{focused ? categories[focused.cat].label : "Spent"}
); } function BudgetRow({ category, spent, limit, pace, onPick, }: { category: MoneyCategory; spent: number; limit: number; pace: number; onPick: () => void; }) { const reduce = useReducedMotion(); const c = categories[category]; const ratio = spent / limit; const over = ratio > 1; const ahead = !over && ratio > pace + 0.08; const left = limit - spent; return ( ); }