'use strict'; import { useEffect, useMemo, useState } from 'react'; import { CheckCircle2, ChevronDown, ExternalLink, Link2, Link2Off, Loader2, Pencil, Plus, Search, X, } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; import { cn, fmt } from '@/lib/utils'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; const TYPE_LABELS = { streaming: 'Streaming', software: 'Software', cloud: 'Cloud', music: 'Music', news: 'News', fitness: 'Fitness', gaming: 'Gaming', utilities: 'Utilities', insurance: 'Insurance', food: 'Food', education: 'Education', shopping: 'Shopping', security: 'Security', other: 'Other', }; const CHIPS = [ { value: null, label: 'All' }, { value: 'streaming', label: 'Streaming' }, { value: 'music', label: 'Music' }, { value: 'gaming', label: 'Gaming' }, { value: 'news', label: 'News' }, { value: 'fitness', label: 'Fitness' }, { value: 'software', label: 'Software' }, { value: 'security', label: 'Security' }, { value: 'food', label: 'Food' }, { value: 'cloud', label: 'Cloud' }, { value: 'shopping', label: 'Shopping' }, { value: 'education', label: 'Education' }, ]; // Returns 'match' | 'above' | 'below' | null function priceDrift(monthly, catalogStarting) { if (!catalogStarting || !monthly) return null; const pct = (monthly - catalogStarting) / catalogStarting; if (Math.abs(pct) <= 0.05) return 'match'; return pct > 0 ? 'above' : 'below'; } // ── Descriptor editor inline inside a matched card ───────────────────────── function DescriptorEditor({ catalogId, descriptors, onAdd, onDelete }) { const [open, setOpen] = useState(false); const [value, setValue] = useState(''); const [busy, setBusy] = useState(false); async function handleAdd() { const d = value.trim(); if (!d) return; setBusy(true); try { await onAdd(catalogId, d); setValue(''); } finally { setBusy(false); } } return (
{open && (
{descriptors.map(d => (
{d.descriptor}
))}
setValue(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && value.trim()) handleAdd(); }} className="h-7 font-mono text-xs" />

Add the exact payee text your bank uses to improve auto-matching.

)}
); } // ── Card for an already-tracked catalog entry ────────────────────────────── function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescriptor }) { const { matched_bill: bill, user_descriptors: descs = [] } = entry; const drift = priceDrift(bill?.monthly_equivalent, entry.starting_monthly_usd); return (
{entry.name} {TYPE_LABELS[entry.subscription_type] || 'Other'} {bill && !bill.active && ( Paused )}
{bill && ( {fmt(bill.monthly_equivalent)}/mo )} {drift === 'match' && entry.starting_monthly_usd && ( matches catalog price )} {drift === 'above' && ( catalog from ${entry.starting_monthly_usd.toFixed(2)}/mo )} {drift === 'below' && entry.starting_monthly_usd && ( catalog from ${entry.starting_monthly_usd.toFixed(2)}/mo )} {!entry.starting_monthly_usd && entry.website && ( website )}
); } // ── Card for an untracked catalog entry ─────────────────────────────────── function UnmatchedCard({ entry, selected, onToggleSelect, onTrack, trackingBusy }) { return (
onToggleSelect(entry.id, !!checked)} aria-label={`Select ${entry.name}`} />

{entry.name}

{entry.category} {entry.starting_monthly_usd && ( from ${entry.starting_monthly_usd.toFixed(2)}/mo )}

{entry.website && ( )}
); } // ── Re-link dialog ───────────────────────────────────────────────────────── function ReLinkDialog({ open, relinkEntry, allEntries, onClose, onConfirm, busy }) { const [search, setSearch] = useState(''); const [selected, setSelected] = useState(null); useEffect(() => { if (open) { setSearch(''); setSelected(null); } }, [open]); const filtered = useMemo(() => { const q = search.toLowerCase(); return allEntries.filter(e => !q || e.name.toLowerCase().includes(q) || (e.category || '').toLowerCase().includes(q) ); }, [allEntries, search]); const bill = relinkEntry?.matched_bill; return ( { if (!v) onClose(); }}> Re-link to catalog entry {bill && (

Choose the correct service for{' '} {bill.name}.

)}
setSearch(e.target.value)} className="text-sm" />
{/* Unlink option */} {filtered.length === 0 && search ? (

No services found.

) : filtered.map(e => ( ))}
); } // ── Main component ───────────────────────────────────────────────────────── export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete }) { const [catalog, setCatalog] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [showUnmatched, setShowUnmatched] = useState(false); const [activeType, setActiveType] = useState(null); const [search, setSearch] = useState(''); const [selectedIds, setSelectedIds] = useState(new Set()); const [relinkEntry, setRelinkEntry] = useState(null); const [relinkBusy, setRelinkBusy] = useState(false); const [trackBusyId, setTrackBusyId] = useState(null); const [bulkBusy, setBulkBusy] = useState(false); async function loadCatalog() { setLoading(true); setError(null); try { const data = await api.subscriptionCatalog(); setCatalog(data.catalog || []); } catch (err) { const message = err.message || 'Failed to load catalog'; setError(message); toast.error(message); } finally { setLoading(false); } } useEffect(() => { loadCatalog(); }, []); // Filter by type chip + search const filtered = useMemo(() => { const q = search.toLowerCase().trim(); return catalog.filter(e => { if (activeType && e.subscription_type !== activeType) return false; if (q) return e.name.toLowerCase().includes(q) || (e.category || '').toLowerCase().includes(q); return true; }); }, [catalog, activeType, search]); const matched = filtered.filter(e => e.matched_bill !== null); const unmatched = filtered.filter(e => e.matched_bill === null); // ── Handlers ───────────────────────────────────────────────────────────── async function handleTrack(entry) { setTrackBusyId(entry.id); try { await api.createSubscriptionFromRecommendation({ name: entry.name, due_day: 1, expected_amount: entry.starting_monthly_usd || 0, cycle_type: 'monthly', subscription_type: entry.subscription_type || 'other', catalog_match: { id: entry.id, name: entry.name, subscription_type: entry.subscription_type }, }); toast.success(`${entry.name} added to your subscriptions`, { description: 'Open it with Edit to set the correct amount and due date.', }); await loadCatalog(); onTrackComplete?.(); } catch (err) { toast.error(err.message || `Failed to add ${entry.name}`); } finally { setTrackBusyId(null); } } async function handleBulkTrack() { if (selectedIds.size === 0) return; setBulkBusy(true); const toTrack = unmatched.filter(e => selectedIds.has(e.id)); let succeeded = 0; let failed = 0; for (const entry of toTrack) { try { await api.createSubscriptionFromRecommendation({ name: entry.name, due_day: 1, expected_amount: entry.starting_monthly_usd || 0, cycle_type: 'monthly', subscription_type: entry.subscription_type || 'other', catalog_match: { id: entry.id, name: entry.name, subscription_type: entry.subscription_type }, }); succeeded++; } catch { failed++; } } if (succeeded > 0) { toast.success(`${succeeded} subscription${succeeded !== 1 ? 's' : ''} added`); setSelectedIds(new Set()); await loadCatalog(); onTrackComplete?.(); } if (failed > 0) { toast.error(`${failed} subscription${failed !== 1 ? 's' : ''} could not be added`); } setBulkBusy(false); } async function handleRelink(billId, catalogId) { setRelinkBusy(true); try { await api.updateSubscriptionCatalogLink(billId, catalogId); toast.success(catalogId ? 'Catalog link updated' : 'Catalog link removed'); setRelinkEntry(null); await loadCatalog(); } catch (err) { toast.error(err.message || 'Failed to update catalog link'); } finally { setRelinkBusy(false); } } async function handleAddDescriptor(catalogId, descriptor) { try { const newDesc = await api.addCatalogDescriptor(catalogId, descriptor); setCatalog(prev => prev.map(e => e.id === catalogId ? { ...e, user_descriptors: [...(e.user_descriptors || []), newDesc] } : e )); toast.success('Descriptor added'); return true; } catch (err) { toast.error(err.message || 'Failed to add descriptor'); return false; } } async function handleDeleteDescriptor(catalogId, descriptorId) { try { await api.deleteCatalogDescriptor(descriptorId); setCatalog(prev => prev.map(e => e.id === catalogId ? { ...e, user_descriptors: (e.user_descriptors || []).filter(d => d.id !== descriptorId) } : e )); toast.success('Descriptor removed'); } catch (err) { toast.error(err.message || 'Failed to remove descriptor'); } } function toggleSelect(id, checked) { setSelectedIds(prev => { const next = new Set(prev); checked ? next.add(id) : next.delete(id); return next; }); } // ── Render ──────────────────────────────────────────────────────────────── return ( Known Service Catalog Popular services, linked bills, and custom bank descriptors used to improve matching. {/* Category filter chips */}
{CHIPS.map(chip => ( ))}
{/* Search */}
setSearch(e.target.value)} className="h-9 pl-8 text-sm" />
{loading ? (
Loading catalog…
) : error ? (

{error}

) : (
{/* ── Matched group ───────────────────────────────────────── */} {matched.length > 0 && (

Tracking ({matched.length})

{matched.map(entry => ( handleDeleteDescriptor(entry.id, descriptorId)} /> ))}
)} {/* ── Unmatched toggle + group ─────────────────────────── */}
{showUnmatched && (
{unmatched.length === 0 ? (

{search || activeType ? 'No services match your filter.' : 'All known services are already being tracked.'}

) : (
{unmatched.map(entry => ( ))}
)}
)}
)}
{/* ── Re-link dialog ─────────────────────────────────────────────── */} setRelinkEntry(null)} onConfirm={handleRelink} busy={relinkBusy} /> {/* ── Bulk action bar ────────────────────────────────────────────── */} {selectedIds.size > 0 && (
{selectedIds.size} {selectedIds.size === 1 ? 'service' : 'services'} selected
)}
); }