1window.ICON_VIEW_SUNSET_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" width="20" height="20" aria-hidden="true"><path d="M7.5 14a4.5 4.5 0 0 1 9 0"/><path d="M12 5v-2"/><path d="M4.5 8.5l1.2 1.2"/><path d="M19.5 8.5l-1.2 1.2"/><path d="M3 7l1.2 1.2"/><path d="M2.5 14h19"/><path d="M3 18q2-1 4 0t4 0t4 0t4 0t2-0.5"/><path d="M3 21q2-1 4 0t4 0t4 0t4 0t2-0.5"/></svg>'; 2window.ICON_EXCLUSIVE_STAR_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="18" height="18" aria-hidden="true"><path d="M12 2c.4 5.2 1.3 7.4 3.5 8.5c2.2 1.1 4.4 1.2 6.5 1.5c-2.1.3-4.3.4-6.5 1.5c-2.2 1.1-3.1 3.3-3.5 8.5c-.4-5.2-1.3-7.4-3.5-8.5C6.3 12.4 4.1 12.3 2 12c2.1-.3 4.3-.4 6.5-1.5C10.7 9.4 11.6 7.2 12 2z"/></svg>'; 3window.sitePath = sitePath; 4window.resolveUrl = resolveUrl; 5window.fetchJsonResource = fetchJsonResource; 6window.getAccommodationSection = getAccommodationSection; 7window.getAccommodationType = getAccommodationType; 8window.roomMatchesSection = roomMatchesSection; 9window.getAccommodationJsonPath = getAccommodationJsonPath; 10window.getAccommodationListPath = getAccommodationListPath; 11window.getAccommodationDetailPath = getAccommodationDetailPath; 12window.extractAccommodationItems = extractAccommodationItems; 13window.getAccommodationGalleryImages = getAccommodationGalleryImages; 14window.getAccommodationCoverImage = getAccommodationCoverImage; 15window.getAccommodationLocalizedCopy = getAccommodationLocalizedCopy; 16window.getAccommodationUiCopy = getAccommodationUiCopy; 17window.appendBookingContextToUrl = appendBookingContextToUrl; 18window.loadTwoTorriMediaManifest = loadTwoTorriMediaManifest; 19window.getTwoTorriImageMeta = getTwoTorriImageMeta; 20window.getOptimizedTwoTorriImageUrl = getOptimizedTwoTorriImageUrl; 21window.getOptimizedTwoTorriVideoUrl = getOptimizedTwoTorriVideoUrl; 22window.renderTwoTorriResponsivePicture = renderTwoTorriResponsivePicture; 23 24// Scheda dell'hotel su Google Maps. Unico riferimento, riusato da footer e menu fullscreen: 25// in entrambi l'indirizzo era un <a href="#"> inerte. à la URL "search" e non "dir" perché 26// cliccare un indirizzo deve mostrare dove si trova l'hotel, non avviare la navigazione: 27// le indicazioni hanno già un pulsante dedicato nella home (index.html:1413). 28const MAPS_URL = "https://www.google.com/maps/search/?api=1&query=Hotel+Club+Due+Torri+Via+Diego+Taiani+3+Maiori"; 29 30const BOOKING_BASE_URL = "https://booking.passepartout.cloud/booking"; 31const BOOKING_PORTAL_ID = "9169"; 32// Ultima data prenotabile (inclusa), formato YYYY-MM-DD: i calendari si fermano qui e le 33// date oltre â anche da querystring â non raggiungono il booking engine. à l'unico punto 34// da aggiornare quando si aprono le vendite a una nuova stagione. Dichiarata qui perché 35// utils.js è caricato prima di main.js su tutte le pagine. 36const BOOKING_LAST_BOOKABLE_DATE = "2027-10-31"; 37const BOOKING_MAX_ADULTS = 10; 38const BOOKING_MAX_CHILDREN = 10; 39const BOOKING_MAX_CHILD_AGE = 17; 40const BOOKING_DEFAULT_OCCUPANCY = Object.freeze({ 41 adults: 2, 42 childrenAges: [], 43}); 44 45function escapeBookingHtml(value) { 46 return String(value ?? "") 47 .replaceAll("&", "&") 48 .replaceAll("<", "<") 49 .replaceAll(">", ">") 50 .replaceAll('"', """) 51 .replaceAll("'", "'"); 52} 53 54function clampInteger(value, min, max, fallback = min) { 55 const parsed = Number.parseInt(value, 10); 56 if (Number.isNaN(parsed)) return fallback; 57 return Math.min(max, Math.max(min, parsed)); 58} 59 60function getDefaultBookingOccupancy() { 61 return { 62 adults: BOOKING_DEFAULT_OCCUPANCY.adults, 63 childrenAges: [...BOOKING_DEFAULT_OCCUPANCY.childrenAges], 64 }; 65} 66 67function normalizeBookingOccupancy(input = null) { 68 const fallback = getDefaultBookingOccupancy(); 69 70 if (input == null) { 71 return fallback; 72 } 73 74 const source = Array.isArray(input) ? input[0] : input; 75 76 if (!source || typeof source !== "object") { 77 return fallback; 78 } 79 80 const adults = clampInteger( 81 source.adults ?? source.adulti, 82 1, 83 BOOKING_MAX_ADULTS, 84 fallback.adults, 85 ); 86 87 const childrenAges = []; 88 89 if (Array.isArray(source.childrenAges)) {
90 source.childrenAges.forEach((age) => { 91 childrenAges.push( 92 clampInteger(age, 0, BOOKING_MAX_CHILD_AGE, 0), 93 ); 94 }); 95 } 96 97 if (Array.isArray(source.bambini)) { 98 source.bambini.forEach((child) => { 99 const quantity = clampInteger( 100 child?.quantita ?? child?.quantity ?? child?.qty, 101 1, 102 BOOKING_MAX_CHILDREN, 103 1, 104 ); 105 const age = clampInteger( 106 child?.eta ?? child?.age, 107 0, 108 BOOKING_MAX_CHILD_AGE, 109 0, 110 ); 111 112 for (let index = 0; index < quantity; index += 1) { 113 if (childrenAges.length >= BOOKING_MAX_CHILDREN) break; 114 childrenAges.push(age); 115 } 116 }); 117 } 118 119 return { 120 adults, 121 childrenAges: childrenAges.slice(0, BOOKING_MAX_CHILDREN), 122 }; 123} 124 125function compressBookingChildren(childrenAges = []) { 126 const quantities = new Map(); 127 128 childrenAges.forEach((age) => { 129 const normalizedAge = clampInteger(age, 0, BOOKING_MAX_CHILD_AGE, 0); 130 quantities.set(normalizedAge, (quantities.get(normalizedAge) || 0) + 1); 131 }); 132 133 return Array.from(quantities.entries()) 134 .sort((a, b) => a[0] - b[0]) 135 .map(([eta, quantita]) => ({ eta, quantita })); 136} 137 138function buildBookingRoomsValue(occupancy = getDefaultBookingOccupancy()) { 139 const normalized = normalizeBookingOccupancy(occupancy); 140 141 return JSON.stringify([ 142 { 143 adulti: normalized.adults, 144 bambini: compressBookingChildren(normalized.childrenAges), 145 }, 146 ]); 147} 148 149function parseBookingRoomsValue(value) { 150 if (!value) return null; 151 152 const candidates = [value]; 153 154 try { 155 const decoded = decodeURIComponent(value); 156 if (!candidates.includes(decoded)) { 157 candidates.push(decoded); 158 } 159 } catch (error) { 160 // Ignore malformed URI input and fall back to raw parsing. 161 } 162 163 for (const candidate of candidates) { 164 try { 165 return normalizeBookingOccupancy(JSON.parse(candidate)); 166 } catch (error) { 167 // Try the next candidate. 168 } 169 } 170 171 return null; 172} 173 174function getBookingOccupancyFromSearchParams(searchParams = new URLSearchParams(window.location.search)) { 175 return ( 176 parseBookingRoomsValue(searchParams.get("camere")) || 177 getDefaultBookingOccupancy() 178 ); 179} 180 181function setBookingGuestsState(container, occupancy) { 182 const normalized = normalizeBookingOccupancy(occupancy); 183 container._bookingGuestsState = normalized; 184 container.dataset.bookingGuestsState = JSON.stringify(normalized); 185 return normalized; 186} 187 188function getBookingGuestsState(container) { 189 if (!container) { 190 return getDefaultBookingOccupancy(); 191 } 192 193 if (container._bookingGuestsState) { 194 return normalizeBookingOccupancy(container._bookingGuestsState); 195 } 196 197 if (container.dataset.bookingGuestsState) { 198 try { 199 return setBookingGuestsState( 200 container, 201 JSON.parse(container.dataset.bookingGuestsState), 202 ); 203 } catch (error) { 204 return setBookingGuestsState( 205 container, 206 getBookingOccupancyFromSearchParams(), 207 ); 208 } 209 } 210 211 return setBookingGuestsState(container, getBookingOccupancyFromSearchParams()); 212} 213 214function getBookingGuestsCopy(lang = getCurrentLang()) { 215 const fallback = 216 lang === "it" 217 ? { 218 guestsLabel: "Ospiti", 219 adultsLabel: "Adulti", 220 childrenLabel: "Bambini", 221 childAgeLabel: "Età bambino", 222 adultSingular: "adulto", 223 adultPlural: "adulti", 224 childSingular: "bambino", 225 childPlural: "bambini", 226 } 227 : { 228 guestsLabel: "Guests", 229 adultsLabel: "Adults", 230 childrenLabel: "Children", 231 childAgeLabel: "Child age", 232 adultSingular: "adult", 233 adultPlural: "adults", 234 childSingular: "child", 235 childPlural: "children", 236 }; 237 238 const dict = window.siteLocaleDict || {}; 239 240 return { 241 guestsLabel: 242 getNested(dict, "commons.bookingGuests") || fallback.guestsLabel, 243 adultsLabel: 244 getNested(dict, "commons.bookingAdults") || fallback.adultsLabel, 245 childrenLabel: 246 getNested(dict, "commons.bookingChildren") || fallback.childrenLabel, 247 childAgeLabel: 248 getNested(dict, "commons.bookingChildAge") || fallback.childAgeLabel, 249 adultSingular: 250 getNested(dict, "commons.bookingAdultSingular") || 251 fallback.adultSingular, 252 adultPlural: 253 getNested(dict, "commons.bookingAdultPlural") || fallback.adultPlural, 254 childSingular: 255 getNested(dict, "commons.bookingChildSingular") || 256 fallback.childSingular, 257 childPlural: 258 getNested(dict, "commons.bookingChildPlural") || fallback.childPlural, 259 }; 260} 261 262function formatBookingGuestsSummary(occupancy = getDefaultBookingOccupancy(), lang = getCurrentLang()) { 263 const normalized = normalizeBookingOccupancy(occupancy); 264 const copy = getBookingGuestsCopy(lang); 265 const childrenCount = normalized.childrenAges.length; 266 const summary = [ 267 `${normalized.adults} ${ 268 normalized.adults === 1 ? copy.adultSingular : copy.adultPlural 269 }`, 270 ]; 271 272 if (childrenCount > 0) { 273 summary.push( 274 `${childrenCount} ${ 275 childrenCount === 1 ? copy.childSingular : copy.childPlural 276 }`, 277 ); 278 } 279 280 return summary.join(", "); 281} 282 283function buildBookingSelectOptions(min, max, selectedValue) { 284 return Array.from({ length: max - min + 1 }, (_, index) => min + index) 285 .map((value) => { 286 const selected = value === selectedValue ? ' selected="selected"' : ""; 287 return `<option value="${value}"${selected}>${value}</option>`; 288 }) 289 .join(""); 290} 291 292function buildBookingChildrenAgeFieldsMarkup(childrenAges, copy) { 293 if (!childrenAges.length) return ""; 294 295 return childrenAges 296 .map((age, index) => ` 297 <label class="bookingGuests__ageField">
298 <span class="bookingGuests__ageLabel">${escapeBookingHtml(copy.childAgeLabel)} ${index + 1}</span> 299 <select class="bookingGuests__select" data-booking-child-age="${index}"> 300 ${buildBookingSelectOptions(0, BOOKING_MAX_CHILD_AGE, age)} 301 </select> 302 </label> 303 `) 304 .join(""); 305} 306 307function closeBookingDropdowns() { 308 if (typeof closeAllDropdowns === "function") { 309 closeAllDropdowns(); 310 return; 311 } 312 313 document.querySelectorAll(".js-form-dd .is-active").forEach((dropdown) => { 314 dropdown.classList.remove("is-active"); 315 }); 316} 317 318function updateBookingGuestsControl(container) { 319 const state = getBookingGuestsState(container); 320 const copy = getBookingGuestsCopy(); 321 const summary = container.querySelector("[data-booking-guests-summary]"); 322 const agesContainer = container.querySelector("[data-booking-children-ages]"); 323 324 if (summary) { 325 summary.textContent = formatBookingGuestsSummary(state); 326 } 327 328 if (agesContainer) { 329 agesContainer.innerHTML = buildBookingChildrenAgeFieldsMarkup( 330 state.childrenAges, 331 copy, 332 ); 333 agesContainer.hidden = state.childrenAges.length === 0; 334 335 agesContainer.querySelectorAll("[data-booking-child-age]").forEach((select) => { 336 select.addEventListener("change", () => { 337 const index = clampInteger( 338 select.dataset.bookingChildAge, 339 0, 340 BOOKING_MAX_CHILDREN - 1, 341 0, 342 ); 343 const nextState = getBookingGuestsState(container); 344 345 nextState.childrenAges[index] = clampInteger( 346 select.value, 347 0, 348 BOOKING_MAX_CHILD_AGE, 349 0, 350 ); 351 352 setBookingGuestsState(container, nextState); 353 updateBookingGuestsControl(container); 354 }); 355 }); 356 } 357} 358 359function bindBookingGuestsControl(container) { 360 const toggle = container.querySelector("[data-booking-guests-toggle]"); 361 const dropdown = container.querySelector(".searchFormItemDropdown"); 362 const adultsSelect = container.querySelector("[data-booking-adults]"); 363 const childrenSelect = container.querySelector("[data-booking-children]"); 364 365 if (toggle && dropdown) { 366 toggle.addEventListener("click", (event) => { 367 event.preventDefault(); 368 event.stopPropagation(); 369 370 const shouldOpen = !dropdown.classList.contains("is-active"); 371 closeBookingDropdowns(); 372 373 if (shouldOpen) { 374 dropdown.classList.add("is-active"); 375 } 376 377 clampDropdownNelViewport(dropdown); 378 }); 379 } 380 381 if (adultsSelect) { 382 adultsSelect.addEventListener("change", () => { 383 const nextState = getBookingGuestsState(container); 384 nextState.adults = clampInteger( 385 adultsSelect.value, 386 1, 387 BOOKING_MAX_ADULTS, 388 BOOKING_DEFAULT_OCCUPANCY.adults, 389 ); 390 391 setBookingGuestsState(container, nextState); 392 updateBookingGuestsControl(container); 393 }); 394 } 395 396 if (childrenSelect) { 397 childrenSelect.addEventListener("change", () => { 398 const nextChildrenCount = clampInteger( 399 childrenSelect.value, 400 0, 401 BOOKING_MAX_CHILDREN, 402 0, 403 ); 404 const nextState = getBookingGuestsState(container); 405 const nextChildrenAges = nextState.childrenAges.slice(0, nextChildrenCount); 406 407 while (nextChildrenAges.length < nextChildrenCount) { 408 nextChildrenAges.push(0); 409 } 410 411 nextState.childrenAges = nextChildrenAges; 412 setBookingGuestsState(container, nextState); 413 updateBookingGuestsControl(container); 414 }); 415 } 416 417 updateBookingGuestsControl(container); 418} 419 420function buildBookingGuestsDropdownMarkup(state, copy) { 421 return ` 422 <div class="searchFormItemDropdown -counter bookingGuests__dropdown"> 423 <div class="searchFormItemDropdown__container"> 424 <div class="searchFormItemDropdown__list bookingGuests__list"> 425 <div class="searchFormItemDropdown__item bookingGuests__row">
426 <span class="bookingGuests__rowLabel">${escapeBookingHtml(copy.adultsLabel)}</span> 427 <select class="bookingGuests__select" data-booking-adults> 428 ${buildBookingSelectOptions(1, BOOKING_MAX_ADULTS, state.adults)} 429 </select> 430 </div> 431 432 <div class="searchFormItemDropdown__item bookingGuests__row"> 433 <span class="bookingGuests__rowLabel">${escapeBookingHtml(copy.childrenLabel)}</span> 434 <select class="bookingGuests__select" data-booking-children> 435 ${buildBookingSelectOptions(0, BOOKING_MAX_CHILDREN, state.childrenAges.length)} 436 </select> 437 </div> 438 439 <div class="bookingGuests__ages" data-booking-children-ages${ 440 state.childrenAges.length ? "" : ' hidden="hidden"' 441 }> 442 ${buildBookingChildrenAgeFieldsMarkup(state.childrenAges, copy)} 443 </div> 444 </div> 445 </div> 446 </div> 447 `; 448} 449 450function renderBookingGuestsControl(container) { 451 const variant = container.dataset.bookingGuests || "hero"; 452 const state = getBookingGuestsState(container); 453 const copy = getBookingGuestsCopy(); 454 455 if (variant === "hero") { 456 container.classList.add("searchFormItem", "js-form-dd", "bookingGuests", "bookingGuests--hero"); 457 container.innerHTML = ` 458 <button type="button" class="searchFormItem__button bookingGuests__button" data-booking-guests-toggle> 459 <div class="bookingGuests__trigger"> 460 <span class="bookingGuests__eyebrow">${escapeBookingHtml(copy.guestsLabel)}</span> 461 <span class="bookingGuests__summary" data-booking-guests-summary>${escapeBookingHtml( 462 formatBookingGuestsSummary(state), 463 )}</span> 464 </div> 465 <i class="icon-arrow-down ml-10"></i> 466 </button> 467 ${buildBookingGuestsDropdownMarkup(state, copy)} 468 `; 469 } else { 470 container.classList.add("bookingGuests", "bookingGuests--sidebar"); 471 container.innerHTML = ` 472 <div class="searchForm -type-1 -dropdown-single bookingGuests__standalone"> 473 <div class="searchForm__form"> 474 <div class="searchFormItem js-form-dd bookingGuests__standaloneItem"> 475 <button type="button" class="searchFormItem__button bookingGuests__button" data-booking-guests-toggle> 476 <div class="bookingGuests__trigger"> 477 <span class="bookingGuests__eyebrow">${escapeBookingHtml(copy.guestsLabel)}</span> 478 <span class="bookingGuests__summary" data-booking-guests-summary>${escapeBookingHtml( 479 formatBookingGuestsSummary(state), 480 )}</span> 481 </div> 482 <i class="icon-chevron-down ml-40"></i> 483 </button> 484 ${buildBookingGuestsDropdownMarkup(state, copy)} 485 </div> 486 </div> 487 </div> 488 `; 489 } 490 491 bindBookingGuestsControl(container); 492} 493 494function initBookingGuestsControls() { 495 document.querySelectorAll("[data-booking-guests-control]").forEach((container) => { 496 renderBookingGuestsControl(container); 497 }); 498} 499 500function getSiteRoot() { 501 return document.documentElement.dataset.siteRoot || "./"; 502} 503 504// Radice del RAMO LINGUISTICO, che non coincide con quella del sito. 505// 506// Sotto /en/ le due divergono: gli asset (immagini, CSS, locales, accommodations.json) vivono 507// una volta sola nella radice reale, mentre i link di pagina devono restare dentro il ramo 508// inglese, altrimenti dal menu di /en/ristorante/ si finirebbe sulla home italiana. 509// getSiteRoot() serve gli asset, langPath() serve i link. 510function getLangRoot() { 511 const root = getSiteRoot(); 512 return isEnglishTree() ? `${root}en/` : root; 513} 514 515// La lingua di una pagina pre-renderizzata è decisa dal percorso, non da localStorage. 516function isEnglishTree() { 517 return /(^|\/)en(\/|$)/.test(window.location.pathname.replace(/[^/]*$/, "")); 518} 519 520function langPath(path = "") { 521 if (path == null) return path; 522 if (path === "") return getLangRoot(); 523 if ( 524 /^(?:[a-z]+:)?\/\//i.test(path) || 525 path.startsWith("data:") || 526 path.startsWith("mailto:") || 527 path.startsWith("tel:") || 528 path.startsWith("#") || 529 path.startsWith("/") 530 ) { 531 return path; 532 } 533 return `${getLangRoot()}${path.replace(/^\.?\//, "")}`; 534} 535 536window.langPath = langPath; 537window.isEnglishTree = isEnglishTree; 538 539function sitePath(path = "") { 540 if (path == null) return path; 541 if (path === "") return getSiteRoot(); 542 543 if ( 544 /^(?:[a-z]+:)?\/\//i.test(path) || 545 path.startsWith("data:") || 546 path.startsWith("mailto:") || 547 path.startsWith("tel:") || 548 path.startsWith("#") 549 ) { 550 return path; 551 } 552 553 return `${getSiteRoot()}${path.replace(/^\/+/, "").replace(/^\.\//, "")}`; 554} 555 556function resolveUrl(path, base = window.location.href) { 557 if (path == null || path === "") return path; 558 559 if ( 560 /^(?:[a-z]+:)?\/\//i.test(path) || 561 path.startsWith("data:") || 562 path.startsWith("mailto:") || 563 path.startsWith("tel:") || 564 path.startsWith("#") 565 ) { 566 return path; 567 } 568 569 return new URL(path, base).toString(); 570} 571
572let twoTorriMediaManifestPromise = null; 573const jsonResourcePromiseCache = new Map(); 574 575function normalizeTwoTorriAssetPath(assetPath = "") { 576 if (!assetPath) return null; 577 578 const decodedPath = decodeURIComponent(String(assetPath)); 579 const normalizedPath = decodedPath 580 .replace(/^https?:\/\/[^/]+\//i, "") 581 .replace(/^(\.\.\/)+/, "") 582 .replace(/^\.\//, ""); 583 584 const match = normalizedPath.match(/img\/2torri\/.+$/i); 585 return match ? match[0] : null; 586} 587 588function encodeAssetPath(path = "") { 589 return String(path) 590 .split("/") 591 .map((segment) => encodeURIComponent(decodeURIComponent(segment))) 592 .join("/") 593 .replace(/%2E/g, "."); 594} 595 596function buildTwoTorriVariantPath(assetPath, width, extension) { 597 const normalizedPath = normalizeTwoTorriAssetPath(assetPath); 598 if (!normalizedPath) return assetPath; 599 600 const parsed = normalizedPath.match(/^(.*)\.([^.]+)$/); 601 if (!parsed) return normalizedPath; 602 603 return `${parsed[1]}-w${width}.${extension}`; 604} 605 606async function loadTwoTorriMediaManifest() { 607 if (!twoTorriMediaManifestPromise) { 608 twoTorriMediaManifestPromise = fetchJsonResource( 609 sitePath("img/2torri/media-manifest.json"), 610 { cache: "no-cache" }, 611 ) 612 .then(({ data }) => data) 613 .catch(() => ({ images: {}, videos: {} })); 614 } 615 616 return twoTorriMediaManifestPromise; 617} 618 619function getTwoTorriImageMeta(assetPath, manifest = null) { 620 const normalizedPath = normalizeTwoTorriAssetPath(assetPath); 621 return normalizedPath ? manifest?.images?.[normalizedPath] || null : null; 622} 623 624function pickVariantWidth(meta, preferredWidth = 1280) { 625 if (!meta?.widths?.length) return null; 626 627 return ( 628 meta.widths.find((width) => width >= preferredWidth) || 629 meta.widths[meta.widths.length - 1] 630 ); 631} 632 633function getOptimizedTwoTorriImageUrl( 634 assetPath, 635 { manifest = null, preferredWidth = 1280, extension = null } = {}, 636) { 637 const normalizedPath = normalizeTwoTorriAssetPath(assetPath); 638 if (!normalizedPath) { 639 return resolveUrl(assetPath); 640 } 641 642 const meta = getTwoTorriImageMeta(normalizedPath, manifest); 643 if (!meta) { 644 return sitePath(encodeAssetPath(normalizedPath)); 645 } 646 647 const width = pickVariantWidth(meta, preferredWidth); 648 const targetExtension = extension || meta.fallbackExtension; 649 const variantPath = buildTwoTorriVariantPath(normalizedPath, width, targetExtension); 650 return sitePath(encodeAssetPath(variantPath)); 651} 652 653function getOptimizedTwoTorriVideoUrl(assetPath, manifest = null) { 654 const normalizedPath = normalizeTwoTorriAssetPath(assetPath); 655 if (!normalizedPath) { 656 return resolveUrl(assetPath); 657 } 658 659 const optimizedPath = manifest?.videos?.[normalizedPath]?.optimizedPath || normalizedPath; 660 return sitePath(encodeAssetPath(optimizedPath)); 661} 662 663function renderTwoTorriResponsivePicture({ 664 assetPath, 665 alt = "", 666 className = "", 667 sizes = "100vw", 668 preferredWidth = 1280, 669 widths = null, 670 loading = "lazy", 671 fetchpriority = null, 672 style = "", 673 title = "", 674 dataAttributes = "", 675 manifest = null, 676}) { 677 const meta = getTwoTorriImageMeta(assetPath, manifest); 678 const safeAlt = escapeBookingHtml(alt); 679 const safeTitle = title ? ` title="${escapeBookingHtml(title)}"` : ""; 680 const safeClassName = className ? ` class="${escapeBookingHtml(className)}"` : ""; 681 const safeStyle = style ? ` style="${escapeBookingHtml(style)}"` : ""; 682 const safeDataAttributes = dataAttributes ? ` ${dataAttributes.trim()}` : ""; 683 684 if (!meta) { 685 return `<img src="${getOptimizedTwoTorriImageUrl(assetPath)}" alt="${safeAlt}"${safeClassName}${safeStyle}${safeTitle}${safeDataAttributes} loading="${loading}" decoding="async">`; 686 } 687 688 const availableWidths = (widths || meta.widths).filter((width) => meta.widths.includes(width)); 689 const srcSetWidths = availableWidths.length ? availableWidths : meta.widths; 690 const fallbackWidth = pickVariantWidth(meta, preferredWidth); 691 const fallbackSrc = getOptimizedTwoTorriImageUrl(assetPath, { 692 manifest, 693 preferredWidth: fallbackWidth, 694 }); 695 const webpSrcSet = srcSetWidths 696 .map((width) => { 697 const url = getOptimizedTwoTorriImageUrl(assetPath, { 698 manifest, 699 preferredWidth: width, 700 extension: "webp", 701 }); 702 return `${url} ${width}w`; 703 }) 704 .join(", "); 705 const fallbackSrcSet = srcSetWidths 706 .map((width) => { 707 const url = getOptimizedTwoTorriImageUrl(assetPath, { 708 manifest, 709 preferredWidth: width, 710 }); 711 return `${url} ${width}w`; 712 }) 713 .join(", "); 714 const priorityAttributes = fetchpriority 715 ? ` fetchpriority="${fetchpriority}"` 716 : ` loading="${loading}" decoding="async"`; 717 718 return ` 719 <picture> 720 <source type="image/webp" srcset="${webpSrcSet}" sizes="${sizes}"> 721 <img src="${fallbackSrc}" srcset="${fallbackSrcSet}" sizes="${sizes}" width="${meta.width}" height="${meta.height}" alt="${safeAlt}"${safeClassName}${safeStyle}${safeTitle}${safeDataAttributes}${priorityAttributes}> 722 </picture> 723 `.trim(); 724} 725 726async function fetchJsonResource(path, options = { cache: "no-cache" }) { 727 const url = resolveUrl(path); 728 const fetchOptions = { ...options }; 729 const shouldMemoize = fetchOptions.memoize !== false; 730 delete fetchOptions.memoize; 731 732 if (shouldMemoize && jsonResourcePromiseCache.has(url)) { 733 return jsonResourcePromiseCache.get(url); 734 } 735 736 const requestPromise = (async () => { 737 const response = await fetch(url, fetchOptions); 738 739 if (!response.ok) { 740 throw new Error(`Impossibile caricare ${url}`); 741 } 742 743 return { 744 url, 745 data: await response.json(), 746 }; 747 })(); 748 749 if (shouldMemoize) { 750 jsonResourcePromiseCache.set(url, requestPromise); 751 } 752 753 try { 754 return await requestPromise; 755 } catch (error) { 756 if (shouldMemoize) { 757 jsonResourcePromiseCache.delete(url); 758 } 759 throw error; 760 } 761} 762 763function getAccommodationSection(pathname = window.location.pathname) { 764 const match = pathname.match(/(?:^|\/)(suites|rooms|suites-rooms)(?
764:\/|$)/); 765 return match ? match[1] : "suites-rooms"; 766} 767 768function getAccommodationType(room = {}) { 769 if (room.category === "suites" || room.category === "rooms") { 770 return room.category; 771 } 772 773 return /suite/i.test(room.name || "") ? "suites" : "rooms"; 774} 775 776function roomMatchesSection(room, section = getAccommodationSection()) { 777 return section === "suites-rooms" || getAccommodationType(room) === section; 778} 779 780function getAccommodationJsonPath(section = getAccommodationSection()) { 781 return sitePath("data/accommodations.json"); 782} 783 784function extractAccommodationItems(payload = {}) { 785 return payload.items || payload; 786} 787 788function getAccommodationGalleryImages(room = {}) { 789 const seen = new Set(); 790 const ordered = []; 791 792 const pushImage = (image) => { 793 if (!image || seen.has(image)) return; 794 seen.add(image); 795 ordered.push(image); 796 }; 797 798 pushImage(room.coverImage); 799 (room.images || []).forEach(pushImage); 800 801 return ordered; 802} 803 804function getAccommodationCoverImage(room = {}) { 805 return getAccommodationGalleryImages(room)[0] || null; 806} 807 808function getAccommodationLocalizedCopy(room = {}, lang = getCurrentLang()) { 809 return room.copy?.[lang] || room.copy?.it || room.copy?.en || {}; 810} 811 812function getAccommodationUiCopy(payload = {}, lang = getCurrentLang()) { 813 return payload.ui?.[lang] || payload.ui?.it || payload.ui?.en || {}; 814} 815 816// Il pannello di un dropdown e' posizionato rispetto al proprio contenitore, che si 817// trova in punti diversi a seconda del layout (widget hero, sidebar della pagina 818// dettaglio, popover dell'header) e della larghezza della finestra. Nessuna regola CSS 819// statica puo' conoscere quell'offset: senza questa correzione il pannello usciva dallo 820// schermo (fino a 76px a sinistra su mobile, 65px a destra nella sidebar a 1024px). 821// Vive qui, e non in main.js, perche' i dropdown si aprono da due punti diversi: 822// la delegation [data-x-click] e il toggle del selettore ospiti poco sopra. 823function clampDropdownNelViewport(el) { 824 if (!el || !el.classList.contains("searchFormItemDropdown")) return; 825 826 el.style.removeProperty("transform"); // misura sempre la posizione naturale 827 if (!el.classList.contains("is-active")) return; 828 829 const MARGINE = 8; 830 const rect = el.getBoundingClientRect(); 831 if (!rect.width) return; 832 833 let scarto = 0; 834 if (rect.right > window.innerWidth - MARGINE) { 835 scarto = window.innerWidth - MARGINE - rect.right; 836 } 837 if (rect.left + scarto < MARGINE) { 838 scarto = MARGINE - rect.left; // il bordo sinistro ha la precedenza 839 } 840 841 if (scarto) el.style.transform = `translateX(${Math.round(scarto)}px)`; 842} 843 844function isValidBookingDate(value) { 845 if (!/^\d{4}-\d{2}-\d{2}$/.test(value || "")) return false; 846 847 // Il formato da solo non basta: le date arrivano anche dalla querystring 848 // (?arrivo=/?partenza=, propagate tra le pagine da rooms-list.js), che bypassa 849 // il calendario. Senza questo controllo un link con una data passata raggiunge 850 // il booking engine. 851 const [year, month, day] = value.split("-").map(Number); 852 const date = new Date(year, month - 1, day); 853 854 // scarta anche le date sintatticamente valide ma inesistenti (es. 2026-02-31) 855 if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) { 856 return false; 857 } 858 859 const now = new Date(); 860 if (date < new Date(now.getFullYear(), now.getMonth(), now.getDate())) return false; 861 862 // ...e quelle oltre l'ultima prenotabile: il calendario le disabilita già , ma la 863 // querystring lo bypassa e finirebbe sul booking engine senza disponibilità . 864 const [lastYear, lastMonth, lastDay] = BOOKING_LAST_BOOKABLE_DATE.split("-").map(Number); 865 return date <= new Date(lastYear, lastMonth - 1, lastDay); 866} 867 868function findBookingCalendarScope(trigger) { 869 const candidates = [ 870 trigger?.closest(".header__bookPopover"), 871 trigger?.closest(".pageHero__filter"), 872 trigger?.closest("section"), 873 document, 874 ].filter(Boolean); 875 876 return candidates.find((scope) => scope.querySelector?.(".js-calendar")) || document; 877} 878 879function getCalendarBookingDates(scope = document) { 880 const activeCells = Array.from( 881 scope.querySelectorAll(".js-calendar .elCalendar__sell.-is-active"), 882 ) 883 .sort((a, b) => { 884 const aIndex = Number.parseInt(a.dataset.index || "0", 10); 885 const bIndex = Number.parseInt(b.dataset.index || "0", 10); 886 return aIndex - bIndex; 887 }) 888 .slice(0, 2); 889 890 if (activeCells.length < 2) { 891 return { arrival: null, departure: null }; 892 } 893 894 const toIsoDate = (cell) => { 895 const year = cell.dataset.year;
896 const month = cell.dataset.monthNumber?.padStart(2, "0"); 897 const day = cell.querySelector(".js-date")?.textContent?.trim()?.padStart(2, "0"); 898 899 if (!year || !month || !day) return null; 900 901 const isoDate = `${year}-${month}-${day}`; 902 return isValidBookingDate(isoDate) ? isoDate : null; 903 }; 904 905 const arrival = toIsoDate(activeCells[0]); 906 const departure = toIsoDate(activeCells[1]); 907 908 return { 909 arrival, 910 departure, 911 }; 912} 913 914function findBookingGuestsScope(trigger) { 915 const candidates = [ 916 trigger?.closest(".header__bookPopover"), 917 trigger?.closest(".pageHero__filter"), 918 trigger?.closest(".sidebar"), 919 trigger?.closest("section"), 920 document, 921 ].filter(Boolean); 922 923 return ( 924 candidates.find((scope) => 925 scope.querySelector?.("[data-booking-guests-control]"), 926 ) || document 927 ); 928} 929 930function getBookingContext(trigger) { 931 const searchParams = new URLSearchParams(window.location.search); 932 const calendarDates = getCalendarBookingDates(findBookingCalendarScope(trigger)); 933 const occupancy = getBookingGuestsState( 934 findBookingGuestsScope(trigger).querySelector?.("[data-booking-guests-control]"), 935 ); 936 937 return { 938 arrival: 939 calendarDates.arrival || 940 (isValidBookingDate(searchParams.get("arrivo")) ? searchParams.get("arrivo") : null), 941 departure: 942 calendarDates.departure || 943 (isValidBookingDate(searchParams.get("partenza")) ? searchParams.get("partenza") : null), 944 bookingEngId: 945 trigger?.dataset.bookingEngId || 946 searchParams.get("oidTipoCameraOTA") || 947 null, 948 occupancy, 949 }; 950} 951 952function buildBookingUrl({ 953 lang = getCurrentLang(), 954 arrival = null, 955 departure = null, 956 bookingEngId = null, 957 occupancy = getDefaultBookingOccupancy(), 958} = {}) { 959 const url = new URL(BOOKING_BASE_URL); 960 url.searchParams.set("oidPortale", BOOKING_PORTAL_ID); 961 url.searchParams.set("lingua", lang); 962 963 // La partenza da sola non è un contesto sensato: se l'arrivo è stato scartato 964 // (formato errato o data passata) si omettono entrambe e il booking engine 965 // riparte dai propri default. 966 if (isValidBookingDate(arrival)) { 967 url.searchParams.set("arrivo", arrival); 968 969 if (isValidBookingDate(departure)) { 970 url.searchParams.set("partenza", departure); 971 } 972 } 973 974 if (bookingEngId) { 975 url.searchParams.set("oidTipoCameraOTA", bookingEngId); 976 } 977 978 url.searchParams.set("camere", buildBookingRoomsValue(occupancy)); 979 980 return url.toString(); 981} 982 983function appendBookingContextToUrl(urlString, context = getBookingContext()) { 984 const url = new URL(urlString, window.location.href); 985 986 if (isValidBookingDate(context.arrival)) { 987 url.searchParams.set("arrivo", context.arrival); 988 } 989 990 if (isValidBookingDate(context.departure)) { 991 url.searchParams.set("partenza", context.departure); 992 } 993 994 if (context.bookingEngId && !url.searchParams.get("oidTipoCameraOTA")) { 995 url.searchParams.set("oidTipoCameraOTA", context.bookingEngId); 996 } 997 998 url.searchParams.set( 999 "camere", 1000 buildBookingRoomsValue(context.occupancy), 1001 ); 1002 1003 return url.toString(); 1004} 1005 1006// Il passaggio al booking engine deve avvenire tramite un <a href>: l'auto-linker di 1007// GA4/GTM decora con il parametro _gl solo i click sugli anchor e i submit dei form, 1008// mai le chiamate a window.open(). Con window.open ogni sessione sul booking engine 1009// risultava come nuovo utente proveniente da referral hotel2torri.com, interrompendo 1010// l'attribuzione della sorgente originaria. 1011// L'anchor viene creato e cliccato dentro il gesto dell'utente, quindi i browser non 1012// lo trattano come popup da bloccare. 1013function apriBookingEngine(bookingUrl) { 1014 const link = document.createElement("a"); 1015 link.href = bookingUrl; 1016 link.target = "_blank"; 1017 link.rel = "noopener"; 1018 link.style.display = "none"; 1019 1020 document.body.appendChild(link); 1021 1022 try { 1023 // L'auto-linker di GA4 si aggancia a mousedown/keyup, NON a click: un link.click() 1024 // secco non viene mai decorato con _gl e la sessione si spezza sul booking engine. 1025 // Il mousedown fa riscrivere l'href al linker; il click successivo naviga sull'URL 1026 // già decorato. Verificato in produzione il 2026-07-21. 1027 link.dispatchEvent( 1028 new MouseEvent("mousedown", { bubbles: true, cancelable: true, view: window }) 1029 ); 1030 } catch (e) { 1031 /* il tracciamento non deve mai bloccare la prenotazione */ 1032 } 1033 1034 try { 1035 link.click(); 1036 } finally { 1037 link.remove(); 1038 } 1039} 1040window.apriBookingEngine = apriBookingEngine; 1041 1042function trackBookingClick(detail = {}) {
1043 // Spinge nel dataLayer di GTM l'uscita verso il booking engine esterno 1044 // (Passepartout): questi passaggi non sono rilevati automaticamente perché 1045 // avvengono via window.open()/redirect JS e non su <a href> esterni. 1046 // In GTM: trigger "Custom Event = booking_engine_open" -> tag GA4 di evento. 1047 window.dataLayer = window.dataLayer || []; 1048 1049 const params = { 1050 booking_engine: "passepartout", 1051 language: detail.lang || getCurrentLang(), 1052 source_page: window.location.pathname, 1053 }; 1054 1055 if (detail.bookingEngId) params.booking_engine_id = String(detail.bookingEngId); 1056 if (detail.roomId) params.item_id = detail.roomId; 1057 if (detail.roomName) params.item_name = detail.roomName; 1058 if (detail.arrival) params.arrival = detail.arrival; 1059 if (detail.departure) params.departure = detail.departure; 1060 if (detail.bookingUrl) params.destination_url = detail.bookingUrl; 1061 // eventCallback/eventTimeout sono chiavi speciali gestite da GTM. 1062 if (typeof detail.eventCallback === "function") { 1063 params.eventCallback = detail.eventCallback; 1064 params.eventTimeout = 1000; 1065 } 1066 1067 try { 1068 // booking_engine_open = uscita verso il booking engine, non il checkout vero 1069 // (quello avviene su Passepartout, che emette il proprio begin_checkout). 1070 // à una micro-conversione di metà funnel: NON marcarla come evento chiave 1071 // in GA4, altrimenti il ricavo per conversione risulta falsato e Google Ads 1072 // ottimizza sui passaggi al booking invece che sulle prenotazioni. 1073 window.dataLayer.push({ event: "booking_engine_open", ...params }); 1074 } catch (e) { 1075 /* il tracciamento non deve mai bloccare la prenotazione */ 1076 } 1077} 1078window.trackBookingClick = trackBookingClick; 1079 1080function getAccommodationListPath(section = getAccommodationSection()) { 1081 const targetSection = 1082 section === "suites" || section === "rooms" ? section : "suites-rooms"; 1083 return langPath(`${targetSection}/`); 1084} 1085 1086function getAccommodationDetailPath(roomId, room, section = getAccommodationSection()) { 1087 // URL pulita e indicizzabile: /rooms/<slug>/ oppure /suites/<slug>/ 1088 // (la categoria reale della camera, non la sezione di provenienza). 1089 const targetSection = getAccommodationType(room); 1090 const searchParams = new URLSearchParams(); 1091 const { arrival, departure, occupancy } = getBookingContext(); 1092 1093 if (isValidBookingDate(arrival)) { 1094 searchParams.set("arrivo", arrival); 1095 } 1096 1097 if (isValidBookingDate(departure)) { 1098 searchParams.set("partenza", departure); 1099 } 1100 1101 if (room?.bookingEngID) { 1102 searchParams.set("oidTipoCameraOTA", String(room.bookingEngID)); 1103 } 1104 1105 searchParams.set("camere", buildBookingRoomsValue(occupancy)); 1106 1107 const query = searchParams.toString(); 1108 return `${langPath(`${targetSection}/${roomId}/`)}${query ? `?${query}` : ""}`; 1109} 1110 1111function loadNavbar() { 1112 const navbar = document.getElementsByClassName("navbar")[0]; 1113 const homeHref = langPath(); 1114 const suitesHref = langPath("suites/"); 1115 const roomsHref = langPath("rooms/"); 1116 const restaurantHref = langPath("ristorante/"); 1117 const experiencesHref = langPath("esperienze/"); 1118 const eventsHref = langPath("eventi/"); 1119 const contactsHref = langPath("contatti/"); 1120 const faqHref = langPath("faq.html"); 1121 const storyHref = langPath("una-storia-di-famiglia/"); 1122 const locationHref = langPath("location/"); 1123 const beachPoolHref = langPath("spiaggia-e-piscina/"); 1124 const breakfastHref = langPath("colazione/"); 1125 const bistrotHref = langPath("bistrot/"); 1126 const cinquantaHref = langPath("cinquanta/"); 1127 const whiteLogoSrc = sitePath("img/2torri/logo-white.png"); 1128 const blackLogoSrc = sitePath("img/2torri/logo-black.png"); 1129 const posterSrc = sitePath("img/2torri/poster-w1280.jpg"); 1130 const menuBgVideoSrc = sitePath("img/2torri/menu_bg_compressed-opt.mp4"); 1131 1132 navbar.innerHTML = ` 1133 <div class="menuFullScreen js-menuFullScreen"> 1134 <div class="menuFullScreen__topMobile js-menuFullScreen-topMobile"> 1135 <div class="d-flex items-center text-white js-menuFullScreen-toggle"> 1136 <i class="icon-menu text-9"></i> 1137 <div class="text-15 uppercase ml-30 sm:d-none">Menu</div> 1138 </div> 1139 1140 <div class=""> 1141 <img src="${whiteLogoSrc}" alt="logo" style="max-height: 50px;"> 1142 </div> 1143 1144 <select class="languageSwitcher" aria-label="Seleziona lingua" style="color: white;"> 1145 <option value="it">IT</option> 1146 <option value="en">EN</option> 1147 </select> 1148 </div> 1149 1150 <div class="menuFullScreen__mobile__bg js-menuFullScreen-mobile-bg"></div> 1151 1152 <div class="menuFullScreen__left"> 1153 <div class="menuFullScreen__bg js-menuFullScreen-bg"> 1154 <video muted loop playsinline webkit-playsinline preload="none" aria-label="Sfondo video menu" data-menu-video data-
1154poster="${posterSrc}"> 1155 <source data-src="${menuBgVideoSrc}" type="video/mp4"> 1156 </video> 1157 </div> 1158 1159 <button class="menuFullScreen__close js-menuFullScreen-toggle js-menuFullScreen-close-btn"> 1160 <span class="icon"> 1161 <span></span> 1162 <span></span> 1163 </span> 1164 CLOSE 1165 </button> 1166 1167 <div class="menuFullScreen-links js-menuFullScreen-links"> 1168 1169 <div class="menuFullScreen-links__item"> 1170 <a href="${homeHref}" data-i18n="nav.home">HOME</a> 1171 </div> 1172 1173 <div class="menuFullScreen-links__item js-menuFullScreen-has-children"> 1174 <button 1175 type="button" 1176 class="menuFullScreen-links__trigger" 1177 aria-expanded="false" 1178 aria-label="Apri il sottomenu Hotel" 1179 data-i18n="nav.dueTorri" 1180 > 1181 DUE TORRI 1182 </button> 1183 1184 <div class="menuFullScreen-links-subnav js-menuFullScreen-subnav"> 1185 <div class="menuFullScreen-links-subnav__item"> 1186 <a href="${storyHref}" data-i18n="nav.family">Una storia di famiglia</a> 1187 </div> 1188 1189 <div class="menuFullScreen-links-subnav__item"> 1190 <a href="${locationHref}" data-i18n="nav.location">Posizione</a> 1191 </div> 1192 1193 <div class="menuFullScreen-links-subnav__item"> 1194 <a href="${beachPoolHref}" data-i18n="nav.pool">Piscina & Solarium</a> 1195 </div> 1196 1197 </div> 1198 1199 </div> 1200 1201 <div class="menuFullScreen-links__item js-menuFullScreen-has-children"> 1202 <button 1203 type="button" 1204 class="menuFullScreen-links__trigger" 1205 aria-expanded="false" 1206 aria-label="Apri il sottomenu Hotel" 1207 data-i18n="nav.accommodation" 1208 > 1209 CAMERE & SUITES 1210 </button> 1211 1212 <div class="menuFullScreen-links-subnav js-menuFullScreen-subnav"> 1213 <div class="menuFullScreen-links-subnav__item"> 1214 <a href="${suitesHref}" data-i18n="nav.suites">Suites</a> 1215 </div> 1216 1217 <div class="menuFullScreen-links-subnav__item"> 1218 <a href="${roomsHref}" data-i18n="nav.rooms">Camere</a> 1219 </div> 1220 </div> 1221 </div> 1222 1223 <div class="menuFullScreen-links__item js-menuFullScreen-has-children"> 1224 <button 1225 type="button" 1226 class="menuFullScreen-links__trigger" 1227 aria-expanded="false" 1228 aria-label="Apri il sottomenu Hotel" 1229 data-i18n="nav.dining" 1230 > 1231 RISTORANTI & BAR 1232 </button> 1233 1234 <div class="menuFullScreen-links-subnav js-menuFullScreen-subnav"> 1235 <div class="menuFullScreen-links-subnav__item"> 1236 <a href="${breakfastHref}" data-i18n="nav.breakfast">La colazione</a> 1237 </div> 1238 1239 <div class="menuFullScreen-links-subnav__item"> 1240 <a href="${restaurantHref}" data-i18n="nav.oltremare">Oltremare</a> 1241 </div> 1242 1243 <div class="menuFullScreen-links-subnav__item"> 1244 <a href="${bistrotHref}" data-i18n="nav.bistrot">Bistrot Donna Emilia</a> 1245 </div> 1246 1247 <div class="menuFullScreen-links-subnav__item"> 1248 <a href="${cinquantaHref}" data-i18n="nav.cinquanta">Cinquanta con vista</a> 1249 </div> 1250 </div> 1251 </div> 1252 1253 <div class="menuFullScreen-links__item"> 1254 <a href="${eventsHref}" data-i18n="nav.events">EVENTI</a> 1255 </div> 1256 1257 <!-- <div class="menuFullScreen-links__item"> 1258 <a href="${experiencesHref}">SPECIAL STAY</a> 1259 </div> --> 1260 1261 <div class="menuFullScreen-links__item"> 1262 <a href="${contactsHref}" data-i18n="nav.contact">CONTATTI</a> 1263 </div> 1264 </div> 1265 </div> 1266 1267 <div class="menuFullScreen__bottomMobile js-menuFullScreen-buttomMobile"> 1268 <button class="button rounded-200 w-1/1 py-20 -light-1 bg-accent-2" data-i18n="commons.bookNow"> 1269 PRENOTA ORA 1270 </button> 1271 1272 <a href="tel:+39089877699" class="d-flex items-center mt-40"> 1273 <i class="icon-phone mr-10"></i>
1274 <span>(+39) 089 87 76 99</span> 1275 </a> 1276 1277 <a href="${MAPS_URL}" target="_blank" rel="noopener noreferrer" class="d-flex mt-20"> 1278 <i class="icon-map mr-10"></i> 1279 <span> 1280 Via Diego Taiani, 3 - 84010 Maiori (SA) 1281 </span> 1282 </a> 1283 1284 <a href="mailto:[email protected]" class="d-flex items-center mt-20"> 1285 <i class="icon-mail mr-10"></i> 1286 <span>[email protected]</span> 1287 </a> 1288 </div> 1289 </div> 1290 `; 1291} 1292 1293function loadHeader() { 1294 const header = document.getElementsByClassName("headerWrapper")[0]; 1295 const homeHref = langPath(); 1296 const whiteLogoSrc = sitePath("img/2torri/logo-white.png"); 1297 const blackLogoSrc = sitePath("img/2torri/logo-black.png"); 1298 const oltremareLogoSrc = sitePath("img/2torri/oltremare/logo-white.png"); 1299 const oltremareLogoDarkSrc = sitePath("img/2torri/oltremare/logo.png"); 1300 const isHomePage = document.body.classList.contains("home-page"); 1301 const isLocationPage = /(?:^|\/)location\/?$/.test(window.location.pathname); 1302 const buildHeaderMarkup = ({ headerClasses, logoSrc, stickyLogoSrc = blackLogoSrc, isLight = false, bgClass }) => ` 1303 <header class="${headerClasses}" data-add-bg="${bgClass}" data-x="header" data-x-toggle="-is-menu-opened"> 1304 <div class="header__container"> 1305 <div class="header__left"> 1306 <button class="header__menuButton js-menuFullScreen-toggle" aria-label="Apri il menu"> 1307 <i class="header__menuIcon icon-menu text-9${isLight ? "" : " text-white"}"></i> 1308 <span class="header__menuLabel text-15 uppercase${isLight ? "" : " text-white"} sm:d-none">Menu</span> 1309 </button> 1310 1311 <select class="languageSwitcher header__languageSwitcher${isLight ? "" : " text-white"} xl:d-none" aria-label="Seleziona lingua"> 1312 <option value="it">IT</option> 1313 <option value="en">EN</option> 1314 </select> 1315 </div> 1316 1317 <div class="header__center"> 1318 <a href="${homeHref}" class="header__homeLink" aria-label="Torna alla home"> 1319 <div class="header__logo"> 1320 <img class="header__logoImage" src="${logoSrc}" data-logo-default="${logoSrc}" data-logo-sticky="${stickyLogoSrc}" alt="Hotel Due Torri"> 1321 </div> 1322 </a> 1323 </div> 1324 1325 <div class="header__right"> 1326 <button class="button header__bookButton mr-0" data-i18n="commons.bookNowShort" data-x-click="bookHeaderForm">Prenota</button> 1327 1328 <div class="header__bookPopover" data-x="bookHeaderForm" data-x-toggle="is-active"> 1329 <div class="searchForm -type-1 rounded-200 -cols-2"> 1330 <div class="searchForm__form"> 1331 <div class="searchFormItem js-select-control js-form-dd js-calendar"> 1332 <div class="searchFormItem__button" data-x-click="calendar-header"> 1333 <div> 1334 <span class="js-first-date">Check In - Check Out</span> 1335 <span class="js-last-date"></span> 1336 </div> 1337 <i class="icon-arrow-down ml-10"></i> 1338 </div> 1339 1340 <div class="searchFormItemDropdown -calendar" data-x="calendar-header" data-x-toggle="is-active"> 1341 <div class="searchFormItemDropdown__container"> 1342 <div class="searchMenu-date -searchForm js-form-dd js-calendar-el"> 1343 <div class="searchMenu-date__field shadow-2" data-x-dd="searchMenu-date-header" data-x-dd-toggle="-is-active"> 1344 <div class="rounded-4"> 1345 <div class="elCalendar js-calendar-el-calendar"></div> 1346 </div> 1347 </div> 1348 </div> 1349 </div> 1350 </div> 1351 </div> 1352 1353 <div data-booking-guests="hero" data-booking-guests-control></div> 1354 </div> 1355 1356 <div class="searchForm__button"> 1357 <button id="bookBtnHeader" class="button -accent-1 bg-accent-2 size-50 rounded-full">Go</button> 1358 </div> 1359 </div> 1360 </div> 1361 </div> 1362 </div> 1363 </header> 1364 `; 1365 1366 if (header) { 1367 const headerClasses = isHomePage 1368 ? "header -h-110 -mx-60 -home-transparent -border-bottom-1 js-
1368header" 1369 : "header -h-110 -mx-60 -blur -border-bottom-1 js-header"; 1370 1371 header.innerHTML = buildHeaderMarkup({ 1372 headerClasses, 1373 logoSrc: whiteLogoSrc, 1374 stickyLogoSrc: blackLogoSrc, 1375 bgClass: "bg-accent-1", 1376 }); 1377 } else { 1378 const header = document.getElementsByClassName("headerWrapperBlack")[0]; 1379 1380 if (header) { 1381 1382 header.innerHTML = buildHeaderMarkup({ 1383 headerClasses: `header -h-110 -mx-60 ${isLocationPage ? "-no-blur" : "-blur"} -border-bottom-2 -light js-header`, 1384 logoSrc: blackLogoSrc, 1385 stickyLogoSrc: blackLogoSrc, 1386 isLight: true, 1387 bgClass: "bg-accent-1", 1388 }); 1389 } else { 1390 const header = document.getElementsByClassName("oltremare-headerWrapper")[0]; 1391 1392 header.innerHTML = buildHeaderMarkup({ 1393 headerClasses: "header -h-110 -mx-60 -border-bottom-2 -light -no-blur -oltremare-hero js-header", 1394 logoSrc: oltremareLogoSrc, 1395 stickyLogoSrc: oltremareLogoDarkSrc, 1396 isLight: false, 1397 bgClass: "bg-accent-1", 1398 }); 1399 } 1400 } 1401 1402 const renderedHeader = document.querySelector(".js-header"); 1403 const logoImage = renderedHeader?.querySelector(".header__logoImage"); 1404 if (!renderedHeader || !logoImage || logoImage.dataset.logoBound === "true") return; 1405 1406 const syncHeaderLogoVariant = () => { 1407 const nextSrc = renderedHeader.classList.contains("is-sticky") 1408 ? logoImage.dataset.logoSticky 1409 : logoImage.dataset.logoDefault; 1410 1411 if (nextSrc && logoImage.getAttribute("src") !== nextSrc) { 1412 logoImage.setAttribute("src", nextSrc); 1413 } 1414 }; 1415 1416 logoImage.dataset.logoBound = "true"; 1417 syncHeaderLogoVariant(); 1418 1419 const observer = new MutationObserver(syncHeaderLogoVariant); 1420 observer.observe(renderedHeader, { attributes: true, attributeFilter: ["class"] }); 1421} 1422 1423function loadFooter() { 1424 const footer = document.getElementsByClassName("footerWrapper")[0]; 1425 if (!footer) return; 1426 1427 const homeHref = langPath(); 1428 const suitesHref = langPath("suites/"); 1429 const roomsHref = langPath("rooms/"); 1430 const restaurantHref = langPath("ristorante/"); 1431 const experiencesHref = langPath("esperienze/"); 1432 const eventsHref = langPath("eventi/"); 1433 const contactsHref = langPath("contatti/"); 1434 const faqHref = langPath("faq.html"); 1435 const storyHref = langPath("una-storia-di-famiglia/"); 1436 const locationHref = langPath("location/"); 1437 const beachPoolHref = langPath("spiaggia-e-piscina/"); 1438 const breakfastHref = langPath("colazione/"); 1439 const bistrotHref = langPath("bistrot/"); 1440 const cinquantaHref = langPath("cinquanta/"); 1441 const hotelPolicyHref = langPath("politica-hotel/"); 1442 const privacyPolicyHref = langPath("privacy-policy/"); 1443 const cookiePolicyHref = langPath("cookie-policy/"); 1444 const careersHref = "#!"; 1445 const creditsHref = "#!"; 1446 const pressHref = "#!"; 1447 const whiteLogoSrc = sitePath("img/2torri/logo-white.png"); 1448 1449 footer.innerHTML = ` 1450 <footer class="footer -type-1 bg-accent-1 text-white"> 1451 <div class="footer__main"> 1452 <div class="container"> 1453 <div class="footer__grid"> 1454 <div class=""> 1455 <h4 class="text-30 fw-500 text-white">Contact</h4> 1456 1457 <div class="d-flex flex-column mt-60 md:mt-20"> 1458 <div class=""> 1459 <a class="d-block text-15 text-white-60 lh-17" href="${MAPS_URL}" target="_blank" rel="noopener noreferrer"> 1460 Via Diego Taiani, 3 - 84010 Maiori (SA) 1461 </a> 1462 </div> 1463 <div class="mt-25"> 1464 <a class="d-block text-15 text-white-60" href="mailto:[email protected]"> 1465 [email protected] 1466 </a> 1467 </div> 1468 <div class="mt-10"> 1469 <a class="d-block text-15 text-white-60" href="tel:+39089877699"> 1470 +39 089 87 76 99 1471 </a> 1472 </div> 1473 </div> 1474 </div> 1475 1476 <div class=""> 1477 <h4 class="text-30 fw-500 text-white">Links</h4> 1478 1479 <div class="footerLinks mt-60 md:mt-20"> 1480 <div class="footerLinks__group"> 1481 <div class="footerLinks__title" data-i18n="footer.explore">Esplora</div> 1482 <div class="footerLinks__items text-15 text-white-60"> 1483 <a class="d-block" href="${homeHref}" data-i18n="nav.home">Home</a> 1484 <!-- <a class="d-block" href="${experiencesHref}">Special Stay</a> --> 1485 <a class="d-block" href="${eventsHref}" data-i18n="nav.events">Eventi</a> 1486 <a class="d-block" href="${contactsHref}" data-i18n="nav.contact">Contatti</a> 1487 </div> 1488 </div> 1489 1490 <div class="footerLinks__group"> 1491 <div class="footerLinks__title" data-i18n="nav.dueTorri">Due Torri</div> 1492 <div class="footerLinks__items text-15 text-white-60"> 1493 <a class="d-block" href="${storyHref}" data-i18n="nav.family">Una storia di famiglia</a> 1494 <a class="d-block" href="${locationHref}" data-i18n="nav.location">Posizione</a> 1495 <a class="d-block" href="${beachPoolHref}" data-i18n="nav.pool">Piscina & Solarium</a> 1496 </div> 1497 </div> 1498 1499 <div class="footerLinks__group"> 1500 <div class="footerLinks__title" data-i18n="nav.accommodation">Camere & Suites</div> 1501 <div class="footerLinks__items text-15 text-white-60"> 1502 <a class="d-block" href="${suitesHref}" data-i18n="nav.suites">Suites</a> 1503 <a class="d-block" href="${roomsHref}" data-i18n="nav.rooms">Camere</a> 1504 </div> 1505 </div> 1506 1507 <div class="footerLinks__group"> 1508 <div class="footerLinks__title" data-i18n="nav.dining">Ristoranti & Bar</div> 1509 <div class="footerLinks__items text-15 text-white-60"> 1510 <a class="d-block" href="${breakfastHref}" data-i18n="nav.breakfast">La colazione</a> 1511 <a class="d-block" href="${restaurantHref}" data-i18n="nav.oltremare">Oltremare</a> 1512 <a class="d-block" href="${bistrotHref}" data-i18n="nav.bistrot">Bistrot Donna Emilia</a> 1513 <a class="d-block" href="${cinquantaHref}" data-i18n="nav.cinquanta">Cinquanta con vista</a> 1514 </div> 1515 </div> 1516 1517 <div class="footerLinks__group"> 1518 <div class="footerLinks__title">Policies</div> 1519 <div class="footerLinks__items text-15 text-white-60"> 1520 <a class="d-block" href="${hotelPolicyHref}">Hotel Policy</a> 1521 <a class="d-block" href="${privacyPolicyHref}">Privacy Policy</a> 1522 <a class="d-block" href="${cookiePolicyHref}">Cookie Policy</a> 1523 <!-- href reale, non "#": se js/prefs-panel.js viene bloccato (estensioni 1524 anti-cookie-notice) la funzione non esiste, il preventDefault non 1525 scatta e il link porta comunque alla cookie policy invece di restare 1526 inerte. Il preventDefault avviene solo se il pannello è disponibile. --> 1527 <a class="d-block" href="${cookiePolicyHref}" onclick="if(window.openCookiePreferences){event.preventDefault();window.openCookiePreferences();}" data-i18n="footer.cookiePrefs">Preferenze cookie</a> 1528 <a class="d-block" href="${careersHref}">Carrers</a> 1529 <a class="d-block" href="${creditsHref}">Credits</a> 1530 </div> 1531 </div> 1532 1533 <div class="footerLinks__group"> 1534 <div class="footerLinks__title">Resources</div> 1535 <div class="footerLinks__items text-15 text-white-60"> 1536 <a class="d-block" href="${faqHref}">FAQ</a> 1537 <a class="d-block" href="${pressHref}">Press</a> 1538 </div> 1539 </div> 1540 </div> 1541 </div> 1542 </div> 1543 </div> 1544 </div> 1545 1546 <div class="footer__bottom"> 1547 <div class="container"> 1548 <div class="row y-gap-30 justify-between md:justify-center items-center"> 1549 <div class="col-md-auto"> 1550 <div class="text-15 text-center text-white-60">Copyright © ${new Date().getFullYear()} by <a href="https://www.studiocalling.it">Studio Calling</a> Creative Hub</div> 1551 </div> 1552 1553 <div class="col-md-auto"> 1554 <div class="footer__bottom_center"> 1555 <div class="d-flex justify-center"> 1556 <img src="${whiteLogoSrc}" alt="logo" style="max-height: 50px;"> 1557 </div> 1558 </div> 1559 </div> 1560 1561 <div class="col-md-auto"> 1562 <div class="row x-gap-25 y-gap-10 items-center justify-center"> 1563 1564 <div class="col-auto"> 1565 <a href="https://www.facebook.com/HotelClub2Torri" target="_blank" class="d-block text-white-60"> 1566 <i class="icon-facebook text-11"></i> 1567 </a> 1568 </div> 1569 1570 <div class="col-auto"> 1571 <a href="https://www.instagram.com/hotelclub2torri/" target="_blank" class="d-block text-white-60"> 1572 <i class="icon-instagram text-11"></i> 1573 </a> 1574 </div> 1575 1576 </div> 1577 </div> 1578 </div> 1579 </div> 1580 </div> 1581 </footer> 1582 `; 1583} 1584 1585const SUPPORTED = ["it", "en"]; 1586const DEFAULT_LANG = "it"; 1587const STORAGE_KEY = "site_lang"; 1588const SWITCHER_SELECTOR = ".languageSwitcher, #languageSwitcher"; 1589 1590function getNested(obj, path) { 1591 return path 1592 .split(".") 1593 .reduce( 1594 (acc, key) => (acc && acc[key] != null ? acc[key] : undefined), 1595 obj, 1596 ); 1597} 1598 1599function getLangFromUrl() { 1600 // opzionale: ?lang=en 1601 const params = new URLSearchParams(window.location.search); 1602 const lang = params.get("lang"); 1603 return lang && SUPPORTED.includes(lang) ? lang : null; 1604} 1605 1606function getBrowserLang() { 1607 const raw = (navigator.language || "").slice(0, 2).toLowerCase(); 1608 return SUPPORTED.includes(raw) ? raw : null; 1609} 1610 1611function resolveInitialLang() { 1612 // Il percorso vince su tutto il resto. Le pagine sotto /en/ sono pre-renderizzate in 1613 // inglese: se qui prevalesse localStorage, un visitatore con site_lang="it" in memoria 1614 // si vedrebbe RITRADURRE in italiano una pagina inglese, sopra un HTML e un canonical 1615 // che dicono il contrario. à la trappola principale di questa architettura. 1616 if (isEnglishTree()) return "en"; 1617 1618 return ( 1619 getLangFromUrl() || 1620 localStorage.getItem(STORAGE_KEY) || 1621 getBrowserLang() || 1622 DEFAULT_LANG 1623 ); 1624} 1625 1626// Percorso della stessa pagina nell'altra lingua. Gli alberi sono speculari, quindi basta 1627// aggiungere o togliere il segmento /en/ iniziale, conservando query e frammento. 1628function counterpartUrl(lang) { 1629 const { pathname, search, hash } = window.location; 1630 const nudo = pathname.replace(/^\/en(?=\/|$)/, "") || "/";
1631 const nuovo = lang === "en" ? `/en${nudo === "/" ? "/" : nudo}` : nudo; 1632 // ?lang= non serve più: la lingua è nel percorso 1633 const query = new URLSearchParams(search); 1634 query.delete("lang"); 1635 const qs = query.toString(); 1636 return `${nuovo}${qs ? `?${qs}` : ""}${hash}`; 1637} 1638window.counterpartUrl = counterpartUrl; 1639 1640async function loadLocale(lang) { 1641 const localePath = sitePath(`locales/${lang}.json`); 1642 const { data } = await fetchJsonResource(localePath, { cache: "no-cache" }); 1643 return data; 1644} 1645 1646function applyTranslations(dict, lang) { 1647 // aggiorna lang della pagina (utile per accessibilità /SEO base) 1648 document.documentElement.setAttribute("lang", lang); 1649 1650 // traduce testo 1651 document.querySelectorAll("[data-i18n]").forEach((el) => { 1652 const key = el.getAttribute("data-i18n"); 1653 const attr = el.getAttribute("data-i18n-attr"); 1654 const value = getNested(dict, key); 1655 1656 if (typeof value === "string") { 1657 if (attr) { 1658 attr.split(",").forEach((name) => { 1659 const trimmed = name.trim(); 1660 if (trimmed) el.setAttribute(trimmed, value); 1661 }); 1662 } else if (el.hasAttribute("data-i18n-html")) { 1663 // Per le stringhe che contengono markup (es. una frase con un link dentro), 1664 // dove textContent perderebbe il markup. Opt-in per singolo elemento: senza 1665 // l'attributo il comportamento resta textContent. 1666 // 1667 // PRECONDIZIONE: i valori arrivano da locales/it.json e locales/en.json, file 1668 // versionati nel repo, e `lang` è vincolato a SUPPORTED, quindi la sorgente è 1669 // interamente sotto il nostro controllo e non c'è superficie XSS. Se un domani 1670 // le traduzioni venissero da un CMS, da una API o da input utente, questa riga 1671 // va sanificata (DOMPurify o equivalente) prima di riusarla. 1672 el.innerHTML = value; 1673 } else { 1674 el.textContent = value; 1675 } 1676 } else { 1677 // key mancante -> lascia il testo presente (fallback) 1678 // volendo puoi loggare: 1679 // console.warn("Missing i18n key:", key); 1680 } 1681 }); 1682 1683 // traduce title se lo vuoi gestire via chiave 1684 const titleEl = document.querySelector("title[data-i18n]"); 1685 if (titleEl) { 1686 const titleKey = titleEl.getAttribute("data-i18n"); 1687 const titleVal = getNested(dict, titleKey); 1688 if (typeof titleVal === "string") document.title = titleVal; 1689 } 1690} 1691 1692async function setLanguage(lang) { 1693 if (!SUPPORTED.includes(lang)) lang = DEFAULT_LANG; 1694 1695 try { 1696 const dict = await loadLocale(lang); 1697 window.siteLocaleDict = dict; 1698 applyTranslations(dict, lang); 1699 localStorage.setItem(STORAGE_KEY, lang); 1700 1701 // sincronizza tutti gli switcher presenti 1702 document.querySelectorAll(SWITCHER_SELECTOR).forEach((switcher) => { 1703 switcher.value = lang; 1704 }); 1705 1706 document.dispatchEvent( 1707 new CustomEvent("i18n:updated", { 1708 detail: { lang }, 1709 }), 1710 ); 1711 1712 return dict; 1713 } catch (e) { 1714 console.error(e); 1715 throw e; 1716 } 1717} 1718 1719function initSwitcher() { 1720 const switchers = document.querySelectorAll(SWITCHER_SELECTOR); 1721 if (!switchers.length) return; 1722 1723 switchers.forEach((switcher) => { 1724 switcher.addEventListener("change", (e) => { 1725 const scelta = e.target.value; 1726 if (!SUPPORTED.includes(scelta)) return; 1727 if (scelta === getCurrentLang()) return; 1728 1729 // Naviga invece di ritradurre sul posto: un indirizzo, una lingua. à ciò che 1730 // rende la versione inglese indicizzabile ed evita due URL con lo stesso 1731 // contenuto. La preferenza resta salvata per le pagine non rispecchiate 1732 // (esperienze, menu-directory, i template id/), dove vale ancora la traduzione 1733 // a runtime. 1734 try { localStorage.setItem(STORAGE_KEY, scelta); } catch (err) {} 1735 window.location.assign(counterpartUrl(scelta)); 1736 }); 1737 }); 1738} 1739 1740function getCurrentLang() { 1741 const docLang = document.documentElement.getAttribute("lang"); 1742 if (docLang && SUPPORTED.includes(docLang)) return docLang; 1743 1744 const stored = localStorage.getItem(STORAGE_KEY); 1745 if (stored && SUPPORTED.includes(stored)) return stored; 1746 1747 return resolveInitialLang(); 1748} 1749 1750window.getCurrentLang = getCurrentLang; 1751 1752document.addEventListener("i18n:updated", () => {
1753 initBookingGuestsControls(); 1754}); 1755 1756document.addEventListener("DOMContentLoaded", () => { 1757 loadNavbar(); 1758 loadHeader(); 1759 loadFooter(); 1760 1761 // init 1762 const initial = resolveInitialLang(); 1763 initSwitcher(); 1764 initBookingGuestsControls(); 1765 window.i18nReady = setLanguage(initial); 1766 1767 const bindBookingTrigger = (trigger) => { 1768 if (!trigger) return; 1769 trigger.addEventListener("click", () => { 1770 const currentLang = getCurrentLang(); 1771 const bookingContext = getBookingContext(trigger); 1772 const bookingUrl = buildBookingUrl({ 1773 lang: currentLang, 1774 arrival: bookingContext.arrival, 1775 departure: bookingContext.departure, 1776 bookingEngId: bookingContext.bookingEngId, 1777 occupancy: bookingContext.occupancy, 1778 }); 1779 1780 trackBookingClick({ 1781 bookingUrl, 1782 lang: currentLang, 1783 arrival: bookingContext.arrival, 1784 departure: bookingContext.departure, 1785 bookingEngId: bookingContext.bookingEngId, 1786 roomId: trigger.dataset.roomId, 1787 roomName: trigger.dataset.roomName, 1788 }); 1789 1790 apriBookingEngine(bookingUrl); 1791 }); 1792 }; 1793 1794 bindBookingTrigger(document.getElementById("bookBtn")); 1795 bindBookingTrigger(document.getElementById("bookBtnHeader")); 1796})
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.