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 // 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(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 (
{message &&
{message}
}
{phase.kind === 'previewFailed' ? ( ) : canConfirm ? ( ) : null}
) } 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(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 (
{tier.name}
{tier.priceDisplay} {copy.monthSuffix}
{tier.creditsDisplay && (
{tier.creditsDisplay}
)}
{isCurrent && {copy.currentPlan}} {tier.state === 'scheduled' && {copy.scheduled}} {tier.state === 'upgrade' && ( )} {tier.state === 'downgrade' && (confirming ? ( ) : ( // Disabled while another tile's change is committing — no concurrent mutation. ))}
) } 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 (
{copy.plans}
{tiers.length > 0 ? (
{tiers.map(tier => ( ))}
) : (
{copy.noPlans}
)}
) }