1/*! 2 * static-lightbox-fix.js 3 * Self-contained gallery lightbox for the static (NitroPack) clone. 4 * 5 * Why: Elementor lazy-loads its lightbox via a webpack dynamic import 6 * (lightbox.<hash>.bundle.min.js). That chunk was never harvested into the 7 * static mirror, so clicking a gallery thumbnail 404s the chunk and nothing 8 * opens. This script provides a dependency-free lightbox that reuses the 9 * existing gallery anchors and cycles through ALL images (prev/next/wrap). 10 * 11 * It does NOT modify, remove, or re-crop any images. It only reads the 12 * <a data-elementor-open-lightbox> anchors already on the page. 13 */ 14(function () { 15 'use strict'; 16 17 function init() { 18 var anchors = Array.prototype.slice.call( 19 document.querySelectorAll('a[data-elementor-open-lightbox="yes"]') 20 ); 21 if (!anchors.length) return; 22 23 // Group anchors by slideshow id (fallback: single global group). 24 var groups = {}; 25 anchors.forEach(function (a) { 26 var id = a.getAttribute('data-elementor-lightbox-slideshow') || '__default__'; 27 (groups[id] = groups[id] || []).push(a); 28 }); 29 30 function slidesFor(id) { 31 return groups[id].map(function (a) { 32 return { 33 src: a.getAttribute('href'), 34 title: a.getAttribute('data-elementor-lightbox-title') || '' 35 }; 36 }); 37 } 38 39 // ---- Build overlay (once) ---- 40 var overlay = document.createElement('div'); 41 overlay.className = 'slbx-overlay'; 42 overlay.setAttribute('role', 'dialog'); 43 overlay.setAttribute('aria-modal', 'true'); 44 overlay.innerHTML = 45 '<button class="slbx-btn slbx-close" aria-label="Close">Ã</button>' + 46 '<button class="slbx-btn slbx-prev" aria-label="Previous image">â¹</button>' + 47 '<figure class="slbx-figure">' + 48 '<img class="slbx-img" alt="" />' + 49 '<figcaption class="slbx-caption"></figcaption>' + 50 '</figure>' + 51 '<button class="slbx-btn slbx-next" aria-label="Next image">âº</button>' + 52 '<div class="slbx-counter"></div>'; 53 54 var css = document.createElement('style'); 55 css.textContent = 56 '.slbx-overlay{position:fixed;inset:0;z-index:999999;display:none;align-items:center;' + 57 'justify-content:center;background:rgba(0,0,0,.92);}' + 58 '.slbx-overlay.slbx-open{display:flex;}' + 59 '.slbx-figure{margin:0;max-width:92vw;max-height:88vh;display:flex;flex-direction:column;' + 60 'align-items:center;justify-content:center;}' + 61 '.slbx-img{max-width:92vw;max-height:80vh;object-fit:contain;box-shadow:0 4px 30px rgba(0,0,0,.5);' + 62 'border-radius:4px;}' + 63 '.slbx-caption{color:#eaeaea;font-size:14px;margin-top:12px;text-align:center;max-width:80vw;' + 64 'font-family:inherit;}' + 65 '.slbx-btn{position:absolute;background:rgba(0,0,0,.35);color:#fff;border:none;cursor:pointer;' + 66 'line-height:1;border-radius:50%;transition:background .2s;-webkit-tap-highlight-color:transparent;}' + 67 '.slbx-btn:hover{background:rgba(255,255,255,.2);}' + 68 '.slbx-close{top:16px;right:16px;width:44px;height:44px;font-size:30px;}' + 69 '.slbx-prev,.slbx-next{top:50%;transform:translateY(-50%);width:56px;height:56px;font-size:40px;}' + 70 '.slbx-prev{left:16px;}.slbx-next{right:16px;}' + 71 '.slbx-counter{position:absolute;top:20px;left:50%;transform:translateX(-50%);color:#ddd;' + 72 'font-size:14px;font-family:inherit;}' + 73 '@media (max-width:600px){.slbx-prev,.slbx-next{width:46px;height:46px;font-size:32px;}' + 74 '.slbx-close{width:40px;height:40px;font-size:26px;top:10px;right:10px;}' + 75 '.slbx-img{max-height:74vh;}}'; 76 77 document.head.appendChild(css); 78 document.body.appendChild(overlay); 79 80 var imgEl = overlay.querySelector('.slbx-img'); 81 var capEl = overlay.querySelector('.slbx-caption'); 82 var cntEl = overlay.querySelector('.slbx-counter'); 83 84 var current = { slides: [], index: 0 }; 85 86 function render() { 87 var s = current.slides[current.index]; 88 if (!s) return; 89 imgEl.src = s.src; 90 imgEl.alt = s.title || ''; 91 capEl.textContent = (s.title || '').replace(/-/g, ' '); 92 cntEl.textContent = (current.index + 1) + ' / ' + current.slides.length; 93 } 94 function open(slides, index) { 95 current.slides = slides; 96 current.index = index; 97 render(); 98 overlay.classList.add('slbx-open'); 99 document.documentElement.style.overflow = 'hidden'; 100 } 101 function close() { 102 overlay.classList.remove('slbx-open'); 103 document.documentElement.style.overflow = ''; 104 imgEl.src = ''; 105 } 106 function step(d) { 107 var n = current.slides.length; 108 current.index = (current.index + d + n) % n; // wrap-around 109 render(); 110 } 111 112 overlay.querySelector('.slbx-close').addEventListener('click', close); 113 overlay.querySelector('.slbx-prev').addEventListener('click', function (e) { e.stopPropagation(); step(-1); }); 114 overlay.querySelector('.slbx-next').addEventListener('click', function (e) { e.stopPropagation(); step(1); }); 115 overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); }); 116 document.addEventListener('keydown', function (e) { 117 if (!overlay.classList.contains('slbx-open')) return; 118 if (e.key === 'Escape') close(); 119 else if (e.key === 'ArrowLeft') step(-1); 120 else if (e.key === 'ArrowRight') step(1); 121 }); 122 123 // ---- Delegated capture-phase handler ---- 124 // Bound on document (capture) so it also catches Swiper-cloned loop slides 125 // and fires before Swiper/Elementor's own click handling. 126 document.addEventListener('click', function (e) { 127 var a = e.target && e.target.closest 128 ? e.target.closest('a[data-elementor-open-lightbox="yes"]') 129 : null; 130 if (!a) return; 131 e.preventDefault(); 132 e.stopImmediatePropagation(); 133 var id = a.getAttribute('data-elementor-lightbox-slideshow') || '__default__'; 134 // Use original (non-clone) anchors for the slide set; the group still maps 135 // by slideshow id, so cloned slides resolve to the same ordered set. 136 var slides = slidesFor(id) || slidesFor('__default__'); 137 var href = a.getAttribute('href'); 138 var idx = slides.findIndex(function (s) { return s.src === href; }); 139 open(slides, idx < 0 ? 0 : idx); 140 }, true); 141 } 142 143 /* 144 * Elementor image-carousel widgets are Swiper-driven, but the static mirror 145 * never runs Elementor's lazy carousel-init handler (bundled in the missing 146 * webpack chunk), so the arrows are dead. Swiper v8 is already loaded on the 147 * page; we instantiate it directly per carousel using each widget's own
148 * data-settings. Idempotent: skips any carousel already initialized. 149 */ 150 function initOneCarousel(w) { 151 if (w.getAttribute('data-slbx-carousel') === 'done') return true; 152 var container = w.querySelector('.swiper, .swiper-container'); 153 if (!container) return false; 154 if (container.swiper) { // a Swiper already lives here 155 bindArrowFallback(w, container.swiper); 156 w.setAttribute('data-slbx-carousel', 'done'); 157 return true; 158 } 159 160 // The static mirror ships with stale Swiper state baked into the markup: 161 // `swiper-initialized` classes and leftover `swiper-slide-duplicate` clones 162 // from the original server render. Swiper's loop logic then clones the 163 // clones, producing index drift that strands navigation after one step. 164 // Strip that stale state so Swiper starts from a clean, real slide set. 165 var wrapper = container.querySelector('.swiper-wrapper'); 166 if (wrapper) { 167 Array.prototype.slice.call(wrapper.querySelectorAll('.swiper-slide-duplicate')) 168 .forEach(function (d) { d.parentNode.removeChild(d); }); 169 } 170 container.classList.remove('swiper-initialized', 'swiper-horizontal', 171 'swiper-pointer-events', 'swiper-backface-hidden'); 172 173 var cfg = {}; 174 try { cfg = JSON.parse(w.getAttribute('data-settings') || '{}'); } catch (e) { cfg = {}; } 175 176 var toShow = parseInt(cfg.slides_to_show, 10) || 3; 177 var toScroll = parseInt(cfg.slides_to_scroll, 10) || 1; 178 var spacing = (cfg.image_spacing_custom && cfg.image_spacing_custom.size) || 20; 179 var realSlides = wrapper ? wrapper.querySelectorAll('.swiper-slide').length : 0; 180 var loop = cfg.infinite !== 'no' && realSlides > toShow; 181 182 var opts = { 183 initialSlide: 0, 184 slidesPerView: toShow, 185 slidesPerGroup: toScroll, 186 spaceBetween: spacing, 187 loop: loop, 188 loopAdditionalSlides: toShow + 1, 189 speed: parseInt(cfg.speed, 10) || 500, 190 watchOverflow: true, 191 navigation: { 192 nextEl: w.querySelector('.elementor-swiper-button-next, .swiper-button-next'), 193 prevEl: w.querySelector('.elementor-swiper-button-prev, .swiper-button-prev') 194 }, 195 breakpoints: { 196 0: { slidesPerView: 1, slidesPerGroup: 1 }, 197 768: { slidesPerView: Math.min(2, toShow), slidesPerGroup: 1 }, 198 1025: { slidesPerView: toShow, slidesPerGroup: toScroll } 199 } 200 }; 201 if (cfg.autoplay === 'yes') { 202 opts.autoplay = { 203 delay: parseInt(cfg.autoplay_speed, 10) || 5000, 204 // Never let a user click permanently kill the autoplay/nav state. 205 disableOnInteraction: false, 206 pauseOnMouseEnter: cfg.pause_on_hover === 'yes' 207 }; 208 } 209 210 try { 211 var sw = new window.Swiper(container, opts); 212 bindArrowFallback(w, sw); 213 w.setAttribute('data-slbx-carousel', 'done'); 214 return true; 215 } catch (e) { return false; } 216 } 217 218 // Capture-phase arrow handler that drives the instance directly. Guarantees 219 // repeated next/prev advance even if Swiper's own nav binding is ever lost, 220 // and prevents any stale Elementor handler from interfering. 221 function bindArrowFallback(w, sw) { 222 if (w.getAttribute('data-slbx-arrows') === 'done') return; 223 var next = w.querySelector('.elementor-swiper-button-next, .swiper-button-next'); 224 var prev = w.querySelector('.elementor-swiper-button-prev, .swiper-button-prev'); 225 function go(dir) { 226 return function (e) { 227 e.preventDefault(); 228 e.stopImmediatePropagation(); 229 if (sw.destroyed) return; 230 if (dir > 0) sw.slideNext(); else sw.slidePrev(); 231 }; 232 } 233 if (next) next.addEventListener('click', go(1), true); 234 if (prev) prev.addEventListener('click', go(-1), true); 235 w.setAttribute('data-slbx-arrows', 'done'); 236 } 237 238 function initCarousels() { 239 if (typeof window.Swiper === 'undefined') return false; 240 var widgets = document.querySelectorAll('.elementor-widget-image-carousel'); 241 var allDone = true; 242 Array.prototype.forEach.call(widgets, function (w) { 243 if (!initOneCarousel(w)) allDone = false; 244 }); 245 return widgets.length === 0 ? true : allDone; 246 } 247 248 /* 249 * Elementor popup-trigger buttons (href="#elementor-action...popup:open...") 250 * never open on the static mirror because Elementor's popup module is in the 251 * lazy webpack chunk that 404s. The popup's real content (a booking-widget 252 * iframe) is already baked into the page inside an .elementor-popup-modal. 253 * We auto-detect that iframe's src and open it in a simple, generic modal. 254 * Portable across the fleet: nothing site-specific is hard-coded. 255 */ 256 var BOOKING_RE = /https?:\/\/[^"'\s]*(?:patientcopilot\.ai|\/widget\/bookings?\/)[^"'\s]*/i; 257 258 // Synchronously find a booking iframe src already in this page's DOM. 259 function findBookingIframeSrc() { 260 var modal = document.querySelector('.elementor-popup-modal'); 261 if (modal) { 262 var fi = modal.querySelector('iframe[src]'); 263 if (fi && fi.getAttribute('src')) return fi.getAttribute('src'); 264 } 265 var any = document.querySelector( 266 'iframe[src*="patientcopilot.ai"], iframe[src*="/widget/booking"], iframe[src*="/widget/bookings"]' 267 ); 268 if (any && any.getAttribute('src')) return any.getAttribute('src');
269 // Any booking URL anywhere in the document markup (data-attrs, scripts). 270 var html = document.documentElement.innerHTML; 271 var m = html.match(BOOKING_RE); 272 return m ? m[0] : null; 273 } 274 275 // Portable cross-page fallback: many interior pages on the mirror have the 276 // popup trigger but the popup body (booking iframe) was never harvested onto 277 // them â only the homepage carries it. Fetch the homepage once, extract the 278 // booking URL, and cache it. No site-specific URL is hard-coded. 279 var _bookingFetch = null; 280 function fetchBookingSrc() { 281 if (_bookingFetch) return _bookingFetch; 282 _bookingFetch = fetch('/', { credentials: 'same-origin' }) 283 .then(function (r) { return r.text(); }) 284 .then(function (t) { var m = t.match(BOOKING_RE); return m ? m[0] : null; }) 285 .catch(function () { return null; }); 286 return _bookingFetch; 287 } 288 289 function buildPopupModal() { 290 var ov = document.createElement('div'); 291 ov.className = 'slbx-pop-overlay'; 292 ov.setAttribute('role', 'dialog'); 293 ov.setAttribute('aria-modal', 'true'); 294 ov.innerHTML = 295 '<div class="slbx-pop-box">' + 296 '<button class="slbx-pop-close" aria-label="Close">Ã</button>' + 297 '<div class="slbx-pop-body"></div>' + 298 '</div>'; 299 var css = document.createElement('style'); 300 css.textContent = 301 '.slbx-pop-overlay{position:fixed;inset:0;z-index:1000000;display:none;align-items:center;' + 302 'justify-content:center;background:rgba(0,0,0,.6);padding:20px;}' + 303 '.slbx-pop-overlay.slbx-open{display:flex;}' + 304 '.slbx-pop-box{position:relative;background:#fff;border-radius:10px;width:100%;max-width:480px;' + 305 'height:min(86vh,640px);box-shadow:0 10px 50px rgba(0,0,0,.4);overflow:hidden;}' + 306 '.slbx-pop-body,.slbx-pop-body iframe{width:100%;height:100%;border:0;display:block;}' + 307 '.slbx-pop-close{position:absolute;top:8px;right:10px;z-index:2;width:34px;height:34px;border:none;' + 308 'border-radius:50%;background:rgba(0,0,0,.45);color:#fff;font-size:24px;line-height:1;cursor:pointer;}' + 309 '.slbx-pop-close:hover{background:rgba(0,0,0,.7);}'; 310 document.head.appendChild(css); 311 document.body.appendChild(ov); 312 return ov; 313 } 314 315 function initBookingPopups() { 316 var hasTriggers = document.querySelector('a[href*="elementor-action"]'); 317 if (!hasTriggers) return; 318 319 var overlay = null, body = null, loaded = false; 320 function ensure() { 321 if (overlay) return; 322 overlay = buildPopupModal(); 323 body = overlay.querySelector('.slbx-pop-body'); 324 overlay.querySelector('.slbx-pop-close').addEventListener('click', closePop); 325 overlay.addEventListener('click', function (e) { if (e.target === overlay) closePop(); }); 326 document.addEventListener('keydown', function (e) { 327 if (e.key === 'Escape' && overlay.classList.contains('slbx-open')) closePop(); 328 }); 329 } 330 function openPop(src) { 331 ensure(); 332 if (!loaded) { 333 var ifr = document.createElement('iframe'); 334 ifr.setAttribute('src', src); 335 ifr.setAttribute('title', 'Booking'); 336 ifr.setAttribute('scrolling', 'yes'); 337 body.appendChild(ifr); 338 loaded = true; 339 } 340 overlay.classList.add('slbx-open'); 341 document.documentElement.style.overflow = 'hidden'; 342 } 343 function closePop() { 344 if (!overlay) return; 345 overlay.classList.remove('slbx-open'); 346 document.documentElement.style.overflow = ''; 347 } 348 349 document.addEventListener('click', function (e) { 350 var a = e.target && e.target.closest 351 ? e.target.closest('a[href*="elementor-action"]') 352 : null; 353 if (!a) return; 354 var href = a.getAttribute('href') || ''; 355 // Only intercept popup:open actions (leave other elementor-actions alone). 356 if (href.indexOf('popup') === -1) return; 357 e.preventDefault(); 358 e.stopImmediatePropagation(); 359 var src = findBookingIframeSrc(); 360 if (src) { openPop(src); return; } 361 // Not on this page â fetch the booking URL from the homepage, then open. 362 fetchBookingSrc().then(function (url) { 363 if (url) openPop(url); 364 else window.open('/contact', '_blank'); // last-resort fallback 365 }); 366 }, true); 367 } 368 369 function boot() { 370 init(); 371 initBookingPopups(); 372 if (initCarousels()) return; 373 // Swiper lib may still be loading; poll until it is ready (max ~6s). 374 var tries = 0; 375 var t = setInterval(function () { 376 tries++; 377 if (initCarousels() || tries >= 30) clearInterval(t); 378 }, 200); 379 } 380 381 if (document.readyState === 'loading') { 382 document.addEventListener('DOMContentLoaded', boot); 383 } else {
384 boot(); 385 } 386})();
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.