Files
aiturk-hermes-ide/apps/desktop/src/app/settings/billing/auto-reload-row.tsx
T

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>
)
}