420 lines
14 KiB
JavaScript
420 lines
14 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
var header = document.getElementById('siteHeader');
|
|
var nav = document.getElementById('primaryNav');
|
|
var burger = document.getElementById('burger');
|
|
var navToggle = nav && nav.querySelector('.nav__toggle');
|
|
var mqDesktop = window.matchMedia('(min-width: 60.0625rem)');
|
|
|
|
/* ---------- mobile menu ---------- */
|
|
function closeMenu() {
|
|
if (!nav || !burger) return;
|
|
nav.classList.remove('is-open');
|
|
burger.setAttribute('aria-expanded', 'false');
|
|
burger.setAttribute('aria-label', 'Open menu');
|
|
}
|
|
|
|
if (burger && nav) {
|
|
burger.addEventListener('click', function () {
|
|
var open = nav.classList.toggle('is-open');
|
|
burger.setAttribute('aria-expanded', String(open));
|
|
burger.setAttribute('aria-label', open ? 'Close menu' : 'Open menu');
|
|
});
|
|
|
|
document.addEventListener('click', function (e) {
|
|
if (mqDesktop.matches) return;
|
|
if (!nav.contains(e.target) && !burger.contains(e.target)) closeMenu();
|
|
});
|
|
|
|
nav.addEventListener('click', function (e) {
|
|
if (!mqDesktop.matches && e.target.closest('a')) closeMenu();
|
|
});
|
|
}
|
|
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.key !== 'Escape') return;
|
|
closeMenu();
|
|
if (navToggle) navToggle.setAttribute('aria-expanded', 'false');
|
|
});
|
|
|
|
mqDesktop.addEventListener('change', function (e) {
|
|
if (e.matches) closeMenu();
|
|
});
|
|
|
|
/* ---------- Tours submenu ---------- */
|
|
if (navToggle) {
|
|
navToggle.addEventListener('click', function () {
|
|
var open = navToggle.getAttribute('aria-expanded') === 'true';
|
|
navToggle.setAttribute('aria-expanded', String(!open));
|
|
});
|
|
}
|
|
|
|
/* ---------- mobile "Book now" inside the menu ---------- */
|
|
var desktopCta = document.querySelector('.header__cta');
|
|
var navList = nav && nav.querySelector('.nav__list');
|
|
if (desktopCta && navList) {
|
|
var li = document.createElement('li');
|
|
li.className = 'nav__mobile-cta';
|
|
var clone = desktopCta.cloneNode(true);
|
|
clone.classList.remove('header__cta');
|
|
clone.classList.add('nav__cta');
|
|
li.appendChild(clone);
|
|
navList.appendChild(li);
|
|
}
|
|
|
|
/* ---------- sticky header past the hero ---------- */
|
|
var hero = document.querySelector('.hero, .hero-tour');
|
|
if (header && hero && 'IntersectionObserver' in window) {
|
|
var sentinel = document.createElement('div');
|
|
sentinel.setAttribute('aria-hidden', 'true');
|
|
sentinel.style.cssText = 'position:absolute;top:0;left:0;width:1px;height:60vh;pointer-events:none';
|
|
hero.appendChild(sentinel);
|
|
|
|
new IntersectionObserver(function (entries) {
|
|
header.classList.toggle('is-stuck', !entries[0].isIntersecting);
|
|
}, { threshold: 0 }).observe(sentinel);
|
|
}
|
|
|
|
/* ---------- card rails (prev / next) ---------- */
|
|
function railStep(rail) {
|
|
var card = rail.firstElementChild;
|
|
if (!card) return rail.clientWidth;
|
|
var gap = parseFloat(getComputedStyle(rail).columnGap) || 0;
|
|
return card.getBoundingClientRect().width + gap;
|
|
}
|
|
|
|
function syncRailButtons(rail, prev, next) {
|
|
var max = rail.scrollWidth - rail.clientWidth - 1;
|
|
if (prev) prev.disabled = rail.scrollLeft <= 0;
|
|
if (next) next.disabled = rail.scrollLeft >= max;
|
|
}
|
|
|
|
Array.prototype.forEach.call(document.querySelectorAll('.rail'), function (rail) {
|
|
var prev = document.querySelector('[data-rail-prev="' + rail.id + '"]');
|
|
var next = document.querySelector('[data-rail-next="' + rail.id + '"]');
|
|
|
|
if (prev) prev.addEventListener('click', function () {
|
|
rail.scrollBy({ left: -railStep(rail), behavior: 'smooth' });
|
|
});
|
|
if (next) next.addEventListener('click', function () {
|
|
rail.scrollBy({ left: railStep(rail), behavior: 'smooth' });
|
|
});
|
|
|
|
rail.addEventListener('scroll', function () {
|
|
syncRailButtons(rail, prev, next);
|
|
}, { passive: true });
|
|
window.addEventListener('resize', function () {
|
|
syncRailButtons(rail, prev, next);
|
|
});
|
|
syncRailButtons(rail, prev, next);
|
|
});
|
|
|
|
/* ---------- testimonials marquee: duplicate for a seamless loop ---------- */
|
|
var marquee = document.querySelector('[data-marquee] .marquee__track');
|
|
if (marquee) {
|
|
var originals = Array.prototype.slice.call(marquee.children);
|
|
originals.forEach(function (node) {
|
|
var copy = node.cloneNode(true);
|
|
copy.setAttribute('aria-hidden', 'true');
|
|
marquee.appendChild(copy);
|
|
});
|
|
}
|
|
|
|
/* ---------- FAQ accordion ---------- */
|
|
var accordion = document.querySelector('[data-accordion]');
|
|
if (accordion) {
|
|
accordion.addEventListener('click', function (e) {
|
|
var btn = e.target.closest('.acc__q');
|
|
if (!btn) return;
|
|
var open = btn.getAttribute('aria-expanded') === 'true';
|
|
Array.prototype.forEach.call(accordion.querySelectorAll('.acc__q'), function (other) {
|
|
other.setAttribute('aria-expanded', 'false');
|
|
});
|
|
btn.setAttribute('aria-expanded', String(!open));
|
|
});
|
|
}
|
|
|
|
/* ---------- date field: text placeholder, native picker on focus ---------- */
|
|
var dateField = document.querySelector('[data-datefield]');
|
|
if (dateField) {
|
|
dateField.addEventListener('focus', function () {
|
|
dateField.type = 'date';
|
|
if (typeof dateField.showPicker === 'function') {
|
|
try { dateField.showPicker(); } catch (err) { /* not user-activated */ }
|
|
}
|
|
});
|
|
dateField.addEventListener('blur', function () {
|
|
if (!dateField.value) dateField.type = 'text';
|
|
});
|
|
}
|
|
|
|
/* ---------- booking form ---------- */
|
|
var form = document.getElementById('booking');
|
|
if (form) {
|
|
form.addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
var data = Object.fromEntries(new FormData(form).entries());
|
|
console.log('Booking request', data);
|
|
});
|
|
}
|
|
|
|
/* ---------- tour details: check availability ---------- */
|
|
var availability = document.getElementById('availability');
|
|
if (availability) {
|
|
availability.addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
console.log('Availability request');
|
|
});
|
|
}
|
|
|
|
|
|
/* ---------- tour archive: filtering, sorting, pagination ---------- */
|
|
var filters = document.getElementById('filters');
|
|
var grid = document.getElementById('tourGrid');
|
|
|
|
if (filters && grid) {
|
|
var PER_PAGE = 9;
|
|
/* the card the design highlights — the middle one of a full row of three */
|
|
var HIGHLIGHT = 4;
|
|
|
|
var items = Array.prototype.slice.call(grid.querySelectorAll('.titem'));
|
|
var pager = document.getElementById('pager');
|
|
var pagerPages = document.getElementById('pagerPages');
|
|
var prevBtn = pager && pager.querySelector('[data-page="prev"]');
|
|
var nextBtn = pager && pager.querySelector('[data-page="next"]');
|
|
var countEl = document.getElementById('resultCount');
|
|
var emptyEl = document.getElementById('tourEmpty');
|
|
var resetBtns = Array.prototype.slice.call(document.querySelectorAll('[data-reset]'));
|
|
var searchInput = filters.querySelector('#f-q');
|
|
|
|
var matches = items.slice();
|
|
var page = 1;
|
|
var searchTimer = null;
|
|
|
|
function value(name) {
|
|
var field = filters.elements[name];
|
|
return field ? field.value : '';
|
|
}
|
|
|
|
function checked(name) {
|
|
var field = filters.elements[name];
|
|
return !!(field && field.checked);
|
|
}
|
|
|
|
function readFilters() {
|
|
return {
|
|
/* every word has to appear, so "night sydney" finds the same tour
|
|
as "sydney night" */
|
|
words: value('q').trim().toLowerCase().split(/\s+/).filter(Boolean),
|
|
destination: value('destination'),
|
|
category: value('category'),
|
|
price: value('price'),
|
|
duration: value('duration'),
|
|
sort: value('sort') || 'newest',
|
|
freeCancellation: checked('free-cancellation'),
|
|
bestDeal: checked('best-deal'),
|
|
likelyToSellOut: checked('likely-to-sell-out')
|
|
};
|
|
}
|
|
|
|
function isFiltered(f) {
|
|
return !!(f.words.length || f.destination || f.category || f.price || f.duration ||
|
|
f.freeCancellation || f.bestDeal || f.likelyToSellOut);
|
|
}
|
|
|
|
/* ranges are inclusive at the top and exclusive at the bottom, so "$50 - $100"
|
|
and "Up to $50" tile without both claiming a $50 tour */
|
|
function inPriceRange(price, range) {
|
|
if (!range) return true;
|
|
if (range === '200-plus') return price > 200;
|
|
var bounds = range.split('-');
|
|
var low = Number(bounds[0]);
|
|
var high = Number(bounds[1]);
|
|
return (low === 0 || price > low) && price <= high;
|
|
}
|
|
|
|
function keeps(el, f) {
|
|
var data = el.dataset;
|
|
if (f.destination && data.city !== f.destination) return false;
|
|
if (f.category && data.category !== f.category) return false;
|
|
if (f.duration && data.duration !== f.duration) return false;
|
|
if (!inPriceRange(Number(data.price), f.price)) return false;
|
|
if (f.freeCancellation && data.freeCancellation !== 'true') return false;
|
|
if (f.bestDeal && data.bestDeal !== 'true') return false;
|
|
if (f.likelyToSellOut && data.likelyToSellOut !== 'true') return false;
|
|
return f.words.every(function (word) {
|
|
return data.search.indexOf(word) > -1;
|
|
});
|
|
}
|
|
|
|
var SORTS = {
|
|
newest: function (a, b) { return Number(b.dataset.added) - Number(a.dataset.added); },
|
|
'price-asc': function (a, b) { return Number(a.dataset.price) - Number(b.dataset.price); },
|
|
'price-desc': function (a, b) { return Number(b.dataset.price) - Number(a.dataset.price); },
|
|
rating: function (a, b) { return Number(b.dataset.rating) - Number(a.dataset.rating); },
|
|
duration: function (a, b) { return durationDays(a) - durationDays(b); }
|
|
};
|
|
|
|
function durationDays(el) {
|
|
return el.dataset.duration === 'half-day' ? 0.5 : Number(el.dataset.duration);
|
|
}
|
|
|
|
function pageNumbers(total, current) {
|
|
var pages = [];
|
|
var n;
|
|
|
|
/* a short run fits without an ellipsis */
|
|
if (total <= 7) {
|
|
for (n = 1; n <= total; n++) pages.push(n);
|
|
return pages;
|
|
}
|
|
|
|
var push = function (n) {
|
|
if (n >= 1 && n <= total && pages.indexOf(n) < 0) pages.push(n);
|
|
};
|
|
push(1);
|
|
push(current - 1);
|
|
push(current);
|
|
push(current + 1);
|
|
push(total);
|
|
pages.sort(function (a, b) { return a - b; });
|
|
|
|
var out = [];
|
|
pages.forEach(function (n, i) {
|
|
if (i > 0 && n - pages[i - 1] > 1) out.push(null); /* an ellipsis */
|
|
out.push(n);
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function renderPager(totalPages) {
|
|
if (!pager) return;
|
|
pager.hidden = totalPages < 2;
|
|
|
|
if (pagerPages) {
|
|
var html = '';
|
|
pageNumbers(totalPages, page).forEach(function (n) {
|
|
if (n === null) {
|
|
html += '<span class="pager__gap" aria-hidden="true">…</span>';
|
|
return;
|
|
}
|
|
html += '<button class="pager__num' + (n === page ? ' is-current' : '') +
|
|
'" type="button" data-page="' + n + '"' +
|
|
(n === page ? ' aria-current="page"' : '') +
|
|
' aria-label="Page ' + n + '">' + n + '</button>';
|
|
});
|
|
pagerPages.innerHTML = html;
|
|
}
|
|
|
|
if (prevBtn) prevBtn.disabled = page === 1;
|
|
if (nextBtn) nextBtn.disabled = page === totalPages;
|
|
}
|
|
|
|
/* built from numbers only, so the markup here is safe to assign as HTML */
|
|
function countText(start, shownCount) {
|
|
var total = matches.length;
|
|
if (!total) return 'No tours found';
|
|
var noun = total === 1 ? 'tour' : 'tours';
|
|
if (shownCount === total) return 'Showing <b>' + total + '</b> ' + noun;
|
|
return 'Showing <b>' + (start + 1) + '–' + (start + shownCount) +
|
|
'</b> of <b>' + total + '</b> ' + noun;
|
|
}
|
|
|
|
function render() {
|
|
var totalPages = Math.max(1, Math.ceil(matches.length / PER_PAGE));
|
|
if (page > totalPages) page = totalPages;
|
|
|
|
var start = (page - 1) * PER_PAGE;
|
|
var shown = matches.slice(start, start + PER_PAGE);
|
|
|
|
items.forEach(function (el) {
|
|
el.hidden = true;
|
|
setHighlight(el, false);
|
|
});
|
|
|
|
/* re-order the DOM so the sort order is what a keyboard/screen reader sees */
|
|
matches.forEach(function (el) {
|
|
grid.appendChild(el);
|
|
});
|
|
|
|
shown.forEach(function (el, i) {
|
|
el.hidden = false;
|
|
setHighlight(el, shown.length > HIGHLIGHT && i === HIGHLIGHT);
|
|
});
|
|
|
|
if (countEl) countEl.innerHTML = countText(start, shown.length);
|
|
if (emptyEl) emptyEl.hidden = matches.length > 0;
|
|
|
|
renderPager(totalPages);
|
|
}
|
|
|
|
function setHighlight(el, on) {
|
|
el.classList.toggle('is-active', on);
|
|
var card = el.querySelector('.dcard');
|
|
var badge = el.querySelector('.ring-badge--card');
|
|
if (card) card.classList.toggle('is-active', on);
|
|
if (badge) badge.classList.toggle('ring-badge--accent', on);
|
|
}
|
|
|
|
function apply(resetPage) {
|
|
var f = readFilters();
|
|
matches = items.filter(function (el) { return keeps(el, f); });
|
|
matches.sort(SORTS[f.sort] || SORTS.newest);
|
|
if (resetPage !== false) page = 1;
|
|
resetBtns.forEach(function (btn) { btn.hidden = !isFiltered(f); });
|
|
render();
|
|
}
|
|
|
|
function goToPage(next, totalPages) {
|
|
if (next < 1 || next > totalPages || next === page) return;
|
|
page = next;
|
|
render();
|
|
var list = document.getElementById('tour-list');
|
|
if (list) list.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
|
}
|
|
|
|
filters.addEventListener('submit', function (e) {
|
|
e.preventDefault();
|
|
apply();
|
|
});
|
|
|
|
filters.addEventListener('change', function () {
|
|
apply();
|
|
});
|
|
|
|
if (searchInput) {
|
|
searchInput.addEventListener('input', function () {
|
|
window.clearTimeout(searchTimer);
|
|
searchTimer = window.setTimeout(apply, 200);
|
|
});
|
|
}
|
|
|
|
resetBtns.forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
filters.reset();
|
|
apply();
|
|
filters.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
});
|
|
});
|
|
|
|
if (pagerPages) {
|
|
pagerPages.addEventListener('click', function (e) {
|
|
var btn = e.target.closest('[data-page]');
|
|
if (!btn) return;
|
|
goToPage(Number(btn.getAttribute('data-page')),
|
|
Math.max(1, Math.ceil(matches.length / PER_PAGE)));
|
|
});
|
|
}
|
|
|
|
if (prevBtn) prevBtn.addEventListener('click', function () {
|
|
goToPage(page - 1, Math.max(1, Math.ceil(matches.length / PER_PAGE)));
|
|
});
|
|
if (nextBtn) nextBtn.addEventListener('click', function () {
|
|
goToPage(page + 1, Math.max(1, Math.ceil(matches.length / PER_PAGE)));
|
|
});
|
|
|
|
apply();
|
|
}
|
|
})();
|