feat(billing): implement US-3.7 billing and subscription UX with detailed dashboard, real-time invoice history, inline paywall and upgrade confirmation

This commit is contained in:
Antigravity
2026-05-27 22:01:21 +00:00
parent da4b5d18be
commit 529fb7a935
8 changed files with 606 additions and 182 deletions

View File

@@ -1,5 +1,4 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { loadStripe } from '@stripe/stripe-js';
@@ -10,6 +9,7 @@ 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';
@@ -18,6 +18,7 @@ interface BillingStatus {
tier: string;
effectiveTier: string;
status: string;
currentPeriodStart: string | null;
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
hasStripeSubscription: boolean;
@@ -61,7 +62,7 @@ export function BillingPlans() {
const data = await res.json();
return data.quotas as Record<string, { remaining: number; limit: number; used: number }>;
},
refetchInterval: 60000,
refetchInterval: 30000,
});
useEffect(() => {
@@ -76,6 +77,15 @@ export function BillingPlans() {
}
}, [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);
@@ -226,6 +236,41 @@ export function BillingPlans() {
},
];
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;
}
};
// Sommes pour l'usage global agrégé (donut)
let totalUsed = 0;
let totalLimit = 0;
if (quotas) {
Object.entries(quotas).forEach(([_, q]) => {
if (q.limit > 0 && q.limit !== Infinity) {
totalUsed += q.used;
totalLimit += q.limit;
}
});
}
const globalPct = totalLimit > 0 ? (totalUsed / totalLimit) * 100 : 0;
// 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 }}
@@ -237,7 +282,7 @@ export function BillingPlans() {
<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-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">
@@ -246,191 +291,255 @@ export function BillingPlans() {
</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">
{/* 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 bg-brand-accent/10 text-brand-accent rounded-xl">
<Activity size={20} />
<div className="p-2.5 bg-brand-accent/10 text-brand-accent rounded-2xl">
<Crown size={24} className="text-brand-accent" />
</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>
<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>
<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'
status?.status === 'active' || status?.status === 'ACTIVE'
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20'
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20'
)}>
{effectiveTier === 'BASIC' ? 'Discovery' : effectiveTier}
{status?.status ? t(`billing.${status.status.toLowerCase()}`) || status.status : t('billing.active')}
</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 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 && (
<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>
)}
</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>
{/* 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">{Math.round(globalPct)}%</span>
<span className="text-[8px] text-concrete uppercase tracking-widest">{t('billing.used')}</span>
</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.'}
<div className="mt-4 text-center">
<p className="text-xs font-bold text-ink">
{totalUsed} <span className="text-concrete font-light">/ {totalLimit} {t('billing.aiCredits') || 'crédits IA'}</span>
</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>
{/* Usage breakdown per feature */}
<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.usageThisPeriod') || 'Utilisation sur cette période'}
</h3>
<p className="text-[10px] text-concrete mt-1">
{status?.currentPeriodStart && status?.currentPeriodEnd ? (
`${formatDate(status.currentPeriodStart)} ${formatDate(status.currentPeriodEnd)}`
) : (
'—'
)}
</p>
</div>
</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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
{!quotas && (
<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>
)}
{quotas && Object.entries(quotas).map(([key, q]) => {
const pct = q.limit > 0 && q.limit !== Infinity ? (q.used / q.limit) * 100 : 0;
const isUnlimited = q.limit === Infinity || q.limit <= 0;
const featureLabels: Record<string, string> = {
semantic_search: t('usageMeter.featureSearch') || 'Recherche Sémantique',
auto_tag: t('usageMeter.featureTags') || 'Tags Automatiques',
auto_title: t('usageMeter.featureTitles') || 'Titres Automatiques',
reformulate: t('usageMeter.featureReformulate') || 'Reformulation IA',
chat: t('usageMeter.featureChat') || 'Chat IA',
brainstorm_create: t('usageMeter.featureBrainstormCreate') || 'Création de Brainstorm',
brainstorm_expand: t('usageMeter.featureBrainstormExpand') || 'Expansion de Brainstorm',
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich') || 'Enrichissement de Brainstorm',
};
// Couleur de barre : normal (violet), warning >= 75% (ambre), exhausted >= 100% (rose)
const barFillColor = pct >= 100 ? 'bg-rose-400' : pct >= 75 ? 'bg-amber-400' : 'bg-gradient-to-r from-violet-400 to-purple-400';
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">
<span className="text-[11px] font-bold text-ink uppercase tracking-wider truncate">
{featureLabels[key] || key}
</span>
<span className="text-[11px] font-medium text-concrete tabular-nums">
{isUnlimited ? t('billing.unlimited') || 'Illimité' : `${q.used} / ${q.limit}`}
</span>
</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 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: `${isUnlimited ? 100 : Math.min(pct, 100)}%` }}
/>
</div>
<p className="text-xs text-concrete font-light leading-relaxed pe-4">{plan.description}</p>
</div>
);
})}
</div>
</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>
{/* Billing History (Only for paid users) */}
{isPaid && <BillingHistory />}
{/* Interval Toggle & Plan Cards (Only for BASIC/Free users) */}
{!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
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)}
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'
)}
>
<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>
{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>
))}
)}
<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>
</div>
)}