Files
aiturk-hermes-ide/apps/desktop/src/app/settings/billing/plans-view.tsx
T

239 lines
7.9 KiB
TypeScript

import { useEffect, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { openExternalLink } from '@/lib/external-link'
import { ChevronLeft, ExternalLink } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { Pill } from '../primitives'
import { type BillingCopy, DEFAULT_BILLING_COPY, useBillingCopy } from './copy'
import { BillingRefusalInline } from './inline-feedback'
import { TierArt } from './tier-art'
import { type BillingPlanTierView, formatBillingDate, formatMonthlyCreditsDelta } from './use-billing-state'
import { type DowngradePhase, useDowngradeFlow } from './use-subscription-change'
type DowngradeFlow = ReturnType<typeof useDowngradeFlow>
// The human sentence for the panel body, derived purely from the phase. `null` while
// a refusal is the only thing to show (BillingRefusalInline renders that separately).
function previewMessage(
phase: DowngradePhase,
fallbackTierName: string,
copy: BillingCopy = DEFAULT_BILLING_COPY
): null | string {
if (phase.kind === 'previewing') {
return copy.checkingChange
}
if (phase.kind === 'previewFailed') {
return null
}
const { preview } = phase
const targetName = preview.target_tier_name ?? fallbackTierName
const creditsDelta = formatMonthlyCreditsDelta(preview.monthly_credits_delta, copy)
switch (preview.effect) {
case 'blocked':
return preview.reason ?? copy.cannotChange
case 'no_op':
return copy.alreadyPlan(targetName)
case 'scheduled':
return copy.scheduledChange(targetName, formatBillingDate(preview.effective_at, copy), creditsDelta)
default:
return copy.cannotSchedule
}
}
// The in-card preview → confirm panel for a downgrade (mirrors the TUI confirm flow).
function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierView }) {
const copy = useBillingCopy()
const active = flow.active
const panelRef = useRef<HTMLDivElement>(null)
const open = active?.target.tierId === tier.tierId
// Move focus into the panel on open so keyboard users land on the confirm flow;
// role="status"/aria-live announces the async preview text as it arrives.
useEffect(() => {
if (open) {
panelRef.current?.focus()
}
}, [open])
if (!active || active.target.tierId !== tier.tierId) {
return null
}
const { phase } = active
const captionCn = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)'
const refusal = phase.kind === 'previewFailed' || phase.kind === 'scheduleFailed' ? phase.refusal : null
const busy = phase.kind === 'previewing' || phase.kind === 'scheduling'
const message = previewMessage(phase, tier.name, copy)
const canConfirm =
(phase.kind === 'ready' && phase.preview.effect === 'scheduled') ||
phase.kind === 'scheduling' ||
phase.kind === 'scheduleFailed'
return (
<div
aria-live="polite"
className="flex min-w-0 flex-col gap-2 rounded-md bg-(--ui-bg-elevated) p-3 outline-none"
ref={panelRef}
role="status"
tabIndex={-1}
>
{message && <div className={captionCn}>{message}</div>}
<BillingRefusalInline refusal={refusal} />
<div className="flex min-w-0 flex-wrap items-center gap-2">
{phase.kind === 'previewFailed' ? (
<Button disabled={busy} onClick={flow.retryPreview} size="sm" type="button">
{copy.tryAgain}
</Button>
) : canConfirm ? (
<Button disabled={busy} onClick={() => void flow.confirm()} size="sm" type="button">
{phase.kind === 'scheduling'
? copy.scheduling
: phase.kind === 'scheduleFailed'
? copy.tryAgain
: copy.confirmDowngrade}
</Button>
) : null}
<Button disabled={busy} onClick={flow.cancel} size="sm" type="button" variant="outline">
{copy.cancel}
</Button>
</div>
</div>
)
}
function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierView }) {
const copy = useBillingCopy()
const isCurrent = tier.state === 'current'
const confirming = flow.active?.target.tierId === tier.tierId
const cardRef = useRef<HTMLDivElement>(null)
const wasConfirming = useRef(false)
// When the confirm panel closes (cancel / scheduled), return focus to this tile
// so keyboard focus is never left detached on the removed panel.
// eslint-disable-next-line no-restricted-syntax -- tracks previous confirming state for focus return, not an atom mirror
useEffect(() => {
if (wasConfirming.current && !confirming) {
cardRef.current?.focus()
}
wasConfirming.current = confirming
}, [confirming])
return (
<div
className={cn(
'flex min-w-0 flex-col gap-3 rounded-lg p-4 outline-none',
isCurrent ? 'bg-(--ui-green)/10' : 'bg-(--ui-bg-quaternary)'
)}
ref={cardRef}
tabIndex={-1}
>
<div className="flex min-w-0 items-center gap-3">
<TierArt name={tier.name} />
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{tier.name}
</div>
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{tier.priceDisplay}
{copy.monthSuffix}
</div>
</div>
</div>
{tier.creditsDisplay && (
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{tier.creditsDisplay}
</div>
)}
<div className="mt-auto min-w-0 pt-1">
{isCurrent && <Pill tone="primary">{copy.currentPlan}</Pill>}
{tier.state === 'scheduled' && <Pill>{copy.scheduled}</Pill>}
{tier.state === 'upgrade' && (
<Button
onClick={() => tier.action && openExternalLink(tier.action.url)}
size="sm"
type="button"
variant="outline"
>
{tier.action.label}
<ExternalLink className="size-3.5" />
</Button>
)}
{tier.state === 'downgrade' &&
(confirming ? (
<DowngradeConfirm flow={flow} tier={tier} />
) : (
// Disabled while another tile's change is committing — no concurrent mutation.
<Button
disabled={flow.mutating}
onClick={() => flow.begin({ tierId: tier.tierId, tierName: tier.name })}
size="sm"
type="button"
variant="outline"
>
{copy.downgrade}
</Button>
))}
</div>
</div>
)
}
export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers: BillingPlanTierView[] }) {
const copy = useBillingCopy()
// A scheduled downgrade lands the user back on the overview, where the plan card
// now shows the pending state with its undo.
const flow = useDowngradeFlow({ onScheduled: onBack })
return (
<div className="@container">
<div className="mb-2.5 flex items-center gap-2 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
<Button
aria-label={copy.backBilling}
className="size-7 p-0 text-(--ui-text-tertiary)"
disabled={flow.mutating}
onClick={onBack}
size="sm"
type="button"
variant="ghost"
>
<ChevronLeft className="size-4" />
</Button>
<span>{copy.plans}</span>
</div>
{tiers.length > 0 ? (
<div className="grid gap-3 @lg:grid-cols-2 @3xl:grid-cols-3">
{tiers.map(tier => (
<PlanCard flow={flow} key={tier.tierId} tier={tier} />
))}
</div>
) : (
<div className="rounded-xl bg-(--ui-bg-quaternary) p-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{copy.noPlans}
</div>
)}
</div>
)
}