feat(credits): solde IA unique (option M), packs Stripe et polish billing
Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux legacy, usage multi-features, hints de coût, anti-flash thème et hydratation admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
This commit is contained in:
@@ -406,7 +406,12 @@ export function BlockActionMenu({
|
||||
{/* Création de diagramme */}
|
||||
<button type="button" className="block-action-item" onClick={() => { void handleCreateDiagram() }}>
|
||||
<Sparkles size={16} className="text-amber-500 transition-all duration-200" />
|
||||
<span className="font-medium text-amber-700 dark:text-amber-400">{t('blockAction.createDiagram')}</span>
|
||||
<span className="font-medium text-amber-700 dark:text-amber-400 flex flex-col items-start gap-0.5">
|
||||
<span>{t('blockAction.createDiagram')}</span>
|
||||
<span className="text-[9px] font-normal text-muted-foreground normal-case tracking-normal">
|
||||
{t('blockAction.createDiagramCostHint')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="block-action-separator" />
|
||||
|
||||
@@ -229,6 +229,9 @@ export function ContextualAIChat({
|
||||
const [slideTheme, setSlideTheme] = useState('auto')
|
||||
const [slideStyle, setSlideStyle] = useState('professional')
|
||||
const [slideTemplate, setSlideTemplate] = useState('auto')
|
||||
const [slidePurpose, setSlidePurpose] = useState('auto')
|
||||
const [slideAudience, setSlideAudience] = useState('auto')
|
||||
const [slideCount, setSlideCount] = useState('auto')
|
||||
const [diagramType, setDiagramType] = useState('logic_flow')
|
||||
const [diagramStyle, setDiagramStyle] = useState('polished')
|
||||
const [diagramEmbedLoading, setDiagramEmbedLoading] = useState(false)
|
||||
@@ -444,6 +447,12 @@ export function ContextualAIChat({
|
||||
style: type === 'slides' ? slideStyle : diagramStyle,
|
||||
language: language === 'fr' ? 'French' : 'English',
|
||||
template: type === 'slides' ? slideTemplate : undefined,
|
||||
purpose: type === 'slides' ? slidePurpose : undefined,
|
||||
audience: type === 'slides' ? slideAudience : undefined,
|
||||
slideCount:
|
||||
type === 'slides' && slideCount !== 'auto'
|
||||
? parseInt(slideCount, 10)
|
||||
: undefined,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
@@ -1105,16 +1114,66 @@ export function ContextualAIChat({
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.template')}</span>
|
||||
<select value={slideTemplate} onChange={e => setSlideTemplate(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||||
<option value="auto">{t('ai.generate.templateAuto')}</option>
|
||||
<option value="board-update">{t('ai.generate.templateBoard')}</option>
|
||||
<option value="project-status">{t('ai.generate.templateProject')}</option>
|
||||
<option value="strategy-review">{t('ai.generate.templateStrategy')}</option>
|
||||
<option value="quarterly-results">{t('ai.generate.templateQuarterly')}</option>
|
||||
</select>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.purpose') || 'Type de deck'}</span>
|
||||
<select value={slidePurpose} onChange={e => setSlidePurpose(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||||
<option value="auto">{t('ai.generate.purposeAuto') || 'Auto (selon la note)'}</option>
|
||||
<option value="course">{t('ai.generate.purposeCourse') || 'Cours / leçon'}</option>
|
||||
<option value="board">{t('ai.generate.purposeBoard') || 'Comité / board'}</option>
|
||||
<option value="project">{t('ai.generate.purposeProject') || 'Suivi projet'}</option>
|
||||
<option value="strategy">{t('ai.generate.purposeStrategy') || 'Stratégie'}</option>
|
||||
<option value="pitch">{t('ai.generate.purposePitch') || 'Pitch'}</option>
|
||||
<option value="summary">{t('ai.generate.purposeSummary') || 'Synthèse courte'}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.audience') || 'Audience'}</span>
|
||||
<select value={slideAudience} onChange={e => setSlideAudience(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||||
<option value="auto">{t('ai.generate.audienceAuto') || 'Auto'}</option>
|
||||
<option value="student">{t('ai.generate.audienceStudent') || 'Étudiants'}</option>
|
||||
<option value="board">{t('ai.generate.audienceBoard') || 'Direction'}</option>
|
||||
<option value="team">{t('ai.generate.audienceTeam') || 'Équipe'}</option>
|
||||
<option value="client">{t('ai.generate.audienceClient') || 'Client'}</option>
|
||||
<option value="general">{t('ai.generate.audienceGeneral') || 'Grand public'}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.slideCount') || 'Nb de slides'}</span>
|
||||
<select value={slideCount} onChange={e => setSlideCount(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||||
<option value="auto">{t('ai.generate.slideCountAuto') || 'Auto (selon longueur)'}</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
<option value="6">6</option>
|
||||
<option value="7">7</option>
|
||||
<option value="8">8</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.template')}</span>
|
||||
<select value={slideTemplate} onChange={e => setSlideTemplate(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||||
<option value="auto">{t('ai.generate.templateAuto')}</option>
|
||||
<option value="board-update">{t('ai.generate.templateBoard')}</option>
|
||||
<option value="project-status">{t('ai.generate.templateProject')}</option>
|
||||
<option value="strategy-review">{t('ai.generate.templateStrategy')}</option>
|
||||
<option value="quarterly-results">{t('ai.generate.templateQuarterly')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const n = slideCount === 'auto' ? 6 : Math.min(8, Math.max(3, parseInt(slideCount, 10) || 6))
|
||||
const creditCost = 1 + n
|
||||
return (
|
||||
<p className="text-[10px] text-muted-foreground px-0.5 leading-relaxed">
|
||||
{t('ai.generate.creditCostEstimate', {
|
||||
cost: String(creditCost),
|
||||
slides: String(n),
|
||||
})}
|
||||
</p>
|
||||
)
|
||||
})()}
|
||||
<button onClick={() => handleGenerate('slides')} disabled={!!generateLoading} 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] disabled:opacity-50">
|
||||
{generateLoading === 'slides' ? <Loader2 size={14} className="animate-spin" /> : <><Presentation size={14} className="opacity-80" /> {t('ai.generating')}</>}
|
||||
</button>
|
||||
|
||||
@@ -126,9 +126,12 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
|
||||
function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: string; canvasId?: string | null }) {
|
||||
const { t } = useLanguage()
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const [currentSlide, setCurrentSlide] = useState(1)
|
||||
const [isLoaded, setIsLoaded] = useState(!!data.spec) // spec → React renderer, no iframe loading
|
||||
const totalSlides = data.slideCount || 1
|
||||
// Always preview the generated HTML (KaTeX, themes, density).
|
||||
// React SlidesRenderer used an older layout schema and looked broken vs HTML.
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const totalSlides = data.slideCount || data.spec?.slides?.length || 1
|
||||
|
||||
// Hide the global AI floating button while slides are displayed
|
||||
useEffect(() => {
|
||||
@@ -149,10 +152,36 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
|
||||
return () => window.removeEventListener('message', handler)
|
||||
}, [])
|
||||
|
||||
// Fit a 16:9 stage into the available panel (letterbox, no crop)
|
||||
useEffect(() => {
|
||||
const stage = stageRef.current
|
||||
if (!stage) return
|
||||
const parent = stage.parentElement
|
||||
if (!parent) return
|
||||
|
||||
const fit = () => {
|
||||
const pw = parent.clientWidth
|
||||
const ph = parent.clientHeight
|
||||
if (pw < 32 || ph < 32) return
|
||||
// Design frame 16:9 — pick max size that fits
|
||||
const designW = 1280
|
||||
const designH = 720
|
||||
const scale = Math.min(pw / designW, ph / designH)
|
||||
const w = Math.round(designW * scale)
|
||||
const h = Math.round(designH * scale)
|
||||
stage.style.width = `${w}px`
|
||||
stage.style.height = `${h}px`
|
||||
}
|
||||
|
||||
fit()
|
||||
const ro = new ResizeObserver(fit)
|
||||
ro.observe(parent)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
const navigate = (dir: number) => {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe) return
|
||||
// Direct contentWindow access (works with allow-same-origin)
|
||||
try {
|
||||
const win = iframe.contentWindow as any
|
||||
if (typeof win?.changeSlide === 'function') {
|
||||
@@ -160,7 +189,6 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
|
||||
return
|
||||
}
|
||||
} catch (_) {}
|
||||
// Fallback: postMessage
|
||||
iframe.contentWindow?.postMessage({ type: 'navigate', dir }, '*')
|
||||
}
|
||||
|
||||
@@ -183,6 +211,9 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000)
|
||||
}
|
||||
|
||||
// Prefer polished HTML; fall back to React only if html is missing
|
||||
const useHtml = Boolean(data.html && data.html.length > 200)
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col bg-zinc-950">
|
||||
{/* Toolbar */}
|
||||
@@ -192,7 +223,7 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
|
||||
<span className="text-sm font-medium truncate">{name}</span>
|
||||
{totalSlides > 1 && (
|
||||
<span className="text-xs bg-white/10 px-2 py-0.5 rounded-full shrink-0 tabular-nums">
|
||||
{currentSlide} / {totalSlides}
|
||||
{currentSlide} / {totalSlides}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -226,45 +257,53 @@ function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: str
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Slides: React renderer (spec) or iframe (HTML fallback) */}
|
||||
<div className="flex-1 relative overflow-hidden group">
|
||||
{data.spec ? (
|
||||
<SlidesRenderer spec={data.spec} />
|
||||
) : (
|
||||
|
||||
{/* Stage: 16:9 letterboxed HTML (source of visual truth) */}
|
||||
<div className="flex-1 relative overflow-hidden group flex items-center justify-center bg-zinc-950 p-3 sm:p-4">
|
||||
{useHtml ? (
|
||||
<>
|
||||
{/* Loading overlay — visible until iframe fires onLoad */}
|
||||
{!isLoaded && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-zinc-950 gap-3">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-white/10 border-t-white/60 animate-spin" />
|
||||
<span className="text-xs text-white/30">{t('lab.loadingPresentation')}</span>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={data.html}
|
||||
className="absolute inset-0 w-full h-full border-0"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
|
||||
title={name}
|
||||
allow="fullscreen"
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
/>
|
||||
{/* Prev button */}
|
||||
<div
|
||||
ref={stageRef}
|
||||
className="relative overflow-hidden rounded-lg shadow-2xl shadow-black/50 border border-white/10 bg-black"
|
||||
style={{ width: '100%', maxWidth: '100%', aspectRatio: '16 / 9' }}
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={data.html}
|
||||
className="absolute inset-0 w-full h-full border-0"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
|
||||
title={name}
|
||||
allow="fullscreen"
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 w-10 h-10 flex items-center justify-center rounded-full bg-black/50 hover:bg-black/80 backdrop-blur-sm border border-white/10 text-white/50 hover:text-white transition-all opacity-0 group-hover:opacity-100"
|
||||
aria-label={t('lab.previousSlide')}
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
{/* Next button */}
|
||||
<button
|
||||
onClick={() => navigate(1)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 w-10 h-10 flex items-center justify-center rounded-full bg-black/50 hover:bg-black/80 backdrop-blur-sm border border-white/10 text-white/50 hover:text-white transition-all opacity-0 group-hover:opacity-100"
|
||||
aria-label={t('lab.nextSlide')}
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
) : data.spec ? (
|
||||
<div className="w-full h-full">
|
||||
<SlidesRenderer spec={data.spec} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white/40 text-sm">{t('lab.loadingPresentation')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import {
|
||||
BrainCircuit, Search, MessageSquare, Zap, Cpu, Workflow,
|
||||
Globe, Shield, ArrowRight, Sparkles, Layers, Activity,
|
||||
Box
|
||||
ArrowRight, Menu, X, Check, BrainCircuit,
|
||||
Network, GraduationCap, Bot, KeyRound, Globe, ChevronDown
|
||||
} from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useState } from 'react'
|
||||
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
|
||||
const ECHO_LINES = ['echo0', 'echo1', 'echo2'] as const
|
||||
|
||||
const LANDING_LANGS: { code: SupportedLanguage; labelKey: string }[] = [
|
||||
{ code: 'fr', labelKey: 'languages.fr' },
|
||||
{ code: 'en', labelKey: 'languages.en' },
|
||||
{ code: 'es', labelKey: 'languages.es' },
|
||||
{ code: 'de', labelKey: 'languages.de' },
|
||||
{ code: 'it', labelKey: 'languages.it' },
|
||||
{ code: 'pt', labelKey: 'languages.pt' },
|
||||
{ code: 'nl', labelKey: 'languages.nl' },
|
||||
{ code: 'pl', labelKey: 'languages.pl' },
|
||||
{ code: 'ru', labelKey: 'languages.ru' },
|
||||
{ code: 'zh', labelKey: 'languages.zh' },
|
||||
{ code: 'ja', labelKey: 'languages.ja' },
|
||||
{ code: 'ko', labelKey: 'languages.ko' },
|
||||
{ code: 'ar', labelKey: 'languages.ar' },
|
||||
{ code: 'fa', labelKey: 'languages.fa' },
|
||||
{ code: 'hi', labelKey: 'languages.hi' },
|
||||
]
|
||||
|
||||
export function LandingPage() {
|
||||
const { t } = useLanguage()
|
||||
const router = useRouter()
|
||||
const { t, language, setLanguage } = useLanguage()
|
||||
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly')
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [langOpen, setLangOpen] = useState(false)
|
||||
const [echoIndex, setEchoIndex] = useState(0)
|
||||
const langRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
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} /> },
|
||||
]
|
||||
useEffect(() => {
|
||||
if (!langOpen) return
|
||||
const onPointer = (e: MouseEvent) => {
|
||||
if (langRef.current && !langRef.current.contains(e.target as Node)) setLangOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setLangOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onPointer)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointer)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [langOpen])
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setEchoIndex((i) => (i + 1) % ECHO_LINES.length), 3200)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const BRAINSTORM_ITEMS = ['waveGeneration', 'collaboration', 'export']
|
||||
useEffect(() => {
|
||||
const root = document.querySelector<HTMLElement>('[data-public-scroll-root]')
|
||||
if (!root) return
|
||||
const prev = root.style.overflow
|
||||
if (menuOpen) root.style.overflow = 'hidden'
|
||||
else root.style.overflow = prev || ''
|
||||
return () => { root.style.overflow = prev }
|
||||
}, [menuOpen])
|
||||
|
||||
const PLANS = [
|
||||
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' },
|
||||
@@ -34,380 +75,556 @@ export function LandingPage() {
|
||||
{ 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 NAV = [
|
||||
{ href: '#product', label: t('landing.nav.secondBrain') },
|
||||
{ href: '#echo', label: t('landing.nav.echo') },
|
||||
{ href: '#agents', label: t('landing.nav.agents') },
|
||||
{ href: '#pricing', label: t('landing.nav.pricing') },
|
||||
]
|
||||
|
||||
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-4 sm: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 className="min-h-screen bg-[#0B0A09] text-[#F4F1EA] font-[family-name:var(--font-manrope)] selection:bg-[#D4A373]/40 selection:text-white">
|
||||
{/* Nav */}
|
||||
<nav className="fixed top-0 left-0 right-0 z-[100] px-5 sm:px-8 py-4 flex items-center justify-between bg-[#0B0A09]/70 backdrop-blur-xl border-b border-white/[0.06]">
|
||||
<Link href="/" className="flex items-center gap-2.5 group">
|
||||
<div className="w-9 h-9 bg-[#F4F1EA] text-[#0B0A09] flex items-center justify-center rounded-lg transition-transform group-hover:scale-105">
|
||||
<span className="font-serif text-xl font-bold leading-none">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-2xl font-medium tracking-tight">Memento</span>
|
||||
<span className="font-serif text-xl font-medium tracking-tight text-[#F4F1EA]">Memento</span>
|
||||
</Link>
|
||||
<div className="hidden lg:flex items-center gap-8">
|
||||
{NAV.map((l) => (
|
||||
<a key={l.href} href={l.href} className="text-[13px] text-white/55 hover:text-white transition-colors">
|
||||
{l.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{/* Language switcher */}
|
||||
<div ref={langRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLangOpen((o) => !o)}
|
||||
aria-expanded={langOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-label={t('landing.nav.language')}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-full border border-white/15 text-[12px] text-white/70 hover:text-white hover:border-white/30 transition-colors"
|
||||
>
|
||||
<Globe size={14} />
|
||||
<span className="uppercase font-semibold tracking-wide">{language}</span>
|
||||
<ChevronDown size={12} className={`opacity-60 transition-transform ${langOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{langOpen && (
|
||||
<motion.ul
|
||||
role="listbox"
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 6 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute end-0 mt-2 w-48 max-h-72 overflow-y-auto rounded-2xl border border-white/10 bg-[#141210] shadow-2xl py-1.5 z-[110]"
|
||||
>
|
||||
{LANDING_LANGS.map((lang) => (
|
||||
<li key={lang.code} role="option" aria-selected={language === lang.code}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLanguage(lang.code)
|
||||
setLangOpen(false)
|
||||
}}
|
||||
className={`w-full text-start px-4 py-2.5 text-[13px] transition-colors ${
|
||||
language === lang.code
|
||||
? 'bg-[#D4A373]/15 text-[#D4A373]'
|
||||
: 'text-white/70 hover:bg-white/5 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{t(lang.labelKey)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</motion.ul>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</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">
|
||||
<Link href="/login" className="hidden sm:inline text-[13px] text-white/55 hover:text-white transition-colors px-2">
|
||||
{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">
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="hidden sm:inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-[#F4F1EA] text-[#0B0A09] text-[13px] font-semibold hover:bg-white transition-colors"
|
||||
>
|
||||
{t('landing.nav.cta')}
|
||||
<ArrowRight size={14} className="group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={menuOpen ? t('landing.nav.closeMenu') : t('landing.nav.openMenu')}
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
className="lg:hidden w-10 h-10 rounded-full border border-white/15 flex items-center justify-center text-white/80"
|
||||
>
|
||||
{menuOpen ? <X size={18} /> : <Menu size={18} />}
|
||||
</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" />
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[99] bg-[#0B0A09] pt-24 px-8 lg:hidden"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{NAV.map((l) => (
|
||||
<a
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="py-4 text-3xl font-serif border-b border-white/10"
|
||||
>
|
||||
{l.label}
|
||||
</a>
|
||||
))}
|
||||
<Link href="/register" onClick={() => setMenuOpen(false)} className="mt-10 py-4 rounded-2xl bg-[#F4F1EA] text-[#0B0A09] text-center font-semibold">
|
||||
{t('landing.nav.cta')}
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<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} />
|
||||
{/* ── HERO ── */}
|
||||
<section className="relative min-h-[100dvh] flex flex-col justify-center pt-28 pb-16 px-5 sm:px-8 overflow-hidden">
|
||||
{/* Atmosphere — warm, not purple neon */}
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div className="absolute -top-32 left-1/2 -translate-x-1/2 w-[900px] h-[500px] bg-[#D4A373]/15 blur-[120px] rounded-full" />
|
||||
<div className="absolute bottom-0 right-0 w-[500px] h-[400px] bg-[#A47148]/10 blur-[100px] rounded-full" />
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.07]"
|
||||
style={{
|
||||
backgroundImage: 'radial-gradient(circle at 1px 1px, #F4F1EA 1px, transparent 0)',
|
||||
backgroundSize: '28px 28px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-5xl mx-auto w-full text-center">
|
||||
<div className="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full border border-[#D4A373]/35 bg-[#D4A373]/10 mb-7">
|
||||
<BrainCircuit size={14} className="text-[#D4A373]" />
|
||||
<span className="text-[12px] tracking-[0.18em] uppercase text-[#D4A373] font-semibold">
|
||||
{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>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<p className="text-[13px] tracking-[0.12em] uppercase text-white/40 mb-5 font-medium">
|
||||
{t('landing.hero.eyebrow')}
|
||||
</p>
|
||||
|
||||
{/* 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">
|
||||
<Image src="/images/workspace-hero.jpg" alt="Memento Workspace" width={1200} height={750} className="w-full h-full object-cover rounded-2xl filter saturate-[0.8]" priority />
|
||||
<div className="absolute inset-0 bg-ink/10 group-hover:bg-ink/0 transition-colors duration-500" />
|
||||
<h1 className="font-serif text-[clamp(2.5rem,7.5vw,5.4rem)] font-medium tracking-tight leading-[1.05] text-[#F4F1EA] mb-6">
|
||||
{t('landing.hero.headline')}
|
||||
<br />
|
||||
<span className="italic text-[#D4A373]">{t('landing.hero.headlineAccent')}</span>
|
||||
</h1>
|
||||
|
||||
<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>
|
||||
<p className="max-w-xl mx-auto text-[17px] sm:text-lg text-white/55 leading-relaxed mb-10">
|
||||
{t('landing.hero.subtitle')}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Link
|
||||
href="/register"
|
||||
className="group inline-flex items-center justify-center gap-3 px-9 py-4 rounded-full bg-[#F4F1EA] text-[#0B0A09] text-[15px] font-semibold hover:bg-white transition-all hover:scale-[1.02] shadow-[0_0_40px_-8px_rgba(212,163,115,0.45)]"
|
||||
>
|
||||
{t('landing.hero.cta')}
|
||||
<ArrowRight size={18} className="transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
<p className="text-[12px] text-white/35">{t('landing.hero.ctaHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product stage */}
|
||||
<div className="relative z-10 mt-14 sm:mt-20 max-w-5xl mx-auto w-full">
|
||||
<div className="relative rounded-[20px] sm:rounded-[28px] overflow-hidden border border-white/10 shadow-[0_40px_100px_-20px_rgba(0,0,0,0.7)]">
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#0B0A09] via-transparent to-transparent z-10 pointer-events-none" />
|
||||
<Image
|
||||
src="/images/workspace-hero.jpg"
|
||||
alt={t('landing.hero.imageAlt')}
|
||||
width={2000}
|
||||
height={1200}
|
||||
priority
|
||||
className="w-full h-auto object-cover aspect-[16/10] opacity-90"
|
||||
/>
|
||||
{/* Live Memory Echo chip */}
|
||||
<div className="absolute bottom-5 left-5 right-5 sm:bottom-8 sm:left-8 sm:right-auto sm:max-w-md z-20">
|
||||
<div className="rounded-2xl border border-white/15 bg-[#0B0A09]/85 backdrop-blur-xl p-4 sm:p-5 text-left shadow-2xl">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#D4A373] opacity-60" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-[#D4A373]" />
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[#D4A373]">
|
||||
{t('landing.hero.echoLive')}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.p
|
||||
key={ECHO_LINES[echoIndex]}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.35 }}
|
||||
className="text-sm sm:text-[15px] font-serif italic text-[#F4F1EA]/90 leading-relaxed"
|
||||
>
|
||||
{t(`landing.hero.${ECHO_LINES[echoIndex]}`)}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</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>
|
||||
{/* Trust strip */}
|
||||
<section className="px-5 sm:px-8 py-10 border-y border-white/[0.06]">
|
||||
<div className="max-w-5xl mx-auto flex flex-wrap items-center justify-center gap-x-10 gap-y-4 text-[12px] sm:text-[13px] text-white/40 tracking-wide">
|
||||
{['trust0', 'trust1', 'trust2', 'trust3'].map((k) => (
|
||||
<span key={k} className="flex items-center gap-2">
|
||||
<Check size={14} className="text-[#D4A373]" />
|
||||
{t(`landing.trust.${k}`)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
{/* Pain → Promise */}
|
||||
<section className="px-5 sm:px-8 py-24 sm:py-32">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<p className="text-[12px] uppercase tracking-[0.25em] text-[#D4A373] mb-5 font-medium">{t('landing.pain.label')}</p>
|
||||
<h2 className="font-serif text-3xl sm:text-5xl tracking-tight leading-[1.15] mb-6">
|
||||
{t('landing.pain.title')}
|
||||
</h2>
|
||||
<p className="text-lg text-white/50 leading-relaxed mb-8">{t('landing.pain.desc')}</p>
|
||||
<p className="text-base sm:text-lg font-serif italic text-[#D4A373]/90 leading-relaxed">
|
||||
{t('landing.pain.secondBrain')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Product moments — large narrative blocks, not wireframe cards */}
|
||||
<section id="product" className="px-5 sm:px-8 pb-8">
|
||||
<div className="max-w-6xl mx-auto space-y-4">
|
||||
<ProductMoment
|
||||
id="echo"
|
||||
eyebrow={t('landing.moments.echo.eyebrow')}
|
||||
title={t('landing.moments.echo.title')}
|
||||
desc={t('landing.moments.echo.desc')}
|
||||
dark
|
||||
>
|
||||
<div className="space-y-3 p-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`rounded-xl border p-4 ${i === 0 ? 'border-[#D4A373]/40 bg-[#D4A373]/10' : 'border-white/10 bg-white/[0.03]'}`}
|
||||
>
|
||||
<p className="text-[11px] uppercase tracking-wider text-[#D4A373] mb-1">
|
||||
{t(`landing.moments.echo.card${i}Label`)}
|
||||
</p>
|
||||
<p className="text-sm text-white/75 font-serif italic leading-relaxed">
|
||||
{t(`landing.moments.echo.card${i}`)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</ProductMoment>
|
||||
|
||||
<ProductMoment
|
||||
eyebrow={t('landing.moments.dashboard.eyebrow')}
|
||||
title={t('landing.moments.dashboard.title')}
|
||||
desc={t('landing.moments.dashboard.desc')}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2 p-2">
|
||||
{['w0', 'w1', 'w2', 'w3'].map((w) => (
|
||||
<div key={w} className="rounded-xl bg-[#0B0A09]/40 border border-black/10 p-4 min-h-[88px]">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#A47148] mb-2">{t(`landing.moments.dashboard.${w}Label`)}</p>
|
||||
<p className="text-sm font-medium text-[#0B0A09]/80">{t(`landing.moments.dashboard.${w}`)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ProductMoment>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<ProductMoment
|
||||
compact
|
||||
eyebrow={t('landing.moments.insights.eyebrow')}
|
||||
title={t('landing.moments.insights.title')}
|
||||
desc={t('landing.moments.insights.desc')}
|
||||
dark
|
||||
>
|
||||
<div className="relative h-36 flex items-center justify-center">
|
||||
<Network className="text-[#D4A373]/80" size={48} strokeWidth={1.25} />
|
||||
<p className="absolute bottom-2 left-4 text-[11px] uppercase tracking-wider text-white/35">
|
||||
{t('landing.moments.insights.chip')}
|
||||
</p>
|
||||
</div>
|
||||
</ProductMoment>
|
||||
<ProductMoment
|
||||
compact
|
||||
eyebrow={t('landing.moments.revision.eyebrow')}
|
||||
title={t('landing.moments.revision.title')}
|
||||
desc={t('landing.moments.revision.desc')}
|
||||
>
|
||||
<div className="rounded-xl bg-white border border-black/5 p-5 mx-2 mb-2">
|
||||
<GraduationCap className="text-[#A47148] mb-3" size={22} />
|
||||
<p className="font-serif text-[#0B0A09] text-base mb-3">{t('landing.moments.revision.card')}</p>
|
||||
<div className="flex gap-2">
|
||||
<span className="flex-1 py-2 rounded-lg bg-[#0B0A09]/5 text-center text-[11px] font-semibold text-[#0B0A09]/50">?</span>
|
||||
<span className="flex-1 py-2 rounded-lg bg-[#0B0A09] text-center text-[11px] font-semibold text-[#F4F1EA]">SM-2</span>
|
||||
</div>
|
||||
</div>
|
||||
</ProductMoment>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section className="px-5 sm:px-8 py-28">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h2 className="font-serif text-3xl sm:text-4xl tracking-tight text-center mb-16">
|
||||
{t('landing.how.title')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-10 md:gap-6">
|
||||
{['s0', 's1', 's2'].map((s, i) => (
|
||||
<div key={s} className="relative text-center md:text-left">
|
||||
<div className="text-[13px] font-semibold text-[#D4A373] mb-4 tracking-widest">0{i + 1}</div>
|
||||
<h3 className="font-serif text-xl mb-3">{t(`landing.how.${s}.title`)}</h3>
|
||||
<p className="text-sm text-white/45 leading-relaxed">{t(`landing.how.${s}.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>
|
||||
<section id="agents" className="px-5 sm:px-8 py-28 border-t border-white/[0.06]">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="max-w-2xl mb-14">
|
||||
<p className="text-[12px] uppercase tracking-[0.25em] text-[#D4A373] mb-4 font-medium">{t('landing.agents.label')}</p>
|
||||
<h2 className="font-serif text-3xl sm:text-5xl tracking-tight mb-5">{t('landing.agents.title')}</h2>
|
||||
<p className="text-white/50 text-lg leading-relaxed">{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 className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{(['scraper', 'researcher', 'slideGen', 'monitor', 'diagramGen', 'custom'] as const).map((key) => (
|
||||
<div key={key} className="p-6 rounded-2xl border border-white/[0.08] bg-white/[0.03] hover:bg-white/[0.06] transition-colors">
|
||||
<Bot size={18} className="text-[#D4A373] mb-4" />
|
||||
<h4 className="font-serif text-lg mb-2">{t(`landing.agents.${key}.title`)}</h4>
|
||||
<p className="text-sm text-white/40 leading-relaxed">{t(`landing.agents.${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">
|
||||
{/* BYOK */}
|
||||
<section className="px-5 sm:px-8 py-20">
|
||||
<div className="max-w-5xl mx-auto rounded-[28px] border border-white/10 bg-gradient-to-br from-[#1a1612] to-[#0B0A09] p-8 sm:p-12 flex flex-col md:flex-row gap-10 items-start">
|
||||
<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 className="flex items-center gap-2 text-[#D4A373] mb-4">
|
||||
<KeyRound size={18} />
|
||||
<span className="text-[12px] uppercase tracking-[0.2em] font-semibold">{t('landing.byok.label')}</span>
|
||||
</div>
|
||||
<h3 className="font-serif text-3xl tracking-tight mb-4">{t('landing.byok.title')}</h3>
|
||||
<p className="text-white/50 leading-relaxed">{t('landing.byok.desc')}</p>
|
||||
</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 className="w-full md:w-[320px] font-mono text-[11px] text-white/35 space-y-1.5 rounded-2xl border border-white/10 bg-black/40 p-5">
|
||||
<p className="text-[#D4A373]">{'{'}</p>
|
||||
<p className="pl-3">"provider": "anthropic",</p>
|
||||
<p className="pl-3">"model": "claude-sonnet",</p>
|
||||
<p className="pl-3 text-[#F4F1EA]/70">"apiKey": "sk-ant-…",</p>
|
||||
<p className="pl-3">"yours": true</p>
|
||||
<p className="text-[#D4A373]">{'}'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing */}
|
||||
<section id="pricing" className="py-32 px-8 bg-paper">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<section id="pricing" className="px-5 sm:px-8 py-28 border-t border-white/[0.06]">
|
||||
<div className="max-w-6xl 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 }} />
|
||||
)}
|
||||
<h2 className="font-serif text-3xl sm:text-5xl tracking-tight mb-4">{t('landing.pricing.title')}</h2>
|
||||
<p className="text-white/45 mb-8">{t('landing.pricing.desc')}</p>
|
||||
<div className="inline-flex p-1 rounded-full border border-white/10 bg-white/[0.03]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingInterval('monthly')}
|
||||
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all ${billingInterval === 'monthly' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/45'}`}
|
||||
>
|
||||
{t('landing.pricing.monthly')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingInterval('annual')}
|
||||
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all relative ${billingInterval === 'annual' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/45'}`}
|
||||
>
|
||||
{t('landing.pricing.annual')}
|
||||
<span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373]">-20%</span>
|
||||
</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'}`}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{PLANS.map((plan) => (
|
||||
<div
|
||||
key={plan.key}
|
||||
className={`rounded-2xl border p-6 flex flex-col ${
|
||||
plan.popular
|
||||
? 'border-[#D4A373]/50 bg-[#D4A373]/10'
|
||||
: 'border-white/[0.08] bg-white/[0.02]'
|
||||
}`}
|
||||
>
|
||||
{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">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-[#D4A373] mb-3">
|
||||
{t('landing.pricing.popular')}
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
<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>
|
||||
<h4 className="text-[12px] uppercase tracking-widest text-white/40 mb-2">
|
||||
{t(`landing.pricing.${plan.key}.name`)}
|
||||
</h4>
|
||||
<div className="flex items-baseline gap-1 mb-2">
|
||||
<span className="text-3xl font-serif">{plan.price}</span>
|
||||
{plan.period && <span className="text-xs text-white/35">{plan.period}</span>}
|
||||
</div>
|
||||
<div className="flex-1 space-y-4 mb-10">
|
||||
{[0, 1, 2, 3, 4, 5].map(j => {
|
||||
<p className="text-sm text-white/45 mb-6">{t(`landing.pricing.${plan.key}.desc`)}</p>
|
||||
<ul className="space-y-2.5 mb-8 flex-1">
|
||||
{[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
|
||||
if (!feat || feat.startsWith('landing.')) 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>
|
||||
<li key={j} className="flex gap-2 text-xs text-white/60">
|
||||
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
|
||||
{feat}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</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'}`}>
|
||||
</ul>
|
||||
<Link
|
||||
href="/register"
|
||||
className={`py-3 rounded-xl text-center text-[12px] font-semibold transition-colors ${
|
||||
plan.popular
|
||||
? 'bg-[#F4F1EA] text-[#0B0A09] hover:bg-white'
|
||||
: 'bg-white/10 text-white hover:bg-white/15'
|
||||
}`}
|
||||
>
|
||||
{t(`landing.pricing.${plan.key}.cta`)}
|
||||
</button>
|
||||
</Link>
|
||||
</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)]">
|
||||
<section className="relative px-5 sm:px-8 py-36 text-center overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[300px] bg-[#D4A373]/20 blur-[100px] rounded-full" />
|
||||
</div>
|
||||
<div className="relative z-10 max-w-2xl mx-auto">
|
||||
<BrainCircuit className="mx-auto text-[#D4A373] mb-6" size={32} strokeWidth={1.25} />
|
||||
<h2 className="font-serif text-4xl sm:text-6xl tracking-tight leading-tight mb-6">
|
||||
{t('landing.cta.title')}
|
||||
</h2>
|
||||
<p className="text-white/50 text-lg mb-10">{t('landing.cta.desc')}</p>
|
||||
<Link
|
||||
href="/register"
|
||||
className="inline-flex items-center gap-3 px-10 py-4 rounded-full bg-[#F4F1EA] text-[#0B0A09] text-[15px] font-semibold hover:bg-white transition-all hover:scale-[1.02]"
|
||||
>
|
||||
{t('landing.cta.button')}
|
||||
</button>
|
||||
<ArrowRight size={18} />
|
||||
</Link>
|
||||
<p className="mt-4 text-[12px] text-white/30">{t('landing.hero.ctaHint')}</p>
|
||||
</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>
|
||||
<footer className="px-5 sm:px-8 py-14 border-t border-white/[0.06]">
|
||||
<div className="max-w-6xl mx-auto flex flex-col md:flex-row justify-between gap-10">
|
||||
<div className="max-w-xs">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-7 h-7 bg-[#F4F1EA] text-[#0B0A09] flex items-center justify-center rounded-md">
|
||||
<span className="font-serif font-bold text-sm">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-xl font-medium tracking-tight">Memento</span>
|
||||
<span className="font-serif text-lg">Memento</span>
|
||||
</div>
|
||||
<p className="text-sm text-concrete font-light max-w-xs">{t('landing.footer.desc')}</p>
|
||||
<p className="text-sm text-white/35">{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 => {
|
||||
<div className="grid grid-cols-3 gap-10 text-sm">
|
||||
{(['product', 'community', 'legal'] as const).map((section) => (
|
||||
<div key={section}>
|
||||
<p className="text-[11px] uppercase tracking-widest text-white/40 mb-3">
|
||||
{t(`landing.footer.${section}.title`)}
|
||||
</p>
|
||||
<ul className="space-y-2 text-white/50">
|
||||
{[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>
|
||||
return (
|
||||
<li key={j}>
|
||||
<a href={href} className="hover:text-white transition-colors">{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>
|
||||
<p className="max-w-6xl mx-auto mt-12 pt-8 border-t border-white/[0.06] text-[11px] text-white/25 tracking-wide">
|
||||
© 2026 Memento. {t('landing.footer.rights')}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProductMoment({
|
||||
id,
|
||||
eyebrow,
|
||||
title,
|
||||
desc,
|
||||
children,
|
||||
dark,
|
||||
compact,
|
||||
}: {
|
||||
id?: string
|
||||
eyebrow: string
|
||||
title: string
|
||||
desc: string
|
||||
children: ReactNode
|
||||
dark?: boolean
|
||||
compact?: boolean
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
id={id}
|
||||
initial={{ opacity: 0, y: 28 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-60px' }}
|
||||
transition={{ duration: 0.55 }}
|
||||
className={`rounded-[24px] sm:rounded-[32px] border overflow-hidden ${
|
||||
dark
|
||||
? 'bg-[#141210] border-white/[0.08] text-[#F4F1EA]'
|
||||
: 'bg-[#EDE8DF] border-transparent text-[#0B0A09]'
|
||||
} ${compact ? '' : 'grid grid-cols-1 lg:grid-cols-2'}`}
|
||||
>
|
||||
<div className={`p-8 sm:p-10 flex flex-col justify-center ${compact ? 'pb-4' : ''}`}>
|
||||
<p className={`text-[11px] uppercase tracking-[0.22em] font-semibold mb-3 ${dark ? 'text-[#D4A373]' : 'text-[#A47148]'}`}>
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h3 className="font-serif text-2xl sm:text-3xl tracking-tight mb-3 leading-tight">{title}</h3>
|
||||
<p className={`text-[15px] leading-relaxed ${dark ? 'text-white/50' : 'text-[#0B0A09]/55'}`}>{desc}</p>
|
||||
</div>
|
||||
<div className={`${compact ? 'px-4 pb-6' : 'p-6 sm:p-8'} flex items-center`}>
|
||||
<div className="w-full">{children}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -761,6 +761,9 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
)}
|
||||
{t('richTextEditor.publishWithAi')}
|
||||
</button>
|
||||
<p className="text-[10px] text-muted-foreground text-center">
|
||||
{t('richTextEditor.publishWithAiCostHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2, Bot, Trash2, Download, Pencil, Presentation, Wind, Scissors } from 'lucide-react'
|
||||
import { Bell, Check, X, Clock, AlertCircle, Circle, Share2, Bot, Download, Pencil, Presentation, Wind, Scissors } from 'lucide-react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -46,15 +44,6 @@ interface ReminderNote {
|
||||
isReminderDone: boolean
|
||||
}
|
||||
|
||||
// ── Memento brand tokens ──────────────────────────────────────────────────────
|
||||
const C = {
|
||||
blue: '#FDFDFE',
|
||||
gold: '#D4A373',
|
||||
green: '#A3B18A',
|
||||
dark: '#1C1C1C',
|
||||
beige: '#FDFDFE',
|
||||
}
|
||||
|
||||
export function NotificationPanel() {
|
||||
const { t } = useLanguage()
|
||||
const router = useRouter()
|
||||
@@ -171,38 +160,32 @@ export function NotificationPanel() {
|
||||
|
||||
const hasContent = requests.length > 0 || brainstormShares.length > 0 || activeReminders.length > 0 || appNotifications.length > 0
|
||||
|
||||
// ── icon bg/color per notification type ──────────────────────────────────
|
||||
const notifIconStyle = (type: string) => {
|
||||
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' }
|
||||
if (type === 'clip') return { bg: 'rgba(99,102,241,0.12)', color: '#6366F1' }
|
||||
return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
|
||||
// ── icon styles per notification type (theme-aware via opacity) ─────────
|
||||
const notifIconClass = (type: string) => {
|
||||
if (type === 'agent_failure') return 'bg-red-500/15 text-red-500'
|
||||
if (type.startsWith('agent')) return 'bg-brand-accent/15 text-brand-accent'
|
||||
if (type.startsWith('brainstorm')) return 'bg-ochre/15 text-ochre'
|
||||
if (type === 'clip') return 'bg-brand-accent/15 text-brand-accent'
|
||||
return 'bg-muted text-muted-foreground'
|
||||
}
|
||||
|
||||
const notifLabelColor = (type: string) => {
|
||||
if (type.startsWith('agent')) {
|
||||
if (type === 'agent_failure') return '#EF4444'
|
||||
if (type === 'brainstorm_invite') return '#A3B18A'
|
||||
if (type === 'brainstorm_joined') return '#A3B18A'
|
||||
return '#A47148'
|
||||
}
|
||||
return '#A3B18A'
|
||||
const notifLabelClass = (type: string) => {
|
||||
if (type === 'agent_failure') return 'text-red-500'
|
||||
if (type.startsWith('agent')) return 'text-brand-accent'
|
||||
if (type.startsWith('brainstorm')) return 'text-ochre'
|
||||
return 'text-muted-foreground'
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="relative 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"
|
||||
className="relative p-1.5 text-muted-foreground hover:text-foreground transition-all hover:bg-black/5 dark:hover:bg-white/10 rounded-lg border border-transparent hover:border-border"
|
||||
>
|
||||
<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 bg-brand-accent"
|
||||
className="absolute -top-1 -right-1 h-4 w-4 flex items-center justify-center rounded-full text-white text-[9px] font-bold border-2 border-background shadow-sm bg-brand-accent"
|
||||
>
|
||||
{pendingCount > 9 ? '9+' : pendingCount}
|
||||
</span>
|
||||
@@ -210,12 +193,15 @@ export function NotificationPanel() {
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="end" className="w-80 p-0 rounded-2xl overflow-hidden shadow-2xl border border-black/20">
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-80 p-0 rounded-2xl overflow-hidden shadow-2xl border border-border bg-popover text-popover-foreground"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b flex items-center justify-between" style={{ background: '#FDFDFE' }}>
|
||||
<div className="px-4 py-3 border-b border-border bg-muted/40 dark:bg-white/[0.03] flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4" style={{ color: C.dark }} />
|
||||
<span className="font-bold text-sm tracking-tight" style={{ color: C.dark }}>
|
||||
<Bell className="h-4 w-4 text-foreground/70" />
|
||||
<span className="font-bold text-sm tracking-tight text-foreground">
|
||||
{t('notification.notifications')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -223,7 +209,7 @@ export function NotificationPanel() {
|
||||
{appNotifications.length > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllRead}
|
||||
className="text-[10px] text-foreground/40 hover:text-foreground transition-colors"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-1 rounded-md hover:bg-black/5 dark:hover:bg-white/10"
|
||||
title={t('notification.markAllRead') || 'Mark all read'}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
@@ -241,25 +227,24 @@ export function NotificationPanel() {
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
<div className="animate-spin h-6 w-6 border-2 border-t-transparent rounded-full mx-auto mb-2" style={{ borderColor: C.blue, borderTopColor: 'transparent' }} />
|
||||
<div className="animate-spin h-6 w-6 border-2 border-brand-accent border-t-transparent rounded-full mx-auto mb-2" />
|
||||
</div>
|
||||
) : !hasContent ? (
|
||||
<div className="p-8 text-center">
|
||||
<Bell className="h-9 w-9 mx-auto mb-3 opacity-20" />
|
||||
<p className="text-[12px] font-medium text-foreground/40">{t('notification.noNotifications') || 'Aucune notification'}</p>
|
||||
<Bell className="h-9 w-9 mx-auto mb-3 text-muted-foreground/40" />
|
||||
<p className="text-[12px] font-medium text-muted-foreground">{t('notification.noNotifications') || 'Aucune notification'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-96 overflow-y-auto divide-y divide-black/5">
|
||||
<div className="max-h-96 overflow-y-auto divide-y divide-border">
|
||||
|
||||
{/* ── App notifications (agents, system) ── */}
|
||||
{appNotifications.map((notif) => {
|
||||
const isSlides = notif.type === 'agent_slides_ready'
|
||||
const isCanvas = notif.type === 'agent_canvas_ready'
|
||||
const canvasId = notif.relatedId
|
||||
const iconStyle = notifIconStyle(notif.type)
|
||||
|
||||
return (
|
||||
<div key={notif.id} className="p-3 hover:bg-black/[0.02] transition-colors">
|
||||
<div key={notif.id} className="p-3 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors">
|
||||
<div
|
||||
className="flex items-start gap-3 cursor-pointer"
|
||||
onClick={() => {
|
||||
@@ -270,11 +255,7 @@ export function NotificationPanel() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Icon badge */}
|
||||
<div
|
||||
className="mt-0.5 flex-none rounded-lg p-1.5"
|
||||
style={{ background: iconStyle.bg, color: iconStyle.color }}
|
||||
>
|
||||
<div className={cn('mt-0.5 flex-none rounded-lg p-1.5', notifIconClass(notif.type))}>
|
||||
{isSlides ? <Presentation className="w-3.5 h-3.5" />
|
||||
: isCanvas ? <Pencil className="w-3.5 h-3.5" />
|
||||
: notif.type === 'brainstorm_invite' ? <Wind className="w-3.5 h-3.5" />
|
||||
@@ -285,10 +266,7 @@ export function NotificationPanel() {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<span
|
||||
className="text-[9px] font-bold uppercase tracking-[0.2em]"
|
||||
style={{ color: notifLabelColor(notif.type) }}
|
||||
>
|
||||
<span className={cn('text-[9px] font-bold uppercase tracking-[0.2em]', notifLabelClass(notif.type))}>
|
||||
{notif.type === 'agent_slides_ready' && (t('notification.slidesReady') || 'Présentation prête')}
|
||||
{notif.type === 'agent_canvas_ready' && (t('notification.canvasReady') || 'Diagramme prêt')}
|
||||
{notif.type === 'agent_success' && (t('notification.agentSuccess') || 'Agent terminé')}
|
||||
@@ -298,11 +276,11 @@ export function NotificationPanel() {
|
||||
{notif.type === 'clip' && (t('notification.clipSaved') || 'Web clip')}
|
||||
{notif.type === 'system' && t('notification.systemNotification')}
|
||||
</span>
|
||||
<p className="text-[13px] font-semibold truncate mt-0.5">{notif.title}</p>
|
||||
<p className="text-[13px] font-semibold truncate mt-0.5 text-foreground">{notif.title}</p>
|
||||
{notif.message && (
|
||||
<p className="text-[11px] text-foreground/50 mt-0.5 line-clamp-2">{notif.message}</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 line-clamp-2">{notif.message}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1 mt-1 text-[10px] text-foreground/30">
|
||||
<div className="flex items-center gap-1 mt-1 text-[10px] text-muted-foreground/70">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })}
|
||||
</div>
|
||||
@@ -310,13 +288,12 @@ export function NotificationPanel() {
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleMarkNotifRead(notif.id) }}
|
||||
className="mt-0.5 text-foreground/20 hover:text-foreground transition-colors"
|
||||
className="mt-0.5 text-muted-foreground/50 hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Download PPTX button */}
|
||||
{isSlides && canvasId && (
|
||||
<div className="mt-2 ml-8">
|
||||
<button
|
||||
@@ -338,8 +315,7 @@ export function NotificationPanel() {
|
||||
document.body.removeChild(a); URL.revokeObjectURL(url)
|
||||
} catch { toast.error(t('notification.downloadFailed')) }
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold rounded-lg text-white uppercase tracking-wide transition-all hover:opacity-90 active:scale-95 shadow-sm"
|
||||
style={{ background: C.blue }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold rounded-lg bg-brand-accent text-white uppercase tracking-wide transition-all hover:opacity-90 active:scale-95 shadow-sm"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
{t('notification.downloadPptx') || 'Télécharger .pptx'}
|
||||
@@ -352,25 +328,24 @@ export function NotificationPanel() {
|
||||
|
||||
{/* ── Overdue reminders ── */}
|
||||
{overdueReminders.map((note) => (
|
||||
<div key={note.id} className="p-3 hover:bg-black/[0.02] transition-colors">
|
||||
<div key={note.id} className="p-3 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors">
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
onClick={() => handleToggleReminder(note.id, true)}
|
||||
className="mt-0.5 flex-none transition-colors hover:opacity-70"
|
||||
style={{ color: C.green }}
|
||||
className="mt-0.5 flex-none text-ochre transition-colors hover:opacity-70"
|
||||
title={t('reminders.markDone')}
|
||||
>
|
||||
<Circle className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<AlertCircle className="w-3 h-3" style={{ color: C.green }} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: C.green }}>
|
||||
<AlertCircle className="w-3 h-3 text-ochre" />
|
||||
<span className="text-[9px] font-bold uppercase tracking-[0.2em] text-ochre">
|
||||
{t('reminders.overdue')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[13px] font-semibold truncate">{note.title || t('notification.untitled')}</p>
|
||||
<div className="flex items-center gap-1 mt-1 text-[10px] text-foreground/30">
|
||||
<p className="text-[13px] font-semibold truncate text-foreground">{note.title || t('notification.untitled')}</p>
|
||||
<div className="flex items-center gap-1 mt-1 text-[10px] text-muted-foreground/70">
|
||||
<Clock className="w-3 h-3" />
|
||||
{note.reminder && formatDistanceToNow(new Date(note.reminder), { addSuffix: true })}
|
||||
</div>
|
||||
@@ -381,12 +356,12 @@ export function NotificationPanel() {
|
||||
|
||||
{/* ── Upcoming reminders ── */}
|
||||
{upcomingReminders.slice(0, 5).map((note) => (
|
||||
<div key={note.id} className="p-3 hover:bg-black/[0.02] transition-colors">
|
||||
<div key={note.id} className="p-3 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors">
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock className="w-4 h-4 mt-0.5 flex-none" style={{ color: C.blue }} />
|
||||
<Clock className="w-4 h-4 mt-0.5 flex-none text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] font-semibold truncate">{note.title || t('notification.untitled')}</p>
|
||||
<div className="text-[11px] text-foreground/40 mt-0.5">
|
||||
<p className="text-[13px] font-semibold truncate text-foreground">{note.title || t('notification.untitled')}</p>
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5">
|
||||
{note.reminder && new Date(note.reminder).toLocaleDateString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
})}
|
||||
@@ -398,25 +373,22 @@ export function NotificationPanel() {
|
||||
|
||||
{/* ── Brainstorm share invites ── */}
|
||||
{brainstormShares.map((share) => (
|
||||
<div key={share.id} className="p-4 hover:bg-black/[0.02] transition-colors space-y-3">
|
||||
<div key={share.id} className="p-4 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors space-y-3">
|
||||
<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, #A47148, #A47148)` }}
|
||||
>
|
||||
<div className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm bg-brand-accent">
|
||||
{(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: '#A47148' }} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: '#A47148' }}>
|
||||
Brainstorm
|
||||
<Wind className="w-3 h-3 text-brand-accent" />
|
||||
<span className="text-[9px] font-bold uppercase tracking-[0.2em] text-brand-accent">
|
||||
{t('notification.brainstormInvite')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[13px] font-semibold truncate">
|
||||
<p className="text-[13px] font-semibold truncate text-foreground">
|
||||
{share.sharer?.name || share.sharer?.email}
|
||||
</p>
|
||||
<p className="text-[11px] text-foreground/50 truncate">
|
||||
<p className="text-[11px] text-muted-foreground truncate">
|
||||
{t('notification.brainstormShared') || 'invited you to a brainstorm'} « {share.session?.seedIdea?.length > 35 ? share.session.seedIdea.substring(0, 35) + '…' : share.session?.seedIdea} »
|
||||
</p>
|
||||
</div>
|
||||
@@ -424,7 +396,7 @@ export function NotificationPanel() {
|
||||
<div className="flex gap-2 ml-11">
|
||||
<button
|
||||
onClick={() => handleDeclineBrainstorm(share.id)}
|
||||
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-black/15 text-foreground/60 hover:bg-black/5 transition-all active:scale-95 flex items-center justify-center gap-1"
|
||||
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-border text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground transition-all active:scale-95 flex items-center justify-center gap-1"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
{t('notification.decline') || 'Decline'}
|
||||
@@ -442,26 +414,22 @@ export function NotificationPanel() {
|
||||
|
||||
{/* ── Share requests ── */}
|
||||
{requests.map((request) => (
|
||||
<div key={request.id} className="p-4 hover:bg-black/[0.02] transition-colors space-y-3">
|
||||
<div key={request.id} className="p-4 hover:bg-black/[0.03] dark:hover:bg-white/[0.04] transition-colors space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Avatar */}
|
||||
<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, ${C.blue}, ${C.green})` }}
|
||||
>
|
||||
<div className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm bg-brand-accent">
|
||||
{(request.sharer.name || request.sharer.email)[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<Share2 className="w-3 h-3" style={{ color: C.blue }} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: C.blue }}>
|
||||
Partage
|
||||
<Share2 className="w-3 h-3 text-brand-accent" />
|
||||
<span className="text-[9px] font-bold uppercase tracking-[0.2em] text-brand-accent">
|
||||
{t('notification.shareLabel')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[13px] font-semibold truncate">
|
||||
<p className="text-[13px] font-semibold truncate text-foreground">
|
||||
{request.sharer.name || request.sharer.email}
|
||||
</p>
|
||||
<p className="text-[11px] text-foreground/50 truncate">
|
||||
<p className="text-[11px] text-muted-foreground truncate">
|
||||
{t('notification.shared', { title: request.note.title || t('notification.untitled') })}
|
||||
</p>
|
||||
</div>
|
||||
@@ -470,15 +438,14 @@ export function NotificationPanel() {
|
||||
<div className="flex gap-2 ml-11">
|
||||
<button
|
||||
onClick={() => handleDecline(request.id)}
|
||||
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-black/15 text-foreground/60 hover:bg-black/5 transition-all active:scale-95 flex items-center justify-center gap-1"
|
||||
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-border text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground transition-all active:scale-95 flex items-center justify-center gap-1"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
{t('notification.decline') || 'Refuser'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAccept(request.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: C.blue }}
|
||||
className="flex-1 h-7 px-3 text-[11px] font-bold rounded-lg text-white bg-brand-accent transition-all active:scale-95 flex items-center justify-center gap-1 shadow-sm hover:opacity-90"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
{t('notification.accept') || 'Accepter'}
|
||||
@@ -489,13 +456,11 @@ export function NotificationPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
{activeReminders.length > 0 && (
|
||||
<div className="px-4 py-2.5 border-t bg-black/[0.02]">
|
||||
<div className="px-4 py-2.5 border-t border-border bg-muted/30 dark:bg-white/[0.02]">
|
||||
<a
|
||||
href="/reminders"
|
||||
className="text-[11px] font-semibold hover:opacity-70 transition-opacity"
|
||||
style={{ color: C.blue }}
|
||||
className="text-[11px] font-semibold text-brand-accent hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{t('reminders.viewAll') || 'Voir tous les rappels →'}
|
||||
</a>
|
||||
|
||||
42
memento-note/components/publish/copy-link-button.tsx
Normal file
42
memento-note/components/publish/copy-link-button.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type CSSProperties } from 'react'
|
||||
import { Check, Link2 } from 'lucide-react'
|
||||
|
||||
export function CopyLinkButton({
|
||||
label = 'Copier le lien',
|
||||
copiedLabel = 'Copié',
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
label?: string
|
||||
copiedLabel?: string
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const onCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onCopy()}
|
||||
className={className}
|
||||
style={style}
|
||||
aria-label={copied ? copiedLabel : label}
|
||||
title={copied ? copiedLabel : label}
|
||||
>
|
||||
{copied ? <Check size={13} strokeWidth={2.25} /> : <Link2 size={13} strokeWidth={2.25} />}
|
||||
<span>{copied ? copiedLabel : label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
77
memento-note/components/publish/reading-progress.tsx
Normal file
77
memento-note/components/publish/reading-progress.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Thin reading progress bar. Tracks the nearest scrollable ancestor
|
||||
* (public layout uses a fixed-height overflow container, not window).
|
||||
*/
|
||||
export function ReadingProgress({ accent = '#A47148' }: { accent?: string }) {
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const findScrollRoot = (): HTMLElement | Window => {
|
||||
let el: HTMLElement | null = document.querySelector('[data-pub-root]') as HTMLElement | null
|
||||
while (el) {
|
||||
const { overflowY } = getComputedStyle(el)
|
||||
if (overflowY === 'auto' || overflowY === 'scroll') return el
|
||||
el = el.parentElement
|
||||
}
|
||||
return window
|
||||
}
|
||||
|
||||
const root = findScrollRoot()
|
||||
|
||||
const update = () => {
|
||||
let scrollTop: number
|
||||
let scrollHeight: number
|
||||
let clientHeight: number
|
||||
if (root === window) {
|
||||
scrollTop = window.scrollY
|
||||
scrollHeight = document.documentElement.scrollHeight
|
||||
clientHeight = window.innerHeight
|
||||
} else {
|
||||
const node = root as HTMLElement
|
||||
scrollTop = node.scrollTop
|
||||
scrollHeight = node.scrollHeight
|
||||
clientHeight = node.clientHeight
|
||||
}
|
||||
const max = Math.max(1, scrollHeight - clientHeight)
|
||||
setProgress(Math.min(100, Math.max(0, (scrollTop / max) * 100)))
|
||||
}
|
||||
|
||||
update()
|
||||
root.addEventListener('scroll', update, { passive: true })
|
||||
window.addEventListener('resize', update, { passive: true })
|
||||
return () => {
|
||||
root.removeEventListener('scroll', update)
|
||||
window.removeEventListener('resize', update)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 3,
|
||||
zIndex: 100,
|
||||
background: 'transparent',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${progress}%`,
|
||||
background: accent,
|
||||
boxShadow: `0 0 12px ${accent}66`,
|
||||
transition: 'width 80ms linear',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
|
||||
import { Check, Shield, Zap, Crown, X, ExternalLink, Loader2, CheckCircle2, Activity, Clock, ArrowRight } from 'lucide-react';
|
||||
import { Check, Shield, Zap, Crown, X, ExternalLink, Loader2, CheckCircle2, ArrowRight, Coins } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
import { toast } from 'sonner';
|
||||
@@ -33,6 +33,14 @@ interface BillingStatus {
|
||||
year: { display: string; amount: number; currency: string };
|
||||
};
|
||||
};
|
||||
creditPacks?: Array<{
|
||||
id: 'S' | 'M' | 'L';
|
||||
credits: number;
|
||||
display: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
configured: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
const billingEnabledEnvFallback = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' || process.env.NODE_ENV === 'development';
|
||||
@@ -53,6 +61,7 @@ export function BillingPlans() {
|
||||
const [checkoutClientSecret, setCheckoutClientSecret] = useState<string | null>(null);
|
||||
const [isCheckoutOpen, setIsCheckoutOpen] = useState(false);
|
||||
const [checkoutLoading, setCheckoutLoading] = useState<Tier | null>(null);
|
||||
const [packLoading, setPackLoading] = useState<'S' | 'M' | 'L' | null>(null);
|
||||
const [portalLoading, setPortalLoading] = useState(false);
|
||||
const [cancelLoading, setCancelLoading] = useState(false);
|
||||
const [successBanner, setSuccessBanner] = useState<string | null>(null);
|
||||
@@ -78,16 +87,35 @@ export function BillingPlans() {
|
||||
});
|
||||
|
||||
const quotas = usageData?.quotas;
|
||||
const balance = usageData?.balance as
|
||||
| {
|
||||
totalRemaining: number | null
|
||||
subscriptionGranted: number | null
|
||||
purchasedRemaining: number
|
||||
spentThisPeriod: number
|
||||
unlimited: boolean
|
||||
}
|
||||
| undefined
|
||||
const breakdown = (usageData?.breakdown ?? []) as Array<{
|
||||
feature: string
|
||||
creditsUsed: number
|
||||
actionsCount: number
|
||||
}>
|
||||
const billingEnabled = status?.billingEnabled ?? billingEnabledEnvFallback;
|
||||
const stripe = getStripePromise(billingEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const sessionId = params.get('session_id');
|
||||
const isPack = params.get('pack') === '1';
|
||||
|
||||
if (sessionId) {
|
||||
const tier = status?.effectiveTier ?? 'Pro';
|
||||
setSuccessBanner(t('billing.checkoutSuccessBody').replace('{tier}', tier));
|
||||
if (isPack) {
|
||||
setSuccessBanner(t('billing.packCheckoutSuccess'));
|
||||
} else {
|
||||
const tier = status?.effectiveTier ?? 'Pro';
|
||||
setSuccessBanner(t('billing.checkoutSuccessBody').replace('{tier}', tier));
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['billing', 'status'] });
|
||||
window.history.replaceState({}, '', '/settings/billing');
|
||||
@@ -128,6 +156,30 @@ export function BillingPlans() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBuyPack = async (packId: 'S' | 'M' | 'L') => {
|
||||
if (!billingEnabled) return;
|
||||
setPackLoading(packId);
|
||||
try {
|
||||
const res = await fetch('/api/billing/create-pack-checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ packId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? 'Failed to create pack checkout');
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
} else {
|
||||
throw new Error('No checkout URL');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[BillingPlans] pack checkout error:', err);
|
||||
toast.error(t('billing.packCheckoutFailed'));
|
||||
} finally {
|
||||
setPackLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePortal = async (action: 'portal' | 'cancel' | React.MouseEvent = 'portal') => {
|
||||
const actualAction = typeof action === 'string' ? action : 'portal';
|
||||
setPortalLoading(true);
|
||||
@@ -199,23 +251,19 @@ export function BillingPlans() {
|
||||
const effectiveTier = status?.effectiveTier ?? 'BASIC';
|
||||
const isPaid = effectiveTier !== 'BASIC';
|
||||
|
||||
const aiUsed = quotas?.semantic_search?.used ?? 0;
|
||||
const aiLimit = quotas?.semantic_search?.limit ?? 50;
|
||||
const aiPct = aiLimit > 0 ? (aiUsed / aiLimit) * 100 : 0;
|
||||
|
||||
const plans = [
|
||||
{
|
||||
id: 'free',
|
||||
name: t('billing.freePlan'),
|
||||
price: t('billing.freePrice') || 'Gratuit',
|
||||
period: '',
|
||||
description: t('billing.freeDescription') || 'Pour découvrir la magie de Memento.',
|
||||
description: t('billing.freeDescription') || 'Pour découvrir Memento.',
|
||||
features: [
|
||||
t('billing.freeF1') || '100 Notes max',
|
||||
t('billing.freeF2') || '3 Carnets',
|
||||
t('billing.freeF3') || '50 crédits IA (Lifetime)',
|
||||
t('billing.freeF4') || 'Recherche sémantique',
|
||||
t('billing.freeF5') || 'Historique 7 jours',
|
||||
t('billing.freeF1'),
|
||||
t('billing.freeF2'),
|
||||
t('billing.freeF3'),
|
||||
t('billing.freeF4'),
|
||||
t('billing.freeF5'),
|
||||
],
|
||||
current: effectiveTier === 'BASIC',
|
||||
buttonText: effectiveTier === 'BASIC' ? (t('billing.currentPlan') || 'Plan Actuel') : t('billing.startCheckout'),
|
||||
@@ -232,12 +280,12 @@ export function BillingPlans() {
|
||||
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
|
||||
description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
|
||||
features: [
|
||||
t('billing.proFeature1') || 'Notes illimitées',
|
||||
t('billing.proFeature2') || 'BYOK (OpenAI/Anthropic)',
|
||||
t('billing.proFeature3') || '200 recherches sémantiques',
|
||||
t('billing.proFeature4') || 'Agents (12 runs/mois)',
|
||||
t('billing.proFeature5') || 'Historique 30 jours',
|
||||
t('billing.proFeature6') || 'Support Email',
|
||||
t('billing.proFeature1'),
|
||||
t('billing.proFeature2'),
|
||||
t('billing.proFeature3'),
|
||||
t('billing.proFeature4'),
|
||||
t('billing.proFeature5'),
|
||||
t('billing.proFeature6'),
|
||||
],
|
||||
current: effectiveTier === 'PRO',
|
||||
popular: true,
|
||||
@@ -254,12 +302,12 @@ export function BillingPlans() {
|
||||
(interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€')),
|
||||
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
|
||||
features: [
|
||||
t('billing.businessFeature1') || '10 Collaborateurs inclus',
|
||||
t('billing.businessFeature2') || 'BYOK (13 fournisseurs)',
|
||||
t('billing.businessFeature3') || '1000 recherches sémantiques',
|
||||
t('billing.businessFeature4') || 'Agents (60 runs/mois)',
|
||||
t('billing.businessFeature5') || 'Brainstorm illimité',
|
||||
t('billing.businessFeature6') || 'Accès API',
|
||||
t('billing.businessFeature1'),
|
||||
t('billing.businessFeature2'),
|
||||
t('billing.businessFeature3'),
|
||||
t('billing.businessFeature4'),
|
||||
t('billing.businessFeature5'),
|
||||
t('billing.businessFeature6'),
|
||||
],
|
||||
current: effectiveTier === 'BUSINESS',
|
||||
buttonText: effectiveTier === 'BUSINESS' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.businessCta') || 'Choisir Plan Business'),
|
||||
@@ -273,13 +321,13 @@ export function BillingPlans() {
|
||||
name: t('billing.enterpriseTitle') || 'Enterprise',
|
||||
price: t('billing.contactSales') || 'Sur devis',
|
||||
period: '',
|
||||
description: t('billing.enterpriseDescription') || 'Quotas personnalisés, SSO, support prioritaire.',
|
||||
description: t('billing.enterpriseDescription') || 'Crédits illimités ou pool dédié, SSO, support prioritaire.',
|
||||
features: [
|
||||
t('billing.enterpriseFeature1') || 'Quotas illimités',
|
||||
t('billing.enterpriseFeature2') || 'SSO / SAML',
|
||||
t('billing.enterpriseFeature3') || 'Support dédié',
|
||||
t('billing.enterpriseFeature4') || 'Facturation personnalisée',
|
||||
t('billing.enterpriseFeature5') || 'SLA garanti',
|
||||
t('billing.enterpriseFeature1'),
|
||||
t('billing.enterpriseFeature2'),
|
||||
t('billing.enterpriseFeature3'),
|
||||
t('billing.enterpriseFeature4'),
|
||||
t('billing.enterpriseFeature5'),
|
||||
],
|
||||
current: effectiveTier === 'ENTERPRISE',
|
||||
buttonText: effectiveTier === 'ENTERPRISE' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.contactSales') || 'Contact Sales'),
|
||||
@@ -307,18 +355,21 @@ export function BillingPlans() {
|
||||
}
|
||||
};
|
||||
|
||||
// Sommes pour l'usage global agrégé (donut)
|
||||
let totalUsed = 0;
|
||||
let totalLimit = 0;
|
||||
if (quotas) {
|
||||
Object.entries(quotas as Record<string, any>).forEach(([_, q]) => {
|
||||
if (q.limit > 0 && q.limit !== Infinity) {
|
||||
totalUsed += q.used;
|
||||
totalLimit += q.limit;
|
||||
}
|
||||
});
|
||||
}
|
||||
const globalPct = totalLimit > 0 ? (totalUsed / totalLimit) * 100 : 0;
|
||||
// Solde crédits global (modèle A) — pas la somme des anciens seaux
|
||||
const unlimitedCredits = !!balance?.unlimited
|
||||
const totalUsed = balance?.spentThisPeriod ?? 0
|
||||
const remainingCredits = unlimitedCredits ? null : (balance?.totalRemaining ?? 0)
|
||||
const granted = unlimitedCredits
|
||||
? null
|
||||
: (balance?.subscriptionGranted ?? 0) + (balance?.purchasedRemaining ?? 0)
|
||||
// pot ≈ restants + déjà utilisés sur la fenêtre courante
|
||||
const totalLimit = unlimitedCredits
|
||||
? 0
|
||||
: Math.max(granted ?? 0, totalUsed + (remainingCredits ?? 0))
|
||||
const globalPct =
|
||||
unlimitedCredits || totalLimit <= 0
|
||||
? 0
|
||||
: Math.min(100, (totalUsed / totalLimit) * 100)
|
||||
|
||||
// SVG Donut Config
|
||||
const radius = 28;
|
||||
@@ -450,79 +501,160 @@ export function BillingPlans() {
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute flex flex-col items-center justify-center">
|
||||
<span className="text-xl font-bold text-ink">{Math.round(globalPct)}%</span>
|
||||
<span className="text-xl font-bold text-ink">
|
||||
{unlimitedCredits ? '∞' : `${Math.round(globalPct)}%`}
|
||||
</span>
|
||||
<span className="text-[8px] text-concrete uppercase tracking-widest">{t('billing.used')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-center">
|
||||
<p className="text-xs font-bold text-ink">
|
||||
{totalUsed} <span className="text-concrete font-light">/ {totalLimit} {t('billing.aiCredits') || 'crédits IA'}</span>
|
||||
{unlimitedCredits ? (
|
||||
<span className="text-concrete font-light">{t('billing.unlimited')}</span>
|
||||
) : (
|
||||
<>
|
||||
{totalUsed}{' '}
|
||||
<span className="text-concrete font-light">
|
||||
/ {totalLimit} {t('billing.aiCredits') || 'crédits IA'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{!unlimitedCredits && remainingCredits != null && (
|
||||
<p className="text-[10px] text-concrete mt-1">
|
||||
{remainingCredits} {t('billing.creditsRemaining')?.toLowerCase() || 'restants'}
|
||||
</p>
|
||||
)}
|
||||
{!unlimitedCredits && (balance?.purchasedRemaining ?? 0) > 0 && (
|
||||
<p className="text-[10px] text-brand-accent mt-1">
|
||||
{balance?.purchasedRemaining} {t('billing.creditsFromPacks')?.toLowerCase() || 'de packs'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage breakdown per feature */}
|
||||
{/* Packs de crédits (one-shot) */}
|
||||
{!unlimitedCredits && billingEnabled && (status?.creditPacks?.length ?? 0) > 0 && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2.5 bg-brand-accent/10 text-brand-accent rounded-2xl shrink-0">
|
||||
<Coins size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete">
|
||||
{t('billing.packsTitle')}
|
||||
</h3>
|
||||
<p className="text-[11px] text-concrete mt-1 leading-relaxed max-w-xl">
|
||||
{t('billing.packsDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{(status?.creditPacks ?? []).map((pack) => (
|
||||
<div
|
||||
key={pack.id}
|
||||
className="p-6 rounded-3xl border border-border bg-white/40 dark:bg-white/5 flex flex-col gap-4"
|
||||
>
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
|
||||
{t(`billing.pack${pack.id}Name`) || `Pack ${pack.id}`}
|
||||
</p>
|
||||
<p className="text-2xl font-serif font-bold text-ink mt-1 tabular-nums">
|
||||
{pack.credits.toLocaleString()}
|
||||
<span className="text-sm font-sans font-normal text-concrete ms-1">
|
||||
{t('billing.aiCredits') || 'crédits'}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-ink mt-2">{pack.display}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBuyPack(pack.id)}
|
||||
disabled={!pack.configured || packLoading !== null}
|
||||
className="mt-auto w-full py-3 rounded-2xl bg-ink text-white dark:bg-white dark:text-black text-[10px] font-bold uppercase tracking-[0.15em] hover:opacity-90 disabled:opacity-40 transition-all"
|
||||
>
|
||||
{packLoading === pack.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mx-auto" />
|
||||
) : (
|
||||
t('billing.buyPack')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Répartition crédits par fonction */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete">
|
||||
{t('billing.usageThisPeriod') || 'Utilisation sur cette période'}
|
||||
{t('billing.creditsWhereTitle') || t('billing.usageThisPeriod')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-concrete mt-1">
|
||||
{status?.currentPeriodStart && status?.currentPeriodEnd ? (
|
||||
`${formatDate(status.currentPeriodStart)} – ${formatDate(status.currentPeriodEnd)}`
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
{t('billing.creditsUsageLegend')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{!quotas && (
|
||||
{!usageData && (
|
||||
<div className="col-span-full flex items-center justify-center py-12 bg-white/40 dark:bg-white/5 border border-border rounded-3xl">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-brand-accent" />
|
||||
</div>
|
||||
)}
|
||||
{quotas && Object.entries(quotas as Record<string, any>).map(([key, q]) => {
|
||||
const pct = q.limit > 0 && q.limit !== Infinity ? (q.used / q.limit) * 100 : 0;
|
||||
const isUnlimited = q.limit === Infinity || q.limit <= 0;
|
||||
|
||||
const featureLabels: Record<string, string> = {
|
||||
semantic_search: t('usageMeter.featureSearch') || 'Recherche Sémantique',
|
||||
auto_tag: t('usageMeter.featureTags') || 'Tags Automatiques',
|
||||
auto_title: t('usageMeter.featureTitles') || 'Titres Automatiques',
|
||||
reformulate: t('usageMeter.featureReformulate') || 'Reformulation IA',
|
||||
chat: t('usageMeter.featureChat') || 'Chat IA',
|
||||
brainstorm_create: t('usageMeter.featureBrainstormCreate') || 'Création de Brainstorm',
|
||||
brainstorm_expand: t('usageMeter.featureBrainstormExpand') || 'Expansion de Brainstorm',
|
||||
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich') || 'Enrichissement de Brainstorm',
|
||||
};
|
||||
|
||||
// Couleur de barre : normal (violet), warning >= 75% (ambre), exhausted >= 100% (rose)
|
||||
const barFillColor = pct >= 100 ? 'bg-rose-400' : pct >= 75 ? 'bg-amber-400' : 'bg-gradient-to-r from-violet-400 to-purple-400';
|
||||
|
||||
return (
|
||||
<div key={key} className="p-5 rounded-2xl bg-white/40 dark:bg-white/5 border border-border space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[11px] font-bold text-ink uppercase tracking-wider truncate">
|
||||
{featureLabels[key] || key}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-concrete tabular-nums">
|
||||
{isUnlimited ? t('billing.unlimited') || 'Illimité' : `${q.used} / ${q.limit}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-2 w-full bg-secondary/40 rounded-full overflow-hidden">
|
||||
{usageData &&
|
||||
(() => {
|
||||
const featureLabels: Record<string, string> = {
|
||||
semantic_search: t('usageMeter.featureSearch'),
|
||||
auto_tag: t('usageMeter.featureTags'),
|
||||
auto_title: t('usageMeter.featureTitles'),
|
||||
reformulate: t('usageMeter.featureReformulate'),
|
||||
chat: t('usageMeter.featureChat'),
|
||||
brainstorm_create: t('usageMeter.featureBrainstormSessions'),
|
||||
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
|
||||
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
|
||||
suggest_charts: t('usageMeter.featureCharts'),
|
||||
publish_enhance: t('usageMeter.featurePublishEnhance'),
|
||||
ai_flashcard: t('usageMeter.featureFlashcards'),
|
||||
voice_transcribe: t('usageMeter.featureVoice'),
|
||||
slide_generate: t('usageMeter.featureSlides'),
|
||||
excalidraw_generate: t('usageMeter.featureDiagrams'),
|
||||
}
|
||||
const byFeature = new Map(breakdown.map((r) => [r.feature, r]))
|
||||
return Object.keys(featureLabels).map((key) => {
|
||||
const row = byFeature.get(key)
|
||||
const used = row?.creditsUsed ?? 0
|
||||
const pct = totalUsed > 0 && used > 0 ? (used / totalUsed) * 100 : 0
|
||||
const barFillColor =
|
||||
pct >= 40
|
||||
? 'bg-gradient-to-r from-violet-400 to-purple-400'
|
||||
: 'bg-gradient-to-r from-violet-300/80 to-purple-300/80'
|
||||
return (
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all duration-500', barFillColor)}
|
||||
style={{ width: `${isUnlimited ? 100 : Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
key={key}
|
||||
className="p-5 rounded-2xl bg-white/40 dark:bg-white/5 border border-border space-y-3"
|
||||
>
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<span className="text-[11px] font-bold text-ink uppercase tracking-wider truncate">
|
||||
{featureLabels[key]}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-concrete tabular-nums shrink-0">
|
||||
{used > 0 ? `${used} cr.` : '0'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-secondary/40 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all duration-500', barFillColor)}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,55 +1,84 @@
|
||||
'use client';
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { AlertTriangle, Sparkles, Key, X, ArrowRight } from 'lucide-react';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { AlertTriangle, Sparkles, Key, X, ArrowRight } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface InlinePaywallProps {
|
||||
feature: string;
|
||||
onDismiss: () => void;
|
||||
/** Action en cours (pour contexte uniquement — le solde est global) */
|
||||
feature: string
|
||||
onDismiss: () => void
|
||||
/** Coût de l'action si connu */
|
||||
creditCost?: number
|
||||
/** Solde restant si connu */
|
||||
creditsRemaining?: number
|
||||
}
|
||||
|
||||
export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
|
||||
const { t } = useLanguage();
|
||||
const router = useRouter();
|
||||
const [timeLeft, setTimeLeft] = useState(10);
|
||||
export function InlinePaywall({
|
||||
feature,
|
||||
onDismiss,
|
||||
creditCost,
|
||||
creditsRemaining,
|
||||
}: InlinePaywallProps) {
|
||||
const { t } = useLanguage()
|
||||
const router = useRouter()
|
||||
const [timeLeft, setTimeLeft] = useState(12)
|
||||
|
||||
useEffect(() => {
|
||||
if (timeLeft <= 0) {
|
||||
onDismiss();
|
||||
return;
|
||||
onDismiss()
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
setTimeLeft((prev) => prev - 1);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [timeLeft, onDismiss]);
|
||||
setTimeLeft((prev) => prev - 1)
|
||||
}, 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [timeLeft, onDismiss])
|
||||
|
||||
const handleUpgrade = () => {
|
||||
router.push('/settings/billing');
|
||||
onDismiss();
|
||||
};
|
||||
router.push('/settings/billing')
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
const handleByok = () => {
|
||||
router.push('/settings/ai#byok');
|
||||
onDismiss();
|
||||
};
|
||||
router.push('/settings/ai#byok')
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
const featureLabels: Record<string, string> = {
|
||||
semantic_search: t('usageMeter.featureSearch') || 'Recherche Sémantique',
|
||||
auto_tag: t('usageMeter.featureTags') || 'Tags Automatiques',
|
||||
auto_title: t('usageMeter.featureTitles') || 'Titres Automatiques',
|
||||
reformulate: t('usageMeter.featureReformulate') || 'Reformulation',
|
||||
chat: t('usageMeter.featureChat') || 'Chat IA',
|
||||
brainstorm_create: t('usageMeter.featureBrainstormCreate') || 'Création de Brainstorm',
|
||||
brainstorm_expand: t('usageMeter.featureBrainstormExpand') || 'Expansion de Brainstorm',
|
||||
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich') || 'Enrichissement de Brainstorm',
|
||||
publish_enhance: t('usageMeter.featurePublishEnhance') || 'Publication IA',
|
||||
};
|
||||
semantic_search: t('usageMeter.featureSearch'),
|
||||
auto_tag: t('usageMeter.featureTags'),
|
||||
auto_title: t('usageMeter.featureTitles'),
|
||||
reformulate: t('usageMeter.featureReformulate'),
|
||||
chat: t('usageMeter.featureChat'),
|
||||
brainstorm_create: t('usageMeter.featureBrainstormSessions'),
|
||||
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
|
||||
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
|
||||
publish_enhance: t('usageMeter.featurePublishEnhance'),
|
||||
slide_generate: t('usageMeter.featureSlides'),
|
||||
excalidraw_generate: t('usageMeter.featureDiagrams'),
|
||||
ai_flashcard: t('usageMeter.featureFlashcards'),
|
||||
voice_transcribe: t('usageMeter.featureVoice'),
|
||||
credits: t('billing.aiCredits'),
|
||||
}
|
||||
|
||||
const currentFeatureLabel = featureLabels[feature] || feature;
|
||||
const currentFeatureLabel = featureLabels[feature] || feature
|
||||
|
||||
let description = t('quotaPaywall.description').replace('{feature}', currentFeatureLabel)
|
||||
if (
|
||||
typeof creditCost === 'number' &&
|
||||
typeof creditsRemaining === 'number' &&
|
||||
Number.isFinite(creditCost) &&
|
||||
Number.isFinite(creditsRemaining)
|
||||
) {
|
||||
description = t('quotaPaywall.descriptionWithCost', {
|
||||
remaining: String(creditsRemaining),
|
||||
cost: String(creditCost),
|
||||
feature: currentFeatureLabel,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -66,17 +95,17 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<h4 className="text-sm font-bold text-rose-800 dark:text-rose-400">
|
||||
{t('quotaPaywall.title') || 'Limite mensuelle atteinte'}
|
||||
{t('quotaPaywall.title')}
|
||||
</h4>
|
||||
<p className="text-xs text-rose-700/80 dark:text-rose-400/70 font-light leading-relaxed">
|
||||
{(t('quotaPaywall.description') || "Vous avez épuisé vos crédits pour la fonctionnalité {feature} ce mois-ci.").replace('{feature}', currentFeatureLabel)}
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="text-rose-500/60 hover:text-rose-500 dark:text-rose-400/50 dark:hover:text-rose-400 p-1 hover:bg-rose-500/5 dark:hover:bg-rose-400/5 rounded-lg transition-all"
|
||||
title={t('quotaPaywall.later') || 'Peut-être plus tard'}
|
||||
title={t('quotaPaywall.later')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
@@ -89,17 +118,17 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-[#D4A373] text-white hover:bg-[#C49363] text-xs font-semibold rounded-xl transition-all shadow-sm shadow-[#D4A373]/15"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
{t('quotaPaywall.upgrade') || 'Passer au plan Pro'}
|
||||
{t('quotaPaywall.upgrade')}
|
||||
<ArrowRight size={12} className="ml-1" />
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleByok}
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 border border-rose-300 dark:border-rose-800/40 text-rose-800 dark:text-rose-400 hover:bg-rose-100/30 dark:hover:bg-rose-400/5 text-xs font-semibold rounded-xl transition-all"
|
||||
>
|
||||
<Key size={14} />
|
||||
{t('quotaPaywall.useOwnKey') || 'Utiliser ma propre clé'}
|
||||
{t('quotaPaywall.useOwnKey')}
|
||||
</button>
|
||||
|
||||
<div className="sm:ml-auto flex items-center justify-center gap-1.5 text-[10px] text-rose-500/60 dark:text-rose-400/40 font-mono">
|
||||
@@ -108,5 +137,5 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,59 +1,88 @@
|
||||
'use client';
|
||||
'use client'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2, BarChart2 } from 'lucide-react';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Loader2, BarChart2 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface QuotaEntry {
|
||||
remaining: number;
|
||||
limit: number;
|
||||
used: number;
|
||||
interface BalanceData {
|
||||
totalRemaining: number | null
|
||||
subscriptionRemaining: number | null
|
||||
purchasedRemaining: number
|
||||
subscriptionGranted: number | null
|
||||
spentThisPeriod: number
|
||||
unlimited: boolean
|
||||
}
|
||||
|
||||
type Quotas = Record<string, QuotaEntry>;
|
||||
interface BreakdownRow {
|
||||
feature: string
|
||||
creditsUsed: number
|
||||
actionsCount: number
|
||||
percentOfSpent: number
|
||||
}
|
||||
|
||||
/** Toutes les fonctions IA — même liste que le compteur sidebar. */
|
||||
const FEATURE_LABEL_KEYS: Record<string, string> = {
|
||||
aiSummary: 'sidebar.aiSummary',
|
||||
aiFlashcards: 'sidebar.aiFlashcards',
|
||||
aiMindmap: 'sidebar.aiMindmap',
|
||||
aiTranscribe: 'sidebar.aiTranscribe',
|
||||
aiDiagram: 'sidebar.aiDiagram',
|
||||
aiAgent: 'sidebar.aiAgent',
|
||||
semantic_search: 'usageMeter.featureSearch',
|
||||
auto_tag: 'usageMeter.featureTags',
|
||||
auto_title: 'usageMeter.featureTitles',
|
||||
reformulate: 'usageMeter.featureReformulate',
|
||||
chat: 'usageMeter.featureChat',
|
||||
brainstorm_create: 'usageMeter.featureBrainstormSessions',
|
||||
brainstorm_expand: 'usageMeter.featureBrainstormExpand',
|
||||
brainstorm_enrich: 'usageMeter.featureBrainstormEnrich',
|
||||
suggest_charts: 'usageMeter.featureCharts',
|
||||
publish_enhance: 'usageMeter.featurePublishEnhance',
|
||||
};
|
||||
|
||||
function UsageBar({ used, limit, isUnlimited }: { used: number; limit: number; isUnlimited: boolean }) {
|
||||
const pct = isUnlimited ? 0 : Math.min(100, limit > 0 ? (used / limit) * 100 : 0);
|
||||
const color =
|
||||
pct >= 90 ? 'bg-rose-500' : pct >= 70 ? 'bg-amber-500' : 'bg-primary';
|
||||
ai_flashcard: 'usageMeter.featureFlashcards',
|
||||
voice_transcribe: 'usageMeter.featureVoice',
|
||||
slide_generate: 'usageMeter.featureSlides',
|
||||
excalidraw_generate: 'usageMeter.featureDiagrams',
|
||||
}
|
||||
|
||||
function UsageBar({ percent }: { percent: number }) {
|
||||
return (
|
||||
<div className="h-1.5 w-full rounded-full bg-foreground/10 overflow-hidden">
|
||||
{!isUnlimited && (
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all', color)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
percent >= 40 ? 'bg-primary' : 'bg-primary/60',
|
||||
)}
|
||||
style={{ width: `${Math.min(Math.max(percent, 0), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function UsageBreakdown() {
|
||||
const { t } = useLanguage();
|
||||
const { t } = useLanguage()
|
||||
|
||||
const { data, isLoading } = useQuery<{ quotas: Quotas }>({
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['usage', 'current'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/usage/current');
|
||||
if (!res.ok) throw new Error('Failed to fetch usage');
|
||||
return res.json();
|
||||
const res = await fetch('/api/usage/current')
|
||||
if (!res.ok) throw new Error('Failed to fetch usage')
|
||||
return res.json() as Promise<{
|
||||
balance: BalanceData
|
||||
breakdown: BreakdownRow[]
|
||||
period: string
|
||||
tier: string
|
||||
}>
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const quotas = data?.quotas ?? {};
|
||||
const entries = Object.entries(quotas);
|
||||
const balance = data?.balance
|
||||
const byFeature = new Map((data?.breakdown ?? []).map((r) => [r.feature, r]))
|
||||
const spent = balance?.spentThisPeriod ?? 0
|
||||
|
||||
const rows = Object.keys(FEATURE_LABEL_KEYS).map((key) => {
|
||||
const row = byFeature.get(key)
|
||||
return {
|
||||
feature: key,
|
||||
label: t(FEATURE_LABEL_KEYS[key]),
|
||||
creditsUsed: row?.creditsUsed ?? 0,
|
||||
actionsCount: row?.actionsCount ?? 0,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/40 bg-paper p-6 space-y-4">
|
||||
@@ -68,33 +97,72 @@ export function UsageBreakdown() {
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
) : !balance ? (
|
||||
<p className="text-xs text-muted-foreground py-2">{t('billing.noUsage')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{entries.map(([feature, quota]) => {
|
||||
const isUnlimited = quota.limit === -1 || !isFinite(quota.limit);
|
||||
const labelKey = FEATURE_LABEL_KEYS[feature];
|
||||
const label = labelKey ? t(labelKey) : feature;
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="rounded-lg border border-border/40 p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
|
||||
{t('billing.creditsRemaining')}
|
||||
</p>
|
||||
<p className="text-lg font-semibold tabular-nums mt-1">
|
||||
{balance.unlimited ? t('billing.unlimited') : balance.totalRemaining}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/40 p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
|
||||
{t('billing.creditsSpent')}
|
||||
</p>
|
||||
<p className="text-lg font-semibold tabular-nums mt-1">
|
||||
{balance.spentThisPeriod}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/40 p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
|
||||
{t('billing.creditsFromPacks')}
|
||||
</p>
|
||||
<p className="text-lg font-semibold tabular-nums mt-1">
|
||||
{balance.purchasedRemaining}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div key={feature} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-xs font-medium text-foreground tabular-nums">
|
||||
{isUnlimited ? (
|
||||
<span className="text-primary/80 dark:text-primary">{t('billing.unlimited')}</span>
|
||||
) : (
|
||||
`${quota.used} / ${quota.limit}`
|
||||
)}
|
||||
</span>
|
||||
<p className="text-xs font-medium text-foreground pt-1">
|
||||
{t('billing.creditsWhereTitle')}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{t('billing.creditsUsageLegend')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{rows.map((row) => {
|
||||
const barPct =
|
||||
spent > 0 && row.creditsUsed > 0
|
||||
? Math.min(100, (row.creditsUsed / spent) * 100)
|
||||
: 0
|
||||
return (
|
||||
<div key={row.feature} className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">{row.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-medium tabular-nums shrink-0',
|
||||
row.creditsUsed > 0 ? 'text-foreground' : 'text-muted-foreground/60',
|
||||
)}
|
||||
>
|
||||
{row.creditsUsed > 0
|
||||
? `${row.creditsUsed} cr. · ${row.actionsCount}×`
|
||||
: '0'}
|
||||
</span>
|
||||
</div>
|
||||
<UsageBar percent={barPct} />
|
||||
</div>
|
||||
<UsageBar used={quota.used} limit={quota.limit} isUnlimited={isUnlimited} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
import { useSearchModal } from '@/context/search-modal-context'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { applyDocumentTheme } from '@/lib/apply-document-theme'
|
||||
import { persistThemePreference } from '@/lib/apply-document-theme'
|
||||
import { getAllNotes, getTrashCount, getNotesWithReminders, toggleReminderDone, getInboxCount } from '@/app/actions/notes'
|
||||
import { NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
@@ -676,9 +676,9 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
const toggleTheme = useCallback(() => {
|
||||
const next = !isDark
|
||||
setIsDark(next)
|
||||
const theme = next ? 'dark' : 'light'
|
||||
localStorage.setItem('theme-preference', theme)
|
||||
applyDocumentTheme(theme)
|
||||
// Cookie + localStorage : le serveur doit connaître le dark, sinon flash clair
|
||||
// à chaque changement de page (layout racine réécrit <html>).
|
||||
persistThemePreference(next ? 'dark' : 'light')
|
||||
}, [isDark])
|
||||
const [deletingNotebook, setDeletingNotebook] = useState<Notebook | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
16
memento-note/components/sw-cleanup.tsx
Normal file
16
memento-note/components/sw-cleanup.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/** Désenregistre d’anciens service workers (remplace /scripts/sw-cleanup.js). */
|
||||
export function SwCleanup() {
|
||||
useEffect(() => {
|
||||
if (!('serviceWorker' in navigator)) return
|
||||
void navigator.serviceWorker.getRegistrations().then((rs) => {
|
||||
rs.forEach((r) => {
|
||||
void r.unregister()
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
102
memento-note/components/theme-class-guard.tsx
Normal file
102
memento-note/components/theme-class-guard.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import { useLayoutEffect } from 'react'
|
||||
import { persistThemePreference, normalizeThemeId } from '@/lib/apply-document-theme'
|
||||
|
||||
/**
|
||||
* Empêche le flash clair en mode sombre.
|
||||
*
|
||||
* Au changement de route, le layout racine Next peut réécrire la classe de
|
||||
* <html> depuis le serveur (souvent « light »). Sans garde, une frame claire
|
||||
* apparaît avant le prochain effet React.
|
||||
*
|
||||
* useLayoutEffect = avant le paint navigateur.
|
||||
* MutationObserver = remet le thème si React retire « dark » à tort.
|
||||
*/
|
||||
export function ThemeClassGuard() {
|
||||
useLayoutEffect(() => {
|
||||
let applying = false
|
||||
|
||||
const preferredTheme = (): string => {
|
||||
try {
|
||||
const local = localStorage.getItem('theme-preference')
|
||||
if (local) return normalizeThemeId(local)
|
||||
} catch {
|
||||
/* localStorage indisponible */
|
||||
}
|
||||
const server =
|
||||
document.documentElement.getAttribute('data-server-theme') || 'light'
|
||||
return normalizeThemeId(server)
|
||||
}
|
||||
|
||||
const wantsDarkClass = (theme: string): boolean => {
|
||||
const t = normalizeThemeId(theme)
|
||||
if (t === 'dark' || t === 'midnight') return true
|
||||
if (t === 'auto') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const sync = () => {
|
||||
if (applying) return
|
||||
applying = true
|
||||
try {
|
||||
persistThemePreference(preferredTheme())
|
||||
} finally {
|
||||
// Laisser le navigateur appliquer le DOM avant de réarmer
|
||||
queueMicrotask(() => {
|
||||
applying = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Immédiat (avant paint)
|
||||
sync()
|
||||
|
||||
// Après commit React éventuel qui a écrasé <html>
|
||||
const raf1 = requestAnimationFrame(() => {
|
||||
sync()
|
||||
requestAnimationFrame(sync)
|
||||
})
|
||||
|
||||
const root = document.documentElement
|
||||
const observer = new MutationObserver(() => {
|
||||
if (applying) return
|
||||
const theme = preferredTheme()
|
||||
const needDark = wantsDarkClass(theme)
|
||||
const hasDark = root.classList.contains('dark')
|
||||
if (needDark && !hasDark) {
|
||||
sync()
|
||||
return
|
||||
}
|
||||
// data-theme retiré à tort pour une palette nommée
|
||||
if (
|
||||
theme !== 'light' &&
|
||||
theme !== 'dark' &&
|
||||
theme !== 'auto' &&
|
||||
root.getAttribute('data-theme') !== theme
|
||||
) {
|
||||
sync()
|
||||
}
|
||||
})
|
||||
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'data-theme'],
|
||||
})
|
||||
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === 'theme-preference') sync()
|
||||
}
|
||||
window.addEventListener('storage', onStorage)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf1)
|
||||
observer.disconnect()
|
||||
window.removeEventListener('storage', onStorage)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { applyDocumentTheme, normalizeThemeId } from '@/lib/apply-document-theme'
|
||||
import { useLayoutEffect } from 'react'
|
||||
import { applyDocumentTheme, normalizeThemeId, persistThemePreference } from '@/lib/apply-document-theme'
|
||||
|
||||
interface ThemeInitializerProps {
|
||||
theme?: string
|
||||
@@ -11,7 +11,10 @@ interface ThemeInitializerProps {
|
||||
}
|
||||
|
||||
export function ThemeInitializer({ theme, fontSize, fontFamily, accentColor }: ThemeInitializerProps) {
|
||||
useEffect(() => {
|
||||
// useLayoutEffect : avant le paint (useEffect = trop tard = flash clair)
|
||||
useLayoutEffect(() => {
|
||||
const root = document.documentElement
|
||||
|
||||
const applyFontSize = (s?: string) => {
|
||||
const size = s || 'medium'
|
||||
|
||||
@@ -29,25 +32,21 @@ export function ThemeInitializer({ theme, fontSize, fontFamily, accentColor }: T
|
||||
'extra-large': 1.25,
|
||||
}
|
||||
|
||||
const root = document.documentElement
|
||||
root.style.setProperty('--user-font-size', fontSizeMap[size] || '16px')
|
||||
root.style.setProperty('--user-font-size-factor', (fontSizeFactorMap[size] || 1).toString())
|
||||
}
|
||||
|
||||
// Préférence locale prioritaire (jamais écrasée par un rendu serveur « light »)
|
||||
const localTheme = localStorage.getItem('theme-preference')
|
||||
const effectiveTheme = localTheme || theme || 'light'
|
||||
const effectiveTheme = normalizeThemeId(localTheme || theme || 'light')
|
||||
|
||||
applyDocumentTheme(effectiveTheme)
|
||||
|
||||
if (!localTheme && theme) {
|
||||
localStorage.setItem('theme-preference', normalizeThemeId(theme))
|
||||
}
|
||||
// Persiste aussi le cookie pour les prochains rendus serveur (anti-flash navigation)
|
||||
persistThemePreference(effectiveTheme)
|
||||
|
||||
applyFontSize(fontSize)
|
||||
|
||||
const localFontFamily = localStorage.getItem('font-family')
|
||||
const effectiveFontFamily = localFontFamily || fontFamily || 'inter'
|
||||
const root = document.documentElement
|
||||
root.classList.remove('font-system', 'font-playfair', 'font-jetbrains')
|
||||
if (effectiveFontFamily === 'system') root.classList.add('font-system')
|
||||
if (effectiveFontFamily === 'playfair') root.classList.add('font-playfair')
|
||||
@@ -58,7 +57,7 @@ export function ThemeInitializer({ theme, fontSize, fontFamily, accentColor }: T
|
||||
|
||||
const localAccent = localStorage.getItem('accent-color')
|
||||
const effectiveAccent = localAccent || accentColor || '#A47148'
|
||||
document.documentElement.style.setProperty('--color-brand-accent', effectiveAccent)
|
||||
root.style.setProperty('--color-brand-accent', effectiveAccent)
|
||||
if (!localAccent && accentColor) {
|
||||
localStorage.setItem('accent-color', accentColor)
|
||||
}
|
||||
|
||||
@@ -1,123 +1,128 @@
|
||||
'use client';
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Sparkles, ChevronDown, X, Crown } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Sparkles, ChevronDown, X, Crown } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface QuotaData {
|
||||
remaining: number;
|
||||
limit: number;
|
||||
used: number;
|
||||
interface BalanceData {
|
||||
totalRemaining: number | null
|
||||
subscriptionRemaining: number | null
|
||||
purchasedRemaining: number
|
||||
subscriptionGranted: number | null
|
||||
subscriptionSpent: number
|
||||
spentThisPeriod: number
|
||||
unlimited: boolean
|
||||
}
|
||||
|
||||
interface BreakdownRow {
|
||||
feature: string
|
||||
creditsUsed: number
|
||||
actionsCount: number
|
||||
percentOfSpent: number
|
||||
}
|
||||
|
||||
interface UsageMeterProps {
|
||||
className?: string;
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Même liste que l'ancien compteur — toutes les actions IA, pas seulement les slides. */
|
||||
const FEATURE_LABEL_KEYS: Record<string, string> = {
|
||||
semantic_search: 'usageMeter.featureSearch',
|
||||
auto_tag: 'usageMeter.featureTags',
|
||||
auto_title: 'usageMeter.featureTitles',
|
||||
reformulate: 'usageMeter.featureReformulate',
|
||||
chat: 'usageMeter.featureChat',
|
||||
brainstorm_create: 'usageMeter.featureBrainstormSessions',
|
||||
brainstorm_expand: 'usageMeter.featureBrainstormExpand',
|
||||
brainstorm_enrich: 'usageMeter.featureBrainstormEnrich',
|
||||
suggest_charts: 'usageMeter.featureCharts',
|
||||
publish_enhance: 'usageMeter.featurePublishEnhance',
|
||||
ai_flashcard: 'usageMeter.featureFlashcards',
|
||||
voice_transcribe: 'usageMeter.featureVoice',
|
||||
slide_generate: 'usageMeter.featureSlides',
|
||||
excalidraw_generate: 'usageMeter.featureDiagrams',
|
||||
}
|
||||
|
||||
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 { t } = useLanguage()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['usage', 'current'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/usage/current');
|
||||
if (!res.ok) throw new Error('Failed to fetch quotas');
|
||||
const json = await res.json();
|
||||
return { quotas: json.quotas as Record<string, QuotaData>, tier: json.tier as string };
|
||||
const res = await fetch('/api/usage/current')
|
||||
if (!res.ok) throw new Error('Failed to fetch usage')
|
||||
const json = await res.json()
|
||||
return {
|
||||
tier: json.tier as string,
|
||||
period: json.period as string,
|
||||
balance: json.balance as BalanceData,
|
||||
breakdown: (json.breakdown ?? []) as BreakdownRow[],
|
||||
}
|
||||
},
|
||||
staleTime: 0,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
|
||||
window.addEventListener('ai-usage-changed', handler);
|
||||
return () => window.removeEventListener('ai-usage-changed', handler);
|
||||
}, [queryClient]);
|
||||
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) {
|
||||
if (isLoading || !data?.balance) {
|
||||
return (
|
||||
<div className={cn('px-2 py-2', className)}>
|
||||
<div className="h-8 rounded-lg bg-paper/50 animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const getPackLabel = () => {
|
||||
if (!data.tier) return t('usageMeter.packName');
|
||||
if (!data.tier) return t('usageMeter.packName')
|
||||
switch (data.tier) {
|
||||
case 'PRO': return t('usageMeter.packPro');
|
||||
case 'BUSINESS': return t('usageMeter.packBusiness');
|
||||
case 'ENTERPRISE': return t('usageMeter.packEnterprise');
|
||||
default: return t('usageMeter.packName');
|
||||
case 'PRO':
|
||||
return t('usageMeter.packPro')
|
||||
case 'BUSINESS':
|
||||
return t('usageMeter.packBusiness')
|
||||
case 'ENTERPRISE':
|
||||
return t('usageMeter.packEnterprise')
|
||||
default:
|
||||
return t('usageMeter.packName')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const isProPlus = data.tier && data.tier !== 'BASIC';
|
||||
const isProPlus = data.tier && data.tier !== 'BASIC'
|
||||
const unlimited = !!data.balance.unlimited
|
||||
const remaining = unlimited ? Infinity : (data.balance.totalRemaining ?? 0)
|
||||
const granted = unlimited ? Infinity : (data.balance.subscriptionGranted ?? 0)
|
||||
const spent = data.balance.spentThisPeriod ?? 0
|
||||
// Barre = part du forfait consommée (crédits utilisés / allocation du mois)
|
||||
const totalPct =
|
||||
!unlimited && Number.isFinite(granted) && granted > 0
|
||||
? Math.min(100, (spent / granted) * 100)
|
||||
: 0
|
||||
const isExhausted = !unlimited && remaining <= 0
|
||||
|
||||
// Features visibles dans l'UI (les features techniques comme expand/enrich sont cachées)
|
||||
const VISIBLE_FEATURES = new Set([
|
||||
'semantic_search',
|
||||
'auto_tag',
|
||||
'auto_title',
|
||||
'reformulate',
|
||||
'chat',
|
||||
'brainstorm_create',
|
||||
'brainstorm_expand',
|
||||
'brainstorm_enrich',
|
||||
'suggest_charts',
|
||||
'publish_enhance',
|
||||
'ai_flashcard',
|
||||
'voice_transcribe',
|
||||
'slide_generate',
|
||||
'excalidraw_generate',
|
||||
]);
|
||||
|
||||
const featureLabels: Record<string, string> = {
|
||||
semantic_search: t('usageMeter.featureSearch'),
|
||||
auto_tag: t('usageMeter.featureTags'),
|
||||
auto_title: t('usageMeter.featureTitles'),
|
||||
reformulate: t('usageMeter.featureReformulate'),
|
||||
chat: t('usageMeter.featureChat'),
|
||||
brainstorm_create: t('usageMeter.featureBrainstormSessions'),
|
||||
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
|
||||
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
|
||||
suggest_charts: t('usageMeter.featureCharts'),
|
||||
publish_enhance: t('usageMeter.featurePublishEnhance'),
|
||||
ai_flashcard: t('usageMeter.featureFlashcards'),
|
||||
voice_transcribe: t('usageMeter.featureVoice'),
|
||||
slide_generate: t('usageMeter.featureSlides'),
|
||||
excalidraw_generate: t('usageMeter.featureDiagrams'),
|
||||
};
|
||||
|
||||
const featureQuotas = Object.entries(data.quotas)
|
||||
.filter(([key, q]) => q.limit > 0 && VISIBLE_FEATURES.has(key))
|
||||
.map(([key, quota]) => ({
|
||||
key: key === 'brainstorm_create' ? 'brainstorm_sessions' : key, // Renommer pour l'affichage
|
||||
label: featureLabels[key] || key,
|
||||
used: quota.used,
|
||||
limit: quota.limit,
|
||||
remaining: quota.remaining,
|
||||
}));
|
||||
|
||||
const isUnlimited = featureQuotas.every((f) => !Number.isFinite(f.limit));
|
||||
|
||||
const totalUsed = featureQuotas.reduce(
|
||||
(sum, f) => sum + (Number.isFinite(f.used) ? f.used : 0), 0,
|
||||
);
|
||||
const totalLimit = featureQuotas.reduce(
|
||||
(sum, f) => sum + (Number.isFinite(f.limit) ? f.limit : 0), 0,
|
||||
);
|
||||
const totalRemaining = totalLimit - totalUsed;
|
||||
const totalPct = totalLimit > 0 ? (totalUsed / totalLimit) * 100 : 0;
|
||||
const isExhausted = !isUnlimited && totalRemaining <= 0;
|
||||
// Map breakdown → toutes les features du tableau (ordre API = déjà trié, on garde l'ordre labels)
|
||||
const byFeature = new Map(data.breakdown.map((r) => [r.feature, r]))
|
||||
const featureRows = Object.keys(FEATURE_LABEL_KEYS).map((key) => {
|
||||
const row = byFeature.get(key)
|
||||
return {
|
||||
key,
|
||||
label: t(FEATURE_LABEL_KEYS[key]),
|
||||
creditsUsed: row?.creditsUsed ?? 0,
|
||||
actionsCount: row?.actionsCount ?? 0,
|
||||
percent: row?.percentOfSpent ?? 0,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -134,39 +139,54 @@ export function UsageMeter({ className }: UsageMeterProps) {
|
||||
'shrink-0',
|
||||
isExhausted
|
||||
? 'text-rose-400 fill-rose-400/20'
|
||||
: isUnlimited
|
||||
: unlimited
|
||||
? 'text-emerald-400 fill-emerald-400/20'
|
||||
: 'text-brand-accent fill-brand-accent/20'
|
||||
: 'text-brand-accent fill-brand-accent/20',
|
||||
)}
|
||||
/>
|
||||
<span className="text-[11px] font-bold text-ink/70">{getPackLabel()}</span>
|
||||
|
||||
{!isUnlimited && (
|
||||
{!unlimited && (
|
||||
<>
|
||||
<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',
|
||||
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
|
||||
className={cn(
|
||||
'text-[10px] font-medium tabular-nums shrink-0',
|
||||
totalPct >= 90
|
||||
? 'text-rose-500'
|
||||
: totalPct >= 70
|
||||
? 'text-amber-500'
|
||||
: 'text-concrete',
|
||||
)}
|
||||
title={t('usageMeter.creditsRemaining')}
|
||||
>
|
||||
{remaining}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isUnlimited && (
|
||||
<span className="text-[10px] font-medium text-emerald-500 ml-auto">{t('usageMeter.unlimited')}</span>
|
||||
{unlimited && (
|
||||
<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>
|
||||
{isProPlus && !unlimited && (
|
||||
<span className="text-[9px] font-bold text-brand-accent uppercase tracking-widest ml-auto">
|
||||
{data.tier}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<ChevronDown
|
||||
@@ -189,37 +209,76 @@ export function UsageMeter({ className }: UsageMeterProps) {
|
||||
>
|
||||
<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
|
||||
<p className="text-[9px] font-bold uppercase tracking-widest text-concrete">
|
||||
{t('usageMeter.creditsWhereTitle')}
|
||||
</p>
|
||||
{!unlimited && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t('usageMeter.creditsSummary', {
|
||||
remaining: String(remaining),
|
||||
spent: String(spent),
|
||||
granted: String(granted),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{featureRows.map((f) => {
|
||||
// Barre = part de la conso totale, ou 0 si rien
|
||||
const barPct =
|
||||
spent > 0 && f.creditsUsed > 0
|
||||
? Math.min(100, (f.creditsUsed / spent) * 100)
|
||||
: 0
|
||||
return (
|
||||
<div key={f.key} className="space-y-1">
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<div className="flex justify-between text-[10px] gap-2">
|
||||
<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
|
||||
className={cn(
|
||||
'font-medium tabular-nums shrink-0',
|
||||
f.creditsUsed > 0 ? 'text-ink/70' : 'text-concrete/50',
|
||||
)}
|
||||
>
|
||||
{f.creditsUsed > 0
|
||||
? t('usageMeter.creditsUsedLine', {
|
||||
credits: String(f.creditsUsed),
|
||||
actions: String(f.actionsCount),
|
||||
})
|
||||
: '0'}
|
||||
</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 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',
|
||||
barPct >= 40 ? 'bg-brand-accent' : 'bg-brand-accent/50',
|
||||
)}
|
||||
style={{ width: `${barPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{!isProPlus && (
|
||||
{isExhausted && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); router.push('/settings/billing'); }}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowModal(true)
|
||||
}}
|
||||
className="w-full mt-1 py-2 bg-rose-500/10 hover:bg-rose-500 text-rose-600 hover:text-white text-[9px] font-bold uppercase tracking-widest rounded-xl transition-all border border-rose-500/20"
|
||||
>
|
||||
{t('usageMeter.outOfCredits')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isProPlus && !isExhausted && (
|
||||
<button
|
||||
type="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')}
|
||||
@@ -253,44 +312,31 @@ export function UsageMeter({ className }: UsageMeterProps) {
|
||||
{t('usageMeter.upgradeDescription')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="text-[11px] font-medium text-ink">{t('usageMeter.proIncludes')}</div>
|
||||
<ul className="text-[11px] text-muted-ink space-y-1">
|
||||
<li>• {t('usageMeter.proSearch')}</li>
|
||||
<li>• {t('usageMeter.proTags')}</li>
|
||||
<li>• {t('usageMeter.proTitles')}</li>
|
||||
<li>• {t('usageMeter.proReformulate')}</li>
|
||||
<li>• {t('usageMeter.proChat')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="flex-1 px-3 py-2 text-[12px] rounded-xl border border-border hover:bg-foreground/5 transition-colors"
|
||||
>
|
||||
{t('usageMeter.later')}
|
||||
</button>
|
||||
<a
|
||||
href="/settings/billing"
|
||||
className="flex-1 px-3 py-2 text-[12px] font-medium rounded-xl bg-brand-accent text-white text-center hover:opacity-90 transition-colors"
|
||||
>
|
||||
{t('usageMeter.upgradePricing')}
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
href="/settings/billing"
|
||||
className="w-full px-3 py-2 text-[12px] font-medium rounded-xl bg-brand-accent text-white text-center hover:opacity-90 transition-colors"
|
||||
>
|
||||
{t('usageMeter.upgradePricing')}
|
||||
</a>
|
||||
<a
|
||||
href="/settings/ai#byok"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="w-full px-3 py-2 text-[12px] font-medium rounded-xl border border-brand-accent/40 text-brand-accent dark:text-brand-accent text-center hover:bg-brand-accent/10 transition-colors"
|
||||
className="w-full px-3 py-2 text-[12px] font-medium rounded-xl border border-brand-accent/40 text-brand-accent text-center hover:bg-brand-accent/10 transition-colors"
|
||||
>
|
||||
{t('usageMeter.addApiKey')}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="w-full px-3 py-2 text-[12px] rounded-xl border border-border hover:bg-foreground/5 transition-colors"
|
||||
>
|
||||
{t('usageMeter.later')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user