'use strict'; (() => { const cards = [...document.querySelectorAll('[data-hotel]')]; const search = document.querySelector('#hotel-search'); const area = document.querySelector('#area-filter'); const sort = document.querySelector('#sort-hotels'); const chips = [...document.querySelectorAll('[data-category]')]; const grid = document.querySelector('.hotel-grid'); const status = document.querySelector('#result-count'); const empty = document.querySelector('#empty-state'); // The filter/sort/search UI exists only on collection pages; the city homepage has hotel cards but no // list controls. Without this guard search.addEventListener threw on the homepage and the script died // before wiring the 'Check Availability' clickunder below. const hasList = !!(search && sort && status && grid); let category = 'all'; let saved = new Set(); try { const value=JSON.parse(localStorage.getItem('sinatra-saved') || '[]'); if(Array.isArray(value)) saved=new Set(value.filter(x=>typeof x==='string')); } catch {} let lastSort=null; function update() { const query = search.value.trim().toLowerCase(); const val=(el,k,d)=>{const v=parseFloat(el.dataset[k]);return isNaN(v)?d:v;}; // Reorder the DOM only when the sort actually changed — never on filter/save, so toggling // a card's shortlist heart mid-page doesn't re-append every card and jump the scroll. if(sort.value!==lastSort){ lastSort=sort.value; [...cards].sort((a,b)=>{ switch(sort.value){ case 'name': return a.dataset.name.localeCompare(b.dataset.name); case 'price-asc': return val(a,'price',1e9)-val(b,'price',1e9); case 'rating-desc': return val(b,'rating',-1)-val(a,'rating',-1); case 'reviews-desc': return val(b,'reviews',-1)-val(a,'reviews',-1); case 'distance-asc': return val(a,'distance',1e9)-val(b,'distance',1e9); default: return Number(a.dataset.order)-Number(b.dataset.order); } }).forEach(card=>grid.append(card)); } let count=0; cards.forEach(card=>{ card.hidden = !(card.dataset.name.toLowerCase().includes(query) && (!area||area.value==='all'||card.dataset.area===area.value) && (category==='all'||(category==='saved'?saved.has(card.dataset.id):card.dataset.categories.split(' ').includes(category)))); if(!card.hidden) count++; }); chips.forEach(button=>button.setAttribute('aria-pressed',String(button.dataset.category===category))); const nS=status.dataset.noun||'stay', nP=status.dataset.nounPlural||'stays'; status.textContent=`${count} ${count===1?nS:nP}`; if(empty) empty.hidden=count!==0; } function choose(value){category=value;update();} if(hasList) chips.forEach(button=>button.addEventListener('click',()=>{choose(button.dataset.category); if(button.classList.contains('collection')) document.querySelector('#hotels').scrollIntoView({behavior:'smooth'});})); if(hasList){ search.addEventListener('input',update); if(area)area.addEventListener('change',update); sort.addEventListener('change',update); document.querySelectorAll('[data-reset]').forEach(button=>button.addEventListener('click',()=>{search.value='';if(area)area.value='all';sort.value='featured';choose('all');})); document.querySelectorAll('[data-area-link]').forEach(button=>button.addEventListener('click',()=>{area.value=button.dataset.areaLink;search.value='';choose('all');document.querySelector('#hotels').scrollIntoView({behavior:'smooth'});})); } // currency switcher — prices are stored in EUR (data-eur); convert the display client-side with // indicative fixed rates. Sorting stays on data-price (EUR) since conversion is linear. const FX={EUR:[1,'€'],USD:[1.09,'$'],GBP:[0.85,'£']}; const curSel=document.querySelector('#currency-select'); let cur='EUR'; try{const s=localStorage.getItem('sinatra-cur'); if(s&&FX[s])cur=s;}catch{} function applyCur(){ const [rate,sym]=FX[cur]||FX.EUR; document.querySelectorAll('[data-eur]').forEach(el=>{ const base=parseFloat(el.dataset.eur); if(isNaN(base))return; const amt=Math.round(base*rate), suf=el.dataset.suffix||'', t=el.querySelector('.amt'); if(t) t.textContent=sym+amt; else el.textContent=sym+amt+suf; }); } if(curSel){curSel.value=cur; curSel.addEventListener('change',()=>{cur=curSel.value;try{localStorage.setItem('sinatra-cur',cur);}catch{}applyCur();});} applyCur(); let toastTimer; document.querySelectorAll('.save').forEach(button=>{ const id=button.closest('[data-hotel]').dataset.id; function paint(){const active=saved.has(id);button.setAttribute('aria-pressed',String(active));button.textContent=active?'♥':'♡';button.setAttribute('aria-label',`${active?'Unsave':'Save'} ${button.closest('[data-hotel]').dataset.name}`);} paint(); button.addEventListener('click',()=>{saved.has(id)?saved.delete(id):saved.add(id);let persistent=true;try{localStorage.setItem('sinatra-saved',JSON.stringify([...saved]));}catch{persistent=false;}paint();if(hasList)update();const toast=document.querySelector('#toast');toast.textContent=(saved.has(id)?'Added to your shortlist':'Removed from your shortlist')+(persistent?'':' · this session only');toast.hidden=false;clearTimeout(toastTimer);toastTimer=setTimeout(()=>toast.hidden=true,2600);}); }); if(hasList) update(); // --- "Check Availability" → Booking (affiliate) --------------------------------------- // The hotel NAME stays a plain internal (→ our property page); only the CTA button // goes to Booking. Cloaked: the Booking base URL is base64 in window.__CLU and the full URL is // built at click time (never in the crawlable HTML). Crawlers are skipped, so they just follow // the button's internal href fallback. // Label + click stats (spec hc-content/docs/specs/2026-10-19-clicks-own-options.md): when the page carries // window.shared.options (our block, or the platform's own), the label is in the platform format // --property[-w]-pid-pbid and the click is logged to /log/event/ exactly like the // platform script does; pages without the block keep the old label and log nothing. (() => { const enc = window.__CLU; // base64 of the Booking base URL, or falsy if (!enc) return; const ua = navigator.userAgent || ''; if (/bot|crawl|spider|slurp|bing|google|yandex|baidu|duckduck|facebookexternalhit|lighthouse|pagespeed|headless|preview/i.test(ua) || navigator.webdriver) return; const o = (window.shared && window.shared.options) || null; const log = (o && o.log) || {}; const labelFor = (propUrl) => { if (!o || !o.gdid) return location.hostname + '-hotel-place-n-' + propUrl; // old structure (no block) return encodeURIComponent(location.host) + (o.page ? '-' + o.page : '') + '-property' + (log.wsl ? '-w' + log.wsl : '') + (o.pid ? '-pid' + o.pid : '') + '-pbid' + o.gdid; }; const bookingURL = (propUrl) => { const base = atob(enc); // https://www.booking.com/searchresults.html?dest_type=city&dest_id=…&aid=… const day = 864e5, iso = t => new Date(t).toISOString().slice(0, 10), now = Date.now(); return base + '&checkin=' + iso(now + day) + '&checkout=' + iso(now + 2 * day) + '&group_adults=2&group_children=0&selected_currency=&label=' + labelFor(propUrl); }; const logClick = (url, propUrl) => { // = platform Stats.sendEvent('open', {category: 'clickunder'}) if (!o || !o.gdid || !window.fetch) return; try { const m = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/); const s = v => String(v === undefined ? null : v); const l = ['property', s(log.id), s(o.pid), s(log.wsl), s(o.maid), url, JSON.stringify({hotel: propUrl})].join('||'); const headers = {'Content-Type': 'application/json', 'Accept': 'application/json, text/plain, */*', 'X-Requested-With': 'XMLHttpRequest'}; if (m) headers['X-XSRF-TOKEN'] = decodeURIComponent(m[1]); fetch('/log/event/', {method: 'POST', credentials: 'same-origin', keepalive: true, headers, body: JSON.stringify({id: log.id === undefined ? null : log.id, c: 'clickunder', a: 'open', l})}) .then(r => r.json()).then(d => { if (!d || !d.success) console.error('clickunder log: not accepted'); }) .catch(() => {}); } catch (err) { /* stats must never block Booking */ } }; document.querySelectorAll('.check-btn').forEach(b => { b.addEventListener('click', (e) => { e.preventDefault(); const prop = b.dataset.u || location.href; // this hotel's page URL (old label tail / log detail) const url = bookingURL(prop); window.open(url, '_blank'); // Booking opens first; our page stays logClick(url, prop); }); }); })(); })();