/* iVillas shared site script. Host at a real JS URL, e.g. https://ivillas.org/app.js/ (same trick as style.css: a CMS page whose slug ends in .js, served as JS). Every landing loads this one file via the loader in each MainText (or once in the template ). Edit here once -> whole site updates; bump ?v= (or ?clear_cache=1) to bust cache. Keep it dependency-free, defensive (pages may or may not have a given block), and idempotent. */ (function () { 'use strict'; if (window.__ivInit) return; // guard against double-injection window.__ivInit = true; function ready(fn) { if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); } function txt(el) { return el ? (el.textContent || '').replace(/\s+/g, ' ').trim() : ''; } function abs(u) { try { return new URL(u, location.href).href; } catch (e) { return u || ''; } } ready(function () { // 1) Harden outbound/affiliate links: any target=_blank gets rel noopener (perf + security). document.querySelectorAll('a[target="_blank"]').forEach(function (a) { var rel = (a.getAttribute('rel') || '').split(/\s+/).filter(Boolean); ['noopener', 'noreferrer'].forEach(function (t) { if (rel.indexOf(t) < 0) rel.push(t); }); a.setAttribute('rel', rel.join(' ')); }); // 1b) Cloaked booking CTAs: the affiliate URL lives in data-book, not href, so it never // appears as a crawlable in the static HTML (Googlebot's standard crawl reads // href values; it doesn't fire click handlers, so this keeps the link out of its graph). // Real visitors still get a normal new-tab navigation on click or Enter/Space (the anchor // has no href so it needs role=button + tabindex=0 in the HTML to stay keyboard-reachable). document.querySelectorAll('[data-book]').forEach(function (a) { function go() { window.open(a.getAttribute('data-book'), '_blank', 'noopener,noreferrer'); } a.addEventListener('click', function (e) { e.preventDefault(); go(); }); a.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } }); }); // 1c) Booking click-under. The FIRST qualifying internal-link click per device-hour opens the // reader's target in a new tab and sends THIS tab to Booking.com, so the affiliate cookie // is set even if they later reach Booking on their own. The Booking target is the page's // own cloaked data-book URL — our affiliate `label` is already baked into it, so there's no // second place to keep in sync and nothing to encode. It lives OVER the normal s // (never replaces them: the crawler still follows real links). Order is critical — open the // new tab FIRST, then mark + redirect; on iOS Safari a popup opened AFTER a same-tab nav is // queued gets killed, stranding the reader. Mark only after a real window.open so a blocked // popup doesn't burn the hour. Rationale + field lessons: repo CLICKUNDER note. (function () { var book = document.querySelector('[data-book]'); var B = book && book.getAttribute('data-book'); if (!B) return; // no affiliate target here -> never fire var KEY = 'iv_cu', TTL = 3600000; // one hour, per device function seen() { try { return Date.now() - (+localStorage.getItem(KEY) || 0) < TTL; } catch (_) { try { return !!sessionStorage.getItem(KEY); } catch (__) { return false; } } } function mark() { try { localStorage.setItem(KEY, String(Date.now())); } catch (_) { try { sessionStorage.setItem(KEY, '1'); } catch (__) { /* private mode: skip */ } } } document.addEventListener('click', function (e) { if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; // keep open-in-new-tab etc. var a = e.target.closest && e.target.closest('a'); if (!a) return; if (a.hasAttribute('data-book') || a.classList.contains('btn-stay') || a.getAttribute('target') === '_blank') return; // already Booking / new tab var h = a.getAttribute('href') || ''; if (!h || h.charAt(0) === '#') return; // in-page anchor if (a.protocol && a.protocol !== 'http:' && a.protocol !== 'https:') return; // mailto:, tel: if (a.host && a.host !== location.host) return; // external domain if (seen()) return; var w = window.open(a.href, '_blank'); // 1. reader's target first if (!w) return; // popup blocked -> normal click mark(); // 2. mark after a real open e.preventDefault(); location.href = B; // 3. this tab -> Booking }, true); // capture: before page handlers })(); // 2) Lazy-load images that didn't opt out (below-the-fold gallery/cards). Hero can set // loading="eager" in the HTML to stay excluded. document.querySelectorAll('img:not([loading])').forEach(function (img) { img.setAttribute('loading', 'lazy'); img.setAttribute('decoding', 'async'); }); // 3) SEO layer. The CMS serves only the MainText, so the arrives empty (no , // meta, canonical or structured data). Build them from what's visible on the page. Every // step is a gap-filler: it no-ops when the value already exists (a real CMS <head> wins) or // when its source block is absent. Googlebot renders JS, so it reads what we inject here. // NOTE: this is a stopgap — server-side <title>/meta in the template is still preferable. try { seoHead(); } catch (e) { /* never break the page over SEO */ } // 4) Live maps ([data-iv-map] blocks). MapLibre GL is injected via DOM APIs, never a literal // <script> tag in the MainText source — the CMS can mangle an inline/external <script> // tag it parses from the paste (see the trailing-script note above), but a script element // created at runtime with appendChild never touches that parser. try { initMaps(); } catch (e) { /* never break the page over a map */ } // 5) Photo gallery + lightbox ([.gallery] blocks). Dependency-free, no external library. try { initGalleries(); } catch (e) { /* never break the page over the gallery */ } // 6) FAQ accordion ([.faq-item] blocks). Collapsed by default — same click/Enter/Space // toggle pattern as the cloaked data-book CTAs and the map-legend button. try { initFaq(); } catch (e) { /* never break the page over the FAQ */ } }); // --- SEO helpers ------------------------------------------------------------- function ensureMeta(key, keyAttr, content) { if (!content) return; var sel = keyAttr + '="' + key + '"'; if (document.head.querySelector('meta[' + sel + ']')) return; // don't override an existing one var m = document.createElement('meta'); m.setAttribute(keyAttr, key); m.setAttribute('content', content); document.head.appendChild(m); } function ensureLink(rel, href) { if (!href || document.head.querySelector('link[rel="' + rel + '"]')) return; var l = document.createElement('link'); l.rel = rel; l.href = href; document.head.appendChild(l); } function addJsonLd(obj) { var s = document.createElement('script'); s.type = 'application/ld+json'; s.textContent = JSON.stringify(obj); document.head.appendChild(s); } function seoHead() { var h1 = document.querySelector('h1.title, h1'); var name = txt(h1); // Exclude the "/" separator spans (.sep) — only real crumb links + the current-page span. var crumbEls = [].slice.call(document.querySelectorAll('.crumb a, .crumb span:not(.sep)')); var city = crumbEls.length >= 2 ? txt(crumbEls[crumbEls.length - 2]) : ''; // Prefer the lede (a full sentence) over the subline (fact fragments read run-on once the CSS // separators are stripped); trim to ~200 chars on a word boundary for a clean snippet. var desc = txt(document.querySelector('.lede')) || txt(document.querySelector('.subline')); if (desc.length > 200) desc = desc.slice(0, 200).replace(/\s+\S*$/, '') + '…'; var heroImg = document.querySelector('.hero-media img'); var imgUrl = heroImg ? abs(heroImg.getAttribute('src')) : ''; var canonical = location.origin + location.pathname; // <html lang> — default en; a translated page may set data-lang on any element. if (!document.documentElement.getAttribute('lang')) { var dl = document.querySelector('[data-lang]'); document.documentElement.setAttribute('lang', (dl && dl.getAttribute('data-lang')) || 'en'); } // viewport — its absence hurts mobile usability (a ranking factor). if (!document.head.querySelector('meta[name="viewport"]')) { var vp = document.createElement('meta'); vp.name = 'viewport'; vp.content = 'width=device-width, initial-scale=1'; document.head.appendChild(vp); } // <title> — the single most important on-page element; build it if the CMS left it empty. if (!document.title && name) { document.title = name + (city ? ', ' + city : '') + ' | iVillas'; } ensureMeta('description', 'name', desc); ensureLink('canonical', canonical); // Open Graph / Twitter (social unfurls + some non-Google crawlers). var ogTitle = document.title || name; ensureMeta('og:title', 'property', ogTitle); ensureMeta('og:description', 'property', desc); ensureMeta('og:type', 'property', 'article'); ensureMeta('og:url', 'property', canonical); ensureMeta('og:site_name', 'property', 'iVillas'); if (imgUrl) ensureMeta('og:image', 'property', imgUrl); ensureMeta('twitter:card', 'name', imgUrl ? 'summary_large_image' : 'summary'); // --- structured data (JSON-LD) --- if (document.head.querySelector('script[type="application/ld+json"]')) return; // CMS already has it var graph = []; // BreadcrumbList — from the hero breadcrumb. if (crumbEls.length) { graph.push({ '@type': 'BreadcrumbList', itemListElement: crumbEls.map(function (el, i) { var li = { '@type': 'ListItem', position: i + 1, name: txt(el) }; if (el.tagName === 'A') li.item = abs(el.getAttribute('href')); return li; }) }); } // A hub/city page links OUT to other pages' .grid-cards (real hrefs); a single-villa page's // .grid-cards are room-type cards that only jump to '#stay' on the same page. That distinction // decides which schema fits: one LodgingBusiness, or a CollectionPage + ItemList of the villas. // Two card shapes coexist: legacy <a class="card"> (single-villa room cards, still '#stay'-only) // and the newer <div class="card"><a class="card-go" href="…real url…"> (hub cards, which also // carry a sibling data-book "Check deals" button — that's why the click-through can't be the // outer element: an <a data-book role=button> can't nest inside another real <a href>). var cardLinks = [].slice.call(document.querySelectorAll('.grid-cards a.card, .grid-cards .card > a.card-go')); var hubCards = cardLinks.filter(function (a) { return (a.getAttribute('href') || '').indexOf('#') !== 0; }); var isHub = hubCards.length >= 3; if (isHub) { graph.push({ '@type': 'CollectionPage', name: name, url: canonical }); graph.push({ '@type': 'ItemList', itemListElement: hubCards.map(function (a, i) { var n = txt(a.querySelector('.card-n')) || txt(a.querySelector('.card-t')) || txt(a); return { '@type': 'ListItem', position: i + 1, url: abs(a.getAttribute('href')), name: n }; }) }); } else { // LodgingBusiness/Hotel + AggregateRating — score & review count are visible in the hero. var rank = txt(document.querySelector('.badge-rank')); // "★ 8.9 · Fabulous" var ratingM = rank.match(/(\d+(?:\.\d+)?)/); var revTxt = txt(document.querySelector('.badge:not(.badge-cat):not(.badge-rank)')) || txt(document.querySelector('.bigstat span')); // "800 reviews" var revM = revTxt.match(/([\d,]+)\s*review/i); var lodging = { '@type': ['LodgingBusiness', 'Hotel'], name: name, url: canonical }; if (imgUrl) lodging.image = imgUrl; if (city) lodging.address = { '@type': 'PostalAddress', addressLocality: city }; if (ratingM && revM) { lodging.aggregateRating = { '@type': 'AggregateRating', ratingValue: ratingM[1], reviewCount: revM[1].replace(/,/g, ''), bestRating: '10', worstRating: '1' }; } graph.push(lodging); } // FAQPage — one `.faq-item` per Q&A (`.faq-q` question, `.faq-a` answer); `.faq-a` is // collapsed (display:none) until toggled, but textContent still reads fine either way, so // the schema always matches the page regardless of open/closed state. var qas = []; [].slice.call(document.querySelectorAll('.faq-item')).forEach(function (item) { var q = txt(item.querySelector('.faq-q')); var a = txt(item.querySelector('.faq-a')); if (q && a) { qas.push({ '@type': 'Question', name: q, acceptedAnswer: { '@type': 'Answer', text: a } }); } }); if (qas.length) graph.push({ '@type': 'FAQPage', mainEntity: qas }); if (graph.length) addJsonLd({ '@context': 'https://schema.org', '@graph': graph }); } // --- live maps ----------------------------------------------------------------- // Markup contract: a `.mapbox`/`.mapbox-home` box holding one child `#lmap`/`#hmap` canvas, // optionally followed by a sibling `.map-note` (auto-filled as a legend), with a `data-iv-map` // JSON attribute on the outer box: // {"center":[lat,lon], "zoom":15, "radius":400, "markers":[{ // "lat":.., "lon":.., "name":"..", // "here":true, // the "you are here" pin: amber teardrop, no icon // "kind":"restaurant", // a landmark's category -> a line icon (see ICONS below) // "label":"8.8", // OR a short text label instead of an icon (hub map = guest score) // "caption":"360 m · 5 min walk", // popup subtitle line // "href":"https://…" // optional popup link (villa hub cards link out; POIs don't) // }]} // `radius` (metres) draws a walk-radius circle around `center`. Every landmark pin is a place // we resolved to a real coordinate (FoursquarePlaces, name+category matched) — never a guessed // bearing off a distance figure; unconfirmed ones are left off the map, not approximated. // Engine: MapLibre GL JS + OpenFreeMap's "positron" vector style — no API key, no usage cap, // commercial use explicitly allowed (openfreemap.org), attribution auto-added by the library. // Everything is lazy: MapLibre only loads if a map block exists, and only once it scrolls near view. var MAPLIBRE_CSS = 'https://cdn.jsdelivr.net/npm/maplibre-gl@4.7.1/dist/maplibre-gl.css'; var MAPLIBRE_JS = 'https://cdn.jsdelivr.net/npm/maplibre-gl@4.7.1/dist/maplibre-gl.js'; var MAP_STYLE = 'https://tiles.openfreemap.org/styles/positron'; var maplibrePromise = null; function loadMaplibre() { if (window.maplibregl) return Promise.resolve(window.maplibregl); if (maplibrePromise) return maplibrePromise; if (!document.querySelector('link[href="' + MAPLIBRE_CSS + '"]')) { var link = document.createElement('link'); link.rel = 'stylesheet'; link.href = MAPLIBRE_CSS; document.head.appendChild(link); } maplibrePromise = new Promise(function (resolve, reject) { var s = document.createElement('script'); s.src = MAPLIBRE_JS; s.async = true; s.onload = function () { resolve(window.maplibregl); }; s.onerror = reject; document.head.appendChild(s); }); return maplibrePromise; } // Simple stroke-based line icons (24x24, currentColor via `stroke="#fff"` since every pin has a // solid-colour disc behind it) — chosen over the old Unicode dingbats because a shape (a fork, a // parasol, a shopping bag) reads at a glance; an abstract glyph like "⊹" or "◈" doesn't. var ICONS = { restaurant: { label: 'Restaurants', svg: '<line x1="7" y1="2" x2="7" y2="9"/><line x1="10" y1="2" x2="10" y2="9"/><line x1="13" y1="2" x2="13" y2="9"/>' + '<path d="M7 9c0 1.7 1.3 3 3 3s3-1.3 3-3"/><line x1="10" y1="12" x2="10" y2="22"/>' + '<line x1="18" y1="2" x2="18" y2="22"/><path d="M18 2c0 3-2 3-2 6s2 3 2 4"/>' }, beach: { label: 'Beach', svg: '<path d="M3 12a9 9 0 0 1 18 0z"/><line x1="12" y1="12" x2="12" y2="21"/><line x1="9" y1="21" x2="15" y2="21"/>' }, temple: { label: 'Temple', svg: '<line x1="3" y1="7" x2="21" y2="7"/><path d="M5 7l-1.5 3M19 7l1.5 3"/>' + '<line x1="7" y1="10" x2="7" y2="21"/><line x1="17" y1="10" x2="17" y2="21"/><line x1="4" y1="13" x2="20" y2="13"/>' }, mall: { label: 'Shops & malls', svg: '<path d="M9 8V6a3 3 0 0 1 6 0v2"/><path d="M6 8h12l-1.2 12.5a1 1 0 0 1-1 .5H8.2a1 1 0 0 1-1-.9L6 8z"/>' }, club: { label: 'Beach clubs & bars', svg: '<path d="M4 4h16l-7.2 8v7"/><line x1="8.5" y1="21" x2="15.5" y2="21"/><line x1="12" y1="12" x2="12" y2="21"/>' }, landmark: { label: 'Landmark', svg: '<path fill="#fff" stroke="none" d="M12 2l2.9 6.6 7.1.6-5.4 4.7 1.6 7-6.2-3.8-6.2 3.8 1.6-7L2 9.2l7.1-.6z"/>' } }; function ivPinEl(opts) { var el = document.createElement('div'); if (opts.here) { el.className = 'iv-pin here'; el.innerHTML = '<i class="iv-pin-ring"></i><i class="iv-pin-dot"></i>'; return el; } el.className = 'iv-pin'; var icon = ICONS[opts.kind]; if (icon) { el.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" ' + 'stroke-linecap="round" stroke-linejoin="round">' + icon.svg + '</svg>'; } else if (opts.label) { el.innerHTML = '<b>' + opts.label + '</b>'; } return el; } // A small lat/lon offset circle (~64-gon) — MapLibre has no geographic-radius primitive, so we // approximate one as a GeoJSON polygon. Fine at walking-distance radii (a few hundred metres). function geoCircle(centerLngLat, radiusM, steps) { steps = steps || 64; var lat = centerLngLat[1] * Math.PI / 180; var dLon = radiusM / (111320 * Math.cos(lat)); var dLat = radiusM / 110540; var ring = []; for (var i = 0; i <= steps; i++) { var t = (i / steps) * 2 * Math.PI; ring.push([centerLngLat[0] + dLon * Math.cos(t), centerLngLat[1] + dLat * Math.sin(t)]); } return { type: 'Feature', geometry: { type: 'Polygon', coordinates: [ring] } }; } // Builds the `.map-note` legend from what's actually on the map — never hand-authored, so it // can't drift out of sync with the markers. Tucked behind a small "Map key" toggle rather than // shown open by default: with icon + villa + radius + score-hint lines, the row got long enough // to wrap to 2-3 lines and clutter the page for something most visitors never need to read. function buildLegend(el, cfg, markers) { var note = el.nextElementSibling; if (!note || !note.classList.contains('map-note')) return; var hereM = markers.filter(function (m) { return m.here; })[0]; var kinds = []; markers.forEach(function (m) { if (m.kind && ICONS[m.kind] && kinds.indexOf(m.kind) < 0) kinds.push(m.kind); }); var hasScored = markers.some(function (m) { return !m.here && !m.kind && m.label; }); var parts = []; if (hereM) parts.push('<span>◆ ' + hereM.name + '</span>'); if (cfg.radius) parts.push('<span>✦ Shaded ring = 5-min (≈' + cfg.radius + ' m) walk</span>'); kinds.forEach(function (k) { parts.push('<span class="map-leg-ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ' + 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + ICONS[k].svg + '</svg>' + ICONS[k].label + '</span>'); }); if (hasScored) { parts.push('<span>Tap a pin for the villa’s score and page</span>'); parts.push('<span>' + markers.length + ' villas plotted by real address</span>'); } if (!parts.length) return; note.innerHTML = parts.join(''); note.hidden = true; var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'map-leg-toggle'; btn.setAttribute('aria-expanded', 'false'); btn.innerHTML = '<span class="map-leg-i">ⓘ</span>Map key'; btn.addEventListener('click', function () { note.hidden = !note.hidden; btn.setAttribute('aria-expanded', String(!note.hidden)); btn.classList.toggle('open', !note.hidden); }); note.parentNode.insertBefore(btn, note); } function renderMap(el, maplibregl) { var cfg; try { cfg = JSON.parse(el.getAttribute('data-iv-map')); } catch (e) { return; } var markers = cfg && cfg.markers || []; if (!markers.length) return; var canvas = el.querySelector('#lmap, #hmap') || el; var centerLL = cfg.center || [markers[0].lat, markers[0].lon]; var center = [centerLL[1], centerLL[0]]; // MapLibre wants [lng, lat] var accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#2B4C8C'; var map = new maplibregl.Map({ container: canvas, style: MAP_STYLE, center: center, zoom: cfg.zoom || 14, attributionControl: { compact: true } }); map.scrollZoom.disable(); map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-left'); map.addControl(new maplibregl.FullscreenControl(), 'top-right'); map.addControl(new maplibregl.ScaleControl({ unit: 'metric' }), 'bottom-left'); map.on('load', function () { if (cfg.radius) { map.addSource('iv-radius', { type: 'geojson', data: geoCircle(center, cfg.radius) }); map.addLayer({ id: 'iv-radius-fill', type: 'fill', source: 'iv-radius', paint: { 'fill-color': accent, 'fill-opacity': 0.07 } }); map.addLayer({ id: 'iv-radius-line', type: 'line', source: 'iv-radius', paint: { 'line-color': accent, 'line-width': 1.5, 'line-dasharray': [2, 3] } }); } var bounds = null; markers.forEach(function (m) { var lngLat = [m.lon, m.lat]; var marker = new maplibregl.Marker({ element: ivPinEl({ here: !!m.here, kind: m.kind, label: m.label }) }) .setLngLat(lngLat).addTo(map); bounds = bounds ? bounds.extend(lngLat) : new maplibregl.LngLatBounds(lngLat, lngLat); if (m.name) { var html = '<div class="iv-pop"><b>' + m.name + '</b>' + (m.caption ? '<span class="iv-pop-s">' + m.caption + '</span>' : '') + (m.href ? '<a href="' + m.href + '">View villa →</a>' : '') + '</div>'; marker.setPopup(new maplibregl.Popup({ offset: 16, closeButton: true }).setHTML(html)); } }); if (markers.length > 1 && bounds) map.fitBounds(bounds, { padding: 40, animate: false }); }); buildLegend(el, cfg, markers); } function initMaps() { var boxes = [].slice.call(document.querySelectorAll('[data-iv-map]')); if (!boxes.length) return; function boot(el) { if (el.__ivMapDone) return; el.__ivMapDone = true; loadMaplibre().then(function (maplibregl) { renderMap(el, maplibregl); }).catch(function () {}); } if ('IntersectionObserver' in window) { var io = new IntersectionObserver(function (entries) { entries.forEach(function (en) { if (en.isIntersecting) { boot(en.target); io.unobserve(en.target); } }); }, { rootMargin: '300px' }); boxes.forEach(function (el) { io.observe(el); }); } else { boxes.forEach(boot); } } // --- photo gallery + lightbox --------------------------------------------------- // Markup contract: `<div class="gallery">` of `.gal-item` tiles, each // `<div class="gal-item" role="button" tabindex="0" data-gal-full="LARGE_URL" data-gal-caption=".."> // <img src="THUMB_URL" alt=".."> // </div>`. Plain `div`s (not `<button>`) to stay on the documented TinyMCE-safe tag list, wired // up the same way the cloaked `data-book` CTAs are: click + Enter/Space, no native button needed. // No external lightbox library — a fixed overlay built from scratch, same spirit as the rest of // this file (dependency-free, defensive, never breaks the page it's attached to). function openLightbox(items, startIndex) { var idx = startIndex; var overlay = document.createElement('div'); overlay.className = 'iv-lightbox'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.innerHTML = '<button type="button" class="iv-lb-close" aria-label="Close">×</button>' + '<button type="button" class="iv-lb-prev" aria-label="Previous photo">‹</button>' + '<img>' + '<button type="button" class="iv-lb-next" aria-label="Next photo">›</button>' + '<div class="iv-lb-cap"></div>' + '<div class="iv-lb-count"></div>'; document.body.appendChild(overlay); var prevOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; var imgEl = overlay.querySelector('img'); var capEl = overlay.querySelector('.iv-lb-cap'); var countEl = overlay.querySelector('.iv-lb-count'); var multi = items.length > 1; overlay.querySelector('.iv-lb-prev').style.display = multi ? '' : 'none'; overlay.querySelector('.iv-lb-next').style.display = multi ? '' : 'none'; function show(i) { idx = (i + items.length) % items.length; imgEl.src = items[idx].full; imgEl.alt = items[idx].alt || items[idx].caption || ''; capEl.textContent = items[idx].caption || ''; capEl.hidden = !items[idx].caption; countEl.textContent = multi ? (idx + 1) + ' / ' + items.length : ''; } function close() { document.body.style.overflow = prevOverflow; document.removeEventListener('keydown', onKey); overlay.remove(); } function onKey(e) { if (e.key === 'Escape') close(); else if (e.key === 'ArrowLeft') show(idx - 1); else if (e.key === 'ArrowRight') show(idx + 1); } overlay.querySelector('.iv-lb-close').addEventListener('click', close); overlay.querySelector('.iv-lb-prev').addEventListener('click', function () { show(idx - 1); }); overlay.querySelector('.iv-lb-next').addEventListener('click', function () { show(idx + 1); }); overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); }); document.addEventListener('keydown', onKey); show(idx); overlay.querySelector('.iv-lb-close').focus(); } function initGalleries() { [].slice.call(document.querySelectorAll('.gallery')).forEach(function (gal) { var tiles = [].slice.call(gal.querySelectorAll('.gal-item')); if (!tiles.length) return; var items = tiles.map(function (t) { var img = t.querySelector('img'); var alt = (img && img.alt) || ''; return { full: t.getAttribute('data-gal-full') || (img && img.src) || '', // `caption` is the short line shown under the enlarged photo ("Poolside path"); // `alt` is the full descriptive text already on the thumbnail's <img alt> ("Villa // Kayu Raja - Poolside path") — keep them separate, or the lightbox's own <img> // ends up with just the short caption as its alt, silently dropping the villa // name that's the whole point of the alt text. caption: t.getAttribute('data-gal-caption') || alt, alt: alt || t.getAttribute('data-gal-caption') || '' }; }); tiles.forEach(function (t, i) { function open() { openLightbox(items, i); } t.addEventListener('click', open); t.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } }); }); }); } // --- FAQ accordion --------------------------------------------------------------- // Markup contract: `.faq` wraps one `.faq-item` per question, each a plain // `<div class="faq-item"><div class="faq-q" role="button" tabindex="0">Q</div> // <div class="faq-a">A</div></div>` — divs, not `<details>`, for the same TinyMCE-safe-tag // reason as the gallery tiles. All collapsed by default; toggling just flips an `.open` class // (CSS shows/hides `.faq-a`) — no animation library needed for a show/hide this simple. function initFaq() { [].slice.call(document.querySelectorAll('.faq-item')).forEach(function (item) { var q = item.querySelector('.faq-q'); if (!q) return; function toggle() { item.classList.toggle('open'); } q.addEventListener('click', toggle); q.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } }); }); } })();