/* ============================================================
Shared: Router, Nav, Footer, Page Header, Newsletter
============================================================ */
const { useState, useEffect, useRef, createContext, useContext } = React;
/* --- Router ------------------------------------------------- */
const RouteCtx = createContext({ route: "home", go: () => {} });
function useRoute() { return useContext(RouteCtx); }
function RouteProvider({ children }) {
const getRoute = () => (window.location.hash.replace("#/", "") || "home");
const [route, setRoute] = useState(getRoute());
useEffect(() => {
const handler = () => { setRoute(getRoute()); window.scrollTo({ top: 0, behavior: "instant" }); };
window.addEventListener("hashchange", handler);
return () => window.removeEventListener("hashchange", handler);
}, []);
const go = (r) => { window.location.hash = "#/" + r; };
return {children};
}
/* --- Utility Bar with EN/FR switch ------------------------- */
function UtilityBar() {
const { lang, setLang, t } = useT();
return (
·
);
}
function Logo() {
const { t } = useT();
return (
{t.brandShort}
{t.brandLine1}
{t.brandLine2}
);
}
function Nav() {
const { route } = useRoute();
const { t, lang, setLang } = useT();
const [open, setOpen] = useState(false);
// Close drawer on route change
useEffect(() => { setOpen(false); }, [route]);
// Lock body scroll when drawer is open
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = open ? "hidden" : prev;
return () => { document.body.style.overflow = prev; };
}, [open]);
// Close on Escape
useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") setOpen(false); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
return (
<>
{/* Mobile drawer */}
{t.brandShort} · Menu
Lang
·
>
);
}
/* --- Footer -------------------------------------------------- */
function Footer() {
const { t } = useT();
return (
);
}
/* --- Page Header (inner pages) ------------------------------ */
function PageHeader({ eyebrow, title, lede, meta, bgUrl }) {
return (
{eyebrow}
{title}
{lede &&
{lede}
}
{meta && (
{meta.map((m, i) => (
{m.label}
{m.value}
))}
)}
);
}
/* --- Animated number (counts up when visible) --------------- */
function CountUp({ end, duration = 1600, prefix = "", suffix = "" }) {
const { lang } = useT();
const ref = useRef(null);
const [n, setN] = useState(0);
const [started, setStarted] = useState(false);
useEffect(() => {
if (!ref.current) return;
const io = new IntersectionObserver(entries => {
entries.forEach(e => { if (e.isIntersecting && !started) setStarted(true); });
}, { threshold: 0.4 });
io.observe(ref.current);
return () => io.disconnect();
}, [started]);
useEffect(() => {
if (!started) return;
const t0 = performance.now();
let raf;
const tick = (t) => {
const k = Math.min(1, (t - t0) / duration);
const eased = 1 - Math.pow(1 - k, 3);
setN(Math.round(eased * end));
if (k < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [started, end, duration]);
// French locale uses non-breaking space as thousand separator
const locale = lang === "fr" ? "fr-CA" : "en-CA";
return {prefix}{n.toLocaleString(locale)}{suffix && {suffix}};
}
/* --- Newsletter Signup form (with validation) -------------- */
function NewsletterForm() {
const { t } = useT();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [role, setRole] = useState("");
const [errs, setErrs] = useState({});
const [done, setDone] = useState(false);
const validate = () => {
const e = {};
if (!name.trim()) e.name = t.nlRequired;
if (!email.trim()) e.email = t.nlRequired;
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) e.email = t.nlInvalidEmail;
setErrs(e);
return Object.keys(e).length === 0;
};
const submit = (ev) => {
ev.preventDefault();
if (validate()) setDone(true);
};
if (done) {
return (
{t.nlSuccessTag}
{t.nlSuccessLine(name.split(" ")[0])}
{t.nlSuccessNote}
);
}
return (
);
}
function NewsletterBand() {
const { t } = useT();
return (
{t.nlEyebrow}
{t.nlHeadline}
{t.nlLede}
);
}
Object.assign(window, {
RouteProvider, useRoute,
Nav, UtilityBar, Footer, Logo,
PageHeader, CountUp, NewsletterForm, NewsletterBand,
});