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:
@@ -3,7 +3,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
|
||||
import { Check, Shield, Zap, Crown, X, ExternalLink, Loader2, CheckCircle2, Activity, Clock, ArrowRight } from 'lucide-react';
|
||||
import { Check, Shield, Zap, Crown, X, ExternalLink, Loader2, CheckCircle2, ArrowRight, Coins } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
import { toast } from 'sonner';
|
||||
@@ -33,6 +33,14 @@ interface BillingStatus {
|
||||
year: { display: string; amount: number; currency: string };
|
||||
};
|
||||
};
|
||||
creditPacks?: Array<{
|
||||
id: 'S' | 'M' | 'L';
|
||||
credits: number;
|
||||
display: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
configured: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
const billingEnabledEnvFallback = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' || process.env.NODE_ENV === 'development';
|
||||
@@ -53,6 +61,7 @@ export function BillingPlans() {
|
||||
const [checkoutClientSecret, setCheckoutClientSecret] = useState<string | null>(null);
|
||||
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
|
||||
const [checkoutLoading, setCheckoutLoading] = useState<Tier | null>(null);
|
||||
const [packLoading, setPackLoading] = useState<'S' | 'M' | 'L' | null>(null);
|
||||
const [portalLoading, setPortalLoading] = useState(false);
|
||||
const [cancelLoading, setCancelLoading] = useState(false);
|
||||
const [successBanner, setSuccessBanner] = useState<string | null>(null);
|
||||
@@ -78,16 +87,35 @@ export function BillingPlans() {
|
||||
});
|
||||
|
||||
const quotas = usageData?.quotas;
|
||||
const balance = usageData?.balance as
|
||||
| {
|
||||
totalRemaining: number | null
|
||||
subscriptionGranted: number | null
|
||||
purchasedRemaining: number
|
||||
spentThisPeriod: number
|
||||
unlimited: boolean
|
||||
}
|
||||
| undefined
|
||||
const breakdown = (usageData?.breakdown ?? []) as Array<{
|
||||
feature: string
|
||||
creditsUsed: number
|
||||
actionsCount: number
|
||||
}>
|
||||
const billingEnabled = status?.billingEnabled ?? billingEnabledEnvFallback;
|
||||
const stripe = getStripePromise(billingEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const sessionId = params.get('session_id');
|
||||
const isPack = params.get('pack') === '1';
|
||||
|
||||
if (sessionId) {
|
||||
const tier = status?.effectiveTier ?? 'Pro';
|
||||
setSuccessBanner(t('billing.checkoutSuccessBody').replace('{tier}', tier));
|
||||
if (isPack) {
|
||||
setSuccessBanner(t('billing.packCheckoutSuccess'));
|
||||
} else {
|
||||
const tier = status?.effectiveTier ?? 'Pro';
|
||||
setSuccessBanner(t('billing.checkoutSuccessBody').replace('{tier}', tier));
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['billing', 'status'] });
|
||||
window.history.replaceState({}, '', '/settings/billing');
|
||||
@@ -128,6 +156,30 @@ export function BillingPlans() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBuyPack = async (packId: 'S' | 'M' | 'L') => {
|
||||
if (!billingEnabled) return;
|
||||
setPackLoading(packId);
|
||||
try {
|
||||
const res = await fetch('/api/billing/create-pack-checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ packId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? 'Failed to create pack checkout');
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
} else {
|
||||
throw new Error('No checkout URL');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[BillingPlans] pack checkout error:', err);
|
||||
toast.error(t('billing.packCheckoutFailed'));
|
||||
} finally {
|
||||
setPackLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePortal = async (action: 'portal' | 'cancel' | React.MouseEvent = 'portal') => {
|
||||
const actualAction = typeof action === 'string' ? action : 'portal';
|
||||
setPortalLoading(true);
|
||||
@@ -199,23 +251,19 @@ export function BillingPlans() {
|
||||
const effectiveTier = status?.effectiveTier ?? 'BASIC';
|
||||
const isPaid = effectiveTier !== 'BASIC';
|
||||
|
||||
const aiUsed = quotas?.semantic_search?.used ?? 0;
|
||||
const aiLimit = quotas?.semantic_search?.limit ?? 50;
|
||||
const aiPct = aiLimit > 0 ? (aiUsed / aiLimit) * 100 : 0;
|
||||
|
||||
const plans = [
|
||||
{
|
||||
id: 'free',
|
||||
name: t('billing.freePlan'),
|
||||
price: t('billing.freePrice') || 'Gratuit',
|
||||
period: '',
|
||||
description: t('billing.freeDescription') || 'Pour découvrir la magie de Memento.',
|
||||
description: t('billing.freeDescription') || 'Pour découvrir Memento.',
|
||||
features: [
|
||||
t('billing.freeF1') || '100 Notes max',
|
||||
t('billing.freeF2') || '3 Carnets',
|
||||
t('billing.freeF3') || '50 crédits IA (Lifetime)',
|
||||
t('billing.freeF4') || 'Recherche sémantique',
|
||||
t('billing.freeF5') || 'Historique 7 jours',
|
||||
t('billing.freeF1'),
|
||||
t('billing.freeF2'),
|
||||
t('billing.freeF3'),
|
||||
t('billing.freeF4'),
|
||||
t('billing.freeF5'),
|
||||
],
|
||||
current: effectiveTier === 'BASIC',
|
||||
buttonText: effectiveTier === 'BASIC' ? (t('billing.currentPlan') || 'Plan Actuel') : t('billing.startCheckout'),
|
||||
@@ -232,12 +280,12 @@ export function BillingPlans() {
|
||||
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
|
||||
description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
|
||||
features: [
|
||||
t('billing.proFeature1') || 'Notes illimitées',
|
||||
t('billing.proFeature2') || 'BYOK (OpenAI/Anthropic)',
|
||||
t('billing.proFeature3') || '200 recherches sémantiques',
|
||||
t('billing.proFeature4') || 'Agents (12 runs/mois)',
|
||||
t('billing.proFeature5') || 'Historique 30 jours',
|
||||
t('billing.proFeature6') || 'Support Email',
|
||||
t('billing.proFeature1'),
|
||||
t('billing.proFeature2'),
|
||||
t('billing.proFeature3'),
|
||||
t('billing.proFeature4'),
|
||||
t('billing.proFeature5'),
|
||||
t('billing.proFeature6'),
|
||||
],
|
||||
current: effectiveTier === 'PRO',
|
||||
popular: true,
|
||||
@@ -254,12 +302,12 @@ export function BillingPlans() {
|
||||
(interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€')),
|
||||
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
|
||||
features: [
|
||||
t('billing.businessFeature1') || '10 Collaborateurs inclus',
|
||||
t('billing.businessFeature2') || 'BYOK (13 fournisseurs)',
|
||||
t('billing.businessFeature3') || '1000 recherches sémantiques',
|
||||
t('billing.businessFeature4') || 'Agents (60 runs/mois)',
|
||||
t('billing.businessFeature5') || 'Brainstorm illimité',
|
||||
t('billing.businessFeature6') || 'Accès API',
|
||||
t('billing.businessFeature1'),
|
||||
t('billing.businessFeature2'),
|
||||
t('billing.businessFeature3'),
|
||||
t('billing.businessFeature4'),
|
||||
t('billing.businessFeature5'),
|
||||
t('billing.businessFeature6'),
|
||||
],
|
||||
current: effectiveTier === 'BUSINESS',
|
||||
buttonText: effectiveTier === 'BUSINESS' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.businessCta') || 'Choisir Plan Business'),
|
||||
@@ -273,13 +321,13 @@ export function BillingPlans() {
|
||||
name: t('billing.enterpriseTitle') || 'Enterprise',
|
||||
price: t('billing.contactSales') || 'Sur devis',
|
||||
period: '',
|
||||
description: t('billing.enterpriseDescription') || 'Quotas personnalisés, SSO, support prioritaire.',
|
||||
description: t('billing.enterpriseDescription') || 'Crédits illimités ou pool dédié, SSO, support prioritaire.',
|
||||
features: [
|
||||
t('billing.enterpriseFeature1') || 'Quotas illimités',
|
||||
t('billing.enterpriseFeature2') || 'SSO / SAML',
|
||||
t('billing.enterpriseFeature3') || 'Support dédié',
|
||||
t('billing.enterpriseFeature4') || 'Facturation personnalisée',
|
||||
t('billing.enterpriseFeature5') || 'SLA garanti',
|
||||
t('billing.enterpriseFeature1'),
|
||||
t('billing.enterpriseFeature2'),
|
||||
t('billing.enterpriseFeature3'),
|
||||
t('billing.enterpriseFeature4'),
|
||||
t('billing.enterpriseFeature5'),
|
||||
],
|
||||
current: effectiveTier === 'ENTERPRISE',
|
||||
buttonText: effectiveTier === 'ENTERPRISE' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.contactSales') || 'Contact Sales'),
|
||||
@@ -307,18 +355,21 @@ export function BillingPlans() {
|
||||
}
|
||||
};
|
||||
|
||||
// Sommes pour l'usage global agrégé (donut)
|
||||
let totalUsed = 0;
|
||||
let totalLimit = 0;
|
||||
if (quotas) {
|
||||
Object.entries(quotas as Record<string, any>).forEach(([_, q]) => {
|
||||
if (q.limit > 0 && q.limit !== Infinity) {
|
||||
totalUsed += q.used;
|
||||
totalLimit += q.limit;
|
||||
}
|
||||
});
|
||||
}
|
||||
const globalPct = totalLimit > 0 ? (totalUsed / totalLimit) * 100 : 0;
|
||||
// Solde crédits global (modèle A) — pas la somme des anciens seaux
|
||||
const unlimitedCredits = !!balance?.unlimited
|
||||
const totalUsed = balance?.spentThisPeriod ?? 0
|
||||
const remainingCredits = unlimitedCredits ? null : (balance?.totalRemaining ?? 0)
|
||||
const granted = unlimitedCredits
|
||||
? null
|
||||
: (balance?.subscriptionGranted ?? 0) + (balance?.purchasedRemaining ?? 0)
|
||||
// pot ≈ restants + déjà utilisés sur la fenêtre courante
|
||||
const totalLimit = unlimitedCredits
|
||||
? 0
|
||||
: Math.max(granted ?? 0, totalUsed + (remainingCredits ?? 0))
|
||||
const globalPct =
|
||||
unlimitedCredits || totalLimit <= 0
|
||||
? 0
|
||||
: Math.min(100, (totalUsed / totalLimit) * 100)
|
||||
|
||||
// SVG Donut Config
|
||||
const radius = 28;
|
||||
@@ -450,79 +501,160 @@ export function BillingPlans() {
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute flex flex-col items-center justify-center">
|
||||
<span className="text-xl font-bold text-ink">{Math.round(globalPct)}%</span>
|
||||
<span className="text-xl font-bold text-ink">
|
||||
{unlimitedCredits ? '∞' : `${Math.round(globalPct)}%`}
|
||||
</span>
|
||||
<span className="text-[8px] text-concrete uppercase tracking-widest">{t('billing.used')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-center">
|
||||
<p className="text-xs font-bold text-ink">
|
||||
{totalUsed} <span className="text-concrete font-light">/ {totalLimit} {t('billing.aiCredits') || 'crédits IA'}</span>
|
||||
{unlimitedCredits ? (
|
||||
<span className="text-concrete font-light">{t('billing.unlimited')}</span>
|
||||
) : (
|
||||
<>
|
||||
{totalUsed}{' '}
|
||||
<span className="text-concrete font-light">
|
||||
/ {totalLimit} {t('billing.aiCredits') || 'crédits IA'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{!unlimitedCredits && remainingCredits != null && (
|
||||
<p className="text-[10px] text-concrete mt-1">
|
||||
{remainingCredits} {t('billing.creditsRemaining')?.toLowerCase() || 'restants'}
|
||||
</p>
|
||||
)}
|
||||
{!unlimitedCredits && (balance?.purchasedRemaining ?? 0) > 0 && (
|
||||
<p className="text-[10px] text-brand-accent mt-1">
|
||||
{balance?.purchasedRemaining} {t('billing.creditsFromPacks')?.toLowerCase() || 'de packs'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage breakdown per feature */}
|
||||
{/* Packs de crédits (one-shot) */}
|
||||
{!unlimitedCredits && billingEnabled && (status?.creditPacks?.length ?? 0) > 0 && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2.5 bg-brand-accent/10 text-brand-accent rounded-2xl shrink-0">
|
||||
<Coins size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete">
|
||||
{t('billing.packsTitle')}
|
||||
</h3>
|
||||
<p className="text-[11px] text-concrete mt-1 leading-relaxed max-w-xl">
|
||||
{t('billing.packsDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{(status?.creditPacks ?? []).map((pack) => (
|
||||
<div
|
||||
key={pack.id}
|
||||
className="p-6 rounded-3xl border border-border bg-white/40 dark:bg-white/5 flex flex-col gap-4"
|
||||
>
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
|
||||
{t(`billing.pack${pack.id}Name`) || `Pack ${pack.id}`}
|
||||
</p>
|
||||
<p className="text-2xl font-serif font-bold text-ink mt-1 tabular-nums">
|
||||
{pack.credits.toLocaleString()}
|
||||
<span className="text-sm font-sans font-normal text-concrete ms-1">
|
||||
{t('billing.aiCredits') || 'crédits'}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-ink mt-2">{pack.display}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBuyPack(pack.id)}
|
||||
disabled={!pack.configured || packLoading !== null}
|
||||
className="mt-auto w-full py-3 rounded-2xl bg-ink text-white dark:bg-white dark:text-black text-[10px] font-bold uppercase tracking-[0.15em] hover:opacity-90 disabled:opacity-40 transition-all"
|
||||
>
|
||||
{packLoading === pack.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mx-auto" />
|
||||
) : (
|
||||
t('billing.buyPack')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Répartition crédits par fonction */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete">
|
||||
{t('billing.usageThisPeriod') || 'Utilisation sur cette période'}
|
||||
{t('billing.creditsWhereTitle') || t('billing.usageThisPeriod')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-concrete mt-1">
|
||||
{status?.currentPeriodStart && status?.currentPeriodEnd ? (
|
||||
`${formatDate(status.currentPeriodStart)} – ${formatDate(status.currentPeriodEnd)}`
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
{t('billing.creditsUsageLegend')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{!quotas && (
|
||||
{!usageData && (
|
||||
<div className="col-span-full flex items-center justify-center py-12 bg-white/40 dark:bg-white/5 border border-border rounded-3xl">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-brand-accent" />
|
||||
</div>
|
||||
)}
|
||||
{quotas && Object.entries(quotas as Record<string, any>).map(([key, q]) => {
|
||||
const pct = q.limit > 0 && q.limit !== Infinity ? (q.used / q.limit) * 100 : 0;
|
||||
const isUnlimited = q.limit === Infinity || q.limit <= 0;
|
||||
|
||||
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 IA',
|
||||
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',
|
||||
};
|
||||
|
||||
// Couleur de barre : normal (violet), warning >= 75% (ambre), exhausted >= 100% (rose)
|
||||
const barFillColor = pct >= 100 ? 'bg-rose-400' : pct >= 75 ? 'bg-amber-400' : 'bg-gradient-to-r from-violet-400 to-purple-400';
|
||||
|
||||
return (
|
||||
<div key={key} className="p-5 rounded-2xl bg-white/40 dark:bg-white/5 border border-border space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[11px] font-bold text-ink uppercase tracking-wider truncate">
|
||||
{featureLabels[key] || key}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-concrete tabular-nums">
|
||||
{isUnlimited ? t('billing.unlimited') || 'Illimité' : `${q.used} / ${q.limit}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-2 w-full bg-secondary/40 rounded-full overflow-hidden">
|
||||
{usageData &&
|
||||
(() => {
|
||||
const featureLabels: Record<string, string> = {
|
||||
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'),
|
||||
suggest_charts: t('usageMeter.featureCharts'),
|
||||
publish_enhance: t('usageMeter.featurePublishEnhance'),
|
||||
ai_flashcard: t('usageMeter.featureFlashcards'),
|
||||
voice_transcribe: t('usageMeter.featureVoice'),
|
||||
slide_generate: t('usageMeter.featureSlides'),
|
||||
excalidraw_generate: t('usageMeter.featureDiagrams'),
|
||||
}
|
||||
const byFeature = new Map(breakdown.map((r) => [r.feature, r]))
|
||||
return Object.keys(featureLabels).map((key) => {
|
||||
const row = byFeature.get(key)
|
||||
const used = row?.creditsUsed ?? 0
|
||||
const pct = totalUsed > 0 && used > 0 ? (used / totalUsed) * 100 : 0
|
||||
const barFillColor =
|
||||
pct >= 40
|
||||
? 'bg-gradient-to-r from-violet-400 to-purple-400'
|
||||
: 'bg-gradient-to-r from-violet-300/80 to-purple-300/80'
|
||||
return (
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all duration-500', barFillColor)}
|
||||
style={{ width: `${isUnlimited ? 100 : Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
key={key}
|
||||
className="p-5 rounded-2xl bg-white/40 dark:bg-white/5 border border-border space-y-3"
|
||||
>
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<span className="text-[11px] font-bold text-ink uppercase tracking-wider truncate">
|
||||
{featureLabels[key]}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-concrete tabular-nums shrink-0">
|
||||
{used > 0 ? `${used} cr.` : '0'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-secondary/40 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all duration-500', barFillColor)}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,59 +1,88 @@
|
||||
'use client';
|
||||
'use client'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2, BarChart2 } from 'lucide-react';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Loader2, BarChart2 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface QuotaEntry {
|
||||
remaining: number;
|
||||
limit: number;
|
||||
used: number;
|
||||
interface BalanceData {
|
||||
totalRemaining: number | null
|
||||
subscriptionRemaining: number | null
|
||||
purchasedRemaining: number
|
||||
subscriptionGranted: number | null
|
||||
spentThisPeriod: number
|
||||
unlimited: boolean
|
||||
}
|
||||
|
||||
type Quotas = Record<string, QuotaEntry>;
|
||||
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> = {
|
||||
aiSummary: 'sidebar.aiSummary',
|
||||
aiFlashcards: 'sidebar.aiFlashcards',
|
||||
aiMindmap: 'sidebar.aiMindmap',
|
||||
aiTranscribe: 'sidebar.aiTranscribe',
|
||||
aiDiagram: 'sidebar.aiDiagram',
|
||||
aiAgent: 'sidebar.aiAgent',
|
||||
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',
|
||||
};
|
||||
|
||||
function UsageBar({ used, limit, isUnlimited }: { used: number; limit: number; isUnlimited: boolean }) {
|
||||
const pct = isUnlimited ? 0 : Math.min(100, limit > 0 ? (used / limit) * 100 : 0);
|
||||
const color =
|
||||
pct >= 90 ? 'bg-rose-500' : pct >= 70 ? 'bg-amber-500' : 'bg-primary';
|
||||
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">
|
||||
{!isUnlimited && (
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all', color)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
)}
|
||||
<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 { t } = useLanguage()
|
||||
|
||||
const { data, isLoading } = useQuery<{ quotas: Quotas }>({
|
||||
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();
|
||||
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 quotas = data?.quotas ?? {};
|
||||
const entries = Object.entries(quotas);
|
||||
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">
|
||||
@@ -68,33 +97,72 @@ export function UsageBreakdown() {
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
) : !balance ? (
|
||||
<p className="text-xs text-muted-foreground py-2">{t('billing.noUsage')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{entries.map(([feature, quota]) => {
|
||||
const isUnlimited = quota.limit === -1 || !isFinite(quota.limit);
|
||||
const labelKey = FEATURE_LABEL_KEYS[feature];
|
||||
const label = labelKey ? t(labelKey) : feature;
|
||||
<>
|
||||
<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>
|
||||
|
||||
return (
|
||||
<div key={feature} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-xs font-medium text-foreground tabular-nums">
|
||||
{isUnlimited ? (
|
||||
<span className="text-primary/80 dark:text-primary">{t('billing.unlimited')}</span>
|
||||
) : (
|
||||
`${quota.used} / ${quota.limit}`
|
||||
)}
|
||||
</span>
|
||||
<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>
|
||||
<UsageBar used={quota.used} limit={quota.limit} isUnlimited={isUnlimited} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user