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,24 +1,16 @@
'use client'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { updateBillingConfig, updatePlanEntitlement } from '@/app/actions/admin-billing'
import { updateBillingConfig } from '@/app/actions/admin-billing'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { CreditCard, Gauge, Settings2 } from 'lucide-react'
type EntitlementRow = {
tier: string
feature: string
limitValue: number | null
mode: 'limited' | 'unlimited' | 'unavailable'
}
import { CreditCard, Gauge, Coins, Package } from 'lucide-react'
type BillingAdminData = {
entitlements: EntitlementRow[]
billingConfig: Record<string, string>
usageOverview: {
period: string
@@ -26,34 +18,55 @@ type BillingAdminData = {
byFeature: Array<{ feature: string; requests: number; tokens: number; users: number }>
topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }>
}
features: string[]
tiers: string[]
/** Allocations mensuelles du solde unique (source de vérité débit) */
creditAllocations?: Array<{
tier: string
monthlyCredits: number | null
unlimited: boolean
}>
creditCosts?: Record<string, number>
creditPacks?: Array<{
id: string
credits: number
defaultDisplay: string
}>
}
function getEntitlement(
entitlements: EntitlementRow[],
tier: string,
feature: string,
): EntitlementRow | undefined {
return entitlements.find((e) => e.tier === tier && e.feature === feature)
const SUBSCRIPTION_PRICE_KEYS = [
'STRIPE_PRICE_PRO_MONTHLY',
'STRIPE_PRICE_PRO_ANNUAL',
'STRIPE_PRICE_BUSINESS_MONTHLY',
'STRIPE_PRICE_BUSINESS_ANNUAL',
] as const
const PACK_PRICE_KEYS = [
'STRIPE_PRICE_CREDITS_S',
'STRIPE_PRICE_CREDITS_M',
'STRIPE_PRICE_CREDITS_L',
] as const
/** Date stable SSR/client (pas de toLocaleString — mismatch locale + fuseau). */
function formatStableUtc(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`
}
export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) {
const { t } = useLanguage()
const [activeTier, setActiveTier] = useState(initialData.tiers[0] ?? 'BASIC')
const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true')
const [isSavingBilling, setIsSavingBilling] = useState(false)
const [savingCell, setSavingCell] = useState<string | null>(null)
const handleSaveBilling = async (formData: FormData) => {
setIsSavingBilling(true)
try {
const data: Record<string, string> = {
BILLING_ENABLED: billingEnabled ? 'true' : 'false',
STRIPE_PRICE_PRO_MONTHLY: String(formData.get('STRIPE_PRICE_PRO_MONTHLY') ?? ''),
STRIPE_PRICE_PRO_ANNUAL: String(formData.get('STRIPE_PRICE_PRO_ANNUAL') ?? ''),
STRIPE_PRICE_BUSINESS_MONTHLY: String(formData.get('STRIPE_PRICE_BUSINESS_MONTHLY') ?? ''),
STRIPE_PRICE_BUSINESS_ANNUAL: String(formData.get('STRIPE_PRICE_BUSINESS_ANNUAL') ?? ''),
}
for (const key of [...SUBSCRIPTION_PRICE_KEYS, ...PACK_PRICE_KEYS]) {
data[key] = String(formData.get(key) ?? '')
}
await updateBillingConfig(data)
toast.success(t('admin.billing.configSaved'))
@@ -64,23 +77,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
}
}
const handleEntitlementChange = async (
feature: string,
mode: 'unavailable' | 'unlimited' | 'limited',
limitValue?: number,
) => {
const key = `${activeTier}:${feature}`
setSavingCell(key)
try {
await updatePlanEntitlement(activeTier, feature, mode, limitValue)
toast.success(t('admin.billing.limitSaved'))
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : t('admin.billing.limitFailed'))
} finally {
setSavingCell(null)
}
}
return (
<div className="space-y-8">
<div>
@@ -98,7 +94,7 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
<p className="text-sm text-muted-foreground">{t('admin.billing.stripeConfigDescription')}</p>
</div>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSaveBilling(new FormData(e.currentTarget)) }} className="p-6 space-y-4">
<form onSubmit={(e) => { e.preventDefault(); handleSaveBilling(new FormData(e.currentTarget)) }} className="p-6 space-y-6">
<div className="flex items-center space-x-2">
<Checkbox
id="BILLING_ENABLED"
@@ -107,81 +103,105 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
/>
<Label htmlFor="BILLING_ENABLED">{t('admin.billing.enableBilling')}</Label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{(['STRIPE_PRICE_PRO_MONTHLY', 'STRIPE_PRICE_PRO_ANNUAL', 'STRIPE_PRICE_BUSINESS_MONTHLY', 'STRIPE_PRICE_BUSINESS_ANNUAL'] as const).map((key) => (
<div key={key} className="space-y-2">
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
<Input
id={key}
name={key}
defaultValue={initialData.billingConfig[key] ?? ''}
placeholder="price_..."
/>
</div>
))}
<div>
<h3 className="text-sm font-medium mb-3">{t('admin.billing.subscriptionPricesTitle')}</h3>
<div className="grid gap-4 sm:grid-cols-2">
{SUBSCRIPTION_PRICE_KEYS.map((key) => (
<div key={key} className="space-y-2">
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
<Input
id={key}
name={key}
defaultValue={initialData.billingConfig[key] ?? ''}
placeholder="price_..."
/>
</div>
))}
</div>
</div>
<div className="border-t border-border/50 pt-4">
<div className="flex items-center gap-2 mb-1">
<Package className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">{t('admin.billing.packPricesTitle')}</h3>
</div>
<p className="text-xs text-muted-foreground mb-3">{t('admin.billing.packPricesDescription')}</p>
<div className="grid gap-4 sm:grid-cols-3">
{PACK_PRICE_KEYS.map((key) => (
<div key={key} className="space-y-2">
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
<Input
id={key}
name={key}
defaultValue={initialData.billingConfig[key] ?? ''}
placeholder="price_..."
/>
</div>
))}
</div>
</div>
<p className="text-xs text-muted-foreground">{t('admin.billing.secretsNote')}</p>
<Button type="submit" disabled={isSavingBilling}>{t('admin.billing.saveConfig')}</Button>
</form>
</div>
{/* Solde unique — source de vérité du débit */}
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<Settings2 className="h-5 w-5" />
<Coins className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold">{t('admin.billing.limitsTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.limitsDescription')}</p>
<h2 className="font-semibold">{t('admin.billing.creditsTitle')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.billing.creditsDescription')}</p>
</div>
</div>
<div className="p-6 space-y-4">
<div className="flex flex-wrap gap-2">
{initialData.tiers.map((tier) => (
<button
key={tier}
type="button"
onClick={() => setActiveTier(tier)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
activeTier === tier
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:bg-muted'
}`}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{(initialData.creditAllocations ?? []).map((row) => (
<div
key={row.tier}
className="rounded-xl border border-border/60 bg-muted/30 p-4 space-y-1"
>
{tier}
</button>
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{row.tier}
</p>
<p className="text-2xl font-semibold tabular-nums">
{row.unlimited
? t('admin.billing.modeUnlimited')
: row.monthlyCredits?.toLocaleString('fr-FR')}
</p>
{!row.unlimited && (
<p className="text-[11px] text-muted-foreground">
{t('admin.billing.creditsPerMonth')}
</p>
)}
</div>
))}
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4">{t('admin.billing.feature')}</th>
<th className="py-2 pr-4">{t('admin.billing.mode')}</th>
<th className="py-2 pr-4">{t('admin.billing.monthlyLimit')}</th>
<th className="py-2">{t('admin.billing.actions')}</th>
</tr>
</thead>
<tbody>
{initialData.features.map((feature) => {
const row = getEntitlement(initialData.entitlements, activeTier, feature)
const mode = row?.mode ?? 'unavailable'
const cellKey = `${activeTier}:${feature}`
return (
<EntitlementRowEditor
key={`${activeTier}:${feature}`}
feature={feature}
mode={mode}
limitValue={row?.limitValue ?? null}
isSaving={savingCell === cellKey}
onSave={handleEntitlementChange}
t={t}
/>
)
})}
</tbody>
</table>
</div>
{(initialData.creditPacks?.length ?? 0) > 0 && (
<div className="border-t border-border/40 pt-3">
<p className="text-xs font-medium mb-2">{t('admin.billing.packsCatalogTitle')}</p>
<div className="flex flex-wrap gap-2">
{(initialData.creditPacks ?? []).map((pack) => (
<span
key={pack.id}
className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-muted/20 px-3 py-1.5 text-xs"
>
<span className="font-semibold uppercase">{pack.id}</span>
<span className="text-muted-foreground">
{pack.credits.toLocaleString('fr-FR')} cr. · {pack.defaultDisplay}
</span>
</span>
))}
</div>
</div>
)}
<p className="text-xs text-muted-foreground leading-relaxed border-t border-border/40 pt-3">
{t('admin.billing.creditsCostsNote')}
</p>
<p className="text-xs text-muted-foreground">
{t('admin.billing.creditsResetHint')}
</p>
</div>
</div>
@@ -195,7 +215,10 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
<p className="text-sm text-muted-foreground">
{t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })}
{initialData.usageOverview.lastSyncedAt
? ` · ${t('admin.billing.lastSync', { date: new Date(initialData.usageOverview.lastSyncedAt).toLocaleString() })}`
? ` · ${t('admin.billing.lastSync', {
// Format fixe UTC (évite mismatch SSR locale/fuseau vs client)
date: formatStableUtc(initialData.usageOverview.lastSyncedAt),
})}`
: ` · ${t('admin.billing.notSynced')}`}
</p>
</div>
@@ -236,68 +259,3 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
</div>
)
}
function EntitlementRowEditor({
feature,
mode: initialMode,
limitValue: initialLimit,
isSaving,
onSave,
t,
}: {
feature: string
mode: 'unavailable' | 'unlimited' | 'limited'
limitValue: number | null
isSaving: boolean
onSave: (feature: string, mode: 'unavailable' | 'unlimited' | 'limited', limit?: number) => Promise<void>
t: (key: string, params?: Record<string, string | number>) => string
}) {
const [mode, setMode] = useState(initialMode)
const [limit, setLimit] = useState(initialLimit != null ? String(initialLimit) : '50')
useEffect(() => {
setMode(initialMode)
setLimit(initialLimit != null ? String(initialLimit) : '50')
}, [initialMode, initialLimit])
return (
<tr className="border-b border-border/30">
<td className="py-3 pr-4 font-mono text-xs">{feature}</td>
<td className="py-3 pr-4">
<select
value={mode}
onChange={(e) => setMode(e.target.value as typeof mode)}
className="h-9 rounded-md border border-input bg-background px-2 text-xs"
>
<option value="unavailable">{t('admin.billing.modeUnavailable')}</option>
<option value="limited">{t('admin.billing.modeLimited')}</option>
<option value="unlimited">{t('admin.billing.modeUnlimited')}</option>
</select>
</td>
<td className="py-3 pr-4">
{mode === 'limited' ? (
<Input
type="number"
min={0}
value={limit}
onChange={(e) => setLimit(e.target.value)}
className="h-9 w-24 text-xs"
/>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</td>
<td className="py-3">
<Button
type="button"
size="sm"
variant="outline"
disabled={isSaving}
onClick={() => onSave(feature, mode, mode === 'limited' ? parseInt(limit, 10) : undefined)}
>
{isSaving ? '…' : t('admin.billing.saveLimit')}
</Button>
</td>
</tr>
)
}