Publication IA: - 4 templates (magazine, brief, essay, simple) avec CSS riche - Rewrite IA (article/exercises/tutorial/reference/mixed) - Modération avec timeout 12s + fallback safe - Quotas publish_enhance par tier (basic=2, pro=15, business=100) - Détection contenu stale (hash) - Migration DB publishedContent/publishedTemplate/publishedSourceHash Fixes: - cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast - _isShared ajouté au type Note (champ virtuel serveur) - callout colors PDF export: extraction fonction pure testable - admin/published: guard note.userId null - Cmd+S fonctionne en mode dialog (pas seulement fullPage) i18n: - 23 clés publish* traduites dans les 15 locales - Extension Web Clipper: 13 locales mise à jour Tests: - callout-colors.test.ts (6 tests) - note-visible-in-view.test.ts (5 tests) - entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs - 199/199 tests passent Tracker: user-stories.md sync avec sprint-status.yaml
285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useRouter } from 'next/navigation';
|
|
import { cn } from '@/lib/utils';
|
|
import { Sparkles, ChevronDown, X, Crown } from 'lucide-react';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import { useLanguage } from '@/lib/i18n';
|
|
|
|
interface QuotaData {
|
|
remaining: number;
|
|
limit: number;
|
|
used: number;
|
|
}
|
|
|
|
interface UsageMeterProps {
|
|
className?: string;
|
|
}
|
|
|
|
export function UsageMeter({ className }: UsageMeterProps) {
|
|
const { t } = useLanguage();
|
|
const router = useRouter();
|
|
const queryClient = useQueryClient();
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [showModal, setShowModal] = useState(false);
|
|
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 quotas');
|
|
const json = await res.json();
|
|
return { quotas: json.quotas as Record<string, QuotaData>, tier: json.tier as string };
|
|
},
|
|
staleTime: 0,
|
|
refetchInterval: 5000,
|
|
});
|
|
|
|
useEffect(() => {
|
|
const handler = () => queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
|
|
window.addEventListener('ai-usage-changed', handler);
|
|
return () => window.removeEventListener('ai-usage-changed', handler);
|
|
}, [queryClient]);
|
|
|
|
if (isLoading || !data || !data.quotas) {
|
|
return (
|
|
<div className={cn('px-2 py-2', className)}>
|
|
<div className="h-8 rounded-lg bg-paper/50 animate-pulse" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const getPackLabel = () => {
|
|
if (!data.tier) return t('usageMeter.packName');
|
|
switch (data.tier) {
|
|
case 'PRO': return t('usageMeter.packPro');
|
|
case 'BUSINESS': return t('usageMeter.packBusiness');
|
|
case 'ENTERPRISE': return t('usageMeter.packEnterprise');
|
|
default: return t('usageMeter.packName');
|
|
}
|
|
};
|
|
|
|
const isProPlus = data.tier && data.tier !== 'BASIC';
|
|
|
|
// Features visibles dans l'UI (les features techniques comme expand/enrich sont cachées)
|
|
const VISIBLE_FEATURES = new Set([
|
|
'semantic_search',
|
|
'auto_tag',
|
|
'auto_title',
|
|
'reformulate',
|
|
'chat',
|
|
'brainstorm_create', // Sera affiché comme "Sessions brainstorm"
|
|
'suggest_charts',
|
|
'publish_enhance',
|
|
]);
|
|
|
|
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'), // Label simplifié
|
|
suggest_charts: t('usageMeter.featureCharts'),
|
|
publish_enhance: t('usageMeter.featurePublishEnhance'),
|
|
};
|
|
|
|
const featureQuotas = Object.entries(data.quotas)
|
|
.filter(([key, q]) => q.limit > 0 && VISIBLE_FEATURES.has(key))
|
|
.map(([key, quota]) => ({
|
|
key: key === 'brainstorm_create' ? 'brainstorm_sessions' : key, // Renommer pour l'affichage
|
|
label: featureLabels[key] || key,
|
|
used: quota.used,
|
|
limit: quota.limit,
|
|
remaining: quota.remaining,
|
|
}));
|
|
|
|
const isUnlimited = featureQuotas.every((f) => !Number.isFinite(f.limit));
|
|
|
|
const totalUsed = featureQuotas.reduce(
|
|
(sum, f) => sum + (Number.isFinite(f.used) ? f.used : 0), 0,
|
|
);
|
|
const totalLimit = featureQuotas.reduce(
|
|
(sum, f) => sum + (Number.isFinite(f.limit) ? f.limit : 0), 0,
|
|
);
|
|
const totalRemaining = totalLimit - totalUsed;
|
|
const totalPct = totalLimit > 0 ? (totalUsed / totalLimit) * 100 : 0;
|
|
const isExhausted = !isUnlimited && totalRemaining <= 0;
|
|
|
|
return (
|
|
<>
|
|
<div className="px-2 py-2 w-full">
|
|
<div className="bg-slate-50 dark:bg-white/5 border border-border rounded-2xl overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded(!expanded)}
|
|
className="w-full p-3 flex items-center gap-2 hover:bg-slate-100 dark:hover:bg-white/5 transition-colors"
|
|
>
|
|
<Sparkles
|
|
size={12}
|
|
className={cn(
|
|
'shrink-0',
|
|
isExhausted
|
|
? 'text-rose-400 fill-rose-400/20'
|
|
: isUnlimited
|
|
? 'text-emerald-400 fill-emerald-400/20'
|
|
: 'text-brand-accent fill-brand-accent/20'
|
|
)}
|
|
/>
|
|
<span className="text-[11px] font-bold text-ink/70">{getPackLabel()}</span>
|
|
|
|
{!isUnlimited && (
|
|
<>
|
|
<div className="flex-1 h-1 bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden mx-2">
|
|
<div
|
|
className={cn(
|
|
'h-full rounded-full',
|
|
totalPct >= 90 ? 'bg-rose-400' : totalPct >= 70 ? 'bg-amber-400' : 'bg-brand-accent',
|
|
)}
|
|
style={{ width: `${Math.min(totalPct, 100)}%` }}
|
|
/>
|
|
</div>
|
|
<span className={cn(
|
|
'text-[10px] font-medium tabular-nums shrink-0',
|
|
totalPct >= 90 ? 'text-rose-500' : totalPct >= 70 ? 'text-amber-500' : 'text-concrete'
|
|
)}>
|
|
{totalRemaining}
|
|
</span>
|
|
</>
|
|
)}
|
|
|
|
{isUnlimited && (
|
|
<span className="text-[10px] font-medium text-emerald-500 ml-auto">{t('usageMeter.unlimited')}</span>
|
|
)}
|
|
|
|
{isProPlus && !isUnlimited && (
|
|
<span className="text-[9px] font-bold text-brand-accent uppercase tracking-widest ml-auto">{data.tier}</span>
|
|
)}
|
|
|
|
<ChevronDown
|
|
size={12}
|
|
className={cn(
|
|
'text-concrete shrink-0 transition-transform duration-200',
|
|
expanded && 'rotate-180',
|
|
)}
|
|
/>
|
|
</button>
|
|
|
|
<AnimatePresence initial={false}>
|
|
{expanded && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="px-3 pb-3 space-y-2">
|
|
<div className="border-t border-border/40 pt-2" />
|
|
{featureQuotas.map((f) => {
|
|
const fPct = Number.isFinite(f.limit) && f.limit > 0 ? (f.used / f.limit) * 100 : 0
|
|
return (
|
|
<div key={f.key} className="space-y-1">
|
|
<div className="flex justify-between text-[10px]">
|
|
<span className="text-concrete truncate">{f.label}</span>
|
|
<span className={cn(
|
|
'font-medium tabular-nums',
|
|
fPct >= 90 ? 'text-rose-500' : fPct >= 70 ? 'text-amber-500' : 'text-ink/60'
|
|
)}>
|
|
{!Number.isFinite(f.remaining) ? '∞' : f.remaining}/{!Number.isFinite(f.limit) ? '∞' : f.limit}
|
|
</span>
|
|
</div>
|
|
{Number.isFinite(f.limit) && (
|
|
<div className="h-1 w-full bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden">
|
|
<div
|
|
className={cn(
|
|
'h-full rounded-full transition-all duration-300',
|
|
fPct >= 90 ? 'bg-rose-400' : fPct >= 70 ? 'bg-amber-400' : 'bg-brand-accent',
|
|
)}
|
|
style={{ width: `${Math.min(fPct, 100)}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{!isProPlus && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); router.push('/settings/billing'); }}
|
|
className="w-full mt-2 py-2 bg-brand-accent/10 hover:bg-brand-accent text-brand-accent hover:text-white text-[9px] font-bold uppercase tracking-widest rounded-xl transition-all duration-300 border border-brand-accent/20"
|
|
>
|
|
{t('usageMeter.upgradePricing')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
|
|
{showModal && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
|
<div className="bg-paper border border-border rounded-2xl p-6 mx-4 max-w-sm w-full shadow-xl">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Crown size={18} className="text-amber-500" />
|
|
<h3 className="text-sm font-semibold">{t('usageMeter.upgradeTitle')}</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowModal(false)}
|
|
className="text-muted-ink hover:text-ink transition-colors"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-[12px] text-muted-ink mb-4">
|
|
{t('usageMeter.upgradeDescription')}
|
|
</p>
|
|
|
|
<div className="space-y-2 mb-4">
|
|
<div className="text-[11px] font-medium text-ink">{t('usageMeter.proIncludes')}</div>
|
|
<ul className="text-[11px] text-muted-ink space-y-1">
|
|
<li>• {t('usageMeter.proSearch')}</li>
|
|
<li>• {t('usageMeter.proTags')}</li>
|
|
<li>• {t('usageMeter.proTitles')}</li>
|
|
<li>• {t('usageMeter.proReformulate')}</li>
|
|
<li>• {t('usageMeter.proChat')}</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowModal(false)}
|
|
className="flex-1 px-3 py-2 text-[12px] rounded-xl border border-border hover:bg-foreground/5 transition-colors"
|
|
>
|
|
{t('usageMeter.later')}
|
|
</button>
|
|
<a
|
|
href="/settings/billing"
|
|
className="flex-1 px-3 py-2 text-[12px] font-medium rounded-xl bg-brand-accent text-white text-center hover:opacity-90 transition-colors"
|
|
>
|
|
{t('usageMeter.upgradePricing')}
|
|
</a>
|
|
</div>
|
|
<a
|
|
href="/settings/ai#byok"
|
|
onClick={() => setShowModal(false)}
|
|
className="w-full px-3 py-2 text-[12px] font-medium rounded-xl border border-brand-accent/40 text-brand-accent dark:text-brand-accent text-center hover:bg-brand-accent/10 transition-colors"
|
|
>
|
|
{t('usageMeter.addApiKey')}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|