'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}
))}
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 (
);
}
// ── 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 ? (
) : (
{/* ── 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
)}
);
}