/* global React */ /* ─────────────────────────────────────────────────────────────── Abba Automotive — i18n engine Eigen, verzorgde vertalingen. Nederlands is de brontaal; de woordenboeken (dict-*.jsx) mappen elke NL-bronstring naar de doeltalen. De engine vervangt tekst direct in de DOM zodat de React-componenten zelf onaangeroerd blijven, en hervertaalt bij elke re-render via een MutationObserver. ─────────────────────────────────────────────────────────────── */ const LANGS = [ { code: "nl", label: "Nederlands", short: "NL", dir: "ltr" }, { code: "en", label: "English", short: "EN", dir: "ltr" }, { code: "de", label: "Deutsch", short: "DE", dir: "ltr" }, { code: "fr", label: "Français", short: "FR", dir: "ltr" }, { code: "ar", label: "العربية", short: "AR", dir: "rtl" }, { code: "pl", label: "Polski", short: "PL", dir: "ltr" }, ]; const LANG_BY_CODE = Object.fromEntries(LANGS.map((l) => [l.code, l])); const I18N = { observer: null, current: "nl", reverse: null, // any-translation-string -> NL source string }; const SKIP_TAGS = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEXTAREA"]); const TRANSLATABLE_ATTRS = ["placeholder", "aria-label", "title", "alt"]; function getLang() { try { return localStorage.getItem("abba-lang") || "nl"; } catch (e) { return "nl"; } } /* Build a reverse index so we can recover the NL source from any already-shown translation — this lets us switch directly between two non-NL languages and back to NL without caching per-node state (which would freeze React-driven dynamic text). */ function buildReverse() { const rev = {}; const D = window.I18N_DICT || {}; for (const nl in D) { const entry = D[nl]; for (const code in entry) { const v = entry[code]; if (typeof v === "string" && v.length) { const k = v.replace(/\s+/g, " ").trim(); if (k && !(k in rev)) rev[k] = nl; } } } I18N.reverse = rev; } /* Given the current trimmed text, find its NL source string (or null if this is not translatable content — e.g. numbers, names, React-controlled values). */ function resolveSource(cur) { const D = window.I18N_DICT || {}; if (cur in D) return cur; // already NL source if (!I18N.reverse) buildReverse(); return I18N.reverse[cur] || null; // some language's translation } function renderFor(src, lang) { if (lang === "nl") return src; const e = (window.I18N_DICT || {})[src]; return (e && typeof e[lang] === "string" && e[lang].length) ? e[lang] : src; } function translateNodeText(node, lang) { const raw = node.nodeValue; if (!raw) return; const cur = raw.replace(/\s+/g, " ").trim(); if (!cur) return; const src = resolveSource(cur); if (!src) return; // leave dynamic / unknown text untouched const lead = raw.match(/^\s*/)[0]; const tail = raw.match(/\s*$/)[0]; const target = lead + renderFor(src, lang) + tail; if (node.nodeValue !== target) node.nodeValue = target; } function translateAttr(el, attr, lang) { if (!el.hasAttribute(attr)) return; const raw = el.getAttribute(attr); const cur = (raw || "").replace(/\s+/g, " ").trim(); if (!cur) return; const src = resolveSource(cur); if (!src) return; const target = renderFor(src, lang); if (el.getAttribute(attr) !== target) el.setAttribute(attr, target); } function translateDocument(lang) { const root = document.body; if (!root) return; const ob = I18N.observer; if (ob) ob.disconnect(); const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(n) { if (!n.parentNode || SKIP_TAGS.has(n.parentNode.nodeName)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; }, }); let n; while ((n = walker.nextNode())) translateNodeText(n, lang); const sel = TRANSLATABLE_ATTRS.map((a) => "[" + a + "]").join(","); root.querySelectorAll(sel).forEach((el) => { if (SKIP_TAGS.has(el.nodeName)) return; TRANSLATABLE_ATTRS.forEach((a) => { if (el.hasAttribute(a)) translateAttr(el, a, lang); }); }); if (ob) ob.observe(root, { childList: true, subtree: true, characterData: true }); } let _pending = null; function scheduleRetranslate() { if (_pending) return; _pending = requestAnimationFrame(() => { _pending = null; translateDocument(I18N.current); }); } function applyLangChrome(lang) { const meta = LANG_BY_CODE[lang] || LANG_BY_CODE.nl; document.documentElement.setAttribute("lang", lang); document.documentElement.setAttribute("dir", meta.dir); } function setLang(lang) { if (!LANG_BY_CODE[lang]) lang = "nl"; I18N.current = lang; try { localStorage.setItem("abba-lang", lang); } catch (e) {} applyLangChrome(lang); translateDocument(lang); window.dispatchEvent(new CustomEvent("abba-langchange", { detail: { lang } })); } function initI18n() { const lang = getLang(); I18N.current = lang; applyLangChrome(lang); // Observe future React renders and re-translate the new Dutch nodes. I18N.observer = new MutationObserver(scheduleRetranslate); if (document.body) { I18N.observer.observe(document.body, { childList: true, subtree: true, characterData: true }); } // Initial pass (also restores NL cleanly if lang === 'nl'). translateDocument(lang); } /* ─── VLAGGEN (inline SVG, cross-platform — geen emoji) ───────── */ function Flag({ code }) { const flags = { nl: ( ), en: ( ), de: ( ), fr: ( ), ar: ( ), pl: ( ), }; return {flags[code] || flags.nl}; } /* ─── LANGUAGE SWITCHER (React, used inside Nav) ─────────────── */ function LangSwitcher() { const { useState, useEffect, useRef } = React; const [lang, setLangState] = useState(getLang()); const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", onDoc); const onChange = () => setLangState(getLang()); window.addEventListener("abba-langchange", onChange); return () => { document.removeEventListener("mousedown", onDoc); window.removeEventListener("abba-langchange", onChange); }; }, []); const meta = LANG_BY_CODE[lang] || LANG_BY_CODE.nl; const choose = (code) => { setOpen(false); setLangState(code); setLang(code); }; return (