PageSourceSearch

https://www.hotel2torri.com/js/commons/utils.js

js hotel2torri.com collected 2026-10-04 20:56:32 UTC 71,773 bytes, 1,796 lines download raw bytes

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("&", "&amp;")
48        .replaceAll("<", "&lt;")
49        .replaceAll(">", "&gt;")
50        .replaceAll('"', "&quot;")
51        .replaceAll("'", "&#39;");
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 &amp; 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 &amp; Solarium</a>
1496                                    </div>
1497                                </div>
1498
1499                                <div class="footerLinks__group">
1500                                    <div class="footerLinks__title" data-i18n="nav.accommodation">Camere &amp; 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 &amp; 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.