import { useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' import { ListRow, Pill } from '../primitives' import { RowValue } from './account-row-value' import type { BillingRefusal } from './api' import { useBillingApi } from './api' import { initialAutoReloadAmount, validateAutoReloadInputs } from './billing-amounts' import { BillingRefusalInline } from './inline-feedback' import type { BillingAutoReload, BillingStateResponse } from './types' import type { BillingAccountRowView } from './use-billing-state' export function AutoReloadRow({ autoReload, bounds, row }: { autoReload: BillingAutoReload bounds: Pick row: BillingAccountRowView }) { const api = useBillingApi() const queryClient = useQueryClient() const [confirmDisable, setConfirmDisable] = useState(false) const [editing, setEditing] = useState(false) // Validation errors are silent until the user edits a field or attempts a // save — opening Manage on a prefilled (possibly below-min) config must not // flash an error (spec §9). const [showErrors, setShowErrors] = useState(false) const [message, setMessage] = useState(null) const [refusal, setRefusal] = useState(null) const [reloadTo, setReloadTo] = useState( initialAutoReloadAmount(autoReload.reload_to_usd, autoReload.reload_to_display) ) const [saving, setSaving] = useState(false) const [threshold, setThreshold] = useState( initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display) ) const validation = validateAutoReloadInputs(threshold, reloadTo, bounds) const busy = saving const maxBound = bounds.max_usd ?? undefined const minBound = bounds.min_usd ?? undefined // Only the canonical-card enabled state edits in place (flagged in the view model). // Off / divergent-card rows have no Manage affordance (or a portal link) and render // read-only. const editable = row.manageInApp === true const resetFeedback = () => { setConfirmDisable(false) setMessage(null) setRefusal(null) } const openEdit = () => { resetFeedback() setShowErrors(false) setEditing(true) } const cancelEdit = () => { resetFeedback() setEditing(false) } const save = async () => { if (!validation.values || busy) { return } resetFeedback() setSaving(true) const result = await api.updateAutoReload({ enabled: true, reload_to_usd: validation.values.reloadTo, threshold_usd: validation.values.threshold }) setSaving(false) if (!result.ok) { setRefusal(result.refusal) return } await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] }) setMessage({ kind: 'success', text: 'Auto-refill updated.' }) setEditing(false) } const disable = async () => { if (busy) { return } resetFeedback() setSaving(true) // The gateway's billing.auto_reload handler unconditionally requires threshold // + top_up_amount (invalid_request otherwise), so a disable must still carry the // current amounts — mirroring the TUI, which always sends both. const result = await api.updateAutoReload({ enabled: false, reload_to_usd: initialAutoReloadAmount(autoReload.reload_to_usd, autoReload.reload_to_display), threshold_usd: initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display) }) setSaving(false) if (!result.ok) { setRefusal(result.refusal) return } await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] }) setMessage({ kind: 'success', text: 'Auto-refill turned off.' }) setEditing(false) } // Read-only states (off / divergent card) keep the original ListRow shape. if (!editable) { return ( } below={ <> {row.caption ? (
{row.caption}
) : null} {message && {message.text}} } description={row.description} key={row.id} title={row.title} /> ) } const onField = (setter: (value: string) => void) => (event: { target: { value: string } }) => { resetFeedback() setShowErrors(true) setter(event.target.value) } // Zero-shift by exact reservation, not a magic min-height: the edit form is // ALWAYS rendered and both states share a single grid cell (`[grid-area:stack]`), // so the row's height always equals the tallest state at EVERY container width — // no breakpoint math that under-reserves when the two inputs stack on narrow // panes. The form is `invisible` + `aria-hidden` when not editing. return (
{row.title}
{row.description}
{/* EDIT layer — always in layout (reserves exact height); hidden until editing. */}
{/* Pre-allocated error line — occupies height whether or not shown. */}
{showErrors && validation.error ? validation.error : ''}
{confirmDisable ? (
Turn off auto-refill?
) : ( )} {/* Refusal stays INSIDE the reserved layer so it never pushes Usage. */}
{/* VIEW layer — success feedback overlaid in the same cell when not editing. */} {!editing && message && (
{message.text}
)}
{/* Action column swaps Manage ↔ Save/Cancel in place (top-aligned, no move). */}
{row.pill && {row.pill.label}} {editing ? ( <> ) : ( )}
) } // A one-line success/error note under the row — the only consumer of this shape. function InlineMessage({ children, kind }: { children: string; kind: 'error' | 'success' }) { return (
{children}
) }