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
113 lines
4.5 KiB
TypeScript
113 lines
4.5 KiB
TypeScript
'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';
|
|
|
|
interface InlinePaywallProps {
|
|
feature: string;
|
|
onDismiss: () => void;
|
|
}
|
|
|
|
export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
|
|
const { t } = useLanguage();
|
|
const router = useRouter();
|
|
const [timeLeft, setTimeLeft] = useState(10);
|
|
|
|
useEffect(() => {
|
|
if (timeLeft <= 0) {
|
|
onDismiss();
|
|
return;
|
|
}
|
|
const timer = setTimeout(() => {
|
|
setTimeLeft((prev) => prev - 1);
|
|
}, 1000);
|
|
return () => clearTimeout(timer);
|
|
}, [timeLeft, onDismiss]);
|
|
|
|
const handleUpgrade = () => {
|
|
router.push('/settings/billing');
|
|
onDismiss();
|
|
};
|
|
|
|
const handleByok = () => {
|
|
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',
|
|
};
|
|
|
|
const currentFeatureLabel = featureLabels[feature] || feature;
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.95, y: -10 }}
|
|
transition={{ duration: 0.3, ease: 'easeOut' }}
|
|
className="rounded-2xl border border-rose-200 dark:border-rose-900/40 bg-rose-50/60 dark:bg-rose-950/15 backdrop-blur-md p-6 space-y-4 shadow-lg"
|
|
>
|
|
<div className="flex items-start gap-4">
|
|
<div className="p-2.5 bg-rose-500/10 text-rose-500 rounded-xl shrink-0">
|
|
<AlertTriangle size={20} />
|
|
</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'}
|
|
</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)}
|
|
</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'}
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3 pt-2 sm:pt-0">
|
|
<button
|
|
type="button"
|
|
onClick={handleUpgrade}
|
|
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'}
|
|
<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é'}
|
|
</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">
|
|
<span>Fermeture dans {timeLeft}s</span>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
);
|
|
}
|