fix: brainstorm infinite loop, ghost cursor, embedding ::vector cast, semantic search, billing stats, usage meter accordion
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s

- Fix useBrainstormSocket: stable guestId via useRef, remove setState in cleanup
- Fix GhostCursor: direct DOM manipulation via refs, no useState re-renders
- Fix all SQL embedding queries: add ::vector cast on text columns
- Fix embedding truncation to 15000 chars (under 8192 token limit)
- Fix NoteEmbedding INSERT: remove non-existent updatedAt column
- Fix billing page: show all quota stats in grid instead of single metric
- Fix usage meter: accordion expand/collapse, per-feature detail
- Fix semantic search: rebuild 103 note embeddings, ::vector cast on vectorSearch
- Fix brainstorm expand/manual-idea/create: ::vector cast on embedding SQL
This commit is contained in:
Antigravity
2026-05-16 18:50:34 +00:00
parent ee8e2bda59
commit 8c7ca69640
117 changed files with 11732 additions and 834 deletions

View File

@@ -122,7 +122,7 @@ export function AdminSidebar({ className }: { className?: string }) {
</DropdownMenu>
<a
href="/"
href="/home"
className={cn(
'flex min-w-0 flex-1 items-center gap-2 rounded-xl px-2 py-1.5 transition-colors',
'hover:bg-white/40 dark:hover:bg-white/10'
@@ -143,7 +143,7 @@ export function AdminSidebar({ className }: { className?: string }) {
</div>
<a
href="/"
href="/home"
className={cn(
'flex items-center gap-3 rounded-xl px-4 py-2.5 text-[13px] font-medium transition-all',
'text-muted-foreground hover:bg-white/40 hover:text-foreground dark:hover:bg-white/10'

View File

@@ -205,7 +205,7 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="p-3 bg-muted rounded-xl group-hover:bg-foreground group-hover:text-background transition-all">
<div className="p-3 bg-muted rounded-xl group-hover:bg-brand-accent group-hover:text-white transition-all">
<Icon className="w-5 h-5" />
</div>
<div className="space-y-1">
@@ -224,7 +224,7 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
>
<div className="relative inline-flex items-center cursor-pointer">
<div className={`w-8 h-4 rounded-full transition-colors ${
agent.isEnabled ? 'bg-primary' : 'bg-muted-foreground/30'
agent.isEnabled ? 'bg-brand-accent' : 'bg-muted-foreground/30'
}`}>
<span className={`absolute top-0.5 left-[2px] bg-background border border-muted-foreground/30 rounded-full h-3 w-3 transition-all ${
agent.isEnabled ? 'translate-x-4' : ''
@@ -260,9 +260,9 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
<span className="uppercase tracking-tight">{t('agents.status.lastStatus')}</span>
{lastAction ? (
<span className={`flex items-center gap-1 ${
lastAction.status === 'success' ? 'text-primary'
lastAction.status === 'success' ? 'text-brand-accent'
: lastAction.status === 'failure' ? 'text-destructive'
: lastAction.status === 'running' ? 'text-primary'
: lastAction.status === 'running' ? 'text-brand-accent'
: 'text-muted-foreground'
}`}>
{lastAction.status === 'success' && <Activity className="w-2 h-2" />}

View File

@@ -353,7 +353,7 @@ export function AgentDetailView({
<div className="flex items-end justify-between">
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="p-4 bg-foreground text-background rounded-2xl shadow-xl shadow-foreground/10">
<div className="p-4 bg-brand-accent text-white rounded-2xl shadow-xl shadow-brand-accent/10">
<Icon className="w-8 h-8" />
</div>
<div className="space-y-1">
@@ -369,7 +369,7 @@ export function AgentDetailView({
{isNew ? t('agents.newBadge') : `ID: ${agent?.id?.slice(0, 8)}`}
</span>
{!isNew && agent?.isEnabled && (
<span className="text-[10px] font-bold uppercase tracking-widest px-2 py-1 bg-primary/10 text-primary rounded-md">
<span className="text-[10px] font-bold uppercase tracking-widest px-2 py-1 bg-brand-accent/10 text-brand-accent rounded-md">
{t('agents.actions.toggleOn')}
</span>
)}
@@ -386,7 +386,7 @@ export function AgentDetailView({
{successRate !== null && (
<div className="flex flex-col items-end gap-1">
<span className="opacity-40">Succès</span>
<span className="text-primary">{successRate}%</span>
<span className="text-brand-accent">{successRate}%</span>
</div>
)}
</div>
@@ -751,7 +751,7 @@ export function AgentDetailView({
<div className="space-y-10">
<section className="space-y-6">
<h3 className={sectionTitleCls}>{t('agents.form.frequency')}<FieldHelp tooltip={t('agents.help.tooltips.frequency')} /></h3>
<div className="bg-foreground rounded-3xl p-8 space-y-8 text-background shadow-2xl shadow-foreground/20 relative overflow-hidden">
<div className="bg-ink rounded-3xl p-8 space-y-8 text-paper shadow-2xl shadow-ink/20 relative overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-10">
<Clock className="w-24 h-24" />
</div>

View File

@@ -6,9 +6,8 @@ import { DefaultChatTransport } from 'ai'
import type { UIMessage } from 'ai'
import { cn } from '@/lib/utils'
import {
X, Bot, Sparkles, History, Send, Globe, Briefcase, Palette, GraduationCap, Coffee,
Loader2, Layers, Square, Plus, ChevronRight, MessageSquare, FileCode,
Zap, Network, Clock, Scissors, Languages, Layout, ArrowRightLeft, BookOpen,
X, Bot, Sparkles, History, Send, Globe,
Loader2, Square, Plus, MessageSquare, FileCode,
Maximize2, Minimize2
} from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
@@ -28,14 +27,7 @@ function getTextContent(msg: UIMessage): string {
return ''
}
const TONE_IDS = [
{ id: 'professional', icon: Briefcase },
{ id: 'creative', icon: Palette },
{ id: 'academic', icon: GraduationCap },
{ id: 'casual', icon: Coffee },
] as const
type AITab = 'discussion' | 'actions' | 'explore' | 'resources'
type AITab = 'discussion' | 'history'
export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: boolean } = {}) {
const { t, language } = useLanguage()
@@ -45,7 +37,6 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
const [isOpen, setIsOpen] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const [aiTab, setAiTab] = useState<AITab>('discussion')
const [selectedTone, setSelectedTone] = useState('professional')
const [webSearch, setWebSearch] = useState(false)
const [chatScope, setChatScope] = useState<'all' | string>('all')
const [input, setInput] = useState('')
@@ -55,9 +46,6 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
const [history, setHistory] = useState<any[]>([])
const [historyLoading, setHistoryLoading] = useState(false)
const [insights, setInsights] = useState<string>('')
const [insightsLoading, setInsightsLoading] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current
@@ -65,6 +53,23 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
transport,
onError: (error) => {
console.error('Chat error:', error)
try {
const parsed = JSON.parse((error as Error).message || '{}')
if (parsed.error === 'QUOTA_EXCEEDED') {
const isBasic = (parsed.currentTier || 'BASIC') === 'BASIC'
toast.error(
language === 'fr'
? isBasic
? 'Le chat IA est réservé au plan PRO et supérieur.'
: `Limite mensuelle atteinte pour le plan ${parsed.currentTier}. Elle se réinitialise le mois prochain.`
: isBasic
? 'AI Chat is available from the PRO plan onwards.'
: `Monthly quota reached for ${parsed.currentTier} plan. It will reset next month.`,
{ duration: 8000 }
)
return
}
} catch {}
toast.error(t('chat.assistantError') || 'Chat error')
}
})
@@ -91,7 +96,6 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
{ text },
{
body: {
tone: selectedTone,
chatScope,
notebookId: chatScope !== 'all' ? chatScope : undefined,
webSearch: webSearch && webSearchAvailable,
@@ -115,24 +119,6 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
setHistoryLoading(false)
}
const fetchInsights = async () => {
setInsightsLoading(true)
try {
const res = await fetch('/api/chat/insights')
if (res.ok) {
const data = await res.json()
setInsights(data.insight)
}
} catch (e) { console.error(e) }
setInsightsLoading(false)
}
useEffect(() => {
if (aiTab === 'discussion') {
// history is loaded on demand via insights tab
}
}, [aiTab])
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
@@ -203,6 +189,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
<button
onClick={() => setIsOpen(false)}
className="p-1.5 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg transition-colors text-concrete"
title={t('general.close') || 'Fermer'}
>
<X size={18} />
</button>
@@ -215,22 +202,28 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
{/* Tabs */}
<div className="flex border-b border-border px-2 shrink-0">
{(['discussion', 'actions', 'explore', 'resources'] as AITab[]).map((tab) => {
{(['discussion', 'history'] as AITab[]).map((tab) => {
const labels: Record<AITab, string> = {
discussion: t('ai.chatTab') || 'Discussion',
actions: t('ai.actionsTab') || 'Actions',
explore: t('ai.exploreTab') || 'Explorer',
resources: t('ai.resourcesTab') || 'Ressources',
history: t('ai.historyTab') || 'Historique',
}
const icons: Record<AITab, React.ReactNode> = {
discussion: <MessageSquare size={12} />,
history: <History size={12} />,
}
return (
<button
key={tab}
onClick={() => setAiTab(tab)}
onClick={() => {
setAiTab(tab)
if (tab === 'history') fetchHistory()
}}
className={cn(
"flex-1 py-3 text-[10px] uppercase tracking-[0.2em] font-bold transition-all relative",
"flex-1 py-3 text-[10px] uppercase tracking-[0.2em] font-bold transition-all relative flex items-center justify-center gap-1.5",
aiTab === tab ? 'text-brand-accent' : 'text-concrete hover:text-ink/60'
)}
>
{icons[tab]}
{labels[tab]}
{aiTab === tab && (
<motion.div
@@ -248,38 +241,15 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
<AnimatePresence mode="wait">
{aiTab === 'discussion' && (
<motion.div key="discussion" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="space-y-6">
{/* Context selector */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.writingTone')}</label>
<div className="flex items-center gap-1">
{TONE_IDS.map((tone) => {
const Icon = tone.icon
return (
<button
key={tone.id}
onClick={() => setSelectedTone(tone.id)}
title={t(`ai.tones.${tone.id}`)}
className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center text-[9px] font-bold transition-all border',
selectedTone === tone.id
? 'bg-brand-accent text-white border-brand-accent shadow-sm'
: 'bg-white/50 dark:bg-white/5 border-border/40 text-concrete hover:border-brand-accent/40'
)}
>
<Icon className="h-3.5 w-3.5" />
</button>
)
})}
</div>
</div>
{/* Scope selector */}
<div className="space-y-2">
<button
onClick={() => setChatScope('all')}
className={cn(
'w-full p-2.5 border rounded-xl text-[11px] flex items-center justify-between transition-all',
chatScope === 'all' ? 'bg-brand-accent/5 border-brand-accent/30' : 'bg-white/50 dark:bg-white/5 border-border/40 hover:border-ink/20'
)}
title={t('ai.allMyNotes') || 'Toutes mes notes'}
>
<div className="flex items-center gap-2.5">
<FileCode size={14} className="text-brand-accent/60" />
@@ -324,14 +294,14 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
<div key={msg.id} className={cn('flex gap-3', msg.role === 'user' && 'flex-row-reverse')}>
<div className={cn(
'w-8 h-8 rounded-xl flex items-center justify-center flex-shrink-0',
msg.role === 'user' ? 'bg-ink text-paper' : 'bg-brand-accent/10 text-brand-accent',
msg.role === 'user' ? 'bg-brand-accent text-white' : 'bg-brand-accent/10 text-brand-accent',
)}>
{msg.role === 'user' ? 'U' : <Bot className="h-4 w-4" />}
</div>
<div className={cn(
'max-w-[85%] p-3.5 rounded-2xl text-sm leading-relaxed',
msg.role === 'user'
? 'bg-ink text-paper rounded-tr-sm'
? 'bg-brand-accent text-white rounded-tr-sm'
: 'bg-white/60 dark:bg-white/5 border border-border rounded-tl-sm text-ink',
)}>
{msg.role === 'assistant' ? <MarkdownContent content={text} /> : <p>{text}</p>}
@@ -354,187 +324,36 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
</motion.div>
)}
{aiTab === 'actions' && (
<motion.div key="actions" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="space-y-8">
<div className="flex items-center gap-2 mb-2">
<div className="h-px flex-1 bg-border/40" />
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-concrete whitespace-nowrap">Transformations</h4>
<div className="h-px flex-1 bg-border/40" />
</div>
<div className="grid grid-cols-2 gap-2">
{[
{ icon: <Sparkles size={14} />, label: t('ai.actionClarify') || 'Clarifier' },
{ icon: <Scissors size={14} />, label: t('ai.actionShorten') || 'Raccourcir' },
{ icon: <Zap size={14} />, label: t('ai.actionImprove') || 'Améliorer' },
{ icon: <Languages size={14} />, label: t('ai.actionTranslate') || 'Traduire' },
].map((action, i) => (
<button key={i} className="flex flex-col items-center gap-3 p-4 bg-white/50 dark:bg-white/5 border border-border rounded-xl transition-all group hover:border-ink/20">
<div className="p-2 rounded-lg bg-slate-50 dark:bg-white/10 transition-colors group-hover:bg-brand-accent group-hover:text-white shadow-sm text-concrete">
{action.icon}
{aiTab === 'history' && (
<motion.div key="history" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="space-y-3">
{historyLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-concrete" />
</div>
) : history.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-concrete/30">
<History size={24} />
<p className="text-[11px] mt-3 italic">{t('ai.noHistory') || 'Aucun historique'}</p>
</div>
) : (
history.map((conv: any) => (
<button
key={conv.id}
onClick={() => { setConversationId(conv.id); setMessages(conv.messages || []); setAiTab('discussion') }}
className="w-full text-left p-4 bg-white/50 dark:bg-white/5 border border-border rounded-xl hover:border-brand-accent/30 transition-all group"
>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-brand-accent/10 text-brand-accent group-hover:bg-brand-accent group-hover:text-white transition-colors">
<MessageSquare size={14} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-ink truncate">{conv.title || conv.id}</p>
<p className="text-[10px] text-concrete mt-0.5">{new Date(conv.createdAt).toLocaleDateString()}</p>
</div>
</div>
<span className="text-[10px] font-bold text-ink/80 uppercase tracking-widest">{action.label}</span>
</button>
))}
<button className="col-span-2 flex items-center justify-center gap-3 py-3 px-4 bg-white/50 dark:bg-white/5 border border-border rounded-xl text-[10px] font-bold text-ink/80 hover:bg-white dark:hover:bg-white/10 transition-colors hover:border-ink/20 uppercase tracking-widest">
<FileCode size={14} className="text-concrete" />
{t('ai.actionMarkdown') || 'Convertir en Markdown'}
</button>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2 mb-2">
<div className="h-px flex-1 bg-border/40" />
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-concrete whitespace-nowrap">Generation Tools</h4>
<div className="h-px flex-1 bg-border/40" />
</div>
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Layout size={80} className="text-brand-accent" />
</div>
<div className="relative space-y-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-50 rounded-lg text-brand-accent"><Layout size={18} /></div>
<div className="space-y-0.5">
<h5 className="text-sm font-bold text-ink leading-none">{t('ai.slides') || 'Présentation'}</h5>
<p className="text-[10px] text-concrete uppercase tracking-tight">{t('ai.slidesDesc') || 'Convertir en slides interactives'}</p>
</div>
</div>
<button className="w-full py-3.5 bg-brand-accent text-white rounded-xl text-[10px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-brand-accent/20 uppercase tracking-[0.2em]">
{t('ai.generate') || 'Générer'} <ArrowRightLeft size={14} className="opacity-60" />
</button>
</div>
</div>
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-emerald-500/30 transition-all duration-500 overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<BookOpen size={80} className="text-emerald-500" />
</div>
<div className="relative space-y-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-50 rounded-lg text-emerald-500"><BookOpen size={18} /></div>
<div className="space-y-0.5">
<h5 className="text-sm font-bold text-ink leading-none">{t('ai.diagram') || 'Diagramme'}</h5>
<p className="text-[10px] text-concrete uppercase tracking-tight">{t('ai.diagramDesc') || 'Visualisation de structure'}</p>
</div>
</div>
<button className="w-full py-3.5 bg-emerald-600 text-white rounded-xl text-[10px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-emerald-600/20 uppercase tracking-[0.2em]">
{t('ai.trace') || 'Tracer'} <ArrowRightLeft size={14} className="opacity-60" />
</button>
</div>
</div>
</div>
<div className="flex flex-col items-center gap-2 opacity-20 py-4">
<History size={16} />
<span className="text-[10px] font-bold uppercase tracking-widest whitespace-nowrap">Auto-Save Enabled</span>
</div>
</motion.div>
)}
{aiTab === 'explore' && (
<motion.div key="explore" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="space-y-6">
<div className="flex items-center gap-2 mb-2">
<div className="h-px flex-1 bg-border/40" />
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-concrete whitespace-nowrap">Intelligence Modules</h4>
<div className="h-px flex-1 bg-border/40" />
</div>
<div className="space-y-3">
<button className="w-full group relative p-5 rounded-2xl bg-white border border-border hover:border-brand-accent/30 transition-all text-left overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Zap size={60} className="text-brand-accent" />
</div>
<div className="relative flex items-center gap-4">
<div className="p-3 bg-brand-accent/10 rounded-xl text-brand-accent group-hover:bg-brand-accent group-hover:text-white transition-colors">
<Zap size={20} />
</div>
<div>
<h5 className="font-bold text-ink text-sm">{t('ai.brainstormWave') || 'Brainstorm Wave'}</h5>
<p className="text-[10px] text-concrete uppercase tracking-tight">{t('ai.brainstormWaveDesc') || 'Unfold dimensions of thought'}</p>
</div>
</div>
</button>
<button className="w-full group relative p-5 rounded-2xl bg-white border border-border hover:border-indigo-500/30 transition-all text-left overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Network size={60} className="text-indigo-500" />
</div>
<div className="relative flex items-center gap-4">
<div className="p-3 bg-indigo-500/10 rounded-xl text-indigo-500 group-hover:bg-indigo-500 group-hover:text-white transition-colors">
<Network size={20} />
</div>
<div>
<h5 className="font-bold text-ink text-sm">{t('ai.semanticNetwork') || 'Réseau Sémantique'}</h5>
<p className="text-[10px] text-concrete uppercase tracking-tight">{t('ai.semanticNetworkDesc') || 'Detect clusters and bridges'}</p>
</div>
</div>
</button>
<button className="w-full group relative p-5 rounded-2xl bg-white border border-border hover:border-rose-500/30 transition-all text-left overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Clock size={60} className="text-rose-500" />
</div>
<div className="relative flex items-center gap-4">
<div className="p-3 bg-rose-500/10 rounded-xl text-rose-500 group-hover:bg-rose-500 group-hover:text-white transition-colors">
<Clock size={20} />
</div>
<div>
<h5 className="font-bold text-ink text-sm">{t('ai.temporalForecast') || 'Prévision Temporelle'}</h5>
<p className="text-[10px] text-concrete uppercase tracking-tight">{t('ai.temporalForecastDesc') || 'Predict relevance recurrence'}</p>
</div>
</div>
</button>
</div>
<div className="p-6 rounded-2xl bg-slate-50 dark:bg-white/5 border border-dashed border-border">
<p className="text-[10px] text-concrete leading-relaxed font-medium italic text-center">
{t('ai.modulesHint') || 'Ces modules utilisent les embeddings pour analyser vos pensées.'}
</p>
</div>
</motion.div>
)}
{aiTab === 'resources' && (
<motion.div key="resources" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.resourceUrl') || 'URL (Optionnel)'}</label>
<div className="relative">
<input type="text" placeholder="https://..." className="w-full bg-white/50 dark:bg-white/5 border border-border rounded-xl pl-4 pr-10 py-3 text-xs outline-none focus:border-brand-accent transition-colors text-ink" />
<Globe size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-concrete/40" />
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.resourceText') || 'Texte de la ressource'}</label>
<textarea
rows={8}
placeholder={t('ai.resourcePlaceholder') || 'Collez votre texte ici...'}
className="w-full bg-white/50 dark:bg-white/5 border border-border rounded-xl p-4 text-xs outline-none focus:border-brand-accent transition-colors resize-none leading-relaxed text-ink"
/>
</div>
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.integrationMode') || "Mode d'intégration"}</label>
<div className="grid grid-cols-3 gap-2">
{[
{ id: 'replace', label: t('ai.modeReplace') || 'Remplacer', sub: 'Direct' },
{ id: 'append', label: t('ai.modeAppend') || 'Compléter', sub: 'Ajoute' },
{ id: 'merge', label: t('ai.modeMerge') || 'Fusionner', sub: 'Intègre' },
].map((mode) => (
<button key={mode.id} className="flex flex-col items-center justify-center p-3 rounded-xl border transition-all text-center bg-white border-border hover:bg-slate-50 dark:hover:bg-white/5">
<span className="text-[10px] font-bold text-ink">{mode.label}</span>
<span className="text-[8px] text-concrete opacity-60 leading-tight mt-1 font-medium">{mode.sub}</span>
</button>
))}
</div>
</div>
<button className="w-full py-4 bg-brand-accent text-white rounded-xl text-[11px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-3 hover:opacity-90 transition-opacity shadow-lg shadow-brand-accent/20">
<Sparkles size={18} />
{t('ai.generatePreview') || "Générer l'aperçu"}
</button>
))
)}
</motion.div>
)}
</AnimatePresence>
@@ -561,6 +380,15 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
}}
disabled={isLoading}
/>
<div className="absolute left-6 bottom-4 flex gap-3 text-concrete/40">
<button
onClick={() => webSearchAvailable && setWebSearch(!webSearch)}
className={cn("hover:text-brand-accent transition-colors", webSearch && "text-brand-accent")}
title={webSearch ? (t('ai.webSearchEnabled') || 'Recherche web activée') : (t('ai.webSearchDisabled') || 'Activer la recherche web')}
>
<Globe size={14} />
</button>
</div>
<div className="absolute right-4 bottom-4 flex flex-col gap-2">
{isLoading ? (
<button onClick={() => stop()} className="p-2.5 bg-rose-500 text-white rounded-xl transition-all hover:scale-110 active:scale-95 shadow-lg shadow-rose-500/20">
@@ -572,18 +400,6 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
</button>
)}
</div>
<div className="absolute left-6 bottom-4 flex gap-3 text-concrete/40">
<button
onClick={() => webSearchAvailable && setWebSearch(!webSearch)}
className={cn("hover:text-brand-accent transition-colors", webSearch && "text-brand-accent")}
>
<Globe size={14} />
</button>
<button className="hover:text-brand-accent transition-colors"><Network size={14} /></button>
</div>
</div>
<div className="flex justify-center mt-4">
<p className="text-[9px] text-concrete/40 uppercase tracking-[0.3em] font-bold">Shift+Enter for new line</p>
</div>
</motion.div>
)}

View File

@@ -1,7 +1,7 @@
'use client'
import { useState, useCallback } from 'react'
import { PageEntry } from '@/components/page-entry'
import dynamic from 'next/dynamic'
import type { Note } from '@/lib/types'
import { NotesEditorialView } from '@/components/notes-editorial-view'
@@ -41,11 +41,9 @@ export function ArchiveClient({ notes }: ArchiveClientProps) {
}
return (
<PageEntry>
<NotesEditorialView
notes={notes}
onOpen={handleOpen}
/>
</PageEntry>
)
}

View File

@@ -213,7 +213,7 @@ export function BrainstormPage() {
setConvertToast({ noteTitle: result.title || idea.title, noteId: result.id })
setTimeout(() => {
setConvertToast(null)
router.push(`/?openNote=${result.id}`)
router.push(`/home?openNote=${result.id}`)
}, 2000)
}
} catch {}
@@ -228,7 +228,7 @@ export function BrainstormPage() {
setExportToast({ noteTitle: result.title || t('brainstorm.exportDefaultNoteTitle'), notebookName })
setTimeout(() => {
setExportToast(null)
router.push(`/?openNote=${result.id}`)
router.push(`/home?openNote=${result.id}`)
}, 2000)
return
}
@@ -634,7 +634,7 @@ export function BrainstormPage() {
</div>
{ref.noteId && (
<button
onClick={() => router.push(`/?openNote=${ref.noteId}`)}
onClick={() => router.push(`/home?openNote=${ref.noteId}`)}
className="shrink-0 px-2 py-1 text-[9px] font-bold uppercase tracking-wider rounded-lg bg-foreground/5 hover:bg-foreground/10 text-muted-foreground hover:text-foreground transition-all"
>
{t('brainstorm.viewNote') || 'View'}

View File

@@ -137,8 +137,8 @@ export function BrainstormShareDialog({
<DialogContent className="sm:max-w-md bg-white dark:bg-[#1A1A1A] border-border rounded-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-foreground">
<div className="w-7 h-7 rounded-lg bg-orange-500/10 flex items-center justify-center">
<UserPlus size={14} className="text-orange-500" />
<div className="w-7 h-7 rounded-lg bg-brand-accent/10 flex items-center justify-center">
<UserPlus size={14} className="text-brand-accent" />
</div>
<span className="font-serif">{t('brainstorm.shareDialogTitle')}</span>
</DialogTitle>
@@ -159,7 +159,7 @@ export function BrainstormShareDialog({
onFocus={() => results.length > 0 && setShowDropdown(true)}
onBlur={() => setTimeout(() => setShowDropdown(false), 150)}
placeholder={t('brainstorm.shareNameOrEmailPlaceholder')}
className="w-full px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-orange-500/20 focus:border-orange-500/40 transition-all"
className="w-full px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/40 transition-all"
autoFocus
/>
@@ -173,9 +173,9 @@ export function BrainstormShareDialog({
e.preventDefault()
selectUser(user)
}}
className="w-full px-4 py-2.5 flex items-center gap-3 hover:bg-orange-500/5 transition-colors text-left"
className="w-full px-4 py-2.5 flex items-center gap-3 hover:bg-brand-accent/5 transition-colors text-left"
>
<div className="w-8 h-8 rounded-full bg-orange-500/10 flex items-center justify-center text-xs font-bold text-orange-600 shrink-0">
<div className="w-8 h-8 rounded-full bg-brand-accent/10 flex items-center justify-center text-xs font-bold text-brand-accent shrink-0">
{(user.name || user.email).charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
@@ -214,7 +214,7 @@ export function BrainstormShareDialog({
<button
type="submit"
disabled={!query.trim() || isPending}
className="w-full py-3 bg-orange-500 hover:bg-orange-600 text-white text-[10px] font-bold uppercase tracking-[0.15em] rounded-xl disabled:opacity-50 transition-all flex items-center justify-center gap-1.5"
className="w-full py-3 bg-brand-accent hover:bg-brand-accent/90 text-white text-[10px] font-bold uppercase tracking-[0.15em] rounded-xl disabled:opacity-50 transition-all flex items-center justify-center gap-1.5"
>
<UserPlus size={12} />
{isPending ? t('brainstorm.shareSubmitting') : t('brainstorm.shareSubmit')}

View File

@@ -1,6 +1,6 @@
'use client'
import React, { useEffect, useState, useRef } from 'react'
import React, { useEffect, useRef } from 'react'
import { motion, AnimatePresence } from 'motion/react'
interface GhostCursorProps {
@@ -10,114 +10,132 @@ interface GhostCursorProps {
}
export function GhostCursor({ isActive, containerRef, targetId }: GhostCursorProps) {
const [position, setPosition] = useState({ x: 0, y: 0 })
const [visible, setVisible] = useState(false)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const positionRef = useRef({ x: 0, y: 0 })
const visibleRef = useRef(false)
const elRef = useRef<HTMLDivElement>(null)
const rafRef = useRef<number | null>(null)
const targetRef = useRef({ x: 0, y: 0 })
const initializedRef = useRef(false)
const targetIdRef = useRef(targetId)
targetIdRef.current = targetId
const isActiveRef = useRef(isActive)
isActiveRef.current = isActive
useEffect(() => {
if (!isActive) {
setVisible(false)
if (intervalRef.current) clearInterval(intervalRef.current)
visibleRef.current = false
initializedRef.current = false
if (elRef.current) elRef.current.style.opacity = '0'
if (rafRef.current) cancelAnimationFrame(rafRef.current)
return
}
const container = containerRef.current
if (!container) return
setVisible(true)
// Initialisation sécurisée
const initialRect = container.getBoundingClientRect()
const initialCx = initialRect.width > 0 ? initialRect.width / 2 : window.innerWidth / 2
const initialCy = initialRect.height > 0 ? initialRect.height / 2 : window.innerHeight / 2
setPosition({
x: initialCx + (Math.random() - 0.5) * 200,
y: initialCy + (Math.random() - 0.5) * 200,
})
let angle = Math.random() * Math.PI * 2
let targetX = initialCx + Math.cos(angle) * 250
let targetY = initialCy + Math.sin(angle) * 250
intervalRef.current = setInterval(() => {
// Recalculer les dimensions à chaque tick pour s'adapter aux redimensionnements
const currentContainer = containerRef.current
if (!currentContainer) return
const init = () => {
const container = containerRef.current
if (!container) { rafRef.current = requestAnimationFrame(init); return }
const rect = container.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) { rafRef.current = requestAnimationFrame(init); return }
const cx = rect.width / 2
const cy = rect.height / 2
targetRef.current = { x: cx + (Math.random() - 0.5) * 200, y: cy + (Math.random() - 0.5) * 200 }
positionRef.current = { ...targetRef.current }
initializedRef.current = true
visibleRef.current = true
if (elRef.current) {
elRef.current.style.opacity = '1'
elRef.current.style.transform = `translate(${positionRef.current.x}px, ${positionRef.current.y}px)`
}
}
rafRef.current = requestAnimationFrame(init)
const tick = () => {
if (!isActiveRef.current || !initializedRef.current) {
rafRef.current = requestAnimationFrame(tick)
return
}
const container = containerRef.current
if (!container) { rafRef.current = requestAnimationFrame(tick); return }
const containerRect = container.getBoundingClientRect()
if (containerRect.width === 0 || containerRect.height === 0) { rafRef.current = requestAnimationFrame(tick); return }
const containerRect = currentContainer.getBoundingClientRect()
const cx = containerRect.width / 2
const cy = containerRect.height / 2
let tx = targetRef.current.x
let ty = targetRef.current.y
let currentTargetX = targetX;
let currentTargetY = targetY;
if (targetId) {
const nodeElement = document.querySelector(`[data-id="${targetId}"]`);
const currentTargetId = targetIdRef.current
if (currentTargetId) {
const nodeElement = container.querySelector(`[data-id="${currentTargetId}"]`) as HTMLElement | null
if (nodeElement) {
const nodeRect = nodeElement.getBoundingClientRect();
currentTargetX = nodeRect.left - containerRect.left + nodeRect.width / 2;
currentTargetY = nodeRect.top - containerRect.top + nodeRect.height / 2;
const nodeRect = nodeElement.getBoundingClientRect()
tx = nodeRect.left - containerRect.left + nodeRect.width / 2
ty = nodeRect.top - containerRect.top + nodeRect.height / 2
}
}
setPosition(prev => {
const dx = currentTargetX - prev.x
const dy = currentTargetY - prev.y
const dist = Math.sqrt(dx * dx + dy * dy)
const prev = positionRef.current
const dx = tx - prev.x
const dy = ty - prev.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (!targetId && dist < 20) {
angle = Math.random() * Math.PI * 2
const radius = 150 + Math.random() * 200
targetX = cx + Math.cos(angle) * radius
targetY = cy + Math.sin(angle) * radius
}
if (!currentTargetId && dist < 20) {
angle = Math.random() * Math.PI * 2
const radius = 150 + Math.random() * 200
tx = cx + Math.cos(angle) * radius
ty = cy + Math.sin(angle) * radius
targetRef.current = { x: tx, y: ty }
}
const speed = targetId ? 0.15 : 0.06;
let newX = prev.x + dx * speed;
let newY = prev.y + dy * speed;
// Protection Anti-NaN qui bloquait le curseur en haut à gauche
if (isNaN(newX)) newX = cx;
if (isNaN(newY)) newY = cy;
const speed = currentTargetId ? 0.12 : 0.04
let newX = prev.x + (tx - prev.x) * speed
let newY = prev.y + (ty - prev.y) * speed
if (isNaN(newX)) newX = cx
if (isNaN(newY)) newY = cy
return {
x: newX,
y: newY,
}
})
}, 50)
positionRef.current = { x: newX, y: newY }
if (elRef.current) {
elRef.current.style.transform = `translate(${newX}px, ${newY}px)`
}
rafRef.current = requestAnimationFrame(tick)
}
rafRef.current = requestAnimationFrame(tick)
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
if (rafRef.current) cancelAnimationFrame(rafRef.current)
}
}, [isActive, containerRef, targetId])
}, [isActive, containerRef])
return (
<AnimatePresence>
{visible && (
<motion.div
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
className="absolute pointer-events-none z-50"
style={{ transform: `translate(${position.x}px, ${position.y}px)` }}
>
<div className="relative">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M0 0L16 6L8 8L6 16L0 0Z" fill="#a78bfa" />
</svg>
<div className="absolute -top-1 -right-1 w-3 h-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-violet-400 opacity-50" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-violet-500" />
</div>
<div className="mt-3 ml-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white whitespace-nowrap shadow-lg bg-gradient-to-r from-violet-500 to-purple-600">
AI
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<div
ref={elRef}
className="absolute pointer-events-none z-50"
style={{
left: 0,
top: 0,
opacity: 0,
transition: 'opacity 0.3s ease',
}}
>
<div className="relative">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M0 0L16 6L8 8L6 16L0 0Z" fill="#a78bfa" />
</svg>
<div className="absolute -top-1 -right-1 w-3 h-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-violet-400 opacity-50" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-violet-500" />
</div>
<div className="mt-3 ml-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white whitespace-nowrap shadow-lg bg-gradient-to-r from-violet-500 to-purple-600">
AI
</div>
</div>
</div>
)
}

View File

@@ -564,16 +564,16 @@ export function ContextualAIChat({
<div className="p-6 border-b border-border/60 space-y-1.5 bg-white/50 dark:bg-black/20 backdrop-blur-md shrink-0">
<div className="flex items-center justify-between">
<div>
<div className="min-w-0 flex-1">
<h3 className="flex items-center gap-2 font-serif text-xl font-medium text-ink">
<Sparkles size={18} className="text-brand-accent" />
<Sparkles size={18} className="text-brand-accent shrink-0" />
{t('ai.assistantTitle') || 'IA Assistant'}
</h3>
<p className="text-[11px] text-concrete uppercase tracking-wider font-medium opacity-60 truncate">
"{noteTitle || t('ai.currentNote')}"
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<div className="flex items-center gap-1 shrink-0 ml-2">
<button
onClick={() => setExpanded(e => !e)}
className="p-1.5 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg transition-colors text-concrete"
@@ -584,6 +584,7 @@ export function ContextualAIChat({
<button
onClick={onClose}
className="p-1.5 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg transition-colors text-concrete"
title={t('general.close') || 'Fermer'}
>
<X size={18} />
</button>

View File

@@ -40,7 +40,10 @@ export function HierarchicalNotebookSelector({
const getDropdownStyle = useCallback((): React.CSSProperties => {
if (!triggerRef.current) return { position: 'fixed', top: 0, left: 0, width: 280, zIndex: 9999 }
const rect = triggerRef.current.getBoundingClientRect()
if (dropUp) {
const dropdownHeight = 350
const viewportHeight = window.innerHeight
const wouldOverflowBottom = rect.bottom + 8 + dropdownHeight > viewportHeight
if (dropUp || wouldOverflowBottom) {
return {
position: 'fixed',
bottom: window.innerHeight - rect.top + 8,

View File

@@ -23,7 +23,7 @@ import { NoteHistoryModal } from '@/components/note-history-modal'
import { CreateNotebookDialog } from '@/components/create-notebook-dialog'
import { toast } from 'sonner'
import { AnimatePresence, motion } from 'motion/react'
import { PageEntry } from '@/components/page-entry'
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
@@ -111,7 +111,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
setEditingNote(null)
const params = new URLSearchParams(searchParams.toString())
params.delete('forceList')
const newUrl = params.toString() ? `/?${params.toString()}` : '/'
const newUrl = params.toString() ? `/home?${params.toString()}` : '/home'
router.replace(newUrl, { scroll: false })
}
}, [searchParams, router])
@@ -441,7 +441,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const params = new URLSearchParams(searchParams.toString())
params.delete('openNote')
const qs = params.toString()
router.replace(qs ? `/?${qs}` : '/', { scroll: false })
router.replace(qs ? `/home?${qs}` : '/home', { scroll: false })
// Invalidate notes cache and trigger refresh
refreshNotes(searchParams.get('notebook') || null)
}, [refreshNotes, router, searchParams])
@@ -457,7 +457,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
}, [refreshNotes])
return (
<PageEntry>
<div
className={cn(
'flex w-full min-h-0 flex-1 flex-col gap-3 py-1'
@@ -543,7 +542,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
} else {
params.delete('search')
}
router.push(`/?${params.toString()}`)
router.push(`/home?${params.toString()}`)
}}
onBlur={() => {
if (!inlineSearchQuery) {
@@ -556,7 +555,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
setInlineSearchQuery('')
const params = new URLSearchParams(searchParams.toString())
params.delete('search')
router.push(`/?${params.toString()}`)
router.push(`/home?${params.toString()}`)
}
}}
placeholder={t('search.placeholder') || 'Rechercher...'}
@@ -569,7 +568,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
setInlineSearchQuery('')
const params = new URLSearchParams(searchParams.toString())
params.delete('search')
router.push(`/?${params.toString()}`)
router.push(`/home?${params.toString()}`)
}}
className="text-muted-foreground hover:text-foreground transition-colors"
>
@@ -837,6 +836,5 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
/>
)}
</div>
</PageEntry>
)
}

View File

@@ -100,10 +100,10 @@ export function LabHeader({ canvases, currentCanvasId, onCreateCanvas }: LabHead
<ChevronDown className="h-4 w-4 text-muted-foreground ms-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[280px] p-2 rounded-2xl shadow-xl border-muted/20">
<DropdownMenuContent align="start" className="w-[280px] p-2 rounded-2xl shadow-xl border-border/40 bg-paper dark:bg-dark-paper">
<DropdownMenuLabel className="text-xs text-muted-foreground px-2 py-1.5 flex justify-between items-center">
{t('labHeader.yourSpaces')}
<span className="text-[10px] bg-muted px-1.5 py-0.5 rounded-full font-mono">{canvases.length}</span>
<span className="text-[10px] bg-brand-accent/10 text-brand-accent px-1.5 py-0.5 rounded-full font-mono">{canvases.length}</span>
</DropdownMenuLabel>
<div className="space-y-1 mt-1">
{canvases.map(c => (
@@ -111,13 +111,13 @@ export function LabHeader({ canvases, currentCanvasId, onCreateCanvas }: LabHead
<DropdownMenuItem
className={cn(
"flex-1 flex flex-col items-start gap-0.5 rounded-xl cursor-pointer p-3 transition-all",
c.id === currentCanvasId ? "bg-primary/5 text-primary border border-primary/20" : "hover:bg-muted"
c.id === currentCanvasId ? "bg-brand-accent/5 text-brand-accent border border-brand-accent/20" : "hover:bg-muted/50"
)}
onClick={() => router.push(`/lab?id=${c.id}`)}
>
<div className="flex items-center gap-2 w-full justify-between">
<span className="font-semibold text-sm">{c.name}</span>
{c.id === currentCanvasId && <span className="w-2 h-2 rounded-full bg-primary" />}
{c.id === currentCanvasId && <span className="w-2 h-2 rounded-full bg-brand-accent" />}
</div>
<span className="text-[10px] text-muted-foreground">{t('labHeader.updated')} {new Date(c.updatedAt).toLocaleDateString()}</span>
</DropdownMenuItem>
@@ -139,7 +139,7 @@ export function LabHeader({ canvases, currentCanvasId, onCreateCanvas }: LabHead
<DropdownMenuItem
onClick={handleCreate}
disabled={isPending}
className="flex items-center gap-2 text-primary font-medium p-3 rounded-xl cursor-pointer hover:bg-primary/5"
className="flex items-center gap-2 text-brand-accent font-medium p-3 rounded-xl cursor-pointer hover:bg-brand-accent/5"
>
<Plus className="h-4 w-4" />
{t('labHeader.newSpace')}

View File

@@ -0,0 +1,412 @@
'use client'
import { motion } from 'motion/react'
import {
BrainCircuit, Search, MessageSquare, Zap, Cpu, Workflow,
Globe, Shield, ArrowRight, Sparkles, Layers, Activity,
Box
} from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useLanguage } from '@/lib/i18n'
import { useState } from 'react'
export function LandingPage() {
const { t } = useLanguage()
const router = useRouter()
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly')
const AGENTS = [
{ key: 'scraper', icon: <Globe size={24} /> },
{ key: 'researcher', icon: <Search size={24} /> },
{ key: 'slideGen', icon: <Layers size={24} /> },
{ key: 'monitor', icon: <Activity size={24} /> },
{ key: 'diagramGen', icon: <Box size={24} /> },
{ key: 'custom', icon: <Workflow size={24} /> },
]
const BRAINSTORM_ITEMS = ['waveGeneration', 'collaboration', 'export']
const PLANS = [
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' },
{ key: 'pro', popular: true, price: billingInterval === 'monthly' ? '9,90€' : '7,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
{ key: 'business', popular: false, price: billingInterval === 'monthly' ? '29,90€' : '23,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
{ key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') },
]
const TECH_TIERS = [
{ key: 'tags', color: 'bg-brand-accent' },
{ key: 'embeddings', color: 'bg-ochre' },
{ key: 'chatRag', color: 'bg-ink' },
]
const FOOTER_SECTIONS = ['product', 'community', 'legal'] as const
return (
<div className="min-h-screen bg-paper text-ink font-sans selection:bg-ochre/30 selection:text-ink">
{/* Navigation */}
<nav className="fixed top-0 left-0 right-0 z-[100] bg-paper/80 backdrop-blur-md border-b border-border px-8 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-ink flex items-center justify-center rounded-xl shadow-lg rotate-3 group hover:rotate-0 transition-transform cursor-pointer">
<span className="text-paper font-serif text-2xl font-bold">M</span>
</div>
<span className="font-serif text-2xl font-medium tracking-tight">Momento</span>
</div>
<div className="hidden md:flex items-center gap-10">
<a href="#features" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.features')}</a>
<a href="#agents" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.agents')}</a>
<a href="#brainstorm" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.brainstorm')}</a>
<a href="#pricing" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.pricing')}</a>
<a href="#tech" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.tech')}</a>
</div>
<div className="flex items-center gap-4">
<button onClick={() => router.push('/login')} className="hidden md:block px-6 py-2.5 text-concrete hover:text-ink text-[11px] font-bold uppercase tracking-widest transition-colors">
{t('landing.nav.login')}
</button>
<button onClick={() => router.push('/register')} className="px-6 py-2.5 bg-ink text-paper rounded-full text-[11px] font-bold uppercase tracking-widest hover:opacity-90 transition-all flex items-center gap-2 group shadow-xl shadow-ink/10">
{t('landing.nav.cta')}
<ArrowRight size={14} className="group-hover:translate-x-1 transition-transform" />
</button>
</div>
</nav>
{/* Hero */}
<section className="relative pt-40 pb-32 px-8 overflow-hidden">
<div className="absolute top-0 right-0 w-[800px] h-[800px] bg-ochre/5 rounded-full blur-[120px] -translate-y-1/2 translate-x-1/4 -z-10" />
<div className="absolute bottom-0 left-0 w-[600px] h-[600px] bg-brand-accent/5 rounded-full blur-[100px] translate-y-1/2 -translate-x-1/4 -z-10" />
<div className="max-w-6xl mx-auto text-center">
<motion.div initial={{ opacity: 0, y: 30 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease: [0.23, 1, 0.32, 1] }}>
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-ochre/10 border border-ochre/20 text-ochre text-[10px] font-bold uppercase tracking-[0.2em] mb-8">
<Sparkles size={12} />
{t('landing.hero.badge')}
</div>
<h1 className="text-6xl md:text-8xl font-serif font-medium tracking-tight text-ink mb-8 leading-[1.1]">
{t('landing.hero.title1')} <br />
<span className="italic">{t('landing.hero.title2')}</span>
</h1>
<p className="max-w-2xl mx-auto text-lg md:text-xl text-concrete font-light leading-relaxed mb-12">
{t('landing.hero.subtitle')}
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<button onClick={() => router.push('/register')} className="px-10 py-5 bg-ink text-paper rounded-2xl text-sm font-bold uppercase tracking-[0.2em] hover:opacity-95 transition-all shadow-2xl shadow-ink/20 flex items-center gap-4 group">
{t('landing.hero.cta')}
<ArrowRight size={18} className="group-hover:translate-x-1 transition-transform" />
</button>
<a href="#features" className="px-10 py-5 border border-border rounded-2xl text-sm font-bold uppercase tracking-[0.2em] hover:bg-slate-50 transition-all">
{t('landing.hero.secondary')}
</a>
</div>
</motion.div>
{/* App Preview Mockup */}
<motion.div initial={{ opacity: 0, y: 100 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 1, delay: 0.2, ease: [0.23, 1, 0.32, 1] }} className="mt-24 relative">
<div className="relative mx-auto max-w-5xl aspect-[16/10] bg-white rounded-[32px] shadow-[0_40px_100px_-20px_rgba(0,0,0,0.15)] border border-border p-4 overflow-hidden group">
<img src="/images/workspace-hero.jpg" alt="Momento Workspace" className="w-full h-full object-cover rounded-2xl filter saturate-[0.8]" />
<div className="absolute inset-0 bg-ink/10 group-hover:bg-ink/0 transition-colors duration-500" />
<div className="absolute top-10 right-10 w-64 bg-paper/90 backdrop-blur-xl border border-border p-6 rounded-2xl shadow-2xl">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-full bg-brand-accent/20 flex items-center justify-center text-brand-accent">
<BrainCircuit size={16} />
</div>
<span className="text-[10px] font-bold uppercase tracking-widest">{t('landing.hero.memoryEcho')}</span>
</div>
<p className="text-xs font-serif italic text-ink/70">{t('landing.hero.memoryEchoText')}</p>
</div>
<div className="absolute bottom-10 left-10 w-72 bg-ink text-paper p-6 rounded-2xl shadow-2xl">
<div className="flex items-center gap-3 mb-4">
<Activity size={16} className="text-ochre" />
<span className="text-[10px] font-bold uppercase tracking-widest text-ochre">{t('landing.hero.brainstormLive')}</span>
</div>
<div className="flex items-center -space-x-2">
{[1, 2, 3].map(i => (
<div key={i} className="w-6 h-6 rounded-full border-2 border-ink bg-concrete text-[8px] flex items-center justify-center font-bold">JD</div>
))}
<span className="text-[10px] ml-4 text-paper/60">{t('landing.hero.ideasGenerated')}</span>
</div>
</div>
</div>
</motion.div>
</div>
</section>
{/* Features */}
<section id="features" className="py-32 px-8 bg-paper">
<div className="max-w-6xl mx-auto">
<div className="mb-24 flex flex-col md:flex-row md:items-end justify-between gap-8">
<div className="max-w-2xl">
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.features.label')}</span>
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink">{t('landing.features.title')} <br />{t('landing.features.title2')}</h2>
</div>
<div className="text-concrete font-light">{t('landing.features.desc')}</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
{['f1', 'f2', 'f3'].map((f, i) => {
const icons = [
<Search key="s" className="text-brand-accent" />,
<MessageSquare key="m" className="text-ochre" />,
<Zap key="z" className="text-ink" />
]
return (
<div key={f} className="group">
<div className="w-14 h-14 bg-slate-50 border border-border rounded-2xl flex items-center justify-center mb-8 group-hover:bg-ink group-hover:text-paper transition-all duration-300">
{icons[i]}
</div>
<h3 className="text-xl font-serif font-medium mb-4">{t(`landing.features.${f}Title`)}</h3>
<p className="text-sm text-concrete leading-relaxed font-light">{t(`landing.features.${f}Desc`)}</p>
</div>
)
})}
</div>
</div>
</section>
{/* Agents */}
<section id="agents" className="py-32 px-8 bg-ink text-paper overflow-hidden relative">
<div className="absolute top-0 right-0 w-[1000px] h-[1000px] bg-brand-accent/10 rounded-full blur-[150px] -translate-y-1/2 translate-x-1/2" />
<div className="max-w-6xl mx-auto relative z-10">
<div className="text-center mb-24">
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.agents.label')}</span>
<h2 className="text-4xl md:text-6xl font-serif tracking-tight mb-8">{t('landing.agents.title')}</h2>
<p className="text-paper/60 max-w-xl mx-auto font-light">{t('landing.agents.desc')}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{AGENTS.map((agent, i) => (
<div key={i} className="p-8 rounded-3xl bg-white/5 border border-white/10 hover:bg-white/10 transition-all cursor-default group">
<div className="text-ochre mb-6 transition-transform group-hover:scale-110 duration-300">{agent.icon}</div>
<h4 className="text-xl font-serif font-medium mb-4">{t(`landing.agents.${agent.key}.title`)}</h4>
<p className="text-sm text-paper/50 leading-relaxed font-light">{t(`landing.agents.${agent.key}.desc`)}</p>
</div>
))}
</div>
</div>
</section>
{/* Brainstorm */}
<section id="brainstorm" className="py-32 px-8 bg-paper">
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center gap-24">
<div className="flex-1">
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.brainstorm.label')}</span>
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink mb-8 leading-tight">{t('landing.brainstorm.title')}</h2>
<div className="space-y-8">
{BRAINSTORM_ITEMS.map((item, i) => (
<div key={item} className="flex gap-6">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-ochre/10 text-ochre flex items-center justify-center font-bold text-xs">{i + 1}</div>
<div>
<h5 className="font-bold text-sm mb-1">{t(`landing.brainstorm.${item}.title`)}</h5>
<p className="text-sm text-concrete font-light leading-relaxed">{t(`landing.brainstorm.${item}.desc`)}</p>
</div>
</div>
))}
</div>
</div>
<div className="flex-1 relative">
<div className="w-[450px] h-[450px] border-2 border-dashed border-border rounded-full flex items-center justify-center relative">
<div className="absolute top-0 right-1/2 translate-x-1/2 -translate-y-1/2 w-4 h-4 bg-ink rounded-full" />
<div className="absolute bottom-0 right-1/2 translate-x-1/2 translate-y-1/2 w-4 h-4 bg-ochre rounded-full" />
<div className="w-[300px] h-[300px] border-2 border-dashed border-border rounded-full flex items-center justify-center">
<div className="w-[150px] h-[150px] border-2 border-dashed border-border rounded-full flex items-center justify-center">
<div className="w-12 h-12 bg-ink rounded-xl shadow-2xl flex items-center justify-center text-paper font-serif text-xl">M</div>
</div>
</div>
</div>
<div className="absolute top-10 right-0 p-4 bg-white border border-border rounded-xl shadow-xl">
<p className="text-[10px] font-bold text-ochre">{t('landing.brainstorm.disruptionLabel')}</p>
<p className="text-xs font-serif italic text-ink">{t('landing.brainstorm.disruptionText')}</p>
</div>
<div className="absolute bottom-20 -left-10 p-4 bg-white border border-border rounded-xl shadow-xl">
<p className="text-[10px] font-bold text-brand-accent">{t('landing.brainstorm.analogyLabel')}</p>
<p className="text-xs font-serif italic text-ink">{t('landing.brainstorm.analogyText')}</p>
</div>
</div>
</div>
</section>
{/* Tech */}
<section id="tech" className="py-32 px-8 bg-slate-50 border-y border-border">
<div className="max-w-6xl mx-auto text-center">
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.tech.label')}</span>
<h2 className="text-4xl font-serif tracking-tight mb-16">{t('landing.tech.title')}</h2>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-8 grayscale opacity-50 hover:grayscale-0 hover:opacity-100 transition-all duration-700">
{['OpenAI', 'Google', 'Anthropic', 'DeepSeek', 'Mistral', 'Meta', 'Ollama', 'Groq', 'X.AI', 'Custom'].map((brand, i) => (
<div key={i} className="flex flex-col items-center gap-3">
<div className="w-12 h-12 bg-white rounded-xl border border-border flex items-center justify-center text-xs font-black tracking-tighter">
{brand.slice(0, 2).toUpperCase()}
</div>
<span className="text-[10px] font-bold uppercase tracking-widest">{brand}</span>
</div>
))}
</div>
<div className="mt-24 max-w-2xl mx-auto p-1 bg-white rounded-3xl border border-border shadow-sm flex flex-col md:flex-row gap-0.5">
{TECH_TIERS.map((tier, i) => (
<div key={i} className="flex-1 p-6 text-left">
<div className={`w-1.5 h-1.5 rounded-full ${tier.color} mb-4`} />
<h6 className="text-[10px] font-bold uppercase tracking-widest text-concrete mb-2">{t(`landing.tech.${tier.key}.title`)}</h6>
<p className="text-xs font-light text-concrete">{t(`landing.tech.${tier.key}.desc`)}</p>
</div>
))}
</div>
</div>
</section>
{/* Pricing */}
<section id="pricing" className="py-32 px-8 bg-paper">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-12">
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.pricing.label')}</span>
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink mb-6">{t('landing.pricing.title')}</h2>
<p className="text-concrete font-light max-w-xl mx-auto mb-12">{t('landing.pricing.desc')}</p>
<div className="flex items-center justify-center gap-10 mb-8">
<button onClick={() => setBillingInterval('monthly')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'monthly' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.monthly')}</span>
{billingInterval === 'monthly' && (
<motion.div layoutId="interval-active" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
)}
</button>
<div className="relative">
<button onClick={() => setBillingInterval('annual')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'annual' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.annual')}</span>
{billingInterval === 'annual' && (
<motion.div layoutId="interval-active" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
)}
</button>
<div className="absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap">
<span className="text-[9px] font-bold text-ochre uppercase tracking-widest italic animate-pulse">(-20%)</span>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch">
{PLANS.map((plan, i) => (
<div key={plan.key} className={`relative p-8 rounded-[32px] border flex flex-col transition-all duration-300 hover:shadow-2xl hover:shadow-ink/5 ${plan.popular ? 'bg-ink text-paper border-ink ring-4 ring-ochre/20' : 'bg-white border-border text-ink'}`}>
{plan.popular && (
<div className="absolute -top-4 left-1/2 -translate-x-1/2 px-4 py-1 bg-ochre text-ink text-[10px] font-bold uppercase tracking-widest rounded-full">
{t('landing.pricing.popular')}
</div>
)}
<div className="mb-8">
<h4 className="text-[11px] font-bold uppercase tracking-widest mb-2 opacity-60">{t(`landing.pricing.${plan.key}.name`)}</h4>
<div className="flex items-baseline gap-1 mb-4">
<span className="text-4xl font-serif font-medium">{plan.price}</span>
{plan.period && <span className="text-xs opacity-60">{plan.period}</span>}
</div>
<p className="text-sm font-light leading-relaxed opacity-80">{t(`landing.pricing.${plan.key}.desc`)}</p>
</div>
<div className="flex-1 space-y-4 mb-10">
{[0, 1, 2, 3, 4, 5].map(j => {
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
if (!feat || feat === `landing.pricing.${plan.key}.feature${j}`) return null
return (
<div key={j} className="flex items-start gap-3">
<div className={`mt-1 rounded-full p-0.5 ${plan.popular ? 'bg-ochre text-ink' : 'bg-brand-accent/10 text-brand-accent'}`}>
<Shield size={10} fill="currentColor" />
</div>
<span className="text-xs font-light">{feat}</span>
</div>
)
})}
</div>
<button onClick={() => router.push('/register')} className={`w-full py-4 rounded-2xl text-xs font-bold uppercase tracking-widest transition-all ${plan.popular ? 'bg-ochre text-ink hover:opacity-90' : 'bg-ink text-paper hover:bg-ink/90'}`}>
{t(`landing.pricing.${plan.key}.cta`)}
</button>
</div>
))}
</div>
{/* BYOK */}
<div className="mt-20 p-12 bg-slate-50 border border-border rounded-[40px] flex flex-col md:flex-row items-center gap-12">
<div className="flex-1">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand-accent/10 text-brand-accent text-[9px] font-bold uppercase tracking-widest mb-6">
<Cpu size={12} />
{t('landing.byok.label')}
</div>
<h3 className="text-3xl font-serif font-medium mb-4">{t('landing.byok.title')}</h3>
<p className="text-concrete font-light leading-relaxed mb-6">{t('landing.byok.desc')}</p>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-white rounded-2xl border border-border">
<h5 className="text-[10px] font-bold uppercase tracking-widest mb-2">{t('landing.byok.noLockin')}</h5>
<p className="text-[10px] text-concrete font-light">{t('landing.byok.noLockinDesc')}</p>
</div>
<div className="p-4 bg-white rounded-2xl border border-border">
<h5 className="text-[10px] font-bold uppercase tracking-widest mb-2">{t('landing.byok.cost')}</h5>
<p className="text-[10px] text-concrete font-light">{t('landing.byok.costDesc')}</p>
</div>
</div>
</div>
<div className="w-full md:w-[400px] bg-ink rounded-3xl p-8 relative overflow-hidden group">
<div className="absolute inset-0 bg-brand-accent/10 blur-[50px] group-hover:bg-ochre/10 transition-colors" />
<div className="relative z-10 font-mono text-[10px] text-paper/40 space-y-2">
<p className="text-ochre">{"{"}</p>
<p className="pl-4">"provider": "anthropic",</p>
<p className="pl-4">"model": "claude-3-opus",</p>
<p className="pl-4 border-l border-brand-accent/30 bg-brand-accent/5">"apiKey": "sk-ant-at03-..."</p>
<p className="pl-4">"useSystemKey": false</p>
<p className="text-ochre">{"}"}</p>
</div>
<div className="mt-8 flex items-center justify-between relative z-10">
<span className="text-[10px] font-bold text-paper uppercase tracking-widest">{t('landing.byok.configLabel')}</span>
<div className="flex gap-1">{[1, 2, 3].map(i => <div key={i} className="w-1 h-1 rounded-full bg-paper/20" />)}</div>
</div>
</div>
</div>
</div>
</section>
{/* Final CTA */}
<section className="py-40 px-8 text-center bg-paper relative overflow-hidden">
<div className="max-w-3xl mx-auto relative z-10">
<h2 className="text-5xl md:text-7xl font-serif tracking-tight mb-8 leading-tight">{t('landing.cta.title1')} <br /><span className="italic">{t('landing.cta.title2')}</span></h2>
<p className="text-lg text-concrete font-light mb-12">{t('landing.cta.desc')}</p>
<button onClick={() => router.push('/register')} className="px-16 py-6 bg-ink text-paper rounded-[32px] text-lg font-bold uppercase tracking-[0.2em] hover:scale-105 transition-all shadow-[0_30px_60px_-15px_rgba(0,0,0,0.3)]">
{t('landing.cta.button')}
</button>
</div>
</section>
{/* Footer */}
<footer className="py-20 px-8 border-t border-border bg-paper">
<div className="max-w-6xl mx-auto flex flex-col md:flex-row justify-between gap-12">
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-ink flex items-center justify-center rounded-lg">
<span className="text-paper font-serif text-lg font-bold">M</span>
</div>
<span className="font-serif text-xl font-medium tracking-tight">Momento</span>
</div>
<p className="text-sm text-concrete font-light max-w-xs">{t('landing.footer.desc')}</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-16">
{FOOTER_SECTIONS.map(section => (
<div key={section} className="space-y-4">
<h6 className="text-[10px] font-bold uppercase tracking-widest text-ink">{t(`landing.footer.${section}.title`)}</h6>
<ul className="space-y-2 text-sm text-concrete font-light">
{[0, 1, 2].map(j => {
const label = t(`landing.footer.${section}.link${j}`)
const href = t(`landing.footer.${section}.link${j}Href`)
if (!label || label.startsWith('landing.')) return null
return <li key={j}><a href={href} className="hover:text-ink">{label}</a></li>
})}
</ul>
</div>
))}
</div>
</div>
<div className="max-w-6xl mx-auto mt-20 pt-10 border-t border-border flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] uppercase font-bold tracking-widest text-concrete">
<div>© 2026 MOMENTO LABS. ALL RIGHTS RESERVED.</div>
<div className="flex gap-8"><span>DESIGNED BY ANTIGRAVITY</span></div>
</div>
</footer>
</div>
)
}

View File

@@ -116,7 +116,7 @@ export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
noteId={note.id}
onOpenNote={(noteId: string) => {
onClose()
window.location.href = `/?note=${noteId}`
window.location.href = `/home?note=${noteId}`
}}
onCompareNotes={(noteIds: string[]) => {
Promise.all(noteIds.map(async (id: string) => {
@@ -296,7 +296,7 @@ export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
notes={state.comparisonNotes}
onOpenNote={(noteId: string) => {
onClose()
window.location.href = `/?note=${noteId}`
window.location.href = `/home?note=${noteId}`
}}
/>
)}

View File

@@ -177,23 +177,23 @@ export function NotificationPanel() {
// ── icon bg/color per notification type ──────────────────────────────────
const notifIconStyle = (type: string) => {
if (type === 'agent_success') return { bg: `${C.gold}20`, color: C.gold }
if (type === 'agent_slides_ready') return { bg: `${C.gold}20`, color: C.gold }
if (type === 'agent_canvas_ready') return { bg: `${C.gold}20`, color: C.gold }
if (type === 'agent_failure') return { bg: '#EF444420', color: '#EF4444' }
if (type === 'brainstorm_invite') return { bg: '#10b98120', color: '#10b981' }
if (type === 'brainstorm_joined') return { bg: '#60a5fa20', color: '#60a5fa' }
return { bg: `${C.green}20`, color: C.green }
if (type === 'agent_success') return { bg: 'rgba(164,113,72,0.12)', color: '#A47148' }
if (type === 'agent_slides_ready') return { bg: 'rgba(164,113,72,0.12)', color: '#A47148' }
if (type === 'agent_canvas_ready') return { bg: 'rgba(164,113,72,0.12)', color: '#A47148' }
if (type === 'agent_failure') return { bg: 'rgba(239,68,68,0.12)', color: '#EF4444' }
if (type === 'brainstorm_invite') return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
if (type === 'brainstorm_joined') return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
}
const notifLabelColor = (type: string) => {
if (type.startsWith('agent')) {
if (type === 'agent_failure') return '#EF4444'
if (type === 'brainstorm_invite') return '#10b981'
if (type === 'brainstorm_joined') return '#60a5fa'
return C.gold
if (type === 'brainstorm_invite') return '#A3B18A'
if (type === 'brainstorm_joined') return '#A3B18A'
return '#A47148'
}
return C.green
return '#A3B18A'
}
return (
@@ -205,8 +205,7 @@ export function NotificationPanel() {
<Bell className="h-4 w-4 transition-transform duration-200 hover:scale-110" />
{pendingCount > 0 && (
<span
className="absolute -top-1 -right-1 h-4 w-4 flex items-center justify-center rounded-full text-white text-[9px] font-bold border border-white shadow-sm"
style={{ background: C.green }}
className="absolute -top-1 -right-1 h-4 w-4 flex items-center justify-center rounded-full text-white text-[9px] font-bold border border-white shadow-sm bg-brand-accent"
>
{pendingCount > 9 ? '9+' : pendingCount}
</span>
@@ -235,8 +234,7 @@ export function NotificationPanel() {
)}
{pendingCount > 0 && (
<span
className="h-5 px-1.5 flex items-center justify-center rounded-full text-white text-[9px] font-bold"
style={{ background: C.green }}
className="h-5 px-1.5 flex items-center justify-center rounded-full text-white text-[9px] font-bold bg-brand-accent"
>
{pendingCount}
</span>
@@ -405,14 +403,14 @@ export function NotificationPanel() {
<div className="flex items-start gap-3">
<div
className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm"
style={{ background: `linear-gradient(135deg, #fb923c, #f97316)` }}
style={{ background: `linear-gradient(135deg, #A47148, #A47148)` }}
>
{(share.sharer?.name || share.sharer?.email || '?')[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<Wind className="w-3 h-3" style={{ color: '#fb923c' }} />
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: '#fb923c' }}>
<Wind className="w-3 h-3" style={{ color: '#A47148' }} />
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: '#A47148' }}>
Brainstorm
</span>
</div>
@@ -434,8 +432,7 @@ export function NotificationPanel() {
</button>
<button
onClick={() => handleAcceptBrainstorm(share.id)}
className="flex-1 h-7 px-3 text-[11px] font-bold rounded-lg text-white transition-all active:scale-95 flex items-center justify-center gap-1 shadow-sm hover:opacity-90"
style={{ background: '#fb923c' }}
className="flex-1 h-7 px-3 text-[11px] font-bold rounded-lg text-white transition-all active:scale-95 flex items-center justify-center gap-1 shadow-sm hover:opacity-90 bg-brand-accent"
>
<Check className="h-3 w-3" />
{t('notification.accept') || 'Accept'}

View File

@@ -1,16 +0,0 @@
'use client'
import { motion } from 'motion/react'
export function PageEntry({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
className={className}
>
{children}
</motion.div>
)
}

View File

@@ -1,9 +0,0 @@
'use client'
export function PageTransition({ children }: { children: React.ReactNode }) {
return (
<div className="flex-1 flex flex-col min-h-0">
{children}
</div>
)
}

View File

@@ -0,0 +1,41 @@
'use client'
import { LanguageProvider, useLanguage } from '@/lib/i18n/LanguageProvider'
import { QueryProvider } from '@/components/query-provider'
import type { Translations } from '@/lib/i18n/load-translations'
import type { ReactNode } from 'react'
import { useEffect } from 'react'
const RTL_LANGUAGES = ['ar', 'fa']
function DirWrapper({ children }: { children: ReactNode }) {
const { language } = useLanguage()
const dir = RTL_LANGUAGES.includes(language) ? 'rtl' : 'ltr'
useEffect(() => {
document.documentElement.lang = language
document.documentElement.dir = dir
}, [language, dir])
return <div dir={dir} className="contents">{children}</div>
}
export function PublicProviders({
children,
initialLanguage = 'en',
initialTranslations
}: {
children: ReactNode
initialLanguage?: string
initialTranslations?: Translations
}) {
return (
<QueryProvider>
<LanguageProvider initialLanguage={initialLanguage as any} initialTranslations={initialTranslations}>
<DirWrapper>
{children}
</DirWrapper>
</LanguageProvider>
</QueryProvider>
)
}

View File

@@ -150,7 +150,22 @@ async function aiReformulate(text: string, option: string, language?: string): P
})
const data = await res.json()
if (!res.ok) {
const serverMsg = typeof data?.error === 'string' && data.error.trim() ? data.error.trim() : AI_REFORMULATE_FALLBACK
if (data?.errorKey === 'ai.wordCountMin') {
throw new Error(t('ai.wordCountMin') || `Minimum ${data?.params?.min || 10} mots requis (${data?.params?.current || 0} actuels)`)
}
if (data?.errorKey === 'ai.wordCountMax') {
throw new Error(t('ai.wordCountMax') || `Maximum ${data?.params?.max || 500} mots (${data?.params?.current || 0} actuels)`)
}
if (data?.errorKey === 'ai.featureLocked') {
throw new Error(t('ai.featureLocked') || 'Cette fonctionnalité nécessite le plan PRO.')
}
if (data?.errorKey === 'ai.quotaExceeded') {
throw new Error(t('ai.quotaExceeded') || 'Limite mensuelle atteinte.')
}
if (data?.quotaExceeded) {
throw new Error(t('ai.quotaExceeded') || 'Limite mensuelle atteinte.')
}
const serverMsg = typeof data?.error === 'string' && !data.error.includes(':') ? data.error.trim() : AI_REFORMULATE_FALLBACK
throw new Error(serverMsg)
}
return data.reformulatedText || data.text || text
@@ -385,6 +400,7 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
try {
const lang = option === 'translate' ? (targetLang || language) : language
const result = await aiReformulate(text, option, lang)
window.dispatchEvent(new Event('ai-usage-changed'))
if (option === 'explain') {
setAiModal({ type: 'explain', origText: text, html: result, from, to })
} else {

View File

@@ -28,12 +28,12 @@ export function SettingsNav({ className }: SettingsNavProps) {
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/')
return (
<nav className={`flex items-center gap-1 border-b border-border/40 pb-px ${className || ''}`}>
<nav className={`flex flex-wrap items-center gap-1 border-b border-border/40 pb-px ${className || ''}`}>
{tabs.map((tab) => (
<Link
key={tab.id}
href={tab.href}
className="flex items-center gap-2.5 px-6 py-5 text-[10px] font-bold uppercase tracking-[0.18em] transition-all relative whitespace-nowrap text-concrete hover:text-ink/60"
className="flex items-center gap-2.5 px-4 py-3 text-[10px] font-bold uppercase tracking-[0.18em] transition-all relative whitespace-nowrap text-concrete hover:text-ink/60"
style={{ color: isActive(tab.href) ? 'var(--ink)' : undefined }}
>
<span style={{ color: isActive(tab.href) ? 'var(--ink)' : 'var(--concrete)' }}>{tab.icon}</span>

View File

@@ -234,7 +234,7 @@ export function BillingPlans() {
{/* Usage Overview */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-8 space-y-6">
<div className="md:col-span-2 bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-8 space-y-6">
<div className="flex items-center gap-4">
<div className="p-2 bg-brand-accent/10 text-brand-accent rounded-xl">
<Activity size={20} />
@@ -243,18 +243,57 @@ export function BillingPlans() {
<h4 className="text-sm font-bold text-ink">{t('billing.currentUsage') || 'Utilisation actuelle'}</h4>
<p className="text-[10px] text-concrete uppercase tracking-widest">{t('billing.currentPeriod') || 'Période en cours'}</p>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between text-[11px] font-medium text-concrete uppercase tracking-wider">
<span>{t('billing.aiCredits') || 'Crédits IA'}</span>
<span>{aiUsed} / {aiLimit} {t('billing.used') || 'utilisés'}</span>
</div>
<div className="h-2 w-full bg-slate-100 dark:bg-white/5 rounded-full overflow-hidden">
<div className="h-full bg-brand-accent rounded-full" style={{ width: `${aiPct}%` }} />
</div>
<div className="ml-auto">
<span className={cn(
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest',
isPaid ? 'bg-brand-accent/10 text-brand-accent' : 'bg-concrete/10 text-concrete'
)}>
{effectiveTier === 'BASIC' ? 'Discovery' : effectiveTier}
</span>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{!quotas && (
<div className="col-span-full flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-concrete" />
</div>
)}
{quotas && Object.entries(quotas).filter(([_, q]) => q.limit > 0).length === 0 && (
<p className="col-span-full text-xs text-concrete text-center py-4">Aucune donnée d'utilisation disponible</p>
)}
{quotas && Object.entries(quotas).filter(([_, q]) => q.limit > 0).map(([key, q]) => {
const pct = q.limit > 0 ? (q.used / q.limit) * 100 : 0
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.featureBrainstormCreate'),
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
}
return (
<div key={key} className="space-y-2 p-3 rounded-xl bg-paper/50 dark:bg-white/5">
<div className="flex justify-between items-center">
<span className="text-[10px] font-bold text-concrete uppercase tracking-wider truncate">
{featureLabels[key] || key}
</span>
</div>
<div className="flex items-baseline gap-1">
<span className="text-lg font-bold text-ink">{q.remaining === Infinity ? '' : q.remaining}</span>
<span className="text-[10px] text-concrete">/ {q.limit === Infinity ? '' : q.limit}</span>
</div>
<div className="h-1.5 w-full bg-slate-100 dark:bg-white/5 rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all', pct >= 90 ? 'bg-rose-500' : pct >= 70 ? 'bg-amber-500' : 'bg-brand-accent')}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
</div>
)
})}
</div>
</div>
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-8 space-y-6">

View File

@@ -469,7 +469,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const currentNoteId = searchParams.get('openNote')
const isInboxActive =
pathname === '/' &&
pathname === '/home' &&
!searchParams.get('notebook') &&
!searchParams.get('labels') &&
!searchParams.get('archived') &&
@@ -534,11 +534,11 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const params = new URLSearchParams()
params.set('notebook', notebookId)
params.set('forceList', '1')
router.push(`/?${params.toString()}`)
router.push(`/home?${params.toString()}`)
}
const handleInboxClick = () => {
router.push('/?forceList=1')
router.push('/home?forceList=1')
}
const handleNoteClick = (noteId: string, notebookId: string) => {
@@ -546,7 +546,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
params.set('notebook', notebookId)
params.set('openNote', noteId)
params.delete('forceList')
router.push(`/?${params.toString()}`)
router.push(`/home?${params.toString()}`)
}
// ── Drag state ──
@@ -706,7 +706,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
await trashNotebook(deletingNotebook.id)
setDeletingNotebook(null)
if (currentNotebookId === deletingNotebook.id) {
router.push('/')
router.push('/home')
}
} catch (err) {
console.error('Trash failed:', err)
@@ -871,7 +871,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<Settings size={14} />
</Link>
<button
onClick={() => { router.push('/') }}
onClick={() => { router.push('/home') }}
className="p-1.5 text-muted-ink hover:text-ink transition-all hover:bg-white/50 dark:hover:bg-white/10 rounded-lg border border-transparent hover:border-border"
title={t('nav.home') || 'Accueil'}
>
@@ -889,29 +889,29 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<div className="flex bg-white/50 dark:bg-white/10 p-1 rounded-xl border border-border dark:border-white/10">
<button
onClick={() => { setActiveView('notebooks'); if (pathname !== '/') router.push('/') }}
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'notebooks' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
onClick={() => { setActiveView('notebooks'); if (pathname !== '/home') router.push('/home') }}
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'notebooks' ? 'bg-brand-accent text-white shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
title={t('nav.notebooks')}
>
<BookOpen size={14} />
</button>
<button
onClick={() => setActiveView('reminders')}
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'reminders' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'reminders' ? 'bg-brand-accent text-white shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
title={t('sidebar.reminders')}
>
<Clock size={14} />
</button>
<button
onClick={() => { setActiveView('agents'); router.push('/agents') }}
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'agents' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'agents' ? 'bg-brand-accent text-white shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
title={t('nav.agents')}
>
<Bot size={14} />
</button>
<button
onClick={() => { setActiveView('brainstorms'); router.push('/brainstorm') }}
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'brainstorms' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'brainstorms' ? 'bg-brand-accent text-white shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
title={t('brainstorm.sessions')}
>
<Sparkles size={14} />
@@ -989,8 +989,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
isInboxActive
? 'bg-ink text-paper border-ink'
: 'bg-white/60 dark:bg-white/5 text-ink dark:text-foreground border-border'
? 'bg-brand-accent text-white border-brand-accent'
: 'bg-paper dark:bg-white/5 text-muted-ink border-border group-hover:border-brand-accent/20'
)}>
<Inbox size={14} />
</div>
@@ -1067,8 +1067,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center border transition-colors shrink-0',
isActive
? 'bg-foreground text-background border-foreground'
: 'bg-paper border-border group-hover:border-foreground/20'
? 'bg-brand-accent text-white border-brand-accent'
: 'bg-paper border-border group-hover:border-brand-accent/20'
)}>
<item.icon size={16} />
</div>
@@ -1109,15 +1109,15 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<UsageMeter />
<div className="px-2 space-y-0.5">
<Link
href="/?shared=1&forceList=1"
href="/home?shared=1&forceList=1"
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl',
searchParams.get('shared') === '1' && pathname === '/'
searchParams.get('shared') === '1' && pathname === '/home'
? 'bg-accent/5 text-accent'
: 'text-muted-ink hover:text-ink hover:bg-black/5'
)}
>
<Users size={14} className={searchParams.get('shared') === '1' && pathname === '/' ? 'text-accent' : 'text-muted-ink group-hover:text-ink'} />
<Users size={14} className={searchParams.get('shared') === '1' && pathname === '/home' ? 'text-accent' : 'text-muted-ink group-hover:text-ink'} />
<span>{t('sidebar.sharedWithMe') || 'Shared'}</span>
</Link>

View File

@@ -1,10 +1,11 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { cn } from '@/lib/utils';
import { Sparkles, X, Crown } from 'lucide-react';
import { Sparkles, ChevronDown, X, Crown } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { useLanguage } from '@/lib/i18n';
interface QuotaData {
@@ -17,27 +18,31 @@ interface UsageMeterProps {
className?: string;
}
function formatRemaining(value: number): string {
if (!Number.isFinite(value)) return '∞';
return String(value);
}
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 data = await res.json();
return data.quotas as Record<string, QuotaData>;
const json = await res.json();
return { quotas: json.quotas as Record<string, QuotaData>, tier: json.tier as string };
},
refetchInterval: 30000,
staleTime: 5000,
refetchInterval: 10000,
});
if (isLoading || !data) {
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" />
@@ -45,97 +50,151 @@ export function UsageMeter({ className }: UsageMeterProps) {
);
}
const features = [
{ key: 'semantic_search', labelKey: 'usageMeter.featureSearch' as const },
{ key: 'auto_tag', labelKey: 'usageMeter.featureTags' as const },
{ key: 'auto_title', labelKey: 'usageMeter.featureTitles' as const },
] as const;
const isProPlus = data.tier && data.tier !== 'BASIC';
const featureQuotas = features.map((f) => {
const quota = data[f.key];
return {
...f,
label: t(f.labelKey),
used: quota?.used ?? 0,
limit: quota?.limit ?? 0,
remaining: quota?.remaining ?? 0,
};
});
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.featureBrainstormCreate'),
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
};
const featureQuotas = Object.entries(data.quotas)
.filter(([_, q]) => q.limit > 0)
.map(([key, quota]) => ({
key,
label: featureLabels[key] || key,
used: quota.used,
limit: quota.limit,
remaining: quota.remaining,
}));
const isUnlimited = featureQuotas.every((f) => !Number.isFinite(f.limit));
const totalRemaining = featureQuotas.reduce(
(sum, f) => sum + (Number.isFinite(f.remaining) ? f.remaining : 0),
0,
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,
(sum, f) => sum + (Number.isFinite(f.limit) ? f.limit : 0), 0,
);
const used = totalLimit - totalRemaining;
const percentage = totalLimit > 0 ? Math.min((used / totalLimit) * 100, 100) : 0;
const totalRemaining = totalLimit - totalUsed;
const totalPct = totalLimit > 0 ? (totalUsed / totalLimit) * 100 : 0;
const isExhausted = !isUnlimited && totalRemaining <= 0;
const isLow = !isUnlimited && percentage >= 75 && !isExhausted;
return (
<>
<div className="px-2 py-2 w-full">
<div className="p-4 bg-slate-50 dark:bg-white/5 border border-border rounded-2xl space-y-3 group hover:shadow-lg transition-all duration-300">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles
size={12}
className={
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">{t('usageMeter.packName')}</span>
</div>
<span
className={cn(
'text-[10px] font-medium',
isExhausted
? 'text-rose-500'
: isUnlimited
? 'text-emerald-500'
: isLow
? 'text-amber-500'
: 'text-concrete',
)}
>
{isUnlimited
? t('usageMeter.unlimited')
: t('usageMeter.remaining', { count: totalRemaining })}
</span>
</div>
{!isUnlimited && (
<div className="h-1.5 w-full bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all duration-300',
isExhausted
? 'bg-rose-400'
: isLow
? 'bg-amber-400'
: 'bg-brand-accent',
)}
style={{ width: `${percentage}%` }}
/>
</div>
)}
<div className="bg-slate-50 dark:bg-white/5 border border-border rounded-2xl overflow-hidden">
<button
onClick={() => router.push('/settings/billing')}
className="w-full 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"
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"
>
{t('usageMeter.upgradePricing') || 'Passer à Pro'}
<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">{t('usageMeter.packName')}</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') || 'Passer à Pro'}
</button>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>