fix(ui): tarifs publics unifiés et prix annuel lisible
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m5s
CI / Deploy production (on server) (push) Successful in 24s

La page tarifs reprend la barre du site public. En annuel, Pro affiche
8,25 € par mois (99 € l’année), plus le 99 € comme gros chiffre.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-09-01 17:43:05 +00:00
parent dcd7f38c11
commit 4fc5e3ce04
21 changed files with 721 additions and 602 deletions

View File

@@ -2,44 +2,27 @@
import { motion, AnimatePresence } from 'motion/react'
import {
ArrowRight, Menu, X, Check, BrainCircuit,
Network, GraduationCap, Bot, KeyRound, Globe, ChevronDown
ArrowRight, Check, BrainCircuit,
Network, GraduationCap, Bot, KeyRound
} from 'lucide-react'
import Link from 'next/link'
import Image from 'next/image'
import { useLanguage } from '@/lib/i18n'
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useEffect, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { PublicSiteChrome } from '@/components/public-site-chrome'
import {
DEFAULT_PRICES,
annualDiscountPercent,
} from '@/lib/billing/price-catalog'
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, language, setLanguage } = useLanguage()
const { t } = 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 { data: byokCatalog } = useQuery({
queryKey: ['public', 'byok-catalog'],
queryFn: async () => {
@@ -51,36 +34,16 @@ export function LandingPage() {
})
const byokProviders = byokCatalog?.providers ?? []
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)
}, [])
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 trialDays = SUBSCRIPTION_TRIAL_DAYS
const annualSavePercent = annualDiscountPercent(
DEFAULT_PRICES.PRO.month.amount,
DEFAULT_PRICES.PRO.year.amount,
)
const PLANS = [
{ key: 'basic', popular: false, hasTrial: false, price: t('landing.pricing.basicPrice'), period: '' },
{
@@ -106,13 +69,6 @@ export function LandingPage() {
},
]
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 scrollPublicHash = (hash: string) => {
const id = hash.replace(/^#/, '')
const target = document.getElementById(id)
@@ -130,138 +86,7 @@ export function LandingPage() {
}, [])
return (
<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-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}
onClick={(event) => {
event.preventDefault()
scrollPublicHash(l.href)
window.history.replaceState(null, '', l.href)
}}
className="text-[13px] text-white/75 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>
<Link href="/login" className="hidden sm:inline text-[13px] text-white/75 hover:text-white transition-colors px-2">
{t('landing.nav.login')}
</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')}
</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>
<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={(event) => {
event.preventDefault()
setMenuOpen(false)
scrollPublicHash(l.href)
window.history.replaceState(null, '', l.href)
}}
className="py-4 text-3xl font-serif border-b border-white/10"
>
{l.label}
</a>
))}
<Link
href="/login"
onClick={() => setMenuOpen(false)}
className="mt-10 py-4 text-2xl font-serif text-white/80 text-center border-b border-white/10"
>
{t('landing.nav.login')}
</Link>
<Link href="/register" onClick={() => setMenuOpen(false)} className="mt-4 py-4 rounded-2xl bg-[#F4F1EA] text-[#0B0A09] text-center font-semibold">
{t('landing.nav.cta')}
</Link>
</div>
</motion.div>
)}
</AnimatePresence>
<PublicSiteChrome currentPage="home" onHashNavigate={scrollPublicHash}>
{/* ── 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 */}
@@ -524,23 +349,23 @@ export function LandingPage() {
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<h2 className="font-serif text-3xl sm:text-5xl tracking-tight mb-4">{t('landing.pricing.title')}</h2>
<p className="text-white/70 mb-8">{t('landing.pricing.desc')}</p>
<div className="inline-flex p-1 rounded-full border border-white/10 bg-white/[0.03]">
<p className="text-white/80 mb-8">{t('landing.pricing.desc')}</p>
<div className="inline-flex p-1 rounded-full border border-white/15 bg-white/[0.04]">
<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/70'}`}
className={`px-5 py-2 rounded-full text-[13px] font-semibold transition-all ${billingInterval === 'monthly' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/80'}`}
>
{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/70'}`}
className={`px-5 py-2 rounded-full text-[13px] font-semibold transition-all relative ${billingInterval === 'annual' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/80'}`}
>
{t('landing.pricing.annual')}
<span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373] whitespace-nowrap">
{t('landing.pricing.savePercent')}
<span className="absolute -top-3 -right-1 text-[11px] text-[#E8C39A] whitespace-nowrap">
{t('landing.pricing.savePercent', { percent: annualSavePercent })}
</span>
</button>
</div>
@@ -551,32 +376,32 @@ export function LandingPage() {
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]'
? 'border-[#D4A373]/60 bg-[#D4A373]/12'
: 'border-white/[0.12] bg-white/[0.04]'
}`}
>
{plan.popular && (
<span className="text-[10px] font-bold uppercase tracking-widest text-[#D4A373] mb-3">
<span className="text-[11px] font-bold uppercase tracking-widest text-[#E8C39A] mb-3">
{t('landing.pricing.popular')}
</span>
)}
<h4 className="text-[13px] font-medium tracking-wide text-white/80 mb-2">
<h4 className="text-[14px] font-medium tracking-wide text-[#F4F1EA] 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-sm text-white/70">{plan.period}</span>}
{plan.period && <span className="text-sm text-white/80">{plan.period}</span>}
</div>
{plan.hasTrial && (
<p className="text-[11px] font-semibold text-[#D4A373] mb-3">
<p className="text-[12px] font-semibold text-[#E8C39A] mb-3">
{t('landing.pricing.trialBadge', { days: trialDays })}
</p>
)}
<p className="text-sm text-white/70 mb-6">{t(`landing.pricing.${plan.key}.desc`)}</p>
<p className="text-sm text-white/80 mb-6">{t(`landing.pricing.${plan.key}.desc`)}</p>
<ul className="space-y-2.5 mb-8 flex-1">
{plan.hasTrial && (
<li className="flex gap-2 text-xs text-[#D4A373]/90">
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
<li className="flex gap-2 text-xs text-[#E8C39A]">
<Check size={12} className="text-[#E8C39A] mt-0.5 shrink-0" />
{t('landing.pricing.trialFeature', { days: trialDays })}
</li>
)}
@@ -586,8 +411,8 @@ export function LandingPage() {
})
if (!feat || feat.startsWith('landing.')) return null
return (
<li key={j} className="flex gap-2 text-sm text-white/80">
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
<li key={j} className="flex gap-2 text-sm text-[#F4F1EA]/90">
<Check size={12} className="text-[#E8C39A] mt-0.5 shrink-0" />
{feat}
</li>
)
@@ -598,7 +423,7 @@ export function LandingPage() {
className={`py-3 rounded-xl text-center text-[13px] font-semibold transition-colors ${
plan.popular
? 'bg-[#F4F1EA] text-[#0B0A09] hover:bg-white'
: 'bg-white/10 text-white hover:bg-white/15'
: 'bg-white/15 text-white hover:bg-white/20'
}`}
>
{plan.hasTrial
@@ -633,48 +458,7 @@ export function LandingPage() {
</div>
</section>
<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-lg">Memento</span>
</div>
<p className="text-sm text-white/60">{t('landing.footer.desc')}</p>
</div>
<div className="grid grid-cols-3 gap-10 text-sm">
{(['product', 'community', 'legal'] as const).map((section) => (
<div key={section}>
<p className="text-[13px] font-medium tracking-wide text-white/70 mb-3">
{t(`landing.footer.${section}.title`)}
</p>
<ul className="space-y-2 text-white/70">
{[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}>
{href.startsWith('/') ? (
<Link href={href} className="hover:text-white transition-colors">{label}</Link>
) : (
<a href={href} className="hover:text-white transition-colors">{label}</a>
)}
</li>
)
})}
</ul>
</div>
))}
</div>
</div>
<p className="max-w-6xl mx-auto mt-12 pt-8 border-t border-white/[0.06] text-[13px] text-white/70 tracking-wide">
© 2026 Memento. {t('landing.footer.rights')}
</p>
</footer>
</div>
</PublicSiteChrome>
)
}

View File

@@ -0,0 +1,267 @@
'use client'
import { motion, AnimatePresence } from 'motion/react'
import { Menu, X, Globe, ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { useLanguage } from '@/lib/i18n'
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
import { useEffect, useRef, useState, type ReactNode } from 'react'
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 type PublicSitePage = 'home' | 'pricing'
function resolvePublicHref(href: string, currentPage: PublicSitePage): string {
if (!href.startsWith('#')) return href
if (href === '#pricing' && currentPage === 'pricing') return '/pricing'
if (currentPage === 'home') return href
const id = href.replace(/^#/, '')
return id === 'pricing' ? '/pricing' : `/#${id}`
}
export function PublicSiteChrome({
children,
currentPage,
onHashNavigate,
}: {
children: ReactNode
currentPage: PublicSitePage
onHashNavigate?: (hash: string) => void
}) {
const { t, language, setLanguage } = useLanguage()
const [menuOpen, setMenuOpen] = useState(false)
const [langOpen, setLangOpen] = useState(false)
const langRef = useRef<HTMLDivElement>(null)
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') },
]
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 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 goSection = (event: React.MouseEvent<HTMLAnchorElement>, hash: string) => {
setMenuOpen(false)
if (currentPage === 'home' && onHashNavigate) {
event.preventDefault()
onHashNavigate(hash)
window.history.replaceState(null, '', hash)
}
}
return (
<div className="min-h-screen bg-[#0B0A09] text-[#F4F1EA] font-[family-name:var(--font-manrope)] selection:bg-[#D4A373]/40 selection:text-white">
<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-xl font-medium tracking-tight text-[#F4F1EA]">Memento</span>
</Link>
<div className="hidden lg:flex items-center gap-8">
{NAV.map((l) => (
<Link
key={l.href}
href={resolvePublicHref(l.href, currentPage)}
onClick={(event) => goSection(event, l.href)}
className={`text-[13px] transition-colors ${
(currentPage === 'pricing' && l.href === '#pricing')
? 'text-white'
: 'text-white/75 hover:text-white'
}`}
>
{l.label}
</Link>
))}
</div>
<div className="flex items-center gap-2 sm:gap-3">
<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>
<Link href="/login" className="hidden sm:inline text-[13px] text-white/75 hover:text-white transition-colors px-2">
{t('landing.nav.login')}
</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')}
</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>
<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) => (
<Link
key={l.href}
href={resolvePublicHref(l.href, currentPage)}
onClick={(event) => goSection(event, l.href)}
className="py-4 text-3xl font-serif border-b border-white/10"
>
{l.label}
</Link>
))}
<Link
href="/login"
onClick={() => setMenuOpen(false)}
className="mt-10 py-4 text-2xl font-serif text-white/80 text-center border-b border-white/10"
>
{t('landing.nav.login')}
</Link>
<Link href="/register" onClick={() => setMenuOpen(false)} className="mt-4 py-4 rounded-2xl bg-[#F4F1EA] text-[#0B0A09] text-center font-semibold">
{t('landing.nav.cta')}
</Link>
</div>
</motion.div>
)}
</AnimatePresence>
<div className={currentPage === 'home' ? '' : 'pt-20'}>
{children}
</div>
<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-lg">Memento</span>
</div>
<p className="text-sm text-white/60">{t('landing.footer.desc')}</p>
</div>
<div className="grid grid-cols-3 gap-10 text-sm">
{(['product', 'community', 'legal'] as const).map((section) => (
<div key={section}>
<p className="text-[13px] font-medium tracking-wide text-white/70 mb-3">
{t(`landing.footer.${section}.title`)}
</p>
<ul className="space-y-2 text-white/70">
{[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
const resolved = resolvePublicHref(href, currentPage)
return (
<li key={j}>
{resolved.startsWith('/') ? (
<Link href={resolved} className="hover:text-white transition-colors">{label}</Link>
) : (
<a href={resolved} className="hover:text-white transition-colors">{label}</a>
)}
</li>
)
})}
</ul>
</div>
))}
</div>
</div>
<p className="max-w-6xl mx-auto mt-12 pt-8 border-t border-white/[0.06] text-[13px] text-white/70 tracking-wide">
© 2026 Memento. {t('landing.footer.rights')}
</p>
</footer>
</div>
)
}

View File

@@ -11,6 +11,12 @@ import { format } from 'date-fns';
import { motion } from 'motion/react';
import { BillingHistory } from './billing-history';
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants';
import {
DEFAULT_PRICES,
annualDiscountPercent,
formatBillingAmount,
yearToMonthlyEquivalent,
} from '@/lib/billing/price-catalog';
type Tier = 'PRO' | 'BUSINESS';
type Interval = 'month' | 'year';
@@ -285,12 +291,36 @@ export function BillingPlans() {
const trialCta = (fallback: string) =>
trialEligible ? t('billing.startTrialCta', { days: trialDays }) : fallback;
const proMonth = status?.prices?.PRO?.month ?? DEFAULT_PRICES.PRO.month;
const proYear = status?.prices?.PRO?.year ?? DEFAULT_PRICES.PRO.year;
const businessMonth = status?.prices?.BUSINESS?.month ?? DEFAULT_PRICES.BUSINESS.month;
const businessYear = status?.prices?.BUSINESS?.year ?? DEFAULT_PRICES.BUSINESS.year;
const savePercent = annualDiscountPercent(proMonth.amount, proYear.amount);
const billed = (month: typeof proMonth, year: typeof proYear) => {
if (interval === 'month') {
return {
price: month.display,
period: t('billing.perMonth'),
yearHint: null as string | null,
};
}
return {
price: formatBillingAmount(yearToMonthlyEquivalent(year.amount), year.currency),
period: t('landing.pricing.perMonthAnnual'),
yearHint: t('billing.billedYearTotal', { price: year.display }),
};
};
const proBilled = billed(proMonth, proYear);
const businessBilled = billed(businessMonth, businessYear);
const plans = [
{
id: 'free',
name: t('billing.freePlan'),
price: t('billing.freePrice') || 'Gratuit',
period: '',
yearHint: null as string | null,
description: t('billing.freeDescription') || 'Pour découvrir Memento.',
features: [
t('billing.freeF1'),
@@ -313,9 +343,9 @@ export function BillingPlans() {
{
id: 'pro',
name: t('billing.proPlan'),
price: status?.prices?.PRO?.[interval]?.display ??
(interval === 'month' ? (t('billing.proPrice') || '9,90€') : (t('billing.proAnnualPrice') || '99€')),
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
price: proBilled.price,
period: proBilled.period,
yearHint: proBilled.yearHint,
description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
features: [
...(trialEligible ? [t('billing.trialFeature', { days: trialDays })] : []),
@@ -337,9 +367,9 @@ export function BillingPlans() {
{
id: 'business',
name: t('billing.businessPlan'),
price: status?.prices?.BUSINESS?.[interval]?.display ??
(interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€')),
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
price: businessBilled.price,
period: businessBilled.period,
yearHint: businessBilled.yearHint,
features: [
...(trialEligible ? [t('billing.trialFeature', { days: trialDays })] : []),
t('billing.businessFeature1'),
@@ -361,6 +391,7 @@ export function BillingPlans() {
name: t('billing.enterpriseTitle') || 'Enterprise',
price: t('billing.contactSales') || 'Sur devis',
period: '',
yearHint: null as string | null,
description: t('billing.enterpriseDescription') || 'Crédits illimités ou pool dédié, connexion unique pour léquipe, support prioritaire.',
features: [
t('billing.enterpriseFeature1'),
@@ -765,7 +796,11 @@ export function BillingPlans() {
)}
>
{t('billing.annual')}
<span className="ms-1 text-primary/80 dark:text-primary">{t('billing.savePercent')}</span>
{savePercent > 0 && (
<span className="ms-1 text-primary/80 dark:text-primary">
{t('billing.savePercent', { percent: savePercent })}
</span>
)}
</button>
</div>
) : (
@@ -800,6 +835,9 @@ export function BillingPlans() {
<span className="text-4xl font-serif font-bold text-ink">{plan.price}</span>
<span className="text-concrete text-xs font-light italic">{plan.period}</span>
</div>
{plan.yearHint && (
<p className="text-xs text-concrete font-light">{plan.yearHint}</p>
)}
<p className="text-xs text-concrete font-light leading-relaxed pe-4">{plan.description}</p>
</div>