/* global React */ const { useEffect, useRef, useState } = React; /* ============ Animated text counter ============ */ function Counter({ target, suffix = "", duration = 1400, decimals = 0 }) { const [v, setV] = useState(0); const ref = useRef(null); const started = useRef(false); useEffect(() => { const obs = new IntersectionObserver((entries) => { entries.forEach((e) => { if (e.isIntersecting && !started.current) { started.current = true; const t0 = performance.now(); const tick = (now) => { const p = Math.min(1, (now - t0) / duration); const eased = 1 - Math.pow(1 - p, 3); setV(target * eased); if (p < 1) requestAnimationFrame(tick); }; requestAnimationFrame(tick); } }); }, { threshold: 0.4 }); if (ref.current) obs.observe(ref.current); return () => obs.disconnect(); }, [target, duration]); const formatted = decimals > 0 ? v.toFixed(decimals).replace(".", ",") : Math.round(v).toLocaleString("fr-FR"); return {formatted}{suffix}; } /* ============ Reveal on scroll ============ */ function Reveal({ children, delay = 0, as: Tag = "div", className = "", style }) { const ref = useRef(null); const [shown, setShown] = useState(false); useEffect(() => { const obs = new IntersectionObserver((entries) => { entries.forEach((e) => { if (e.isIntersecting) { setTimeout(() => setShown(true), delay); obs.disconnect(); } }); }, { threshold: 0.15 }); if (ref.current) obs.observe(ref.current); return () => obs.disconnect(); }, [delay]); return {children}; } /* ============ Live clock (newsroom style) ============ */ function LiveClock() { const [now, setNow] = useState(new Date()); useEffect(() => { const id = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(id); }, []); const hh = String(now.getHours()).padStart(2, "0"); const mm = String(now.getMinutes()).padStart(2, "0"); const ss = String(now.getSeconds()).padStart(2, "0"); return {hh}:{mm}:{ss} CET; } /* ============ Typewriter (variant B hero) ============ */ function Typewriter({ lines = [], typeSpeed = 36, holdMs = 1600, eraseSpeed = 18, className = "" }) { const [text, setText] = useState(""); const [i, setI] = useState(0); const [phase, setPhase] = useState("type"); // type, hold, erase useEffect(() => { if (lines.length === 0) return; let to; const current = lines[i]; if (phase === "type") { if (text.length < current.length) { to = setTimeout(() => setText(current.slice(0, text.length + 1)), typeSpeed); } else { to = setTimeout(() => setPhase("erase"), holdMs); } } else if (phase === "erase") { if (text.length > 0) { to = setTimeout(() => setText(text.slice(0, -1)), eraseSpeed); } else { setI((i + 1) % lines.length); setPhase("type"); } } return () => clearTimeout(to); }, [text, phase, i, lines, typeSpeed, holdMs, eraseSpeed]); return ( {text} ); } /* ============ Word-by-word reveal ============ */ function WordReveal({ text, delay = 0, stagger = 70, className = "", style }) { const words = text.split(" "); return ( {words.map((w, idx) => ( {w} ))} ); } /* ============ Browser-bar mockup (case studies) ============ */ function BrowserBar({ url }) { return (
{url}
); } /* ============ Generic placeholder card (AlarmTech mock without screenshot) ============ */ function AlarmMockCard() { return (
ALARMTECH 31
NF&A2P
Sécurité
certifiée.
Toulouse.
{["Alarme", "Vidéo", "Contrôle"].map((s) => {s} )}
); } Object.assign(window, { Counter, Reveal, LiveClock, Typewriter, WordReveal, BrowserBar, AlarmMockCard });