/* ============================================================
Manigod Kids Club — site core
Icons, Photo, Reveal, Navbar, Footer, Modal, Shell, hooks.
Exports to window (Babel scopes per file).
============================================================ */
const { useState, useEffect, useRef } = React;
const PAGES = [
{ label: "Accueil", href: "/" },
{ label: "Saisons", href: "/saisons/" },
{ label: "Tarifs", href: "/tarifs/" },
{ label: "Galerie", href: "/galerie/" },
{ label: "Contact", href: "/contact/" },
];
/* page label → en helper for aria/labels */
const _T = (s) => (window.t ? window.t(s) : s);
/* ---------- BOOKING (YoPlanning) ----------
Deux liens définitifs : un par saison. La page neutre /reservation/
laisse le parent choisir sa saison avant de partir vers YoPlanning. */
const BOOKING = {
ete: "https://booking.yoplanning.pro/e168364c-330a-4e0a-9456-e3a39f96c63c/",
hiver: "https://booking.yoplanning.pro/63b7a662-a876-4288-86ee-f543dca95cb1/",
page: "/reservation/",
};
/* season connu → onglet YoPlanning ; sinon → page de choix de saison */
function goBook(season) {
const s = season === "ete" || season === "hiver" ? season : null;
if (s && BOOKING[s]) {
window.open(BOOKING[s], "_blank", "noopener,noreferrer");
} else {
window.location.href = BOOKING.page;
}
}
/* ---------- ICONS (line, brand convention: 24/24, ~2.1 stroke) ---------- */
const I = {
arrow: ,
arrowDown: ,
plus: ,
chevron: ,
prev: ,
next: ,
sun: ,
snow: ,
marker: ,
phone: ,
mail: ,
star: ,
menu: ,
close: ,
check: ,
clock: ,
mountain: ,
bike: ,
tent: ,
compass: ,
target: ,
users: ,
leaf: ,
globe: ,
ski: ,
sled: ,
igloo: ,
image: ,
calendar: ,
euro: ,
utensils: ,
sparkles: ,
binocular: ,
paw: ,
route: ,
shield: ,
heart: ,
moon: ,
cup: ,
};
function Icon({ name, size = 22, fill = false, style }) {
return (
);
}
/* ---------- PHOTO (real src or branded placeholder) ---------- */
function Photo({ src, label, alt = "", radius, className = "", style, eager = false }) {
const base = { position: "absolute", inset: 0, width: "100%", height: "100%" };
if (src) {
return ;
}
return (
{label && {label}}
);
}
/* ---------- Reveal-on-scroll — IntersectionObserver (no per-frame layout
reads, so scrolling stays smooth). Elements below the fold stay hidden
until you actually scroll them into view, so the animation always plays
*during* the scroll, never pre-emptively. ---------- */
let _revealIO = null;
function _getRevealIO() {
if (_revealIO || typeof IntersectionObserver === "undefined") return _revealIO;
_revealIO = new IntersectionObserver((entries, obs) => {
for (const e of entries) {
if (e.isIntersecting) { e.target.classList.add("is-in"); obs.unobserve(e.target); }
}
}, { rootMargin: "0px 0px -12% 0px", threshold: 0 });
return _revealIO;
}
function Reveal({ children, as = "div", delay = 0, className = "", style, ...rest }) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current; if (!el) return;
const io = _getRevealIO();
if (!io) { el.classList.add("is-in"); return; }
io.observe(el);
// Failsafe — only reveal on a timer if the element is ALREADY on screen
// (e.g. above-the-fold content, or if the observer somehow never fires).
// Off-screen elements are left to the observer so they animate on scroll.
const t = setTimeout(() => {
const r = el.getBoundingClientRect();
if (r.top < (window.innerHeight || 0) && r.bottom > 0) {
el.classList.add("is-in"); io.unobserve(el);
}
}, 1400);
return () => { io.unobserve(el); clearTimeout(t); };
}, []);
const Tag = as;
return (
{children}
);
}
/* ---------- SCROLL THREAD ("lacet") — a soft wavy line that runs the full
height of the page and is "drawn" downward as you scroll, weaving between
the sections. Palette-only (brand green), no gradient, pointer-events none;
it naturally fades into the green sections. Static under reduced-motion. ---------- */
function _buildWave(w, h) {
const cx = w * 0.5;
// sweep wide so the line hugs the page edges / margins and only crosses the
// central text column briefly on the diagonals (reads as "going around" copy)
const ampFrac = w < 760 ? 0.30 : 0.42;
const amp = Math.min(w * ampFrac, 560);
const segs = Math.max(4, Math.round(h / 760));
const step = h / segs;
let d = `M ${cx.toFixed(1)} 0`;
let dir = 1;
for (let i = 0; i < segs; i++) {
const y0 = i * step, y1 = (i + 1) * step;
d += ` C ${(cx + dir * amp).toFixed(1)} ${(y0 + step * 0.30).toFixed(1)}, ${(cx + dir * amp).toFixed(1)} ${(y1 - step * 0.30).toFixed(1)}, ${cx.toFixed(1)} ${y1.toFixed(1)}`;
dir *= -1;
}
return d;
}
/* Collect the page-coordinate vertical ranges that have a GREEN background,
so the cream copy of the thread can be revealed only over those bands.
Green = any .mkc-sec--ink / .mkc-pagehero--ink block, plus the whole page
when the season switch has set data-saison="hiver" on .mkc-app. */
function _greenBands(startY, h) {
const app = document.querySelector(".mkc-app");
const top = startY, bot = startY + h;
if (app && app.getAttribute("data-saison") === "hiver") {
// The whole page is green EXCEPT sections explicitly flipped to cream
// (the Avis block + the final-CTA section, marked data-thread-cream).
// Subtract those bands so the GREEN base thread shows through the cream,
// and the line keeps reading correctly all the way down to the footer.
const cream = [];
document.querySelectorAll("[data-thread-cream]").forEach((el) => {
const r = el.getBoundingClientRect();
const t = Math.round(r.top + window.scrollY), b = Math.round(r.bottom + window.scrollY);
if (b > t) cream.push({ t, b });
});
cream.sort((a, b) => a.t - b.t);
const bands = [];
let cur = top;
for (const c of cream) {
const ct = Math.max(top, c.t), cb = Math.min(bot, c.b);
if (cb <= cur) continue;
if (ct > cur) bands.push({ t: cur, b: ct });
cur = cb;
}
if (cur < bot) bands.push({ t: cur, b: bot });
return bands;
}
const bands = [];
document.querySelectorAll(".mkc-sec--ink, .mkc-pagehero--ink").forEach((el) => {
const r = el.getBoundingClientRect();
bands.push({ t: Math.round(r.top + window.scrollY), b: Math.round(r.bottom + window.scrollY) });
});
return bands;
}
function ScrollThread() {
const baseRef = useRef(null);
const overRef = useRef(null);
const cid = useRef("mkcband" + Math.random().toString(36).slice(2, 8));
const [g, setG] = useState({ w: 1200, h: 3000, startY: 0, d: "", bands: [] });
useEffect(() => {
const build = () => {
const w = document.documentElement.clientWidth;
const sh = document.documentElement.scrollHeight;
// begin the thread just below the hero on the home page (under the season
// cards); on every inner page it starts right under the floating navbar so
// it reads as one continuous line from the top, never cut by a section.
const fan = document.querySelector(".mkc-fan");
const nav = document.querySelector(".mkc-nav");
const ph = document.querySelector(".mkc-pagehero");
let startY = Math.round(window.innerHeight * 0.6);
if (fan) startY = Math.round(fan.getBoundingClientRect().bottom + window.scrollY);
else if (nav) startY = Math.round(nav.getBoundingClientRect().bottom + 10); // fixed nav → page-Y under it
else if (ph) startY = Math.round(ph.getBoundingClientRect().bottom + window.scrollY);
const h = Math.max(240, sh - startY);
setG({ w, h, startY, d: _buildWave(w, h), bands: _greenBands(startY, h) });
};
build();
const t1 = setTimeout(build, 700);
const t2 = setTimeout(build, 1800);
window.addEventListener("resize", build);
// recompute the green bands when the season switch flips data-saison
const app = document.querySelector(".mkc-app");
let mo = null;
if (app && typeof MutationObserver !== "undefined") {
mo = new MutationObserver(build);
mo.observe(app, { attributes: true, attributeFilter: ["data-saison"] });
}
return () => {
window.removeEventListener("resize", build);
clearTimeout(t1); clearTimeout(t2); if (mo) mo.disconnect();
};
}, []);
useEffect(() => {
const paths = [baseRef.current, overRef.current].filter(Boolean);
if (!paths.length || !g.d) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const lens = paths.map((p) => { const l = p.getTotalLength(); p.style.strokeDasharray = l; return l; });
if (reduce) { paths.forEach((p) => { p.style.strokeDashoffset = 0; }); return; }
let raf = 0;
const onScroll = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
const sh = document.documentElement.scrollHeight;
const head = window.scrollY + window.innerHeight * 1.15;
const span = Math.max(1, sh - g.startY);
const prog = Math.min(1, Math.max(0.04, (head - g.startY) / span));
paths.forEach((p, i) => { p.style.strokeDashoffset = lens[i] * (1 - prog); });
});
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => { window.removeEventListener("scroll", onScroll); cancelAnimationFrame(raf); };
}, [g.d, g.startY, g.bands]);
// green bands → clip rects in the thread's local (viewBox) coordinate space
const rects = (g.bands || []).map((bnd, i) => {
const y = Math.max(0, bnd.t - g.startY);
const y2 = Math.min(g.h, bnd.b - g.startY);
if (y2 <= y) return null;
return ;
}).filter(Boolean);
const stroke = { fill: "none", vectorEffect: "non-scaling-stroke", strokeWidth: "2.4",
strokeLinecap: "round", strokeLinejoin: "round" };
return (
<>
{/* green thread — sits on the cream paper */}
{/* cream thread — identical geometry, revealed only over the green bands */}
>
);
}
/* ---------- NAVBAR ---------- */
function Navbar({ active = "Accueil", glass = false, onBook }) {
const [open, setOpen] = useState(false);
const logo = glass ? "logo-typo-cream.svg" : "logo-typo-green.svg";
// lock body scroll while the mobile menu is open + close on Escape; both
// cleaned up so nothing stays blocked after the menu closes/unmounts
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e) => { if (e.key === "Escape") setOpen(false); };
window.addEventListener("keydown", onKey);
return () => { document.body.style.overflow = prev; window.removeEventListener("keydown", onKey); };
}, [open]);
return (