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

@@ -1,123 +1,128 @@
'use client';
'use client'
import { useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { cn } from '@/lib/utils';
import { Sparkles, ChevronDown, X, Crown } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { useLanguage } from '@/lib/i18n';
import { useState, useEffect } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { cn } from '@/lib/utils'
import { Sparkles, ChevronDown, X, Crown } from 'lucide-react'
import { motion, AnimatePresence } from 'motion/react'
import { useLanguage } from '@/lib/i18n'
interface QuotaData {
remaining: number;
limit: number;
used: number;
interface BalanceData {
totalRemaining: number | null
subscriptionRemaining: number | null
purchasedRemaining: number
subscriptionGranted: number | null
subscriptionSpent: number
spentThisPeriod: number
unlimited: boolean
}
interface BreakdownRow {
feature: string
creditsUsed: number
actionsCount: number
percentOfSpent: number
}
interface UsageMeterProps {
className?: string;
className?: string
}
/** Même liste que l'ancien compteur — toutes les actions IA, pas seulement les slides. */
const FEATURE_LABEL_KEYS: Record<string, string> = {
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',
ai_flashcard: 'usageMeter.featureFlashcards',
voice_transcribe: 'usageMeter.featureVoice',
slide_generate: 'usageMeter.featureSlides',
excalidraw_generate: 'usageMeter.featureDiagrams',
}
export function UsageMeter({ className }: UsageMeterProps) {
const { t } = useLanguage();
const router = useRouter();
const queryClient = useQueryClient();
const [expanded, setExpanded] = useState(false);
const [showModal, setShowModal] = useState(false);
const { t } = useLanguage()
const router = useRouter()
const queryClient = useQueryClient()
const [expanded, setExpanded] = useState(false)
const [showModal, setShowModal] = useState(false)
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 quotas');
const json = await res.json();
return { quotas: json.quotas as Record<string, QuotaData>, tier: json.tier as string };
const res = await fetch('/api/usage/current')
if (!res.ok) throw new Error('Failed to fetch usage')
const json = await res.json()
return {
tier: json.tier as string,
period: json.period as string,
balance: json.balance as BalanceData,
breakdown: (json.breakdown ?? []) as BreakdownRow[],
}
},
staleTime: 0,
refetchInterval: 5000,
});
})
useEffect(() => {
const handler = () => queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
window.addEventListener('ai-usage-changed', handler);
return () => window.removeEventListener('ai-usage-changed', handler);
}, [queryClient]);
const handler = () => queryClient.invalidateQueries({ queryKey: ['usage', 'current'] })
window.addEventListener('ai-usage-changed', handler)
return () => window.removeEventListener('ai-usage-changed', handler)
}, [queryClient])
if (isLoading || !data || !data.quotas) {
if (isLoading || !data?.balance) {
return (
<div className={cn('px-2 py-2', className)}>
<div className="h-8 rounded-lg bg-paper/50 animate-pulse" />
</div>
);
)
}
const getPackLabel = () => {
if (!data.tier) return t('usageMeter.packName');
if (!data.tier) return t('usageMeter.packName')
switch (data.tier) {
case 'PRO': return t('usageMeter.packPro');
case 'BUSINESS': return t('usageMeter.packBusiness');
case 'ENTERPRISE': return t('usageMeter.packEnterprise');
default: return t('usageMeter.packName');
case 'PRO':
return t('usageMeter.packPro')
case 'BUSINESS':
return t('usageMeter.packBusiness')
case 'ENTERPRISE':
return t('usageMeter.packEnterprise')
default:
return t('usageMeter.packName')
}
};
}
const isProPlus = data.tier && data.tier !== 'BASIC';
const isProPlus = data.tier && data.tier !== 'BASIC'
const unlimited = !!data.balance.unlimited
const remaining = unlimited ? Infinity : (data.balance.totalRemaining ?? 0)
const granted = unlimited ? Infinity : (data.balance.subscriptionGranted ?? 0)
const spent = data.balance.spentThisPeriod ?? 0
// Barre = part du forfait consommée (crédits utilisés / allocation du mois)
const totalPct =
!unlimited && Number.isFinite(granted) && granted > 0
? Math.min(100, (spent / granted) * 100)
: 0
const isExhausted = !unlimited && remaining <= 0
// Features visibles dans l'UI (les features techniques comme expand/enrich sont cachées)
const VISIBLE_FEATURES = new Set([
'semantic_search',
'auto_tag',
'auto_title',
'reformulate',
'chat',
'brainstorm_create',
'brainstorm_expand',
'brainstorm_enrich',
'suggest_charts',
'publish_enhance',
'ai_flashcard',
'voice_transcribe',
'slide_generate',
'excalidraw_generate',
]);
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 featureQuotas = Object.entries(data.quotas)
.filter(([key, q]) => q.limit > 0 && VISIBLE_FEATURES.has(key))
.map(([key, quota]) => ({
key: key === 'brainstorm_create' ? 'brainstorm_sessions' : key, // Renommer pour l'affichage
label: featureLabels[key] || key,
used: quota.used,
limit: quota.limit,
remaining: quota.remaining,
}));
const isUnlimited = featureQuotas.every((f) => !Number.isFinite(f.limit));
const totalUsed = featureQuotas.reduce(
(sum, f) => sum + (Number.isFinite(f.used) ? f.used : 0), 0,
);
const totalLimit = featureQuotas.reduce(
(sum, f) => sum + (Number.isFinite(f.limit) ? f.limit : 0), 0,
);
const totalRemaining = totalLimit - totalUsed;
const totalPct = totalLimit > 0 ? (totalUsed / totalLimit) * 100 : 0;
const isExhausted = !isUnlimited && totalRemaining <= 0;
// Map breakdown → toutes les features du tableau (ordre API = déjà trié, on garde l'ordre labels)
const byFeature = new Map(data.breakdown.map((r) => [r.feature, r]))
const featureRows = Object.keys(FEATURE_LABEL_KEYS).map((key) => {
const row = byFeature.get(key)
return {
key,
label: t(FEATURE_LABEL_KEYS[key]),
creditsUsed: row?.creditsUsed ?? 0,
actionsCount: row?.actionsCount ?? 0,
percent: row?.percentOfSpent ?? 0,
}
})
return (
<>
@@ -134,39 +139,54 @@ export function UsageMeter({ className }: UsageMeterProps) {
'shrink-0',
isExhausted
? 'text-rose-400 fill-rose-400/20'
: isUnlimited
: unlimited
? 'text-emerald-400 fill-emerald-400/20'
: 'text-brand-accent fill-brand-accent/20'
: 'text-brand-accent fill-brand-accent/20',
)}
/>
<span className="text-[11px] font-bold text-ink/70">{getPackLabel()}</span>
{!isUnlimited && (
{!unlimited && (
<>
<div className="flex-1 h-1 bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden mx-2">
<div
className={cn(
'h-full rounded-full',
totalPct >= 90 ? 'bg-rose-400' : totalPct >= 70 ? 'bg-amber-400' : 'bg-brand-accent',
totalPct >= 90
? 'bg-rose-400'
: totalPct >= 70
? 'bg-amber-400'
: 'bg-brand-accent',
)}
style={{ width: `${Math.min(totalPct, 100)}%` }}
/>
</div>
<span className={cn(
'text-[10px] font-medium tabular-nums shrink-0',
totalPct >= 90 ? 'text-rose-500' : totalPct >= 70 ? 'text-amber-500' : 'text-concrete'
)}>
{totalRemaining}
<span
className={cn(
'text-[10px] font-medium tabular-nums shrink-0',
totalPct >= 90
? 'text-rose-500'
: totalPct >= 70
? 'text-amber-500'
: 'text-concrete',
)}
title={t('usageMeter.creditsRemaining')}
>
{remaining}
</span>
</>
)}
{isUnlimited && (
<span className="text-[10px] font-medium text-emerald-500 ml-auto">{t('usageMeter.unlimited')}</span>
{unlimited && (
<span className="text-[10px] font-medium text-emerald-500 ml-auto">
{t('usageMeter.unlimited')}
</span>
)}
{isProPlus && !isUnlimited && (
<span className="text-[9px] font-bold text-brand-accent uppercase tracking-widest ml-auto">{data.tier}</span>
{isProPlus && !unlimited && (
<span className="text-[9px] font-bold text-brand-accent uppercase tracking-widest ml-auto">
{data.tier}
</span>
)}
<ChevronDown
@@ -189,37 +209,76 @@ export function UsageMeter({ className }: UsageMeterProps) {
>
<div className="px-3 pb-3 space-y-2">
<div className="border-t border-border/40 pt-2" />
{featureQuotas.map((f) => {
const fPct = Number.isFinite(f.limit) && f.limit > 0 ? (f.used / f.limit) * 100 : 0
<p className="text-[9px] font-bold uppercase tracking-widest text-concrete">
{t('usageMeter.creditsWhereTitle')}
</p>
{!unlimited && (
<p className="text-[10px] text-muted-foreground">
{t('usageMeter.creditsSummary', {
remaining: String(remaining),
spent: String(spent),
granted: String(granted),
})}
</p>
)}
{featureRows.map((f) => {
// Barre = part de la conso totale, ou 0 si rien
const barPct =
spent > 0 && f.creditsUsed > 0
? Math.min(100, (f.creditsUsed / spent) * 100)
: 0
return (
<div key={f.key} className="space-y-1">
<div className="flex justify-between text-[10px]">
<div className="flex justify-between text-[10px] gap-2">
<span className="text-concrete truncate">{f.label}</span>
<span className={cn(
'font-medium tabular-nums',
fPct >= 90 ? 'text-rose-500' : fPct >= 70 ? 'text-amber-500' : 'text-ink/60'
)}>
{!Number.isFinite(f.remaining) ? '∞' : f.remaining}/{!Number.isFinite(f.limit) ? '∞' : f.limit}
<span
className={cn(
'font-medium tabular-nums shrink-0',
f.creditsUsed > 0 ? 'text-ink/70' : 'text-concrete/50',
)}
>
{f.creditsUsed > 0
? t('usageMeter.creditsUsedLine', {
credits: String(f.creditsUsed),
actions: String(f.actionsCount),
})
: '0'}
</span>
</div>
{Number.isFinite(f.limit) && (
<div className="h-1 w-full bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-300',
fPct >= 90 ? 'bg-rose-400' : fPct >= 70 ? 'bg-amber-400' : 'bg-brand-accent',
)}
style={{ width: `${Math.min(fPct, 100)}%` }}
/>
</div>
)}
<div className="h-1 w-full bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-300',
barPct >= 40 ? 'bg-brand-accent' : 'bg-brand-accent/50',
)}
style={{ width: `${barPct}%` }}
/>
</div>
</div>
)
})}
{!isProPlus && (
{isExhausted && (
<button
onClick={(e) => { e.stopPropagation(); router.push('/settings/billing'); }}
type="button"
onClick={(e) => {
e.stopPropagation()
setShowModal(true)
}}
className="w-full mt-1 py-2 bg-rose-500/10 hover:bg-rose-500 text-rose-600 hover:text-white text-[9px] font-bold uppercase tracking-widest rounded-xl transition-all border border-rose-500/20"
>
{t('usageMeter.outOfCredits')}
</button>
)}
{!isProPlus && !isExhausted && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
router.push('/settings/billing')
}}
className="w-full mt-2 py-2 bg-brand-accent/10 hover:bg-brand-accent text-brand-accent hover:text-white text-[9px] font-bold uppercase tracking-widest rounded-xl transition-all duration-300 border border-brand-accent/20"
>
{t('usageMeter.upgradePricing')}
@@ -253,44 +312,31 @@ export function UsageMeter({ className }: UsageMeterProps) {
{t('usageMeter.upgradeDescription')}
</p>
<div className="space-y-2 mb-4">
<div className="text-[11px] font-medium text-ink">{t('usageMeter.proIncludes')}</div>
<ul className="text-[11px] text-muted-ink space-y-1">
<li> {t('usageMeter.proSearch')}</li>
<li> {t('usageMeter.proTags')}</li>
<li> {t('usageMeter.proTitles')}</li>
<li> {t('usageMeter.proReformulate')}</li>
<li> {t('usageMeter.proChat')}</li>
</ul>
</div>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 px-3 py-2 text-[12px] rounded-xl border border-border hover:bg-foreground/5 transition-colors"
>
{t('usageMeter.later')}
</button>
<a
href="/settings/billing"
className="flex-1 px-3 py-2 text-[12px] font-medium rounded-xl bg-brand-accent text-white text-center hover:opacity-90 transition-colors"
>
{t('usageMeter.upgradePricing')}
</a>
</div>
<a
href="/settings/billing"
className="w-full px-3 py-2 text-[12px] font-medium rounded-xl bg-brand-accent text-white text-center hover:opacity-90 transition-colors"
>
{t('usageMeter.upgradePricing')}
</a>
<a
href="/settings/ai#byok"
onClick={() => setShowModal(false)}
className="w-full px-3 py-2 text-[12px] font-medium rounded-xl border border-brand-accent/40 text-brand-accent dark:text-brand-accent text-center hover:bg-brand-accent/10 transition-colors"
className="w-full px-3 py-2 text-[12px] font-medium rounded-xl border border-brand-accent/40 text-brand-accent text-center hover:bg-brand-accent/10 transition-colors"
>
{t('usageMeter.addApiKey')}
</a>
<button
type="button"
onClick={() => setShowModal(false)}
className="w-full px-3 py-2 text-[12px] rounded-xl border border-border hover:bg-foreground/5 transition-colors"
>
{t('usageMeter.later')}
</button>
</div>
</div>
</div>
)}
</>
);
)
}