// 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 (
);
}
// 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,
});