Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux legacy, usage multi-features, hints de coût, anti-flash thème et hydratation admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
814 lines
34 KiB
TypeScript
814 lines
34 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, 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" />
|
||
) : (
|
||
t('billing.buyPack')
|
||
)}
|
||
</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>
|
||
);
|
||
}
|