// shared.jsx — helpers, hooks, common components const { useState, useEffect, useRef, useMemo, useCallback } = React; // ---------- Hooks ---------- function useInView(threshold = 0.2) { const ref = useRef(null); const [inView, setInView] = useState(false); useEffect(() => { if (!ref.current) return; // Immediate check: if already in viewport at mount, reveal now const rect = ref.current.getBoundingClientRect(); if (rect.top < window.innerHeight && rect.bottom > 0) { setInView(true); return; } const obs = new IntersectionObserver( ([e]) => { if (e.isIntersecting) setInView(true); }, { threshold } ); obs.observe(ref.current); // Safety fallback in case observer never fires const tid = setTimeout(() => setInView(true), 1200); return () => { obs.disconnect(); clearTimeout(tid); }; }, [threshold]); return [ref, inView]; } function useScrollProgress() { const [p, setP] = useState(0); useEffect(() => { const onScroll = () => { const h = document.documentElement.scrollHeight - window.innerHeight; setP(h > 0 ? (window.scrollY / h) * 100 : 0); }; onScroll(); window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); }, []); return p; } function useScrollY() { const [y, setY] = useState(0); useEffect(() => { const onScroll = () => setY(window.scrollY); onScroll(); window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); }, []); return y; } // Active section for nav function useActiveSection(ids) { const [active, setActive] = useState(ids[0]); useEffect(() => { const onScroll = () => { const y = window.scrollY + window.innerHeight * 0.35; let cur = ids[0]; for (const id of ids) { const el = document.getElementById(id); if (el && el.offsetTop <= y) cur = id; } setActive(cur); }; onScroll(); window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); }, [ids.join(",")]); return active; } // Counter — anime de 0 vers `to`, déclenche un glow burst en fin d'animation function Counter({ to, duration = 1600, suffix = "" }) { const [ref, inView] = useInView(0.4); const [n, setN] = useState(0); useEffect(() => { if (!inView) return; const start = performance.now(); let raf; const tick = (t) => { const p = Math.min(1, (t - start) / duration); const eased = 1 - Math.pow(1 - p, 3); setN(Math.round(to * eased)); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [inView, to, duration]); return {n}{suffix}; } // Reveal wrapper — variantes : "up" (defaut), "blur", "scale", "left", "right", "mask" function Reveal({ children, delay = 0, variant = "up", as: Tag = "div", className = "", style, ...rest }) { const [ref, inView] = useInView(0.15); return ( {children} ); } // RevealStagger — applique `.reveal` directement aux enfants (cloneElement) // pour rester compatible avec n'importe quel parent (ul, grid, etc.) sans // introduire de div intermédiaire qui casserait le HTML sémantique. function RevealStagger({ children, stagger = 120, baseDelay = 0, variant = "up", className = "", as: Tag = "div", ...rest }) { const [ref, inView] = useInView(0.15); const arr = React.Children.toArray(children); return ( {arr.map((child, i) => (
{child}
))}
); } // Magnetic — le wrapper "tire" vers le curseur dans un rayon donné. // Utilisé pour CTAs primaires. Ne se déclenche pas sur pointer:coarse (mobile). function useMagnetic({ strength = 0.35, radius = 100 } = {}) { const ref = useRef(null); useEffect(() => { const el = ref.current; if (!el) return; if (window.matchMedia && window.matchMedia("(pointer: coarse)").matches) return; let raf; let tx = 0, ty = 0, cx = 0, cy = 0; const onMove = (e) => { const r = el.getBoundingClientRect(); const ecx = r.left + r.width / 2; const ecy = r.top + r.height / 2; const dx = e.clientX - ecx; const dy = e.clientY - ecy; const dist = Math.hypot(dx, dy); if (dist < radius) { tx = dx * strength; ty = dy * strength; } else { tx = 0; ty = 0; } }; const tick = () => { cx += (tx - cx) * 0.18; cy += (ty - cy) * 0.18; el.style.transform = `translate(${cx.toFixed(2)}px, ${cy.toFixed(2)}px)`; raf = requestAnimationFrame(tick); }; tick(); window.addEventListener("mousemove", onMove); return () => { cancelAnimationFrame(raf); window.removeEventListener("mousemove", onMove); el.style.transform = ""; }; }, [strength, radius]); return ref; } // MagneticButton — wrap n'importe quel bouton/lien pour effet aimanté. function MagneticButton({ children, strength = 0.32, radius = 90, className = "", ...rest }) { const ref = useMagnetic({ strength, radius }); return ( {children} ); } // Tilt 3D — perspective + rotateX/Y vers le curseur quand il survole. function useTilt({ max = 8, scale = 1.02 } = {}) { const ref = useRef(null); useEffect(() => { const el = ref.current; if (!el) return; if (window.matchMedia && window.matchMedia("(pointer: coarse)").matches) return; let raf; let rx = 0, ry = 0, cx = 0, cy = 0, active = false, scaleTarget = 1, scaleCur = 1; const onMove = (e) => { const r = el.getBoundingClientRect(); const px = (e.clientX - r.left) / r.width; const py = (e.clientY - r.top) / r.height; rx = (py - 0.5) * -2 * max; ry = (px - 0.5) * 2 * max; }; const onEnter = () => { active = true; scaleTarget = scale; }; const onLeave = () => { active = false; rx = 0; ry = 0; scaleTarget = 1; }; const tick = () => { cx += (rx - cx) * 0.15; cy += (ry - cy) * 0.15; scaleCur += (scaleTarget - scaleCur) * 0.15; el.style.transform = `perspective(900px) rotateX(${cx.toFixed(2)}deg) rotateY(${cy.toFixed(2)}deg) scale(${scaleCur.toFixed(3)})`; raf = requestAnimationFrame(tick); }; tick(); el.addEventListener("mousemove", onMove); el.addEventListener("mouseenter", onEnter); el.addEventListener("mouseleave", onLeave); return () => { cancelAnimationFrame(raf); el.removeEventListener("mousemove", onMove); el.removeEventListener("mouseenter", onEnter); el.removeEventListener("mouseleave", onLeave); el.style.transform = ""; }; }, [max, scale]); return ref; } // TiltCard — wrap qui applique le tilt 3D et préserve les transforms internes. function TiltCard({ children, max = 7, scale = 1.025, className = "", ...rest }) { const ref = useTilt({ max, scale }); return (
{children}
); } // Parallax — translate Y inversement proportionnel au scroll, basé sur position // de l'élément à l'écran. `speed` positif = élément remonte plus lentement. function useParallax(speed = 0.25) { const ref = useRef(null); useEffect(() => { const el = ref.current; if (!el) return; let raf; const tick = () => { const r = el.getBoundingClientRect(); const winH = window.innerHeight; const center = r.top + r.height / 2; const offset = (center - winH / 2) * speed; el.style.transform = `translate3d(0, ${(-offset).toFixed(2)}px, 0)`; raf = requestAnimationFrame(tick); }; tick(); return () => cancelAnimationFrame(raf); }, [speed]); return ref; } // Scroll-driven : retourne une progression 0→1 selon que l'élément est // loin/proche du centre du viewport. Utilisé pour interpoler des effets // continus (scale, blur) pendant que l'élément entre à l'écran. function useScrollProgressInView({ start = 0.95, end = 0.45 } = {}) { const ref = useRef(null); const [p, setP] = useState(0); useEffect(() => { const el = ref.current; if (!el) return; let raf; const tick = () => { const r = el.getBoundingClientRect(); const winH = window.innerHeight; const top = r.top / winH; let prog = (start - top) / (start - end); if (prog < 0) prog = 0; if (prog > 1) prog = 1; setP(prog); raf = requestAnimationFrame(tick); }; tick(); return () => cancelAnimationFrame(raf); }, [start, end]); return [ref, p]; } // ScrollTitle — titre qui émerge dramatiquement : scale 0.65 → 1, translateY 80→0, // blur 16px → 0, opacity 0 → 1 pendant qu'il entre dans le viewport. function ScrollTitle({ children, as: Tag = "h2", className = "", style, ...rest }) { const [ref, p] = useScrollProgressInView({ start: 0.95, end: 0.5 }); const scale = 0.94 + 0.06 * p; const blur = (1 - p) * 6; const opacity = 0.4 + 0.6 * p; return ( {children} ); } // Nav scrolled — true dès que window.scrollY > seuil. function useScrolled(threshold = 80) { const [scrolled, setScrolled] = useState(false); useEffect(() => { const onScroll = () => setScrolled(window.scrollY > threshold); onScroll(); window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); }, [threshold]); return scrolled; } // Cursor custom function CustomCursor() { const dot = useRef(null); const ring = useRef(null); useEffect(() => { let rx = 0, ry = 0, dx = 0, dy = 0; const onMove = (e) => { dx = e.clientX; dy = e.clientY; if (dot.current) { dot.current.style.transform = `translate(${dx}px, ${dy}px) translate(-50%, -50%)`; } }; let raf; const animate = () => { rx += (dx - rx) * 0.18; ry += (dy - ry) * 0.18; if (ring.current) { ring.current.style.transform = `translate(${rx}px, ${ry}px) translate(-50%, -50%)`; } raf = requestAnimationFrame(animate); }; animate(); const hoverables = "a, button, .hoverable, [data-hover]"; const magnetics = ".magnetic, [data-magnetic]"; const onOver = (e) => { if (!e.target.closest) return; if (e.target.closest(magnetics)) { ring.current && ring.current.classList.add("magnetic"); } else if (e.target.closest(hoverables)) { ring.current && ring.current.classList.add("hovering"); } }; const onOut = (e) => { if (!e.target.closest) return; if (e.target.closest(magnetics)) { ring.current && ring.current.classList.remove("magnetic"); } else if (e.target.closest(hoverables)) { ring.current && ring.current.classList.remove("hovering"); } }; window.addEventListener("mousemove", onMove); document.addEventListener("mouseover", onOver); document.addEventListener("mouseout", onOut); return () => { cancelAnimationFrame(raf); window.removeEventListener("mousemove", onMove); document.removeEventListener("mouseover", onOver); document.removeEventListener("mouseout", onOut); }; }, []); return ( <>
); } // Dust particles function DustParticles() { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); let w = canvas.width = window.innerWidth; let h = canvas.height = window.innerHeight; const parts = Array.from({ length: 60 }, () => ({ x: Math.random() * w, y: Math.random() * h, r: Math.random() * 1.8 + 0.3, vx: (Math.random() - 0.5) * 0.15, vy: (Math.random() - 0.5) * 0.1 - 0.05, a: Math.random() * 0.6 + 0.2, hue: Math.random() > 0.7 ? 40 : (Math.random() > 0.5 ? 25 : 0), })); let raf; const tick = () => { ctx.clearRect(0, 0, w, h); for (const p of parts) { p.x += p.vx; p.y += p.vy; if (p.x < 0) p.x = w; if (p.x > w) p.x = 0; if (p.y < 0) p.y = h; if (p.y > h) p.y = 0; ctx.beginPath(); ctx.fillStyle = `hsla(${p.hue + 30}, 70%, 75%, ${p.a})`; ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill(); } raf = requestAnimationFrame(tick); }; tick(); const onResize = () => { w = canvas.width = window.innerWidth; h = canvas.height = window.innerHeight; }; window.addEventListener("resize", onResize); return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", onResize); }; }, []); return ; } // Drapeau officiel Burkina Faso — rouge (haut), vert (bas), étoile jaune 5 branches au centre function FlagDot({ size = 10 }) { const star = "M0,-1 L0.294,-0.309 L0.951,-0.309 L0.397,0.118 L0.588,0.809 L0,0.382 L-0.588,0.809 L-0.397,0.118 L-0.951,-0.309 L-0.294,-0.309 Z"; return ( ); } // Logo 3D — pièce qui tourne continuellement (gauche→droite), recto = verso function LogoCoin({ size = 220 }) { // Version simple : juste le logo qui tourne lentement sur lui-même. // Plus d'effet "verre volumétrique" — l'utilisateur préfère un rendu net, // que le logo lui-même soit la vedette sans matière autour. return (
FasoTravel
); } // Store button function StoreButton({ store, label, sub, onNotify }) { return ( ); } Object.assign(window, { useInView, useScrollProgress, useScrollY, useActiveSection, useScrolled, useMagnetic, useTilt, useParallax, useScrollProgressInView, Counter, Reveal, RevealStagger, MagneticButton, TiltCard, ScrollTitle, CustomCursor, DustParticles, FlagDot, LogoCoin, StoreButton, });