xpeditis2.0/apps/frontend/app/[locale]/page.tsx
2026-08-25 16:33:08 +02:00

1241 lines
50 KiB
TypeScript

'use client';
import { useRef, useState, useEffect } from 'react';
import { Link } from '@/i18n/navigation';
import Image from 'next/image';
import { motion, useInView, useReducedMotion, useScroll, useTransform } from 'framer-motion';
import {
Ship,
Shield,
Zap,
BarChart3,
Package,
Clock,
CheckCircle2,
ArrowRight,
Search,
Anchor,
Container,
FileText,
LayoutDashboard,
Bell,
BookOpen,
Users,
Check,
X,
} from 'lucide-react';
import { useTranslations, useLocale } from 'next-intl';
import { useAuth } from '@/lib/context/auth-context';
import { LandingHeader, LandingFooter } from '@/components/layout';
import { DemoVideoModal } from '@/components/DemoVideoModal';
import { RateSearchDemo } from '@/components/landing/RateSearchDemo';
import { EASE, reveal, revealGroup } from '@/components/landing/motion';
/**
* Rythme vertical de la page : une seule echelle pour toutes les sections,
* afin que la lecture avance a pas constant plutot que par a-coups.
*/
const SECTION_PADDING = 'py-16 sm:py-24 lg:py-32';
const SECTION_PADDING_TIGHT = 'py-12 sm:py-16';
const CONTAINER = 'mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8';
/** Anneau de focus commun aux elements interactifs de la page. */
const FOCUS_RING =
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-turquoise focus-visible:ring-offset-2';
/**
* En-tete de section : surtitre, titre, sous-titre.
*
* Les sections repetaient jusqu'ici le meme bloc avec des tailles et des
* marges legerement differentes. Le centraliser garantit que tous les titres
* de la page partagent la meme hierarchie.
*/
function SectionHeading({
eyebrow,
title,
subtitle,
id,
tone = 'light',
animate = true,
inView = true,
}: {
eyebrow?: string;
title: string;
subtitle?: string;
id?: string;
tone?: 'light' | 'dark';
animate?: boolean;
inView?: boolean;
}) {
const dark = tone === 'dark';
return (
<motion.div
initial={animate ? { opacity: 0, y: 16 } : false}
animate={animate ? (inView ? { opacity: 1, y: 0 } : {}) : undefined}
transition={{ duration: 0.6, ease: EASE }}
className="mx-auto mb-12 max-w-3xl text-center sm:mb-16"
>
{eyebrow && (
<span
className={`mb-4 inline-block rounded-full px-4 py-1.5 text-xs font-semibold uppercase tracking-[0.12em] ${
dark ? 'bg-white/10 text-white/80' : 'bg-brand-turquoise/10 text-brand-navy'
}`}
>
{eyebrow}
</span>
)}
<h2
id={id}
className={`text-balance text-3xl font-bold tracking-tight sm:text-4xl lg:text-5xl ${
dark ? 'text-white' : 'text-brand-navy'
}`}
>
{title}
</h2>
{subtitle && (
<p
className={`mx-auto mt-4 max-w-2xl text-pretty text-base leading-relaxed sm:text-lg ${
dark ? 'text-white/70' : 'text-neutral-600'
}`}
>
{subtitle}
</p>
)}
</motion.div>
);
}
function AnimatedCounter({
end,
suffix = '',
prefix = '',
decimals = 0,
isActive,
duration = 2,
}: {
end: number;
suffix?: string;
prefix?: string;
decimals?: number;
isActive: boolean;
duration?: number;
}) {
const [count, setCount] = useState(0);
const prefersReducedMotion = useReducedMotion();
useEffect(() => {
if (!isActive) return;
// Mouvement reduit : on affiche directement la valeur finale.
if (prefersReducedMotion) {
setCount(end);
return;
}
let startTime: number | undefined;
let frame = 0;
const animate = (timestamp: number) => {
if (!startTime) startTime = timestamp;
const progress = Math.min((timestamp - startTime) / (duration * 1000), 1);
const eased = 1 - Math.pow(1 - progress, 3);
setCount(eased * end);
if (progress < 1) frame = requestAnimationFrame(animate);
else setCount(end);
};
frame = requestAnimationFrame(animate);
// Sans annulation, la boucle survivait au demontage et continuait de
// declencher setCount sur un composant disparu.
return () => cancelAnimationFrame(frame);
}, [end, duration, isActive, prefersReducedMotion]);
const display = decimals > 0 ? count.toFixed(decimals) : Math.floor(count).toString();
return (
<>
{prefix}
{display}
{suffix}
</>
);
}
export default function LandingPage() {
const t = useTranslations('landing');
const tCommon = useTranslations('common');
const locale = useLocale();
const { user, isAuthenticated } = useAuth();
const heroRef = useRef(null);
const featuresRef = useRef(null);
const statsRef = useRef(null);
const pricingRef = useRef(null);
const testimonialsRef = useRef(null);
const ctaRef = useRef(null);
const howRef = useRef(null);
const isHeroInView = useInView(heroRef, { once: true });
const isFeaturesInView = useInView(featuresRef, { once: true });
const isStatsInView = useInView(statsRef, { once: true, amount: 0.3 });
const isPricingInView = useInView(pricingRef, { once: true });
const isTestimonialsInView = useInView(testimonialsRef, { once: true });
const isCtaInView = useInView(ctaRef, { once: true });
const isHowInView = useInView(howRef, { once: true, amount: 0.2 });
const [billingYearly, setBillingYearly] = useState(false);
const [isDemoOpen, setIsDemoOpen] = useState(false);
const prefersReducedMotion = useReducedMotion();
const { scrollYProgress } = useScroll();
// Parallaxe neutralisee si l'utilisateur a demande moins de mouvement.
const backgroundY = useTransform(
scrollYProgress,
[0, 1],
prefersReducedMotion ? ['0%', '0%'] : ['0%', '50%']
);
// Six modules, un seul traitement. Les degrades arc-en-ciel qui les
// distinguaient (violet, orange, indigo...) n'encodaient rien : ils
// n'existaient que pour colorer. L'icone suffit a identifier le module.
const features = [
{
icon: BarChart3,
title: t('features.dashboard.title'),
description: t('features.dashboard.description'),
link: '/dashboard',
},
{
icon: Package,
title: t('features.bookings.title'),
description: t('features.bookings.description'),
link: '/dashboard/bookings',
},
{
icon: FileText,
title: t('features.documents.title'),
description: t('features.documents.description'),
link: '/dashboard/documents',
},
{
icon: Search,
title: t('features.tracking.title'),
description: t('features.tracking.description'),
link: '/dashboard/track-trace',
},
{
icon: BookOpen,
title: t('features.wiki.title'),
description: t('features.wiki.description'),
link: '/dashboard/wiki',
},
{
icon: Bell,
title: t('features.notifications.title'),
description: t('features.notifications.description'),
link: '/dashboard',
},
];
const stats = [
{ end: 50, prefix: '', suffix: '+', decimals: 0, label: t('stats.carriers'), icon: Ship },
{ end: 10, prefix: '', suffix: 'K+', decimals: 0, label: t('stats.ports'), icon: Anchor },
{ end: 2, prefix: '<', suffix: 's', decimals: 0, label: t('stats.responseTime'), icon: Zap },
{
end: 99.5,
prefix: '',
suffix: '%',
decimals: 1,
label: t('stats.availability'),
icon: CheckCircle2,
},
];
const planFeaturesByKey: Record<string, Array<{ key: string; included: boolean }>> = {
bronze: [
{ key: 'lclBooking', included: true },
{ key: 'tracking', included: true },
{ key: 'dashboard', included: false },
{ key: 'wiki', included: false },
{ key: 'userManagement', included: false },
{ key: 'csvExport', included: false },
{ key: 'apiAccess', included: false },
{ key: 'kam', included: false },
],
silver: [
{ key: 'lclBooking', included: true },
{ key: 'tracking', included: true },
{ key: 'dashboardAdvanced', included: true },
{ key: 'wikiFull', included: true },
{ key: 'userManagement', included: true },
{ key: 'csvExport', included: true },
{ key: 'apiAccess', included: false },
{ key: 'kam', included: false },
],
gold: [
{ key: 'lclBooking', included: true },
{ key: 'tracking', included: true },
{ key: 'dashboardAdvanced', included: true },
{ key: 'wikiFull', included: true },
{ key: 'userManagement', included: true },
{ key: 'csvExport', included: true },
{ key: 'apiFull', included: true },
{ key: 'kam', included: false },
],
platinium: [
{ key: 'lclBooking', included: true },
{ key: 'tracking', included: true },
{ key: 'dashboardAdvanced', included: true },
{ key: 'wikiFull', included: true },
{ key: 'userManagement', included: true },
{ key: 'csvExport', included: true },
{ key: 'apiFull', included: true },
{ key: 'kamCustom', included: true },
],
};
const pricingPlans = [
{
key: 'bronze',
badge: null,
monthlyPrice: 0,
yearlyPrice: 0,
yearlyMonthly: 0,
bookingFee: 15,
ctaLink: '/register',
highlighted: false,
accentColor: 'from-amber-600 to-yellow-500',
textAccent: 'text-amber-700',
badgeBg: 'bg-amber-100 text-amber-800',
},
{
key: 'silver',
badge: 'popular',
monthlyPrice: 299,
yearlyPrice: 3289,
yearlyMonthly: 274,
bookingFee: 10,
ctaLink: '/register',
highlighted: true,
accentColor: 'from-slate-400 to-slate-500',
textAccent: 'text-slate-600',
badgeBg: 'bg-slate-100 text-slate-700',
},
{
key: 'gold',
badge: null,
monthlyPrice: 799,
yearlyPrice: 8789,
yearlyMonthly: 732,
bookingFee: 5,
ctaLink: '/register',
highlighted: false,
accentColor: 'from-yellow-400 to-amber-400',
textAccent: 'text-amber-600',
badgeBg: 'bg-yellow-50 text-amber-700',
},
{
key: 'platinium',
badge: 'custom',
monthlyPrice: null,
yearlyPrice: null,
yearlyMonthly: null,
bookingFee: -1,
ctaLink: '/contact',
highlighted: false,
accentColor: 'from-brand-navy to-brand-turquoise',
textAccent: 'text-brand-turquoise',
badgeBg: 'bg-brand-navy/10 text-brand-navy',
},
] as const;
const testimonials =
(t.raw('testimonials.items') as Array<{
quote: string;
author: string;
role: string;
company: string;
}>) ?? [];
// Grammaire commune : meme courbe, meme distance, meme cascade partout.
const containerVariants = revealGroup;
const itemVariants = reveal;
const numberFormat = new Intl.NumberFormat(locale === 'fr' ? 'fr-FR' : 'en-US');
return (
<div className="min-h-screen bg-white">
<LandingHeader transparentOnTop={true} />
{/* Hero Section */}
<section
ref={heroRef}
aria-labelledby="hero-title"
// 100svh plutot que 100vh : sur mobile, la barre d'URL ne rogne plus
// le bas du hero et les CTA restent visibles sans defilement.
className="relative flex min-h-[100svh] items-center overflow-hidden"
>
<motion.div
aria-hidden="true"
// L'image est portee par le conteneur, pas par le contenu de <video> :
// elle s'affiche donc pendant le chargement de la video et sert de
// repli reel si celle-ci echoue.
className="absolute inset-0 z-0 bg-brand-navy bg-cover bg-center"
style={{
y: backgroundY,
backgroundImage: 'url(/assets/images/background-section-1-landingpage.png)',
}}
>
<video
autoPlay
loop
muted
playsInline
preload="metadata"
className="absolute inset-0 h-full w-full object-cover"
>
<source src="https://assets.mixkit.co/videos/36264/36264-720.mp4" type="video/mp4" />
</video>
<div className="absolute inset-0 bg-brand-navy/65" />
<div className="absolute inset-0 bg-gradient-to-br from-brand-navy/70 via-brand-navy/50 to-brand-turquoise/20" />
</motion.div>
<div className={`relative z-10 pt-28 pb-20 sm:pt-32 ${CONTAINER}`}>
{/* Deux colonnes a partir de lg : l'argument a gauche, sa preuve a
droite. En dessous, la preuve passe sous l'argument.
La colonne de droite a une largeur fixe : en `auto`, elle se
dimensionnait sur le contenu de la carte, qui change a chaque
etape de la demonstration — la carte se retrecissait donc a
chaque reprise de boucle. */}
<div className="grid items-center gap-12 xl:grid-cols-[minmax(0,1fr)_28rem] xl:gap-16">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8, ease: EASE }}
className="text-center xl:text-left"
>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.15, ease: EASE }}
className="mb-6 inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-4 py-2 backdrop-blur-sm sm:mb-8"
>
<Ship className="size-4 flex-shrink-0 text-brand-turquoise" aria-hidden="true" />
<span className="text-xs font-medium text-white/90 sm:text-sm">
{t('hero.badge')}
</span>
</motion.div>
<motion.h1
id="hero-title"
initial={{ opacity: 0, y: 20 }}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8, delay: 0.25, ease: EASE }}
className="mb-5 text-balance text-4xl font-bold leading-[1.06] tracking-tight text-white sm:mb-6 sm:text-6xl"
>
{t('hero.titleLine1')}{' '}
{/* Bloc plutot que <br /> : la coupure ne s'impose qu'a partir
de sm, ou la ligne a la largeur pour la porter. */}
<span className="bg-gradient-to-r from-brand-turquoise to-brand-green bg-clip-text text-transparent sm:block">
{t('hero.titleLine2')}
</span>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8, delay: 0.35, ease: EASE }}
className="mx-auto mb-8 max-w-xl text-pretty text-base leading-relaxed text-white/80 sm:mb-10 sm:text-xl xl:mx-0"
>
{t('hero.subtitle')}
</motion.p>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8, delay: 0.45, ease: EASE }}
className="mx-auto flex w-full max-w-md flex-col items-stretch gap-3 sm:max-w-none sm:flex-row sm:items-center sm:justify-center sm:gap-4 xl:justify-start"
>
{isAuthenticated && user ? (
<Link
href="/dashboard"
target="_blank"
rel="noopener noreferrer"
className={`group inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-lg bg-brand-turquoise px-8 text-base font-semibold text-white shadow-lg shadow-brand-navy/20 transition-all hover:bg-brand-turquoise/90 hover:shadow-2xl sm:w-auto sm:text-lg ${FOCUS_RING} focus-visible:ring-offset-brand-navy`}
>
<span>{t('hero.ctaAuthenticated')}</span>
<LayoutDashboard className="h-5 w-5 transition-transform group-hover:translate-x-1" />
</Link>
) : (
<Link
href="/register"
target="_blank"
rel="noopener noreferrer"
className={`group inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-lg bg-brand-turquoise px-8 text-base font-semibold text-white shadow-lg shadow-brand-navy/20 transition-all hover:bg-brand-turquoise/90 hover:shadow-2xl sm:w-auto sm:text-lg ${FOCUS_RING} focus-visible:ring-offset-brand-navy`}
>
<span>{t('hero.ctaRegister')}</span>
<ArrowRight className="h-5 w-5 transition-transform group-hover:translate-x-1" />
</Link>
)}
<button
type="button"
onClick={() => setIsDemoOpen(true)}
// Action secondaire : contour sur le fond plutot qu'un second
// aplat, pour que le CTA principal reste seul a porter l'oeil.
className={`inline-flex min-h-14 w-full items-center justify-center rounded-lg border border-white/40 bg-white/10 px-8 text-base font-semibold text-white backdrop-blur-sm transition-all hover:bg-white/20 sm:w-auto sm:text-lg ${FOCUS_RING} focus-visible:ring-offset-brand-navy`}
>
{t('hero.ctaDemo')}
</button>
</motion.div>
{/* Reassurance sous les CTA : les trois memes garanties qu'en bas
de page, remontees la ou la decision se prend. */}
<motion.ul
initial={{ opacity: 0 }}
animate={isHeroInView ? { opacity: 1 } : {}}
transition={{ duration: 0.8, delay: 0.65, ease: EASE }}
className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-white/70 xl:justify-start"
>
{[
t('cta.features.noCard'),
t('cta.features.quickSetup'),
t('cta.features.secure'),
].map(item => (
<li key={item} className="flex items-center gap-2">
<CheckCircle2
className="size-4 flex-shrink-0 text-brand-turquoise"
aria-hidden="true"
/>
<span>{item}</span>
</li>
))}
</motion.ul>
</motion.div>
{/* Signature de la page : la recherche de tarif se joue seule. */}
<motion.div
initial={{ opacity: 0, y: 28 }}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.9, delay: 0.5, ease: EASE }}
className="flex justify-center xl:justify-end"
>
<RateSearchDemo />
</motion.div>
</div>
</div>
<div className="pointer-events-none absolute bottom-0 left-0 right-0" aria-hidden="true">
<svg className="h-20 w-full lg:h-28" viewBox="0 0 1440 120" preserveAspectRatio="none">
<path
d="M0,60 C240,90 480,30 720,60 C960,90 1200,30 1440,60 L1440,120 L0,120 Z"
fill="white"
opacity="0.8"
/>
<path
d="M0,80 C240,50 480,110 720,80 C960,50 1200,110 1440,80 L1440,120 L0,120 Z"
fill="white"
/>
</svg>
</div>
</section>
{/* Stats Section */}
<section ref={statsRef} className={`bg-neutral-50 ${SECTION_PADDING_TIGHT}`}>
<motion.div
variants={containerVariants}
initial="hidden"
animate={isStatsInView ? 'visible' : 'hidden'}
className={CONTAINER}
>
{/* Separateurs plutot que grands ecarts : les quatre chiffres se
lisent comme un seul bandeau et non comme quatre blocs isoles. */}
<dl className="grid grid-cols-2 gap-y-10 sm:gap-y-12 lg:grid-cols-4 lg:divide-x lg:divide-border lg:gap-y-0">
{stats.map((stat, index) => {
const IconComponent = stat.icon;
return (
<motion.div
key={index}
variants={itemVariants}
className="group px-4 text-center lg:px-8"
>
<div className="mb-3 flex justify-center">
<div className="rounded-full bg-brand-turquoise/10 p-3 transition-colors group-hover:bg-brand-turquoise/20">
<IconComponent className="h-7 w-7 text-brand-turquoise" aria-hidden="true" />
</div>
</div>
{/* `dt` precede `dd` dans le source (contrainte de <dl>) ;
l'inversion visuelle est faite par flex-col-reverse. */}
<div className="flex flex-col-reverse">
<dt className="mt-1 text-sm font-medium text-neutral-600 sm:text-base">
{stat.label}
</dt>
<dd className="text-4xl font-bold tabular-nums tracking-tight text-brand-navy sm:text-5xl">
<AnimatedCounter
end={stat.end}
prefix={stat.prefix}
suffix={stat.suffix}
decimals={stat.decimals}
isActive={isStatsInView}
duration={2.2}
/>
</dd>
</div>
</motion.div>
);
})}
</dl>
</motion.div>
</section>
{/* Features Section */}
<section
ref={featuresRef}
id="features"
aria-labelledby="features-title"
className={SECTION_PADDING}
>
<div className={CONTAINER}>
<SectionHeading
id="features-title"
title={t('features.title')}
subtitle={t('features.subtitle')}
inView={isFeaturesInView}
/>
<motion.div
variants={containerVariants}
initial="hidden"
animate={isFeaturesInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"
>
{features.map((feature, index) => {
const IconComponent = feature.icon;
return (
<motion.div
key={index}
variants={itemVariants}
whileHover={{ y: -4 }}
className="h-full"
>
<Link
href={feature.link}
target="_blank"
rel="noopener noreferrer"
// Ombre allegee : douze cartes en `shadow-lg` saturaient la
// page. Le relief n'apparait plus qu'au survol.
className={`group relative flex h-full flex-col overflow-hidden rounded-2xl border border-border bg-white p-6 shadow-sm transition-all hover:border-brand-turquoise/40 hover:shadow-xl sm:p-7 ${FOCUS_RING}`}
>
<div
aria-hidden="true"
className="mb-5 flex size-12 flex-shrink-0 items-center justify-center rounded-xl bg-brand-navy transition-colors group-hover:bg-brand-turquoise"
>
<IconComponent className="size-6 text-brand-turquoise transition-colors group-hover:text-brand-navy" />
</div>
<h3 className="mb-2 text-lg font-bold text-brand-navy transition-colors group-hover:text-brand-turquoise">
{feature.title}
</h3>
<p className="flex-1 text-sm leading-relaxed text-neutral-600">
{feature.description}
</p>
<div className="mt-5 flex items-center gap-2 text-sm font-semibold text-brand-navy">
<span>{tCommon('discover')}</span>
<ArrowRight
className="h-4 w-4 text-brand-turquoise transition-transform group-hover:translate-x-1"
aria-hidden="true"
/>
</div>
</Link>
</motion.div>
);
})}
</motion.div>
</div>
</section>
{/* Partner Logos Section */}
<section
aria-labelledby="partners-title"
className={`border-y border-border bg-white ${SECTION_PADDING_TIGHT}`}
>
<div className={CONTAINER}>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="mb-10 text-center"
>
{/* Bandeau de preuve, pas un chapitre : titre discret pour ne pas
concurrencer les vrais titres de section. */}
<h2
id="partners-title"
className="text-sm font-semibold uppercase tracking-[0.12em] text-neutral-500"
>
{t('partners.title')}
</h2>
<p className="mt-2 text-neutral-600">{t('partners.subtitle')}</p>
</motion.div>
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.2 }}
className="grid grid-cols-2 items-center gap-x-8 gap-y-6 md:grid-cols-4 lg:grid-cols-6"
>
{[
'ECU Line 2.png',
'ICL 1.png',
'NVO Consolidation 1.png',
'TCC LOG 1.png',
'VANGUARD 1.png',
'image 1.png',
].map((logo, index) => (
<motion.div
key={index}
initial={{ opacity: 0, scale: 0.8 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
whileHover={{ scale: 1.04 }}
className="flex items-center justify-center p-2 opacity-70 grayscale transition-all hover:opacity-100 hover:grayscale-0"
>
<Image
src={`/assets/logos/partner/${logo}`}
alt={`Partner ${index + 1}`}
width={120}
height={60}
className="h-10 w-auto object-contain sm:h-12"
/>
</motion.div>
))}
</motion.div>
</div>
</section>
{/* Pricing Section */}
<section
ref={pricingRef}
id="pricing"
aria-labelledby="pricing-title"
className={`bg-gradient-to-b from-white to-neutral-50 ${SECTION_PADDING}`}
>
<div className={CONTAINER}>
<SectionHeading
id="pricing-title"
eyebrow={t('pricing.badge')}
title={t('pricing.title')}
subtitle={t('pricing.subtitle')}
inView={isPricingInView}
/>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={isPricingInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.2 }}
className="mb-12 flex flex-wrap items-center justify-center gap-x-4 gap-y-3"
>
<span
className={`text-sm font-medium ${!billingYearly ? 'text-brand-navy' : 'text-neutral-400'}`}
>
{t('pricing.monthly')}
</span>
<button
type="button"
role="switch"
aria-checked={billingYearly}
aria-label={t('pricing.yearly')}
onClick={() => setBillingYearly(v => !v)}
className={`relative inline-flex h-7 w-14 flex-shrink-0 items-center rounded-full transition-colors ${FOCUS_RING} ${
billingYearly ? 'bg-brand-turquoise' : 'bg-neutral-300'
}`}
>
<span
aria-hidden="true"
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
billingYearly ? 'translate-x-8' : 'translate-x-1'
}`}
/>
</button>
<span
className={`text-sm font-medium ${billingYearly ? 'text-brand-navy' : 'text-neutral-400'}`}
>
{t('pricing.yearly')}
</span>
{/* Place reservee en permanence : sans cela, l'apparition du badge
decalait toute la ligne au moment du basculement. */}
<span
aria-hidden={!billingYearly}
className={`rounded-full bg-brand-green/10 px-2.5 py-1 text-xs font-bold text-brand-green transition-opacity ${
billingYearly ? 'opacity-100' : 'invisible opacity-0'
}`}
>
{t('pricing.yearlySaving')}
</span>
</motion.div>
<motion.div
variants={containerVariants}
initial="hidden"
animate={isPricingInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 items-stretch gap-6 md:grid-cols-2 lg:grid-cols-4 lg:gap-5"
>
{pricingPlans.map(plan => {
const planName = t(`pricing.plans.${plan.key}.name` as any);
const planDescription = t(`pricing.plans.${plan.key}.description` as any);
const planUsers = t(`pricing.plans.${plan.key}.users` as any);
const planShipments = t(`pricing.plans.${plan.key}.shipments` as any);
const planBookingFee =
plan.bookingFee === -1
? t('pricing.bookingFeeCustom')
: t('pricing.bookingFee', { price: `${plan.bookingFee}€` });
const planCta = t(`pricing.plans.${plan.key}.cta` as any);
const planFeatures = planFeaturesByKey[plan.key] ?? [];
return (
<motion.div
key={plan.key}
variants={itemVariants}
whileHover={{ y: -4 }}
// L'offre mise en avant est surelevee a partir de lg : sur une
// rangee de quatre cartes, l'aplat navy seul ne suffisait pas
// a la designer au premier coup d'oeil.
className={`relative flex flex-col overflow-hidden rounded-2xl transition-all ${
plan.highlighted
? 'bg-brand-navy shadow-2xl ring-2 ring-brand-turquoise lg:-mt-4 lg:mb-4'
: 'border border-border bg-white shadow-sm hover:border-brand-turquoise/30 hover:shadow-xl'
}`}
>
<div
aria-hidden="true"
className={`h-1.5 w-full bg-gradient-to-r ${plan.accentColor}`}
/>
{plan.badge === 'popular' && (
<div className="absolute right-4 top-5">
<span className="rounded-full bg-brand-turquoise px-2.5 py-1 text-xs font-bold text-white">
{t('pricing.popularBadge')}
</span>
</div>
)}
{plan.badge === 'custom' && (
<div className="absolute right-4 top-5">
<span className="rounded-full bg-gradient-to-r from-brand-navy to-brand-turquoise px-2.5 py-1 text-xs font-bold text-white">
{t('pricing.customBadge')}
</span>
</div>
)}
<div className="flex flex-col flex-1 p-6">
<div className="mb-4">
<div
className={`inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider px-2.5 py-1 rounded-full mb-3 ${plan.highlighted ? 'bg-white/10 text-white/70' : plan.badgeBg}`}
>
<div
className={`w-1.5 h-1.5 rounded-full bg-gradient-to-r ${plan.accentColor}`}
/>
{planName}
</div>
<h3
className={`text-xl font-bold mb-1 ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}
>
{planDescription}
</h3>
</div>
<div className="mb-6">
{plan.monthlyPrice === null ? (
<div>
<span
className={`text-3xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}
>
{t('pricing.custom')}
</span>
<p
className={`text-sm mt-1 ${plan.highlighted ? 'text-white/60' : 'text-neutral-500'}`}
>
{t('pricing.customSubtitle')}
</p>
</div>
) : plan.monthlyPrice === 0 ? (
<div>
<span
className={`text-4xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}
>
{t('pricing.free')}
</span>
<p
className={`text-sm mt-1 ${plan.highlighted ? 'text-white/60' : 'text-neutral-500'}`}
>
{t('pricing.freeSubtitle')}
</p>
</div>
) : (
<div>
<div className="flex items-end gap-1">
<span
className={`text-4xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}
>
{billingYearly ? plan.yearlyMonthly : plan.monthlyPrice}€
</span>
<span
className={`text-sm pb-1.5 ${plan.highlighted ? 'text-white/60' : 'text-neutral-500'}`}
>
{t('pricing.perMonth')}
</span>
</div>
{billingYearly ? (
<p
className={`text-xs mt-1 ${plan.highlighted ? 'text-white/60' : 'text-neutral-500'}`}
>
{t('pricing.billedYearly', {
price: numberFormat.format(plan.yearlyPrice ?? 0),
})}
</p>
) : (
<p className="text-xs mt-1 text-brand-turquoise">
{t('pricing.saveWithYearly')}
</p>
)}
</div>
)}
</div>
<div
className={`rounded-xl p-3 mb-5 space-y-2 ${plan.highlighted ? 'bg-white/10' : 'bg-neutral-50'}`}
>
<div className="flex items-center gap-2">
<Users className="w-3.5 h-3.5 flex-shrink-0 text-brand-turquoise" />
<span
className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-neutral-700'}`}
>
{planUsers}
</span>
</div>
<div className="flex items-center gap-2">
<Ship className="w-3.5 h-3.5 flex-shrink-0 text-brand-turquoise" />
<span
className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-neutral-700'}`}
>
{planShipments}
</span>
</div>
<div className="flex items-center gap-2">
<Package className="w-3.5 h-3.5 flex-shrink-0 text-brand-turquoise" />
<span
className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-neutral-700'}`}
>
{planBookingFee}
</span>
</div>
</div>
<ul className="space-y-2.5 mb-6 flex-1">
{planFeatures.map((feature, featureIndex) => (
<li key={featureIndex} className="flex items-start gap-2.5">
{feature.included ? (
<Check
className={`w-4 h-4 flex-shrink-0 mt-0.5 ${plan.highlighted ? 'text-brand-turquoise' : 'text-brand-green'}`}
/>
) : (
<X
className={`w-4 h-4 flex-shrink-0 mt-0.5 ${plan.highlighted ? 'text-white/20' : 'text-neutral-300'}`}
/>
)}
<span
className={`text-sm ${
feature.included
? plan.highlighted
? 'text-white/90'
: 'text-neutral-700'
: plan.highlighted
? 'text-white/30'
: 'text-neutral-400'
}`}
>
{t(`pricing.features.${feature.key}` as any)}
</span>
</li>
))}
</ul>
<Link
href={plan.ctaLink}
className={`inline-flex h-11 w-full items-center justify-center rounded-lg px-6 text-sm font-semibold transition-all ${FOCUS_RING} ${
plan.highlighted
? 'bg-brand-turquoise text-white shadow-lg shadow-brand-turquoise/30 hover:bg-brand-turquoise/90 hover:shadow-xl focus-visible:ring-offset-brand-navy'
: plan.key === 'bronze'
? 'bg-neutral-100 text-brand-navy hover:bg-neutral-200'
: 'bg-brand-navy text-white shadow-sm hover:bg-brand-navy/90 hover:shadow-md'
}`}
>
{planCta}
</Link>
</div>
</motion.div>
);
})}
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isPricingInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8, delay: 0.5 }}
className="mt-12 text-center space-y-2"
>
<p className="text-neutral-600 text-sm">{t('pricing.noCommitment')}</p>
<p className="text-sm text-neutral-500">
{t('pricing.questions')}{' '}
<Link href="/contact" className="text-brand-turquoise font-medium hover:underline">
{t('pricing.contactSales')}
</Link>
</p>
</motion.div>
</div>
</section>
{/* How It Works Section */}
<section
ref={howRef}
aria-labelledby="how-title"
className={`relative overflow-hidden bg-gradient-to-br from-brand-navy to-brand-navy/95 ${SECTION_PADDING}`}
>
<div className="pointer-events-none absolute inset-0 opacity-10" aria-hidden="true">
<div className="absolute left-10 top-10 h-72 w-72 rounded-full bg-brand-turquoise blur-3xl" />
<div className="absolute bottom-10 right-10 h-72 w-72 rounded-full bg-brand-green blur-3xl" />
</div>
<div className={`relative z-10 ${CONTAINER}`}>
<SectionHeading
id="how-title"
title={t('howItWorks.title')}
subtitle={t('howItWorks.subtitle')}
tone="dark"
inView={isHowInView}
/>
<div className="relative">
<div
aria-hidden="true"
className="absolute left-[12.5%] right-[12.5%] top-10 hidden h-px bg-white/15 lg:block"
>
{/* Le trait se remplit dans le sens de la lecture : c'est la
traversee, du depart a l'arrivee. */}
<motion.div
initial={{ scaleX: 0 }}
animate={isHowInView ? { scaleX: 1 } : {}}
transition={{ duration: 1.4, delay: 0.4, ease: EASE }}
style={{ transformOrigin: 'left' }}
className="absolute inset-0 bg-brand-turquoise"
/>
</div>
<div className="grid grid-cols-1 gap-10 md:grid-cols-2 lg:grid-cols-4 lg:gap-8">
{[
{ stepKey: 'step1', step: '01', icon: Search },
{ stepKey: 'step2', step: '02', icon: BarChart3 },
{ stepKey: 'step3', step: '03', icon: CheckCircle2 },
{ stepKey: 'step4', step: '04', icon: Container },
].map((step, index) => {
const IconComponent = step.icon;
return (
<motion.div
key={index}
initial={{ opacity: 0, y: 16 }}
animate={isHowInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.55, delay: 0.3 + index * 0.12, ease: EASE }}
className="relative z-10 text-center"
>
{/* Escale sur la ligne : disque navy cercle de turquoise,
l'icone du module a l'interieur. Le numero, lui, est
une legende — la sequence est reelle, il la porte. */}
<div className="mb-4 flex justify-center">
<div className="flex size-20 items-center justify-center rounded-full border border-brand-turquoise/40 bg-brand-navy shadow-xl">
<IconComponent
className="size-7 text-brand-turquoise"
aria-hidden="true"
/>
</div>
</div>
<p className="mb-2 font-heading text-xs font-bold tracking-[0.2em] tabular-nums text-brand-turquoise">
{step.step}
</p>
<h3 className="mb-2 text-lg font-bold text-white">
{t(`howItWorks.${step.stepKey}.title` as any)}
</h3>
<p className="mx-auto max-w-xs text-pretty text-sm leading-relaxed text-white/70">
{t(`howItWorks.${step.stepKey}.description` as any)}
</p>
</motion.div>
);
})}
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={isHowInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 1, ease: EASE }}
className="mt-16 text-center"
>
<Link
href="/register"
target="_blank"
rel="noopener noreferrer"
className={`group inline-flex h-14 items-center justify-center gap-2 rounded-lg bg-brand-turquoise px-8 text-lg font-semibold text-white transition-all hover:bg-brand-turquoise/90 hover:shadow-2xl ${FOCUS_RING} focus-visible:ring-offset-brand-navy`}
>
<span>{tCommon('tryNow')}</span>
<ArrowRight
className="h-5 w-5 transition-transform group-hover:translate-x-1"
aria-hidden="true"
/>
</Link>
<p className="mt-3 text-white/50 text-sm">{t('howItWorks.ctaHint')}</p>
</motion.div>
</div>
</section>
{/* Testimonials Section */}
<section
ref={testimonialsRef}
aria-labelledby="testimonials-title"
className={`bg-gradient-to-br from-neutral-50 to-white ${SECTION_PADDING}`}
>
<div className={CONTAINER}>
<SectionHeading
id="testimonials-title"
title={t('testimonials.title')}
subtitle={t('testimonials.subtitle')}
inView={isTestimonialsInView}
/>
<motion.div
variants={containerVariants}
initial="hidden"
animate={isTestimonialsInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 gap-6 md:grid-cols-3"
>
{testimonials.map((testimonial, index) => (
<motion.figure
key={index}
variants={itemVariants}
whileHover={{ y: -4 }}
className="flex h-full flex-col rounded-2xl border border-border bg-white p-6 shadow-sm transition-shadow hover:shadow-xl sm:p-7"
>
<div className="mb-4 flex" role="img" aria-label="5/5">
{[...Array(5)].map((_, i) => (
<svg
key={i}
aria-hidden="true"
className="h-4 w-4 fill-current text-yellow-400"
viewBox="0 0 20 20"
>
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
</svg>
))}
</div>
<blockquote className="flex-1 text-pretty leading-relaxed text-neutral-700">
&ldquo;{testimonial.quote}&rdquo;
</blockquote>
<figcaption className="mt-6 border-t border-border pt-4">
<div className="font-bold text-brand-navy">{testimonial.author}</div>
<div className="text-sm text-neutral-600">
{testimonial.role} — {testimonial.company}
</div>
</figcaption>
</motion.figure>
))}
</motion.div>
</div>
</section>
{/* CTA Section */}
<section ref={ctaRef} aria-labelledby="cta-title" className={SECTION_PADDING}>
<div className={CONTAINER}>
<motion.div
variants={containerVariants}
initial="hidden"
animate={isCtaInView ? 'visible' : 'hidden'}
// Panneau delimite : la derniere section flottait sur le meme blanc
// que la precedente et se lisait comme sa suite plutot que comme
// une adresse directe au lecteur.
className="mx-auto max-w-4xl rounded-3xl border border-border bg-neutral-50 px-6 py-12 text-center sm:px-12 sm:py-16"
>
<motion.div variants={itemVariants}>
<h2
id="cta-title"
className="text-balance text-3xl font-bold tracking-tight text-brand-navy sm:text-4xl lg:text-5xl"
>
{t('cta.title')}
</h2>
<p className="mx-auto mt-4 max-w-2xl text-pretty text-base text-neutral-600 sm:text-lg">
{t('cta.subtitle')}
</p>
</motion.div>
<motion.div
variants={itemVariants}
className="mx-auto mt-8 flex w-full max-w-md flex-col items-stretch justify-center gap-3 sm:max-w-none sm:flex-row sm:items-center sm:gap-4"
>
{isAuthenticated && user ? (
<Link
href="/dashboard"
target="_blank"
rel="noopener noreferrer"
className={`group inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-lg bg-brand-turquoise px-8 text-base font-semibold text-white transition-all hover:bg-brand-turquoise/90 hover:shadow-xl sm:w-auto sm:text-lg ${FOCUS_RING}`}
>
<span>{t('cta.ctaAuthenticated')}</span>
<LayoutDashboard className="h-5 w-5 transition-transform group-hover:translate-x-1" />
</Link>
) : (
<>
<Link
href="/register"
target="_blank"
rel="noopener noreferrer"
className={`group inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-lg bg-brand-turquoise px-8 text-base font-semibold text-white transition-all hover:bg-brand-turquoise/90 hover:shadow-xl sm:w-auto sm:text-lg ${FOCUS_RING}`}
>
<span>{t('cta.ctaRegister')}</span>
<ArrowRight className="h-5 w-5 transition-transform group-hover:translate-x-1" />
</Link>
<Link
href="/login"
target="_blank"
rel="noopener noreferrer"
className={`inline-flex min-h-14 w-full items-center justify-center rounded-lg border border-brand-navy/15 bg-white px-8 text-base font-semibold text-brand-navy transition-all hover:bg-white hover:shadow-md sm:w-auto sm:text-lg ${FOCUS_RING}`}
>
{t('cta.ctaLogin')}
</Link>
</>
)}
</motion.div>
<motion.ul
variants={itemVariants}
className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-3 text-sm text-neutral-500"
>
{[
{ icon: CheckCircle2, label: t('cta.features.noCard') },
{ icon: Clock, label: t('cta.features.quickSetup') },
{ icon: Shield, label: t('cta.features.secure') },
].map(({ icon: Icon, label }) => (
<li key={label} className="flex items-center gap-2">
<Icon className="h-4 w-4 flex-shrink-0 text-brand-green" aria-hidden="true" />
<span>{label}</span>
</li>
))}
</motion.ul>
</motion.div>
</div>
</section>
<LandingFooter />
<DemoVideoModal isOpen={isDemoOpen} onClose={() => setIsDemoOpen(false)} />
</div>
);
}