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.