PageSourceSearch

https://canoetribe.com/wp-content/plugins/tbx-plugin/js/tb-base.js?ver=1790633312

js canoetribe.com collected 2026-09-29 00:54:28 UTC 24,616 bytes, 531 lines download raw bytes

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.