import React, { useState, useEffect } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { AppLayout } from '../../components/layout/AppLayout';
import { LoadingSpinner } from '../../components/ui/LoadingSpinner';
import { ArrowLeft, CheckCircle2, AlertCircle, ArrowRight, Loader2 } from 'lucide-react';
import { loadStripe } from '@stripe/stripe-js';
interface Subscription {
status: string;
plan: string;
is_active: boolean;
current_period_end: string | null;
cancel_at_period_end: boolean;
trial_end: string | null;
}
interface BillingConfig {
publishableKey: string;
plans: Record<string, { name: string; price: string; seats: number }>;
seatUsage: { used: number; limit: number; tier: string; company_name: string } | null;
}
const PLAN_IDS: Record<string, string> = {
Launch: 'launch',
Growth: 'growth',
Command: 'command',
};
export function BillingPage() {
const [searchParams] = useSearchParams();
const [subscription, setSubscription] = useState<Subscription | null>(null);
const [config, setConfig] = useState<BillingConfig | null>(null);
const [loading, setLoading] = useState(true);
const [checkoutLoading, setCheckoutLoading] = useState<string | null>(null);
const [error, setError] = useState('');
const pendingPlan = searchParams.get('plan');
useEffect(() => {
Promise.all([
fetch('/api/billing/subscription').then(r => r.json()),
fetch('/api/billing/config').then(r => r.json()),
]).then(([sub, cfg]) => {
setSubscription(sub.subscription || null);
setConfig(cfg);
setLoading(false);
}).catch(() => {
setError('Failed to load billing info');
setLoading(false);
});
}, []);
const handleCheckout = async (planSlug: string) => {
setCheckoutLoading(planSlug);
setError('');
try {
const res = await fetch('/api/billing/checkout-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan: planSlug }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Checkout failed');
}
// Use Stripe.js for a proper redirect to Stripe Checkout.
// loadStripe is memoised by the SDK so repeated calls are cheap.
if (config?.publishableKey) {
const stripe = await loadStripe(config.publishableKey);
if (stripe && data.sessionId) {
// Prefer redirectToCheckout (uses sessionId) over window.location so
// Stripe can handle 3DS, SCA, and other payment flows correctly.
const { error: stripeErr } = await stripe.redirectToCheckout({
sessionId: data.sessionId,
});
if (stripeErr) throw new Error(stripeErr.message);
return; // redirectToCheckout navigates away — no further work needed
}
}
// Fallback: direct URL redirect (e.g. publishableKey unavailable)
window.location.href = data.url;
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Checkout failed. Please try again.');
} finally {
setCheckoutLoading(null);
}
};
const handleManageBilling = async () => {
try {
const res = await fetch('/api/billing/portal-session', {
method: 'POST',
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed');
}
window.location.href = data.url;
} catch {
setError('Could not open billing portal');
}
};
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center">
<LoadingSpinner size="lg" />
</div>
);
}
const currentPlan = subscription?.plan || 'Starter';
const isActive = subscription?.is_active || false;
return (
<AppLayout title="Billing & Subscription">
<div className="max-w-4xl mx-auto space-y-8">
<Link
to="/app/dashboard"
className="inline-flex items-center gap-2 text-sm text-white/60 hover:text-white/80 transition-colors"
>
<ArrowLeft size={16} /> Back to Dashboard
</Link>
<div>
<h1 className="text-2xl font-bold mb-2">Billing & Subscription</h1>
<p className="text-white/60">
Manage your subscription plan and payment method.
</p>
</div>
{error && (
<div className="flex items-center gap-2 rounded-lg bg-red-500/10 p-4 text-sm text-red-400">
<AlertCircle className="h-5 w-5 shrink-0" />
<span>{error}</span>
</div>
)}
{/* Current Plan */}
<div className="mb-8 rounded-xl border border-white/10 bg-white/5 p-6">
<h2 className="text-lg font-semibold mb-4">Current Plan</h2>
<div className="flex items-center justify-between">
<div>
<div className="text-xl font-bold capitalize">{currentPlan}</div>
<div className="text-sm text-white/50 mt-1">
{isActive ? (
<>
Active{' '}
{subscription?.cancel_at_period_end && (
<span className="text-yellow-400">(cancels at period end)</span>
)}
</>
) : (
'Free trial eligible'
)}
{config?.seatUsage && (
<span className="ml-3 inline-flex items-center gap-1">
<span className="font-medium text-white/80">
{config.seatUsage.used}/{config.seatUsage.limit} seats
</span>
{config.seatUsage.used >= config.seatUsage.limit * 0.8 && (
<span className="text-orange-400 text-xs">
({Math.round((1 - config.seatUsage.used / config.seatUsage.limit) * 100)}% remaining)
</span>
)}
</span>
)}
</div>
</div>
{isActive && subscription?.current_period_end && (
<div className="text-right text-sm text-white/50">
<div>Renews</div>
<div className="font-medium text-white/70">
{new Date(subscription.current_period_end).toLocaleDateString()}
</div>
</div>
)}
</div>
{isActive && (
<button
onClick={handleManageBilling}
className="mt-4 text-sm text-brand-400 hover:text-brand-300 flex items-center gap-1"
>
Manage billing <ArrowRight size={14} />
</button>
)}
</div>
{/* Plans */}
<h2 className="text-lg font-semibold mb-4">Available Plans</h2>
<div className="grid md:grid-cols-3 gap-4 mb-8">
{config?.plans && Object.entries(config.plans).map(([slug, plan]) => {
const isCurrent = PLAN_IDS[plan.name] === currentPlan;
return (
<div
key={slug}
className={`rounded-xl border p-6 flex flex-col ${
isCurrent
? 'border-green-500/50 bg-green-500/5'
: 'border-white/10 bg-white/5'
}`}
>
<div className="flex items-center justify-between mb-2">
<h3 className="font-bold text-lg">{plan.name}</h3>
{isCurrent && (
<span className="flex items-center gap-1 text-xs text-green-400">
<CheckCircle2 size={14} /> Current
</span>
)}
</div>
<div className="text-2xl font-bold mb-1">{plan.price}</div>
<p className="text-xs text-white/50 mb-4 flex-1">
{plan.name === 'Launch' && '1 market · 5 seats · 3 integrations'}
{plan.name === 'Growth' && '5 markets · 25 seats · 10 integrations'}
{plan.name === 'Command' && '25 markets · 100 seats · 50 integrations'}
{config?.seatUsage && (
<span className="block mt-1 text-white/40">
Your company: {config.seatUsage.used}/{config.seatUsage.limit} seats used
</span>
)}
</p>
<button
onClick={() => handleCheckout(slug)}
disabled={checkoutLoading !== null}
className={`w-full py-2.5 rounded-lg text-sm font-semibold transition-all min-h-[44px] ${
isCurrent
? 'bg-white/5 text-white/30 cursor-default'
: isActive
? 'bg-green-500/10 text-green-400 hover:bg-green-500/20'
: 'bg-brand-600 text-white hover:bg-brand-500'
}`}
>
{checkoutLoading === slug ? (
<Loader2 size={16} className="animate-spin mx-auto" />
) : isCurrent ? (
'Current Plan'
) : isActive ? (
`Switch to ${plan.name}`
) : (
`Start 7-day trial`
)}
</button>
</div>
);
})}
</div>
{/* Pending plan from signup */}
{pendingPlan && config?.plans[pendingPlan] && !isActive && (
<div className="rounded-xl border border-brand-500/30 bg-brand-500/5 p-6 text-center">
<p className="text-white/70 mb-3">
Ready to start your <strong>{config.plans[pendingPlan].name}</strong> trial?
</p>
<button
onClick={() => handleCheckout(pendingPlan)}
disabled={checkoutLoading !== null}
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-brand-600 text-white font-semibold hover:bg-brand-500 transition-colors min-h-[44px]"
>
{checkoutLoading === pendingPlan ? (
<Loader2 size={16} className="animate-spin" />
) : (
<>
Start {config.plans[pendingPlan].name} Trial <ArrowRight size={16} />
</>
)}
</button>
</div>
)}
</div>
</AppLayout>
);
}