1// ajax_object is injected via wp_localize_script. Inline scripts can be blocked by 2// browser extensions (uBlock Origin, Brave) â fall back to empty object so tb is 3// always defined and block scripts don't throw ReferenceError. 4const _ao = (typeof ajax_object !== 'undefined') ? ajax_object : {}; 5 6window.tb = { 7 AJAX_URL: (_ao.ajax_url || '') + '?current_language=' + (_ao.tb_current_language || ''), 8 CONNECTOR_AJAX_URL: typeof connector_ajax_object !== 'undefined' && connector_ajax_object.ajax_url 9 ? connector_ajax_object.ajax_url 10 : null, 11 TRP_IS_ACTIVATED: _ao.trp_is_activated, 12 CURRENT_LANGUAGE: _ao.tb_current_language || '', 13 IS_TB_CONNECTOR_REQUEST: _ao.is_tb_connector_request, 14 MOBILE_BREAKPOINT: 700, 15 // Content root: `.tb-root` is set server-side (body, connector wrapper, RTA 16 // article); the iframed editor canvas body only carries WP's own 17 // `.editor-styles-wrapper`, so match that too (tb-root gets stamped on it below). 18 BODY_SELECTOR: ':is(.tb-root, .editor-styles-wrapper)', 19 // The brand_id option is a Core UUID (older sites: a numeric legacy id). Either 20 // is passed on as-is; the lead handler resolves both through BrandApi. 21 TB_BRAND_ID: (() => { 22 const raw = _ao.tb_brand_id; 23 if (raw === undefined || raw === null || raw === '') { 24 return null; 25 } 26 return String(raw); 27 })(), 28 TB_BRAND_SLUG: _ao.tb_brand_slug || null, 29 30 elementReadyPromise: (selector) => { 31 return new Promise((resolve, reject) => { 32 let el = document.querySelector(selector); 33 if (el) { 34 resolve(el); 35 return; 36 } 37 new MutationObserver((mutationRecords, observer) => { 38 // Query for elements matching the specified selector 39 Array.from(document.querySelectorAll(selector)).forEach((element) => { 40 resolve(element); 41 //Once we have resolved we don't need the observer anymore. 42 observer.disconnect(); 43 }); 44 }).observe(document.documentElement, { 45 childList: true, 46 subtree: true, 47 }); 48 }); 49 }, 50 onElementReady(selector, callback) { 51 tb.elementReadyPromise(selector).then(callback); 52 }, 53 onRootElementReady: (callback) => { 54 tb.onElementReady(tb.BODY_SELECTOR, callback); 55 }, 56 /** 57 * trigger passed callback function when the passed element is resized 58 * 59 * @param {HTMLElement} element the element to watch for resizes. 60 * @param {function} callback this function will be called when the element is resized. The callback function will receive the ResizeObserverEntry as parameter. 61 */ 62 onElementResized: (element, callback) => { 63 new ResizeObserver((entries) => { 64 for (let resizeObserverEntry of entries) { 65 callback(resizeObserverEntry); 66 } 67 }).observe(element); 68 }, 69 70 /** 71 * Anchor a native Popover API element to the trigger that opens it. 72 * 73 * The Popover API puts the popover in the top layer and centres it in the 74 * viewport; this places it next to its trigger instead. Native light-dismiss 75 * (outside-click / Esc) is untouched. 76 * 77 * When the browser supports CSS Anchor Positioning (Chrome 125+), positioning 78 * is handed to the engine via `anchor-name` / `position-anchor` / `anchor()` 79 * (no JS reflow needed â it tracks scroll/resize natively). Otherwise it falls 80 * back to a JS `getBoundingClientRect` loop on scroll/resize. 81 * 82 * @param {HTMLElement} pop the [popover] element 83 * @param {HTMLElement} trigger the element to anchor against 84 * @param {object} [opts] 85 * @param {'bottom'|'top'} [opts.placement='bottom'] preferred side (auto-flips on overflow) 86 * @param {'start'|'end'} [opts.align='start'] left-align ('start') or right-align ('end') to the trigger 87 * @param {number} [opts.gap=8] gap in px between trigger and popover 88 */ 89 anchorPopover: (pop, trigger, opts = {}) => { 90 if (!pop || !trigger || pop._tbAnchored) return; 91 pop._tbAnchored = true; 92 93 const gap = opts.gap ?? 8; 94 95 // Nearest scrollable ancestor;
95 its box is the clip area we keep the popover inside. 96 const scrollParent = (() => { 97 let node = trigger.parentElement; 98 while (node) { 99 const overflowY = getComputedStyle(node).overflowY; 100 if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) return node; 101 node = node.parentElement; 102 } 103 return null; 104 })(); 105 106 // True once the trigger scrolls out of the clip area (used to dismiss the popover). 107 const triggerLeftClip = () => { 108 const triggerRect = trigger.getBoundingClientRect(); 109 const clip = scrollParent 110 ? scrollParent.getBoundingClientRect() 111 : { top: 0, bottom: window.innerHeight }; 112 return triggerRect.bottom <= clip.top || triggerRect.top >= clip.bottom; 113 }; 114 115 // Preferred path: native CSS Anchor Positioning (engine handles placement + scroll/resize tracking). 116 const supportsCssAnchor = CSS.supports('anchor-name', '--a') && CSS.supports('top', 'anchor(bottom)'); 117 if (supportsCssAnchor) { 118 tb._anchorSeq = (tb._anchorSeq || 0) + 1; 119 const anchorName = `--tb-anchor-${tb._anchorSeq}`; 120 trigger.style.anchorName = anchorName; 121 122 pop.style.position = 'fixed'; 123 pop.style.margin = '0'; 124 pop.style.inset = 'auto'; 125 pop.style.positionAnchor = anchorName; 126 if (opts.placement === 'top') { 127 pop.style.bottom = 'anchor(top)'; 128 pop.style.marginBottom = `${gap}px`; 129 } else { 130 pop.style.top = 'anchor(bottom)'; 131 pop.style.marginTop = `${gap}px`; 132 } 133 if (opts.align === 'end') { 134 pop.style.right = 'anchor(right)'; 135 } else { 136 pop.style.left = 'anchor(left)'; 137 } 138 pop.style.positionTryFallbacks = 'flip-block, flip-inline'; // flip side/edge on overflow 139 140 // Hide when the trigger scrolls out of view: pure CSS where supported, else a JS scroll listener. 141 if (CSS.supports('position-visibility', 'anchors-visible')) { 142 pop.style.positionVisibility = 'anchors-visible'; 143 } else { 144 const onScroll = () => { if (triggerLeftClip()) pop.hidePopover(); }; 145 pop.addEventListener('toggle', (e) => { 146 if (e.newState === 'open') { 147 pop._tbScroll = onScroll; 148 window.addEventListener('scroll', onScroll, true); 149 } else if (pop._tbScroll) { 150 window.removeEventListener('scroll', pop._tbScroll, true); 151 pop._tbScroll = null; 152 } 153 }); 154 } 155 return; 156 } 157 158 // --- Fallback path: JS positioning (browsers without CSS anchor) --- 159 const place = () => { 160 const gap = opts.gap ?? 8; 161 const triggerRect = trigger.getBoundingClientRect(); 162 const popWidth = pop.offsetWidth; 163 const popHeight = pop.offsetHeight; 164 165 let top = opts.placement === 'top' ? triggerRect.top - gap - popHeight : triggerRect.bottom + gap; 166 // Auto-flip to the other side when it would overflow and there is room. 167 if (opts.placement !== 'top' && top + popHeight > window.innerHeight - gap && triggerRect.top - gap - popHeight > gap) { 168 top = triggerRect.top - gap - popHeight; 169 } else if (opts.placement === 'top' && top < gap && triggerRect.bottom + gap + popHeight < window.innerHeight - gap) { 170 top = triggerRect.bottom + gap; 171 } 172 173 let left = opts.align === 'end' ? triggerRect.right - popWidth : triggerRect.left; 174 left = Math.max(gap, Math.min(left, window.innerWidth - popWidth - gap)); 175 176 pop.style.position = 'fixed'; 177 pop.style.margin = '0'; 178 pop.style.inset = 'auto'; 179 pop.style.top = `${top}px`; 180 pop.style.left = `${left}px`; 181 }; 182 183 // Follow the trigger on scroll, but dismiss once it leaves the clip area. 184 const onScroll = () => { 185 const triggerRect = trigger.getBoundingClientRect(); 186 const clip = scrollParent 187 ? scrollParent.getBoundingClientRect() 188 : { top: 0, bottom: window.innerHeight }; 189 if (triggerRect.bottom <= clip.top || triggerRect.top >= clip.bottom) { 190 pop.hidePopover(); 191 } else { 192 place(); 193 } 194 }; 195 196 // Hide until placed: the popover is shown at its default (centred) spot for one 197 // frame before `place()` runs on toggle, which reads as a jump. Keep it invisible 198 // from beforetoggle and reveal it once positioned. 199 pop.addEventListener('beforetoggle', (e) => { 200 if (e.newState === 'open') pop.style.visibility = 'hidden'; 201 }); 202 203 pop.addEventListener('toggle', (e) => { 204 if (e.newState === 'open') { 205 place(); 206 pop.style.visibility = ''; 207 pop._tbScroll = onScroll; 208 pop._tbResize = place; 209 window.addEventListener('scroll', onScroll, true); 210 window.addEventListener('resize', place); 211 } else { 212 if (pop._tbScroll) window.removeEventListener('scroll', pop._tbScroll, true); 213 if (pop._tbResize) window.removeEventListener('resize', pop._tbResize); 214 pop._tbScroll = null; 215 pop._tbResize = null; 216 } 217 }); 218 }, 219 220 /** 221 * Auto-wire static anchored popovers: any `[popover].is-anchored` (or 222 * `[data-anchor]`) with an id is anchored to its `[popovertarget]` trigger. 223 * Reads `data-anchor-placement` / `data-anchor-align` / `data-anchor-gap`. 224 * Dynamically-rendered popovers should call `tb.anchorPopover()` directly. 225 * 226 * @param {ParentNode} [root=document] 227 */ 228 initAnchoredPopovers: (root = document) => { 229 root.querySelectorAll('[popover].is-anchored, [popover][data-anchor]').forEach((pop) => { 230 if (!pop.id) return; 231 const trigger = document.querySelector(`[popovertarget="${CSS.escape(pop.id)}"]`); 232 if (!trigger) return; 233 tb.anchorPopover(pop, trigger, { 234 placement: pop.dataset.anchorPlacement, 235 align: pop.dataset.anchorAlign, 236 gap: pop.dataset.anchorGap ? Number(pop.dataset.anchorGap) : undefined, 237 }); 238 }); 239 }, 240 241 /** 242 * Reusable available-date picker. Enhances pre-rendered markup with a calendar 243 * popover where only a known set of dates is selectable. Output matches a 244 * native <input type=date>: an ISO yyyy-mm-dd value on the hidden input plus 245 * a 'change' event â the only extra is the visual hint of which days are open. 246 * 247 * The full popover skeleton lives in markup (so its labels â placeholder, 248 * legend, clear â can be translated server-side); JS only fills the dynamic 249 * parts (month name, weekday names, day cells) and toggles disabled states. 250 * Expected markup: 251 * <div class="tb-datepicker" data-tb-datepicker> 252 * <button type="button" class="tb-datepicker__btn" popovertarget="ID">
253 * <span class="tb-datepicker__btn-label">Choose a date</span> <!-- placeholder --> 254 * </button> 255 * <input type="hidden" name="..."> 256 * <div popover class="tb-datepicker__pop" id="ID"> 257 * <div class="tb-datepicker__head"> 258 * <button type="button" class="tb-datepicker__nav" data-nav="-1">â¦</button> 259 * <span class="tb-datepicker__month"></span> 260 * <button type="button" class="tb-datepicker__nav" data-nav="1">â¦</button> 261 * </div> 262 * <div class="tb-datepicker__weekdays"></div> 263 * <div class="tb-datepicker__grid"></div> 264 * <div class="tb-datepicker__foot">⦠legend ⦠<button class="tb-datepicker__clear">Clear</button></div> 265 * </div> 266 * </div> 267 * 268 * Available dates come from opts.available or data-available="iso,iso"; the 269 * display locale from opts.lang / data-lang (falls back to the site language). 270 * 271 * @param {HTMLElement} root the .tb-datepicker wrapper 272 * @param {object} [options] available[] (ISO), lang, value (ISO), onChange(iso) 273 * @returns {object} api { element, getValue(), setValue(iso), setAvailable(dates) } 274 */ 275 datePicker: (root, options = {}) => { 276 if (!root) return null; 277 if (root._tbDatePicker) return root._tbDatePicker; 278 279 const triggerButton = root.querySelector('.tb-datepicker__btn'); 280 const popover = root.querySelector('.tb-datepicker__pop'); 281 const hiddenInput = root.querySelector('input'); 282 const triggerLabel = root.querySelector('.tb-datepicker__btn-label') || triggerButton; 283 if (!triggerButton || !popover || !hiddenInput) return null; 284 285 // Skeleton elements (rendered + translated in markup); JS only fills/toggles these. 286 const monthLabel = popover.querySelector('.tb-datepicker__month'); 287 const weekdaysEl = popover.querySelector('.tb-datepicker__weekdays'); 288 const gridEl = popover.querySelector('.tb-datepicker__grid'); 289 const prevButton = popover.querySelector('.tb-datepicker__nav[data-nav="-1"]'); 290 const nextButton = popover.querySelector('.tb-datepicker__nav[data-nav="1"]'); 291 const clearButton = popover.querySelector('.tb-datepicker__clear'); 292 if (!gridEl) return null; 293 294 const language = options.lang || root.dataset.lang || tb.CURRENT_LANGUAGE || document.documentElement.lang || 'en'; 295 // Placeholder = whatever the markup put in the label; cached so clearing restores it. 296 const placeholder = triggerLabel.textContent.trim(); 297 298 const monthFormatter = new Intl.DateTimeFormat(language, { month: 'long', year: 'numeric' }); 299 // Short + compact so the trigger label stays narrow, e.g. "1 jul. 26". 300 const fullDateFormatter = new Intl.DateTimeFormat(language, { day: 'numeric', month: 'short', year: '2-digit' }); 301 const weekdayFormatter = new Intl.DateTimeFormat(language, { weekday: 'short' }); 302 303 // ISO helpers in local time (no UTC shift). 304 const toIso = (date) => 305 `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; 306 const fromIso = (isoDate) => { 307 const [year, month, day] = isoDate.split('-').map(Number); 308 return new Date(year, month - 1, day); 309 }; 310 311 // Monday-first weekday headers (2024-01-01 is a Monday), filled once â locale 312 // names, not translations. 313 const weekdayLabels = Array.from({ length: 7 }, (unused, index) => weekdayFormatter.format(new Date(2024, 0, 1 + index))); 314 if (weekdaysEl) weekdaysEl.innerHTML = weekdayLabels.map((weekday) => `<span>${weekday}</span>`).join(''); 315 const today = toIso(new Date()); 316 317 let availableDates = new Set( 318 options.available ?? (root.dataset.available ? root.dataset.available.split(',').map((date) => date.trim()).filter(Boolean) : []) 319 ); 320 let value = options.value || hiddenInput.value || ''; 321 let view; // { year, month } of the displayed month 322 323 const monthKey = (year, month) => year * 12 + month; 324 const sortedAvailable = () => [...availableDates].sort(); 325 const availableMonthKeys = () => 326 sortedAvailable().map((isoDate) => { 327 const date = fromIso(isoDate); 328 return monthKey(date.getFullYear(), date.getMonth()); 329 });
330 const setView = () => { 331 const date = fromIso(value || sortedAvailable()[0] || today); 332 view = { year: date.getFullYear(), month: date.getMonth() }; 333 }; 334 335 const updateTrigger = () => { 336 triggerLabel.textContent = value ? fullDateFormatter.format(fromIso(value)) : placeholder; 337 root.classList.toggle('is-empty', !value); 338 }; 339 340 // Fills only the dynamic bits of the markup skeleton: month name, nav/clear 341 // disabled states, and the day-cell grid (the one part that is truly dynamic). 342 const render = () => { 343 const firstOfMonth = new Date(view.year, view.month, 1); 344 const leadingBlanks = (firstOfMonth.getDay() + 6) % 7; // Monday-first offset 345 const daysInMonth = new Date(view.year, view.month + 1, 0).getDate(); 346 const monthKeys = availableMonthKeys(); 347 const currentMonthKey = monthKey(view.year, view.month); 348 349 if (monthLabel) monthLabel.textContent = monthFormatter.format(firstOfMonth); 350 if (prevButton) prevButton.disabled = !monthKeys.length || currentMonthKey <= monthKeys[0]; 351 if (nextButton) nextButton.disabled = !monthKeys.length || currentMonthKey >= monthKeys[monthKeys.length - 1]; 352 if (clearButton) clearButton.disabled = !value; 353 354 let cells = ''; 355 for (let blank = 0; blank < leadingBlanks; blank++) cells += '<span class="tb-datepicker__day is-blank"></span>'; 356 for (let dayNumber = 1; dayNumber <= daysInMonth; dayNumber++) { 357 const isoDate = toIso(new Date(view.year, view.month, dayNumber)); 358 const isAvailable = availableDates.has(isoDate); 359 const classNames = ['tb-datepicker__day', isAvailable ? 'is-available' : 'is-disabled']; 360 if (isoDate === value) classNames.push('is-selected'); 361 if (isoDate === today) classNames.push('is-today'); 362 cells += isAvailable 363 ? `<button type="button" class="${classNames.join(' ')}" data-iso="${isoDate}">${dayNumber}</button>` 364 : `<span class="${classNames.join(' ')}">${dayNumber}</span>`; 365 } 366 gridEl.innerHTML = cells; 367 }; 368 369 const commit = (isoDate) => { 370 value = isoDate; 371 hiddenInput.value = isoDate; 372 updateTrigger(); 373 hiddenInput.dispatchEvent(new Event('change', { bubbles: true })); 374 options.onChange?.(isoDate); 375 }; 376 377 popover.addEventListener('click', (event) => { 378 const navButton = event.target.closest('[data-nav]'); 379 if (navButton) { 380 const targetMonth = new Date(view.year, view.month + Number(navButton.dataset.nav), 1); 381 view = { year: targetMonth.getFullYear(), month: targetMonth.getMonth() }; 382 render(); 383 return; 384 } 385 const dayButton = event.target.closest('[data-iso]'); 386 if (dayButton) { 387 commit(dayButton.dataset.iso); 388 popover.hidePopover(); 389 return; 390 } 391 if (event.target.closest('.tb-datepicker__clear')) { 392 commit(''); 393 setView(); 394 render(); 395 popover.hidePopover(); 396 } 397 }); 398 399 // Re-sync the view to the latest value/available set each time it opens. 400 popover.addEventListener('beforetoggle', (event) => { 401 if (event.newState === 'open') { 402 setView(); 403 render(); 404 } 405 }); 406 407 tb.anchorPopover(popover, triggerButton, { placement: 'bottom', align: 'start', gap: 8 }); 408 409 setView(); 410 updateTrigger(); 411 render(); 412 413 const api = { 414 element: root, 415 getValue: () => value, 416 setValue: (isoDate) => { 417 commit(isoDate || ''); 418 setView(); 419 render(); 420 }, 421 setAvailable: (dates) => { 422 availableDates = new Set((dates || []).map((date) =>
422 String(date).trim()).filter(Boolean)); 423 if (value && !availableDates.has(value)) commit(''); 424 setView(); 425 render(); 426 }, 427 }; 428 root._tbDatePicker = api; 429 return api; 430 }, 431 432 // Auto-wire any [data-tb-datepicker] wrappers present in the markup. 433 initDatePickers: (root = document) => { 434 root.querySelectorAll('[data-tb-datepicker]').forEach((element) => tb.datePicker(element)); 435 }, 436 437 setCookie: (name, value, path, expireInDays) => { 438 let expires = ''; 439 440 let date = new Date(); 441 date.setTime(date.getTime() + +expireInDays * 24 * 60 * 60 * 1000); // 10 days expire 442 expires = '; expires=' + date.toUTCString(); 443 444 let cookie = `${name}=${value || ''}${expires}; path=${path || '/'}`; 445 446 document.cookie = cookie; 447 }, 448 449 getCookie: (name) => { 450 const cookies = document.cookie.split('; '); 451 for (let cookie of cookies) { 452 const [key, value] = cookie.split('='); 453 if (key === name) return decodeURIComponent(value); 454 } 455 return null; 456 }, 457 458 cookieExists: (name) => { 459 return document.cookie.split('; ').some((cookie) => cookie.startsWith(name + '=')); 460 }, 461 462 /** 463 * GA4 ecommerce tracking. The ONE place the dataLayer event/item shape lives. 464 * Blocks call tb.track.viewItemInCalendar({...}) / tb.track.addToCart({...}) 465 * instead of hand-rolling dataLayer.push. Shadow-DOM widgets (calendarv3, 466 * wa-tripsearch) dispatch the CustomEvents below; the listeners at the bottom 467 * of this file relay them through here so the shape stays centralized. 468 */ 469 track: { 470 // Canonical GA4 item. The only place the item shape is defined. 471 buildItem({ id, name, brand, variant, price, category }) { 472 return { 473 item_id: id, 474 item_name: name, 475 item_brand: brand, 476 ...(variant !== undefined ? { item_variant: variant } : {}), 477 price: price, 478 quantity: 1, 479 ...(category ? { item_category: category } : {}), 480 }; 481 }, 482 483 // Low-level push: handles dataLayer init + ecommerce envelope. `brand` is 484 // emitted INSIDE ecommerce (alongside value/items), matching the pre-refactor 485 // payload that GTM tags read. 486 push(event, item, brand) { 487 window.dataLayer = window.dataLayer || []; 488 window.dataLayer.push({ 489 event, 490 ecommerce: { 491 currency: 'EUR', 492 brand: brand, 493 value: item.price ?? null, 494 items: [item], 495 }, 496 }); 497 }, 498 499 viewItemInCalendar(data) { 500 this.push('view_item_in_calendar', this.buildItem(data), data.brand); 501 }, 502 503 addToCart(data) { 504 this.push('add_to_cart', this.buildItem(data), data.brand); 505 }, 506 }, 507}; 508 509// Relay CustomEvents from shadow-DOM widgets (calendarv3 tour-search-widget, 510// wa-tripsearch weadventures-tripsearch) into tb.track. The widget dispatches raw 511// data on window; we own the GA4 shape here. `brand` is derived from the package id 512// when the widget omits it, so the widget needn't know the brand rule. 513// See gtm/BOOKING-TRACKING.md for the event contract. 514(() => { 515 const withBrand = (detail = {}) => ({ 516 ...detail, 517 brand: detail.brand ?? (typeof detail.id === 'string' ? detail.id.split('-')[0] : null), 518 }); 519 window.addEventListener('tb:view-item-in-calendar', (e) => tb.track.viewItemInCalendar(withBrand(e.detail))); 520 window.addEventListener('tb:add-to-cart', (e) => tb.track.addToCart(withBrand(e.detail))); 521})(); 522 523// Stamp `.tb-root` on the editor canvas body (server-side markup already has 524// it) so the JS-set state selectors (`.tb-root.has-subnav`, â¦) match there too. 525tb.onRootElementReady((el) => el.classList.add('tb-root')); 526 527// Wire any static anchored popovers + date pickers present in the initial markup. 528document.addEventListener('DOMContentLoaded', () => { 529 tb.initAnchoredPopovers(); 530 tb.initDatePickers(); 531});
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.