All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 4s
Covers architecture, configuration steps, user flows, API routes, webhooks, pricing, testing with Stripe CLI, production checklist, and troubleshooting.
492 lines
22 KiB
TypeScript
492 lines
22 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { loadStripe } from '@stripe/stripe-js';
|
|
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
|
|
import { Check, Shield, Zap, Crown, X, ExternalLink, Loader2, CheckCircle2, Activity, Clock, ArrowRight } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { useLanguage } from '@/lib/i18n';
|
|
import { toast } from 'sonner';
|
|
import { format } from 'date-fns';
|
|
import { motion } from 'motion/react';
|
|
|
|
type Tier = 'PRO' | 'BUSINESS';
|
|
type Interval = 'month' | 'year';
|
|
|
|
interface BillingStatus {
|
|
tier: string;
|
|
effectiveTier: string;
|
|
status: string;
|
|
currentPeriodEnd: string | null;
|
|
cancelAtPeriodEnd: boolean;
|
|
hasStripeSubscription: boolean;
|
|
}
|
|
|
|
const billingEnabled = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true';
|
|
|
|
let stripePromise: ReturnType<typeof loadStripe> | null = null;
|
|
function getStripePromise() {
|
|
if (!billingEnabled) return null;
|
|
if (!stripePromise && process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) {
|
|
stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
|
|
}
|
|
return stripePromise;
|
|
}
|
|
|
|
export function BillingPlans() {
|
|
const { t } = useLanguage();
|
|
const queryClient = useQueryClient();
|
|
const [interval, setInterval] = useState<Interval>('month');
|
|
const [checkoutClientSecret, setCheckoutClientSecret] = useState<string | null>(null);
|
|
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
|
|
const [checkoutLoading, setCheckoutLoading] = useState<Tier | null>(null);
|
|
const [portalLoading, setPortalLoading] = useState(false);
|
|
const [successBanner, setSuccessBanner] = useState<string | null>(null);
|
|
|
|
const { data: status, isLoading } = useQuery<BillingStatus>({
|
|
queryKey: ['billing', 'status'],
|
|
queryFn: async () => {
|
|
const res = await fetch('/api/billing/status');
|
|
if (!res.ok) throw new Error('Failed to fetch billing status');
|
|
return res.json();
|
|
},
|
|
});
|
|
|
|
const { data: quotas } = useQuery({
|
|
queryKey: ['usage', 'current'],
|
|
queryFn: async () => {
|
|
const res = await fetch('/api/usage/current');
|
|
if (!res.ok) throw new Error('Failed to fetch quotas');
|
|
const data = await res.json();
|
|
return data.quotas as Record<string, { remaining: number; limit: number; used: number }>;
|
|
},
|
|
refetchInterval: 60000,
|
|
});
|
|
|
|
useEffect(() => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const sessionId = params.get('session_id');
|
|
if (sessionId) {
|
|
const tier = status?.effectiveTier ?? 'Pro';
|
|
setSuccessBanner(t('billing.checkoutSuccessBody').replace('{tier}', tier));
|
|
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
|
|
queryClient.invalidateQueries({ queryKey: ['billing', 'status'] });
|
|
window.history.replaceState({}, '', '/settings/billing');
|
|
}
|
|
}, [status, t, queryClient]);
|
|
|
|
const handleCheckout = async (tier: Tier) => {
|
|
if (!billingEnabled) return;
|
|
setCheckoutLoading(tier);
|
|
try {
|
|
const res = await fetch('/api/billing/create-checkout', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ tier, interval }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error ?? 'Failed to create checkout');
|
|
if (data.clientSecret) {
|
|
setCheckoutClientSecret(data.clientSecret);
|
|
setIsCheckoutOpen(true);
|
|
} else if (data.url) {
|
|
window.location.href = data.url;
|
|
}
|
|
} catch (err) {
|
|
console.error('[BillingPlans] checkout error:', err);
|
|
toast.error('Failed to start checkout. Please try again.');
|
|
} finally {
|
|
setCheckoutLoading(null);
|
|
}
|
|
};
|
|
|
|
const handlePortal = async () => {
|
|
setPortalLoading(true);
|
|
try {
|
|
const res = await fetch('/api/billing/portal', { method: 'POST' });
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error ?? 'Failed to open portal');
|
|
window.location.href = data.url;
|
|
} catch (err) {
|
|
console.error('[BillingPlans] portal error:', err);
|
|
toast.error('Failed to open billing portal.');
|
|
} finally {
|
|
setPortalLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCheckoutComplete = useCallback(() => {
|
|
setIsCheckoutOpen(false);
|
|
setCheckoutClientSecret(null);
|
|
const tier = status?.effectiveTier ?? 'Pro';
|
|
setSuccessBanner(t('billing.checkoutSuccessBody').replace('{tier}', tier));
|
|
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
|
|
queryClient.invalidateQueries({ queryKey: ['billing', 'status'] });
|
|
}, [status, t, queryClient]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-16">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const effectiveTier = status?.effectiveTier ?? 'BASIC';
|
|
const isPaid = effectiveTier !== 'BASIC';
|
|
|
|
const aiUsed = quotas?.semantic_search?.used ?? 0;
|
|
const aiLimit = quotas?.semantic_search?.limit ?? 50;
|
|
const aiPct = aiLimit > 0 ? (aiUsed / aiLimit) * 100 : 0;
|
|
|
|
const plans = [
|
|
{
|
|
id: 'free',
|
|
name: t('billing.freePlan'),
|
|
price: t('billing.freePrice') || 'Gratuit',
|
|
period: '',
|
|
description: t('billing.freeDescription') || 'Pour découvrir la magie de Momento.',
|
|
features: [
|
|
t('billing.freeF1') || '100 Notes max',
|
|
t('billing.freeF2') || '3 Carnets',
|
|
t('billing.freeF3') || '50 crédits IA (Lifetime)',
|
|
t('billing.freeF4') || 'Recherche sémantique',
|
|
t('billing.freeF5') || 'Historique 7 jours',
|
|
],
|
|
current: effectiveTier === 'BASIC',
|
|
buttonText: effectiveTier === 'BASIC' ? (t('billing.currentPlan') || 'Plan Actuel') : t('billing.startCheckout'),
|
|
buttonClass: effectiveTier === 'BASIC'
|
|
? 'bg-paper text-concrete cursor-default'
|
|
: 'bg-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
|
|
onClick: () => {},
|
|
},
|
|
{
|
|
id: 'pro',
|
|
name: t('billing.proPlan'),
|
|
price: interval === 'month' ? (t('billing.proPrice') || '9,90€') : (t('billing.proAnnualPrice') || '99€'),
|
|
period: interval === 'month' ? '/mois' : '/an',
|
|
description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
|
|
features: [
|
|
t('billing.proFeature1') || 'Notes illimitées',
|
|
t('billing.proFeature2') || 'BYOK (OpenAI/Anthropic)',
|
|
t('billing.proFeature3') || '200 recherches sémantiques',
|
|
t('billing.proFeature4') || 'Agents (12 runs/mois)',
|
|
t('billing.proFeature5') || 'Historique 30 jours',
|
|
t('billing.proFeature6') || 'Support Email',
|
|
],
|
|
current: effectiveTier === 'PRO',
|
|
popular: true,
|
|
buttonText: effectiveTier === 'PRO' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.proCta') || 'Passer au Plan Pro'),
|
|
buttonClass: effectiveTier === 'PRO'
|
|
? 'bg-paper text-concrete cursor-default'
|
|
: 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
|
|
onClick: () => handleCheckout('PRO'),
|
|
},
|
|
{
|
|
id: 'business',
|
|
name: t('billing.businessPlan'),
|
|
price: interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€'),
|
|
period: interval === 'month' ? '/mois' : '/an',
|
|
description: t('billing.businessDescription') || 'Pour les équipes et chefs de produit.',
|
|
features: [
|
|
t('billing.businessFeature1') || '10 Collaborateurs inclus',
|
|
t('billing.businessFeature2') || 'BYOK (13 fournisseurs)',
|
|
t('billing.businessFeature3') || '1000 recherches sémantiques',
|
|
t('billing.businessFeature4') || 'Agents (60 runs/mois)',
|
|
t('billing.businessFeature5') || 'Brainstorm illimité',
|
|
t('billing.businessFeature6') || 'Accès API',
|
|
],
|
|
current: effectiveTier === 'BUSINESS',
|
|
buttonText: effectiveTier === 'BUSINESS' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.businessCta') || 'Choisir Plan Business'),
|
|
buttonClass: effectiveTier === 'BUSINESS'
|
|
? 'bg-paper text-concrete cursor-default'
|
|
: 'bg-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
|
|
onClick: () => handleCheckout('BUSINESS'),
|
|
},
|
|
{
|
|
id: 'enterprise',
|
|
name: t('billing.enterpriseTitle') || 'Enterprise',
|
|
price: t('billing.contactSales') || 'Sur devis',
|
|
period: '',
|
|
description: t('billing.enterpriseDescription') || 'Quotas personnalisés, SSO, support prioritaire.',
|
|
features: [
|
|
t('billing.enterpriseFeature1') || 'Quotas illimités',
|
|
t('billing.enterpriseFeature2') || 'SSO / SAML',
|
|
t('billing.enterpriseFeature3') || 'Support dédié',
|
|
t('billing.enterpriseFeature4') || 'Facturation personnalisée',
|
|
t('billing.enterpriseFeature5') || 'SLA garanti',
|
|
],
|
|
current: effectiveTier === 'ENTERPRISE',
|
|
buttonText: effectiveTier === 'ENTERPRISE' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.contactSales') || 'Contact Sales'),
|
|
buttonClass: effectiveTier === 'ENTERPRISE'
|
|
? 'bg-paper text-concrete cursor-default'
|
|
: 'bg-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
|
|
onClick: () => { window.location.href = 'mailto:sales@memento-note.com'; },
|
|
},
|
|
];
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="space-y-12"
|
|
>
|
|
{/* Success Banner */}
|
|
{successBanner && (
|
|
<div className="flex items-start gap-3 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-4">
|
|
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400 shrink-0 mt-0.5" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-semibold text-foreground">{t('billing.checkoutSuccessTitle')}</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">{successBanner}</p>
|
|
</div>
|
|
<button type="button" onClick={() => setSuccessBanner(null)} className="text-muted-foreground hover:text-foreground transition-colors">
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<h3 className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60">
|
|
{t('billing.subtitle') || 'Gérer votre abonnement et votre facturation'}
|
|
</h3>
|
|
</div>
|
|
|
|
{/* Usage Overview */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="md:col-span-2 bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-8 space-y-6">
|
|
<div className="flex items-center gap-4">
|
|
<div className="p-2 bg-brand-accent/10 text-brand-accent rounded-xl">
|
|
<Activity size={20} />
|
|
</div>
|
|
<div>
|
|
<h4 className="text-sm font-bold text-ink">{t('billing.currentUsage') || 'Utilisation actuelle'}</h4>
|
|
<p className="text-[10px] text-concrete uppercase tracking-widest">{t('billing.currentPeriod') || 'Période en cours'}</p>
|
|
</div>
|
|
<div className="ml-auto">
|
|
<span className={cn(
|
|
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest',
|
|
isPaid ? 'bg-brand-accent/10 text-brand-accent' : 'bg-concrete/10 text-concrete'
|
|
)}>
|
|
{effectiveTier === 'BASIC' ? 'Discovery' : effectiveTier}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
{!quotas && (
|
|
<div className="col-span-full flex items-center justify-center py-8">
|
|
<Loader2 className="h-5 w-5 animate-spin text-concrete" />
|
|
</div>
|
|
)}
|
|
{quotas && Object.entries(quotas).filter(([_, q]) => q.limit > 0).length === 0 && (
|
|
<p className="col-span-full text-xs text-concrete text-center py-4">Aucune donnée d'utilisation disponible</p>
|
|
)}
|
|
{quotas && Object.entries(quotas).filter(([_, q]) => q.limit > 0).map(([key, q]) => {
|
|
const pct = q.limit > 0 ? (q.used / q.limit) * 100 : 0
|
|
const featureLabels: Record<string, string> = {
|
|
semantic_search: t('usageMeter.featureSearch'),
|
|
auto_tag: t('usageMeter.featureTags'),
|
|
auto_title: t('usageMeter.featureTitles'),
|
|
reformulate: t('usageMeter.featureReformulate'),
|
|
chat: t('usageMeter.featureChat'),
|
|
brainstorm_create: t('usageMeter.featureBrainstormCreate'),
|
|
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
|
|
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
|
|
}
|
|
return (
|
|
<div key={key} className="space-y-2 p-3 rounded-xl bg-paper/50 dark:bg-white/5">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-[10px] font-bold text-concrete uppercase tracking-wider truncate">
|
|
{featureLabels[key] || key}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-baseline gap-1">
|
|
<span className="text-lg font-bold text-ink">{q.remaining === Infinity ? '∞' : q.remaining}</span>
|
|
<span className="text-[10px] text-concrete">/ {q.limit === Infinity ? '∞' : q.limit}</span>
|
|
</div>
|
|
<div className="h-1.5 w-full bg-slate-100 dark:bg-white/5 rounded-full overflow-hidden">
|
|
<div
|
|
className={cn('h-full rounded-full transition-all', pct >= 90 ? 'bg-rose-500' : pct >= 70 ? 'bg-amber-500' : 'bg-brand-accent')}
|
|
style={{ width: `${Math.min(pct, 100)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-8 space-y-6">
|
|
<div className="flex items-center gap-4">
|
|
<div className="p-2 bg-paper dark:bg-white/10 text-concrete rounded-xl">
|
|
<Clock size={20} />
|
|
</div>
|
|
<div>
|
|
<h4 className="text-sm font-bold text-ink">{t('billing.billing') || 'Facturation'}</h4>
|
|
<p className="text-[10px] text-concrete uppercase tracking-widest">{t('billing.renewal') || 'Renouvellement'}</p>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<p className="text-xs text-concrete font-light">
|
|
{isPaid
|
|
? t('billing.paidPlanDesc') || 'Votre abonnement se renouvelle automatiquement.'
|
|
: t('billing.freePlanDesc') || 'Votre plan gratuit n\'expire jamais. Passez à la vitesse supérieure pour débloquer toute la puissance de Momento.'}
|
|
</p>
|
|
{isPaid && (
|
|
<button
|
|
type="button"
|
|
onClick={handlePortal}
|
|
disabled={portalLoading}
|
|
className="mt-2 flex items-center gap-2 text-[10px] font-bold text-brand-accent uppercase tracking-widest hover:underline disabled:opacity-60"
|
|
>
|
|
{portalLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <ExternalLink className="h-3 w-3" />}
|
|
{t('billing.openPortal')}
|
|
</button>
|
|
)}
|
|
<div className="pt-4 flex items-center justify-between border-t border-border/40 mt-4">
|
|
<span className="text-[11px] font-bold text-ink uppercase tracking-widest">{t('billing.currentPlan')}</span>
|
|
<span className="text-[11px] font-bold text-brand-accent uppercase tracking-widest">
|
|
{effectiveTier === 'BASIC' ? 'GRATUIT' : effectiveTier}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Interval Toggle */}
|
|
{billingEnabled && effectiveTier === 'BASIC' && (
|
|
<div className="flex items-center gap-2 justify-center">
|
|
<button
|
|
type="button"
|
|
onClick={() => setInterval('month')}
|
|
className={cn(
|
|
'px-4 py-1.5 text-xs font-medium rounded-full transition-all',
|
|
interval === 'month' ? 'bg-ink text-paper' : 'text-concrete hover:text-ink'
|
|
)}
|
|
>
|
|
{t('billing.monthly')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setInterval('year')}
|
|
className={cn(
|
|
'px-4 py-1.5 text-xs font-medium rounded-full transition-all',
|
|
interval === 'year' ? 'bg-ink text-paper' : 'text-concrete hover:text-ink'
|
|
)}
|
|
>
|
|
{t('billing.annual')}
|
|
<span className="ms-1 text-emerald-600 dark:text-emerald-400">{t('billing.save')} ~17%</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Plan Cards */}
|
|
{(billingEnabled || true) && (
|
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
{plans.map((plan) => (
|
|
<div
|
|
key={plan.id}
|
|
className={cn(
|
|
'relative p-8 rounded-[40px] border transition-all duration-500 overflow-hidden group flex flex-col',
|
|
plan.popular
|
|
? 'bg-white dark:bg-paper border-brand-accent shadow-2xl shadow-brand-accent/10 scale-105 z-10'
|
|
: 'bg-white/40 dark:bg-white/5 border-border hover:border-concrete/30'
|
|
)}
|
|
>
|
|
{plan.popular && (
|
|
<div className="absolute top-0 right-0 py-1.5 px-6 bg-brand-accent text-white text-[9px] font-bold uppercase tracking-widest rounded-bl-2xl">
|
|
{t('billing.recommended') || 'Recommandé'}
|
|
</div>
|
|
)}
|
|
|
|
<div className="mb-8 space-y-2">
|
|
<h4 className="text-xl font-serif font-bold text-ink">{plan.name}</h4>
|
|
<div className="flex items-baseline gap-1">
|
|
<span className="text-4xl font-serif font-bold text-ink">{plan.price}</span>
|
|
<span className="text-concrete text-xs font-light italic">{plan.period}</span>
|
|
</div>
|
|
<p className="text-xs text-concrete font-light leading-relaxed pe-4">{plan.description}</p>
|
|
</div>
|
|
|
|
<div className="space-y-4 mb-10 flex-1">
|
|
{plan.features.map((feature, i) => (
|
|
<div key={i} className="flex items-start gap-3">
|
|
<div className={cn('mt-1 p-0.5 rounded-full', plan.popular ? 'bg-brand-accent/10 text-brand-accent' : 'bg-concrete/10 text-concrete')}>
|
|
<Check size={10} />
|
|
</div>
|
|
<span className="text-xs font-light text-ink/80">{feature}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<button
|
|
onClick={plan.onClick}
|
|
disabled={plan.current || checkoutLoading !== null}
|
|
className={cn('w-full py-4 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] transition-all duration-300', plan.buttonClass)}
|
|
>
|
|
<div className="flex items-center justify-center gap-2">
|
|
{checkoutLoading && !plan.current ? <Loader2 className="h-4 w-4 animate-spin" /> : plan.buttonText}
|
|
{!plan.current && <ArrowRight size={14} />}
|
|
</div>
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer Info */}
|
|
<div className="bg-slate-50 dark:bg-black/20 rounded-[32px] p-8 border border-border/40 flex flex-col md:flex-row items-center justify-between gap-8">
|
|
<div className="flex items-center gap-4">
|
|
<div className="p-3 bg-white dark:bg-paper rounded-2xl shadow-sm border border-border">
|
|
<Shield size={24} className="text-brand-accent" />
|
|
</div>
|
|
<div>
|
|
<h5 className="text-sm font-bold text-ink">{t('billing.secureTransactions') || 'Transactions sécurisées'}</h5>
|
|
<p className="text-xs text-concrete font-light">{t('billing.secureDesc') || 'Paiement via Stripe. Annulez à tout moment, sans engagement.'}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-6">
|
|
<div className="flex items-center gap-2">
|
|
<Zap size={16} className="text-ochre" />
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('billing.instantActivation') || 'Activation instantanée'}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Crown size={16} className="text-amber-500" />
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('billing.satisfactionGuarantee') || 'Garantie satisfait'}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Embedded Checkout Modal */}
|
|
{isCheckoutOpen && checkoutClientSecret && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
|
<h3 className="font-semibold text-foreground">{t('billing.upgradeTitle')}</h3>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsCheckoutOpen(false);
|
|
setCheckoutClientSecret(null);
|
|
toast.info(t('billing.checkoutCanceled'));
|
|
}}
|
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
<div className="p-2">
|
|
<EmbeddedCheckoutProvider
|
|
stripe={getStripePromise()}
|
|
options={{ clientSecret: checkoutClientSecret, onComplete: handleCheckoutComplete }}
|
|
>
|
|
<EmbeddedCheckout />
|
|
</EmbeddedCheckoutProvider>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
);
|
|
}
|