diff --git a/apps/desktop/src/app/settings/billing/index.test.tsx b/apps/desktop/src/app/settings/billing/index.test.tsx index 7052e111d25..65fe9c91cda 100644 --- a/apps/desktop/src/app/settings/billing/index.test.tsx +++ b/apps/desktop/src/app/settings/billing/index.test.tsx @@ -15,7 +15,6 @@ import { todayBillingState, todaySubscriptionState } from './fixtures.test-util' -import { formatUsageUpdatedAgo } from './use-billing-state' import { BillingSettings } from './index' @@ -265,7 +264,7 @@ describe('BillingSettings', () => { // plans capability, so the URL must not surface a grid of Choose buttons. renderBilling(['/settings?tab=billing&bview=plans']) - expect(await screen.findByText('Payment')).toBeTruthy() + expect(await screen.findByText('Payment & credits')).toBeTruthy() expect(screen.queryByText('Plans')).toBeNull() expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull() }) @@ -277,7 +276,7 @@ describe('BillingSettings', () => { renderBilling(['/settings?tab=billing&bview=plans']) - expect(await screen.findByText('Payment')).toBeTruthy() + expect(await screen.findByText('Payment & credits')).toBeTruthy() expect(screen.queryByText('Plans')).toBeNull() expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull() }) @@ -314,7 +313,7 @@ describe('BillingSettings', () => { await waitFor(() => expect(apiMocks.scheduleSubscriptionChange).toHaveBeenCalledWith('cltier000free0000personal')) await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'subscription'] })) // Scheduled → back on the overview. - expect(await screen.findByText('Payment')).toBeTruthy() + expect(await screen.findByText('Payment & credits')).toBeTruthy() expect(screen.queryByText('Plans')).toBeNull() }) @@ -653,65 +652,34 @@ describe('BillingSettings', () => { expect(monthlyCapTrack.classList.contains('bg-(--ui-bg-elevated)')).toBe(true) }) - it('refreshes both billing queries from the usage refresh button', async () => { + it('shows a warn notice that names the no-card blocker with a portal link', async () => { + const fixture = billingDevFixtures['no-card'] + + apiMocks.fetchBillingState.mockResolvedValue(fixture.billing) + apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription) + + renderBilling() + + expect(await screen.findByText('No payment method on file')).toBeTruthy() + expect( + screen.getByText('Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.') + ).toBeTruthy() + expect(screen.getByRole('button', { name: /Add card/ })).toBeTruthy() + }) + + it('does not show the no-card notice when a card is on file', async () => { + renderBilling() + + await screen.findByText('$996.47') + expect(screen.queryByText('No payment method on file')).toBeNull() + }) + + it('polls billing on an interval without a manual refresh control', async () => { renderBilling() await screen.findByText('$120 of $220 left') - expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(1) - expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(1) - - fireEvent.click(screen.getByRole('button', { name: 'Refresh' })) - - await waitFor(() => expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(2)) - expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(2) - }) - - it('disables the usage refresh button while either query is fetching', async () => { - let settleBilling: (value: unknown) => void = () => {} - - let settleSubscription: (value: unknown) => void = () => {} - - apiMocks.fetchBillingState.mockResolvedValueOnce(okBilling(todayBillingState)).mockReturnValueOnce( - new Promise(resolve => { - settleBilling = resolve - }) - ) - apiMocks.fetchSubscriptionState.mockResolvedValueOnce(okSubscription(todaySubscriptionState)).mockReturnValueOnce( - new Promise(resolve => { - settleSubscription = resolve - }) - ) - - renderBilling() - - const refresh = await screen.findByRole('button', { name: 'Refresh' }) - - fireEvent.click(refresh) - - await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(true)) - - settleBilling(okBilling(todayBillingState)) - settleSubscription(okSubscription(todaySubscriptionState)) - - await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(false)) - }) -}) - -describe('formatUsageUpdatedAgo', () => { - it('formats sub-second and current timestamps as just now', () => { - expect(formatUsageUpdatedAgo(1_000, 1_000)).toBe('just now') - expect(formatUsageUpdatedAgo(1_500, 1_000)).toBe('just now') - }) - - it('formats seconds below a minute', () => { - expect(formatUsageUpdatedAgo(1_000, 60_000)).toBe('59s ago') - }) - - it('rounds elapsed time to whole minutes from 61 seconds', () => { - expect(formatUsageUpdatedAgo(1_000, 62_000)).toBe('1m ago') - }) - - it('formats one hour and later as hours', () => { - expect(formatUsageUpdatedAgo(1_000, 3_601_000)).toBe('1h ago') + // The manual refresh affordance is gone — the queries poll on their own. + expect(screen.queryByRole('button', { name: 'Refresh' })).toBeNull() + expect(screen.queryByText(/Updated/)).toBeNull() }) }) diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx index 2d234d74c88..2f783827f28 100644 --- a/apps/desktop/src/app/settings/billing/index.tsx +++ b/apps/desktop/src/app/settings/billing/index.tsx @@ -4,12 +4,11 @@ import { useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Tip } from '@/components/ui/tooltip' -import { BarChart3, ExternalLink, Lock, Package, Plus, RefreshCw } from '@/lib/icons' +import { BarChart3, CreditCard, ExternalLink, Package, Wrench } from '@/lib/icons' import { cn } from '@/lib/utils' import { useRouteEnumParam } from '../../hooks/use-route-enum-param' -import { ListRow, SectionHeading, SettingsContent } from '../primitives' +import { ListRow, SectionHeading, SettingsCard, SettingsContent, SettingsSection } from '../primitives' import { RowValue } from './account-row-value' import { BillingApiProvider } from './api' @@ -27,7 +26,6 @@ import { type BillingNoticeView, type BillingUsageRowView, deriveBillingView, - formatUsageUpdatedAgo, useBillingState, useSubscriptionState } from './use-billing-state' @@ -64,9 +62,23 @@ function SummaryCard({ label, value, tone }: { label: string; tone?: 'muted' | ' } function NoticeCard({ notice }: { notice: BillingNoticeView }) { + const warn = notice.tone === 'warn' + return ( -
-
{notice.title}
+
+
+ {notice.title} +
{notice.message}
@@ -351,53 +363,10 @@ function UsageRow({ row }: { row: BillingUsageRowView }) { ) } -function UsageRefreshRow({ - fixtureName, - isFetching, - onRefresh, - updatedAt -}: { - fixtureName?: BillingFixtureSelection - isFetching: boolean - onRefresh: () => void - updatedAt: number -}) { - const [now, setNow] = useState(() => Date.now()) - - useEffect(() => { - const interval = window.setInterval(() => setNow(Date.now()), 30_000) - - return () => window.clearInterval(interval) - }, []) - - if (fixtureName && fixtureName !== 'live') { - return ( -
- fixture: {fixtureName} -
- ) - } - - return ( -
- Updated {formatUsageUpdatedAgo(updatedAt, now)} - - - -
- ) -} - +// DEV-only preview switcher: swaps the whole page onto a canned fixture so every +// billing state can be reviewed without a matching live account. Marked with a +// wrench + "preview" so it never reads as a shipping control (it's compiled out of +// production builds entirely). function BillingFixtureSelect({ onValueChange, value @@ -406,23 +375,27 @@ function BillingFixtureSelect({ value: BillingFixtureSelection }) { return ( - +
+ + preview + +
) } @@ -464,15 +437,16 @@ function BillingSettingsContent({ const subscriptionResult = subscriptionState.data const view = deriveBillingView(billingResult, subscriptionResult) const billing = billingResult?.ok ? billingResult.data : undefined - const usageUpdatedAt = oldestUpdatedAt(billingState.dataUpdatedAt, subscriptionState.dataUpdatedAt) - const usageIsFetching = billingState.isFetching || subscriptionState.isFetching - - const refreshUsage = () => { - void Promise.all([billingState.refetch(), subscriptionState.refetch()]) - } const { paymentRow, refillRow, topupRow } = view + // The Payment & credits card groups every money control (payment method, + // one-time top-up, auto-refill) into a single divide-y list instead of three + // free-floating one-row sections. + const accountRows = [paymentRow, topupRow, refillRow].filter( + (row): row is BillingAccountRowView => row !== undefined + ) + // Gate the plans sub-view on the SAME capability that renders the in-app button // (`plan.action`): a team / non-changer deep-linking `bview=plans` must never // reach a grid of live Choose buttons — it falls back to the overview. @@ -491,59 +465,42 @@ function BillingSettingsContent({ -
-
+ {view.notice && } + +
+ {view.summary.map(item => ( ))} -
+
- {view.notice && } - {view.plan && ( -
- - setSubView('plans')} plan={view.plan} /> -
+ + + setSubView('plans')} plan={view.plan} /> + + )} - {paymentRow && ( -
- - -
- )} - - {topupRow && ( -
- - -
- )} - - {refillRow && ( -
- - -
+ {accountRows.length > 0 && ( + + + {accountRows.map(row => ( + + ))} + + )} {view.usageRows.length > 0 && ( - <> - -
+ + {view.usageRows.map(row => ( ))} - -
- + + )} { @@ -586,9 +543,3 @@ export function BillingSettings() { return } - -function oldestUpdatedAt(...timestamps: number[]): number { - const populated = timestamps.filter(timestamp => timestamp > 0) - - return populated.length > 0 ? Math.min(...populated) : Date.now() -} diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.ts b/apps/desktop/src/app/settings/billing/use-billing-state.ts index da02f441ebe..97ccf0cccc7 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts @@ -11,7 +11,12 @@ export const EMPTY_BILLING_VALUE = '—' export const FALLBACK_PORTAL_BILLING_URL = 'https://portal.nousresearch.com/billing' export const FALLBACK_PORTAL_URL = 'https://portal.nousresearch.com' +// Billing polls on its own while the page is mounted (react-query only ticks an +// active observer), so the view stays live without a manual refresh control — +// matching every other data view in the app. It pauses when the window is +// backgrounded (refetchIntervalInBackground defaults to false). const BILLING_QUERY_OPTIONS = { + refetchInterval: 30_000, refetchOnWindowFocus: true, retry: false, staleTime: 30_000 @@ -30,6 +35,8 @@ export interface BillingNoticeView { } message: string title: string + /** `warn` = an actionable blocker (e.g. no card); `info` = neutral guidance. */ + tone?: 'info' | 'warn' } export interface BillingRowActionView { @@ -207,7 +214,7 @@ export function deriveBillingView( const tiers = derivePlanTiers(subscription, billing.portal_url, capable, pending) return { - notice: undefined, + notice: noCardNotice(billing), paymentRow: paymentMethodRow(billing), plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable, pending), refillRow: autoReloadRow(billing), @@ -276,26 +283,6 @@ export function formatBillingDate(value?: null | string): string { return fmtDate.format(date) } -export function formatUsageUpdatedAgo(updatedAt: number, now: number): string { - const elapsedSeconds = Math.max(0, Math.floor((now - updatedAt) / 1000)) - - if (elapsedSeconds < 1) { - return 'just now' - } - - if (elapsedSeconds < 60) { - return `${elapsedSeconds}s ago` - } - - const elapsedMinutes = Math.floor(elapsedSeconds / 60) - - if (elapsedMinutes < 60) { - return `${elapsedMinutes}m ago` - } - - return `${Math.floor(elapsedMinutes / 60)}h ago` -} - function emptySummary(): BillingSummaryItemView[] { return [ { label: 'Balance', value: EMPTY_BILLING_VALUE }, @@ -311,7 +298,24 @@ function refusalNotice(refusal: BillingRefusal): BillingNoticeView { return { action: portalUrl ? { label: 'Open portal ↗', url: portalUrl } : undefined, message: resolved.message, - title: resolved.title + title: resolved.title, + tone: 'warn' + } +} + +// A logged-in account with no card can't buy credits or manage auto-refill, and +// every one of those controls disables silently — so lead the page with a single +// warn banner that names the blocker and links straight to the fix. +function noCardNotice(billing: BillingStateResponse): BillingNoticeView | undefined { + if (billing.card) { + return undefined + } + + return { + action: { label: 'Add card ↗', url: billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL }, + message: 'Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.', + title: 'No payment method on file', + tone: 'warn' } } diff --git a/apps/desktop/src/app/settings/primitives.tsx b/apps/desktop/src/app/settings/primitives.tsx index 4e55a2f9f4b..45d48c73fcf 100644 --- a/apps/desktop/src/app/settings/primitives.tsx +++ b/apps/desktop/src/app/settings/primitives.tsx @@ -38,6 +38,35 @@ export function SectionHeading({ icon: Icon, title, meta }: { icon: IconComponen ) } +// The canonical settings surface: a soft-bordered muted well. Callers own the +// inner padding (a `divide-y` list wants none; a single block wants `p-4`) so the +// one container styling stays consistent across every settings page. +export function SettingsCard({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
+} + +// A titled section: heading + body with the shared vertical rhythm. Keeps the +// heading and its content welded together so pages stop hand-rolling +// `
` at every call site. +export function SettingsSection({ + children, + icon, + meta, + title +}: { + children: ReactNode + icon: IconComponent + meta?: string + title: string +}) { + return ( +
+ + {children} +
+ ) +} + export function NavLink({ icon: Icon, label, diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index e66a433b30e..1fda821b796 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -34,6 +34,7 @@ import { IconCopy as Copy, IconCopy as CopyIcon, IconCpu as Cpu, + IconCreditCard as CreditCard, IconDownload as Download, IconEgg as Egg, IconExternalLink as ExternalLink, @@ -155,6 +156,7 @@ export { Copy, CopyIcon, Cpu, + CreditCard, Download, Egg, ExternalLink,