Toronto Minimal Tours

Tours Catalog

Matches: 0
Next refresh in 60s

'), fetch('footer.html').then(r => r.text()).catch(()=>' ') ]); document.querySelector('header').innerHTML = h; document.querySelector('footer').innerHTML = f; initGlobalHandlers(); } function initGlobalHandlers() { document.body.addEventListener('click', (e) => { const openId = e.target.getAttribute('data-open-modal'); if (openId) { const dlg = document.getElementById(openId); if (dlg && typeof dlg.showModal === 'function') dlg.showModal(); } if (e.target.hasAttribute('data-close-modal')) { const dlg = e.target.closest('dialog'); if (dlg) dlg.close(); } if (e.target.id === 'mobile-menu-toggle') { const m = document.getElementById('mobile-menu'); if (m) m.classList.toggle('hidden'); } }); const accept = document.getElementById('cookie-accept'); const banner = document.getElementById('cookie-banner'); if (accept && banner) { if (localStorage.getItem('cookie-consent') === 'yes') banner.classList.add('hidden'); accept.addEventListener('click', () => { localStorage.setItem('cookie-consent','yes'); banner.classList.add('hidden'); }); } const saved = localStorage.getItem('theme'); if (saved === 'dark') document.documentElement.classList.add('dark'); document.body.addEventListener('click', (e) => { if (e.target.matches('[data-theme]')) { const mode = e.target.getAttribute('data-theme'); if (mode === 'dark') document.documentElement.classList.add('dark'); else document.documentElement.classList.remove('dark'); localStorage.setItem('theme', mode); } }); } function readHashParams() { const hash = location.hash.slice(1); if (!hash) return; const parts = new URLSearchParams(hash); state.q = parts.get('q') || state.q; state.category = parts.get('category') || state.category; state.lang = parts.get('lang') || state.lang; state.priceMax = parts.get('priceMax') ? parseFloat(parts.get('priceMax')) : state.priceMax; state.durMax = parts.get('durMax') ? parseFloat(parts.get('durMax')) : state.durMax; state.ratingMin = parts.get('ratingMin') ? parseFloat(parts.get('ratingMin')) : state.ratingMin; state.accessible = parts.get('accessible') === '1' ? true : state.accessible; state.family = parts.get('family') === '1' ? true : state.family; state.page = parts.get('page') ? parseInt(parts.get('page')) || 1 : state.page; } function writeHashParams() { const params = new URLSearchParams(); if (state.q) params.set('q', state.q); if (state.category) params.set('category', state.category); if (state.lang) params.set('lang', state.lang); if (state.priceMax != null) params.set('priceMax', String(state.priceMax)); if (state.durMax != null) params.set('durMax', String(state.durMax)); if (state.ratingMin != null) params.set('ratingMin', String(state.ratingMin)); if (state.accessible) params.set('accessible','1'); if (state.family) params.set('family','1'); params.set('page', String(state.page)); const nextHash = params.toString(); if (location.hash.slice(1) !== nextHash) location.hash = nextHash; localStorage.setItem('catalog-filters', JSON.stringify({ q: state.q, category: state.category, lang: state.lang, priceMax: state.priceMax, durMax: state.durMax, ratingMin: state.ratingMin, accessible: state.accessible, family: state.family, page: state.page })); } function syncFormFromState() { const ids = ['q','category','lang','priceMax','durMax','ratingMin','accessible','family']; ids.forEach(id => { const el = document.getElementById(id); if (!el) return; if (el.type === 'checkbox') el.checked = state[id]; else el.value = state[id] ?? ''; }); } function loadSavedFilters() { const raw = localStorage.getItem('catalog-filters'); if (!raw) return; try { const s = JSON.parse(raw); Object.assign(state, { q: s.q || '', category: s.category || '', lang: s.lang || '', priceMax: s.priceMax ?? null, durMax: s.durMax ?? null, ratingMin: s.ratingMin ?? null, accessible: !!s.accessible, family: !!s.family, page: s.page || 1 }); } catch(e){} } async function loadData() { const data = await fetch('catalog.json').then(r => r.json()); state.all = data; render(); } function applyFilters(items) { return items.filter(it => { if (state.q) { const q = state.q.toLowerCase(); const hay = [it.title, it.excerpt, it.description, (it.neighbourhoods||[]).join(' '), ...(it.tags||[])].join(' ').toLowerCase(); if (!hay.includes(q)) return false; } if (state.category && it.category !== state.category) return false; if (state.lang && !(it.languages||[]).includes(state.lang)) return false; if (state.priceMax != null && it.priceCAD > state.priceMax) return false; if (state.durMax != null && it.durationHours > state.durMax) return false; if (state.ratingMin != null && it.rating < state.ratingMin) return false; if (state.accessible && !it.accessibility) return false; if (state.family && !it.familyFriendly) return false; return true; }); } function sanitizeNumberInput(id, {min=null, max=null}) { const el = document.getElementById(id); if (!el) return {valid:true}; const val = el.value.trim(); if (val === '') { el.classList.remove('ring-2','ring-red-500'); el.setAttribute('aria-invalid','false'); return {valid:true}; } const num = Number(val); if (Number.isNaN(num)) { markInvalid(el); return {valid:false, msg:`${id} must be a number`}; } if (min != null && num < min) { markInvalid(el); return {valid:false, msg:`${id} must be ≥ ${min}`}; } if (max != null && num > max) { markInvalid(el); return {valid:false, msg:`${id} must be ≤ ${max}`}; } el.classList.remove('ring-2','ring-red-500'); el.setAttribute('aria-invalid','false'); return {valid:true}; } function markInvalid(el) { el.classList.add('ring-2','ring-red-500'); el.setAttribute('aria-invalid','true'); } function render() { const filtered = applyFilters(state.all); const totalPages = Math.max(1, Math.ceil(filtered.length / state.perPage)); state.page = Math.min(Math.max(1, state.page), totalPages); const start = (state.page - 1) * state.perPage; const pageItems = filtered.slice(start, start + state.perPage); const favs = new Set(JSON.parse(localStorage.getItem('fallows') || '[]')); const list = document.getElementById('list'); if (state.all.length === 0) { list.innerHTML = Array.from({length: 6}).map(()=>`
  • `).join(''); } else { list.innerHTML = pageItems.map(it => { const isFav = favs.has(it.id); const accBadge = it.accessibility ? 'Accessible' : ''; const famBadge = it.familyFriendly ? 'Family' : ''; return `
  • ${it.title}

    ${it.excerpt}

    Category: ${it.category} • ${it.durationHours}h • $${it.priceCAD} CAD • ★ ${Number(it.rating).toFixed(1)} (${it.reviewsCount}) ${accBadge}${famBadge}
  • `; }).join(''); } document.getElementById('results-count').textContent = String(filtered.length); document.getElementById('page').textContent = `${state.page} / ${Math.max(1, totalPages)}`; document.getElementById('prev').disabled = state.page <= 1; document.getElementById('next').disabled = state.page >= totalPages; writeHashParams(); } function attachUI() { document.getElementById('apply').addEventListener('click', onApply); document.getElementById('reset').addEventListener('click', onReset); document.getElementById('prev').addEventListener('click', () => { state.page--; render(); }); document.getElementById('next').addEventListener('click', () => { state.page++; render(); }); document.getElementById('filters').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); onApply(); } }); document.getElementById('list').addEventListener('click', (e) => { const id = e.target.getAttribute('data-details'); if (id) openDetails(id); const fav = e.target.getAttribute('data-fav'); if (fav) toggleFav(fav); const cart = e.target.getAttribute('data-cart'); if (cart) addToCart(cart, 1); }); window.addEventListener('hashchange', () => { readHashParams(); syncFormFromState(); render(); }); startDealTimer(); } function onApply() { const errs = []; const r1 = sanitizeNumberInput('priceMax', {min:0}); if (!r1.valid && r1.msg) errs.push(r1.msg); const r2 = sanitizeNumberInput('durMax', {min:0}); if (!r2.valid && r2.msg) errs.push(r2.msg); const r3 = sanitizeNumberInput('ratingMin', {min:0, max:5}); if (!r3.valid && r3.msg) errs.push(r3.msg); const qEl = document.getElementById('q'); if (qEl.value && qEl.value.trim().length < 2) { markInvalid(qEl); errs.push('Search must be at least 2 characters'); } else { qEl.classList.remove('ring-2','ring-red-500'); qEl.setAttribute('aria-invalid','false'); } const errorBox = document.getElementById('form-error'); if (errs.length) { errorBox.textContent = errs[0]; return; } else { errorBox.textContent=''; } state.q = document.getElementById('q').value.trim(); state.category = document.getElementById('category').value; state.lang = document.getElementById('lang').value; state.priceMax = document.getElementById('priceMax').value ? parseFloat(document.getElementById('priceMax').value) : null; state.durMax = document.getElementById('durMax').value ? parseFloat(document.getElementById('durMax').value) : null; state.ratingMin = document.getElementById('ratingMin').value ? parseFloat(document.getElementById('ratingMin').value) : null; state.accessible = document.getElementById('accessible').checked; state.family = document.getElementById('family').checked; state.page = 1; render(); } function onReset() { ['q','category','lang','priceMax','durMax','ratingMin','accessible','family'].forEach(id=>{ const el = document.getElementById(id); if (el.type === 'checkbox') el.checked = false; else el.value = ''; el.classList.remove('ring-2','ring-red-500'); el.setAttribute('aria-invalid','false'); }); document.getElementById('form-error').textContent = ''; state.q=''; state.category=''; state.lang=''; state.priceMax=null; state.durMax=null; state.ratingMin=null; state.accessible=false; state.family=false; state.page=1; render(); } function toggleFav(id) { const set = new Set(JSON.parse(localStorage.getItem('fallows') || '[]')); if (set.has(id)) set.delete(id); else set.add(id); localStorage.setItem('fallows', JSON.stringify([...set])); render(); } function addToCart(id, qty) { const cart = JSON.parse(localStorage.getItem('cart') || '{}'); cart[id] = (cart[id] || 0) + qty; localStorage.setItem('cart', JSON.stringify(cart)); announce(`Added to cart`); } function openDetails(id) { const it = state.all.find(x => x.id === id); if (!it) return; document.getElementById('details-title').textContent = it.title; document.getElementById('details-excerpt').textContent = it.excerpt; const detailsLines = [ it.description, `Category: ${it.category}`, `Duration: ${it.durationHours} hours`, `Price: $${it.priceCAD} CAD`, `Rating: ★ ${Number(it.rating).toFixed(1)} (${it.reviewsCount} reviews)`, `Languages: ${it.languages.join(', ')}`, `Neighbourhoods: ${(it.neighbourhoods||[]).join(', ') || '—'}`, `Schedule: ${it.schedule.join(', ')}`, `Start times: ${it.startTimes.join(', ')}`, `Accessibility: ${it.accessibility ? 'Yes' : 'No'}`, `Family-friendly: ${it.familyFriendly ? 'Yes' : 'No'}` ]; document.getElementById('details-description').textContent = detailsLines.shift(); document.getElementById('details-meta').innerHTML = detailsLines.map(l=>`
    ${l}
    `).join(''); document.getElementById('details-add-cart').onclick = () => addToCart(it.id, 1); document.getElementById('details-ics').onclick = () => downloadICS(it); const dlg = document.getElementById('details-modal'); if (typeof dlg.showModal === 'function') dlg.showModal(); } function pad(n){ return String(n).padStart(2,'0'); } function downloadICS(it) { const now = new Date(); const timeStr = (it.startTimes && it.startTimes[0]) ? it.startTimes[0] : '09:00'; const hh = parseInt(timeStr.split(':')[0])||9, mm = parseInt(timeStr.split(':')[1])||0; const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hh, mm); const end = new Date(start.getTime() + (it.durationHours||1) * 3600000); const dt = (d)=> `${d.getUTCFullYear()}${pad(d.getUTCMonth()+1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}00Z`; const ics = [ 'BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//Toronto Minimal Tours//EN','BEGIN:VEVENT', `UID:${it.id}@raperou.top`, `DTSTAMP:${dt(new Date())}`, `DTSTART:${dt(start)}`, `DTEND:${dt(end)}`, `SUMMARY:${it.title}`, `DESCRIPTION:${it.excerpt}`, 'END:VEVENT','END:VCALENDAR' ].join('\r\n'); const blob = new Blob([ics], {type:'text/calendar'}); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `${it.slug}.ics`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href); } function startDealTimer() { let s = 60; const el = document.getElementById('deal-timer'); setInterval(() => { s = s - 1; if (s <= 0) { s = 60; if (state.all.length) { state.all = [...state.all]; render(); } } if (el) el.textContent = s + 's'; }, 1000); } function announce(msg) { let live = document.getElementById('live-region'); if (!live) { live = document.createElement('div'); live.id = 'live-region'; live.setAttribute('aria-live','polite'); live.className = 'sr-only'; document.body.appendChild(live); } live.textContent = msg; } readHashParams(); injectLayout(); loadSavedFilters(); syncFormFromState(); loadData(); attachUI(); raperou.top