/* ============================================================ 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 {alt}; } 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 (
{open && (
setOpen(false)}>
e.stopPropagation()}>
{PAGES.map((p) => ( {_T(p.label)} ))}
{_T("Langue")}
)}
); } /* ---------- FOOTER ---------- */ function Footer() { return ( ); } /* ---------- MODAL ---------- */ function Modal({ kind, onClose }) { const copy = kind === "book" ? { t: _T("La réservation en ligne arrive très bientôt"), b: _T("Le module de réservation est en cours de préparation et sera disponible très prochainement. En attendant, contactez-nous directement — nous serons ravis de réserver la semaine de vos enfants."), cta: _T("Fermer"), ic: "clock" } : { t: _T("Message envoyé"), b: _T("Maquette — votre message serait transmis à l'équipe du Manigod Kids Club (contact@manigodkidsclub.com), qui vous recontacterait rapidement."), cta: _T("Parfait"), ic: "check" }; useEffect(() => { const k = (e) => e.key === "Escape" && onClose(); window.addEventListener("keydown", k); return () => window.removeEventListener("keydown", k); }, []); return (
e.stopPropagation()}>

{copy.t}

{copy.b}

{kind === "book" && }
); } /* ---------- SHELL — wraps a page: nav + chrome + footer + modals + tweaks ---------- Usage: {({ book }) => <>…sections…} */ function Shell({ active, heroGlass = false, children }) { const [modal, setModal] = useState(null); const [scrolled, setScrolled] = useState(false); const [t, setTweak] = useTweaks(window.TWEAK_DEFAULTS || {}); useEffect(() => { if (!heroGlass) return; const onScroll = () => setScrolled(window.scrollY > window.innerHeight * 0.6); window.addEventListener("scroll", onScroll, { passive: true }); onScroll(); return () => window.removeEventListener("scroll", onScroll); }, [heroGlass]); const book = (season) => goBook(season); const sent = () => setModal("sent"); const glass = heroGlass && !scrolled; const appAttrs = { "data-herocards": t.heroCards || "fan", "data-round": t.round || "standard", }; const appStyle = { "--fan-rot": (t.fanRot != null ? t.fanRot : 6) + "deg", "--veil-mul": t.veil != null ? t.veil : 1, "--mkc-r-lg": RND[t.round || "standard"].lg, "--mkc-r-xl": RND[t.round || "standard"].xl, "--mkc-r-2xl": RND[t.round || "standard"].xxl, }; const displayWeight = t.displayWeight || 600; return (
{typeof children === "function" ? children({ book, sent }) : children}
{modal && setModal(null)} />}
); } const RND = { soft: { lg: "20px", xl: "26px", xxl: "34px" }, standard: { lg: "32px", xl: "40px", xxl: "56px" }, extra: { lg: "40px", xl: "52px", xxl: "72px" }, }; /* ---------- CALL BUTTON ---------- Touch devices place the call (tel:). On desktop, where a raw tel: link triggers an unstyled OS/browser "call" vignette, we instead copy the number and show a clean brand toast — no parasitic popup, same visual button. */ function CallButton({ className = "", label, tel = "+33613944509", display = "+33 6 13 94 45 09" }) { const [toast, setToast] = useState(false); const timer = useRef(0); const lbl = label || _T("Nous appeler"); const onClick = () => { const touch = window.matchMedia("(hover: none) and (pointer: coarse)").matches; if (touch) { window.location.href = "tel:" + tel; return; } try { navigator.clipboard && navigator.clipboard.writeText(display); } catch (e) {} setToast(true); window.clearTimeout(timer.current); timer.current = window.setTimeout(() => setToast(false), 2600); }; return ( {_T("Numéro copié")} : {display} ); } Object.assign(window, { Icon, Photo, Reveal, ScrollThread, Navbar, Footer, Modal, Shell, PAGES, RND, CallButton, BOOKING, goBook });