refactor(client): single source of truth for money formatting (IMP-CODE-01)
The client had ~15 hand-rolled currency formatters (local `fmt`/`money`/ `fmtFull`/`fmtDollars`/…) plus a canonical `fmt` in lib/utils used at ~190 sites — same rules copy-pasted, with inconsistent handling of negatives, whole-dollar, cents vs dollars, and blank input. Add client/lib/money.js as the one implementation: - formatUSD(dollars) — "$1,234.56" (whole/dash options) - formatUSDWhole(dollars) — "$1,235" - formatCentsUSD(cents) — from integer cents; signed "+/-" and dash options Inputs are coerced so null/''/NaN never render as "$NaN", and -0 is normalized so it never shows as "-$0.00". lib/utils.fmt now delegates to formatUSD (byte-identical — the existing utils.test.js fmt suite is the regression guard), and the 15 local formatters delegate to money.js. No display currency formatting remains outside money.js; the /100 conversions left behind are calculations (form prefill), not display. Tests: client/lib/money.test.js (13). Full client suite 46 pass; build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6f5ad9a015
commit
a15f00c568
|
|
@ -6,7 +6,7 @@ import {
|
|||
AlertTriangle, Building2, CheckCircle2, CalendarDays, Loader2, Plus, Tag, Trash2, X,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, fmt } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog';
|
||||
|
|
@ -344,7 +344,7 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
|||
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{s.label}</span>
|
||||
<span className="shrink-0 font-mono text-muted-foreground tabular-nums">
|
||||
${amountVal.toFixed(2)}
|
||||
{fmt(amountVal)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useActionState, useEffect, useState } from 'react';
|
||||
import { ChevronDown, Copy, Layers, Link2, Link2Off, Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { formatCentsUSD } from '@/lib/money';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
|
@ -62,13 +63,7 @@ const SUBSCRIPTION_TYPES = [
|
|||
];
|
||||
|
||||
function fmtTransactionAmount(amount, currency = 'USD') {
|
||||
const cents = Number(amount || 0);
|
||||
const value = Math.abs(cents) / 100;
|
||||
const sign = cents < 0 ? '-' : '+';
|
||||
return `${sign}${new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: currency || 'USD',
|
||||
}).format(value)}`;
|
||||
return formatCentsUSD(amount, { signed: true, currency });
|
||||
}
|
||||
|
||||
function transactionDate(tx) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ArrowDown, ArrowUp, Copy, GripVertical, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { scheduleLabel } from '@/lib/billingSchedule';
|
||||
import { formatUSDWhole } from '@/lib/money';
|
||||
import { MobileBillRow } from '@/components/MobileBillRow';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
|
|
@ -178,7 +179,7 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory,
|
|||
<>
|
||||
{(prefs.showCycle || prefs.showDueDay || (prefs.showApr && bill.interest_rate != null)) && <span className="text-border">·</span>}
|
||||
<span className="tracker-number text-[11px] font-medium text-muted-foreground">
|
||||
${Number(bill.current_balance).toLocaleString(undefined, { maximumFractionDigits: 0 })} balance
|
||||
{formatUSDWhole(bill.current_balance)} balance
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { formatUSD } from '@/lib/money';
|
||||
import { TrendingUp, EyeOff, Eye, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -8,7 +9,7 @@ import {
|
|||
} from '@/components/ui/dialog';
|
||||
|
||||
function fmt(n) {
|
||||
return Number(n || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
return formatUSD(n);
|
||||
}
|
||||
|
||||
export default function IncomeBreakdownModal({ open, onClose, year, month, bankTracking }) {
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import { toast } from 'sonner';
|
|||
import { api } from '@/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatUSD } from '@/lib/money';
|
||||
|
||||
function fmtAmt(dollars) {
|
||||
if (dollars == null) return '—';
|
||||
return `$${Number(dollars).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
return formatUSD(dollars, { dash: true });
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatUSD, formatCentsUSD } from '@/lib/money';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
|
@ -77,10 +78,7 @@ function fmtShortDate(date) {
|
|||
}
|
||||
|
||||
function fmtDollars(cents) {
|
||||
if (cents == null) return '—';
|
||||
const abs = Math.abs(cents) / 100;
|
||||
const sign = cents < 0 ? '-' : '';
|
||||
return `${sign}$${abs.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
return formatCentsUSD(cents, { dash: true });
|
||||
}
|
||||
|
||||
function MatchBadge({ status, billName }) {
|
||||
|
|
@ -867,7 +865,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
|||
<span>{acc.org_name ? `${acc.org_name} — ` : ''}{acc.name}</span>
|
||||
{acc.balance_dollars !== null && (
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||
${acc.balance_dollars.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
{formatUSD(acc.balance_dollars)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from 'react';
|
||||
import { formatUSD } from '@/lib/money';
|
||||
|
||||
const W = 720;
|
||||
const H = 300;
|
||||
|
|
@ -13,9 +14,7 @@ function money(v) {
|
|||
}
|
||||
|
||||
function fullMoney(v) {
|
||||
return (Number(v) || 0).toLocaleString(undefined, {
|
||||
style: 'currency', currency: 'USD', maximumFractionDigits: 2,
|
||||
});
|
||||
return formatUSD(v);
|
||||
}
|
||||
|
||||
function buildPoints(track, startBalance, maxMonths) {
|
||||
|
|
|
|||
|
|
@ -2,17 +2,14 @@ import React, { useState } from 'react';
|
|||
import { ChevronDown, History } from 'lucide-react';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatUSD, formatUSDWhole } from '@/lib/money';
|
||||
|
||||
function fmt(v) {
|
||||
return (Number(v) || 0).toLocaleString(undefined, {
|
||||
style: 'currency', currency: 'USD', maximumFractionDigits: 0,
|
||||
});
|
||||
return formatUSDWhole(v);
|
||||
}
|
||||
|
||||
function fmtFull(v) {
|
||||
return (Number(v) || 0).toLocaleString(undefined, {
|
||||
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2,
|
||||
});
|
||||
return formatUSD(v);
|
||||
}
|
||||
|
||||
function dateRange(plan) {
|
||||
|
|
|
|||
|
|
@ -7,11 +7,10 @@ import {
|
|||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatUSDWhole } from '@/lib/money';
|
||||
|
||||
function fmt(v) {
|
||||
return (Number(v) || 0).toLocaleString(undefined, {
|
||||
style: 'currency', currency: 'USD', maximumFractionDigits: 0,
|
||||
});
|
||||
return formatUSDWhole(v);
|
||||
}
|
||||
|
||||
function dateLabel(iso) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
CheckCircle2, Loader2, Link2, Search, Plus,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatCentsUSD } from '@/lib/money';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
|
|
@ -19,12 +20,7 @@ export function transactionTitle(tx) {
|
|||
}
|
||||
|
||||
export function formatTransactionAmount(amount, currency = 'USD') {
|
||||
const value = Math.abs(Number(amount || 0)) / 100;
|
||||
const sign = Number(amount || 0) < 0 ? '-' : '+';
|
||||
return `${sign}${new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: currency || 'USD',
|
||||
}).format(value)}`;
|
||||
return formatCentsUSD(amount, { signed: true, currency });
|
||||
}
|
||||
|
||||
export function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, loading, onCreateBill }) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
// Currency formatting for the client, mirroring the server's utils/money.js.
|
||||
//
|
||||
// The API sends money in two units: bill / summary values are serialized as
|
||||
// DOLLARS (the server calls fromCents before responding), while raw bank
|
||||
// transaction amounts arrive as integer CENTS. So there are two entry points —
|
||||
// formatUSD(dollars) and formatCentsUSD(cents) — matching the server's
|
||||
// formatUSD / formatCentsUSD split. USD / en-US throughout (the app is USD-only,
|
||||
// and this matches the server). Inputs are coerced defensively so null, '',
|
||||
// undefined, or NaN never render as "$NaN".
|
||||
|
||||
const DASH = '—';
|
||||
|
||||
function toNumber(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return n === 0 ? 0 : n; // normalize -0 → +0 so it never renders as "-$0.00"
|
||||
}
|
||||
|
||||
function isBlank(value) {
|
||||
return value === null || value === undefined || value === '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a DOLLAR amount → "$1,234.56".
|
||||
* @param {number|string|null} dollars
|
||||
* @param {{ whole?: boolean, dash?: boolean }} [opts]
|
||||
* whole — drop the cents ("$1,235"); dash — render blank input as "—" not "$0.00".
|
||||
*/
|
||||
export function formatUSD(dollars, { whole = false, dash = false } = {}) {
|
||||
if (dash && isBlank(dollars)) return DASH;
|
||||
return toNumber(dollars).toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: whole ? 0 : 2,
|
||||
maximumFractionDigits: whole ? 0 : 2,
|
||||
});
|
||||
}
|
||||
|
||||
/** Whole-dollar convenience → "$1,235". */
|
||||
export function formatUSDWhole(dollars, opts = {}) {
|
||||
return formatUSD(dollars, { ...opts, whole: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an integer-CENTS amount (e.g. a bank transaction) → "$12.34".
|
||||
* @param {number|string|null} cents
|
||||
* @param {{ signed?: boolean, dash?: boolean, currency?: string }} [opts]
|
||||
* signed — prefix "+"/"-" (income vs expense); dash — blank input → "—";
|
||||
* currency — ISO code (defaults USD).
|
||||
*/
|
||||
export function formatCentsUSD(cents, { signed = false, dash = false, currency = 'USD' } = {}) {
|
||||
if (dash && isBlank(cents)) return DASH;
|
||||
const c = toNumber(cents);
|
||||
const body = (Math.abs(c) / 100).toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency || 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
if (signed) return (c < 0 ? '-' : '+') + body;
|
||||
return (c < 0 ? '-' : '') + body;
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { formatUSD, formatUSDWhole, formatCentsUSD } from './money';
|
||||
|
||||
describe('formatUSD (dollars in)', () => {
|
||||
it('formats to two decimals with grouping', () => {
|
||||
expect(formatUSD(1234.5)).toBe('$1,234.50');
|
||||
expect(formatUSD(0)).toBe('$0.00');
|
||||
expect(formatUSD(8.99)).toBe('$8.99');
|
||||
});
|
||||
|
||||
it('formats negatives', () => {
|
||||
expect(formatUSD(-12.34)).toBe('-$12.34');
|
||||
});
|
||||
|
||||
it('normalizes negative zero to "$0.00" (not "-$0.00")', () => {
|
||||
expect(formatUSD(-0)).toBe('$0.00');
|
||||
expect(formatUSD(Math.round(-0.2))).toBe('$0.00'); // Math.round(-0.2) === -0
|
||||
});
|
||||
|
||||
it('coerces blank / non-numeric to $0.00 (never NaN)', () => {
|
||||
expect(formatUSD(null)).toBe('$0.00');
|
||||
expect(formatUSD(undefined)).toBe('$0.00');
|
||||
expect(formatUSD('')).toBe('$0.00');
|
||||
expect(formatUSD('not a number')).toBe('$0.00');
|
||||
});
|
||||
|
||||
it('accepts numeric strings', () => {
|
||||
expect(formatUSD('1234.5')).toBe('$1,234.50');
|
||||
});
|
||||
|
||||
it('whole:true drops the cents', () => {
|
||||
expect(formatUSD(1234.56, { whole: true })).toBe('$1,235');
|
||||
expect(formatUSDWhole(1234.4)).toBe('$1,234');
|
||||
});
|
||||
|
||||
it('dash:true renders blank input as an em dash, but 0 as $0.00', () => {
|
||||
expect(formatUSD(null, { dash: true })).toBe('—');
|
||||
expect(formatUSD(undefined, { dash: true })).toBe('—');
|
||||
expect(formatUSD('', { dash: true })).toBe('—');
|
||||
expect(formatUSD(0, { dash: true })).toBe('$0.00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCentsUSD (integer cents in)', () => {
|
||||
it('converts cents to dollars', () => {
|
||||
expect(formatCentsUSD(1234)).toBe('$12.34');
|
||||
expect(formatCentsUSD(0)).toBe('$0.00');
|
||||
expect(formatCentsUSD(99)).toBe('$0.99');
|
||||
});
|
||||
|
||||
it('unsigned negatives get a leading minus only', () => {
|
||||
expect(formatCentsUSD(-1234)).toBe('-$12.34');
|
||||
});
|
||||
|
||||
it('signed:true adds +/- for income vs expense', () => {
|
||||
expect(formatCentsUSD(1234, { signed: true })).toBe('+$12.34');
|
||||
expect(formatCentsUSD(-1234, { signed: true })).toBe('-$12.34');
|
||||
expect(formatCentsUSD(0, { signed: true })).toBe('+$0.00');
|
||||
});
|
||||
|
||||
it('dash:true renders blank input as an em dash', () => {
|
||||
expect(formatCentsUSD(null, { dash: true })).toBe('—');
|
||||
expect(formatCentsUSD(undefined, { dash: true })).toBe('—');
|
||||
expect(formatCentsUSD(0, { dash: true })).toBe('$0.00');
|
||||
});
|
||||
|
||||
it('coerces non-numeric to $0.00 (never NaN)', () => {
|
||||
expect(formatCentsUSD('abc')).toBe('$0.00');
|
||||
expect(formatCentsUSD(null)).toBe('$0.00');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
import { clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { formatUSD } from './money';
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// Canonical dollar formatter for the app ("$1,234.50"). Kept here for the many
|
||||
// existing call sites; the implementation lives in ./money (formatUSD) so all
|
||||
// currency formatting has a single source of truth.
|
||||
export function fmt(amount) {
|
||||
const n = Number(amount) || 0;
|
||||
const sign = n < 0 ? '-' : '';
|
||||
return sign + '$' + Math.abs(n).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return formatUSD(amount);
|
||||
}
|
||||
|
||||
export function fmtDate(dateStr) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { formatUSD, formatUSDWhole } from '@/lib/money';
|
||||
import { Printer, RefreshCw, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api';
|
||||
|
|
@ -27,19 +28,11 @@ function currentMonth() {
|
|||
}
|
||||
|
||||
function money(value) {
|
||||
return (Number(value) || 0).toLocaleString(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
return formatUSDWhole(value);
|
||||
}
|
||||
|
||||
function fullMoney(value) {
|
||||
return (Number(value) || 0).toLocaleString(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
return formatUSD(value);
|
||||
}
|
||||
|
||||
function formatRange(range) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
SelectSeparator, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatUSD, formatUSDWhole } from '@/lib/money';
|
||||
import PayoffChart from '@/components/snowball/PayoffChart';
|
||||
|
||||
// ─── Print isolation ──────────────────────────────────────────────────────────
|
||||
|
|
@ -36,14 +37,11 @@ const PRINT_STYLES = `
|
|||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmt(v) {
|
||||
return (Number(v) || 0).toLocaleString(undefined, {
|
||||
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2,
|
||||
});
|
||||
return formatUSD(v);
|
||||
}
|
||||
|
||||
function fmtShort(v) {
|
||||
const n = Number(v) || 0;
|
||||
return n.toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||
return formatUSDWhole(v);
|
||||
}
|
||||
|
||||
function buildPayoffSchedule(balance, annualRatePct, monthlyPayment, oneTimeExtra = 0) {
|
||||
|
|
|
|||
|
|
@ -14,19 +14,15 @@ import { makeBillDraft } from '@/lib/billDrafts';
|
|||
import PlanStatusBanner from '@/components/snowball/PlanStatusBanner';
|
||||
import PlanHistoryPanel from '@/components/snowball/PlanHistoryPanel';
|
||||
import * as AlertDialog from '@radix-ui/react-alert-dialog';
|
||||
import { formatUSD, formatUSDWhole } from '@/lib/money';
|
||||
|
||||
// ── formatters ────────────────────────────────────────────────────────────────
|
||||
function fmt(val) {
|
||||
if (val == null) return '—';
|
||||
return Number(val).toLocaleString(undefined, {
|
||||
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2,
|
||||
});
|
||||
return formatUSD(val, { dash: true });
|
||||
}
|
||||
function fmtCompact(val) {
|
||||
if (val == null || val === 0) return '—';
|
||||
return Number(val).toLocaleString(undefined, {
|
||||
style: 'currency', currency: 'USD', maximumFractionDigits: 0,
|
||||
});
|
||||
return formatUSDWhole(val);
|
||||
}
|
||||
function ordinal(n) {
|
||||
const d = Number(n);
|
||||
|
|
|
|||
|
|
@ -14,11 +14,12 @@ import {
|
|||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { CategoryPicker } from '@/components/transactions/CategoryPicker';
|
||||
import { formatUSD } from '@/lib/money';
|
||||
|
||||
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
|
||||
function fmt(n) {
|
||||
return Number(n || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
return formatUSD(n);
|
||||
}
|
||||
|
||||
function settingEnabled(value, fallback = true) {
|
||||
|
|
|
|||
|
|
@ -498,7 +498,7 @@ function BillPickerDialog({ open, onClose, recommendation, bills, onConfirm, bus
|
|||
}
|
||||
|
||||
function TxResultRow({ tx, onTrack }) {
|
||||
const dollars = (Math.abs(tx.amount) / 100).toFixed(2);
|
||||
const dollars = fmt(Math.abs(tx.amount) / 100);
|
||||
const label = tx.payee || tx.description || tx.memo || '—';
|
||||
const account = tx.account_name || tx.data_source_name || null;
|
||||
const isMatched = tx.match_status === 'matched';
|
||||
|
|
@ -545,7 +545,7 @@ function TxResultRow({ tx, onTrack }) {
|
|||
{catalogMatch ? ` · ${TYPE_LABELS[catalogMatch.subscription_type] || 'Other'}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span className="font-mono text-sm font-semibold tabular-nums shrink-0">${dollars}</span>
|
||||
<span className="font-mono text-sm font-semibold tabular-nums shrink-0">{dollars}</span>
|
||||
{!isMatched && (
|
||||
<Button size="sm" variant="outline" onClick={() => onTrack(tx)}
|
||||
className="h-8 shrink-0 gap-1.5 px-2.5 text-xs">
|
||||
|
|
|
|||
Loading…
Reference in New Issue