// ============================================================
// TERREXO — Shared components (nav, footer, marks, helpers)
// Loaded after React + data/site.js
// ============================================================
const { useState, useEffect, useRef, useMemo } = React;
// --- Responsive breakpoint hook ---
function useMobile(bp = 768) {
const [m, setM] = useState(window.innerWidth < bp);
useEffect(() => {
const h = () => setM(window.innerWidth < bp);
window.addEventListener("resize", h, { passive: true });
return () => window.removeEventListener("resize", h);
}, [bp]);
return m;
}
// --- Logo / wordmark ---
function Wordmark({ size = 22, dark = false, lockup = true }) {
const color = dark ? "#FAFAF7" : "#161A18";
return (
{lockup && (
Terrexo.
)}
);
}
// --- Top nav ---
function Nav({ active = "", dark = false }) {
const [scrolled, setScrolled] = useState(false);
const [progress, setProgress] = useState(0);
const [lang, setLang] = useState("FR");
const [open, setOpen] = useState(false);
const isMobile = useMobile(960);
useEffect(() => {
const onScroll = () => {
setScrolled(window.scrollY > 30);
const h = document.documentElement.scrollHeight - window.innerHeight;
setProgress(h > 0 ? (window.scrollY / h) * 100 : 0);
};
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
if (!isMobile && open) setOpen(false);
}, [isMobile]);
const inkColor = dark ? "#FAFAF7" : "#161A18";
const bg = (scrolled || open)
? (dark ? "rgba(22,26,24,.94)" : "rgba(250,250,247,.96)")
: "transparent";
return (
<>
>
);
}
function ArrowIcon({ size = 14 }) {
return (
);
}
// --- Footer ---
function Footer() {
const T = window.TERREXO;
const isMobile = useMobile(768);
const isTablet = useMobile(1024);
return (
);
}
// --- Section header ---
function SectionHead({ eyebrow, title, lead, action }) {
return (
{lead &&
{lead}
}
{action}
);
}
// --- Stat counter ---
function Stat({ value, label, suffix = "" }) {
const ref = useRef(null);
const [n, setN] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) {
const start = performance.now();
const dur = 1600;
const isFloat = !Number.isInteger(value);
const tick = (t) => {
const p = Math.min((t - start) / dur, 1);
const eased = 1 - Math.pow(1 - p, 3);
setN(isFloat ? +(value * eased).toFixed(1) : Math.round(value * eased));
if (p < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
obs.disconnect();
}
});
}, { threshold: 0.4 });
obs.observe(el);
return () => obs.disconnect();
}, [value]);
return (
);
}
// --- Project card ---
function ProjectCard({ p, large = false }) {
return (
{p.title}
{p.location} · {p.serviceLabel}
{p.year}
);
}
// --- Scroll reveal hook (init for whole page) ---
function useScrollReveal() {
useEffect(() => {
const els = document.querySelectorAll(".sr");
const obs = new IntersectionObserver(entries => {
entries.forEach((e) => {
if (e.isIntersecting) {
const idx = [...e.target.parentElement.children].indexOf(e.target);
e.target.style.transitionDelay = (Math.min(idx, 6) * 0.06) + "s";
e.target.classList.add("in");
obs.unobserve(e.target);
}
});
}, { threshold: 0.12 });
els.forEach(el => obs.observe(el));
return () => obs.disconnect();
}, []);
}
// --- Final CTA (small, used across many pages) ---
function FinalCTASmall() {
return (
Vous savez ce que vous voulez ?
Ou pas du tout ?
Dans les deux cas, on commence par une visite. Gratuite, sans engagement.
);
}
// Expose globally
Object.assign(window, {
Wordmark, Nav, Footer, SectionHead, Stat, ProjectCard, ArrowIcon,
useScrollReveal, FinalCTASmall, useMobile,
});