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:
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user