PageSourceSearch

https://retailtune.ai/assets/site.js?v=52

js retailtune.ai collected 2026-10-09 21:59:56 UTC 28,629 bytes, 530 lines download raw bytes

1/* ===========================================================
2   RetailTune — shared site script
3   Header, footer and CTA live in editable HTML partials
4   (partials/header.html · footer.html · cta.html) and are
5   injected into every page's <div data-include="..."> slots.
6   This file loads those partials, then wires up interactions:
7   portafoglio mega-menu · mobile nav · footer dropdowns ·
8   legal modal · scroll fade-ins · counters · hero slider ·
9   carousels · image fallbacks · favicon
10   =========================================================== */
11(function(){
12  "use strict";
13  var BASE = window.SITE_BASE || "";          // "" at root, "../" in subfolders
14  function R(p){ return BASE + p; }            // resolve internal link
15  var NAV = (document.body && document.body.getAttribute("data-nav")) || "";
16  /* Lingua della pagina (2026-09-15, sezione /en/): decide quali partial scaricare
17     (header-en.html/footer-en.html/cta-en.html invece degli originali italiani) e alcune
18     stringhe fisse dentro questo file (modale legale, zoom immagini). Ogni pagina /en/...
19     dichiara <html lang="en">; tutto il resto (IT, o pagine senza lang) resta italiano. */
20  var LANG = ((document.documentElement.getAttribute("lang") || "it").toLowerCase() === "en") ? "en" : "it";
21
22  /* ---------- shared partials (header / footer / cta) ---------- */
23  function fetchText(url){
24    return fetch(url, {credentials:"same-origin"}).then(function(r){
25      if(!r.ok) throw new Error("HTTP "+r.status+" for "+url);
26      return r.text();
27    });
28  }
29  function applyTokens(html){
30    // voce di menu attiva: ocra scuro --brand-ink (era --brand-700, contrasto 2,5 sul bianco; 2026-09-11)
31    var act = ' style="color:var(--brand-ink)"';
32    html = html.split("{{B}}").join(BASE);
33    html = html.split("{{ACT:home}}").join(NAV==="home" ? act : "");
34    html = html.split("{{ACT:magazine}}").join(NAV==="magazine" ? act : "");
35    html = html.split("{{YEAR}}").join(String(new Date().getFullYear()));
36    return html;
37  }
38  // Replace a placeholder element with the given HTML markup (may be many nodes)
39  function replaceWith(el, html){
40    var tpl = document.createElement("template");
41    tpl.innerHTML = html;
42    el.parentNode.replaceChild(tpl.content, el);
43  }
44  /* Versione dei partial. I file sono scaricati via fetch e lo stage li serve
45     solo con ETag/Last-Modified: senza questa query il browser continua a
46     mostrare header e footer vecchi anche dopo il deploy.
47     ALZARE QUESTO NUMERO a ogni modifica di partials/*.html. */
48  var PARTIALS_V = "39";
49
50  var P_HEADER = LANG==="en" ? "partials/header-en.html" : "partials/header.html";
51  var P_FOOTER = LANG==="en" ? "partials/footer-en.html" : "partials/footer.html";
52  var P_CTA    = LANG==="en" ? "partials/cta-en.html"    : "partials/cta.html";
53  function includePartials(){
54    var slots = {
55      header: document.querySelector('[data-include="header"]'),
56      footer: document.querySelector('[data-include="footer"]'),
57      cta:    document.querySelector('[data-include="cta"]')
58    };
59    var jobs = [];
60    if(slots.header){
61      jobs.push(fetchText(R(P_HEADER + "?v=" + PARTIALS_V)).then(function(t){
62        replaceWith(slots.header, applyTokens(t));
63      }).catch(function(e){ console.error("header include failed:", e); }));
64    }
65    if(slots.cta){
66      jobs.push(fetchText(R(P_CTA + "?v=" + PARTIALS_V)).then(function(t){
67        var tpl = document.createElement("template");
68        tpl.innerHTML = applyTokens(t);
69        var pick = tpl.content.querySelector('[data-cta-section="'+NAV+'"]')
70                || tpl.content.querySelector('[data-cta-section="chisiamo"]');
71        if(pick){ slots.cta.parentNode.replaceChild(pick, slots.cta); }
72        else { slots.cta.parentNode.removeChild(slots.cta); }
73      }).catch(function(e){ console.error("cta include failed:", e); }));
74    }
75    if(slots.footer){
76      jobs.push(fetchText(R(P_FOOTER + "?v=" + PARTIALS_V)).then(function(t){
77        replaceWith(slots.footer, applyTokens(t));
78      }).catch(function(e){ console.error("footer include failed:", e); }));
79    }
80    return Promise.all(jobs);
81  }
82
83  /* ---------- mega-menu interactions (portafoglio) ---------- */
84  function initMega(){
85    var items = document.querySelectorAll(".nav-menu .nav-item");
86    var closeT;
87    /* Vero solo dove c'e' un puntatore che puo' sostare sopra un elemento (mouse, trackpad):
88       li' il pannello lo apre e lo chiude l'hover. Col dito invece l'hover non esiste e
89       comanda il click. Vedi i due blocchi qui sotto. */
90    var conHover = !!(window.matchMedia && window.matchMedia("(hover:hover) and (pointer:fine)").matches);
91    function closeAll(except){
92      items.forEach(function(it){ if(it!==except){ it.classList.remove("open"); var b=it.querySelector(".nav-link"); if(b&&b.setAttribute) b.setAttribute("aria-expanded","false"); } });
93    }
94    items.forEach(function(it){
95      var mega = it.querySelector(".mega");
96      if(!mega) return;
97      var btn = it.querySelector(".nav-link");
98      /* Apertura al passaggio del mouse SOLO dove c'e' un puntatore vero (2026-09-28).
99         Su touch il browser sintetizza un mouseenter subito prima del click: il primo
100         apriva il pannello, il secondo lo trovava aperto e lo richiudeva, quindi col
101         dito il mega-menu non si apriva mai (segnalato dal titolare, su schermo largo
102         abbastanza da mostrare il menu esteso: iPad in orizzontale, portatili touch).
103         Col dito resta il solo click, che apre e chiude correttamente.
104         Stessa trappola gia' risolta in initSlider il 2026-09-09: se si tocca di nuovo
105         questo blocco, tenere i due meccanismi allineati. */
106      if(conHover){
107        it.addEventListener("mouseenter", function(){ clearTimeout(closeT); closeAll(it); it.classList.add("open"); btn.setAttribute("aria-expanded","true"); });
108        it.addEventListener("mouseleave", function(){ closeT=setTimeout(function(){ it.classList.remove("open"); btn.setAttribute("aria-expanded","false"); }, 130); });
109      }
110      /* Col mouse, per cliccare una voce ci devi prima passare sopra: il mouseenter ha
111         gia' aperto il pannello, e un click che lo richiudesse lo farebbe sparire proprio
112         mentre l'utente lo sta aprendo — il menu "lampeggia" e sembra non funzionare
113         (segnalato dal titolare il 2026-09-28, su MacBook Pro). Dove c'e' l'hover il click
114         puo' quindi solo aprire: a chiudere ci pensano il mouseleave e il tasto Esc.
115         Col dito (conHover falso) non c'e' nessun hover ad aprire, quindi il click resta
116         un interruttore: primo tocco apre, secondo chiude. */
117      btn.addEventListener("click", function(e){ e.preventDefault(); var open=it.classList.contains("open"); closeAll();
118        if(!open || conHover){ it.classList.add("open"); btn.setAttribute("aria-expanded","true"); }
119        else { it.classList.remove("open"); btn.setAttribute("aria-expanded","false"); } });
120    });
121    document.addEventListener("keydown", function(e){ if(e.key==="Escape") closeAll(); });
122    document.addEventListener("click", function(e){ if(!e.target.closest(".nav-menu")) closeAll(); });
123  }
124
125  /* ---------- mobile nav ---------- */
126  function initMobile(){
127    var ham = document.querySelector(".site-header .hamburger");
128    var mob = document.getElementById("mobileNav");
129    var close = document.getElementById("mClose");
130    if(!mob) return;
131    function open(){ mob.classList.add("open"); document.body.classList.add("menu-open"); if(ham) ham.setAttribute("aria-expanded","true"); }
132    function shut(){ mob.classList.remove("open"); document.body.classList.remove("menu-open"); if(ham) ham.setAttribute("aria-expanded","false"); }
133    if(ham) ham.addEventListener("click", open);
134    if(close) close.addEventListener("click", shut);
135    document.querySelectorAll(".m-acc-btn").forEach(function(b){
136      b.addEventListener("click", function(){
137        var acc=b.closest(".m-acc"); var wasOpen=acc.classList.contains("open");
138        document.querySelectorAll(".m-acc").forEach(function(x){ x.classList.remove("open"); var bb=x.querySelector(".m-acc-btn"); if(bb) bb.setAttribute("aria-expanded","false"); });
139        if(!wasOpen){ acc.classList.add("open"); b.setAttribute("aria-expanded","true"); }
140      });
141    });
142    mob.querySelectorAll("a[href]").forEach(function(a){ a.addEventListener("click", function(){ if(a.getAttribute("href")!=="#") shut(); }); });
143
144    /* tendina lingue accanto all'hamburger (solo mobile, .lang-dd) */
145    var dd = document.querySelector(".site-header .lang-dd");
146    if(dd){
147      var ddBtn = dd.querySelector(".lang-dd-btn");
148      function ddSet(on){ dd.classList.toggle("open", on); ddBtn.setAttribute("aria-expanded", on ? "true" : "false"); }
149      ddBtn.addEventListener("click", function(e){ e.stopPropagation(); ddSet(!dd.classList.contains("open")); });
150      document.addEventListener("click", function(e){ if(!e.target.closest(".lang-dd")) ddSet(false); });
151      document.addEventListener("keydown", function(e){ if(e.key==="Escape") ddSet(false); });
152      if(ham) ham.addEventListener("click", function(){ ddSet(false); });
153    }
154  }
155
156  /* ---------- selettore di lingua (topbar desktop + tendina mobile) ----------
157     Le pagine /ita/... e /en/... hanno lo stesso percorso dopo il primo segmento
158     (stessa struttura di cartelle, solo il contenuto e' tradotto): il link all'altra
159     lingua si calcola sostituendo quel segmento nell'URL corrente, senza bisogno di
160     una tabella di corrispondenze. Da /en/... si torna sempre a /ita/... (ogni pagina
161     inglese nasce da una pagina italiana). Da /ita/... si va su /en/... solo se quella
162     pagina ha gia' una versione inglese (body[data-has-en]): altrimenti si punta alla
163     home inglese, per non atterrare su un 404 (es. Magazine e rassegna stampa, non
164     ancora tradotti). Non funziona aprendo i file in locale con file:// (il percorso
165     non inizia per /ita/ o /en/): li' i link restano quelli statici del partial. */
166  function initLangLinks(){
167    var path = location.pathname, alt;
168    if(path.indexOf("/en/") !== -1){
169      alt = path.replace("/en/", "/ita/");
170    } else if(path.indexOf("/ita/") !== -1){
171      alt = document.body.hasAttribute("data-has-en") ? path.replace("/ita/", "/en/") : "/en/index.html";
172    } else { return; }
173    document.querySelectorAll(".topbar .lang a, .lang-dd-menu a").forEach(function(a){
174      a.setAttribute("href", a.classList.contains("active") ? path : alt);
175    });
176  }
177
178  /* ---------- footer nav dropdowns ---------- */
179  function initFooterNav(){
180    var fnav = document.querySelector(".footer-nav");
181    if(!fnav) return;
182    fnav.querySelectorAll(".fn-toggle").forEach(function(b){
183      b.addEventListener("click", function(e){
184        e.stopPropagation();
185        var item = b.closest(".fn-item"), was = item.classList.contains("open");
186        fnav.querySelectorAll(".fn-item.open").forEach(function(o){ o.classList.remove("open"); o.querySelector(".fn-toggle").setAttribute("aria-expanded","false"); });
187        if(!was){ item.classList.add("open"); b.setAttribute("aria-expanded","true"); }
188      });
189    });
190    document.addEventListener("click", function(){
191      fnav.querySelectorAll(".fn-item.open").forEach(function(o){ o.classList.remove("open"); o.querySelector(".fn-toggle").setAttribute("aria-expanded","false"); });
192    });
193  }
194
195  /* ---------- legal modal (Privacy / Cookie) ---------- */
196  function initLegalModal(){
197    var m = document.getElementById("legalModal");
198    if(!m) return;
199    var eyebrowTxt = LANG==="en" ? "Legal notice" : "Informativa";
200    // Policy iubenda per lingua (dal 2026-09-30 esiste quella inglese, 77531576): stessi numeri di
201    // footer.html / footer-en.html e di _iub.csLangConfiguration nelle pagine.
202    var POL_ID = LANG==="en" ? "77531576" : "23276248";
203    var POL = {
204      privacy: { eyebrow:eyebrowTxt, title:"Privacy Policy", full:"https://www.iubenda.com/privacy-policy/"+POL_ID },
205      cookie:  { eyebrow:eyebrowTxt, title:"Cookie Policy",  full:"https://www.iubenda.com/privacy-policy/"+POL_ID+"/cookie-policy" }
206    };
207    var embeds = {
208      privacy: m.querySelector('[data-embed="privacy"]'),
209      cookie:  m.querySelector('[data-embed="cookie"]')
210    };
211    var iubLoaded = false;
212    function loadIub(){
213      if(iubLoaded) return; iubLoaded = true;
214      var s = document.createElement("script");
215      s.src = "https://cdn.iubenda.com/iubenda.js";
216      s.async = true;
217      document.body.appendChild(s);
218    }
219    var lastFocus = null;
220    function openModal(key){
221      var p = POL[key]; if(!p) return;
222      lastFocus = document.activeElement;
223      m.querySelector("#legalModalEyebrow").textContent = p.eyebrow;
224      m.querySelector("#legalModalTitle").textContent = p.title;
225      var full = m.querySelector("#legalModalFull");
226      full.setAttribute("href", p.full);
227      if(embeds.privacy) embeds.privacy.style.display = (key==="privacy") ? "" : "none";
228      if(embeds.cookie)  embeds.cookie.style.display  = (key==="cookie")  ? "" : "none";
229      m.querySelector("#legalModalBody").scrollTop = 0;
230      m.classList.add("open");
231      m.setAttribute("aria-hidden","false");
232      document.body.classList.add("legal-open");
233      loadIub();
234    }
235    function closeModal(){
236      m.classList.remove("open");
237      m.setAttribute("aria-hidden","true");
238      document.body.classList.remove("legal-open");
239      if(lastFocus && lastFocus.focus) lastFocus.focus();
240    }
241    document.querySelectorAll("[data-legal-open]").forEach(function(a){
242      a.addEventListener("click", function(e){
243        e.preventDefault();
244        openModal(a.getAttribute("data-legal-open"));
245      });
246    });
247    m.querySelectorAll("[data-legal-close]").forEach(function(b){
248      b.addEventListener("click", closeModal);
249    });
250    document.addEventListener("keydown", function(e){
251      if(e.key==="Escape" && m.classList.contains("open")) closeModal();
252    });
253  }
254
255  /* ---------- header shadow on scroll ---------- */
256  function initScroll(){
257    var h=document.querySelector(".site-header");
258    if(!h) return;
259    function on(){ if(window.scrollY>10) h.classList.add("scrolled"); else h.classList.remove("scrolled"); }
260    window.addEventListener("scroll", on, {passive:true}); on();
261  }
262
263  /* ---------- reveal on scroll (load-event independent) ---------- */
264  function initReveal(){
265    var els=document.querySelectorAll(".reveal,.reveal-fade");
266    function showInView(){
267      var vh=window.innerHeight||document.documentElement.clientHeight;
268      els.forEach(function(e){
269        if(e.classList.contains("in")) return;
270        var r=e.getBoundingClientRect();
271        if(r.top < vh*0.92 && r.bottom > 0) e.classList.add("in");
272      });
273    }
274    if(!("IntersectionObserver" in window)){ els.forEach(function(e){ e.classList.add("in"); }); return; }
275    var io=new IntersectionObserver(function(ents){
276      ents.forEach(function(en){ if(en.isIntersecting){ en.target.classList.add("in"); io.unobserve(en.target); } });
277    },{ threshold:0.12, rootMargin:"0px 0px -8% 0px" });
278    els.forEach(function(e){ io.observe(e); });
279    /* don't wait on the load event (external images can stall it) */
280    showInView();
281    window.addEventListener("scroll", showInView, {passive:true});
282    window.addEventListener("resize", showInView, {passive:true});
283    setTimeout(showInView, 250);
284    setTimeout(showInView, 1000);
285    /* Rete di sicurezza: dopo 2,6s non lasciare nascosto niente di cio' che e' gia' a schermo.
286       Quello che sta ancora sotto la piega **non** si tocca (2026-09-09): prima si accendeva
287       tutto insieme e le animazioni a cascata — la timeline di Storia, per dirne una — erano
288       gia' finite quando l'utente ci arrivava. A rivelarle ci pensa showInView, che resta
289       agganciato allo scroll e non dipende da IntersectionObserver. */
290    setTimeout(function(){
291      var vh=window.innerHeight||document.documentElement.clientHeight;
292      els.forEach(function(e){ if(e.getBoundingClientRect().top < vh*1.2) e.classList.add("in"); });
293    }, 2600);
294  }
295
296  /* ---------- counters ---------- */
297  /* formato italiano: punto delle migliaia, virgola decimale (prima "4.000+" diventava
298     "4000+" appena partiva l'animazione: bug delle case history, 2026-09-08) */
299  function fmtIt(n,dec){ var p=n.toFixed(dec).split("."); return p[0].replace(/\B(?=(\d{3})+(?!\d))/g,".")+(p[1]?","+p[1]:""); }
300  function initCounters(){
301    var els=document.querySelectorAll("[data-count]");
302    if(!els.length) return;
303    var io=new IntersectionObserver(function(ents){
304      ents.forEach(function(en){
305        if(!en.isIntersecting) return; io.unobserve(en.target);
306        var el=en.target, end=parseFloat(el.getAttribute("data-count")),
307            dec=(end%1!==0)?1:0, suf=el.getAttribute("data-suffix")||"", pre=el.getAttribute("data-prefix")||"",
308            t0=null, dur=1500;
309        function step(t){ if(!t0)t0=t; var p=Math.min((t-t0)/dur,1); var e=1-Math.pow(1-p,3);
310          el.textContent=pre+fmtIt(end*e,dec)+suf; if(p<1) requestAnimationFrame(step); }
311        requestAnimationFrame(step);
312      });
313    },{ threshold:0.4 });
314    els.forEach(function(e){ io.observe(e); });
315  }
316
317  /* ---------- hero slider ---------- */
318  function initSlider(){
319    var slider=document.querySelector("[data-slider]");
320    if(!slider) return;
321    var slides=slider.querySelectorAll(".hero-slide");
322    var dots=slider.querySelectorAll(".hero-dot");
323    var i=0, timer;
324    function go(n){
325      i=(n+slides.length)%slides.length;
326      slides.forEach(function(s,k){ s.classList.toggle("active",k===i); });
327      dots.forEach(function(d,k){ d.classList.toggle("active",k===i); });
328    }
329    function next(){ go(i+1); }
330    /* 20s (era 13, il cliente le ha volute piu' lente il 2026-09-08): le slide hanno testo e
331       immagini da leggere. play() ferma sempre il timer precedente:
332       prima non lo faceva e ogni uscita del mouse dal banner (mouseleave -> play) aggiungeva un
333       intervallo senza togliere il vecchio, quindi dopo qualche passaggio le slide cambiavano a
334       scatti irregolari (segnalato dal cliente il 2026-09-08). */
335    function stop(){ clearInterval(timer); }
336    var every=parseInt(slider.getAttribute("data-interval"),10)||20000;   /* data-interval="ms" (Dicono di noi: 12000) */
337    function play(){ stop(); timer=setInterval(next,every); }
338    dots.forEach(function(d,k){ d.addEventListener("click", function(){ go(k); stop(); play(); }); });
339    var pv=slider.querySelector("[data-prev]"), nx=slider.querySelector("[data-next]");
340    if(pv) pv.addEventListener("click", function(){ go(i-1); stop(); play(); });
341    if(nx) nx.addEventListener("click", function(){ next(); stop(); play(); });
342    /* La pausa al passaggio del mouse vale SOLO dove c'e' un puntatore vero. Su telefono un
343       tocco fa scattare mouseenter, ma mouseleave non arriva mai: lo slider restava fermo per
344       sempre dopo il primo tocco (segnalato dal cliente il 2026-09-09 su Dicono di noi; valeva
345       anche per l'hero della home). Su touch quindi non si mette in pausa. */
346    if(window.matchMedia && window.matchMedia("(hover:hover) and (pointer:fine)").matches){
347      slider.addEventListener("mouseenter",stop); slider.addEventListener("mouseleave",play);
348    }
349    go(0); play();
350  }
351
352  /* ---------- generic carousel (testimonials) ---------- */
353  function initCarousels(){
354    document.querySelectorAll("[data-carousel]").forEach(function(c){
355      var track=c.querySelector("[data-track]");
356      var prev=c.querySelector("[data-cprev]"), next=c.querySelector("[data-cnext]");
357      function step(){ var card=track.querySelector(":scope > *"); return card?card.getBoundingClientRect().width+24:360; }
358      function scrollBy(dir){ track.scrollBy({left:dir*step(),behavior:"smooth"}); }
359      if(prev) prev.addEventListener("click", function(){ scrollBy(-1); stop(); play(); });
360      if(next) next.addEventListener("click", function(){ scrollBy(1); stop(); play(); });
361
362      var timer;
363      function advance(){
364        var maxScroll=track.scrollWidth-track.clientWidth;
365        if(track.scrollLeft>=maxScroll-2){ track.scrollTo({left:0,behavior:"smooth"}); }
366        else { scrollBy(1); }
367      }
368      var every=parseInt(c.getAttribute("data-interval"),10)||4500;   /* data-interval="ms" per caroselli con testi lunghi (Dicono di noi: 9000) */
369      function play(){ stop(); timer=setInterval(advance,every); }
370      function stop(){ clearInterval(timer); }
371      c.addEventListener("mouseenter",stop);
372      c.addEventListener("mouseleave",play);
373      play();
374    });
375  }
376
377  /* ---------- image fallbacks ---------- */
378  function initImages(){
379    document.querySelectorAll("img").forEach(function(img){
380      if(img.complete && img.naturalWidth===0){ handle(img); }
381      img.addEventListener("error", function(){ handle(img); });
382    });
383    function handle(img){
384      if(img.dataset.fbDone) return; img.dataset.fbDone="1";
385      var mode=img.getAttribute("data-fb")||"img";
386      var box=document.createElement("div");
387      if(mode==="logo"){ box.className="logo-fallback"; box.textContent=img.getAttribute("alt")||"Logo"; }
388      else { box.className="img-fallback"; box.textContent=img.getAttribute("data-label")||img.getAttribute("alt")||"image"; }
389      box.style.position="absolute"; box.style.inset="0";
390      var p=img.parentElement;
391      if(p){ if(getComputedStyle(p).position==="static") p.style.position="relative"; p.appendChild(box); }
392      img.style.visibility="hidden";
393    }
394  }
395
396  /* ---------- lente: le immagini dei moduli si aprono ingrandite ----------
397     Vale solo sulle pagine modulo e solo sulle immagini di contenuto: fuori
398     restano il carosello marchi (loghi piccoli, non c'e' niente da ingrandire)
399     e tutto cio' che sta in header, footer e CTA. Il pannello si chiude con
400     un clic, con Esc o con il bottone; niente libreria esterna. */
401  function initZoom(){
402    if(document.body.getAttribute("data-nav")!=="moduli") return;
403    var imgs=[];
404    document.querySelectorAll("section.section img").forEach(function(img){
405      if(img.closest(".clients-strip")) return;
406      if(img.closest("[data-include]")) return;
407      imgs.push(img);
408    });
409    if(!imgs.length) return;
410
411    var ov=null, big=null, last=null;
412    function build(){
413      ov=document.createElement("div");
414      ov.className="zoom-ov";
415      ov.setAttribute("role","dialog");
416      ov.setAttribute("aria-modal","true");
417      ov.setAttribute("aria-label", LANG==="en" ? "Enlarged image" : "Immagine ingrandita");
418      big=document.createElement("img");
419      big.alt="";
420      var x=document.createElement("button");
421      x.type="button"; x.className="zoom-x"; x.setAttribute("aria-label", LANG==="en" ? "Close" : "Chiudi");
422      x.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"></line><line x1="18" y1="6" x2="6" y2="18"></line></svg>';
423      ov.appendChild(big); ov.appendChild(x);
424      document.body.appendChild(ov);
425      ov.addEventListener("click", function(){ close(); });
426      document.addEventListener("keydown", function(e){ if(e.key==="Escape") close(); });
427    }
428    function open(img){
429      if(!ov) build();
430      big.src=img.currentSrc||img.src;
431      big.alt=img.getAttribute("alt")||"";
432      last=img;
433      ov.classList.add("open");
434      document.body.classList.add("menu-open");
435      ov.querySelector(".zoom-x").focus();
436    }
437    function close(){
438      if(!ov || !ov.classList.contains("open")) return;
439      ov.classList.remove("open");
440      document.body.classList.remove("menu-open");
441      if(last) last.focus();
442    }
443    imgs.forEach(function(img){
444      img.classList.add("zoomable");
445      img.setAttribute("tabindex","0");
446      img.setAttribute("role","button");
447      img.setAttribute("aria-label", (img.getAttribute("alt")||(LANG==="en"?"Image":"Immagine")) + (LANG==="en" ? " \u2014 open enlarged" : " \u2014 apri ingrandita"));
448      img.addEventListener("click", function(){ open(img); });
449      img.addEventListener("keydown", function(e){
450        if(e.key==="Enter"||e.key===" "){ e.preventDefault(); open(img); }
451      });
452    });
453  }
454
455  /* ---------- popup (home soluzioni) ---------- */
456  function initPopup(){
457    document.querySelectorAll("[data-popup-open]").forEach(function(b){
458      b.addEventListener("click", function(e){ e.preventDefault(); var t=document.getElementById(b.getAttribute("data-popup-open")); if(t){ t.classList.add("open"); document.body.classList.add("menu-open"); } });
459    });
460    document.querySelectorAll("[data-popup-close]").forEach(function(b){
461      b.addEventListener("click", function(){ var m=b.closest(".popup"); if(m){ m.classList.remove("open"); document.body.classList.remove("menu-open"); } });
462    });
463    document.querySelectorAll(".popup").forEach(function(m){
464      m.addEventListener("click", function(e){ if(e.target===m){ m.classList.remove("open"); document.body.classList.remove("menu-open"); } });
465    });
466  }
467
468  /* ---------- favicon ---------- */
469  function buildFavicon(){
470    if(document.querySelector('link[rel="icon"]')) return;
471    var h = document.head || document.getElementsByTagName("head")[0];
472    var svg = document.createElement("link");
473    svg.rel = "icon"; svg.type = "image/svg+xml"; svg.href = R("assets/img/favicon.svg");
474    var ico = document.createElement("link");
475    ico.rel = "alternate icon"; ico.type = "image/x-icon"; ico.href = R("assets/img/favicon.ico");
476    var app = document.createElement("link");
477    app.rel = "apple-touch-icon"; app.href = R("assets/img/favicon-180.png");
478    h.appendChild(svg); h.appendChild(ico); h.appendChild(app);
479  }
480
481  /* ---------- interactions (run after partials are injected) ---------- */
482  function initInteractions(){
483    initMega(); initMobile(); initLangLinks(); initFooterNav(); initLegalModal(); initScroll();
484    initReveal(); initCounters(); initSlider(); initCarousels();
485    initImages(); initPopup(); initZoom();
486  }
487
488  /* ---------- reCAPTCHA v3 (2026-09-11) ----------
489     Invisibile, niente casella da spuntare. Lo script di Google si carica solo quando l'utente
490     entra in un form con data-recaptcha (focus su un campo), non all'apertura della pagina: pesa
491     ~150KB e Lighthouse lo conterebbe. Al submit le pagine chiamano window.rtRecaptcha("azione"),
492     che restituisce sempre una Promise: il token, oppure "" se Google non risponde entro 6s
493     (dominio non abilitato sulla chiave, rete, blocchi). Il form parte lo stesso e decide il
494     server: api/submit.php verifica solo se in config.php c'e' recaptcha_secret. I domini
495     della chiave (console reCAPTCHA di Google) devono comprendere retailtune.ai e stage.retailtune.com. Il badge di Google e' nascosto in CSS
496     e al suo posto c'e' la frase .rc-note sotto ogni form (Google lo richiede se il badge non si vede). */
497  // Chiave del sito reCAPTCHA v3 di retailtune.ai (2026-09-30, nuova: quella di prima era del vecchio sito).
498  // La chiave SEGRETA abbinata sta solo in api/config.php di produzione (produzione-config.command, RECAPTCHA=1).
499  var RC_KEY = "6LcalNctAAAAAO5D-Fflr_gJ3pDvEoBQAC-R19ZJ", rcLoading = null;
500  function rcLoad(){
501    if(rcLoading) return rcLoading;
502    rcLoading = new Promise(function(done){
503      var s = document.createElement("script");
504      s.src = "https://www.google.com/recaptcha/api.js?render=" + RC_KEY; s.async = true;
505      s.onload = function(){ if(window.grecaptcha && window.grecaptcha.ready){ window.grecaptcha.ready(function(){ done(true); }); } else { done(false); } };
506      s.onerror = function(){ done(false); };
507      document.head.appendChild(s);
508    });
509    return rcLoading;
510  }
511  window.rtRecaptcha = function(action){
512    return new Promise(function(done){
513      var t = setTimeout(function(){ done(""); }, 6000);
514      rcLoad().then(function(ok){
515        if(!ok){ clearTimeout(t); done(""); return; }
516        try {
517          window.grecaptcha.execute(RC_KEY, {action: action}).then(function(tok){ clearTimeout(t); done(tok || ""); }, function(){ clearTimeout(t); done(""); });
518        } catch(e){ clearTimeout(t); done(""); }
519      });
520    });
521  };
522  document.addEventListener("focusin", function(e){ if(e.target && e.target.closest && e.target.closest("form[data-recaptcha]")) rcLoad(); });
523
524  /* ---------- boot ---------- */
525  function boot(){
526    buildFavicon();
527    includePartials().then(initInteractions);
528  }
529  if(document.readyState==="loading") document.addEventListener("DOMContentLoaded", boot); else boot();
530})();

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.