import { useState, useCallback } from 'react'; import { CreditCard, XCircle, ArrowUpRight, AlertTriangle, ShoppingCart } from 'lucide-react'; import { useNavigate } from '@tanstack/react-router'; import { PageLayout } from '@/components/shared'; import { useSubscription, useAvailablePlans, useInvoices } from './hooks'; import { useCancelSubscription, useResumeSubscription } from './hooks'; import { UsageBar } from './components/UsageBar'; import { InvoiceList } from './components/InvoiceList'; import { ActiveCampaignList } from './components/ActiveCampaignList'; import { PendingPaymentBanner } from './components/PendingPaymentBanner'; import { PaymentPanel } from './components/PaymentPanel'; import { CommandPurchasePanel } from './components/CommandPurchasePanel'; import { ConfiguratorPanel } from './components/ConfiguratorPanel'; import { PlanSelectionModal } from './components/PlanSelectionModal'; import { ReadinessGateModal } from './components/ReadinessGateModal'; import { LUCIDE_ICON_MAP } from '@archer/ui/utils'; import type { AvailablePlanResponse } from '@/infrastructure/http/api/subscription'; import { useSetAutoBuy, useReadinessGate } from './hooks'; import type { MappedPricingPlan } from '@archer/ui/utils'; import { useTranslation } from '@/i18n/TranslationProvider'; import { useAuthenticatedUser } from '@/features/auth/hooks/useAuthenticatedUser'; import { formatPlanPrice } from '@/features/checkout/lib/checkoutBreakdown'; export function SubscriptionPage() { const { t, locale } = useTranslation(); const navigate = useNavigate(); // Billing currency is sourced from the global AuthenticatedUser snapshot — // seeded at login, patched by the company-update mutation. Avoids a // dedicated /companies/:id fetch purely for currency display. const authed = useAuthenticatedUser(); const readinessGate = useReadinessGate(); const preferredCurrency = authed?.preferredCurrency ?? null; const [invoicePage, setInvoicePage] = useState(1); const { data, isLoading, error } = useSubscription(); const { data: plans, isLoading: plansLoading } = useAvailablePlans(); const { data: invoicesData, isLoading: invoicesLoading } = useInvoices(invoicePage); const cancelMutation = useCancelSubscription(); const resumeMutation = useResumeSubscription(); const autoBuyMutation = useSetAutoBuy(); const [showCancelConfirm, setShowCancelConfirm] = useState(false); const [selectedPlan, setSelectedPlan] = useState(null); const [showPlansModal, setShowPlansModal] = useState(false); const [showReadinessModal, setShowReadinessModal] = useState(false); const [showPurchasePanel, setShowPurchasePanel] = useState(false); const [showConfigurator, setShowConfigurator] = useState(false); const [pendingTransactionId, setPendingTransactionId] = useState(null); const subscription = data?.subscription; const quota = data?.quota; const pending = data?.pendingSubscription; const autoBuyCommands = data?.autoBuyCommands ?? false; const commandPackages = data?.commandPackages ?? []; const activeCampaigns = data?.activeCampaigns ?? []; const quotaBreakdown = data?.quotaBreakdown; const currentPlanId = subscription?.planId; const hasStripeSubscription = !!subscription?.paymentProvider && subscription.paymentProvider === 'stripe'; const handleCancel = () => { cancelMutation.mutate(undefined, { onSuccess: () => setShowCancelConfirm(false), }); }; const handleSelectPlan = (plan: AvailablePlanResponse) => { if (plan.id === currentPlanId) return; // Switching to free plan → cancel current Stripe subscription (takes effect at period end) if (plan.price === 0 && hasStripeSubscription) { setShowPlansModal(false); cancelMutation.mutate(); return; } // Paid plan requires a fully-provisioned account: billing profile filled // AND email confirmed. Both flags live on AuthenticatedUser.account and // are patched immediately after the relevant mutation, so this gate is a // pure client read — no network call at decision time. Surface a modal // with the recovery affordances instead of teleporting the user away: // they keep the plan-selection context and can resolve from one place. // Server-side 409 (PROFILE_INCOMPLETE / EMAIL_NOT_VERIFIED) remains // defense-in-depth for tampered clients. readinessGate.attempt( () => { // Paid plan (new or switch): always route through checkout. The old // plan stays active until the new plan's payment succeeds; the // webhook then cancels the superseded Stripe sub and flips local state. setShowPlansModal(false); navigate({ to: '/dashboard/checkout', search: { planId: plan.id } }); }, () => { setShowPlansModal(false); setShowReadinessModal(true); }, ); }; const handleCardAction = (mapped: MappedPricingPlan) => { const plan = plans?.find((p) => p.slug === mapped.slug); if (plan) { handleSelectPlan(plan); setShowPlansModal(false); } }; const handlePaymentSuccess = useCallback(() => { setSelectedPlan(null); }, []); const handlePanelClose = useCallback(() => { setSelectedPlan(null); }, []); const handleCompletePending = (transactionId: string) => { // Same readiness gate as handleSelectPlan — silently failing to pay // a pending tx because the profile is incomplete was the bug. Both // paths funnel through useReadinessGate so the modal copy and the // missing-fields list stay in one place. readinessGate.attempt( () => { const pendingPlanId = pending?.planId; navigate({ to: '/dashboard/checkout', search: { planId: pendingPlanId, txId: transactionId }, }); }, () => setShowReadinessModal(true), ); }; // Resolve current plan data from plans list const currentPlanData = plans?.find((p) => p.id === currentPlanId); const iconKey = currentPlanData?.displayConfig?.icon; const PlanIcon = iconKey ? (LUCIDE_ICON_MAP[iconKey] ?? CreditCard) : CreditCard; const currentPlanFeatures = (currentPlanData?.features ?? []) .slice() .sort((a, b) => a.sortOrder - b.sortOrder) .map((f) => ({ text: f.description?.[locale] ?? f.capability?.name?.[locale] ?? '', included: f.enabled, })) .filter((f) => f.text); const overlayContent = ( <> {showPlansModal && plans && authed && ( setShowPlansModal(false)} /> )} {showReadinessModal && ( setShowReadinessModal(false)} /> )} {selectedPlan && ( )} {pendingTransactionId && ( setPendingTransactionId(null)} onSuccess={() => { setPendingTransactionId(null); handlePaymentSuccess(); }} /> )} {showPurchasePanel && commandPackages.length > 0 && ( setShowPurchasePanel(false)} onSuccess={() => setShowPurchasePanel(false)} /> )} {showConfigurator && ( setShowConfigurator(false)} /> )} ); return ( } gradient={true} overlay={overlayContent} > {error && (

{t('subscription.errorLoading', { error: (error as Error).message })}

)} {/* Pending Payment Banner */} {pending && pending.plan && ( )} {/* Cancellation Notice */} {subscription?.cancelAtPeriodEnd && (

{t('subscription.cancelNotice', { date: new Date(subscription.currentPeriodEnd).toLocaleDateString(locale) })}

{t('subscription.cancelNoticeDetail')}

)} {/* Section 1: Current Plan */}

{t('subscription.currentPlan')}

{isLoading ? (
) : subscription ? (

{subscription.plan.name}

{subscription.status.replace('_', ' ')}

{t('subscription.price')}

{subscription.plan.monthlyPrice === 0 ? t('common.free') : `${formatPlanPrice(subscription.plan.monthlyPrice, subscription.plan.currency)}${t('subscription.perMonth')}`}

{t('subscription.billingCycle')}

{currentPlanData?.billingCycle || 'monthly'}

{t('subscription.currentPeriod')}

{new Date(subscription.currentPeriodStart).toLocaleDateString(locale)} — {new Date(subscription.currentPeriodEnd).toLocaleDateString(locale)}

{t('subscription.renewal')}

{subscription.cancelAtPeriodEnd ? t('subscription.cancelsAtPeriodEnd') : t('common.active')}

{/* Plan Features */} {currentPlanFeatures.length > 0 && (

{t('subscription.planFeatures')}

{currentPlanFeatures.map((f, i) => (
{f.text}
))}
)} {/* Active Campaigns */} {activeCampaigns.length > 0 && ( )} {/* Cancel button */} {subscription.plan.slug !== 'free' && !subscription.cancelAtPeriodEnd && (
{showCancelConfirm ? (

{t('subscription.cancelConfirm')}

) : ( )}
)}
) : null}
{/* Section 2: Usage */} {quota && (

{t('subscription.usage')}

{quota.extraLimit > 0 && (
)} {/* Customize plan (configurator) */}

Customize your plan

Add extra seats, sites or commands to your subscription.

{/* Buy Commands + Auto-buy */} {commandPackages.length > 0 && (
{commandPackages.map((pkg: any) => ( ))}
{t('subscription.autoBuy')}
)}
)} {/* Section 3: Payment History */}

{t('subscription.paymentHistory')}

); }