feat(desktop): add prefix/suffix adornments to Input primitive; use $ prefix in billing top-up

This commit is contained in:
Brooklyn Nicholson 2026-07-22 18:17:33 -05:00
parent f9a8ecc0ce
commit c4acc4d2c5
9 changed files with 230 additions and 73 deletions

View file

@ -120,7 +120,9 @@ describe('BillingSettings', () => {
renderBilling()
expect(await screen.findByText('No card on file')).toBeTruthy()
// No card → the payment row collapses to a single "Add payment method" link.
expect(await screen.findByRole('button', { name: /Add payment method/ })).toBeTruthy()
expect(screen.queryByText('No card on file')).toBeNull()
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$100' }).hasAttribute('disabled')).toBe(true)

View file

@ -3,12 +3,13 @@ import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
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, SettingsCard, SettingsContent, SettingsSection } from '../primitives'
import { ListRow, SectionHeading, SettingsContent, SettingsSection } from '../primitives'
import { RowValue } from './account-row-value'
import { BillingApiProvider } from './api'
@ -45,6 +46,16 @@ const BILLING_DEV_FIXTURE_NAMES = import.meta.env.DEV
type BillingFixtureSelection = 'live' | BillingDevFixtureName
// DEV-only: a canned ~60%-full "ok" bar so the active progress-bar treatment can be
// eyeballed on any account (a live/empty account only ever shows the hatch track).
const DEV_PREVIEW_USAGE_ROW: BillingUsageRowView = {
bar: { label: 'Example usage (dev preview)', state: 'ok', tone: 'subscription', value: 0.62 },
caption: 'Preview only — dev build',
id: 'subscription_credits',
title: 'Example bar (dev preview)',
value: '$62 of $100 left'
}
function SummaryCard({ label, value, tone }: { label: string; tone?: 'muted' | 'primary'; value: string }) {
return (
<div className="min-w-0">
@ -65,16 +76,11 @@ function NoticeCard({ notice }: { notice: BillingNoticeView }) {
const warn = notice.tone === 'warn'
return (
<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('mb-6 rounded-xl p-4', warn ? 'bg-(--ui-yellow)/10' : 'bg-(--ui-bg-quaternary)')}>
<div
className={cn(
'text-[length:var(--conversation-text-font-size)] font-medium',
warn ? 'text-amber-600 dark:text-amber-300' : 'text-foreground'
warn ? 'text-(--ui-yellow)' : 'text-foreground'
)}
>
{notice.title}
@ -98,6 +104,31 @@ function NoticeCard({ notice }: { notice: BillingNoticeView }) {
)
}
// The payment method as it rides in the "Payment & credits" heading: the current
// card (muted) plus a single underline text action (Update / Add payment method).
function PaymentMethodAside({ row }: { row: BillingAccountRowView }) {
return (
<div className="flex min-w-0 items-center gap-2.5">
{row.value && (
<span className="min-w-0 truncate text-[length:var(--conversation-caption-font-size)] font-normal text-(--ui-text-tertiary)">
{row.value}
</span>
)}
{row.action && (
<Button
disabled={row.action.disabled}
onClick={row.action.url ? () => openExternal(row.action?.url) : undefined}
size="sm"
type="button"
variant="textStrong"
>
{row.action.label}
</Button>
)}
</div>
)
}
function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: BillingAccountRowView }) {
if (row.id === 'buy_credits' && row.action && row.chips && billing?.can_charge && billing.cli_billing_enabled) {
return <BuyCreditsRow billing={billing} row={row} />
@ -155,22 +186,15 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
<ListRow
action={
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{presets.map(preset => (
<Button
aria-pressed={amount === preset.amount}
disabled={controlsDisabled}
key={preset.amount}
onClick={() => setAmount(preset.amount)}
size="sm"
type="button"
variant={amount === preset.amount ? 'default' : 'outline'}
>
{preset.label}
</Button>
))}
<SegmentedControl
disabled={controlsDisabled}
onChange={value => setAmount(value)}
options={presets.map(preset => ({ id: preset.amount, label: preset.label }))}
value={amount}
/>
<Input
aria-label="Custom credit amount"
className="w-24 py-[3px]"
containerClassName="w-20"
disabled={controlsDisabled}
inputMode="decimal"
max={billing.max_usd ?? undefined}
@ -180,13 +204,14 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
flow.reset()
setAmount(event.target.value)
}}
placeholder={billing.min_usd ? formatMoney(billing.min_usd) : '$'}
size="sm"
placeholder={billing.min_usd ?? ''}
prefix="$"
size="xs"
step="0.01"
type="number"
value={amount}
/>
<Button disabled={!canBuy} onClick={startBuy} size="sm" type="button" variant="outline">
<Button disabled={!canBuy} onClick={startBuy} size="xs" type="button" variant="secondary">
Buy
</Button>
</div>
@ -311,8 +336,9 @@ function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fa
resolvedBar.track === 'danger'
? 'dither text-destructive/60 bg-destructive/10'
: isEmpty
? 'dither bg-(--ui-bg-elevated)'
: 'bg-muted shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--ui-stroke-secondary)_50%,transparent)]'
? // Muted currentColor so the hatch reads as a faint placeholder, not solid ink.
'dither text-(--ui-text-quaternary) bg-(--ui-bg-elevated)'
: 'bg-(--ui-bg-tertiary) shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--ui-stroke-secondary)_50%,transparent)]'
)}
role="progressbar"
>
@ -381,7 +407,7 @@ function BillingFixtureSelect({
<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"
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-(--ui-bg-tertiary) focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:bg-(--ui-bg-tertiary)"
size="sm"
>
<SelectValue />
@ -440,12 +466,10 @@ function BillingSettingsContent({
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
)
// The payment method rides in the section header (right-aligned) — the
// "Payment & credits" title already names it, so a full labelled row would just
// repeat "Payment method". The stacked rows are the remaining money controls.
const accountRows = [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
@ -468,38 +492,41 @@ function BillingSettingsContent({
{view.notice && <NoticeCard notice={view.notice} />}
<div className="@container mb-6">
<SettingsCard className="grid gap-3 p-4 @2xl:grid-cols-3">
<div className="grid gap-3 @2xl:grid-cols-3">
{view.summary.map(item => (
<SummaryCard key={item.label} label={item.label} tone={item.tone} value={item.value} />
))}
</SettingsCard>
</div>
</div>
{view.plan && (
<SettingsSection icon={Package} title="Plan">
<SettingsCard className="px-4">
<CurrentPlanCard onViewPlans={() => setSubView('plans')} plan={view.plan} />
</SettingsCard>
<CurrentPlanCard onViewPlans={() => setSubView('plans')} plan={view.plan} />
</SettingsSection>
)}
{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>
{(paymentRow || accountRows.length > 0) && (
<SettingsSection
aside={paymentRow ? <PaymentMethodAside row={paymentRow} /> : undefined}
icon={CreditCard}
title="Payment & credits"
>
{accountRows.map(row => (
<AccountRow billing={billing} key={row.id} row={row} />
))}
</SettingsSection>
)}
{view.usageRows.length > 0 && (
<SettingsSection icon={BarChart3} title="Usage">
<SettingsCard className="@container px-4 py-2">
<div className="@container">
{view.usageRows.map(row => (
<UsageRow key={row.id} row={row} />
))}
</SettingsCard>
{/* DEV-only: a fully-filled example so the active (non-disabled) bar can be
reviewed on any account. Compiled out of production. */}
{import.meta.env.DEV && <UsageRow row={DEV_PREVIEW_USAGE_ROW} />}
</div>
</SettingsSection>
)}

View file

@ -79,7 +79,7 @@ function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPl
return (
<div
aria-live="polite"
className="flex min-w-0 flex-col gap-2 rounded-md border border-border/70 bg-background/60 p-3 outline-none"
className="flex min-w-0 flex-col gap-2 rounded-md bg-(--ui-bg-elevated) p-3 outline-none"
ref={panelRef}
role="status"
tabIndex={-1}
@ -129,8 +129,8 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
return (
<div
className={cn(
'flex min-w-0 flex-col gap-3 rounded-lg border p-4 outline-none',
isCurrent ? 'border-(--ui-green)/60 bg-(--ui-green)/5' : 'border-border/70 bg-muted/20'
'flex min-w-0 flex-col gap-3 rounded-lg p-4 outline-none',
isCurrent ? 'bg-(--ui-green)/10' : 'bg-(--ui-bg-quaternary)'
)}
ref={cardRef}
tabIndex={-1}
@ -219,7 +219,7 @@ export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers:
))}
</div>
) : (
<div className="rounded-lg border border-border/70 bg-muted/20 p-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<div className="rounded-xl bg-(--ui-bg-quaternary) p-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
No plans are available to change to right now.
</div>
)}

View file

@ -534,17 +534,19 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView
const card = billing.card
if (!card) {
// No card → a single "Add payment method" link, the way every other app does
// it. The reason (buys/auto-refill are blocked) already leads the page as a
// notice, so the row stays a bare call-to-action with no redundant status text.
return {
action: { label: 'Update ↗', url: portalUrl },
description: 'Add a payment method on the portal before buying top-up credits.',
action: { label: 'Add payment method', url: portalUrl },
description: '',
id: 'payment_method',
title: 'Payment method',
value: 'No card on file'
title: 'Payment method'
}
}
return {
action: { label: 'Update', url: portalUrl },
action: { label: 'Update', url: portalUrl },
description: 'Manage the card used for top-ups and subscription renewals.',
id: 'payment_method',
title: 'Payment method',

View file

@ -28,32 +28,40 @@ export function Pill({ tone = 'muted', children }: { tone?: keyof typeof PILL_VA
return <Badge variant={PILL_VARIANT[tone]}>{children}</Badge>
}
export function SectionHeading({ icon: Icon, title, meta }: { icon: IconComponent; title: string; meta?: string }) {
export function SectionHeading({
aside,
icon: Icon,
meta,
title
}: {
// Right-aligned trailing content on the heading row (e.g. a compact status +
// action), so a single-item section needn't repeat its own label as a row.
aside?: ReactNode
icon: IconComponent
meta?: string
title: string
}) {
return (
<div className="mb-2.5 flex items-center gap-2 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
<Icon className="size-4 text-muted-foreground" />
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span>{title}</span>
{meta && <Pill>{meta}</Pill>}
{aside && <div className="ml-auto flex min-w-0 items-center">{aside}</div>}
</div>
)
}
// 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({
aside,
children,
icon,
meta,
title
}: {
aside?: ReactNode
children: ReactNode
icon: IconComponent
meta?: string
@ -61,7 +69,7 @@ export function SettingsSection({
}) {
return (
<section className="mb-6">
<SectionHeading icon={icon} meta={meta} title={title} />
<SectionHeading aside={aside} icon={icon} meta={meta} title={title} />
{children}
</section>
)

View file

@ -0,0 +1,66 @@
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Input } from './input'
afterEach(cleanup)
describe('Input', () => {
it('renders a bare input carrying the control chrome when unadorned', () => {
const { getByRole } = render(<Input aria-label="plain" />)
const el = getByRole('textbox')
expect(el.tagName).toBe('INPUT')
expect(el.className).toContain('desktop-input-chrome')
// No group wrapper — the input is the top-level node.
expect(el.parentElement?.getAttribute('data-slot')).not.toBe('input-group')
})
it('wraps the field in a chrome-owning group and renders the prefix when adorned', () => {
const { getByRole, getByText } = render(<Input aria-label="amount" prefix="$" />)
const el = getByRole('textbox')
const group = el.closest('[data-slot="input-group"]')
expect(group).not.toBeNull()
// Chrome moves to the wrapper; the input itself goes transparent/borderless.
expect(group?.className).toContain('desktop-input-chrome')
expect(el.className).not.toContain('desktop-input-chrome')
expect(el.className).toContain('bg-transparent')
const prefix = getByText('$')
expect(group?.contains(prefix)).toBe(true)
})
it('renders a trailing suffix inside the group', () => {
const { getByText } = render(<Input aria-label="rate" suffix="%" />)
const suffix = getByText('%')
expect(suffix.closest('[data-slot="input-group"]')).not.toBeNull()
})
it('forwards value/onChange through the adorned field', () => {
const onChange = vi.fn()
const { getByRole } = render(<Input aria-label="amount" onChange={onChange} prefix="$" value="" />)
fireEvent.change(getByRole('textbox'), { target: { value: '100' } })
expect(onChange).toHaveBeenCalledTimes(1)
})
it('dims the group and disables the field when disabled', () => {
const { getByRole } = render(<Input aria-label="amount" disabled prefix="$" />)
const el = getByRole('textbox') as HTMLInputElement
const group = el.closest('[data-slot="input-group"]')
expect(el.disabled).toBe(true)
expect(group?.className).toContain('opacity-50')
})
it('lets containerClassName override the wrapper width', () => {
const { getByRole } = render(<Input aria-label="amount" containerClassName="w-20" prefix="$" />)
const group = getByRole('textbox').closest('[data-slot="input-group"]')
// twMerge resolves the control's default w-full down to the caller's width.
expect(group?.className).toContain('w-20')
expect(group?.className).not.toContain('w-full')
})
})

View file

@ -4,8 +4,22 @@ import { cn } from '@/lib/utils'
import { type ControlVariantProps, controlVariants } from './control'
function Input({ className, type, size, ...props }: Omit<React.ComponentProps<'input'>, 'size'> & ControlVariantProps) {
return (
// `prefix`/`suffix` are DOM string attributes on the native element; we shadow
// them with ReactNode adornments, so they're omitted from the base props.
type InputProps = Omit<React.ComponentProps<'input'>, 'size' | 'prefix' | 'suffix'> &
ControlVariantProps & {
/** Leading adornment rendered inside the field (e.g. a `$` for money). */
prefix?: React.ReactNode
/** Trailing adornment rendered inside the field (e.g. a unit label). */
suffix?: React.ReactNode
/** Applied to the wrapper when an adornment promotes the field to a group. */
containerClassName?: string
}
function Input({ className, containerClassName, prefix, suffix, size, type, ...props }: InputProps) {
const grouped = prefix != null || suffix != null
const field = (
<input
// Off by default for every consumer — these are code/config/search fields,
// not prose. Callers can re-enable per-instance by passing the prop.
@ -13,7 +27,12 @@ function Input({ className, type, size, ...props }: Omit<React.ComponentProps<'i
autoComplete="off"
autoCorrect="off"
className={cn(
controlVariants({ size }),
// When adorned, the wrapper owns the chrome (border/background/focus
// glow) and the input goes transparent so the whole thing reads as one
// box; otherwise the input carries the chrome itself.
grouped
? 'min-w-0 flex-1 border-0 bg-transparent p-0 text-xs leading-4 text-foreground outline-none placeholder:text-muted-foreground'
: controlVariants({ size }),
'selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-xs file:font-medium file:text-foreground',
className
)}
@ -23,6 +42,28 @@ function Input({ className, type, size, ...props }: Omit<React.ComponentProps<'i
{...props}
/>
)
if (!grouped) {
return field
}
return (
<div
className={cn(
// Same control chrome/sizing as a bare input; `.desktop-input-chrome`
// lights on `:focus-within` (styles.css) since the div never focuses.
controlVariants({ size }),
'inline-flex items-center gap-1',
props.disabled && 'cursor-not-allowed opacity-50',
containerClassName
)}
data-slot="input-group"
>
{prefix != null && <span className="pointer-events-none shrink-0 select-none text-muted-foreground">{prefix}</span>}
{field}
{suffix != null && <span className="pointer-events-none shrink-0 select-none text-muted-foreground">{suffix}</span>}
</div>
)
}
export { Input }

View file

@ -12,6 +12,8 @@ interface SegmentedControlProps<T extends string> {
value: T
onChange: (id: T) => void
className?: string
/** Dims the whole track and blocks selection (e.g. gated behind a prerequisite). */
disabled?: boolean
}
/**
@ -19,11 +21,18 @@ interface SegmentedControlProps<T extends string> {
* (color mode, tool-call display, usage period, etc.). Flat by design
* no per-option borders, just a tinted track with a raised active pill.
*/
export function SegmentedControl<T extends string>({ options, value, onChange, className }: SegmentedControlProps<T>) {
export function SegmentedControl<T extends string>({
className,
disabled = false,
onChange,
options,
value
}: SegmentedControlProps<T>) {
return (
<div
className={cn(
'inline-grid w-fit auto-cols-fr grid-flow-col gap-0.5 rounded-[5px] bg-(--ui-bg-tertiary) p-0.5',
disabled && 'opacity-50',
className
)}
>
@ -34,9 +43,10 @@ export function SegmentedControl<T extends string>({ options, value, onChange, c
<button
aria-pressed={active}
className={cn(
'flex items-center justify-center gap-1 rounded-[3px] px-2.5 py-0.5 text-[0.6875rem] font-medium transition-colors',
'flex items-center justify-center gap-1 rounded-[3px] px-2.5 py-0.5 text-[0.6875rem] font-medium transition-colors disabled:cursor-default',
active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
)}
disabled={disabled}
key={id}
onClick={() => onChange(id)}
type="button"

View file

@ -840,6 +840,7 @@ text-* variant utilities. */ .btn-arc {
/* `[data-state='open']` keeps the trigger looking focused while its dropdown is
open Radix moves focus into the list, so the trigger itself loses :focus. */
.desktop-input-chrome:focus,
.desktop-input-chrome:focus-within,
.desktop-input-chrome[data-state='open'] {
border-color: var(--dt-composer-ring);
box-shadow: none;