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.