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,55 +1,84 @@
'use client';
'use client'
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { motion, AnimatePresence } from 'motion/react';
import { AlertTriangle, Sparkles, Key, X, ArrowRight } from 'lucide-react';
import { useLanguage } from '@/lib/i18n';
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'motion/react'
import { AlertTriangle, Sparkles, Key, X, ArrowRight } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
interface InlinePaywallProps {
feature: string;
onDismiss: () => void;
/** Action en cours (pour contexte uniquement — le solde est global) */
feature: string
onDismiss: () => void
/** Coût de l'action si connu */
creditCost?: number
/** Solde restant si connu */
creditsRemaining?: number
}
export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
const { t } = useLanguage();
const router = useRouter();
const [timeLeft, setTimeLeft] = useState(10);
export function InlinePaywall({
feature,
onDismiss,
creditCost,
creditsRemaining,
}: InlinePaywallProps) {
const { t } = useLanguage()
const router = useRouter()
const [timeLeft, setTimeLeft] = useState(12)
useEffect(() => {
if (timeLeft <= 0) {
onDismiss();
return;
onDismiss()
return
}
const timer = setTimeout(() => {
setTimeLeft((prev) => prev - 1);
}, 1000);
return () => clearTimeout(timer);
}, [timeLeft, onDismiss]);
setTimeLeft((prev) => prev - 1)
}, 1000)
return () => clearTimeout(timer)
}, [timeLeft, onDismiss])
const handleUpgrade = () => {
router.push('/settings/billing');
onDismiss();
};
router.push('/settings/billing')
onDismiss()
}
const handleByok = () => {
router.push('/settings/ai#byok');
onDismiss();
};
router.push('/settings/ai#byok')
onDismiss()
}
const featureLabels: Record<string, string> = {
semantic_search: t('usageMeter.featureSearch') || 'Recherche Sémantique',
auto_tag: t('usageMeter.featureTags') || 'Tags Automatiques',
auto_title: t('usageMeter.featureTitles') || 'Titres Automatiques',
reformulate: t('usageMeter.featureReformulate') || 'Reformulation',
chat: t('usageMeter.featureChat') || 'Chat IA',
brainstorm_create: t('usageMeter.featureBrainstormCreate') || 'Création de Brainstorm',
brainstorm_expand: t('usageMeter.featureBrainstormExpand') || 'Expansion de Brainstorm',
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich') || 'Enrichissement de Brainstorm',
publish_enhance: t('usageMeter.featurePublishEnhance') || 'Publication IA',
};
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'),
publish_enhance: t('usageMeter.featurePublishEnhance'),
slide_generate: t('usageMeter.featureSlides'),
excalidraw_generate: t('usageMeter.featureDiagrams'),
ai_flashcard: t('usageMeter.featureFlashcards'),
voice_transcribe: t('usageMeter.featureVoice'),
credits: t('billing.aiCredits'),
}
const currentFeatureLabel = featureLabels[feature] || feature;
const currentFeatureLabel = featureLabels[feature] || feature
let description = t('quotaPaywall.description').replace('{feature}', currentFeatureLabel)
if (
typeof creditCost === 'number' &&
typeof creditsRemaining === 'number' &&
Number.isFinite(creditCost) &&
Number.isFinite(creditsRemaining)
) {
description = t('quotaPaywall.descriptionWithCost', {
remaining: String(creditsRemaining),
cost: String(creditCost),
feature: currentFeatureLabel,
})
}
return (
<AnimatePresence>
@@ -66,17 +95,17 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
</div>
<div className="flex-1 space-y-1">
<h4 className="text-sm font-bold text-rose-800 dark:text-rose-400">
{t('quotaPaywall.title') || 'Limite mensuelle atteinte'}
{t('quotaPaywall.title')}
</h4>
<p className="text-xs text-rose-700/80 dark:text-rose-400/70 font-light leading-relaxed">
{(t('quotaPaywall.description') || "Vous avez épuisé vos crédits pour la fonctionnalité {feature} ce mois-ci.").replace('{feature}', currentFeatureLabel)}
{description}
</p>
</div>
<button
type="button"
onClick={onDismiss}
className="text-rose-500/60 hover:text-rose-500 dark:text-rose-400/50 dark:hover:text-rose-400 p-1 hover:bg-rose-500/5 dark:hover:bg-rose-400/5 rounded-lg transition-all"
title={t('quotaPaywall.later') || 'Peut-être plus tard'}
title={t('quotaPaywall.later')}
>
<X size={16} />
</button>
@@ -89,17 +118,17 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-[#D4A373] text-white hover:bg-[#C49363] text-xs font-semibold rounded-xl transition-all shadow-sm shadow-[#D4A373]/15"
>
<Sparkles size={14} />
{t('quotaPaywall.upgrade') || 'Passer au plan Pro'}
{t('quotaPaywall.upgrade')}
<ArrowRight size={12} className="ml-1" />
</button>
<button
type="button"
onClick={handleByok}
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 border border-rose-300 dark:border-rose-800/40 text-rose-800 dark:text-rose-400 hover:bg-rose-100/30 dark:hover:bg-rose-400/5 text-xs font-semibold rounded-xl transition-all"
>
<Key size={14} />
{t('quotaPaywall.useOwnKey') || 'Utiliser ma propre clé'}
{t('quotaPaywall.useOwnKey')}
</button>
<div className="sm:ml-auto flex items-center justify-center gap-1.5 text-[10px] text-rose-500/60 dark:text-rose-400/40 font-mono">
@@ -108,5 +137,5 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
</div>
</motion.div>
</AnimatePresence>
);
)
}