"use client";
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
import { ArrowLeftRight, BarChart3, Bell, LayoutDashboard, Receipt, Target, X, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { CATEGORY, defaultFinanceData, makeFormatters, monthKey, monthlyCashFlow, spendByCategory } from "./data";
import { TransactionsView } from "./transactions";
import { TransferDialog, type Payee } from "./transfer-dialog";
import type { Budgets, CategoryId, FinanceData, Split, Transaction, TransferRequest, ViewKey } from "./types";
import { focusRing, IconButton } from "./ui";
import { BudgetsView, CashFlowView, OverviewView } from "./views";
export type { Account, Budgets, CategoryId, FinanceData, NetWorthPoint, Split, Transaction, TransferRequest, ViewKey } from "./types";
export { generateFinanceData, CATEGORIES } from "./data";
const NAV: { key: ViewKey; label: string; icon: LucideIcon }[] = [
{ key: "overview", label: "Home", icon: LayoutDashboard },
{ key: "transactions", label: "Transactions", icon: Receipt },
{ key: "budgets", label: "Budgets", icon: Target },
{ key: "cashflow", label: "Cash flow", icon: BarChart3 },
];
const TITLES: Record<ViewKey, string> = { overview: "Good morning, Alex", transactions: "Transactions", budgets: "Budgets", cashflow: "Cash flow" };
export interface FinanceAppProps {
/** Accounts, transactions, budgets and net-worth history. Defaults to a seeded, deterministic demo. */
data?: FinanceData;
/** ISO 4217 code used by Intl.NumberFormat. */
currency?: string;
locale?: string;
brand?: string;
user?: { name: string; plan?: string };
payees?: Payee[];
defaultView?: ViewKey;
onViewChange?: (view: ViewKey) => void;
/** Fired after a transaction is recategorized, split or annotated. */
onTransactionChange?: (tx: Transaction) => void;
onBudgetsChange?: (budgets: Budgets) => void;
/** Fired when a transfer is confirmed (balances update locally either way). */
onTransfer?: (req: TransferRequest) => void;
className?: string;
}
type Toast = { id: number; text: string; undo?: () => void };
let seq = 0;
function BrandMark() {
return (
<svg viewBox="0 0 32 32" className="size-8 shrink-0" aria-hidden>
<rect width="32" height="32" rx="10" className="fill-foreground" />
<circle cx="13" cy="16" r="6" fill="none" strokeWidth="2.6" className="stroke-background" />
<circle cx="19" cy="16" r="6" fill="none" strokeWidth="2.6" className="stroke-primary" />
</svg>
);
}
export function FinanceApp({
data = defaultFinanceData,
currency = "USD",
locale = "en-US",
brand = "Lumen",
user = { name: "Alex Rivera", plan: "Plus plan" },
payees,
defaultView = "overview",
onViewChange,
onTransactionChange,
onBudgetsChange,
onTransfer,
className,
}: FinanceAppProps) {
const [view, setView] = React.useState<ViewKey>(defaultView);
const [accounts, setAccounts] = React.useState(data.accounts);
const [txs, setTxs] = React.useState(data.transactions);
const [budgets, setBudgets] = React.useState<Budgets>(data.budgets);
const [transfer, setTransfer] = React.useState<{ from?: string } | null>(null);
const [catFilter, setCatFilter] = React.useState<CategoryId | "all">("all");
const [toasts, setToasts] = React.useState<Toast[]>([]);
const reduce = useReducedMotion();
const uid = React.useId();
const fmt = React.useMemo(() => makeFormatters(currency, locale), [currency, locale]);
const today = data.today;
const total = accounts.reduce((s, a) => s + a.balance, 0);
const netWorth = React.useMemo(() => {
const nw = data.netWorth.slice();
if (nw.length) nw[nw.length - 1] = { ...nw[nw.length - 1], value: total };
return nw;
}, [data.netWorth, total]);
const monthTx = React.useMemo(() => txs.filter((t) => monthKey(t.date) === monthKey(today)), [txs, today]);
const spent = React.useMemo(() => spendByCategory(monthTx), [monthTx]);
const flow = React.useMemo(() => monthlyCashFlow(txs, 6, today), [txs, today]);
const overCount = Object.entries(budgets).filter(([k, b]) => b && (spent[k as CategoryId] ?? 0) > b).length;
const toast = React.useCallback((text: string, undo?: () => void) => {
const id = ++seq;
setToasts((t) => [...t.slice(-2), { id, text, undo }]);
window.setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 4500);
}, []);
const go = (v: ViewKey, cat: CategoryId | "all" = "all") => {
setCatFilter(cat);
setView(v);
onViewChange?.(v);
};
const patchTx = (id: string, fn: (t: Transaction) => Transaction) => {
let next: Transaction | undefined;
setTxs((ts) =>
ts.map((t) => {
if (t.id !== id) return t;
next = fn(t);
return next;
}),
);
queueMicrotask(() => next && onTransactionChange?.(next));
};
const recategorize = (id: string, c: CategoryId) => {
const before = txs.find((t) => t.id === id);
if (!before) return;
patchTx(id, (t) => ({ ...t, category: c }));
toast(`${before.merchant} moved to ${CATEGORY[c].label}`, () => patchTx(id, (t) => ({ ...t, category: before.category })));
};
const split = (id: string, s: Split[] | undefined) => {
patchTx(id, (t) => ({ ...t, splits: s }));
toast(s ? `Split into ${s.length} categories` : "Split removed");
};
const annotate = (id: string, note: string) => patchTx(id, (t) => ({ ...t, note: note || undefined }));
const setBudget = (id: CategoryId, amount: number) => {
const next = { ...budgets, [id]: amount };
setBudgets(next);
onBudgetsChange?.(next);
toast(`${CATEGORY[id].label} budget set to ${fmt.money(amount, { cents: false })}`);
};
const doTransfer = (req: TransferRequest) => {
const txid = `tx-new-${++seq}`;
setAccounts((as) =>
as.map((a) => {
if (a.id === req.fromId) return { ...a, balance: Math.round((a.balance - req.amount) * 100) / 100 };
if (req.kind === "internal" && a.id === req.toId) return { ...a, balance: Math.round((a.balance + req.amount) * 100) / 100 };
return a;
}),
);
const toName = req.kind === "internal" ? accounts.find((a) => a.id === req.toId)?.name ?? "account" : req.recipient;
const fromName = accounts.find((a) => a.id === req.fromId)?.name ?? "account";
const added: Transaction[] = [{ id: `${txid}-out`, date: today, merchant: `To ${toName}`, amount: -req.amount, category: "transfer", accountId: req.fromId, note: req.note || undefined }];
if (req.kind === "internal") added.unshift({ id: `${txid}-in`, date: today, merchant: `From ${fromName}`, amount: req.amount, category: "transfer", accountId: req.toId, note: req.note || undefined });
setTxs((ts) => [...added, ...ts]);
onTransfer?.(req);
};
const navButtons = (mobile: boolean) => (
<LayoutGroup id={`${uid}-${mobile ? "m" : "d"}`}>
{NAV.map((n) => {
const on = n.key === view;
return (
<button
key={n.key}
type="button"
onClick={() => go(n.key)}
aria-current={on ? "page" : undefined}
className={cn(
"relative outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
mobile ? "flex flex-1 flex-col items-center gap-0.5 rounded-xl py-1.5 text-[10.5px] font-medium" : "flex h-10 items-center gap-3 rounded-xl px-3 text-sm font-medium",
on ? "text-foreground" : "text-muted-foreground hover:text-foreground",
!mobile && !on && "hover:bg-accent/60",
)}
>
{on && <motion.span layoutId="fin-nav" className={cn("absolute rounded-xl bg-accent", mobile ? "inset-x-3 inset-y-0.5" : "inset-0 shadow-xs ring-1 ring-border")} transition={{ type: "spring", stiffness: 450, damping: 36 }} />}
<n.icon className={cn("relative size-[18px]", on && "text-primary")} aria-hidden />
<span className="relative">{n.label}</span>
{n.key === "budgets" && overCount > 0 && (
<span className={cn("relative grid place-items-center rounded-full bg-rose-500 text-[10px] font-bold leading-none text-white tabular-nums", mobile ? "absolute right-[calc(50%-18px)] top-0.5 size-4" : "ml-auto size-5")} aria-label={`${overCount} over budget`}>
{overCount}
</span>
)}
</button>
);
})}
</LayoutGroup>
);
return (
<div data-fin-root className={cn("relative flex h-[760px] w-full overflow-hidden bg-background text-foreground", className)}>
<aside className="hidden w-60 shrink-0 flex-col gap-5 border-r bg-muted/30 p-4 md:flex">
<div className="flex h-9 items-center gap-2.5 px-1">
<BrandMark />
<span className="text-base font-semibold tracking-tight">{brand}</span>
</div>
<button type="button" onClick={() => setTransfer({})} className={cn("inline-flex h-10 items-center justify-center gap-2 rounded-xl bg-primary text-sm font-semibold text-primary-foreground shadow-sm transition hover:opacity-90", focusRing)}>
<ArrowLeftRight className="size-4" aria-hidden /> Move money
</button>
<nav aria-label="Main" className="flex flex-col gap-0.5">
{navButtons(false)}
</nav>
<div className="mt-auto rounded-2xl border bg-background/70 p-3">
<div className="flex items-center gap-2.5">
<span className="grid size-9 place-items-center rounded-full bg-gradient-to-br from-primary to-fuchsia-500 text-xs font-semibold text-white" aria-hidden>
{user.name
.split(" ")
.map((w) => w[0])
.join("")
.slice(0, 2)}
</span>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{user.name}</div>
{user.plan && <div className="truncate text-xs text-muted-foreground">{user.plan}</div>}
</div>
</div>
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 shrink-0 items-center gap-3 border-b bg-background/80 px-4 backdrop-blur sm:px-6">
<span className="md:hidden">
<BrandMark />
</span>
<div className="min-w-0 flex-1">
<h1 className="truncate text-base font-semibold tracking-tight">{TITLES[view]}</h1>
<p className="hidden text-xs text-muted-foreground sm:block">{fmt.dayLong(today)}</p>
</div>
<IconButton label="Notifications" className="relative">
<Bell className="size-4" />
{overCount > 0 && <span className="absolute right-2 top-2 size-2 rounded-full bg-rose-500 ring-2 ring-background" />}
</IconButton>
<button type="button" onClick={() => setTransfer({})} className={cn("inline-flex h-9 items-center gap-2 rounded-xl bg-primary px-3 text-sm font-semibold text-primary-foreground shadow-sm md:hidden", focusRing)} aria-label="Move money">
<ArrowLeftRight className="size-4" aria-hidden />
<span className="hidden min-[400px]:inline">Move</span>
</button>
</header>
<main className={cn("relative min-h-0 flex-1", view === "transactions" ? "overflow-hidden" : "overflow-y-auto overflow-x-hidden")}>
<AnimatePresence mode="wait" initial={false}>
<motion.div key={view} className={cn(view === "transactions" && "h-full")} initial={reduce ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={reduce ? undefined : { opacity: 0, y: -4 }} transition={{ duration: 0.18 }}>
{view === "overview" && (
<OverviewView accounts={accounts} netWorth={netWorth} transactions={txs} budgets={budgets} spent={spent} fmt={fmt} onSeeAll={() => go("transactions")} onOpenBudgets={() => go("budgets")} onTransfer={(from) => setTransfer({ from })} />
)}
{view === "transactions" && <TransactionsView transactions={txs} accounts={accounts} fmt={fmt} today={today} onRecategorize={recategorize} onSplit={split} onNote={annotate} initialCategory={catFilter} />}
{view === "budgets" && <BudgetsView budgets={budgets} spent={spent} fmt={fmt} today={today} onChange={setBudget} onOpenCategory={(c) => go("transactions", c)} />}
{view === "cashflow" && <CashFlowView flow={flow} transactions={txs} fmt={fmt} />}
</motion.div>
</AnimatePresence>
</main>
<nav aria-label="Main" className="flex shrink-0 gap-1 border-t bg-background/95 px-2 pb-2 pt-1.5 backdrop-blur md:hidden">
{navButtons(true)}
</nav>
</div>
<TransferDialog open={!!transfer} onClose={() => setTransfer(null)} accounts={accounts} fmt={fmt} defaultFrom={transfer?.from} payees={payees} onSubmit={doTransfer} />
<div className="pointer-events-none absolute inset-x-0 bottom-20 z-[60] flex flex-col items-center gap-2 px-4 md:bottom-5" aria-live="polite">
<AnimatePresence>
{toasts.map((t) => (
<motion.div
key={t.id}
layout
initial={{ opacity: 0, y: 16, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.96 }}
className="pointer-events-auto flex max-w-full items-center gap-3 rounded-2xl bg-foreground py-2 pl-4 pr-2 text-sm text-background shadow-xl"
>
<span className="truncate">{t.text}</span>
{t.undo && (
<button
type="button"
onClick={() => {
t.undo?.();
setToasts((ts) => ts.filter((x) => x.id !== t.id));
}}
className="rounded-lg bg-background/15 px-2.5 py-1 text-xs font-semibold hover:bg-background/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Undo
</button>
)}
<button type="button" aria-label="Dismiss" onClick={() => setToasts((ts) => ts.filter((x) => x.id !== t.id))} className="grid size-7 place-items-center rounded-lg opacity-70 hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<X className="size-3.5" />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
);
}
export default FinanceApp;