BillTracker/client/components/SubscriptionCatalogSection.jsx

717 lines
26 KiB
React
Raw Permalink Normal View History

'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 (
<div className="mt-2 border-t border-border/30 pt-2">
<button
type="button"
onClick={() => setOpen(s => !s)}
className="flex items-center gap-1 text-[11px] text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronDown className={cn('h-3 w-3 transition-transform', !open && '-rotate-90')} />
Custom bank descriptors
{descriptors.length > 0 && (
<span className="ml-1 rounded bg-muted/50 px-1 font-mono text-[10px]">{descriptors.length}</span>
)}
</button>
{open && (
<div className="mt-2 space-y-1.5">
{descriptors.map(d => (
<div key={d.id} className="flex items-center gap-2">
<span className="flex-1 rounded border border-border/40 bg-muted/30 px-2 py-0.5 font-mono text-[11px]">
{d.descriptor}
</span>
<button
type="button"
onClick={() => onDelete(d.id)}
className="text-muted-foreground transition-colors hover:text-destructive"
title="Remove descriptor"
>
<X className="h-3 w-3" />
</button>
</div>
))}
<div className="flex gap-1.5">
<Input
placeholder="e.g. NETFLIX DIGITAL"
value={value}
onChange={e => setValue(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && value.trim()) handleAdd(); }}
className="h-7 font-mono text-xs"
/>
<Button
type="button"
size="sm"
variant="outline"
disabled={!value.trim() || busy}
onClick={handleAdd}
className="h-7 px-2"
>
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
</Button>
</div>
<p className="text-[10px] text-muted-foreground/70">
Add the exact payee text your bank uses to improve auto-matching.
</p>
</div>
)}
</div>
);
}
// ── 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 (
<div className="rounded-lg border border-primary/20 bg-primary/5 p-3 transition-colors hover:border-primary/30">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-sm font-semibold text-foreground">{entry.name}</span>
<Badge
variant="outline"
className="border-primary/25 bg-primary/10 text-[10px] text-primary"
>
{TYPE_LABELS[entry.subscription_type] || 'Other'}
</Badge>
{bill && !bill.active && (
<Badge variant="outline" className="border-amber-500/25 bg-amber-500/10 text-[10px] text-amber-400">
Paused
</Badge>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs">
{bill && (
<span className="font-mono font-semibold text-foreground">{fmt(bill.monthly_equivalent)}/mo</span>
)}
{drift === 'match' && entry.starting_monthly_usd && (
<span className="flex items-center gap-1 text-emerald-500">
<CheckCircle2 className="h-3 w-3" />
matches catalog price
</span>
)}
{drift === 'above' && (
<span className="text-amber-500">
catalog from ${entry.starting_monthly_usd.toFixed(2)}/mo
</span>
)}
{drift === 'below' && entry.starting_monthly_usd && (
<span className="text-sky-500">
catalog from ${entry.starting_monthly_usd.toFixed(2)}/mo
</span>
)}
{!entry.starting_monthly_usd && entry.website && (
<a
href={entry.website}
target="_blank"
rel="noreferrer"
className="flex items-center gap-0.5 text-muted-foreground hover:text-primary"
>
website <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
size="sm"
variant="outline"
className="h-7 gap-1 text-xs"
onClick={() => onEdit(bill.id)}
>
<Pencil className="h-3 w-3" />
Edit
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 gap-1 text-xs text-muted-foreground hover:text-foreground"
onClick={() => onRelink(entry)}
title="Change catalog link"
>
<Link2 className="h-3 w-3" />
Re-link
</Button>
</div>
</div>
<DescriptorEditor
catalogId={entry.id}
descriptors={descs}
onAdd={onAddDescriptor}
onDelete={onDeleteDescriptor}
/>
</div>
);
}
// ── Card for an untracked catalog entry ───────────────────────────────────
function UnmatchedCard({ entry, selected, onToggleSelect, onTrack, trackingBusy }) {
return (
<div
className={cn(
'flex items-center gap-3 rounded-lg border px-3 py-2.5 transition-colors',
selected
? 'border-primary/30 bg-primary/5'
: 'border-border/60 bg-background/35 hover:border-border/80',
)}
>
<Checkbox
checked={selected}
onCheckedChange={checked => onToggleSelect(entry.id, !!checked)}
aria-label={`Select ${entry.name}`}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">{entry.name}</p>
<p className="text-xs text-muted-foreground">
{entry.category}
{entry.starting_monthly_usd && (
<span className="ml-2 font-mono">from ${entry.starting_monthly_usd.toFixed(2)}/mo</span>
)}
</p>
</div>
{entry.website && (
<a
href={entry.website}
target="_blank"
rel="noreferrer"
className="shrink-0 text-muted-foreground hover:text-primary"
title="Open website"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
)}
<Button
type="button"
size="sm"
variant="outline"
className="h-7 shrink-0 gap-1 text-xs"
disabled={trackingBusy}
onClick={() => onTrack(entry)}
>
{trackingBusy
? <Loader2 className="h-3 w-3 animate-spin" />
: <><Plus className="h-3 w-3" />Track</>}
</Button>
</div>
);
}
// ── 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 (
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
<DialogContent className="border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-base font-semibold">Re-link to catalog entry</DialogTitle>
{bill && (
<p className="text-sm text-muted-foreground">
Choose the correct service for{' '}
<span className="font-medium text-foreground">{bill.name}</span>.
</p>
)}
</DialogHeader>
<Input
autoFocus
placeholder="Search services…"
value={search}
onChange={e => setSearch(e.target.value)}
className="text-sm"
/>
<div className="max-h-60 divide-y divide-border/40 overflow-y-auto rounded-lg border border-border/60">
{/* Unlink option */}
<button
type="button"
onClick={() => setSelected('unlink')}
className={cn(
'flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-muted/30',
selected === 'unlink' && 'bg-primary/5',
)}
>
<Link2Off className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Unlink from catalog</span>
{selected === 'unlink' && (
<CheckCircle2 className="ml-auto h-4 w-4 shrink-0 text-primary" />
)}
</button>
{filtered.length === 0 && search ? (
<p className="px-3 py-4 text-center text-sm text-muted-foreground">No services found.</p>
) : filtered.map(e => (
<button
key={e.id}
type="button"
onClick={() => setSelected(e.id)}
disabled={e.id === relinkEntry?.id}
className={cn(
'flex w-full items-center justify-between gap-3 px-3 py-2 text-left transition-colors',
'hover:bg-muted/30 disabled:pointer-events-none disabled:opacity-40',
selected === e.id && 'bg-primary/5',
)}
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{e.name}</p>
<p className="text-xs text-muted-foreground">{e.category}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{e.starting_monthly_usd && (
<span className="font-mono text-xs text-muted-foreground">
${e.starting_monthly_usd.toFixed(2)}/mo
</span>
)}
{selected === e.id && <CheckCircle2 className="h-4 w-4 text-primary" />}
</div>
</button>
))}
</div>
<DialogFooter className="gap-2">
<Button type="button" variant="ghost" onClick={onClose} className="text-xs">
Cancel
</Button>
<Button
type="button"
disabled={!selected || busy}
onClick={() => onConfirm(bill?.id, selected === 'unlink' ? null : selected)}
className="text-xs"
>
{busy ? 'Saving…' : 'Confirm'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ── 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 (
<Card className="overflow-hidden">
<CardHeader className="px-4 pb-3 sm:px-6">
<CardTitle className="text-base">Known Service Catalog</CardTitle>
<CardDescription>
Popular services, linked bills, and custom bank descriptors used to improve matching.
</CardDescription>
{/* Category filter chips */}
<div className="mt-3 flex flex-wrap gap-1.5">
{CHIPS.map(chip => (
<button
key={String(chip.value)}
type="button"
onClick={() => setActiveType(chip.value)}
className={cn(
'rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors',
activeType === chip.value
? 'border-primary/40 bg-primary/15 text-primary'
: 'border-border/60 bg-muted/20 text-muted-foreground hover:border-border/80 hover:text-foreground',
)}
>
{chip.label}
</button>
))}
</div>
{/* Search */}
<div className="relative mt-2">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search services…"
value={search}
onChange={e => setSearch(e.target.value)}
className="h-9 pl-8 text-sm"
/>
</div>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading catalog
</div>
) : error ? (
<div className="px-4 py-8 text-center">
<p className="text-sm text-destructive">{error}</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-3 text-xs"
onClick={loadCatalog}
>
Retry
</Button>
</div>
) : (
<div className="divide-y divide-border/40">
{/* ── Matched group ───────────────────────────────────────── */}
{matched.length > 0 && (
<div className="px-4 pb-3 pt-3 sm:px-6">
<p className="mb-2.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Tracking ({matched.length})
</p>
<div className="space-y-2">
{matched.map(entry => (
<MatchedCard
key={entry.id}
entry={entry}
onEdit={onEditBill}
onRelink={setRelinkEntry}
onAddDescriptor={handleAddDescriptor}
onDeleteDescriptor={(descriptorId) => handleDeleteDescriptor(entry.id, descriptorId)}
/>
))}
</div>
</div>
)}
{/* ── Unmatched toggle + group ─────────────────────────── */}
<div>
<button
type="button"
onClick={() => setShowUnmatched(s => !s)}
className={cn(
'flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-muted-foreground',
'transition-colors hover:bg-muted/20 hover:text-foreground sm:px-6',
)}
>
<span className="flex items-center gap-2">
<ChevronDown
className={cn('h-4 w-4 transition-transform', !showUnmatched && '-rotate-90')}
/>
{showUnmatched
? `Hide ${unmatched.length} more service${unmatched.length !== 1 ? 's' : ''}`
: `Show ${unmatched.length} more service${unmatched.length !== 1 ? 's' : ''}`}
</span>
{selectedIds.size > 0 && (
<Badge variant="outline" className="border-primary/30 bg-primary/10 text-primary text-[10px]">
{selectedIds.size} selected
</Badge>
)}
</button>
{showUnmatched && (
<div className="px-4 pb-4 pt-1 sm:px-6">
{unmatched.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
{search || activeType ? 'No services match your filter.' : 'All known services are already being tracked.'}
</p>
) : (
<div className="space-y-1.5">
{unmatched.map(entry => (
<UnmatchedCard
key={entry.id}
entry={entry}
selected={selectedIds.has(entry.id)}
onToggleSelect={toggleSelect}
onTrack={handleTrack}
trackingBusy={trackBusyId === entry.id}
/>
))}
</div>
)}
</div>
)}
</div>
</div>
)}
</CardContent>
{/* ── Re-link dialog ─────────────────────────────────────────────── */}
<ReLinkDialog
open={!!relinkEntry}
relinkEntry={relinkEntry}
allEntries={catalog}
onClose={() => setRelinkEntry(null)}
onConfirm={handleRelink}
busy={relinkBusy}
/>
{/* ── Bulk action bar ────────────────────────────────────────────── */}
{selectedIds.size > 0 && (
<div className="sticky bottom-0 flex items-center justify-between gap-3 border-t border-primary/20 bg-card/95 px-4 py-3 backdrop-blur-md sm:px-6">
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-primary/30 bg-primary/10 font-mono text-primary">
{selectedIds.size}
</Badge>
<span className="text-sm font-medium">
{selectedIds.size === 1 ? 'service' : 'services'} selected
</span>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs"
onClick={() => setSelectedIds(new Set())}
disabled={bulkBusy}
>
Clear
</Button>
<Button
type="button"
size="sm"
className="text-xs"
disabled={bulkBusy}
onClick={handleBulkTrack}
>
{bulkBusy
? <><Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />Adding</>
: `Track ${selectedIds.size} selected`}
</Button>
</div>
</div>
)}
</Card>
);
}