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>
)
}

View File

@@ -139,11 +139,11 @@ const SUGGESTED_EMBEDDINGS: Record<string, string[]> = {
zai: ['text-embedding-3-small', 'text-embedding-3-large'],
}
type ModelPurpose = 'tags' | 'embeddings' | 'chat'
type ModelPurpose = 'tags' | 'embeddings' | 'chat' | 'slides'
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
const { t } = useLanguage()
const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat'>('tags')
const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat' | 'slides'>('tags')
const [isSaving, setIsSaving] = useState(false)
const [isTesting, setIsTesting] = useState(false)
@@ -168,11 +168,15 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
return PROVIDERS_WITHOUT_EMBEDDINGS.includes(v) ? 'ollama' : v
})
const [chatProvider, setChatProvider] = useState<AIProvider>((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama')
const [slidesProvider, setSlidesProvider] = useState<AIProvider>(
(config.AI_PROVIDER_SLIDES as AIProvider) || (config.AI_PROVIDER_CHAT as AIProvider) || 'ollama',
)
// Selected Models State
const [selectedTagsModel, setSelectedTagsModel] = useState<string>(config.AI_MODEL_TAGS || '')
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<string>(config.AI_MODEL_EMBEDDING || '')
const [selectedChatModel, setSelectedChatModel] = useState<string>(config.AI_MODEL_CHAT || '')
const [selectedSlidesModel, setSelectedSlidesModel] = useState<string>(config.AI_MODEL_SLIDES || '')
const [tagsFallbackProvider, setTagsFallbackProvider] = useState<string>(config.AI_PROVIDER_TAGS_FALLBACK || '')
const [embedFallbackProvider, setEmbedFallbackProvider] = useState<string>(config.AI_PROVIDER_EMBEDDING_FALLBACK || '')
@@ -186,11 +190,13 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
tags: [],
embeddings: [],
chat: [],
slides: [],
})
const [isLoadingModels, setIsLoadingModels] = useState<Record<ModelPurpose, boolean>>({
tags: false,
embeddings: false,
chat: false,
slides: false,
})
// Fetch models from local providers (Ollama, LM Studio) or cloud providers with /v1/models
@@ -268,6 +274,16 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
const key = config[API_KEY_CONFIG[chatProvider]] || ''
if (url && key) await fetchModels('chat', chatProvider, url, key)
}
if (slidesProvider === 'ollama') {
await fetchModels('slides', 'ollama', config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434')
} else if (slidesProvider === 'lmstudio') {
await fetchModels('slides', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
} else if (PROVIDER_META[slidesProvider]?.hasApiKey && slidesProvider !== 'anthropic_custom') {
const url = DEFAULT_BASE_URLS[slidesProvider]
const key = config[API_KEY_CONFIG[slidesProvider]] || ''
if (url && key) await fetchModels('slides', slidesProvider, url, key)
}
}
fetchInitial()
@@ -408,6 +424,32 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
}
}
// Slides provider (dedicated model for deck generation — never auto-renamed)
const slidesProv = formData.get('AI_PROVIDER_SLIDES') as string
data.AI_PROVIDER_SLIDES = slidesProv?.trim() ?? ''
const slidesModel = formData.get('AI_MODEL_SLIDES') as string
data.AI_MODEL_SLIDES = slidesModel?.trim() ?? ''
if (slidesProv) {
if (slidesProv === 'ollama') {
const ollamaUrl = formData.get('BASE_URL_ollama_slides') as string
if (ollamaUrl) data.OLLAMA_BASE_URL_CHAT = ollamaUrl
} else if (slidesProv === 'lmstudio') {
const url = formData.get('BASE_URL_lmstudio_slides') as string
if (url) data.LMSTUDIO_BASE_URL = url
} else {
const apiKeyConfigKey = API_KEY_CONFIG[slidesProv as AIProvider]
if (apiKeyConfigKey) {
const apiKey = formData.get(`API_KEY_${slidesProv}_slides`) as string
if (apiKey) data[apiKeyConfigKey] = apiKey
}
const baseUrlConfigKey = BASE_URL_CONFIG[slidesProv as AIProvider]
if (baseUrlConfigKey) {
const baseUrl = formData.get(`BASE_URL_${slidesProv}_slides`) as string
if (baseUrl) data[baseUrlConfigKey] = baseUrl
}
}
}
const result = await updateSystemConfig(data)
setIsSaving(false)
@@ -740,6 +782,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
<button type="button" onClick={() => setActiveAiTab('tags')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'tags' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🏷 Tags</button>
<button type="button" onClick={() => setActiveAiTab('embeddings')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'embeddings' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🔍 Embeddings</button>
<button type="button" onClick={() => setActiveAiTab('chat')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'chat' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>💬 Chat</button>
<button type="button" onClick={() => setActiveAiTab('slides')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'slides' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>📑 {t('admin.ai.slidesTab')}</button>
</div>
</div>
<div className="p-6 space-y-6">
@@ -945,6 +988,44 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
/>
</div>
</div>
{/* Slides Provider — dedicated model for deck generation */}
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'slides' ? 'block' : 'hidden'}`}>
<h3 className="text-base font-semibold flex items-center gap-2">
<span>📑</span> {t('admin.ai.slidesProvider')}
</h3>
<p className="text-xs text-muted-foreground">
{t('admin.ai.slidesDescription')}
</p>
<div className="space-y-2">
<Label htmlFor="AI_PROVIDER_SLIDES">{t('admin.ai.provider')}</Label>
<select
id="AI_PROVIDER_SLIDES"
name="AI_PROVIDER_SLIDES"
value={slidesProvider}
onChange={(e) => {
setSlidesProvider(e.target.value as AIProvider)
setSelectedSlidesModel('')
setDynamicModels((prev) => ({ ...prev, slides: [] }))
}}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{providerOptions.map((opt) => (
<option key={`slides-${opt.value}`} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<input type="hidden" name="AI_MODEL_SLIDES" value={selectedSlidesModel} />
{renderProviderConfig(slidesProvider, 'slides', selectedSlidesModel, setSelectedSlidesModel)}
<p className="text-xs text-muted-foreground border-t border-border/40 pt-3">
{t('admin.ai.slidesQuotaNote')}
</p>
</div>
</div>
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between pt-6">
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>

View File

@@ -3,9 +3,9 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { Button } from '@/components/ui/button'
import { deleteUser, updateUserRole, updateUserSubscription } from '@/app/actions/admin'
import { deleteUser, updateUserRole, updateUserSubscription, resetUserQuotas } from '@/app/actions/admin'
import { toast } from 'sonner'
import { Trash2, Shield, ShieldOff, Crown, ChevronDown } from 'lucide-react'
import { Trash2, Shield, ShieldOff, Crown, ChevronDown, RotateCcw } from 'lucide-react'
import { format } from 'date-fns'
import { useLanguage } from '@/lib/i18n'
@@ -91,6 +91,7 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
const { t } = useLanguage()
const [users, setUsers] = useState(initialUsers)
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
const [resettingId, setResettingId] = useState<string | null>(null)
const handleDelete = async (id: string) => {
if (!confirm(t('admin.users.confirmDelete'))) return
@@ -103,6 +104,27 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
}
}
const handleResetQuotas = async (user: { id: string; email?: string | null; name?: string | null }) => {
const label = user.email || user.name || user.id
if (!confirm(t('admin.users.confirmResetQuotas', { user: label }))) return
setResettingId(user.id)
try {
const result = await resetUserQuotas(user.id)
toast.success(
t('admin.users.resetQuotasSuccess', {
period: result.period,
count: result.featuresReset.length,
}),
)
} catch (e) {
toast.error(
e instanceof Error ? e.message : t('admin.users.resetQuotasFailed'),
)
} finally {
setResettingId(null)
}
}
const handleRoleToggle = async (user: any) => {
const newRole = user.role === 'ADMIN' ? 'USER' : 'ADMIN'
try {
@@ -163,6 +185,16 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
<td className="p-4 align-middle">{format(new Date(user.createdAt), 'PP')}</td>
<td className="p-4 align-middle text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleResetQuotas(user)}
disabled={resettingId === user.id}
title={t('admin.users.resetQuotas')}
className="text-amber-700 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200"
>
<RotateCcw className={`h-4 w-4 ${resettingId === user.id ? 'animate-spin' : ''}`} />
</Button>
<Button
variant="ghost"
size="sm"

View File

@@ -1,8 +1,35 @@
'use client';
import { LanguageProvider } from '@/lib/i18n/LanguageProvider';
import { LanguageProvider, useLanguage } from '@/lib/i18n/LanguageProvider';
import Link from 'next/link';
import { Globe } from 'lucide-react';
import { ArrowLeft } from 'lucide-react';
function AuthHeader() {
const { t } = useLanguage();
return (
<header className="p-6 md:p-8 flex justify-between items-center relative z-10">
<Link
href="/"
aria-label={t('general.back')}
className="flex items-center gap-2 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors group"
>
<div className="w-8 h-8 rounded-full border border-[var(--border)] flex items-center justify-center group-hover:border-[var(--color-brand-accent)] transition-colors">
<ArrowLeft size={14} className="group-hover:-translate-x-0.5 transition-transform rtl:rotate-180" />
</div>
</Link>
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-[var(--foreground)] text-[var(--background)] rounded-xl flex items-center justify-center shadow-lg">
<span className="font-serif font-bold text-xl">M</span>
</div>
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
</Link>
<div className="w-8" />
</header>
);
}
export default function AuthLayout({
children,
@@ -15,31 +42,13 @@ export default function AuthLayout({
<div className="absolute top-[-10%] right-[-10%] w-[50%] h-[50%] bg-[var(--color-brand-accent)]/5 blur-[120px] rounded-full pointer-events-none" />
<div className="absolute bottom-[-10%] left-[-10%] w-[50%] h-[50%] bg-[#D4A373]/5 blur-[120px] rounded-full pointer-events-none" />
<header className="p-6 md:p-8 flex justify-between items-center relative z-10">
<Link
href="/"
className="flex items-center gap-2 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors group"
>
<div className="w-8 h-8 rounded-full border border-[var(--border)] flex items-center justify-center group-hover:border-[var(--color-brand-accent)] transition-colors">
<Globe size={14} className="group-hover:rotate-12 transition-transform" />
</div>
</Link>
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-[var(--foreground)] text-[var(--background)] rounded-xl flex items-center justify-center shadow-lg">
<span className="font-serif font-bold text-xl">M</span>
</div>
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
</Link>
<div className="w-8" />
</header>
<AuthHeader />
<main className="flex-1 flex items-center justify-center p-4 md:p-6 relative z-10">
<div className="w-full max-w-md">
{children}
<p className="text-center mt-8 text-[9px] text-[var(--muted-foreground)] font-bold uppercase tracking-[0.3em] opacity-40 select-none">
© 2025 Memento Labs
© 2026 Memento
</p>
</div>
</main>

View File

@@ -35,13 +35,13 @@ export default async function MainLayout({
initialTranslations={initialTranslations}
initialAiProcessingConsent={aiSettings?.aiProcessingConsent === true}
>
{/* No top-bar header — sidebar-only navigation (architectural-grid design) */}
<div className="flex h-screen overflow-hidden bg-memento-desk dark:bg-background">
<Suspense fallback={<div className="hidden w-80 md:w-[26rem] 2xl:w-[30rem] shrink-0 md:block h-full self-stretch" />}>
{/* Fond via --background (suit .dark) — évite le flash beige/clair au changement de route */}
<div className="flex h-screen overflow-hidden bg-background">
<Suspense fallback={<div className="hidden w-80 md:w-[26rem] 2xl:w-[30rem] shrink-0 md:block h-full self-stretch bg-sidebar" />}>
<Sidebar user={session?.user} />
</Suspense>
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-memento-paper dark:bg-background" id="main-content">
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-background" id="main-content">
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-[9999] focus:top-2 focus:left-2 focus:bg-brand-accent focus:text-white focus:px-4 focus:py-2 focus:rounded-lg focus:text-sm focus:font-bold">
Skip to content
</a>

View File

@@ -6,7 +6,7 @@ import { updateUserSettings } from '@/app/actions/user-settings'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
import { Palette, Type, LayoutGrid, Maximize } from 'lucide-react'
import { applyDocumentTheme, normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
import { persistThemePreference, normalizeThemeId, type ThemeId } from '@/lib/apply-document-theme'
import {
NOTES_LAYOUT_STORAGE_KEY,
parseNotesLayoutMode,
@@ -65,8 +65,7 @@ export function AppearanceSettingsClient({
const handleThemeChange = async (value: string) => {
const next = normalizeThemeId(value)
setTheme(next)
localStorage.setItem('theme-preference', next)
applyDocumentTheme(next)
persistThemePreference(next)
await updateUserSettings({ theme: next })
toast.success(t('settings.settingsSaved'))
}

View File

@@ -1,16 +1,12 @@
'use client'
import { motion } from 'motion/react'
/**
* Plus danimation dentrée (opacity / translate) :
* ça laissait voir le fond clair une fraction de seconde en mode sombre.
* Conteneur neutre uniquement.
*/
export default function MainTemplate({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }}
className="flex-1 flex flex-col min-h-0"
>
<div className="flex min-h-0 flex-1 flex-col bg-background text-foreground">
{children}
</motion.div>
</div>
)
}

View File

@@ -3,6 +3,12 @@ import { detectUserLanguage, parseAcceptLanguage } from '@/lib/i18n/detect-user-
import { loadTranslations } from '@/lib/i18n/load-translations'
import { PublicProviders } from '@/components/public-providers'
/**
* Le body racine est en hauteur décran avec débordement masqué
* (pour lapplication connectée). Les pages publiques (accueil, tarifs,
* notes publiées) doivent pouvoir défiler : on crée un conteneur de
* défilement qui couvre tout lécran, indépendant du body.
*/
export default async function PublicLayout({ children }: { children: React.ReactNode }) {
const headersList = await headers()
const browserLang = parseAcceptLanguage(headersList.get('accept-language'))
@@ -11,7 +17,13 @@ export default async function PublicLayout({ children }: { children: React.React
return (
<PublicProviders initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
{children}
<div
data-public-scroll-root
className="fixed inset-0 z-0 overflow-y-auto overflow-x-hidden overscroll-y-contain"
style={{ WebkitOverflowScrolling: 'touch' }}
>
{children}
</div>
</PublicProviders>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,9 @@ const BILLING_CONFIG_KEYS = [
'STRIPE_PRICE_PRO_ANNUAL',
'STRIPE_PRICE_BUSINESS_MONTHLY',
'STRIPE_PRICE_BUSINESS_ANNUAL',
'STRIPE_PRICE_CREDITS_S',
'STRIPE_PRICE_CREDITS_M',
'STRIPE_PRICE_CREDITS_L',
] as const
const TIERS: TierType[] = ['BASIC', 'PRO', 'BUSINESS', 'ENTERPRISE']
@@ -54,7 +57,42 @@ export async function getBillingAdminData() {
BILLING_CONFIG_KEYS.map((key) => [key, config[key] ?? '']),
)
return { entitlements, billingConfig, usageOverview, features: [...VALID_FEATURES], tiers: TIERS }
const { CREDIT_ALLOCATIONS, CREDIT_COSTS, slideGenerateCreditCost } = await import('@/lib/credits')
const { CREDIT_PACKS, CREDIT_PACK_IDS } = await import('@/lib/billing/credit-packs')
const creditAllocations = TIERS.map((tier) => {
const raw = CREDIT_ALLOCATIONS[tier]
return {
tier,
monthlyCredits: raw === 'unlimited' ? null : raw,
unlimited: raw === 'unlimited',
}
})
const creditCosts = {
...CREDIT_COSTS,
slide_generate_example: slideGenerateCreditCost(7),
}
const creditPacks = CREDIT_PACK_IDS.map((id) => {
const p = CREDIT_PACKS[id]
return {
id: p.id,
credits: p.credits,
defaultDisplay: p.defaultDisplay,
}
})
return {
entitlements,
billingConfig,
usageOverview,
features: [...VALID_FEATURES],
tiers: TIERS,
creditAllocations,
creditCosts,
creditPacks,
}
}
export async function updatePlanEntitlement(

View File

@@ -42,24 +42,47 @@ export async function getSystemConfig() {
return getCachedConfig()
}
/** Keys that may be cleared (empty string) to fall back to chat / defaults. */
const CLEARABLE_CONFIG_KEYS = new Set([
'AI_PROVIDER_SLIDES',
'AI_MODEL_SLIDES',
])
export async function updateSystemConfig(data: Record<string, string>) {
await checkAdmin()
try {
// Filter out empty values but keep 'false' as valid
const filteredData = Object.fromEntries(
Object.entries(data).filter(([key, value]) => value !== '' && value !== null && value !== undefined)
)
// Filter out empty values but keep 'false' as valid.
// Slides provider/model may be empty intentionally (= use Chat fallback).
const toUpsert: Record<string, string> = {}
const toDelete: string[] = []
const operations = Object.entries(filteredData).map(([key, value]) =>
prisma.systemConfig.upsert({
where: { key },
update: { value },
create: { key, value }
})
)
for (const [key, value] of Object.entries(data)) {
if (value === null || value === undefined) continue
if (value === '' && CLEARABLE_CONFIG_KEYS.has(key)) {
toDelete.push(key)
continue
}
if (value === '') continue
toUpsert[key] = value
}
await prisma.$transaction(operations)
const operations = [
...Object.entries(toUpsert).map(([key, value]) =>
prisma.systemConfig.upsert({
where: { key },
update: { value },
create: { key, value },
}),
),
...toDelete.map((key) =>
prisma.systemConfig.deleteMany({ where: { key } }),
),
]
if (operations.length > 0) {
await prisma.$transaction(operations)
}
return { success: true }
} catch (error) {

View File

@@ -186,3 +186,60 @@ export async function updateUserSubscription(userId: string, tier: string) {
throw new Error('Failed to update subscription')
}
}
/**
* Remet à zéro les crédits IA + anciens compteurs feature du mois pour un utilisateur.
* @param feature (optionnel, legacy) — le reset crédits est global ; feature ignorée pour le solde.
*/
export async function resetUserQuotas(userId: string, feature?: string) {
const session = await checkAdmin()
if (!userId?.trim()) {
throw new Error('Identifiant utilisateur manquant')
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true },
})
if (!user) {
throw new Error('Utilisateur introuvable')
}
// Nouveau système : solde de crédits global
const { adminResetCredits } = await import('@/lib/credits')
const balance = await adminResetCredits(userId, { clearPurchased: false })
// Ancien système feature (compat / métriques)
const { resetUserUsageForCurrentPeriod } = await import('@/lib/entitlements')
const legacy = await resetUserUsageForCurrentPeriod(
userId,
feature ? { features: [feature] } : undefined,
)
const { logAuditEventAsync } = await import('@/lib/audit-log')
await logAuditEventAsync({
userId: session.user?.id,
action: 'QUOTA_RESET',
resource: userId,
metadata: {
targetUserId: userId,
targetEmail: user.email,
period: balance.period,
creditsRemaining: balance.unlimited ? 'unlimited' : balance.totalRemaining,
legacyFeaturesReset: legacy.featuresReset,
scope: feature || 'all',
},
})
revalidatePath('/admin')
revalidatePath('/admin/users')
return {
success: true as const,
period: balance.period,
featuresReset: legacy.featuresReset,
redisDeleted: legacy.redisDeleted,
dbUpdated: legacy.dbUpdated,
creditsRemaining: balance.unlimited ? null : balance.totalRemaining,
}
}

View File

@@ -1,8 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { QuotaExceededError } from '@/lib/entitlements'
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import { slideGenerateCreditCost, resolveCreditCost } from '@/lib/credits'
import type { FeatureName } from '@/lib/quota-utils'
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
import {
encodeSlideIntentDescription,
normalizeSlideIntent,
type SlideAudience,
type SlidePurpose,
} from '@/lib/ai/services/slide-intent'
type GenerateType = 'slide-generator' | 'excalidraw-generator'
@@ -12,9 +21,10 @@ const TYPE_DEFAULTS: Record<GenerateType, {
maxSteps: number
}> = {
'slide-generator': {
role: 'Génère une présentation professionnelle à partir du contenu de la note fournie. Appelle generate_slides avec un objet JSON structuré {title, theme, slides:[...]}.',
tools: ['note_search', 'note_read', 'generate_slides'],
maxSteps: 6,
// tools unused: slide-generator runs a structured-output pipeline (no function calling)
role: 'Génère un deck exécutif de haute qualité (titres d\'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).',
tools: [],
maxSteps: 1,
},
'excalidraw-generator': {
role: 'Génère un diagramme Excalidraw clair et professionnel à partir du contenu de la note fournie.',
@@ -32,26 +42,46 @@ export async function POST(req: NextRequest) {
const userId = session.user.id
const body = await req.json()
const { noteId, type, theme, style, language, template } = body as {
const { noteId, type, theme, style, language, template, purpose, audience, slideCount } = body as {
noteId: string
type: GenerateType
theme?: string
style?: string
language?: string
template?: string
purpose?: SlidePurpose
audience?: SlideAudience
slideCount?: number
}
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
return NextResponse.json({ error: 'Paramètres invalides' }, { status: 400 })
}
// Quota check — feature key depends on generation type
const featureKey = type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate'
// Crédits globaux (respecte le BYOK) — slides = 1 + N
const featureKey = (type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate') as FeatureName
const creditCost =
type === 'slide-generator'
? slideGenerateCreditCost(slideCount)
: resolveCreditCost(featureKey)
try {
await reserveUsageOrThrow(userId, featureKey)
await reserveAiUsageOrThrow(userId, featureKey, {
amount: creditCost,
slideCount: type === 'slide-generator' ? slideCount : undefined,
metadata: { noteId, type, slideCount: slideCount ?? null },
lane: 'chat',
})
} catch (e) {
if (e instanceof QuotaExceededError) {
return NextResponse.json({ error: e.message }, { status: 402 })
return NextResponse.json(
{
error: e.message,
code: 'QUOTA_EXCEEDED',
feature: featureKey,
creditCost,
},
{ status: 402 },
)
}
throw e
}
@@ -71,25 +101,50 @@ export async function POST(req: NextRequest) {
if (isEn) {
if (type === 'slide-generator') {
const recipeHint = (theme && theme !== 'auto') ? ` Use theme:"${theme}".` : ''
role = `Generate a professional presentation from the provided note content.${recipeHint} Call generate_slides with structured JSON {title, theme, slides:[...]}.`
role = `Generate a high-quality executive deck (action titles, one idea per slide, no filler, charts only with real numbers).${recipeHint}`
} else {
role = 'Generate a clear and professional Excalidraw diagram from the provided note content.'
}
} else if (type === 'slide-generator') {
const recipeHint = (theme && theme !== 'auto') ? ` Utilise le thème "${theme}".` : ''
role = `Génère une présentation professionnelle à partir du contenu de la note fournie.${recipeHint} Appelle generate_slides avec le JSON structuré {title, theme, slides:[...]}.`
role = `Génère un deck exécutif de haute qualité (titres d'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).${recipeHint}`
}
const agentName = type === 'slide-generator'
? `${isEn ? 'Slides' : 'Présentation'}${(note.title || 'Note').substring(0, 40)}`
: `${isEn ? 'Diagram' : 'Diagramme'}${(note.title || 'Note').substring(0, 40)}`
const slideIntent =
type === 'slide-generator'
? normalizeSlideIntent({
purpose,
audience,
slideCount,
template: template || 'auto',
})
: null
// Map purpose → legacy template when user picks intent without template
if (slideIntent && (!slideIntent.template || slideIntent.template === 'auto')) {
const purposeToTemplate: Record<string, string> = {
board: 'board-update',
project: 'project-status',
strategy: 'strategy-review',
}
if (slideIntent.purpose && purposeToTemplate[slideIntent.purpose]) {
slideIntent.template = purposeToTemplate[slideIntent.purpose]
}
}
const agent = await prisma.agent.create({
data: {
name: agentName,
type,
role,
description: (type === 'slide-generator' && template && template !== 'auto') ? `template:${template}` : undefined,
description:
type === 'slide-generator' && slideIntent
? encodeSlideIntentDescription(slideIntent)
: undefined,
tools: JSON.stringify(defaults.tools),
maxSteps: defaults.maxSteps,
frequency: 'one-shot',
@@ -104,8 +159,9 @@ export async function POST(req: NextRequest) {
// ── Fire and forget — do NOT await so the HTTP response returns immediately ──
// In Node.js / Docker self-hosted, the process keeps running after response.
// skipQuota: units already reserved above (avoids double-charge in executeAgent).
import('@/lib/ai/services/agent-executor.service')
.then(({ executeAgent }) => executeAgent(agent.id, userId))
.then(({ executeAgent }) => executeAgent(agent.id, userId, undefined, { skipQuota: true }))
.catch(err => console.error('[run-for-note] Background agent error:', err))
logAuditEvent({

View File

@@ -0,0 +1,135 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { stripe } from '@/lib/stripe'
import { isBillingEnabled } from '@/lib/billing/stripe-prices'
import {
isCreditPackId,
resolvePackPriceId,
getCreditPack,
} from '@/lib/billing/credit-packs'
import { prisma } from '@/lib/prisma'
import { z } from 'zod'
const bodySchema = z.object({
packId: z.enum(['S', 'M', 'L']),
})
export async function POST(req: NextRequest) {
const session = await auth()
if (!session?.user?.id || !session.user.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!(await isBillingEnabled())) {
return NextResponse.json({ error: 'Billing is not enabled' }, { status: 403 })
}
const secret = process.env.STRIPE_SECRET_KEY
if (!secret || secret === 'sk_test_placeholder') {
return NextResponse.json(
{ error: 'Stripe is not configured (STRIPE_SECRET_KEY missing)' },
{ status: 503 },
)
}
const parsed = bodySchema.safeParse(await req.json())
if (!parsed.success || !isCreditPackId(parsed.data.packId)) {
return NextResponse.json({ error: 'Invalid pack' }, { status: 400 })
}
const packId = parsed.data.packId
const pack = getCreditPack(packId)
const userId = session.user.id
const userEmail = session.user.email
try {
let priceId: string
try {
priceId = await resolvePackPriceId(packId)
} catch (e) {
console.error('[billing/create-pack-checkout] price resolve failed:', e)
return NextResponse.json(
{ error: 'Credit pack price IDs not configured. Set them in Admin > Billing.' },
{ status: 503 },
)
}
if (priceId.startsWith('price_mock_')) {
return NextResponse.json(
{ error: 'Credit pack price IDs not configured. Set them in Admin > Billing.' },
{ status: 503 },
)
}
const subscription = await prisma.subscription.findUnique({ where: { userId } })
let customerId = subscription?.stripeCustomerId ?? undefined
if (customerId && customerId.startsWith('cus_mock')) {
customerId = undefined
}
if (!customerId) {
const customer = await stripe.customers.create({
email: userEmail,
metadata: { userId },
})
customerId = customer.id
await prisma.subscription.upsert({
where: { userId },
update: { stripeCustomerId: customerId },
create: {
userId,
stripeCustomerId: customerId,
tier: 'BASIC',
status: 'ACTIVE',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000),
},
})
}
const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? 'localhost:3000'
const proto = req.headers.get('x-forwarded-proto') ?? 'http'
const origin = `${proto}://${host}`
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'payment',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}&pack=1`,
cancel_url: `${origin}/settings/billing?canceled=1`,
metadata: {
userId,
type: 'credit_pack',
packId,
credits: String(pack.credits),
},
payment_intent_data: {
metadata: {
userId,
type: 'credit_pack',
packId,
credits: String(pack.credits),
},
},
customer_update: { address: 'auto' },
allow_promotion_codes: true,
})
if (!checkoutSession.url) {
return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 })
}
return NextResponse.json({
url: checkoutSession.url,
sessionId: checkoutSession.id,
packId,
credits: pack.credits,
})
} catch (error) {
console.error('[billing/create-pack-checkout]', error)
const msg = error instanceof Error ? error.message : 'Failed to create pack checkout'
return NextResponse.json({ error: msg }, { status: 500 })
}
}

View File

@@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { getUserInfo, getEffectiveTier } from '@/lib/entitlements';
import { stripe } from '@/lib/stripe';
import type Stripe from 'stripe';
import { priceIdToTier, getDynamicPrices, isBillingEnabled } from '@/lib/billing/stripe-prices';
export const dynamic = 'force-dynamic';
@@ -76,6 +75,42 @@ export async function GET(req: NextRequest) {
const subscription = await prisma.subscription.findUnique({ where: { userId } });
const prices = await getDynamicPrices();
const billingEnabled = await isBillingEnabled();
const { getPackPublicPrices } = await import('@/lib/billing/credit-packs');
const creditPacks = await getPackPublicPrices();
// Si retour checkout pack : créditer au cas où le webhook n'a pas encore tourné
if (sessionId && sessionId.startsWith('cs_')) {
try {
const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId);
if (
checkoutSession.mode === 'payment' &&
checkoutSession.status === 'complete' &&
(checkoutSession.metadata?.type === 'credit_pack' || checkoutSession.metadata?.packId) &&
checkoutSession.metadata?.userId === userId
) {
const { addPurchasedCredits } = await import('@/lib/credits');
const {
getCreditPack,
isCreditPackId,
} = await import('@/lib/billing/credit-packs');
const packId = checkoutSession.metadata?.packId;
let credits = Number(checkoutSession.metadata?.credits ?? 0);
if (packId && isCreditPackId(packId) && (!Number.isFinite(credits) || credits <= 0)) {
credits = getCreditPack(packId).credits;
}
if (Number.isFinite(credits) && credits > 0) {
await addPurchasedCredits(userId, credits, {
stripeSessionId: checkoutSession.id,
packId: packId ?? null,
type: 'credit_pack',
source: 'billing_status_sync',
});
}
}
} catch (packSyncErr) {
console.error('[billing/status] pack sync failed:', packSyncErr);
}
}
return NextResponse.json({
tier,
@@ -86,6 +121,7 @@ export async function GET(req: NextRequest) {
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd ?? false,
hasStripeSubscription: !!subscription?.stripeSubscriptionId,
prices,
creditPacks,
billingEnabled,
});
} catch (error) {

View File

@@ -6,11 +6,79 @@ import {
handleSubscriptionDeleted,
resolveUserIdFromStripeEvent,
} from '@/lib/billing/sync-subscription-from-stripe';
import { prisma } from '@/lib/prisma';
import { addPurchasedCredits } from '@/lib/credits';
import {
getCreditPack,
isCreditPackId,
resolvePackFromPriceId,
} from '@/lib/billing/credit-packs';
import type Stripe from 'stripe';
export const runtime = 'nodejs';
async function fulfillCreditPackPurchase(session: Stripe.Checkout.Session) {
if (session.mode !== 'payment') return;
if (session.payment_status && session.payment_status !== 'paid' && session.payment_status !== 'no_payment_required') {
console.warn('[billing/webhook] pack checkout not paid yet', session.id, session.payment_status);
return;
}
const userId = session.metadata?.userId as string | undefined;
if (!userId) {
console.warn('[billing/webhook] credit pack: no userId', session.id);
return;
}
let packId = session.metadata?.packId as string | undefined;
let credits = Number(session.metadata?.credits ?? 0);
if ((!packId || !Number.isFinite(credits) || credits <= 0) && session.line_items == null) {
try {
const full = await stripe.checkout.sessions.retrieve(session.id, {
expand: ['line_items.data.price'],
});
const priceId = full.line_items?.data?.[0]?.price?.id;
if (priceId) {
const resolved = await resolvePackFromPriceId(priceId);
if (resolved) {
packId = resolved.packId;
credits = resolved.credits;
}
}
} catch (err) {
console.error('[billing/webhook] expand line_items failed', err);
}
}
if (packId && isCreditPackId(packId) && (!Number.isFinite(credits) || credits <= 0)) {
credits = getCreditPack(packId).credits;
}
if (!Number.isFinite(credits) || credits <= 0) {
console.warn('[billing/webhook] credit pack: invalid credits', session.id, { packId, credits });
return;
}
const result = await addPurchasedCredits(userId, credits, {
stripeSessionId: session.id,
packId: packId ?? null,
type: 'credit_pack',
paymentIntent:
typeof session.payment_intent === 'string'
? session.payment_intent
: session.payment_intent?.id ?? null,
});
if (result.applied) {
console.info('[billing/webhook] credited pack', {
userId,
credits: result.units,
packId,
sessionId: session.id,
});
}
}
export async function POST(req: NextRequest) {
const body = await req.text();
const headersList = await headers();
@@ -49,6 +117,11 @@ export async function POST(req: NextRequest) {
} else {
console.warn('[billing/webhook] checkout.session.completed: no userId in metadata', session.id);
}
} else if (
session.mode === 'payment' &&
(session.metadata?.type === 'credit_pack' || session.metadata?.packId)
) {
await fulfillCreditPackPurchase(session);
}
break;
}

View File

@@ -1,27 +1,48 @@
import { NextResponse } from 'next/server';
import { auth } from '@/auth';
import { getUserQuotas, getEffectiveTier } from '@/lib/entitlements';
import { NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getCreditUsageSummary, getQuotasCompatFromCredits } from '@/lib/credits'
export const dynamic = 'force-dynamic';
export const dynamic = 'force-dynamic'
export async function GET() {
const session = await auth();
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const [quotas, tier] = await Promise.all([
getUserQuotas(session.user.id),
getEffectiveTier(session.user.id),
]);
return NextResponse.json({ quotas, tier });
const summary = await getCreditUsageSummary(session.user.id)
// Compat anciens clients : quotas dérivés des crédits
const quotas = await getQuotasCompatFromCredits(session.user.id)
return NextResponse.json({
tier: summary.tier,
period: summary.period,
balance: {
totalRemaining: summary.balance.unlimited
? null
: summary.balance.totalRemaining,
subscriptionRemaining: summary.balance.unlimited
? null
: summary.balance.subscriptionRemaining,
purchasedRemaining: summary.balance.purchasedRemaining,
subscriptionGranted: summary.balance.unlimited
? null
: summary.balance.subscriptionGranted,
subscriptionSpent: summary.balance.subscriptionSpent,
spentThisPeriod: summary.balance.spentThisPeriod,
unlimited: summary.balance.unlimited,
},
breakdown: summary.breakdown,
// legacy
quotas,
})
} catch (error) {
console.error('[usage/current] Failed to fetch quotas:', error);
console.error('[usage/current] Failed to fetch usage data:', error)
return NextResponse.json(
{ error: 'Failed to fetch usage data' },
{ status: 503 },
);
)
}
}

View File

@@ -4,7 +4,21 @@
@import "vazirmatn/Vazirmatn-font-face.css";
@import "katex/dist/katex.min.css";
@custom-variant dark (&:is(.dark *));
/* Inclure .dark lui-même + ses descendants (évite styles clairs manquants) */
@custom-variant dark (&:where(.dark, .dark *));
/* Filet de sécurité anti-flash : fond sombre dès que html a .dark,
même si une frame Tailwind na pas encore appliqué dark: sur un enfant. */
html.dark {
color-scheme: dark;
background-color: #202020;
}
html.dark body {
color-scheme: dark;
background-color: #202020;
color: #ffffff;
}
/* ── Global UX fixes (UI/UX Pro Max audit) ────────────────────────────── */

View File

@@ -5,14 +5,17 @@ import { SessionProviderWrapper } from "@/components/session-provider-wrapper";
import { getAISettings } from "@/app/actions/ai-settings";
import { getUserSettings } from "@/app/actions/user-settings";
import { ThemeInitializer } from "@/components/theme-initializer";
import { ThemeClassGuard } from "@/components/theme-class-guard";
import { DirectionInitializer } from "@/components/direction-initializer";
import { ErrorReporter } from "@/components/error-reporter";
import { auth } from "@/auth";
import { CookieConsentRoot } from "@/components/legal/cookie-consent-root";
import { LanguageProvider } from "@/lib/i18n/LanguageProvider";
import Script from "next/script";
import type { CSSProperties } from "react";
import { normalizeThemeId } from "@/lib/apply-document-theme";
import { cookies } from "next/headers";
import { normalizeThemeId, THEME_COOKIE_NAME } from "@/lib/apply-document-theme";
import { THEME_INIT_SCRIPT, DIRECTION_INIT_SCRIPT } from "@/lib/inline-boot-scripts";
import { SwCleanup } from "@/components/sw-cleanup";
import { Inter, Manrope, Playfair_Display, JetBrains_Mono } from "next/font/google";
@@ -76,14 +79,20 @@ export default async function RootLayout({
const session = await auth();
const userId = session?.user?.id;
const [aiSettings, userSettings] = await Promise.all([
const [aiSettings, userSettings, cookieStore] = await Promise.all([
getAISettings(userId),
getUserSettings(userId),
cookies(),
])
const htmlTheme = serverHtmlThemeState(userSettings.theme)
// Cookie = préférence immédiate (bascule barre latérale) ; base = profil utilisateur.
// Sans cookie, un dark seulement en localStorage était écrasé par le serveur → flash clair.
const cookieTheme = cookieStore.get(THEME_COOKIE_NAME)?.value
const resolvedTheme = normalizeThemeId(cookieTheme || userSettings.theme || 'light')
const htmlTheme = serverHtmlThemeState(resolvedTheme)
const serverAccent = userSettings.accentColor ?? '#A47148'
const serverTheme = normalizeThemeId(userSettings.theme || 'light')
const serverTheme = resolvedTheme
const htmlStyle = {
'--color-brand-accent': serverAccent,
} as CSSProperties
@@ -97,14 +106,18 @@ export default async function RootLayout({
data-server-accent={serverAccent}
style={htmlStyle}
>
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable} ${jetbrainsMono.variable} h-screen overflow-hidden`}>
<Script id="theme-init" src="/scripts/theme-init.js" strategy="beforeInteractive" />
<Script id="direction-init" src="/scripts/direction-init.js" strategy="beforeInteractive" />
<Script id="sw-cleanup" src="/scripts/sw-cleanup.js" strategy="afterInteractive" />
<head>
{/* Scripts boot inline : anti-flash thème/RTL, sans next/script (React 19). */}
<script id="theme-init" dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
<script id="direction-init" dangerouslySetInnerHTML={{ __html: DIRECTION_INIT_SCRIPT }} />
</head>
<body className={`${inter.className} ${inter.variable} ${manrope.variable} ${playfair.variable} ${jetbrainsMono.variable} h-screen overflow-hidden bg-background text-foreground`}>
<SessionProviderWrapper>
<SwCleanup />
<ErrorReporter />
<DirectionInitializer />
<ThemeInitializer theme={userSettings.theme} fontSize={aiSettings.fontSize} fontFamily={aiSettings.fontFamily} accentColor={userSettings.accentColor} />
<ThemeInitializer theme={resolvedTheme} fontSize={aiSettings.fontSize} fontFamily={aiSettings.fontFamily} accentColor={userSettings.accentColor} />
<ThemeClassGuard />
{children}
<LanguageProvider initialLanguage="en">
<CookieConsentRoot />