"use client"; import { useState, useMemo } from "react"; import { ScatterChart, Scatter, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, LineChart, Line, CartesianGrid, } from "recharts"; import { useMerchants, useMerchantTransactions, MerchantRow } from "@/lib/hooks"; import { formatCategory } from "@/lib/categories"; function fmt(n: number) { return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", maximumFractionDigits: 0, }).format(n); } function fmtExact(n: number) { return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", minimumFractionDigits: 2, }).format(n); } function fmtDate(d: string) { return new Date(d + "T00:00:00").toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric", }); } const CATEGORY_COLORS: Record = { groceries: "#4ade80", dining: "#fb923c", transport: "#60a5fa", fuel: "#facc15", shopping: "#f472b6", utilities: "#a78bfa", entertainment: "#34d399", travel: "#38bdf8", health: "#f87171", insurance: "#94a3b8", subscriptions: "#c084fc", government: "#6b7280", education: "#fbbf24", rent: "#e879f9", home_goods: "#67e8f9", home_maintenance: "#c084fc", personal_care: "#fb7185", pets: "#a3e635", gifts: "#f9a8d4", charity: "#6ee7b7", other: "#71717a", }; // Custom scatter tooltip function ScatterTooltip({ active, payload, }: { active?: boolean; payload?: Array<{ payload: MerchantRow }>; }) { if (!active || !payload?.length) return null; const d = payload[0].payload; return (

{d.merchant}

{formatCategory(d.category)}

{fmt(d.net_spend)} net

{d.refund_count > 0 && (

{d.refund_count} refund(s) −{fmt(d.total_refunds)}

)}

{d.debit_count}× transactions

{fmtExact(d.avg_debit)} avg

); } // Quadrant labels function QuadrantLabels({ medianX, medianY }: { medianX: number; medianY: number }) { return (
high cost · frequent
high cost · rare
low cost · frequent
low cost · rare
); } // Merchant profile drawer function MerchantProfile({ merchant, onClose, }: { merchant: MerchantRow; onClose: () => void; }) { const { data } = useMerchantTransactions(merchant.merchant); const transactions = data?.transactions ?? []; // Build trend chart data from monthly_trend const trendData = useMemo(() => { const months = Object.keys(merchant.monthly_trend).sort(); return months.map((m) => ({ month: m, label: new Date(m + "-01").toLocaleDateString("en-AU", { month: "short", year: "2-digit" }), amount: merchant.monthly_trend[m], })); }, [merchant.monthly_trend]); const color = CATEGORY_COLORS[merchant.category] || "#6366f1"; return (
{/* backdrop */}
{/* drawer */}
{/* Header */}

{merchant.merchant}

{formatCategory(merchant.category)}
{/* Stats */}

Net Spent

{fmt(merchant.net_spend)}

Avg per Visit

{fmtExact(merchant.avg_debit)}

Visits

{merchant.debit_count}

{/* Refund callout — only show if there are refunds */} {merchant.refund_count > 0 && (
{merchant.refund_count} refund{merchant.refund_count > 1 ? "s" : ""} gross {fmt(merchant.gross_spend)} − refunds −{fmt(merchant.total_refunds)}
)} {/* Trend chart */} {trendData.length > 1 && (

Monthly Spend

`$${Math.round(v)}`} width={45} />
)} {/* Transaction history */}

Transactions{" "} ({transactions.length})

{transactions.length === 0 ? (

Loading…

) : (
{transactions.map((tx) => (

{tx.description}

{fmtDate(tx.transaction_date)} · {tx.bank_name}

{fmtExact(tx.my_amount)}

))}
)}
); } export default function MerchantsPage() { const [months, setMonths] = useState(12); const [selected, setSelected] = useState(null); const [search, setSearch] = useState(""); const { data, isLoading } = useMerchants(months); const merchants = data?.merchants ?? []; // Stats for median lines const { medianX, medianY } = useMemo(() => { if (merchants.length === 0) return { medianX: 0, medianY: 0 }; const counts = [...merchants].sort((a, b) => a.debit_count - b.debit_count); const spends = [...merchants].sort((a, b) => a.net_spend - b.net_spend); const mid = Math.floor(merchants.length / 2); return { medianX: counts[mid]?.debit_count ?? 0, medianY: spends[mid]?.net_spend ?? 0, }; }, [merchants]); // Filtered merchants for the table const filtered = useMemo(() => { if (!search.trim()) return merchants; const q = search.toLowerCase(); return merchants.filter((m) => m.merchant.toLowerCase().includes(q)); }, [merchants, search]); // Top merchants for the scatter (max 150 for performance) const scatterData = useMemo(() => merchants.slice(0, 150), [merchants]); if (isLoading) { return (
Loading merchant data…
); } return (
{/* Header */}

Merchants

{merchants.length} merchants · last {months} months

{/* Scatter plot */}

Spend vs Frequency

Each dot = one merchant. Click to open profile.

`$${Math.round(v / 1000)}k`} width={48} label={{ value: "Total Spend", angle: -90, position: "insideLeft", offset: 10, fill: "#52525b", fontSize: 11 }} /> } cursor={{ strokeDasharray: "3 3", stroke: "#52525b" }} /> setSelected(d as unknown as MerchantRow)} style={{ cursor: "pointer" }} > {scatterData.map((entry, idx) => ( ))}
{/* Category legend */}
{Object.entries(CATEGORY_COLORS) .filter(([cat]) => merchants.some((m) => m.category === cat)) .slice(0, 12) .map(([cat, color]) => ( {formatCategory(cat)} ))}
{/* Merchant table */}

All Merchants

setSearch(e.target.value)} className="bg-zinc-800 border border-zinc-700 rounded-md text-sm text-zinc-200 placeholder-zinc-500 px-3 py-1.5 w-48" />
{filtered.map((m) => { const color = CATEGORY_COLORS[m.category] || "#6366f1"; return ( setSelected(m)} className="border-b border-zinc-800 hover:bg-zinc-800 cursor-pointer transition-colors" > ); })}
Merchant Category Total Count Avg Last Seen
{m.merchant} {formatCategory(m.category)} {fmt(m.net_spend)} {m.refund_count > 0 && ( ↩{m.refund_count} )} {m.debit_count} {fmtExact(m.avg_debit)} {fmtDate(m.last_seen)}
{/* Merchant profile drawer */} {selected && ( setSelected(null)} /> )}
); }