feat(credits): solde IA unique (option M), packs Stripe et polish billing

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.
This commit is contained in:
Antigravity
2026-07-16 20:43:18 +00:00
parent 704fed1191
commit 556a0b2f3f
77 changed files with 35745 additions and 10430 deletions

View File

@@ -3,7 +3,7 @@ import { useState, useEffect, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { loadStripe } from '@stripe/stripe-js';
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
import { Check, Shield, Zap, Crown, X, ExternalLink, Loader2, CheckCircle2, Activity, Clock, ArrowRight } from 'lucide-react';
import { 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';
@@ -33,6 +33,14 @@ interface BillingStatus {
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';
@@ -53,6 +61,7 @@ export function BillingPlans() {
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);
@@ -78,16 +87,35 @@ export function BillingPlans() {
});
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) {
const tier = status?.effectiveTier ?? 'Pro';
setSuccessBanner(t('billing.checkoutSuccessBody').replace('{tier}', tier));
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');
@@ -128,6 +156,30 @@ export function BillingPlans() {
}
};
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);
@@ -199,23 +251,19 @@ export function BillingPlans() {
const effectiveTier = status?.effectiveTier ?? 'BASIC';
const isPaid = effectiveTier !== 'BASIC';
const aiUsed = quotas?.semantic_search?.used ?? 0;
const aiLimit = quotas?.semantic_search?.limit ?? 50;
const aiPct = aiLimit > 0 ? (aiUsed / aiLimit) * 100 : 0;
const plans = [
{
id: 'free',
name: t('billing.freePlan'),
price: t('billing.freePrice') || 'Gratuit',
period: '',
description: t('billing.freeDescription') || 'Pour découvrir la magie de Memento.',
description: t('billing.freeDescription') || 'Pour découvrir Memento.',
features: [
t('billing.freeF1') || '100 Notes max',
t('billing.freeF2') || '3 Carnets',
t('billing.freeF3') || '50 crédits IA (Lifetime)',
t('billing.freeF4') || 'Recherche sémantique',
t('billing.freeF5') || 'Historique 7 jours',
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'),
@@ -232,12 +280,12 @@ export function BillingPlans() {
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
features: [
t('billing.proFeature1') || 'Notes illimitées',
t('billing.proFeature2') || 'BYOK (OpenAI/Anthropic)',
t('billing.proFeature3') || '200 recherches sémantiques',
t('billing.proFeature4') || 'Agents (12 runs/mois)',
t('billing.proFeature5') || 'Historique 30 jours',
t('billing.proFeature6') || 'Support Email',
t('billing.proFeature1'),
t('billing.proFeature2'),
t('billing.proFeature3'),
t('billing.proFeature4'),
t('billing.proFeature5'),
t('billing.proFeature6'),
],
current: effectiveTier === 'PRO',
popular: true,
@@ -254,12 +302,12 @@ export function BillingPlans() {
(interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€')),
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
features: [
t('billing.businessFeature1') || '10 Collaborateurs inclus',
t('billing.businessFeature2') || 'BYOK (13 fournisseurs)',
t('billing.businessFeature3') || '1000 recherches sémantiques',
t('billing.businessFeature4') || 'Agents (60 runs/mois)',
t('billing.businessFeature5') || 'Brainstorm illimité',
t('billing.businessFeature6') || 'Accès API',
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'),
@@ -273,13 +321,13 @@ export function BillingPlans() {
name: t('billing.enterpriseTitle') || 'Enterprise',
price: t('billing.contactSales') || 'Sur devis',
period: '',
description: t('billing.enterpriseDescription') || 'Quotas personnalisés, SSO, support prioritaire.',
description: t('billing.enterpriseDescription') || 'Crédits illimités ou pool dédié, SSO, support prioritaire.',
features: [
t('billing.enterpriseFeature1') || 'Quotas illimités',
t('billing.enterpriseFeature2') || 'SSO / SAML',
t('billing.enterpriseFeature3') || 'Support dédié',
t('billing.enterpriseFeature4') || 'Facturation personnalisée',
t('billing.enterpriseFeature5') || 'SLA garanti',
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'),
@@ -307,18 +355,21 @@ export function BillingPlans() {
}
};
// Sommes pour l'usage global agrégé (donut)
let totalUsed = 0;
let totalLimit = 0;
if (quotas) {
Object.entries(quotas as Record<string, any>).forEach(([_, q]) => {
if (q.limit > 0 && q.limit !== Infinity) {
totalUsed += q.used;
totalLimit += q.limit;
}
});
}
const globalPct = totalLimit > 0 ? (totalUsed / totalLimit) * 100 : 0;
// 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;
@@ -450,79 +501,160 @@ export function BillingPlans() {
/>
</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-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">
{totalUsed} <span className="text-concrete font-light">/ {totalLimit} {t('billing.aiCredits') || 'crédits IA'}</span>
{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>
{/* Usage breakdown per feature */}
{/* 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.usageThisPeriod') || 'Utilisation sur cette période'}
{t('billing.creditsWhereTitle') || t('billing.usageThisPeriod')}
</h3>
<p className="text-[10px] text-concrete mt-1">
{status?.currentPeriodStart && status?.currentPeriodEnd ? (
`${formatDate(status.currentPeriodStart)} ${formatDate(status.currentPeriodEnd)}`
) : (
'—'
)}
{t('billing.creditsUsageLegend')}
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{!quotas && (
{!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>
)}
{quotas && Object.entries(quotas as Record<string, any>).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="h-2 w-full bg-secondary/40 rounded-full overflow-hidden">
{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
className={cn('h-full rounded-full transition-all duration-500', barFillColor)}
style={{ width: `${isUnlimited ? 100 : Math.min(pct, 100)}%` }}
/>
</div>
</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>

View File

@@ -1,55 +1,84 @@
'use client';
'use client'
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { motion, AnimatePresence } from 'motion/react';
import { AlertTriangle, Sparkles, Key, X, ArrowRight } from 'lucide-react';
import { useLanguage } from '@/lib/i18n';
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'motion/react'
import { AlertTriangle, Sparkles, Key, X, ArrowRight } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
interface InlinePaywallProps {
feature: string;
onDismiss: () => void;
/** Action en cours (pour contexte uniquement — le solde est global) */
feature: string
onDismiss: () => void
/** Coût de l'action si connu */
creditCost?: number
/** Solde restant si connu */
creditsRemaining?: number
}
export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
const { t } = useLanguage();
const router = useRouter();
const [timeLeft, setTimeLeft] = useState(10);
export function InlinePaywall({
feature,
onDismiss,
creditCost,
creditsRemaining,
}: InlinePaywallProps) {
const { t } = useLanguage()
const router = useRouter()
const [timeLeft, setTimeLeft] = useState(12)
useEffect(() => {
if (timeLeft <= 0) {
onDismiss();
return;
onDismiss()
return
}
const timer = setTimeout(() => {
setTimeLeft((prev) => prev - 1);
}, 1000);
return () => clearTimeout(timer);
}, [timeLeft, onDismiss]);
setTimeLeft((prev) => prev - 1)
}, 1000)
return () => clearTimeout(timer)
}, [timeLeft, onDismiss])
const handleUpgrade = () => {
router.push('/settings/billing');
onDismiss();
};
router.push('/settings/billing')
onDismiss()
}
const handleByok = () => {
router.push('/settings/ai#byok');
onDismiss();
};
router.push('/settings/ai#byok')
onDismiss()
}
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',
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',
publish_enhance: t('usageMeter.featurePublishEnhance') || 'Publication IA',
};
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'),
publish_enhance: t('usageMeter.featurePublishEnhance'),
slide_generate: t('usageMeter.featureSlides'),
excalidraw_generate: t('usageMeter.featureDiagrams'),
ai_flashcard: t('usageMeter.featureFlashcards'),
voice_transcribe: t('usageMeter.featureVoice'),
credits: t('billing.aiCredits'),
}
const currentFeatureLabel = featureLabels[feature] || feature;
const currentFeatureLabel = featureLabels[feature] || feature
let description = t('quotaPaywall.description').replace('{feature}', currentFeatureLabel)
if (
typeof creditCost === 'number' &&
typeof creditsRemaining === 'number' &&
Number.isFinite(creditCost) &&
Number.isFinite(creditsRemaining)
) {
description = t('quotaPaywall.descriptionWithCost', {
remaining: String(creditsRemaining),
cost: String(creditCost),
feature: currentFeatureLabel,
})
}
return (
<AnimatePresence>
@@ -66,17 +95,17 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
</div>
<div className="flex-1 space-y-1">
<h4 className="text-sm font-bold text-rose-800 dark:text-rose-400">
{t('quotaPaywall.title') || 'Limite mensuelle atteinte'}
{t('quotaPaywall.title')}
</h4>
<p className="text-xs text-rose-700/80 dark:text-rose-400/70 font-light leading-relaxed">
{(t('quotaPaywall.description') || "Vous avez épuisé vos crédits pour la fonctionnalité {feature} ce mois-ci.").replace('{feature}', currentFeatureLabel)}
{description}
</p>
</div>
<button
type="button"
onClick={onDismiss}
className="text-rose-500/60 hover:text-rose-500 dark:text-rose-400/50 dark:hover:text-rose-400 p-1 hover:bg-rose-500/5 dark:hover:bg-rose-400/5 rounded-lg transition-all"
title={t('quotaPaywall.later') || 'Peut-être plus tard'}
title={t('quotaPaywall.later')}
>
<X size={16} />
</button>
@@ -89,17 +118,17 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-[#D4A373] text-white hover:bg-[#C49363] text-xs font-semibold rounded-xl transition-all shadow-sm shadow-[#D4A373]/15"
>
<Sparkles size={14} />
{t('quotaPaywall.upgrade') || 'Passer au plan Pro'}
{t('quotaPaywall.upgrade')}
<ArrowRight size={12} className="ml-1" />
</button>
<button
type="button"
onClick={handleByok}
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 border border-rose-300 dark:border-rose-800/40 text-rose-800 dark:text-rose-400 hover:bg-rose-100/30 dark:hover:bg-rose-400/5 text-xs font-semibold rounded-xl transition-all"
>
<Key size={14} />
{t('quotaPaywall.useOwnKey') || 'Utiliser ma propre clé'}
{t('quotaPaywall.useOwnKey')}
</button>
<div className="sm:ml-auto flex items-center justify-center gap-1.5 text-[10px] text-rose-500/60 dark:text-rose-400/40 font-mono">
@@ -108,5 +137,5 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
</div>
</motion.div>
</AnimatePresence>
);
)
}

View File

@@ -1,59 +1,88 @@
'use client';
'use client'
import { useQuery } from '@tanstack/react-query';
import { Loader2, BarChart2 } from 'lucide-react';
import { useLanguage } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { useQuery } from '@tanstack/react-query'
import { Loader2, BarChart2 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
interface QuotaEntry {
remaining: number;
limit: number;
used: number;
interface BalanceData {
totalRemaining: number | null
subscriptionRemaining: number | null
purchasedRemaining: number
subscriptionGranted: number | null
spentThisPeriod: number
unlimited: boolean
}
type Quotas = Record<string, QuotaEntry>;
interface BreakdownRow {
feature: string
creditsUsed: number
actionsCount: number
percentOfSpent: number
}
/** Toutes les fonctions IA — même liste que le compteur sidebar. */
const FEATURE_LABEL_KEYS: Record<string, string> = {
aiSummary: 'sidebar.aiSummary',
aiFlashcards: 'sidebar.aiFlashcards',
aiMindmap: 'sidebar.aiMindmap',
aiTranscribe: 'sidebar.aiTranscribe',
aiDiagram: 'sidebar.aiDiagram',
aiAgent: 'sidebar.aiAgent',
semantic_search: 'usageMeter.featureSearch',
auto_tag: 'usageMeter.featureTags',
auto_title: 'usageMeter.featureTitles',
reformulate: 'usageMeter.featureReformulate',
chat: 'usageMeter.featureChat',
brainstorm_create: 'usageMeter.featureBrainstormSessions',
brainstorm_expand: 'usageMeter.featureBrainstormExpand',
brainstorm_enrich: 'usageMeter.featureBrainstormEnrich',
suggest_charts: 'usageMeter.featureCharts',
publish_enhance: 'usageMeter.featurePublishEnhance',
};
function UsageBar({ used, limit, isUnlimited }: { used: number; limit: number; isUnlimited: boolean }) {
const pct = isUnlimited ? 0 : Math.min(100, limit > 0 ? (used / limit) * 100 : 0);
const color =
pct >= 90 ? 'bg-rose-500' : pct >= 70 ? 'bg-amber-500' : 'bg-primary';
ai_flashcard: 'usageMeter.featureFlashcards',
voice_transcribe: 'usageMeter.featureVoice',
slide_generate: 'usageMeter.featureSlides',
excalidraw_generate: 'usageMeter.featureDiagrams',
}
function UsageBar({ percent }: { percent: number }) {
return (
<div className="h-1.5 w-full rounded-full bg-foreground/10 overflow-hidden">
{!isUnlimited && (
<div
className={cn('h-full rounded-full transition-all', color)}
style={{ width: `${pct}%` }}
/>
)}
<div
className={cn(
'h-full rounded-full transition-all',
percent >= 40 ? 'bg-primary' : 'bg-primary/60',
)}
style={{ width: `${Math.min(Math.max(percent, 0), 100)}%` }}
/>
</div>
);
)
}
export function UsageBreakdown() {
const { t } = useLanguage();
const { t } = useLanguage()
const { data, isLoading } = useQuery<{ quotas: Quotas }>({
const { data, isLoading } = useQuery({
queryKey: ['usage', 'current'],
queryFn: async () => {
const res = await fetch('/api/usage/current');
if (!res.ok) throw new Error('Failed to fetch usage');
return res.json();
const res = await fetch('/api/usage/current')
if (!res.ok) throw new Error('Failed to fetch usage')
return res.json() as Promise<{
balance: BalanceData
breakdown: BreakdownRow[]
period: string
tier: string
}>
},
});
})
const quotas = data?.quotas ?? {};
const entries = Object.entries(quotas);
const balance = data?.balance
const byFeature = new Map((data?.breakdown ?? []).map((r) => [r.feature, r]))
const spent = balance?.spentThisPeriod ?? 0
const rows = Object.keys(FEATURE_LABEL_KEYS).map((key) => {
const row = byFeature.get(key)
return {
feature: key,
label: t(FEATURE_LABEL_KEYS[key]),
creditsUsed: row?.creditsUsed ?? 0,
actionsCount: row?.actionsCount ?? 0,
}
})
return (
<div className="rounded-xl border border-border/40 bg-paper p-6 space-y-4">
@@ -68,33 +97,72 @@ export function UsageBreakdown() {
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : entries.length === 0 ? (
) : !balance ? (
<p className="text-xs text-muted-foreground py-2">{t('billing.noUsage')}</p>
) : (
<div className="space-y-3">
{entries.map(([feature, quota]) => {
const isUnlimited = quota.limit === -1 || !isFinite(quota.limit);
const labelKey = FEATURE_LABEL_KEYS[feature];
const label = labelKey ? t(labelKey) : feature;
<>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="rounded-lg border border-border/40 p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
{t('billing.creditsRemaining')}
</p>
<p className="text-lg font-semibold tabular-nums mt-1">
{balance.unlimited ? t('billing.unlimited') : balance.totalRemaining}
</p>
</div>
<div className="rounded-lg border border-border/40 p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
{t('billing.creditsSpent')}
</p>
<p className="text-lg font-semibold tabular-nums mt-1">
{balance.spentThisPeriod}
</p>
</div>
<div className="rounded-lg border border-border/40 p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
{t('billing.creditsFromPacks')}
</p>
<p className="text-lg font-semibold tabular-nums mt-1">
{balance.purchasedRemaining}
</p>
</div>
</div>
return (
<div key={feature} className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-xs font-medium text-foreground tabular-nums">
{isUnlimited ? (
<span className="text-primary/80 dark:text-primary">{t('billing.unlimited')}</span>
) : (
`${quota.used} / ${quota.limit}`
)}
</span>
<p className="text-xs font-medium text-foreground pt-1">
{t('billing.creditsWhereTitle')}
</p>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t('billing.creditsUsageLegend')}
</p>
<div className="space-y-3">
{rows.map((row) => {
const barPct =
spent > 0 && row.creditsUsed > 0
? Math.min(100, (row.creditsUsed / spent) * 100)
: 0
return (
<div key={row.feature} className="space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">{row.label}</span>
<span
className={cn(
'text-xs font-medium tabular-nums shrink-0',
row.creditsUsed > 0 ? 'text-foreground' : 'text-muted-foreground/60',
)}
>
{row.creditsUsed > 0
? `${row.creditsUsed} cr. · ${row.actionsCount}×`
: '0'}
</span>
</div>
<UsageBar percent={barPct} />
</div>
<UsageBar used={quota.used} limit={quota.limit} isUnlimited={isUnlimited} />
</div>
);
})}
</div>
)
})}
</div>
</>
)}
</div>
);
)
}