1241 lines
50 KiB
TypeScript
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">
|
|
“{testimonial.quote}”
|
|
</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>
|
|
);
|
|
}
|