PageSourceSearch

https://ccci.org/_static/??-eJzTLy/QTc7PK0nNK9EvyClNz8wr1s/KLyhI1E…F+aV1JUmZxYBNJun2traG5pYGRgbmBikgUAx5lFBg==

js ccci.org collected 2026-09-24 18:58:08 UTC 30,122 bytes, 1,028 lines download raw bytes

1/**
2 * Joppa Navigation Utilities
3 * ------------------------------------------------------------
4 * Golden Rule:
5 *   Never redirect with a leading-slash string or hand-built URL.
6 *   Always resolve relative to the current page so base paths
7 *   like /ccci-dev/, /vipdev/, etc. are preserved.
8 *
9 * Exposes:
10 *   window.joppaToUrl(pathOrUrl)
11 *   window.joppaGo(pathOrUrl)
12 */
13(function (window) {
14  "use strict";
15
16  /**
17   * Resolve a path or URL into a base-safe absolute URL.
18   *
19   * @param {string} pathOrUrl
20   * @returns {string}
21   */
22  function joppaToUrl(pathOrUrl) {
23    if (!pathOrUrl || typeof pathOrUrl !== "string") {
24      return window.location.href;
25    }
26
27    const s = pathOrUrl.trim();
28    if (!s) return window.location.href;
29
30    // TEMP GUARDRAIL (remove after rollout confidence)
31    if (s.startsWith("/") && !s.startsWith("//") && window.console?.warn) {
32      console.warn("[Joppa] Avoid leading-slash redirects:", s);
33    }
34
35    // Allow non-http(s) schemes and protocol-relative URLs to pass through unchanged
36    // Examples: mailto:, tel:, sms:, #hash, ?query, //cdn...
37    if (
38      /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(s) || // scheme:...
39      s.startsWith("//") ||                 // protocol-relative
40      s.startsWith("#") ||                  // anchor
41      s.startsWith("?")                     // query only
42    ) {
43      return s.startsWith("//") ? `${window.location.protocol}${s}` : s;
44    }
45
46    try {
47      return new URL(s, window.location.href).toString();
48    } catch (e) {
49      return window.location.href;
50    }
51  }
52
53  /**
54   * Navigate using a base-safe URL.
55   *
56   * @param {string} pathOrUrl
57   */
58  function joppaGo(pathOrUrl) {
59    window.location.assign(joppaToUrl(pathOrUrl));
60  }
61
62  /**
63   * Helper for building URLs with query parameters safely.
64   *
65   * @param {string} pathOrUrl
66   * @param {Object<string,string>} params
67   * @returns {string}
68   */
69  function joppaBuildUrl(pathOrUrl, params) {
70    const url = new URL(joppaToUrl(pathOrUrl), window.location.href);
71    if (params && typeof params === "object") {
72      Object.entries(params).forEach(([key, val]) => {
73        if (val !== undefined && val !== null) {
74          url.searchParams.set(key, String(val));
75        }
76      });
77    }
78    return url.toString();
79  }
80
81  // Expose globally (single source of truth)
82  window.joppaToUrl = joppaToUrl;
83  window.joppaGo = joppaGo;
84  window.joppaBuildUrl = joppaBuildUrl;
85
86  // Optional dev-time signal
87  if (window.console && console.debug) {
88    console.debug("[Joppa] nav utilities loaded");
89  }
90})(window);
91;
92document.addEventListener("DOMContentLoaded", () => {
93  const container = document.querySelector(".locator-map-container");
94  if (!container) return;
95
96  const mapSlot =
97    container.querySelector("#locator-map") ||
98    container.querySelector(".locator-map");
99
100  if (!mapSlot) return;
101
102  /* =========================================================
103     Golden-rule navigation helpers (fallback-safe)
104     ========================================================= */
105  function go(urlOrPath) {
106    const target = String(urlOrPath || "");
107
108    // Prefer global helper from nav.js
109    if (typeof window.joppaGo === "function") {
110      window.joppaGo(target);
111      return;
112    }
113
114    // Fallback (still base-safe)
115    try {
116      const u = /^https?:\/\//i.test(target)
117        ? target
118        : new URL(target, window.location.href).toString();
119      window.location.assign(u);
120    } catch (e) {
121      window.location.assign(window.location.href);
122    }
123  }
124
125  function toAbsUrl(urlOrPath) {
126    const target = String(urlOrPath || "");
127
128    if (typeof window.joppaToUrl === "function") return window.joppaToUrl(target);
129
130    try {
131      return /^https?:\/\//i.test(target)
132        ? target
133        : new URL(target, window.location.href).toString();
134    } catch (e) {
135      return window.location.href;
136    }
137  }
138
139  /* =========================================================
140    Modal (Country Card Popup) – Locator owns open/close
141    ========================================================= */
142  const modal = document.getElementById("joppa-country-modal");
143  const modalBody = document.getElementById("joppa-country-modal-body");
144
145  function getLocatorMapInstance() {
146    return window.__locatorMapInstance || null;
147  }
148
149  function setMapInteractionLocked(locked) {
150    try {
151      const map = getLocatorMapInstance();
152      if (!map || typeof map.setOptions !== "function") return;
153
154      map.setOptions({
155        gestureHandling: locked ? "none" : "auto",
156        draggable: !locked,
157        scrollwheel: !locked,
158        disableDoubleClickZoom: locked,
159        keyboardShortcuts: !locked,
160      });
161    } catch (e) {}
162  }
163
164  function setBodyScrollLocked(locked) {
165    try {
166      document.body.classList.toggle("joppa-modal-open", !!locked);
167      document.body.style.overflow = locked ? "hidden" : "";
168    } catch (e) {}
169  }
170
171  function setModalAnchorToLocatorWrapperTop() {
172    try {
173      const wrapper = document.querySelector(".locator-wrapper");
174      if (!wrapper) return;
175
176      // Prefer the cover inside the wrapper if present (more exact "top of image")
177      const cover = wrapper.querySelector(".wp-block-cover") || wrapper;
178
179      const r = cover.getBoundingClientRect();
180      const top = Math.max(0, r.top + window.scrollY);
181
182      document.documentElement.style.setProperty("--joppa-modal-anchor-top", `${top}px`);
183    } catch (e) {}
184  }
185
186  function openModal() {
187    if (!modal) return;
188    setModalAnchorToLocatorWrapperTop(); // ✅ anchor to top of locator wrapper/cover
189    modal.classList.add("is-open");
190    modal.setAttribute("aria-hidden", "false");
191
192    setBodyScrollLocked(true);
193    setMapInteractionLocked(true);
194  }
195
196  function closeModal() {
197    if (!modal) return;
198    modal.classList.remove("is-open");
199    modal.setAttribute("aria-hidden", "true");
200
201    setBodyScrollLocked(false);
202    setMapInteractionLocked(false);
203  }
204
205  function setModalLoading(code) {
206    if (!modalBody) return;
207    modalBody.innerHTML = `
208      <div style="font:14px/1.4 system-ui;padding:8px 2px;">
209        <strong style="display:block;margin-bottom:6px;">${code}</strong>
210        <div>Loading country card…</div>
211      </div>
212    `;
213  }
214
215  window.addEventListener("resize", () => {
216    if (modal?.classList.contains("is-open")) {
217      setModalAnchorToLocatorWrapperTop();
218    }
219  });
220
221  // Close when clicking close button
222  if (modal) {
223    modal.addEventListener("click", (e) => {
224      const t = e.target;
225      if (t && t.closest && t.closest("[data-modal-close]")) {
226        closeModal();
227      }
228    });
229  }
230
231  // Close on ESC
232  document.addEventListener("keydown", (e) => {
233    if (e.key === "Escape" && modal?.classList.contains("is-open")) {
234      closeModal();
235    }
236  });
237
238  /* =========================================================
239     URL helper (subdirectory-safe)
240     ========================================================= */
241  function toLocatorCountryUrl(code) {
242    const c = String(code || "").toUpperCase().trim();
243    if (!/^[A-Z]{2,3}$/.test(c)) return null;
244
245    // Use raw pieces; avoid new URL() pathname normalization surprises.
246    const origin = window.location.origin;
247    const path = window.location.pathname || "";
248    const search = window.location.search || "";
249    const hash = window.location.hash || "";
250
251    // Replace:
252    //   /locator
253    //   /locator/
254    //   /locator/US
255    //   /locator/US/
256    // with:
257    //   /locator/{CODE}/
258    const re = /\/locator(?:\/[A-Za-z]{2,3})?\/?$/;
259
260    let nextPath;
261    if (re.test(path)) {
262      nextPath = path.replace(re, `/locator/${encodeURIComponent(c)}/`);
263    } else {
264      nextPath = path.replace(/\/+$/, "") + `/locator/${encodeURIComponent(c)}/`;
265    }
266
267    // Keep search/hash
268    return `${origin}${nextPath}${search}${hash}`;
269  }
270
271  /* =========================================================
272     URL country (/locator/XX/)
273     ========================================================= */
274  const path = window.location.pathname || "";
275  const m = path.match(/\/locator\/([A-Za-z]{2,3})\/?$/);
276  const urlCode = m?.[1] ? m[1].toUpperCase() : "";
277
278  const DEFAULT_CODE = (container.dataset.defaultCode || "ML").toUpperCase();
279
280  try {
281    if ("scrollRestoration" in history) history.scrollRestoration = "manual";
282  } catch (e) {}
283
284  (function restoreLocatorScroll() {
285    try {
286      const y = sessionStorage.getItem("locator_scrollY");
287      const ts = parseInt(sessionStorage.getItem("locator_scrollTS") || "0", 10);
288      if (!y) return;
289
290      if (!ts || Date.now() - ts > 5000) {
291        sessionStorage.removeItem("locator_scrollY");
292        sessionStorage.removeItem("locator_scrollTS");
293        return;
294      }
295
296      const yy = parseInt(y, 10);
297      sessionStorage.removeItem("locator_scrollY");
298      sessionStorage.removeItem("locator_scrollTS");
299
300      setTimeout(() =>
300 window.scrollTo({ top: yy, left: 0, behavior: "auto" }), 50);
301    } catch (e) {}
302  })();
303
304  function setSession(key, val) {
305    try {
306      sessionStorage.setItem(key, String(val));
307    } catch (e) {}
308  }
309  function getSession(key) {
310    try {
311      return sessionStorage.getItem(key);
312    } catch (e) {
313      return null;
314    }
315  }
316
317  function parseCenterString(str) {
318    if (!str || typeof str !== "string") return null;
319    const parts = str.split("|").map((p) => p.trim());
320    if (parts.length < 2) return null;
321
322    const lat = parseFloat(parts[0]);
323    const lng = parseFloat(parts[1]);
324    const zoom = parts[2] != null ? parseInt(parts[2], 10) : null;
325
326    if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
327    return { lat, lng, zoom: Number.isFinite(zoom) ? zoom : null };
328  }
329
330  function getDefaultView() {
331    return {
332      lat: parseFloat(container.dataset.centerLat || "20"),
333      lng: parseFloat(container.dataset.centerLng || "0"),
334      zoom: parseInt(container.dataset.zoom || "3", 10),
335    };
336  }
337
338  function getMapId() {
339    return (
340      container.dataset.mapId ||
341      container.dataset.dataMapId ||
342      container.dataset.dataDataMapId ||
343      ""
344    );
345  }
346
347  function looksLikeGoogleMapAlreadyRendered(el) {
348    return !!el.querySelector(".gm-style, canvas, [aria-label*='Map']");
349  }
350
351  function sleep(ms) {
352    return new Promise((r) => setTimeout(r, ms));
353  }
354
355  async function waitForGoogle(what, maxMs = 6000) {
356    const start = Date.now();
357    while (Date.now() - start < maxMs) {
358      if (what()) return true;
359      await sleep(50);
360    }
361    return false;
362  }
363
364  function suppressIdle(ms = 1200) {
365    container.__suppressIdleUntil = Date.now() + ms;
366  }
367  function isIdleSuppressed() {
368    return Date.now() < (container.__suppressIdleUntil || 0);
369  }
370
371  function safeSetView(map, coords, zoomMin = 6) {
372    if (!map || !coords) return;
373    suppressIdle(1200);
374    map.setCenter({ lat: coords.lat, lng: coords.lng });
375    try {
376      if (typeof map.getZoom === "function" && map.getZoom() < zoomMin) {
377        map.setZoom(zoomMin);
378      }
379    } catch (e) {}
380  }
381
382  function setOrMoveUserMarker(map, lat, lng) {
383    if (!map || !Number.isFinite(lat) || !Number.isFinite(lng)) return;
384
385    const pos = { lat, lng };
386
387    if (!window.google?.maps?.Marker) {
388      console.warn("[Locator] google.maps.Marker not available yet");
389      return;
390    }
391
392    if (!container.__userMarker) {
393      container.__userMarker = new google.maps.Marker({
394        map,
395        position: pos,
396        title: "You are here",
397        zIndex: 999999,
398        optimized: false,
399      });
400    } else {
401      container.__userMarker.setPosition(pos);
402      container.__userMarker.setMap(map);
403    }
404  }
405
406  function saveUserCoords(lat, lng) {
407    try {
408      sessionStorage.setItem("locator_user_lat", String(lat));
409      sessionStorage.setItem("locator_user_lng", String(lng));
410    } catch (e) {}
411  }
412
413  function loadUserCoords() {
414    try {
415      const lat = parseFloat(sessionStorage.getItem("locator_user_lat"));
416      const lng = parseFloat(sessionStorage.getItem("locator_user_lng"));
417      if (Number.isFinite(lat) && Number.isFinite(lng)) return { lat, lng };
418    } catch (e) {}
419    return null;
420  }
421
422  function getRestRootAbs() {
423    const restRootRaw =
424      (window.JOPPA_LOCATOR && window.JOPPA_LOCATOR.restRoot) ||
425      container.dataset.restRoot ||
426      "wp-json/joppa/v1/"; // ✅ no leading slash
427
428    const restRoot = String(restRootRaw || "").trim();
429    const withSlash = restRoot.endsWith("/") ? restRoot : `${restRoot}/`;
430    return toAbsUrl(withSlash);
431  }
432
433  async function fetchCenter(code) {
434    const restRootAbs = getRestRootAbs();
435    const url = `${restRootAbs}country/center/${encodeURIComponent(code)}`;
436
437    const res = await fetch(url, { credentials: "same-origin" });
438
439    const ct = res.headers.get("content-type") || "";
440    if (!ct.includes("application/json")) {
441      const text = await res.text();
442      console.warn("[Locator] center response not JSON", {
443        url,
444        status: res.status,
445        contentType: ct,
446        preview: text.slice(0, 200),
447      });
448      return { view: null, centerStr: null, json: null };
449    }
450
451    const json = await res.json();
452
453    const centerStr = json?.data?.center ?? json?.center ?? null;
454    const view = parseCenterString(centerStr);
455
456    return { view, centerStr, json };
457  }
458
459  /* =========================================================
460     Redirect Lock Overlay (blocks interaction during auto-geo)
461     ========================================================= */
462  function ensureRedirectOverlay() {
463    if (container.__redirectOverlayEl) return container.__redirectOverlayEl;
464
465    const overlay = document.createElement("div");
466    overlay.className = "locator-redirect-overlay";
467    overlay.innerHTML = `
468      <div class="locator-redirect-overlay__inner" role="status" aria-live="polite">
469        <div class="locator-spinner" aria-hidden="true"></div>
470        <div class="locator-redirect-overlay__text">Finding your location…</div>
471      </div>
472    `;
473
474    container.appendChild(overlay);
475    container.__redirectOverlayEl = overlay;
476    return overlay;
477  }
478
479  function showRedirectOverlay() {
480    const el = ensureRedirectOverlay();
481    el.classList.add("is-active");
482    container.classList.add("locator-is-redirecting");
483  }
484
485  function hideRedirectOverlay() {
486    const el = container.__redirectOverlayEl;
487    if (el) el.classList.remove("is-active");
488    container.classList.remove("locator-is-redirecting");
489  }
490
491  function applyView(map, view) {
492    if (!map || !view) return;
493    suppressIdle(1200);
494    map.setCenter({ lat: view.lat, lng: view.lng });
495    if (view.zoom != null) map.setZoom(view.zoom);
496  }
497
498  function getExistingMapIfAny() {
499    if (container.__locatorMap) return container.__locatorMap;
500    if (window.__locatorMapInstance) return window.__locatorMapInstance;
501    return null;
502  }
503
504  function ensureMapReady() {
505    return new Promise((resolve) => {
506      const existing = getExistingMapIfAny();
507      if (existing) return resolve(existing);
508
509      initMapIfNeeded((map) => resolve(map || null));
510    });
511  }
512
513  function initMapIfNeeded(callback) {
514    const existing = getExistingMapIfAny();
515    if (existing) {
516      callback(existing);
517      return;
518    }
519
520    if (looksLikeGoogleMapAlreadyRendered(mapSlot)) {
521      console.warn("[Locator] map rendered in DOM but no instance ref; not re-init");
522      callback(null);
523      return;
524    }
525
526    const base = getDefaultView();
527    const mapId = getMapId();
528
529    const tryInit = () => {
530      if (!window.google?.maps?.Map) return false;
531
532      const opts = {
533        center: { lat: base.lat, lng: base.lng },
534        zoom: base.zoom,
535        mapTypeControl: false,
536        streetViewControl: false,
537        fullscreenControl: true,
538        gestureHandling: "greedy",
539      };
540      if (mapId) opts.mapId = mapId;
541
542      const map = new google.maps.Map(mapSlot, opts);
543      container.__locatorMap = map;
544      window.__locatorMapInstance = map;
545
546      suppressIdle(1200);
547
548      callback(map);
549      return true;
550    };
551
552    if (tryInit()) return;
553
554    let tries = 0;
555    const t = setInterval(() => {
556      tries++;
557      if (tryInit() || tries > 120) {
558        clearInterval(t);
559        callback(null);
560      }
561    }, 50);
562  }
563
564  async function getUserCoordsOnce() {
565    if (!navigator.geolocation) return null;
566
567    const pos = await new Promise((resolve, reject) => {
568      navigator.geolocation.getCurrentPosition(resolve, reject, {
569        enableHighAccuracy: false,
570        timeout: 6000,
571        maximumAge: 5 * 60 * 1000,
572      });
573    }).catch((err) => {
574      console.error("[Locator] geolocation denied/unavailable:", err?.message || err);
575      return null;
576    });
577
578    if (!pos) return null;
579    return { lat: pos.coords.latitude, lng: pos.coords.longitude };
580  }
581
582  async function geolocateAndRedirectIfBlank(map) {
583    if (urlCode) return;
584
585    if (getSession("locator_geo_attempted") === "1") {
586      console.warn("[Locator] geo redirect skipped (already attempted)");
587      return;
588    }
589    setSession("locator_geo_attempted", "1");
590
591    // 🔒 Immediately block interaction in the locator container
592    showRedirectOverlay();
593
594    const coords = await getUserCoordsOnce();
595    if (!coords) {
596      hideRedirectOverlay();
597      return;
598    }
599
600    saveUserCoords(coords.lat, coords.lng);
601    safeSetView(map, coords, 6);
602
603    const markerReady = await waitForGoogle(() => !!window.google?.maps?.Marker, 4000);
604    if (markerReady) setOrMoveUserMarker(map, coords.lat, coords.lng);
605
606    const geocoderReady = await waitForGoogle(() => !!window.google?.maps?.Geocoder, 6000);
607    if (!geocoderReady) {
608      hideRedirectOverlay();
609      return;
610    }
611
612    const geocoder = new google.maps.Geocoder();
613    geocoder.geocode({ location: coords }, (results, status) => {
614      if (status !== "OK" || !results?.length) {
615        hideRedirectOverlay();
616        return;
617      }
618
619      const comp = results
620        .flatMap((r) => r.address_components || [])
621        .find((c) => c.types?.includes("country"));
622
623      const code = comp?.short_name ? comp.short_name.toUpperCase() : "";
624      if (!code) {
625        hideRedirectOverlay();
626        return;
627      }
628
629      try {
630        sessionStorage.setItem("locator_scrollY", String(window.scrollY || 0));
631        sessionStorage.setItem("locator_scrollTS", String(Date.now()));
632      } catch (e) {}
633
634      const next = toLocatorCountryUrl(code);
635      if (next) {
636        // Keep overlay up while navigating away
637        go(next);
638        return;
639      }
640
641      hideRedirectOverlay();
642    });
643  }
644
645  async function geolocateAndPinOnCountryPage(map) {
646    if (!urlCode || !map) return;
647
648    const saved = loadUserCoords();
649    if (saved) {
650      const ok = await waitForGoogle(() => !!window.google?.maps?.Marker, 4000);
651      if (ok) setOrMoveUserMarker(map, saved.lat, saved.lng);
652      return;
653    }
654
655    const coords = await getUserCoordsOnce();
656    if (!coords) return;
657
658    saveUserCoords(coords.lat, coords.lng);
659
660    const ok = await waitForGoogle(() => !!window.google?.maps?.Marker, 4000);
661    if (ok) setOrMoveUserMarker(map, coords.lat, coords.lng);
662  }
663
664  /* =========================================================
665     Pan-to-search controller (toggleable)
666     - When enabled and map is idle, resolves center->country and navigates
667     - Must support "immediate" evaluation when toggled ON
668     ========================================================= */
669  function createPanZoomCountrySwitchController(map, currentCode) {
670    if (!map) {
671      return { enable() {}, disable() {}, isEnabled() { return false; }, evaluateNow() {} };
672    }
673
674    let lastCode = (currentCode || "").toUpperCase();
675    let debounceTimer = null;
676    let inFlight = false;
677    let enabled = false;
678
679    const DEBOUNCE_MS = 700;
680    const MIN_ZOOM = 3;
681    const PRECISION = 2;
682
683    // IMPORTANT: this is what your old code used to require.
684    // We keep it, but allow the toggle to "force" interaction.
685    let userInteracted = false;
686
687    // Listener handles so we can remove them later
688    let dragStartListener = null;
689    let zoomChangedListener = null;
690    let idleListener = null;
691
692    function clearDebounce() {
693      if (debounceTimer) clearTimeout(debounceTimer);
694      debounceTimer = null;
695    }
696
697    function getCenterLatLngKey() {
698      const center = map.getCenter?.();
699      if (!center) return null;
700
701      const lat = center.lat();
702      const lng = center.lng();
703      return {
704        lat,
705        lng,
706        key: `${lat.toFixed(PRECISION)}|${lng.toFixed(PRECISION)}`,
707      };
708    }
709
710    function evaluateNow({ force = false } = {}) {
711      if (!enabled) return;
712      if (!force && !userInteracted) return;
713      if (isIdleSuppressed()) return;
714      if (inFlight) return;
715
716      try {
717        if (typeof map.getZoom === "function" && map.getZoom() < MIN_ZOOM) return;
718      } catch (e) {}
719
720      const pack = getCenterLatLngKey();
721      if (!pack) return;
722
723      if (container.__lastIdleKey === pack.key) return;
724      container.__lastIdleKey = pack.key;
725
726      if (!window.google?.maps?.Geocoder) return;
727
728      inFlight = true;
729
730      const geocoder = new google.maps.Geocoder();
731      geocoder.geocode({ location: { lat: pack.lat, lng: pack.lng } }, (results, status) => {
732        inFlight = false;
733        if (!enabled) return;
734        if (status !== "OK" || !results?.length) return;
735
736        let code = "";
737        for (const r of results) {
738          const comps = r.address_components || [];
739          const c = comps.find((x) => x.types?.includes("country"));
740          if (c?.short_name) {
741            code = c.short_name.toUpperCase();
742            break;
743          }
744        }
745
746        if (!code) return;
747        if (code === lastCode) return;
748
749        lastCode = code;
750        const next = toLocatorCountryUrl(code);
751        if (next) go(next);
752      });
753    }
754
755    function enable({ immediate = false } = {}) {
756      if (enabled) return;
757      enabled = true;
758
759      // Reset interaction state each time we enable
760      userInteracted = false;
761
762      dragStartListener = map.addListener("dragstart", () => (userInteracted = true));
763      zoomChangedListener = map.addListener("zoom_changed", () => (userInteracted = true));
764
765      idleListener = map.addListener("idle", () => {
766        if (!enabled) return;
767        if (!userInteracted) return;
768        if (isIdleSuppressed()) return;
769
770        clearDebounce();
771        debounceTimer = setTimeout(() => evaluateNow({ force: false }), DEBOUNCE_MS);
772      });
773
774      // If toggled ON, you want it to run immediately based on current map center.
775      // We "force" it by marking userInteracted true and evaluating now.
776      if (immediate) {
777        userInteracted = true;
778        // no debounce for the initial evaluation
779        evaluateNow({ force: true });
780      }
781    }
782
783    function disable() {
784      if (!enabled) return;
785      enabled = false;
786
787      clearDebounce();
788      inFlight = false;
789
790      try { dragStartListener?.remove(); } catch (e) {}
791      try { zoomChangedListener?.remove(); } catch (e) {}
792      try { idleListener?.remove(); } catch (e) {}
793
794      dragStartListener = null;
795      zoomChangedListener = null;
796      idleListener = null;
797    }
798
799    function isEnabled() {
800      return enabled;
801    }
802
803    return { enable, disable, isEnabled, evaluateNow };
804  }
805
806  let activeCode = (urlCode || "").toUpperCase().trim();
807
808async function goToCountry(code, { open = true } = {}) {
809  const c = String(code || "").toUpperCase().trim();
810  if (!/^[A-Z]{2,3}$/.test(c)) return;
811
812  // If same country, just open modal (optional behavior)
813  if (c === activeCode) {
814    if (open) openModal();
815    return;
816  }
817
818  activeCode = c;
819
820  if (open) {
821    setModalLoading(c);
822    openModal();
823  }
824
825  // ✅ Ensure we have a map instance even if selection happens early
826  const map = await ensureMapReady();
827  if (!map) return;
828
829  try {
830    const { view, centerStr } = await fetchCenter(c);
831    if (view) applyView(map, view);
832    else console.warn("[Locator] could not parse center string", { centerStr });
833  } catch (err) {
834    console.error("[Locator] failed to recenter for", c, err);
835  }
836}
837
838
839  /* =========================================================
840     Locator owns popup opening; CountryData will render into it
841     ========================================================= */
842  window.addEventListener("countryChange", (e) => {
843    const code = String(e?.detail?.code || "").toUpperCase().trim();
844    goToCountry(code, { open: true });
845  });
846
847  function getCodeFromPathname(pathname) {
848    const p = String(pathname || "");
849    const m = p.match(/\/locator\/([A-Za-z]{2,3})\/?$/);
850    return m?.[1] ? m[1].toUpperCase() : "";
851  }
852
853  window.addEventListener("popstate", () => {
854    const code = getCodeFromPathname(window.location.pathname);
855    if (code) goToCountry(code, { open: true });
856  });
857
858  window.addEventListener("countryCardRendered", (e) => {
859    const code = String(e?.detail?.code || "").toUpperCase().trim();
860  });
861
862  initMapIfNeeded(async (map) => {
863    if (!map) return;
864
865    geolocateAndRedirectIfBlank(map);
866
867    if (urlCode) {
868      try {
869        const { view, centerStr } = await fetchCenter(urlCode);
870        if (view) applyView(map, view);
871        else console.warn("[Locator] could not parse center string", { centerStr });
872      } catch (e) {
873        console.error("[Locator] center fetch failed", e);
874      }
875
876      geolocateAndPinOnCountryPage(map);
877
878      goToCountry(urlCode, { open: true });
879    }
880
881    /* =========================================================
882       Search-by-moving-map (checkbox in render.php)
883       - OFF by default
884       - When toggled ON: immediately evaluates current center and navigates
885       - No mapId/map-style changes yet
886       ========================================================= */
887    const panToSearch = createPanZoomCountrySwitchController(map, urlCode);
888    container.__panToSearchController = panToSearch;
889    window.__joppaPanToSearchController = panToSearch;
890
891    // Default OFF
892    panToSearch.disable();
893
894    const panToggle = container.querySelector("[data-pan-to-search-toggle]");
895    if (panToggle) {
896      panToggle.checked = false;
897
898      panToggle.addEventListener("change", async () => {
899        const on = !!panToggle.checked;
900
901        if (on) {
902          // Ensure geocoder is available before immediate evaluation
903          const geocoderReady = await waitForGoogle(() => !!window.google?.maps?.Geocoder, 6000);
904          if (!geocoderReady) {
905            console.warn("[Locator] Geocoder not ready; cannot enable pan-to-search yet");
906            panToggle.checked = false;
907            return;
908          }
909
910          // Enable and immediately evaluate current center
911          panToSearch.enable({ immediate: true });
912        } else {
913          panToSearch.disable();
914        }
915
916        // FUTURE: toggle labels/borders here via styles or mapId swap.
917      });
918    }
919  });
920});
921;
922document.addEventListener("DOMContentLoaded", () => {
923  if (window.__joppaLocatorCountryCardLoaded) return;
924  window.__joppaLocatorCountryCardLoaded = true;
925
926  const modalBody = document.getElementById("joppa-country-modal-body");
927  if (!modalBody) {
928    console.warn("[CountryCard] modal body not found; skipping");
929    return;
930  }
931
932  // Base-safe URL resolver (prefer nav.js)
933  function toAbsUrl(pathOrUrl) {
934    const target = String(pathOrUrl || "").trim();
935    if (!target) return window.location.href;
936
937    if (typeof window.joppaToUrl === "function") {
938      return window.joppaToUrl(target);
939    }
940
941    try {
942      // No leading slash fallback required here; resolve relative to current page
943      return new URL(target, window.location.href).toString();
944    } catch (e) {
945      return window.location.href;
946    }
947  }
948
949  // ✅ restRoot must be base-safe (no leading slash fallback)
950  const restRootRaw =
951    (window.JOPPA_LOCATOR && window.JOPPA_LOCATOR.restRoot) ||
952    "wp-json/joppa/v1/";
953
954  const restRootRel = String(restRootRaw || "").trim();
955  const restRootWithSlash = restRootRel.endsWith("/") ? restRootRel : `${restRootRel}/`;
956  const restRootAbs = toAbsUrl(restRootWithSlash);
957
958  let lastCode = "";
959
960  function setLoading(code) {
961    modalBody.innerHTML = `
962      <div style="font:14px/1.4 system-ui;padding:8px 2px;">
963        <strong style="display:block;margin-bottom:6px;">${code}</strong>
964        <div>Loading country card…</div>
965      </div>
966    `;
967  }
968
969  function setError(msg) {
970    modalBody.innerHTML = `
971      <div style="font:14px/1.4 system-ui;padding:8px 2px;">
972        <strong style="display:block;margin-bottom:6px;">Country Data</strong>
973        <div style="opacity:.85;">${msg}</div>
974      </div>
975    `;
976  }
977
978  async function fetchAndRender(code) {
979    const normalized = String(code || "").toUpperCase().trim();
980    if (!/^[A-Z]{2,3}$/.test(normalized)) return;
981
982    if (normalized === lastCode) return;
983    lastCode = normalized;
984
985    setLoading(normalized);
986
987    const endpoint = `${restRootAbs}country/content/${encodeURIComponent(normalized)}`;
988
989    try {
990      const res = await fetch(endpoint, { credentials: "same-origin" });
991
992      if (!res.ok) {
993        setError(`Lookup failed (${res.status}).`);
994        return;
995      }
996
997      const json = await res.json();
998
999      if (json?.success && json?.data?.html) {
1000        modalBody.innerHTML = json.data.html;
1001
1002        window.dispatchEvent(
1003          new CustomEvent("countryCardRendered", { detail: { code: normalized } })
1004        );
1005      } else {
1006        setError(`No data available for ${normalized}.`);
1007      }
1008    } catch (e) {
1009      console.error("[CountryCard] fetch error", e);
1010      setError("Lookup exception (see console).");
1011    }
1012  }
1013
1014  // Listen for selections
1015  window.addEventListener("countryChange", (e) => {
1016    const code = e?.detail?.code ? String(e.detail.code).toUpperCase().trim() : "";
1017    if (!code) return;
1018    fetchAndRender(code);
1019  });
1020
1021  // On load: if URL is already /locator/BR/, load it immediately
1022  const path = window.location.pathname || "";
1023  const m = path.match(/\/locator\/([A-Za-z]{2,3})\/?$/);
1024  if (m?.[1]) {
1025    fetchAndRender(m[1]);
1026  }
1027});
1028;

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.