diff --git a/apps/desktop/src/app/settings/billing/index.test.tsx b/apps/desktop/src/app/settings/billing/index.test.tsx index 65fe9c91cda3..b111d9f1d8e5 100644 --- a/apps/desktop/src/app/settings/billing/index.test.tsx +++ b/apps/desktop/src/app/settings/billing/index.test.tsx @@ -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) diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx index 2f783827f281..2c2ba0b1ab3a 100644 --- a/apps/desktop/src/app/settings/billing/index.tsx +++ b/apps/desktop/src/app/settings/billing/index.tsx @@ -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 (
@@ -65,16 +76,11 @@ function NoticeCard({ notice }: { notice: BillingNoticeView }) { const warn = notice.tone === 'warn' return ( -
+
{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 ( +
+ {row.value && ( + + {row.value} + + )} + {row.action && ( + + )} +
+ ) +} + 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 @@ -155,22 +186,15 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B - {presets.map(preset => ( - - ))} + setAmount(value)} + options={presets.map(preset => ({ id: preset.amount, label: preset.label }))} + value={amount} + /> -
@@ -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({ ) + 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() + 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() + 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() + + fireEvent.change(getByRole('textbox'), { target: { value: '100' } }) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('dims the group and disables the field when disabled', () => { + const { getByRole } = render() + 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() + 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') + }) +}) diff --git a/apps/desktop/src/components/ui/input.tsx b/apps/desktop/src/components/ui/input.tsx index a195e9a9ab84..8279cd1d459f 100644 --- a/apps/desktop/src/components/ui/input.tsx +++ b/apps/desktop/src/components/ui/input.tsx @@ -4,8 +4,22 @@ import { cn } from '@/lib/utils' import { type ControlVariantProps, controlVariants } from './control' -function Input({ className, type, size, ...props }: Omit, '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, '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 = ( ) + + if (!grouped) { + return field + } + + return ( +
+ {prefix != null && {prefix}} + {field} + {suffix != null && {suffix}} +
+ ) } export { Input } diff --git a/apps/desktop/src/components/ui/segmented-control.tsx b/apps/desktop/src/components/ui/segmented-control.tsx index 994cc17a99bd..45c854ecf2be 100644 --- a/apps/desktop/src/components/ui/segmented-control.tsx +++ b/apps/desktop/src/components/ui/segmented-control.tsx @@ -12,6 +12,8 @@ interface SegmentedControlProps { 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 { * (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({ options, value, onChange, className }: SegmentedControlProps) { +export function SegmentedControl({ + className, + disabled = false, + onChange, + options, + value +}: SegmentedControlProps) { return (
@@ -34,9 +43,10 @@ export function SegmentedControl({ options, value, onChange, c