294 lines
10 KiB
TypeScript
294 lines
10 KiB
TypeScript
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<BillingStateResponse, 'max_usd' | 'min_usd'>
|
|
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 | { kind: 'error' | 'success'; text: string }>(null)
|
|
const [refusal, setRefusal] = useState<BillingRefusal | null>(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 (
|
|
<ListRow
|
|
action={<RowValue row={row} />}
|
|
below={
|
|
<>
|
|
{row.caption ? (
|
|
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
|
{row.caption}
|
|
</div>
|
|
) : null}
|
|
<BillingRefusalInline refusal={refusal} />
|
|
{message && <InlineMessage kind={message.kind}>{message.text}</InlineMessage>}
|
|
</>
|
|
}
|
|
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 (
|
|
<div className="@container">
|
|
<div className="grid gap-3 py-3 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-start">
|
|
<div className="min-w-0">
|
|
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
|
|
{row.title}
|
|
</div>
|
|
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
|
{row.description}
|
|
</div>
|
|
<div className="mt-3 grid [grid-template-areas:'stack']">
|
|
{/* EDIT layer — always in layout (reserves exact height); hidden until editing. */}
|
|
<div aria-hidden={!editing} className={cn('space-y-2 [grid-area:stack]', !editing && 'invisible')}>
|
|
<div className="grid gap-2 @2xl:grid-cols-2">
|
|
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
|
Threshold
|
|
<Input
|
|
aria-label="Auto-refill threshold"
|
|
className="mt-1 py-[3px]"
|
|
disabled={busy || !editing}
|
|
inputMode="decimal"
|
|
max={maxBound}
|
|
min={minBound}
|
|
onChange={onField(setThreshold)}
|
|
size="sm"
|
|
step="0.01"
|
|
tabIndex={editing ? undefined : -1}
|
|
type="number"
|
|
value={threshold}
|
|
/>
|
|
</label>
|
|
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
|
Reload to
|
|
<Input
|
|
aria-label="Auto-refill reload-to amount"
|
|
className="mt-1 py-[3px]"
|
|
disabled={busy || !editing}
|
|
inputMode="decimal"
|
|
max={maxBound}
|
|
min={minBound}
|
|
onChange={onField(setReloadTo)}
|
|
size="sm"
|
|
step="0.01"
|
|
tabIndex={editing ? undefined : -1}
|
|
type="number"
|
|
value={reloadTo}
|
|
/>
|
|
</label>
|
|
</div>
|
|
{/* Pre-allocated error line — occupies height whether or not shown. */}
|
|
<div className="min-h-4 text-[length:var(--conversation-caption-font-size)] text-destructive">
|
|
{showErrors && validation.error ? validation.error : ''}
|
|
</div>
|
|
{confirmDisable ? (
|
|
<div className="flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
|
<span>Turn off auto-refill?</span>
|
|
<Button disabled={busy} onClick={() => void disable()} size="sm" type="button" variant="outline">
|
|
Turn off
|
|
</Button>
|
|
<Button
|
|
disabled={busy}
|
|
onClick={() => setConfirmDisable(false)}
|
|
size="sm"
|
|
type="button"
|
|
variant="ghost"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
disabled={busy}
|
|
onClick={() => setConfirmDisable(true)}
|
|
size="sm"
|
|
tabIndex={editing ? undefined : -1}
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
Disable
|
|
</Button>
|
|
)}
|
|
{/* Refusal stays INSIDE the reserved layer so it never pushes Usage. */}
|
|
<BillingRefusalInline refusal={refusal} />
|
|
</div>
|
|
{/* VIEW layer — success feedback overlaid in the same cell when not editing. */}
|
|
{!editing && message && (
|
|
<div className="[grid-area:stack]">
|
|
<InlineMessage kind={message.kind}>{message.text}</InlineMessage>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* Action column swaps Manage ↔ Save/Cancel in place (top-aligned, no move). */}
|
|
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
|
|
{row.pill && <Pill tone={row.pill.tone}>{row.pill.label}</Pill>}
|
|
{editing ? (
|
|
<>
|
|
<Button disabled={busy || !validation.values} onClick={() => void save()} size="sm" type="button">
|
|
{busy ? 'Saving…' : 'Save'}
|
|
</Button>
|
|
<Button disabled={busy} onClick={cancelEdit} size="sm" type="button" variant="outline">
|
|
Cancel
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button onClick={openEdit} size="sm" type="button" variant="outline">
|
|
Manage
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 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 (
|
|
<div
|
|
className={cn(
|
|
'mt-2 text-[length:var(--conversation-caption-font-size)]',
|
|
kind === 'error' ? 'text-destructive' : 'text-(--ui-text-tertiary)'
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|