polish(desktop/billing): auto-poll, no-card notice, grouped card layout

Post-merge review polish of #68722 + #68761:
- Usage: drop the manual refresh + 'Updated Xm ago'; billing queries now
  refetchInterval-poll while the page is mounted, like every other data view.
- No card: lead the page with a warn notice naming the blocker + 'Add card ↗',
  so the silently-disabled buy/auto-refill controls have an obvious cause.
- Layout: unify on a shared SettingsCard/SettingsSection primitive; collapse the
  three floating one-row sections (Payment / One-time top-up / Automatic refill)
  into a single divide-y 'Payment & credits' card.
- Dev fixture switcher: relabel as a wrench + 'preview' dashed control so it
  reads as the DEV-only tool it is (compiled out of production).

Removed formatUsageUpdatedAgo/oldestUpdatedAt/UsageRefreshRow + their tests;
added no-card-notice and no-manual-refresh tests. vitest 107 green, tsc + eslint clean.
This commit is contained in:
Brooklyn Nicholson 2026-07-22 16:47:52 -05:00
parent e0d62b509e
commit f9a8ecc0ce
5 changed files with 158 additions and 204 deletions

View file

@ -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()
})
})

View file

@ -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 (
<div className="mb-5 rounded-lg border border-border/70 bg-muted/20 p-4">
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">{notice.title}</div>
<div
className={cn(
'mb-6 rounded-xl border p-4',
warn ? 'border-amber-500/30 bg-amber-500/5' : 'border-border/70 bg-muted/20'
)}
>
<div
className={cn(
'text-[length:var(--conversation-text-font-size)] font-medium',
warn ? 'text-amber-600 dark:text-amber-300' : 'text-foreground'
)}
>
{notice.title}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{notice.message}
</div>
@ -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 (
<div className="flex items-center justify-end pt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
fixture: {fixtureName}
</div>
)
}
return (
<div className="flex min-w-0 items-center justify-end gap-1.5 pt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>Updated {formatUsageUpdatedAgo(updatedAt, now)}</span>
<Tip label="Refresh">
<Button
aria-label="Refresh"
className="size-7 p-0 text-(--ui-text-tertiary)"
disabled={isFetching}
onClick={onRefresh}
size="sm"
type="button"
variant="ghost"
>
<RefreshCw className={cn('size-3.5', isFetching && 'animate-spin')} />
</Button>
</Tip>
</div>
)
}
// 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 (
<Select onValueChange={value => onValueChange(value as BillingFixtureSelection)} value={value}>
<SelectTrigger
aria-label="Billing fixture"
className="h-7 w-32 border-transparent bg-transparent px-1.5 text-xs font-normal text-(--ui-text-tertiary) shadow-none hover:bg-muted/40 focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:bg-muted/40"
size="sm"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="live">live</SelectItem>
{BILLING_DEV_FIXTURE_NAMES.map(name => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1.5 text-(--ui-text-tertiary)">
<Wrench className="size-3.5 shrink-0" />
<span className="text-xs font-normal">preview</span>
<Select onValueChange={value => onValueChange(value as BillingFixtureSelection)} value={value}>
<SelectTrigger
aria-label="Billing preview fixture (dev only)"
className="h-7 w-36 border-dashed border-(--ui-stroke-secondary) bg-transparent px-2 text-xs font-normal text-(--ui-text-tertiary) shadow-none hover:bg-muted/40 focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:bg-muted/40"
size="sm"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="live">live</SelectItem>
{BILLING_DEV_FIXTURE_NAMES.map(name => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
@ -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({
<SettingsContent>
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
<div className="@container mb-5">
<div className="grid gap-3 rounded-lg border border-border/70 bg-muted/20 p-4 @2xl:grid-cols-3">
{view.notice && <NoticeCard notice={view.notice} />}
<div className="@container mb-6">
<SettingsCard className="grid gap-3 p-4 @2xl:grid-cols-3">
{view.summary.map(item => (
<SummaryCard key={item.label} label={item.label} tone={item.tone} value={item.value} />
))}
</div>
</SettingsCard>
</div>
{view.notice && <NoticeCard notice={view.notice} />}
{view.plan && (
<div className="mb-5">
<SectionHeading icon={Package} title="Plan" />
<CurrentPlanCard onViewPlans={() => setSubView('plans')} plan={view.plan} />
</div>
<SettingsSection icon={Package} title="Plan">
<SettingsCard className="px-4">
<CurrentPlanCard onViewPlans={() => setSubView('plans')} plan={view.plan} />
</SettingsCard>
</SettingsSection>
)}
{paymentRow && (
<div className="mb-5">
<SectionHeading icon={Lock} title="Payment" />
<AccountRow billing={billing} row={paymentRow} />
</div>
)}
{topupRow && (
<div className="mb-5">
<SectionHeading icon={Plus} title="One-time top-up" />
<AccountRow billing={billing} row={topupRow} />
</div>
)}
{refillRow && (
<div className="mb-5">
<SectionHeading icon={RefreshCw} title="Automatic refill" />
<AccountRow billing={billing} row={refillRow} />
</div>
{accountRows.length > 0 && (
<SettingsSection icon={CreditCard} title="Payment & credits">
<SettingsCard className="divide-y divide-border/60 px-4">
{accountRows.map(row => (
<AccountRow billing={billing} key={row.id} row={row} />
))}
</SettingsCard>
</SettingsSection>
)}
{view.usageRows.length > 0 && (
<>
<SectionHeading icon={BarChart3} title="Usage" />
<div className="@container rounded-lg border border-border/70 bg-muted/20 px-4 py-2">
<SettingsSection icon={BarChart3} title="Usage">
<SettingsCard className="@container px-4 py-2">
{view.usageRows.map(row => (
<UsageRow key={row.id} row={row} />
))}
<UsageRefreshRow
fixtureName={fixtureName}
isFetching={usageIsFetching}
onRefresh={refreshUsage}
updatedAt={usageUpdatedAt}
/>
</div>
</>
</SettingsCard>
</SettingsSection>
)}
{
@ -586,9 +543,3 @@ export function BillingSettings() {
return <BillingSettingsContent />
}
function oldestUpdatedAt(...timestamps: number[]): number {
const populated = timestamps.filter(timestamp => timestamp > 0)
return populated.length > 0 ? Math.min(...populated) : Date.now()
}

View file

@ -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'
}
}

View file

@ -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 <div className={cn('rounded-xl border border-border/70 bg-muted/20', className)}>{children}</div>
}
// A titled section: heading + body with the shared vertical rhythm. Keeps the
// heading and its content welded together so pages stop hand-rolling
// `<div className="mb-…"><SectionHeading/>…</div>` at every call site.
export function SettingsSection({
children,
icon,
meta,
title
}: {
children: ReactNode
icon: IconComponent
meta?: string
title: string
}) {
return (
<section className="mb-6">
<SectionHeading icon={icon} meta={meta} title={title} />
{children}
</section>
)
}
export function NavLink({
icon: Icon,
label,

View file

@ -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,