/* 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 (
Sécurité
certifiée.
Toulouse.
{["Alarme", "Vidéo", "Contrôle"].map((s) =>
{s}
)}
);
}
Object.assign(window, {
Counter, Reveal, LiveClock, Typewriter, WordReveal, BrowserBar, AlarmMockCard
});