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.
343 lines
13 KiB
TypeScript
343 lines
13 KiB
TypeScript
'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'
|
|
|
|
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
|
|
}
|
|
|
|
/** 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 { 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')
|
|
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])
|
|
|
|
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')
|
|
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')
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
// 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 (
|
|
<>
|
|
<div className="px-2 py-2 w-full">
|
|
<div className="bg-slate-50 dark:bg-white/5 border border-border rounded-2xl overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded(!expanded)}
|
|
className="w-full p-3 flex items-center gap-2 hover:bg-slate-100 dark:hover:bg-white/5 transition-colors"
|
|
>
|
|
<Sparkles
|
|
size={12}
|
|
className={cn(
|
|
'shrink-0',
|
|
isExhausted
|
|
? 'text-rose-400 fill-rose-400/20'
|
|
: unlimited
|
|
? 'text-emerald-400 fill-emerald-400/20'
|
|
: 'text-brand-accent fill-brand-accent/20',
|
|
)}
|
|
/>
|
|
<span className="text-[11px] font-bold text-ink/70">{getPackLabel()}</span>
|
|
|
|
{!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',
|
|
)}
|
|
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',
|
|
)}
|
|
title={t('usageMeter.creditsRemaining')}
|
|
>
|
|
{remaining}
|
|
</span>
|
|
</>
|
|
)}
|
|
|
|
{unlimited && (
|
|
<span className="text-[10px] font-medium text-emerald-500 ml-auto">
|
|
{t('usageMeter.unlimited')}
|
|
</span>
|
|
)}
|
|
|
|
{isProPlus && !unlimited && (
|
|
<span className="text-[9px] font-bold text-brand-accent uppercase tracking-widest ml-auto">
|
|
{data.tier}
|
|
</span>
|
|
)}
|
|
|
|
<ChevronDown
|
|
size={12}
|
|
className={cn(
|
|
'text-concrete shrink-0 transition-transform duration-200',
|
|
expanded && 'rotate-180',
|
|
)}
|
|
/>
|
|
</button>
|
|
|
|
<AnimatePresence initial={false}>
|
|
{expanded && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="px-3 pb-3 space-y-2">
|
|
<div className="border-t border-border/40 pt-2" />
|
|
<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] gap-2">
|
|
<span className="text-concrete truncate">{f.label}</span>
|
|
<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>
|
|
<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>
|
|
)
|
|
})}
|
|
|
|
{isExhausted && (
|
|
<button
|
|
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')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
|
|
{showModal && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
|
<div className="bg-paper border border-border rounded-2xl p-6 mx-4 max-w-sm w-full shadow-xl">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Crown size={18} className="text-amber-500" />
|
|
<h3 className="text-sm font-semibold">{t('usageMeter.upgradeTitle')}</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowModal(false)}
|
|
className="text-muted-ink hover:text-ink transition-colors"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-[12px] text-muted-ink mb-4">
|
|
{t('usageMeter.upgradeDescription')}
|
|
</p>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<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 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>
|
|
)}
|
|
</>
|
|
)
|
|
}
|