+
+ {notice.title}
+
{notice.message}
@@ -86,6 +103,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
@@ -143,22 +185,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}
+ />
-
@@ -283,43 +319,20 @@ function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fa
value: 0
}
- const width = Math.round(resolvedBar.value * 100)
- const isEmpty = resolvedBar.value === 0
- const showDangerNub = resolvedBar.track === 'danger' && resolvedBar.state === 'danger' && width === 0
+ // Plain shared primitive — no bespoke track chrome. Only the fill tone carries
+ // billing meaning: destructive when over-limit, green for healthy remaining
+ // credits, muted otherwise. Color rides the sanctioned `fillClassName` override.
+ const isOk = resolvedBar.state === 'ok' && (resolvedBar.tone === 'subscription' || resolvedBar.tone === 'topup')
return (
-
- {showDangerNub &&
}
-
0 ? 4 : undefined,
- width: `${width}%`
- }}
- />
-
+ destructive={resolvedBar.state === 'danger'}
+ fillClassName={resolvedBar.state === 'danger' ? undefined : isOk ? 'bg-(--ui-green)' : 'bg-muted-foreground/45'}
+ fillStyle={{ minWidth: resolvedBar.value > 0 ? 4 : undefined }}
+ size="lg"
+ value={resolvedBar.value}
+ />
)
}
@@ -351,53 +364,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 +376,27 @@ function BillingFixtureSelect({
value: BillingFixtureSelection
}) {
return (
-
+
+
+ preview
+
+
)
}
@@ -446,6 +420,34 @@ function BillingHeader({
)
}
+// Loading shape for the billing overview: three summary cards over the Plan /
+// Payment & credits / Usage sections. Rendered under the real header.
+function BillingSkeleton() {
+ return (
+ <>
+
+
+ {[0, 1, 2].map(i => (
+
+
+
+
+ ))}
+
+
+ {[0, 1, 2].map(section => (
+
+ ))}
+ >
+ )
+}
+
function BillingSettingsContent({
fixtureName,
onFixtureChange
@@ -460,19 +462,30 @@ function BillingSettingsContent({
// fixture short-circuit here.
const billingState = useBillingState()
const subscriptionState = useSubscriptionState()
+
+ // First load keeps the page's shape via a skeleton instead of flashing "—"
+ // summary cards (background refetches leave `isPending` false, so no flicker).
+ if (billingState.isPending) {
+ return (
+
+
+
+
+ )
+ }
+
const billingResult = billingState.data
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 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
// reach a grid of live Choose buttons — it falls back to the overview.
@@ -491,59 +504,42 @@ function BillingSettingsContent({
-
-
+ {view.notice &&
}
+
+
+
{view.summary.map(item => (