Files
Momento/memento-note/components/settings/billing-plans.tsx
Antigravity afbb0dfc2d
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m57s
CI / Deploy production (on server) (push) Successful in 24s
fix(ui): thème, textes et lisibilité restants de la revue
Les boutons suivent la couleur d’apparence, les libellés trop petits ou trop techniques sont clarifiés, et le catalogue des fournisseurs se met à jour tout seul.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 21:13:07 +00:00

892 lines
37 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useCallback, useRef } 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, ArrowRight, Coins } 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';
import { BillingHistory } from './billing-history';
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants';
type Tier = 'PRO' | 'BUSINESS';
type Interval = 'month' | 'year';
interface BillingStatus {
tier: string;
effectiveTier: string;
status: string;
currentPeriodStart: string | null;
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
hasStripeSubscription: boolean;
trialEndsAt?: string | null;
trialEligible?: boolean;
trialDays?: number;
billingEnabled?: boolean;
prices?: {
PRO: {
month: { display: string; amount: number; currency: string };
year: { display: string; amount: number; currency: string };
};
BUSINESS: {
month: { display: string; amount: number; currency: string };
year: { display: string; amount: number; currency: string };
};
};
creditPacks?: Array<{
id: 'S' | 'M' | 'L';
credits: number;
display: string;
amount: number;
currency: string;
configured: boolean;
}>;
}
const billingEnabledEnvFallback = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' || process.env.NODE_ENV === 'development';
let stripePromise: ReturnType<typeof loadStripe> | null = null;
function getStripePromise(enabled: boolean) {
if (!enabled) 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, language } = 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 [packLoading, setPackLoading] = useState<'S' | 'M' | 'L' | null>(null);
const [portalLoading, setPortalLoading] = useState(false);
const [cancelLoading, setCancelLoading] = useState(false);
const [successBanner, setSuccessBanner] = useState<string | null>(null);
const plansSectionRef = useRef<HTMLDivElement>(null);
const { data: status, isLoading } = useQuery<BillingStatus>({
queryKey: ['billing', 'status'],
queryFn: async () => {
const search = typeof window !== 'undefined' ? window.location.search : '';
const res = await fetch(`/api/billing/status${search}`);
if (!res.ok) throw new Error(t('billing.fetchStatusFailed'));
return res.json();
},
});
const { data: byokCatalog } = useQuery({
queryKey: ['public', 'byok-catalog'],
queryFn: async () => {
const res = await fetch('/api/public/byok-catalog')
if (!res.ok) throw new Error('catalog')
return res.json() as Promise<{ providers: { id: string }[] }>
},
staleTime: 60_000,
})
const providerCount = byokCatalog?.providers.length || '…'
const { data: usageData } = useQuery({
queryKey: ['usage', 'current'],
queryFn: async () => {
const res = await fetch('/api/usage/current');
if (!res.ok) throw new Error(t('billing.fetchQuotasFailed'));
return res.json();
},
refetchInterval: 30000,
});
const quotas = usageData?.quotas;
const balance = usageData?.balance as
| {
totalRemaining: number | null
subscriptionGranted: number | null
purchasedRemaining: number
spentThisPeriod: number
unlimited: boolean
}
| undefined
const breakdown = (usageData?.breakdown ?? []) as Array<{
feature: string
creditsUsed: number
actionsCount: number
}>
const billingEnabled = status?.billingEnabled ?? billingEnabledEnvFallback;
const stripe = getStripePromise(billingEnabled);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const sessionId = params.get('session_id');
const isPack = params.get('pack') === '1';
if (sessionId) {
if (isPack) {
setSuccessBanner(t('billing.packCheckoutSuccess'));
} else {
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]);
useEffect(() => {
if (successBanner) {
const timer = setTimeout(() => {
setSuccessBanner(null);
}, 5000);
return () => clearTimeout(timer);
}
}, [successBanner]);
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(t('billing.checkoutFailed'));
} finally {
setCheckoutLoading(null);
}
};
const handleBuyPack = async (packId: 'S' | 'M' | 'L') => {
if (!billingEnabled) return;
setPackLoading(packId);
try {
const res = await fetch('/api/billing/create-pack-checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ packId }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? 'Failed to create pack checkout');
if (data.url) {
window.location.href = data.url;
} else {
throw new Error('No checkout URL');
}
} catch (err) {
console.error('[BillingPlans] pack checkout error:', err);
toast.error(t('billing.packCheckoutFailed'));
} finally {
setPackLoading(null);
}
};
const handlePortal = async (action: 'portal' | 'cancel' | React.MouseEvent = 'portal') => {
const actualAction = typeof action === 'string' ? action : 'portal';
setPortalLoading(true);
try {
const res = await fetch('/api/billing/portal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: actualAction }),
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || t('billing.portalFailed'));
return;
}
window.location.href = data.url;
} catch (err) {
console.error('[BillingPlans] portal error:', err);
toast.error(t('billing.portalFailed'));
} finally {
setPortalLoading(false);
}
};
const handleCancelSubscription = async () => {
const confirmMsg = t('billing.cancelConfirm');
if (!window.confirm(confirmMsg)) {
return;
}
setCancelLoading(true);
try {
const res = await fetch('/api/billing/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || t('billing.cancelFailed'));
return;
}
toast.success(t('billing.cancelSuccess'));
queryClient.invalidateQueries({ queryKey: ['billing', 'status'] });
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
} catch (err) {
console.error('[BillingPlans] cancel error:', err);
toast.error(t('billing.cancelFailed'));
} finally {
setCancelLoading(false);
}
};
const scrollToPlans = () => {
plansSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const handleChangePlan = (tier: Tier) => {
if (status?.hasStripeSubscription) {
void handlePortal('portal');
return;
}
void handleCheckout(tier);
};
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 isTrialing = (status?.status ?? '').toUpperCase() === 'TRIALING';
const trialEligible = !!status?.trialEligible;
const trialDays = status?.trialDays ?? SUBSCRIPTION_TRIAL_DAYS;
const trialCta = (fallback: string) =>
trialEligible ? t('billing.startTrialCta', { days: trialDays }) : fallback;
const plans = [
{
id: 'free',
name: t('billing.freePlan'),
price: t('billing.freePrice') || 'Gratuit',
period: '',
description: t('billing.freeDescription') || 'Pour découvrir Memento.',
features: [
t('billing.freeF1'),
t('billing.freeF2'),
t('billing.freeF3'),
t('billing.freeF4'),
t('billing.freeF5'),
],
current: effectiveTier === 'BASIC',
buttonText: effectiveTier === 'BASIC'
? (t('billing.currentPlan') || 'Plan Actuel')
: t('billing.downgradeToFree'),
buttonClass: effectiveTier === 'BASIC'
? '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: () => {
if (effectiveTier !== 'BASIC') void handleCancelSubscription();
},
},
{
id: 'pro',
name: t('billing.proPlan'),
price: status?.prices?.PRO?.[interval]?.display ??
(interval === 'month' ? (t('billing.proPrice') || '9,90€') : (t('billing.proAnnualPrice') || '99€')),
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
features: [
...(trialEligible ? [t('billing.trialFeature', { days: trialDays })] : []),
t('billing.proFeature1'),
t('billing.proFeature2'),
t('billing.proFeature3', { count: providerCount }),
t('billing.proFeature4'),
t('billing.proFeature5'),
t('billing.proFeature6'),
],
current: effectiveTier === 'PRO',
popular: true,
buttonText: effectiveTier === 'PRO' ? (t('billing.currentPlan') || 'Plan Actuel') : trialCta(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: () => handleChangePlan('PRO'),
},
{
id: 'business',
name: t('billing.businessPlan'),
price: status?.prices?.BUSINESS?.[interval]?.display ??
(interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€')),
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
features: [
...(trialEligible ? [t('billing.trialFeature', { days: trialDays })] : []),
t('billing.businessFeature1'),
t('billing.businessFeature2'),
t('billing.businessFeature3', { count: providerCount }),
t('billing.businessFeature4'),
t('billing.businessFeature5'),
t('billing.businessFeature6'),
],
current: effectiveTier === 'BUSINESS',
buttonText: effectiveTier === 'BUSINESS' ? (t('billing.currentPlan') || 'Plan Actuel') : trialCta(t('billing.businessCta') || 'Choisir Plan Business'),
buttonClass: effectiveTier === 'BUSINESS'
? '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: () => handleChangePlan('BUSINESS'),
},
{
id: 'enterprise',
name: t('billing.enterpriseTitle') || 'Enterprise',
price: t('billing.contactSales') || 'Sur devis',
period: '',
description: t('billing.enterpriseDescription') || 'Crédits illimités ou pool dédié, SSO, support prioritaire.',
features: [
t('billing.enterpriseFeature1'),
t('billing.enterpriseFeature2'),
t('billing.enterpriseFeature3'),
t('billing.enterpriseFeature4'),
t('billing.enterpriseFeature5'),
],
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-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
onClick: () => { window.location.href = 'mailto:sales@memento-note.com'; },
},
];
const plansToShow = plans;
const formatDate = (dateStr: string | null | undefined) => {
if (!dateStr) return '—';
try {
const date = new Date(dateStr);
const locale = language === 'fa' ? 'fa-IR' : language === 'zh' ? 'zh-CN' : language;
return new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
...(language === 'fa' ? { calendar: 'persian' as const } : {}),
}).format(date);
} catch (e) {
return dateStr;
}
};
// Solde crédits global (modèle A) — pas la somme des anciens seaux
const unlimitedCredits = !!balance?.unlimited
const totalUsed = balance?.spentThisPeriod ?? 0
const remainingCredits = unlimitedCredits ? null : (balance?.totalRemaining ?? 0)
const granted = unlimitedCredits
? null
: (balance?.subscriptionGranted ?? 0) + (balance?.purchasedRemaining ?? 0)
// pot ≈ restants + déjà utilisés sur la fenêtre courante
const totalLimit = unlimitedCredits
? 0
: Math.max(granted ?? 0, totalUsed + (remainingCredits ?? 0))
const globalPct =
unlimitedCredits || totalLimit <= 0
? 0
: Math.min(100, (totalUsed / totalLimit) * 100)
// SVG Donut Config
const radius = 28;
const strokeWidth = 5;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset = circumference - (Math.min(globalPct, 100) / 100) * circumference;
const donutColor = globalPct >= 100 ? '#f43f5e' : globalPct >= 75 ? '#f59e0b' : '#a78bfa'; // Rose, Ambre, Violet
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-primary/30 bg-primary/10 p-4">
<CheckCircle2 className="h-5 w-5 text-primary/80 dark:text-primary shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground">{t('billing.checkoutSuccessTitle') || 'Abonnement activé !'}</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>
)}
{/* Current Plan Card & Usage Ring */}
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-8 grid grid-cols-1 md:grid-cols-3 gap-8 items-center">
<div className="md:col-span-2 space-y-6">
<div className="flex items-center gap-4">
<div className="p-2.5 bg-brand-accent/10 text-brand-accent rounded-2xl">
<Crown size={24} className="text-brand-accent" />
</div>
<div>
<p className="text-[10px] text-concrete uppercase tracking-widest">{t('billing.currentPlan')}</p>
<h4 className="text-xl font-bold font-serif text-ink mt-0.5">
{effectiveTier === 'BASIC' ? t('billing.freePlan') : effectiveTier === 'PRO' ? t('billing.proPlan') : effectiveTier === 'BUSINESS' ? t('billing.businessPlan') : t('billing.enterprisePlan')}
</h4>
</div>
{isPaid && (
<div className="ml-auto">
<span className={cn(
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest',
isTrialing
? 'bg-sky-500/10 text-sky-700 dark:text-sky-300 border border-sky-500/20'
: status?.status === 'active' || status?.status === 'ACTIVE'
? 'bg-primary/10 text-primary/80 dark:text-primary border border-primary/20'
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20'
)}>
{status?.status ? t(`billing.${status.status.toLowerCase()}`) || status.status : t('billing.active')}
</span>
</div>
)}
</div>
{isTrialing && status?.trialEndsAt && (
<p className="text-xs text-sky-700 dark:text-sky-300 bg-sky-500/10 border border-sky-500/20 rounded-xl px-3 py-2">
{t('billing.trialEndsOn', { date: formatDate(status.trialEndsAt) })}
</p>
)}
{isPaid && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4 border-t border-border/40">
<div className="space-y-1">
<span className="text-[10px] text-concrete uppercase tracking-wider">{t('billing.billingPeriod')}</span>
<p className="text-xs font-semibold text-ink">
{status?.currentPeriodStart && status?.currentPeriodEnd ? (
`${formatDate(status.currentPeriodStart)} ${formatDate(status.currentPeriodEnd)}`
) : (
'—'
)}
</p>
</div>
<div className="space-y-1">
<span className="text-[10px] text-concrete uppercase tracking-wider">
{isTrialing
? t('billing.trialEndsLabel')
: status?.cancelAtPeriodEnd
? t('billing.expiresOn')
: t('billing.nextBillingDate')}
</span>
<p className="text-xs font-semibold text-ink">
{isTrialing && status?.trialEndsAt
? formatDate(status.trialEndsAt)
: status?.currentPeriodEnd
? formatDate(status.currentPeriodEnd)
: '—'}
</p>
</div>
</div>
)}
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={scrollToPlans}
className="flex items-center gap-2 px-5 py-2.5 bg-brand-accent text-white rounded-xl text-xs font-semibold hover:opacity-90 transition-all shadow-md shadow-brand-accent/20"
>
{t('billing.changeOffer')}
</button>
{isPaid && !status?.cancelAtPeriodEnd && (
<button
type="button"
onClick={handleCancelSubscription}
disabled={cancelLoading}
className="flex items-center gap-2 px-5 py-2.5 border border-rose-200 text-rose-600 dark:border-rose-800/40 dark:text-rose-400 hover:bg-rose-50/50 dark:hover:bg-rose-950/15 rounded-xl text-xs font-semibold transition-all"
>
{cancelLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{t('billing.cancelSubscription')}
</button>
)}
{isPaid && status?.hasStripeSubscription && (
<button
type="button"
onClick={handlePortal}
disabled={portalLoading}
className="flex items-center gap-2 px-5 py-2.5 border border-border text-ink rounded-xl text-xs font-semibold hover:bg-paper/60 dark:hover:bg-white/5 disabled:opacity-60 transition-all"
>
{portalLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <ExternalLink className="h-4 w-4" />}
{t('billing.manageBilling')}
</button>
)}
</div>
{isPaid && status?.cancelAtPeriodEnd && (
<p className="text-xs text-amber-700 dark:text-amber-300 bg-amber-500/10 border border-amber-500/20 rounded-xl px-3 py-2">
{t('billing.cancellingNotice', { date: formatDate(status.currentPeriodEnd) })}
</p>
)}
</div>
{/* Global Usage Donut */}
<div className="flex flex-col items-center justify-center p-6 bg-paper/30 dark:bg-white/5 rounded-3xl border border-border/40 relative">
<div className="relative w-28 h-28 flex items-center justify-center">
<svg className="w-full h-full transform -rotate-90">
{/* Background circle */}
<circle
cx="56"
cy="56"
r={radius}
className="stroke-slate-100 dark:stroke-white/5 fill-transparent"
strokeWidth={strokeWidth}
/>
{/* Foreground circle */}
<circle
cx="56"
cy="56"
r={radius}
className="fill-transparent transition-all duration-500 ease-out"
stroke={donutColor}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
/>
</svg>
<div className="absolute flex flex-col items-center justify-center">
<span className="text-xl font-bold text-ink">
{unlimitedCredits ? '∞' : `${Math.round(globalPct)}%`}
</span>
<span className="text-[8px] text-concrete uppercase tracking-widest">{t('billing.used')}</span>
</div>
</div>
<div className="mt-4 text-center">
<p className="text-xs font-bold text-ink">
{unlimitedCredits ? (
<span className="text-concrete font-light">{t('billing.unlimited')}</span>
) : (
<>
{totalUsed}{' '}
<span className="text-concrete font-light">
/ {totalLimit} {t('billing.aiCredits') || 'crédits IA'}
</span>
</>
)}
</p>
{!unlimitedCredits && remainingCredits != null && (
<p className="text-[10px] text-concrete mt-1">
{remainingCredits} {t('billing.creditsRemaining')?.toLowerCase() || 'restants'}
</p>
)}
{!unlimitedCredits && (balance?.purchasedRemaining ?? 0) > 0 && (
<p className="text-[10px] text-brand-accent mt-1">
{balance?.purchasedRemaining} {t('billing.creditsFromPacks')?.toLowerCase() || 'de packs'}
</p>
)}
</div>
</div>
</div>
{/* Packs de crédits (one-shot) */}
{!unlimitedCredits && billingEnabled && (status?.creditPacks?.length ?? 0) > 0 && (
<div className="space-y-6">
<div className="flex items-start gap-3">
<div className="p-2.5 bg-brand-accent/10 text-brand-accent rounded-2xl shrink-0">
<Coins size={20} />
</div>
<div>
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete">
{t('billing.packsTitle')}
</h3>
<p className="text-[11px] text-concrete mt-1 leading-relaxed max-w-xl">
{t('billing.packsDescription')}
</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{(status?.creditPacks ?? []).map((pack) => (
<div
key={pack.id}
className="p-6 rounded-3xl border border-border bg-white/40 dark:bg-white/5 flex flex-col gap-4"
>
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
{t(`billing.pack${pack.id}Name`) || `Pack ${pack.id}`}
</p>
<p className="text-2xl font-serif font-bold text-ink mt-1 tabular-nums">
{pack.credits.toLocaleString()}
<span className="text-sm font-sans font-normal text-concrete ms-1">
{t('billing.aiCredits') || 'crédits'}
</span>
</p>
<p className="text-lg font-semibold text-ink mt-2">{pack.display}</p>
</div>
<button
type="button"
onClick={() => handleBuyPack(pack.id)}
disabled={!pack.configured || packLoading !== null}
className="mt-auto w-full py-3 rounded-2xl bg-brand-accent text-white text-[13px] font-semibold uppercase tracking-wider hover:opacity-90 disabled:opacity-40 transition-all"
>
{packLoading === pack.id ? (
<Loader2 className="h-4 w-4 animate-spin mx-auto" />
) : pack.configured ? (
t('billing.buyPack')
) : (
t('billing.packNotConfigured') || 'Config Stripe requise'
)}
</button>
</div>
))}
</div>
</div>
)}
{/* Répartition crédits par fonction */}
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete">
{t('billing.creditsWhereTitle') || t('billing.usageThisPeriod')}
</h3>
<p className="text-[10px] text-concrete mt-1">
{t('billing.creditsUsageLegend')}
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{!usageData && (
<div className="col-span-full flex items-center justify-center py-12 bg-white/40 dark:bg-white/5 border border-border rounded-3xl">
<Loader2 className="h-6 w-6 animate-spin text-brand-accent" />
</div>
)}
{usageData &&
(() => {
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.featureBrainstormSessions'),
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
suggest_charts: t('usageMeter.featureCharts'),
interactive_demo: t('usageMeter.featureInteractiveDemo'),
publish_enhance: t('usageMeter.featurePublishEnhance'),
ai_flashcard: t('usageMeter.featureFlashcards'),
voice_transcribe: t('usageMeter.featureVoice'),
slide_generate: t('usageMeter.featureSlides'),
excalidraw_generate: t('usageMeter.featureDiagrams'),
}
const byFeature = new Map(breakdown.map((r) => [r.feature, r]))
return Object.keys(featureLabels).map((key) => {
const row = byFeature.get(key)
const used = row?.creditsUsed ?? 0
const pct = totalUsed > 0 && used > 0 ? (used / totalUsed) * 100 : 0
const barFillColor =
pct >= 40
? 'bg-brand-accent'
: 'bg-brand-accent/60'
return (
<div
key={key}
className="p-5 rounded-2xl bg-white/40 dark:bg-white/5 border border-border space-y-3"
>
<div className="flex justify-between items-center gap-2">
<span className="text-[11px] font-bold text-ink uppercase tracking-wider truncate">
{featureLabels[key]}
</span>
<span className="text-[11px] font-medium text-concrete tabular-nums shrink-0">
{used > 0 ? `${used} cr.` : '0'}
</span>
</div>
<div className="h-2 w-full bg-secondary/40 rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all duration-500', barFillColor)}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
</div>
)
})
})()}
</div>
</div>
{/* Billing History (Only for paid users) */}
{isPaid && <BillingHistory />}
{/* Interval Toggle & Plan Cards */}
<div ref={plansSectionRef} className="space-y-8 pt-6 border-t border-border/40">
<div className="text-center space-y-2">
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete">
{t('billing.upgradePlan') || 'Changer de plan'}
</h3>
</div>
{billingEnabled ? (
<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-brand-accent text-white' : '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-brand-accent text-white' : 'text-concrete hover:text-ink'
)}
>
{t('billing.annual')}
<span className="ms-1 text-primary/80 dark:text-primary">{t('billing.savePercent')}</span>
</button>
</div>
) : (
<p className="text-center text-sm text-muted-foreground px-4">
{t('billing.disabledByAdmin')}
</p>
)}
<div className={cn(
"grid grid-cols-1 gap-6",
plansToShow.length === 3 ? "lg:grid-cols-3" : "lg:grid-cols-4"
)}>
{plansToShow.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
|| cancelLoading
|| (plan.id === 'free' && !!status?.cancelAtPeriodEnd)
}
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>
</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={stripe}
options={{ clientSecret: checkoutClientSecret, onComplete: handleCheckoutComplete }}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</div>
</div>
</div>
)}
</motion.div>
);
}