Files
Momento/memento-note/components/settings/usage-breakdown.tsx
Antigravity 556a0b2f3f 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.
2026-07-16 20:43:18 +00:00

169 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useQuery } from '@tanstack/react-query'
import { Loader2, BarChart2 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
interface BalanceData {
totalRemaining: number | null
subscriptionRemaining: number | null
purchasedRemaining: number
subscriptionGranted: number | null
spentThisPeriod: number
unlimited: boolean
}
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> = {
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',
}
function UsageBar({ percent }: { percent: number }) {
return (
<div className="h-1.5 w-full rounded-full bg-foreground/10 overflow-hidden">
<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 { 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() as Promise<{
balance: BalanceData
breakdown: BreakdownRow[]
period: string
tier: string
}>
},
})
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">
<div className="flex items-center gap-2">
<BarChart2 className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold text-foreground">
{t('billing.usageThisPeriod')}
</h3>
</div>
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : !balance ? (
<p className="text-xs text-muted-foreground py-2">{t('billing.noUsage')}</p>
) : (
<>
<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>
<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>
)
})}
</div>
</>
)}
</div>
)
}