From b48a34ee333c11ecffbe0d32a4ab432bf1e303db Mon Sep 17 00:00:00 2001 From: null Date: Sat, 4 Jul 2026 22:12:49 -0500 Subject: [PATCH] =?UTF-8?q?refactor(ts):=20SubscriptionCatalogSection=20?= =?UTF-8?q?=E2=86=92=20TypeScript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typed CatalogEntry/Descriptor/MatchedBill; branded Dollars on monthly_equivalent. Completes all loose components/ conversions. typecheck 0, lint 0 errors, build green. --- ...ion.jsx => SubscriptionCatalogSection.tsx} | 125 ++++++++++++------ 1 file changed, 88 insertions(+), 37 deletions(-) rename client/components/{SubscriptionCatalogSection.jsx => SubscriptionCatalogSection.tsx} (87%) diff --git a/client/components/SubscriptionCatalogSection.jsx b/client/components/SubscriptionCatalogSection.tsx similarity index 87% rename from client/components/SubscriptionCatalogSection.jsx rename to client/components/SubscriptionCatalogSection.tsx index 271f725..8c252c0 100644 --- a/client/components/SubscriptionCatalogSection.jsx +++ b/client/components/SubscriptionCatalogSection.tsx @@ -1,5 +1,3 @@ -'use strict'; - import { useEffect, useMemo, useState } from 'react'; import { CheckCircle2, ChevronDown, ExternalLink, Link2, Link2Off, @@ -7,7 +5,8 @@ import { } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; -import { cn, fmt } from '@/lib/utils'; +import { cn, fmt, errMessage } from '@/lib/utils'; +import type { Dollars } from '@/lib/money'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -17,14 +16,37 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; -const TYPE_LABELS = { +interface Descriptor { + id: number | string; + descriptor?: string; +} + +interface MatchedBill { + id: number; + name?: string; + active?: number; + monthly_equivalent?: Dollars; +} + +interface CatalogEntry { + id: number | string; + name: string; + category?: string | null; + subscription_type?: string; + starting_monthly_usd?: number | null; + website?: string | null; + matched_bill?: MatchedBill | null; + user_descriptors?: Descriptor[]; +} + +const TYPE_LABELS: Record = { 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 = [ +const CHIPS: { value: string | null; label: string }[] = [ { value: null, label: 'All' }, { value: 'streaming', label: 'Streaming' }, { value: 'music', label: 'Music' }, @@ -40,7 +62,7 @@ const CHIPS = [ ]; // Returns 'match' | 'above' | 'below' | null -function priceDrift(monthly, catalogStarting) { +function priceDrift(monthly: number | null | undefined, catalogStarting: number | null | undefined): 'match' | 'above' | 'below' | null { if (!catalogStarting || !monthly) return null; const pct = (monthly - catalogStarting) / catalogStarting; if (Math.abs(pct) <= 0.05) return 'match'; @@ -48,7 +70,12 @@ function priceDrift(monthly, catalogStarting) { } // ── Descriptor editor inline inside a matched card ───────────────────────── -function DescriptorEditor({ catalogId, descriptors, onAdd, onDelete }) { +function DescriptorEditor({ catalogId, descriptors, onAdd, onDelete }: { + catalogId: number | string; + descriptors: Descriptor[]; + onAdd: (catalogId: number | string, descriptor: string) => Promise | void; + onDelete: (descriptorId: number | string) => void; +}) { const [open, setOpen] = useState(false); const [value, setValue] = useState(''); const [busy, setBusy] = useState(false); @@ -126,7 +153,13 @@ function DescriptorEditor({ catalogId, descriptors, onAdd, onDelete }) { } // ── Card for an already-tracked catalog entry ────────────────────────────── -function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescriptor }) { +function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescriptor }: { + entry: CatalogEntry; + onEdit: (id: number) => void; + onRelink: (entry: CatalogEntry) => void; + onAddDescriptor: (catalogId: number | string, descriptor: string) => Promise | void; + onDeleteDescriptor: (descriptorId: number | string) => void; +}) { const { matched_bill: bill, user_descriptors: descs = [] } = entry; const drift = priceDrift(bill?.monthly_equivalent, entry.starting_monthly_usd); @@ -140,7 +173,7 @@ function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescrip variant="outline" className="border-primary/25 bg-primary/10 text-[10px] text-primary" > - {TYPE_LABELS[entry.subscription_type] || 'Other'} + {TYPE_LABELS[entry.subscription_type ?? ''] || 'Other'} {bill && !bill.active && ( @@ -161,7 +194,7 @@ function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescrip )} {drift === 'above' && ( - catalog from ${entry.starting_monthly_usd.toFixed(2)}/mo + catalog from ${entry.starting_monthly_usd?.toFixed(2)}/mo )} {drift === 'below' && entry.starting_monthly_usd && ( @@ -188,7 +221,7 @@ function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescrip size="sm" variant="outline" className="h-7 gap-1 text-xs" - onClick={() => onEdit(bill.id)} + onClick={() => bill && onEdit(bill.id)} > Edit @@ -218,7 +251,13 @@ function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescrip } // ── Card for an untracked catalog entry ─────────────────────────────────── -function UnmatchedCard({ entry, selected, onToggleSelect, onTrack, trackingBusy }) { +function UnmatchedCard({ entry, selected, onToggleSelect, onTrack, trackingBusy }: { + entry: CatalogEntry; + selected: boolean; + onToggleSelect: (id: number | string, checked: boolean) => void; + onTrack: (entry: CatalogEntry) => void; + trackingBusy: boolean; +}) { return (
void; + onConfirm: (billId: number | undefined, catalogId: number | string | null) => void; + busy: boolean; +}) { const [search, setSearch] = useState(''); - const [selected, setSelected] = useState(null); + const [selected, setSelected] = useState(null); useEffect(() => { if (open) { setSearch(''); setSelected(null); } @@ -374,27 +420,30 @@ function ReLinkDialog({ open, relinkEntry, allEntries, onClose, onConfirm, busy } // ── Main component ───────────────────────────────────────────────────────── -export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete }) { - const [catalog, setCatalog] = useState([]); +export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete }: { + onEditBill: (id: number) => void; + onTrackComplete?: () => void; +}) { + const [catalog, setCatalog] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [error, setError] = useState(null); const [showUnmatched, setShowUnmatched] = useState(false); - const [activeType, setActiveType] = useState(null); + const [activeType, setActiveType] = useState(null); const [search, setSearch] = useState(''); - const [selectedIds, setSelectedIds] = useState(new Set()); - const [relinkEntry, setRelinkEntry] = useState(null); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [relinkEntry, setRelinkEntry] = useState(null); const [relinkBusy, setRelinkBusy] = useState(false); - const [trackBusyId, setTrackBusyId] = useState(null); + const [trackBusyId, setTrackBusyId] = useState(null); const [bulkBusy, setBulkBusy] = useState(false); async function loadCatalog() { setLoading(true); setError(null); try { - const data = await api.subscriptionCatalog(); + const data = await api.subscriptionCatalog() as { catalog?: CatalogEntry[] }; setCatalog(data.catalog || []); } catch (err) { - const message = err.message || 'Failed to load catalog'; + const message = errMessage(err, 'Failed to load catalog'); setError(message); toast.error(message); } finally { @@ -414,12 +463,12 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete }); }, [catalog, activeType, search]); - const matched = filtered.filter(e => e.matched_bill !== null); - const unmatched = filtered.filter(e => e.matched_bill === null); + const matched = filtered.filter(e => e.matched_bill != null); + const unmatched = filtered.filter(e => e.matched_bill == null); // ── Handlers ───────────────────────────────────────────────────────────── - async function handleTrack(entry) { + async function handleTrack(entry: CatalogEntry) { setTrackBusyId(entry.id); try { await api.createSubscriptionFromRecommendation({ @@ -436,7 +485,7 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete await loadCatalog(); onTrackComplete?.(); } catch (err) { - toast.error(err.message || `Failed to add ${entry.name}`); + toast.error(errMessage(err, `Failed to add ${entry.name}`)); } finally { setTrackBusyId(null); } @@ -475,23 +524,24 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete setBulkBusy(false); } - async function handleRelink(billId, catalogId) { + async function handleRelink(billId: number | undefined, catalogId: number | string | null) { + if (billId == null) return; setRelinkBusy(true); try { - await api.updateSubscriptionCatalogLink(billId, catalogId); + await api.updateSubscriptionCatalogLink(billId, catalogId as number | string); toast.success(catalogId ? 'Catalog link updated' : 'Catalog link removed'); setRelinkEntry(null); await loadCatalog(); } catch (err) { - toast.error(err.message || 'Failed to update catalog link'); + toast.error(errMessage(err, 'Failed to update catalog link')); } finally { setRelinkBusy(false); } } - async function handleAddDescriptor(catalogId, descriptor) { + async function handleAddDescriptor(catalogId: number | string, descriptor: string): Promise { try { - const newDesc = await api.addCatalogDescriptor(catalogId, descriptor); + const newDesc = await api.addCatalogDescriptor(catalogId, descriptor) as Descriptor; setCatalog(prev => prev.map(e => e.id === catalogId ? { ...e, user_descriptors: [...(e.user_descriptors || []), newDesc] } @@ -500,12 +550,12 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete toast.success('Descriptor added'); return true; } catch (err) { - toast.error(err.message || 'Failed to add descriptor'); + toast.error(errMessage(err, 'Failed to add descriptor')); return false; } } - async function handleDeleteDescriptor(catalogId, descriptorId) { + async function handleDeleteDescriptor(catalogId: number | string, descriptorId: number | string) { try { await api.deleteCatalogDescriptor(descriptorId); setCatalog(prev => prev.map(e => @@ -515,14 +565,15 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete )); toast.success('Descriptor removed'); } catch (err) { - toast.error(err.message || 'Failed to remove descriptor'); + toast.error(errMessage(err, 'Failed to remove descriptor')); } } - function toggleSelect(id, checked) { + function toggleSelect(id: number | string, checked: boolean) { setSelectedIds(prev => { const next = new Set(prev); - checked ? next.add(id) : next.delete(id); + if (checked) next.add(id); + else next.delete(id); return next; }); }