feat(landing): hero animé dessiné — la promesse du produit montrée au lieu d'une photo de banque d'images
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m11s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m11s
Deux pages de document côte à côte (source FR, traduction qui cycle EN/ES/DE) : squelette identique (titre, lignes, tableau, graphique), seuls les mots et les longueurs de lignes changent, avec balayage doré à chaque changement et points animés sur le connecteur. Respecte le réglage « réduire les animations ». Légende : « Même mise en page, nouvelle langue — rien d'autre ne bouge ».
This commit is contained in:
187
frontend/src/components/landing/hero-visual.tsx
Normal file
187
frontend/src/components/landing/hero-visual.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { motion, useReducedMotion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Languages } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Landing hero visual — the product promise drawn instead of photographed:
|
||||||
|
* one document whose layout never moves while its language changes.
|
||||||
|
* Pure geometry (bars, table, chart) + one real translated heading per card.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SOURCE_HEADING = 'Rapport annuel';
|
||||||
|
|
||||||
|
const TARGETS = [
|
||||||
|
{ code: 'EN', heading: 'Annual report', widths: [0.94, 0.82, 0.88, 0.58, 0.76] },
|
||||||
|
{ code: 'ES', heading: 'Informe anual', widths: [0.9, 0.86, 0.84, 0.64, 0.8] },
|
||||||
|
{ code: 'DE', heading: 'Jahresbericht', widths: [0.98, 0.78, 0.92, 0.52, 0.7] },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SOURCE_WIDTHS = [0.92, 0.8, 0.86, 0.62, 0.78];
|
||||||
|
|
||||||
|
const CYCLE_MS = 3600;
|
||||||
|
|
||||||
|
function TextLines({ widths, accent = false }: { widths: readonly number[]; accent?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-[5%]">
|
||||||
|
{widths.map((w, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0, x: 6 },
|
||||||
|
show: { opacity: 1, x: 0, transition: { duration: 0.35, ease: 'easeOut' } },
|
||||||
|
}}
|
||||||
|
className={cn('h-[4.5%] min-h-[3px] rounded-full', accent ? 'bg-brand-dark/70 dark:bg-white/70' : 'bg-brand-dark/55 dark:bg-white/55')}
|
||||||
|
style={{ width: `${w * 100}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniTable() {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-[3%]">
|
||||||
|
{[0, 1, 2].map((c) => (
|
||||||
|
<div key={c} className="space-y-[4%]">
|
||||||
|
<div className="h-[6px] min-h-[3px] rounded-sm bg-brand-accent/60" />
|
||||||
|
<div className="h-[4px] min-h-[2px] rounded-sm bg-brand-dark/25 dark:bg-white/25" />
|
||||||
|
<div className="h-[4px] min-h-[2px] w-4/5 rounded-sm bg-brand-dark/25 dark:bg-white/25" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniChart() {
|
||||||
|
const heights = [42, 68, 52, 84];
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-end justify-between gap-[6%]">
|
||||||
|
{heights.map((h, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn('w-full rounded-t-sm', i === 3 ? 'bg-brand-accent' : 'bg-brand-dark/30 dark:bg-white/30')}
|
||||||
|
style={{ height: `${h}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentCard({
|
||||||
|
side,
|
||||||
|
heading,
|
||||||
|
widths,
|
||||||
|
tag,
|
||||||
|
}: {
|
||||||
|
side: 'source' | 'target';
|
||||||
|
heading: string;
|
||||||
|
widths: readonly number[];
|
||||||
|
tag: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-full w-[44%] flex-col rounded-lg border border-black/[0.06] bg-white p-[5%] shadow-[0_10px_30px_rgba(26,26,26,0.06)] dark:border-white/[0.08] dark:bg-[#181818]">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute right-[4%] top-[4%] rounded-full px-2 py-0.5 text-[9px] font-black tracking-widest',
|
||||||
|
side === 'source'
|
||||||
|
? 'border border-black/10 text-brand-dark/50 dark:border-white/15 dark:text-white/50'
|
||||||
|
: 'border border-brand-accent/40 bg-brand-accent/10 text-brand-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<p className="mb-[6%] font-serif text-[clamp(10px,1.6vw,15px)] font-medium leading-tight tracking-tight text-brand-dark dark:text-white">
|
||||||
|
{heading}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
key={heading}
|
||||||
|
variants={{ hidden: {}, show: { transition: { staggerChildren: 0.05 } } }}
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
className="flex flex-1 flex-col justify-between gap-[5%]"
|
||||||
|
>
|
||||||
|
<TextLines widths={widths} accent={side === 'target'} />
|
||||||
|
<div className="h-[16%] min-h-[26px]">
|
||||||
|
<MiniTable />
|
||||||
|
</div>
|
||||||
|
<div className="h-[20%] min-h-[30px]">
|
||||||
|
<MiniChart />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* one shimmer sweep per language change — the "translation" moment */}
|
||||||
|
{side === 'target' && (
|
||||||
|
<motion.div
|
||||||
|
key={`sweep-${heading}`}
|
||||||
|
className="pointer-events-none absolute inset-y-0 w-1/3 bg-gradient-to-r from-transparent via-brand-accent/10 to-transparent"
|
||||||
|
initial={{ x: '-160%', opacity: 1 }}
|
||||||
|
animate={{ x: '420%', opacity: 0 }}
|
||||||
|
transition={{ duration: 0.9, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeroVisual() {
|
||||||
|
const prefersReduced = useReducedMotion();
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prefersReduced) return;
|
||||||
|
const id = setInterval(() => setIndex((i) => (i + 1) % TARGETS.length), CYCLE_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [prefersReduced]);
|
||||||
|
|
||||||
|
const target = TARGETS[index];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="editorial-card overflow-hidden !bg-brand-muted/30 p-4 dark:!bg-[#1f1f1f]/30 lg:p-6">
|
||||||
|
<div className="relative aspect-[16/10] overflow-hidden rounded-2xl border border-black/5 bg-white shadow-sm dark:border-white/5 dark:bg-[#141414] lg:aspect-[21/9]">
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center gap-[3%] p-[4%] lg:p-[3%]">
|
||||||
|
<DocumentCard side="source" heading={SOURCE_HEADING} widths={SOURCE_WIDTHS} tag="FR" />
|
||||||
|
|
||||||
|
{/* connector: the translation itself, dots travelling left to right */}
|
||||||
|
<div className="relative flex h-full w-[10%] min-w-[44px] flex-col items-center justify-center">
|
||||||
|
<div className="absolute inset-x-[18%] top-1/2 h-px -translate-y-1/2 bg-brand-dark/10 dark:bg-white/10" />
|
||||||
|
<div className="relative z-10 flex size-9 items-center justify-center rounded-full bg-brand-accent text-white shadow-lg lg:size-10">
|
||||||
|
<Languages size={16} />
|
||||||
|
</div>
|
||||||
|
{!prefersReduced && (
|
||||||
|
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2">
|
||||||
|
{[0, 1, 2].map((d) => (
|
||||||
|
<motion.span
|
||||||
|
key={d}
|
||||||
|
className="absolute top-0 size-1 rounded-full bg-brand-accent"
|
||||||
|
initial={{ left: '0%', opacity: 0 }}
|
||||||
|
animate={{ left: '100%', opacity: [0, 1, 1, 0] }}
|
||||||
|
transition={{ duration: 1.6, repeat: Infinity, delay: d * 0.45, ease: 'easeInOut' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.span
|
||||||
|
key={target.code}
|
||||||
|
initial={{ opacity: 0, y: 4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -4 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
className="absolute bottom-[14%] rounded-full border border-brand-accent/30 px-2 py-0.5 text-[9px] font-black tracking-widest text-brand-accent"
|
||||||
|
>
|
||||||
|
{target.code}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocumentCard side="target" heading={target.heading} widths={target.widths} tag={target.code} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,10 +23,10 @@ import {
|
|||||||
Languages,
|
Languages,
|
||||||
Monitor,
|
Monitor,
|
||||||
Eye,
|
Eye,
|
||||||
Activity,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import { HeroVisual } from "./hero-visual";
|
||||||
import { API_BASE } from "@/lib/config";
|
import { API_BASE } from "@/lib/config";
|
||||||
import { LanguageSwitcher } from "@/components/ui/language-switcher";
|
import { LanguageSwitcher } from "@/components/ui/language-switcher";
|
||||||
|
|
||||||
@@ -134,17 +134,10 @@ const Hero = () => {
|
|||||||
transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1], delay: 0.2 }}
|
transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1], delay: 0.2 }}
|
||||||
className="relative max-w-5xl mx-auto"
|
className="relative max-w-5xl mx-auto"
|
||||||
>
|
>
|
||||||
<div className="editorial-card p-4 lg:p-6 overflow-hidden !bg-brand-muted/30 dark:!bg-[#1f1f1f]/30">
|
<HeroVisual />
|
||||||
<div className="aspect-[16/9] lg:aspect-[21/9] bg-white dark:bg-[#141414] rounded-2xl relative overflow-hidden shadow-sm border border-black/5 dark:border-white/5">
|
<p className="mt-6 text-center text-xs font-light uppercase tracking-[0.25em] text-brand-dark/50 dark:text-white/50">
|
||||||
<img
|
{t('landing.hero.visualCaption')}
|
||||||
src="https://images.unsplash.com/photo-1499951360447-b19be8fe80f5?auto=format&fit=crop&q=80&w=2000"
|
</p>
|
||||||
alt="Workspace"
|
|
||||||
className="w-full h-full object-cover filter saturate-[0.8] grayscale-[0.1]"
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-brand-dark/5 dark:bg-black/20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -149,5 +149,6 @@
|
|||||||
"landing.translate.aiAnalysis": "Active AI Analysis",
|
"landing.translate.aiAnalysis": "Active AI Analysis",
|
||||||
"landing.translate.processing": "Processing",
|
"landing.translate.processing": "Processing",
|
||||||
"landing.translate.preservingLayout": "Your layout is being preserved",
|
"landing.translate.preservingLayout": "Your layout is being preserved",
|
||||||
"landing.formats.pill": "COMPATIBILITY"
|
"landing.formats.pill": "COMPATIBILITY",
|
||||||
|
"landing.hero.visualCaption": "Same layout, new language — nothing else moves"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,5 +149,6 @@
|
|||||||
"landing.translate.aiAnalysis": "Analyse IA Active",
|
"landing.translate.aiAnalysis": "Analyse IA Active",
|
||||||
"landing.translate.processing": "Traitement en cours",
|
"landing.translate.processing": "Traitement en cours",
|
||||||
"landing.translate.preservingLayout": "Votre mise en page est en cours de préservation",
|
"landing.translate.preservingLayout": "Votre mise en page est en cours de préservation",
|
||||||
"landing.formats.pill": "COMPATIBILITÉ"
|
"landing.formats.pill": "COMPATIBILITÉ",
|
||||||
|
"landing.hero.visualCaption": "Même mise en page, nouvelle langue — rien d'autre ne bouge"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user