// SALVO — Part 1: Utilities, Loader, Cursor, Nav, Hero, Stats, Problem
const { useState, useEffect, useRef, useCallback, useMemo } = React;
// ===== HOOKS =====
const useInView = (options = {}) => {
const ref = useRef(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) { setInView(true); observer.disconnect(); }
}, { threshold: 0.12, ...options });
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return [ref, inView];
};
const useReducedMotion = () => {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setReduced(mq.matches);
const handler = (e) => setReduced(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
return reduced;
};
// ===== ANIMATED COUNTER =====
const AnimatedCounter = ({ from = 0, to, duration = 1500, suffix = '', prefix = '', format }) => {
const [value, setValue] = useState(from);
const [ref, inView] = useInView();
const reduced = useReducedMotion();
useEffect(() => {
if (!inView) return;
if (reduced) { setValue(to); return; }
const start = performance.now();
const animate = (now) => {
const p = Math.min((now - start) / duration, 1);
const ease = p === 1 ? 1 : 1 - Math.pow(2, -10 * p);
setValue(from + (to - from) * ease);
if (p < 1) requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}, [inView, reduced]);
const display = format ? format(value) : (Number.isInteger(to) ? Math.round(value) : value.toFixed(1));
return {prefix}{display}{suffix} ;
};
// ===== REVEAL ON SCROLL =====
const RevealOnScroll = ({ children, delay = 0, y = 20, className = '' }) => {
const [ref, inView] = useInView();
const reduced = useReducedMotion();
const style = reduced ? {} : {
opacity: inView ? 1 : 0,
transform: inView ? 'translateY(0)' : `translateY(${y}px)`,
transition: `opacity 0.6s cubic-bezier(0.16,1,0.3,1) ${delay}ms, transform 0.6s cubic-bezier(0.16,1,0.3,1) ${delay}ms`,
};
return
{children}
;
};
// ===== SALVO BUTTON =====
const SalvoButton = ({ children, variant = 'primary', onClick, href, style = {} }) => {
const base = {
display: 'inline-flex', alignItems: 'center', gap: '8px',
fontFamily: "'Syne', sans-serif", fontWeight: 700,
fontSize: '11px', letterSpacing: '0.12em', textTransform: 'uppercase',
padding: '12px 28px', cursor: 'pointer', border: 'none',
textDecoration: 'none', transition: 'all 0.2s cubic-bezier(0.16,1,0.3,1)',
position: 'relative', overflow: 'hidden',
};
const primary = {
...base, background: 'var(--accent)', color: 'var(--bg)',
...style,
};
const ghost = {
...base, background: 'transparent', color: 'var(--white)',
border: '1px solid var(--border)', ...style,
};
const s = variant === 'primary' ? primary : ghost;
const El = href ? 'a' : 'button';
return (
{
if (variant === 'primary') {
e.currentTarget.style.background = 'var(--accent-dim)';
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 8px 32px rgba(255,107,53,0.25)';
} else {
e.currentTarget.style.color = 'var(--accent)';
e.currentTarget.style.borderColor = 'var(--accent)';
e.currentTarget.style.transform = 'translateY(-2px)';
}
}}
onMouseLeave={e => {
e.currentTarget.style.background = variant === 'primary' ? 'var(--accent)' : 'transparent';
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = 'none';
if (variant !== 'primary') {
e.currentTarget.style.color = 'var(--white)';
e.currentTarget.style.borderColor = 'var(--border)';
}
}}
>{children}
);
};
// ===== CUSTOM CURSOR =====
const CustomCursor = () => {
const dotRef = useRef(null);
const ringRef = useRef(null);
const pos = useRef({ x: 0, y: 0 });
const ring = useRef({ x: 0, y: 0 });
useEffect(() => {
const dot = dotRef.current;
const ringEl = ringRef.current;
if (!dot || !ringEl) return;
const onMove = (e) => {
pos.current = { x: e.clientX, y: e.clientY };
dot.style.transform = `translate(${e.clientX - 4}px, ${e.clientY - 4}px)`;
};
let raf;
const animate = () => {
ring.current.x += (pos.current.x - ring.current.x) * 0.12;
ring.current.y += (pos.current.y - ring.current.y) * 0.12;
ringEl.style.transform = `translate(${ring.current.x - 16}px, ${ring.current.y - 16}px)`;
raf = requestAnimationFrame(animate);
};
animate();
const onEnter = () => { dot.style.width = '32px'; dot.style.height = '32px'; dot.style.marginTop = '-12px'; dot.style.marginLeft = '-12px'; };
const onLeave = () => { dot.style.width = '8px'; dot.style.height = '8px'; dot.style.marginTop = '0'; dot.style.marginLeft = '0'; };
document.addEventListener('mousemove', onMove);
document.querySelectorAll('a, button, [role=button]').forEach(el => {
el.addEventListener('mouseenter', onEnter);
el.addEventListener('mouseleave', onLeave);
});
return () => {
document.removeEventListener('mousemove', onMove);
cancelAnimationFrame(raf);
};
}, []);
return (
<>
>
);
};
// ===== LOADER =====
const Loader = ({ onDone }) => {
const [phase, setPhase] = useState('letters'); // letters | line | tagline | fadeout
const [letters, setLetters] = useState([false,false,false,false,false]);
const [lineWidth, setLineWidth] = useState(0);
const [typed, setTyped] = useState('');
const [opacity, setOpacity] = useState(1);
const reduced = useReducedMotion();
const tagline = 'FIRE WHEN READY.';
useEffect(() => {
if (reduced) { onDone(); return; }
const word = 'SALVO';
// Stagger letters
word.split('').forEach((_, i) => {
setTimeout(() => setLetters(prev => { const n=[...prev]; n[i]=true; return n; }), i * 80 + 100);
});
// Draw line
setTimeout(() => {
setPhase('line');
let w = 0;
const lineAnim = setInterval(() => {
w += 4;
setLineWidth(Math.min(w, 100));
if (w >= 100) clearInterval(lineAnim);
}, 16);
}, 700);
// Typewriter tagline
setTimeout(() => {
setPhase('tagline');
let i = 0;
const typeAnim = setInterval(() => {
i++;
setTyped(tagline.slice(0, i));
if (i >= tagline.length) clearInterval(typeAnim);
}, 30);
}, 1100);
// Fade out
setTimeout(() => {
setPhase('fadeout');
setOpacity(0);
setTimeout(onDone, 500);
}, 1900);
}, []);
return (
{'SALVO'.split('').map((l, i) => (
{l}
))}
{typed}
Click to skip
);
};
// ===== NAV =====
const Nav = ({ onReserve }) => {
const [scrolled, setScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 80);
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
const scrollTo = (id) => {
const el = document.getElementById(id);
if (el) window.scrollTo({ top: el.offsetTop - 80, behavior: 'smooth' });
setMobileOpen(false);
};
const links = [
{ label: 'Science', id: 'science' },
{ label: 'Produit', id: 'product' },
{ label: 'Athlètes', id: 'athletes' },
{ label: 'Pré-commande', id: 'preorder' },
];
return (
<>
window.scrollTo({ top: 0, behavior: 'smooth' })}
>SALVO
{links.map(l => (
scrollTo(l.id)} style={{
fontFamily: "'DM Mono', monospace", fontSize: 10, letterSpacing: '0.15em',
textTransform: 'uppercase', color: 'var(--muted)', background: 'none',
border: 'none', cursor: 'pointer', transition: 'color 0.2s',
padding: 0,
}}
onMouseEnter={e => e.target.style.color = 'var(--white)'}
onMouseLeave={e => e.target.style.color = 'var(--muted)'}
>{l.label}
))}
Réserver
setMobileOpen(!mobileOpen)} style={{
display: 'none', background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--white)', padding: 4,
}}>
{/* Mobile overlay */}
{mobileOpen && (
setMobileOpen(false)} style={{
position: 'absolute', top: 20, right: 24, background: 'none', border: 'none',
color: 'var(--muted)', cursor: 'pointer', fontSize: 24,
}}>✕
{links.map(l => (
scrollTo(l.id)} style={{
fontFamily: "'Syne', sans-serif", fontWeight: 800, fontSize: 44,
color: 'var(--white)', background: 'none', border: 'none', cursor: 'pointer',
letterSpacing: '-0.02em',
}}
onMouseEnter={e => e.target.style.color = 'var(--accent)'}
onMouseLeave={e => e.target.style.color = 'var(--white)'}
>{l.label}
))}
{ onReserve(); setMobileOpen(false); }}>Réserver mon pack
)}
>
);
};
// ===== PARTICLE CANVAS =====
const ParticleCanvas = () => {
const canvasRef = useRef(null);
const reduced = useReducedMotion();
useEffect(() => {
if (reduced) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const resize = () => {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
};
resize();
window.addEventListener('resize', resize);
const N = 90;
const particles = Array.from({ length: N }, () => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
vx: (Math.random() - 0.5) * 0.25,
vy: (Math.random() - 0.5) * 0.25,
r: Math.random() * 1.2 + 0.5,
op: Math.random() * 0.12 + 0.03,
}));
let raf;
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
particles.forEach(p => {
p.x = (p.x + p.vx + canvas.width) % canvas.width;
p.y = (p.y + p.vy + canvas.height) % canvas.height;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255,107,53,${p.op})`;
ctx.fill();
});
raf = requestAnimationFrame(draw);
};
draw();
return () => { cancelAnimationFrame(raf); window.removeEventListener('resize', resize); };
}, [reduced]);
return ;
};
// ===== HERO SECTION =====
const HeroSection = ({ onReserve }) => {
const [visible, setVisible] = useState({ badge: false, letters: [false,false,false,false,false], sub: false, tagline: false, cta: false, stats: false });
const reduced = useReducedMotion();
useEffect(() => {
if (reduced) {
setVisible({ badge: true, letters: [true,true,true,true,true], sub: true, tagline: true, cta: true, stats: true });
return;
}
setTimeout(() => setVisible(v => ({ ...v, badge: true })), 300);
'SALVO'.split('').forEach((_, i) => {
setTimeout(() => setVisible(v => { const l = [...v.letters]; l[i] = true; return { ...v, letters: l }; }), 500 + i * 70);
});
setTimeout(() => setVisible(v => ({ ...v, sub: true })), 900);
setTimeout(() => setVisible(v => ({ ...v, tagline: true })), 1100);
setTimeout(() => setVisible(v => ({ ...v, cta: true })), 1400);
setTimeout(() => setVisible(v => ({ ...v, stats: true })), 1700);
}, []);
const scrollTo = (id) => {
const el = document.getElementById(id);
if (el) window.scrollTo({ top: el.offsetTop - 80, behavior: 'smooth' });
};
const stats = [
{ val: '90s', label: 'Onset time' },
{ val: '900mg', label: 'Sodium' },
{ val: '1/7', label: 'Athlètes ciblés' },
{ val: '30ml', label: 'Format shot' },
];
return (
{/* Radial glow */}
{/* Badge */}
Pré-commande — Q4 2026
{/* Brand hero */}
{'SALVO'.split('').map((l, i) => (
{l}
))}
{/* Subtitle */}
Fire When Ready.
{/* Tagline */}
Le premier shot anti-crampe européen.
90 secondes. Double action. Pour le 1 sur 7.
{/* CTA row */}
Réserver mon pack
scrollTo('science')} style={{ padding: '14px 28px', fontSize: 12 }}>
Comprendre la science →
{/* Stats strip */}
{stats.map((s, i) => (
))}
{/* Scroll indicator */}
);
};
// ===== STATS BAR =====
const StatsBar = () => {
const cards = [
{ value: 15, suffix: '%', label: 'Des athlètes sont des heavy sweaters' },
{ value: 900, suffix: 'mg', label: 'Sodium par dose' },
{ value: 90, suffix: 's', label: "Délai d'action TRP" },
{ value: 0, suffix: '', label: 'Concurrent européen direct', special: 'ZÉRO' },
];
return (
{cards.map((c, i) => {
const [ref, inView] = useInView();
return (
e.currentTarget.style.background = 'var(--accent-glow)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
{c.special ? c.special : (
inView ?
:
0{c.suffix}
)}
{c.label}
);
})}
);
};
// ===== PROBLEM SECTION =====
const ProblemSection = () => {
const [ref, inView] = useInView();
return (
{/* Left: visual placeholder */}
{/* Duotone photo placeholder */}
{/* Diagonal stripes */}
Athlète en effort — photo à venir
Traitement duotone orange / bleu nuit
{/* Orange overlay effect */}
{/* Orange accent line */}
{/* Right: copy */}
Le problème
Vous transpirez plus.
Vous crampez plus.
Vous avez tout essayé : pastilles de sel, boissons iso, magnésium, étirements préventifs. Vous crampez quand même. Au km 35, dans le dernier round, dans la dernière côte.
Vous êtes le 1 sur 7 . Et le marché vous a oublié.
{/* Pull quote */}
"67% des athlètes d'endurance crampent. Pour 1 sur 7, c'est récurrent et invalidant."
— Schwellnus, Br J Sports Med
);
};
// Export all to window
Object.assign(window, {
useInView, useReducedMotion, AnimatedCounter, RevealOnScroll, SalvoButton,
CustomCursor, Loader, Nav, ParticleCanvas, HeroSection, StatsBar, ProblemSection,
});