feat(subscriptions): simplified SubscriptionsPage, inline actions, improved matching card, Service Catalog route
- Extracted known-service catalog to dedicated /subscriptions/catalog route - Simplified main Subscriptions page to focus on tracked services + bank-backed recommendations - Replaced inline Pause/Resume with Edit + MoreHorizontal dropdown on subscription rows - Added 'Improve Matching' card linking to Service Catalog - Vite proxy respects API_PORT env var for dev flexibility - Added top_200_us_subscriptions_researched dataset - Updated HISTORY.md with v0.35.0 changes
This commit is contained in:
parent
a1e6a308cf
commit
83e6afa9e6
14
HISTORY.md
14
HISTORY.md
|
|
@ -5,6 +5,8 @@
|
|||
|
||||
- **Improved unmatch flow — choice dialog + bulk deselect** — Clicking Unmatch on a linked transaction in the Bill modal now opens a two-option choice dialog instead of immediately removing the match. Option 1 ("Unmatch this payment only") confirms via a single AlertDialog and removes only that transaction. Option 2 ("Review all similar matches") fetches all linked transactions for the bill whose payee normalizes to the same prefix and opens a checklist dialog where each similar match is pre-checked. Users can deselect individual transactions to keep them matched, use All/None quick-selects, and optionally check a "Remove merchant rule" checkbox (shown only when a merchant rule matching the payee pattern exists on the bill). The confirm button shows the count of selected transactions and is disabled when nothing is selected. New backend endpoint `POST /api/transactions/unmatch-bulk` handles mixed `provider_sync` (restores balance + soft-deletes payment) and `transaction_match` (standard unmatch service) entries in a single database transaction.
|
||||
|
||||
- **Service Catalog page for subscription matching** — The full known-service catalog moved out of the main Subscriptions page and into its own dedicated route at `/subscriptions/catalog`. The catalog now acts as an advanced matching tool instead of a second subscriptions list: tracked entries appear under a "Tracking" header with price drift indicators, each linked entry can be edited in BillModal, Re-link opens a searchable dialog to swap or remove the catalog link, and Custom bank descriptors let users add exact payee strings from their bank statements to improve future matching. Untracked catalog entries remain searchable/filterable and can still be tracked individually or in bulk. The Subscriptions page now shows a compact "Improve Matching" card that links to the Service Catalog when users need to tune descriptors, fix a wrong service link, or connect an existing bill to a known service. Catalog load failures now show both inline error state and toast feedback. New migration v0.96 adds `bills.catalog_id FK` (backfilled for existing subscriptions via name matching) and the `user_catalog_descriptors` table for per-user custom payee strings; user descriptors are merged into `loadCatalog` so they improve auto-matching for only that user's account.
|
||||
|
||||
- **Subscription catalog: bank descriptors + pricing (2026 researched dataset)** — `subscription_catalog` now carries researched bank statement descriptor strings and common nicknames/slang from a 200-service dataset. Migration v0.95 adds four columns (`subcategory`, `starting_monthly_usd`, `starting_annual_usd`, `price_notes`) and a new `subscription_catalog_descriptors` table (1,501 rows: 1,069 bank descriptors + 432 slang terms). `loadCatalog` joins descriptors into each catalog entry at load time. `lookupCatalog` now checks bank statement descriptors first (score 2000+) before falling back to name/domain fuzzy-match (1000/500); this resolves cases where bank payee strings like "NETFLIX *SUBSCRIPTION" or "AMZN PRIME VIDEO" bore no obvious similarity to the service name. `catalogMatchPayload` now includes `starting_monthly_usd`.
|
||||
|
||||
- **Improved unmatch flow — choice dialog + bulk deselect** — Clicking Unmatch on a linked transaction in the Bill modal now opens a two-option choice dialog. Option 1 ("Unmatch this payment only") shows a confirmation AlertDialog and removes just that transaction. Option 2 ("Review all similar matches") fetches all linked transactions for the bill whose payee normalizes to the same prefix and opens a checklist dialog: each similar match is pre-checked, users can deselect ones to keep, All/None quick-selects are available, and an optional "Remove merchant rule" checkbox appears when a matching merchant rule exists. Confirm shows the count of selected transactions. New backend endpoint `POST /api/transactions/unmatch-bulk` handles mixed `provider_sync` (restores balance + soft-deletes payment) and `transaction_match` entries in a single database transaction.
|
||||
|
|
@ -26,6 +28,18 @@
|
|||
|
||||
### 🔧 Changed
|
||||
|
||||
- **Subscription recommendations narrowed to bank-backed known services** — The Recommendations panel now only shows high-confidence (`90%+`) bank transaction matches that resolve to a known subscription catalog entry. Unknown recurring merchant patterns are no longer mixed into primary recommendations; those can be reviewed separately later without diluting the "known service" signal. Recommendation confidence now separates identity, amount, and cadence evidence instead of relying on a single recurrence score: user-added descriptors and researched bank descriptors score strongest, service name/domain/slang matches score lower, catalog starting monthly/annual pricing is used to judge amount plausibility, and recurring cadence/amount stability add confidence when multiple charges exist. A single exact known bank descriptor with a plausible amount can still appear as a 90%+ recommendation, but weak one-off name/domain matches no longer do. Recommendation cards now expose the evidence to the user with badges and reason chips for descriptor type, price check, recurring cadence, amount range, catalog starting price, account, last seen date, and average amount.
|
||||
|
||||
- **Subscription recommendation review details** — Recommendation scoring now includes an ambiguity evidence bucket for broad merchants and very short service names such as Amazon, Apple, Google, Walmart, Disney, Microsoft, Target, and Max. Exact known/user bank descriptors remain strong, but weaker broad name/domain/slang matches receive a confidence penalty and surface a "Review" badge with reasons. Recommendation payloads now include the matched transaction previews, and the Subscriptions page has a Details dialog showing identity evidence, price evidence, cadence evidence, ambiguity notes, amount range, catalog pricing, source accounts, and the specific bank transactions behind the recommendation before the user tracks, declines, or links it to an existing bill.
|
||||
|
||||
- **Subscription recommendations learn and prefer existing bills** — Recommendations now inspect the user's existing bills instead of hiding matches when a bill name already exists. If an existing bill matches the known service or bank merchant, and especially when amount and due day line up, the recommendation's preferred action becomes "Link existing" rather than "Track new"; linking can also backfill the bill's `catalog_id` when the bill was named correctly but not yet connected to the catalog. New migration v0.97 adds `subscription_recommendation_feedback` so accepts, declines, existing-bill links, catalog relinks/unlinks, and descriptor additions/removals become per-user learning signals. Future scoring gets a modest user-specific boost or penalty from that history while hard-declined recommendations remain suppressed. Edge-case coverage now pins broad Apple/Amazon/Google one-off purchases, annual known-service charges, exact user descriptors, existing-bill link preference, and feedback-driven confidence changes.
|
||||
|
||||
- **Subscription page actions simplified** — Recommendation cards now show one clear primary action (`Track Subscription` or `Link Existing Bill`), a Details icon, and a compact More menu for secondary actions such as choosing another bill, tracking as new, or dismissing. Tracked subscription rows now use a cleaner Edit + More menu pattern for pause/resume, and dialog/header/search/catalog action buttons use more consistent sizing and icon spacing. The noisy full reason-chip list was removed from the recommendation card surface and remains available in the Details dialog, making the page easier to scan.
|
||||
|
||||
- **Subscriptions page simplified** — Removed the full known-service catalog from the main Subscriptions page to reduce overload. The page now focuses on tracked subscriptions, strict known-service recommendations from bank data, transaction search, and a small "Improve Matching" link to the dedicated Service Catalog. Supporting failures that were previously quiet now surface toasts: loading bills for the link-to-bill dialog, transaction search errors, and catalog load errors.
|
||||
|
||||
- **Vite API proxy respects alternate API ports** — `vite.config.mjs` now reads `API_PORT` or `PORT` when configuring the `/api` proxy instead of hardcoding port `3000`. This lets local development run the Bill Tracker API on another port when `3000` is already occupied.
|
||||
|
||||
- **SimpleFIN transaction deduplication stable across disconnect/reconnect** — `provider_transaction_id` was built as `simplefin:{data_source_id}:{account_id}:{tx_id}`. When a user disconnected (deleting the data source) and reconnected (creating a new data source with a new ID), the ID in the key changed, so nothing matched the old orphaned rows and the full transaction history was duplicated. Changed the key format to `simplefin:{account_id}:{tx_id}` (no data_source_id) — the SimpleFIN account ID and transaction ID are stable identifiers assigned by the financial institution. The unique dedupe index was changed from `(data_source_id, provider_transaction_id)` to `(user_id, provider_transaction_id)` to match the new scope. Migration v0.93 rewrites all existing keys (stripping the numeric data_source_id segment), deduplicates any rows that are now identical after the key change (preserving the linked row over the orphan), and replaces the index atomically.
|
||||
|
||||
- **Transaction currency from account, not hardcoded USD** — `normalizeTransaction` hardcoded `currency: 'USD'` for every imported transaction even though `normalizeAccount` correctly reads `rawAccount.currency`. Non-USD users' transactions were always mislabeled. The currency is now read from the account's own currency field; the `'USD'` fallback only applies when the account has no currency data.
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
Info,
|
||||
Link2,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Pause,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
|
|
@ -32,6 +33,15 @@ import { Input } from '@/components/ui/input';
|
|||
import {
|
||||
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import BillModal from '@/components/BillModal';
|
||||
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
|
||||
|
||||
|
|
@ -69,7 +79,43 @@ function StatCard({ icon: Icon, label, value, hint }) {
|
|||
);
|
||||
}
|
||||
|
||||
function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps }) {
|
||||
function SubscriptionRowActions({ item, onEdit, onToggle, busy }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2 md:flex md:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 min-w-0 px-3"
|
||||
onClick={() => onEdit(item)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
disabled={busy}
|
||||
aria-label={`More actions for ${item.name}`}
|
||||
>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <MoreHorizontal className="h-4 w-4" />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem onSelect={() => onToggle(item)}>
|
||||
{item.active ? <Pause className="h-4 w-4" /> : <CheckCircle2 className="h-4 w-4" />}
|
||||
{item.active ? 'Pause' : 'Resume'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps, busy }) {
|
||||
return (
|
||||
<div
|
||||
draggable={dragProps?.draggable}
|
||||
|
|
@ -155,23 +201,7 @@ function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 md:justify-end">
|
||||
<Button type="button" variant="outline" size="sm" className="flex-1 md:flex-none" onClick={() => onEdit(item)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'flex-1 md:flex-none',
|
||||
item.active ? 'text-amber-300 hover:bg-amber-500/10 hover:text-amber-200' : 'text-emerald-300 hover:bg-emerald-500/10 hover:text-emerald-200',
|
||||
)}
|
||||
onClick={() => onToggle(item)}
|
||||
>
|
||||
{item.active ? 'Pause' : 'Resume'}
|
||||
</Button>
|
||||
</div>
|
||||
<SubscriptionRowActions item={item} onEdit={onEdit} onToggle={onToggle} busy={busy} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -232,10 +262,13 @@ function BillPickerDialog({ open, onClose, recommendation, bills, onConfirm, bus
|
|||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose} disabled={busy}>Cancel</Button>
|
||||
<Button onClick={() => onConfirm(selectedId)} disabled={!selectedId || busy}>
|
||||
{busy ? <><Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />Linking…</> : 'Link to bill'}
|
||||
<DialogFooter className="gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" className="gap-2" onClick={() => onConfirm(selectedId)} disabled={!selectedId || busy}>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Link2 className="h-3.5 w-3.5" />}
|
||||
Link Bill
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
@ -277,7 +310,7 @@ function TxResultRow({ tx, onTrack }) {
|
|||
<span className="font-mono text-sm font-semibold tabular-nums shrink-0">${dollars}</span>
|
||||
{!isMatched && (
|
||||
<Button size="sm" variant="outline" onClick={() => onTrack(tx)}
|
||||
className="shrink-0 h-7 px-2 text-xs gap-1">
|
||||
className="h-8 shrink-0 gap-1.5 px-2.5 text-xs">
|
||||
<Plus className="h-3 w-3" /> Track
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -285,6 +318,62 @@ function TxResultRow({ tx, onTrack }) {
|
|||
);
|
||||
}
|
||||
|
||||
function RecommendationIconButton({ label, icon: Icon, onClick, disabled, className, variant = 'outline' }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={variant}
|
||||
className={cn('h-9 w-9 shrink-0 p-0', className)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendationMoreMenu({ recommendation, existingBill, busy, onAccept, onDecline, onMatch, categoryId }) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-9 w-9 shrink-0 p-0"
|
||||
disabled={busy}
|
||||
aria-label="More recommendation actions"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{existingBill && (
|
||||
<DropdownMenuItem onSelect={() => onAccept({ ...recommendation, category_id: categoryId })}>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Track as new
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => onMatch(recommendation)}>
|
||||
<Link2 className="h-4 w-4" />
|
||||
{existingBill ? 'Choose different bill' : 'Link existing bill'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem destructive onSelect={() => onDecline(recommendation)}>
|
||||
<X className="h-4 w-4" />
|
||||
Dismiss
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, onQuickLink, onDetails, busy }) {
|
||||
const identity = recommendation.evidence?.identity;
|
||||
const amount = recommendation.evidence?.amount;
|
||||
|
|
@ -294,7 +383,8 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
|
|||
const existingBill = recommendation.existing_bill_match;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/[0.05] p-4">
|
||||
<TooltipProvider delayDuration={180}>
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/[0.05] p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
|
|
@ -372,61 +462,49 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
|
|||
Range {fmt(amountRange.min)}-{fmt(amountRange.max)}
|
||||
</span>
|
||||
)}
|
||||
{recommendation.reasons?.map(reason => (
|
||||
<span key={reason} className="max-w-full rounded-md border border-border/60 bg-background/60 px-2 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
{reason}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 min-[520px]:grid-cols-4">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
variant="default"
|
||||
className="h-9 min-w-0 gap-2 px-3"
|
||||
disabled={busy}
|
||||
onClick={() => existingBill
|
||||
? onQuickLink(recommendation, existingBill.id)
|
||||
: onAccept({ ...recommendation, category_id: categoryId })}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : existingBill ? (
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="min-w-0 truncate">
|
||||
{existingBill ? 'Link Existing Bill' : 'Track Subscription'}
|
||||
</span>
|
||||
</Button>
|
||||
<RecommendationIconButton
|
||||
label="View recommendation details"
|
||||
icon={Info}
|
||||
disabled={busy}
|
||||
onClick={() => onDetails(recommendation)}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
Details
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="gap-1.5 text-muted-foreground hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => onDecline(recommendation)}
|
||||
>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
||||
Decline
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={existingBill ? 'default' : 'outline'}
|
||||
className="gap-1.5"
|
||||
disabled={busy}
|
||||
onClick={() => existingBill ? onQuickLink(recommendation, existingBill.id) : onMatch(recommendation)}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{existingBill ? 'Link existing' : 'Link to bill'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={existingBill ? 'outline' : 'default'}
|
||||
className="gap-2"
|
||||
disabled={busy}
|
||||
onClick={() => onAccept({ ...recommendation, category_id: categoryId })}
|
||||
>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
|
||||
{existingBill ? 'Track new' : 'Track'}
|
||||
</Button>
|
||||
/>
|
||||
<RecommendationMoreMenu
|
||||
recommendation={recommendation}
|
||||
existingBill={existingBill}
|
||||
busy={busy}
|
||||
onAccept={onAccept}
|
||||
onDecline={onDecline}
|
||||
onMatch={onMatch}
|
||||
categoryId={categoryId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -592,19 +670,33 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
|
|||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button variant="ghost" onClick={handleDecline} disabled={busy} className="gap-1.5 text-muted-foreground hover:text-destructive">
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
||||
Decline
|
||||
</Button>
|
||||
<Button variant={existingBill ? 'default' : 'outline'} onClick={existingBill ? handleQuickLink : handleMatch} disabled={busy} className="gap-1.5">
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{existingBill ? 'Link existing' : 'Link to bill'}
|
||||
</Button>
|
||||
<Button variant={existingBill ? 'outline' : 'default'} onClick={handleAccept} disabled={busy} className="gap-2">
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
|
||||
{existingBill ? 'Track new' : 'Track'}
|
||||
Dismiss
|
||||
</Button>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row">
|
||||
{existingBill && (
|
||||
<Button variant="outline" onClick={handleAccept} disabled={busy} className="gap-1.5">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Track New
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={handleMatch} disabled={busy} className="gap-1.5">
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{existingBill ? 'Choose Bill' : 'Link Existing'}
|
||||
</Button>
|
||||
<Button variant="default" onClick={existingBill ? handleQuickLink : handleAccept} disabled={busy} className="gap-2">
|
||||
{busy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : existingBill ? (
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{existingBill ? 'Link Existing' : 'Track Subscription'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
@ -915,11 +1007,11 @@ export default function SubscriptionsPage() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
||||
<Button type="button" variant="outline" className="gap-2" onClick={refreshAll} disabled={loading || recommendationsLoading}>
|
||||
<Button type="button" variant="outline" className="h-9 gap-2" onClick={refreshAll} disabled={loading || recommendationsLoading}>
|
||||
<RefreshCw className={cn('h-4 w-4', (loading || recommendationsLoading) && 'animate-spin')} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button type="button" className="gap-2" onClick={openManualSubscription}>
|
||||
<Button type="button" className="h-9 gap-2" onClick={openManualSubscription}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Subscription
|
||||
</Button>
|
||||
|
|
@ -960,6 +1052,7 @@ export default function SubscriptionsPage() {
|
|||
item={item}
|
||||
onEdit={bill => setModal({ bill })}
|
||||
onToggle={toggleSubscription}
|
||||
busy={busyId === `toggle-${item.id}`}
|
||||
moveControls={moveControlsForGroup(active, true)(item, index)}
|
||||
dragProps={dragPropsForGroup(active, true)(item, index)}
|
||||
/>
|
||||
|
|
@ -970,6 +1063,7 @@ export default function SubscriptionsPage() {
|
|||
item={item}
|
||||
onEdit={bill => setModal({ bill })}
|
||||
onToggle={toggleSubscription}
|
||||
busy={busyId === `toggle-${item.id}`}
|
||||
moveControls={moveControlsForGroup(paused, false)(item, index)}
|
||||
dragProps={dragPropsForGroup(paused, false)(item, index)}
|
||||
/>
|
||||
|
|
@ -1117,7 +1211,7 @@ export default function SubscriptionsPage() {
|
|||
<p className="text-sm text-muted-foreground">
|
||||
Use the service catalog when a recommendation names the wrong service, a bill needs a catalog link, or your bank uses a custom descriptor.
|
||||
</p>
|
||||
<Button asChild type="button" variant="outline" className="shrink-0 gap-2">
|
||||
<Button asChild type="button" variant="outline" className="h-9 shrink-0 gap-2">
|
||||
<Link to="/subscriptions/catalog">
|
||||
<Link2 className="h-4 w-4" />
|
||||
Service Catalog
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.36.0",
|
||||
"version": "0.37.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bill-tracker",
|
||||
"version": "0.36.0",
|
||||
"version": "0.37.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
|
|
@ -21,6 +21,8 @@
|
|||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@simplewebauthn/browser": "^13.0.0",
|
||||
"@simplewebauthn/server": "^13.0.0",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"@tanstack/react-query-devtools": "^5.100.9",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
|
@ -2090,6 +2092,12 @@
|
|||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@hexagon/base64": {
|
||||
"version": "1.1.28",
|
||||
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
|
||||
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
|
||||
|
|
@ -2157,6 +2165,12 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@levischuck/tiny-cbor": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
|
||||
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
|
|
@ -2260,6 +2274,174 @@
|
|||
"@otplib/core": "13.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-android": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.7.0.tgz",
|
||||
"integrity": "sha512-iD3VskhVQnM4nE3PN9cBdPTR7JrqZy3FYk+uD2CeG6DUqKoANqaEfx0f7izPmW+Qm5JBM35ek+viLCmjy18ByQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-cms": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz",
|
||||
"integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"@peculiar/asn1-x509-attr": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-csr": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz",
|
||||
"integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-ecc": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz",
|
||||
"integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pfx": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz",
|
||||
"integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.7.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.7.0",
|
||||
"@peculiar/asn1-rsa": "^2.7.0",
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs8": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz",
|
||||
"integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs9": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz",
|
||||
"integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.7.0",
|
||||
"@peculiar/asn1-pfx": "^2.7.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.7.0",
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"@peculiar/asn1-x509-attr": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-rsa": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz",
|
||||
"integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz",
|
||||
"integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz",
|
||||
"integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509-attr": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz",
|
||||
"integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/utils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
|
||||
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/x509": {
|
||||
"version": "1.14.3",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
|
||||
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.6.0",
|
||||
"@peculiar/asn1-csr": "^2.6.0",
|
||||
"@peculiar/asn1-ecc": "^2.6.0",
|
||||
"@peculiar/asn1-pkcs9": "^2.6.0",
|
||||
"@peculiar/asn1-rsa": "^2.6.0",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"tslib": "^2.8.1",
|
||||
"tsyringe": "^4.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||
|
|
@ -3862,6 +4044,31 @@
|
|||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "13.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
|
||||
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@simplewebauthn/server": {
|
||||
"version": "13.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.1.tgz",
|
||||
"integrity": "sha512-GV/oM/qeycWn8p42JZIMJBsXWQcNFg+nJFzeQTnMA4gN8mXg0+HZFWJerHg8ZN/zlveMS3iV1wzuFpOVWS/46w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hexagon/base64": "^1.1.27",
|
||||
"@levischuck/tiny-cbor": "^0.2.2",
|
||||
"@peculiar/asn1-android": "^2.6.0",
|
||||
"@peculiar/asn1-ecc": "^2.6.1",
|
||||
"@peculiar/asn1-rsa": "^2.6.1",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.1",
|
||||
"@peculiar/x509": "^1.14.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.100.9",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.9.tgz",
|
||||
|
|
@ -4239,6 +4446,20 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
||||
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.5",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
|
|
@ -8919,6 +9140,24 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pvtsutils": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pvutils": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
|
||||
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
|
|
@ -9274,6 +9513,12 @@
|
|||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
|
|
@ -10615,6 +10860,24 @@
|
|||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsyringe": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
|
||||
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^1.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
|
|
|
|||
Loading…
Reference in New Issue