Files
Momento/memento-note/components/settings/billing-plans.tsx
Antigravity a77f3ffb6e
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m15s
CI / Deploy production (on server) (push) Has been skipped
fix: batch priorités — thème, scroll, wizard, agents, slides, charts, packs
Anti-flash dark (color-scheme + applyDocumentTheme), scroll pages publiques,
prompts wizard/slides moins génériques, cron agents rattrapage nextRun null,
slides carnet via notebookId, graphiques à 2 crédits + hint, audit dashboard,
packs Stripe marqués non configurés sans price ID.
2026-07-16 20:48:55 +00:00

816 lines
34 KiB
TypeScript
Raw 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 } 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';
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;
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 } = 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 { 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: 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 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 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.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: 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: [
t('billing.proFeature1'),
t('billing.proFeature2'),
t('billing.proFeature3'),
t('billing.proFeature4'),
t('billing.proFeature5'),
t('billing.proFeature6'),
],
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: 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: [
t('billing.businessFeature1'),
t('billing.businessFeature2'),
t('billing.businessFeature3'),
t('billing.businessFeature4'),
t('billing.businessFeature5'),
t('billing.businessFeature6'),
],
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') || '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-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
onClick: () => { window.location.href = 'mailto:sales@memento-note.com'; },
},
];
const plansToShow = isPaid ? plans.filter((p) => p.id !== 'free') : plans;
const formatDate = (dateStr: string | null | undefined) => {
if (!dateStr) return '—';
try {
const date = new Date(dateStr);
const locale = typeof window !== 'undefined' ? window.navigator.language : 'fr-FR';
return new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'long',
year: 'numeric',
}).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',
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>
{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">
{status?.cancelAtPeriodEnd ? t('billing.expiresOn') : t('billing.nextBillingDate')}
</span>
<p className="text-xs font-semibold text-ink">
{status?.currentPeriodEnd ? formatDate(status.currentPeriodEnd) : '—'}
</p>
</div>
</div>
)}
{isPaid && (
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handlePortal}
disabled={portalLoading}
className="flex items-center gap-2 px-5 py-2.5 bg-ink text-white dark:bg-white dark:text-black rounded-xl text-xs font-semibold hover:opacity-90 disabled:opacity-60 transition-all shadow-md shadow-black/5"
>
{portalLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <ExternalLink className="h-4 w-4" />}
{t('billing.manageBilling') || 'Gérer la facturation'}
</button>
{status?.hasStripeSubscription && !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') || "Résilier l'abonnement"}
</button>
)}
</div>
)}
</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-ink text-white dark:bg-white dark:text-black text-[10px] font-bold uppercase tracking-[0.15em] 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'),
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-gradient-to-r from-violet-400 to-purple-400'
: 'bg-gradient-to-r from-violet-300/80 to-purple-300/80'
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 */}
{!isPaid && (
<div 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-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-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}
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>
);
}