1window.__CAF_PRIVACY__ = false; 2window.__CAF_DOMANDEFISSE__ = true; 3window.__CAF_FIREBASE_CONFIG__ = {"apiKey":"","authDomain":"","projectId":"","storageBucket":"","messagingSenderId":"","appId":""}; 4window.__CAF_ZONA__ = ""; 5window.__CAF_DEMOPROMO__ = false; 6window.__CAF_DEMO_PROFILE__ = ""; 7window.__CAF_PIEMONTE__ = false; 8window.__CAF_NEUTRAL__ = true; 9window.__CAF_CHATBOT_NAME__ = "CAF CISL"; 10window.__CAF_PRIVACY_INFO__ = {"controller":"","address":"","email":"","phone":"","vat":"","dpo":""}; 11window.__CAF_CONSENSO__ = true; 12window.__CAF_AI_DISCLOSURE__ = true; 13window.__CAF_PRIVACY_URL__ = ""; 14window.__CAF_HELPLINE__ = "055 43921"; 15window.__CAF_WIDGET_BUILD__ = "fa23cdd7802e@2026-10-03T07:25:44.501Z"; 16/** 17 * CAF CISL Toscana â Inline Chat Widget (no iframe) 18 * 19 * Drop this script on any page. It injects a floating chat bubble + panel 20 * entirely via the DOM using Shadow DOM so it won't clash with the host page's 21 * CSS. All styles are self-contained. 22 * 23 * Usage: 24 * <script 25 * src="https://YOUR_DOMAIN/widget-inline.js" 26 * data-base-url="https://YOUR_DOMAIN" 27 * data-color="#059669" 28 * data-name="CAF CISL - Toscana" 29 * data-logo="https://indesignstudio.b-cdn.net/logoCAFCISL.png" 30 * data-welcome="Ciao, come posso aiutarti?" 31 * data-quick-replies="Che spese si possono detrarre?|Vorrei ricevere più informazioni|Qual è il centro CAF più vicino a me?" 32 * data-voice="si" 33 * ></script> 34 * 35 * Host-page triggers (no JS needed): add data-caf-action to any element. 36 * <button data-caf-action="open">Chat</button> <!-- opens the chat --> 37 * <button data-caf-action="book">Prenota</button> <!-- opens the booking wizard --> 38 * Works on any element (button, a, divâ¦), even if added to the DOM later 39 * (event delegation). Programmatic equivalent of the two actions: 40 * CafChatInline.open() // open chat with the welcome message 41 * CafChatInline.openBooking() // open the booking wizard directly 42 * 43 * Or initialise manually: 44 * CafChatInline.init({ baseUrl: "https://YOUR_DOMAIN" }); 45 */ 46(function () { 47 "use strict"; 48 49 if (window.CafChatInline) return; 50 51 /* ââ Storage namespace: isolamento per-LINK (origine) ââââââââââââââââââ 52 * BUGFIX (leak cross-sito): tutte le chiavi localStorage/sessionStorage 53 * erano costanti GLOBALI identiche ovunque. localStorage è già per-origine, 54 * ma due tenant serviti dalla STESSA origine â pagina demo che cambia 55 * <script src>, localhost tra build di progetti diversi, preview, dominio 56 * condiviso multi-tenant â finivano nello STESSO bucket: la conversazione 57 * (e i dati cliente + il consenso) "passava" da un sito all'altro. 58 * 59 * REQUISITO: ogni LINK deve avere uno storico COMPLETAMENTE indipendente, 60 * senza alcuna persistenza tra link diversi. Quindi la chiave è legata 61 * ESPLICITAMENTE all'origine (location.origin = schema://host:porta), così 62 * l'isolamento è dimostrabile e non dipende da nessuna assunzione. In più 63 * incorporiamo l'identità tenant iniettata dalla route (__CAF_ZONA__ / 64 * __CAF_CHATBOT_NAME__ / flag) per separare anche più tenant sulla STESSA 65 * origine. Due contesti condividono lo storico SOLO se hanno STESSO link E 66 * STESSO tenant â cioè sono lo stesso identico sito. La parte leggibile 67 * (host + zona) è solo per ispezione DevTools; l'unicità è garantita 68 * dall'hash calcolato sull'origine RAW + tenant (immune a collisioni dei 69 * caratteri persi nella sanitizzazione). */ 70 var CAF_NS = (function () { 71 /* ISOLAMENTO CONVERSAZIONI â SEMPRE, IN QUALSIASI CIRCOSTANZA. 72 * Ogni (origine + tenant) ha le proprie chiavi storage: due chatbot di 73 * tenant diversi NON condividono mai la conversazione, nemmeno se serviti 74 * dalla STESSA origine (pagina che cambia config, localhost, dominio 75 * condiviso). L'identita' tenant arriva dai globali iniettati dalla route 76 * (__CAF_ZONA__/__CAF_CHATBOT_NAME__/flag) + location.origin. Suffisso 77 * deterministico e stabile per tenant: basis iniettiva (JSON.stringify) + 78 * hash a 64 bit -> nessuna collisione. Effetto una-tantum: gli utenti 79 * esistenti ripartono da bucket pulito (purga lo stato gia' contaminato); 80 * il FLUSSO del prenotatore resta identico. */ 81 try { 82 var w = (typeof window !== "undefined") ? window : {}; 83 var loc = (typeof location !== "undefined" && location) || {}; 84 /* Il LINK: origine completa (schema://host:porta). Fallback host/href e 85 * infine costante: mai stringa vuota, mai chiave globale condivisa. */ 86 var link = (loc.origin || loc.host || loc.href || "nolink").toString(); 87 var zona = (w.__CAF_ZONA__ || "").toString().trim().toLowerCase(); 88 var name = (w.__CAF_CHATBOT_NAME__ || "").toString().trim().toLowerCase(); 89 var demo = (w.__CAF_DEMOPROMO__ === true) ? "1" : "0"; 90 var pie = (w.__CAF_PIEMONTE__ === true) ? "1" : "0"; 91 /* basis iniettiva al 100%: JSON.stringify codifica i confini dei campi 92 * senza separatori ambigui (niente NUL, niente spazi). */ 93 var basis = JSON.stringify([link, zona, name, demo, pie]); 94 /* due hash indipendenti (djb2-xor + sdbm) -> 64 bit effettivi. */ 95 var h1 = 5381, h2 = 0; 96 for (var i = 0; i < basis.length; i++) { 97 var c = basis.charCodeAt(i); 98 h1 = ((((h1 << 5) + h1) ^ c) >>> 0); 99 h2 = ((h2 * 65599 + c) >>> 0); 100 } 101 var hash = ("0000000" + h1.toString(36)).slice(-7) + ("0000000" + h2.toString(36)).slice(-7); 102 /* tag leggibili: includiamo anche il NOME, cosi' due tenant con stesso 103 * host+zona ma nome diverso restano distinti gia' nella parte leggibile. */ 104 var hostTag = (loc.host || loc.hostname || "").toString().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 24); 105 var zonaTag = zona.replace(/[^a-z0-9]+/g, "").slice(0, 12); 106 var nameTag = name.replace(/[^a-z0-9]+/g, "").slice(0, 12);
107 return "::" + [hostTag, zonaTag, nameTag, hash].filter(Boolean).join("-"); 108 } catch (_e) { 109 /* fallback non-vuoto: meglio un bucket isolato che la chiave globale. */ 110 return "::isolated"; 111 } 112 })(); 113 function nsKey(base) { return base + CAF_NS; } 114 /* Chiavi un tempo inline, ora scopate per tenant (riusate in più punti). */ 115 var BK_SID_KEY = nsKey("caf-bk-sid"); 116 var BK_LANG_KEY = nsKey("caf-bk-lang"); 117 118 /* ââ Consenso privacy CHAT (gate all'apertura, vedi opts.consenso) ââ 119 * Registro del consenso espresso dall'utente per usare la chat. Come il 120 * consenso del wizard prenotazione, il RECORD del consenso e' esso stesso 121 * "necessario" e viene salvato anche in privacy-mode. Chiave scopata per 122 * tenant via nsKey â mai condivisa cross-tenant. Attivo SOLO quando il 123 * deploy/host imposta opts.consenso (env CONSENSO=si / data-consenso). */ 124 var CHAT_CONSENT_KEY = nsKey("caf-chat-consent-v2"); 125 function chatConsentLoad() { 126 try { 127 var r = localStorage.getItem(CHAT_CONSENT_KEY); 128 if (!r) return null; 129 var c = JSON.parse(r); 130 /* L. 132/2025 art. 4 c.4: un record senza dichiarazione di eta' 131 (scritto da versioni pre-age-gate) NON supera il gate: l'utente 132 deve riconfermare consenso E dichiarazione >=14 anni. */ 133 return (c && c.v === 2 && c.ageDeclared === true) ? c : null; 134 } catch (_e) { return null; } 135 } 136 function chatConsentSave(url, ageOk) { 137 /* mini audit trail: SOLO origin+pathname (mai query/hash: potrebbero 138 contenere token o dati personali). ageOk = dichiarazione >=14 anni 139 (L. 132/2025 art. 4 c.4). */ 140 var pg = ""; 141 try { pg = location.origin + (location.pathname || ""); } catch (_e) {} 142 try { 143 localStorage.setItem(CHAT_CONSENT_KEY, JSON.stringify({ 144 v: 2, ts: Date.now(), url: url || "", ageDeclared: !!ageOk, 145 page: pg 146 })); 147 } catch (_e) { /* storage negato/pieno: resta il flag in-memory */ } 148 } 149 150 /* ââ helpers âââââââââââââââââââââââââââââââââââââââââââââââââ */ 151 function getAttr(name) { 152 var scripts = document.getElementsByTagName("script"); 153 var scriptSrcs = []; 154 for (var i = scripts.length - 1; i >= 0; i--) { 155 var s = scripts[i]; 156 scriptSrcs.push(s.src || "(no src)"); 157 if (s && s.src && s.src.indexOf("widget-inline") !== -1) { 158 return s.getAttribute("data-" + name) || ""; 159 } 160 } 161 return ""; 162 } 163 164 function escapeHtml(str) { 165 var d = document.createElement("div"); 166 d.appendChild(document.createTextNode(str)); 167 return d.innerHTML; 168 } 169 170 function quickId() { 171 try { 172 var _c = (typeof crypto !== "undefined") ? crypto : null; 173 if (_c && _c.getRandomValues) { 174 var _a = new Uint32Array(2); 175 _c.getRandomValues(_a); 176 return (_a[0].toString(36) + _a[1].toString(36)).slice(0, 8); 177 } 178 } catch (_e) {} 179 return Math.random().toString(36).slice(2, 10); 180 } 181 182 /* 1.4.13 reduced-motion: usa "auto" quando l'utente ha ridotto le animazioni */ 183 function bkScrollBehavior() { 184 return (window.matchMedia && window.matchMedia("(prefers-reduced-motion:reduce)").matches) ? "auto" : "smooth"; 185 } 186 187 /* ââ pagine su cui il widget NON deve MAI essere mostrato ââââââ 188 * 189 * Richiesta esplicita: il widget inline non va MAI mostrato su 190 * queste pagine del sito cafcisl.it: 191 * https://www.cafcisl.it/it-schede-11-prenota_appuntamento 192 * https://www.cafcisl.it/it-schede-706-dichiarazione_dei_redditi 193 * https://www.cafcisl.it/it-schede-437-dichiarazione_di_successione 194 * 195 * Il match avviene SOLO sul pathname (gli ID schede 11 / 706 / 437 sono 196 * specifici del CMS di cafcisl.it, quindi non serve vincolare l'host 197 * e così il blocco regge anche con/senza "www", http/https, ecc.). 198 * Si ignorano trailing slash, query string, hash e maiuscole. 199 * Su qualsiasi altra pagina il widget continua a essere mostrato. 200 */ 201 var WIDGET_BLOCKED_PATHS = [
202 "/it-schede-11-prenota_appuntamento", 203 "/it-schede-706-dichiarazione_dei_redditi", 204 "/it-schede-437-dichiarazione_di_successione", 205 ]; 206 207 function isWidgetBlockedPage() { 208 try { 209 var path = (location.pathname || "").toLowerCase().replace(/\/+$/, ""); 210 for (var i = 0; i < WIDGET_BLOCKED_PATHS.length; i++) { 211 if (path === WIDGET_BLOCKED_PATHS[i]) return true; 212 } 213 return false; 214 } catch (e) { 215 return false; 216 } 217 } 218 219 /* ââ Mantis sentinel parser âââââââââââââââââââââââââââââââââ 220 * 221 * Il backend `/api/chat` emette in streaming due linee sentinel: 222 * <<<MANTIS_RETRIEVED>>>{...citations...}\n 223 * <<<MANTIS_DONE>>>{...conversationId,confidence,...}\n 224 * Servono al widget React per esporre metadata, ma per questo 225 * widget vanilla NON devono essere mai visibili all'utente. 226 * 227 * Questa funzione rimuove i sentinel dal testo. Robusta su: 228 * 1. Sentinel completo 229 * 2. Sentinel con prefix completo ma JSON incompleto (manca \n) 230 * â tronca dal prefix in poi 231 * 3. Suffix parziale del prefix (es. "<<<MAN") â tronca quel suffix 232 * 233 * Strip anche citazioni inline tipo `[F1, descrizione]` che il 234 * modello a volte inserisce nel testo della risposta. 235 */ 236 var MANTIS_RETRIEVED_PREFIX = "<<<MANTIS_RETRIEVED>>>"; 237 var MANTIS_DONE_PREFIX = "<<<MANTIS_DONE>>>"; 238 239 function parseMantisSentinels(raw) { 240 var clean = raw; 241 var retrieved, done; 242 function stripPrefixedLine(prefix, onParsed) { 243 while (true) { 244 var idx = clean.indexOf(prefix); 245 if (idx === -1) return; 246 var nl = clean.indexOf("\n", idx + prefix.length); 247 if (nl === -1) { 248 /* payload non ancora completo: tronca dal prefix in avanti */ 249 clean = clean.slice(0, idx); 250 return; 251 } 252 var jsonText = clean.slice(idx + prefix.length, nl); 253 try { 254 if (onParsed) onParsed(JSON.parse(jsonText)); 255 clean = clean.slice(0, idx) + clean.slice(nl + 1); 256 } catch (e) { 257 /* JSON malformato: rimuovi la linea senza distruggere il resto */ 258 clean = clean.slice(0, idx) + clean.slice(nl + 1); 259 } 260 } 261 } 262 stripPrefixedLine(MANTIS_RETRIEVED_PREFIX, function (p) { retrieved = p; }); 263 stripPrefixedLine(MANTIS_DONE_PREFIX, function (p) { done = p; }); 264 265 /* Tail check: suffix parziale di uno dei prefix â nascondi */ 266 var prefixes = [MANTIS_RETRIEVED_PREFIX, MANTIS_DONE_PREFIX]; 267 for (var pi = 0; pi < prefixes.length; pi++) { 268 var prefix = prefixes[pi]; 269 var maxLen = Math.min(prefix.length - 1, clean.length); 270 for (var len = maxLen; len > 0; len--) { 271 var tail = clean.slice(clean.length - len); 272 if (prefix.indexOf(tail) === 0) { 273 clean = clean.slice(0, clean.length - len); 274 break; 275 } 276 } 277 } 278 279 /* Strip citazioni inline tipo [F1, descrizione] o [F2] */ 280 clean = clean.replace(/\s*\[F\d+(?:[\s,]+[^\]]*)?\]/g, ""); 281 /* Strip [F<n>... aperto a fine buffer (in arrivo durante stream) */ 282 var openIdx = clean.lastIndexOf("[F"); 283 if (openIdx !== -1 && clean.slice(openIdx).indexOf("]") === -1) { 284 var afterF = clean.slice(openIdx + 2); 285 if (afterF.length === 0 || /^\d/.test(afterF)) { 286 clean = clean.slice(0, openIdx).replace(/\s+$/, ""); 287 } 288 } 289 290 return { clean: clean, retrieved: retrieved, done: done }; 291 } 292 293 /* ââ booking intent detection via Claude API ââââââââââââââ */ 294 295 /** Ask Claude to analyze the user's message for booking intent. 296 * Returns { isBooking, service, location } or null on error. */ 297 async function analyzeBookingIntent(baseUrl, text) { 298 /* Quick pre-filter: skip Claude for very short or obviously non-booking messages */ 299 var t = text.toLowerCase().trim(); 300 if (t.length < 3) return null; 301 /* skip pure greetings/thanks */ 302 if (/^(ciao|salve|buongiorno|buonasera|grazie|ok|va bene|perfetto|arrivederci|bye)\s*[.!?]*$/i.test(t)) return null; 303 try { 304 var ctrl = new AbortController(); 305 var tid = setTimeout(function(){ ctrl.abort(); }, 4000); 306 var r = await fetch(baseUrl + "/api/booking-intent", { 307 method: "POST", 308 headers: { "Content-Type": "application/json" }, 309 body: JSON.stringify({ message: text }), 310 signal: ctrl.signal, 311 }); 312 clearTimeout(tid); 313 if (!r.ok) return null; 314 return await r.json(); 315 } catch (e) { return null; } 316 } 317 318 /* Lightweight regex fallback â only used if Claude API is unavailable */ 319 var BOOKING_KEYWORDS = 320 /\b(prenot\w*|appuntamento|fissare|booking|devo\s+fare|ho\s+bisogno\s+di|mi\s+serve|vorrei\s+fare|voglio\s+fare|voglio\s+prenotare|posso\s+prenotare|quando\s+posso)\b/i; 321 322 /* ââ password reset intent detection via Claude API ââââââââââ 323 * Dedicated endpoint: /api/password-reset-intent 324 * Returns { isPasswordReset, email } or null on error. */ 325 async function analyzePasswordResetIntent(baseUrl, text) { 326 var t = text.toLowerCase().trim(); 327 if (t.length < 3) return null; 328 if (/^(ciao|salve|buongiorno|buonasera|grazie|ok|va bene|perfetto|arrivederci|bye)\s*[.!?]*$/i.test(t)) return null; 329 try { 330 var ctrl = new AbortController(); 331 var tid = setTimeout(function(){ ctrl.abort(); }, 4000); 332 var r = await fetch(baseUrl + "/api/password-reset-intent", { 333 method: "POST", 334 headers: { "Content-Type": "application/json" }, 335 body: JSON.stringify({ message: text }), 336 signal: ctrl.signal, 337 }); 338 clearTimeout(tid); 339 if (!r.ok) return null; 340 return await r.json(); 341 } catch (e) { return null; } 342 } 343 344 /* Regex fallback for password reset (used if Claude unavailable) */ 345 var PWD_RESET_KEYWORDS = 346 /\b(password|credenzial|accedere|accesso|login|loggare|entrare|recuper[aio]re|dimenticat[ao]|reimpost|reset)\b/i; 347 var PWD_RESET_CONTEXT =
348 /\b(personal\s*caf|area\s+(personale|riservata)|portale|profilo)\b/i; 349 350 /** Extract an email address from free text, if present. */ 351 function extractEmail(text) { 352 var m = (text || "").match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/); 353 return m ? m[0] : ""; 354 } 355 356 /* Client-side extraction of service + location from the user's message. 357 * Used both as fallback when Claude is unavailable AND to fill in gaps 358 * when Claude misses one of the two hints. */ 359 /* SOLO servizi gestiti dal CAF. NASPI, Assegno Unico Universale (INPS), 360 * pensioni e altri servizi di patronato NON appaiono qui â sono 361 * intercettati a monte dalla blocklist NON_CAF_SERVICE_RE. 362 * Pattern più specifici PRIMA dei più generici (primo match vince). */ 363 var CAF_SERVICE_PATTERNS = [ 364 {k: /\b730\b/i, name: "730"}, 365 {k: /\bise[ue]\b/i, name: "ISEE"}, 366 {k: /\bdsu\b/i, name: "DSU"}, 367 {k: /\bcud\b/i, name: "CUD"}, 368 {k: /\bsuccessi[oò]n\w*/i, name: "Successione"}, 369 {k: /\b(colf|badant\w*)\b/i, name: "Colf e Badanti"}, 370 {k: /\b(imu|tasi|tari)\b/i, name: "IMU/TASI/TARI"}, 371 {k: /\bmodello\s+redditi\b|\bunico\b/i, name: "Modello REDDITI"}, 372 {k: /\bmodello\s+red\b|\bred\b/i, name: "Modello RED"}, 373 {k: /\binvciv\b/i, name: "INVCIV"}, 374 {k: /\baccas\/?ps\b|\bassegno\s+sociale\b/i, name: "ACCAS/PS"}, 375 {k: /\bcedolare\s+secca\b|\blocazion[ei]\b/i, name: "Locazione"}, 376 {k: /\bdelega\s+unica\b/i, name: "Delega Unica"}, 377 {k: /\bvisur[ae]\s+catastal[ei]\b|\bvisura\b/i, name: "Visura Catastale"}, 378 {k: /\bpartita\s+iva\b|\bsapi\b/i, name: "Partita IVA"}, 379 {k: /\bdichiarazion\w*/i, name: "dichiarazione"}, 380 {k: /\bbonus\b/i, name: "bonus"} 381 ]; 382 /* Italian provinces (major ones). Multi-word entries use capital-preserving name. */ 383 var ITALIAN_PROVINCES = [ 384 "Agrigento","Alessandria","Ancona","Aosta","Arezzo","Ascoli Piceno","Asti","Avellino", 385 "Bari","Barletta","Belluno","Benevento","Bergamo","Biella","Bologna","Bolzano","Brescia","Brindisi", 386 "Cagliari","Caltanissetta","Campobasso","Caserta","Catania","Catanzaro","Chieti","Como","Cosenza","Cremona","Crotone","Cuneo", 387 "Enna","Fermo","Ferrara","Firenze","Foggia","Forlì","Forli","Frosinone", 388 "Genova","Gorizia","Grosseto", 389 "Imperia","Isernia", 390 "La Spezia","Latina","Lecce","Lecco","Livorno","Lodi","Lucca", 391 "Macerata","Mantova","Massa","Matera","Messina","Milano","Modena","Monza", 392 "Napoli","Novara","Nuoro","Olbia","Oristano", 393 "Padova","Palermo","Parma","Pavia","Perugia","Pesaro","Pescara","Piacenza","Pisa","Pistoia","Pordenone","Potenza","Prato", 394 "Ragusa","Ravenna","Reggio Calabria","Reggio Emilia","Rieti","Rimini","Roma","Rovigo", 395 "Salerno","Sassari","Savona","Siena","Siracusa","Sondrio", 396 "Taranto","Teramo","Terni","Torino","Trapani","Trento","Treviso","Trieste", 397 "Udine","Urbino", 398 "Varese","Venezia","Verbania","Vercelli","Verona","Vibo Valentia","Vicenza","Viterbo" 399 ]; 400 function clientExtractIntent(text) { 401 var t = (text || "").trim(); 402 if (!t) return { isBooking: false, service: "", location: "" }; 403 var lower = t.toLowerCase(); 404 /* extract service */ 405 var service = ""; 406 for (var i = 0; i < CAF_SERVICE_PATTERNS.length; i++) { 407 if (CAF_SERVICE_PATTERNS[i].k.test(t)) { service = CAF_SERVICE_PATTERNS[i].name; break; } 408 } 409 /* extract location â sorted by longest first so "Reggio Calabria" wins over "Reggio" alone */ 410 var location = ""; 411 var sorted = ITALIAN_PROVINCES.slice().sort(function(a,b){return b.length-a.length;}); 412 for (var j = 0; j < sorted.length; j++) { 413 var pv = sorted[j]; 414 var re = new RegExp("\\b" + pv.replace(/[.*+?^${}()|[\]\\]/g,"\\$&") + "\\b", "i"); 415 if (re.test(t)) { location = pv; break; } 416 } 417 var isBooking = BOOKING_KEYWORDS.test(lower) || (!!service && /\b(a|in|di|presso)\s+/i.test(t)); 418 return { isBooking: isBooking, service: service, location: location }; 419 } 420 421 /** Build a human-readable list of available province names. 422 * If there are too many (national coverage), return a short summary. */ 423 function formatProvinceList(provList) { 424 if (!provList || !provList.length) return ""; 425 var names = provList.map(function(p) { return p.descrizione; }); 426 if (names.length === 1) return names[0]; 427 if (names.length <= 10) return names.slice(0, -1).join(", ") + " e " + names[names.length - 1]; 428 /* too many â just say "tutta Italia" or show count */ 429 return "tutte le province italiane (" + names.length + " province)"; 430 } 431 432 /** Try to match a location string against the available provinces. 433 * Returns the matched province or null. */ 434 function matchProvince(locText, provList) { 435 if (!locText || !provList || !provList.length) return null; 436 var locLow = locText.toLowerCase() 437 .replace(/\b(sono|abito|mi trovo|sto|vivo|lavoro|vengo)\s+(a|di|da|in)\b/g, "") 438 .replace(/\bprovincia\s+di\b/g, "") 439 .replace(/\b(centro|sud|nord|est|ovest|zona)\b/g, "") 440 .replace(/^provincia\s+di\s+/i, "") 441 .replace(/[^a-z\u00e0-\u00fa\s]/g, "").replace(/\s+/g, " ").trim(); 442 if (!locLow) return null; 443 var best = null, bestScore = 0; 444 provList.forEach(function(pv) { 445 var pn = (pv.descrizione || "").toLowerCase(); 446 if (locLow === pn) { if (100 > bestScore) { bestScore = 100; best = pv; } } 447 else if (locLow.indexOf(pn) !== -1 || pn.indexOf(locLow) !== -1) { 448 if (80 > bestScore) { bestScore = 80; best = pv; } 449 }
450 locLow.split(/\s+/).forEach(function(w) { 451 if (w.length > 2 && pn.indexOf(w) !== -1 && 60 > bestScore) { bestScore = 60; best = pv; } 452 }); 453 }); 454 return best; 455 } 456 457 /** Score how well a service matches a search term (0 = no match) */ 458 function matchService(srv, hint) { 459 if (!hint) return 0; 460 var h = hint.toLowerCase(); 461 var name = ((srv.desonline || "") + " " + (srv.descrizione || "")).toLowerCase(); 462 if (name.indexOf(h) !== -1) return 100; 463 var words = h.split(/\s+/).filter(function(w){ return w.length > 1; }); 464 if (words.length > 0) { 465 var found = words.filter(function(w){ return name.indexOf(w) !== -1; }); 466 if (found.length === words.length) return 80; 467 if (found.length > 0) return 40 * found.length / words.length; 468 } 469 return 0; 470 } 471 472 /* ââ voice booking helpers âââââââââââââââââââââââââââââââââââââ */ 473 474 /** Parse spoken Italian numbers/ordinals to integer (1-based) */ 475 function parseVoiceNumber(text) { 476 var t = text.toLowerCase().trim(); 477 var map = { 478 "uno":1,"un":1,"primo":1,"prima":1,"il primo":1,"la prima":1, 479 "due":2,"secondo":2,"seconda":2,"il secondo":2,"la seconda":2, 480 "tre":3,"terzo":3,"terza":3,"il terzo":3,"la terza":3, 481 "quattro":4,"quarto":4,"quarta":4, 482 "cinque":5,"quinto":5,"quinta":5, 483 "sei":6,"sesto":6,"sesta":6, 484 "sette":7,"settimo":7,"settima":7, 485 "otto":8,"ottavo":8,"ottava":8, 486 "nove":9,"nono":9,"nona":9, 487 "dieci":10,"decimo":10,"decima":10 488 }; 489 /* exact match first */ 490 if (map[t] !== undefined) return map[t]; 491 /* digit match */ 492 var m = t.match(/\b(\d+)\b/); 493 if (m) return parseInt(m[1], 10); 494 /* substring match â prefer LAST match (handles "non il primo, il secondo") */ 495 var lastVal = null, lastPos = -1; 496 for (var key in map) { 497 var idx = t.lastIndexOf(key); 498 if (idx !== -1 && idx > lastPos) { lastPos = idx; lastVal = map[key]; } 499 } 500 return lastVal; 501 } 502 503 /** Try to match a spoken day/time to a slot. 504 * Handles: day names (lunedì), relative days (domani, oggi, dopodomani), 505 * time of day (mattina, pomeriggio), spoken hours (alle dieci, alle 15), 506 * and specific dates (il 15, il quindici). */ 507 function matchSlotByVoice(text, spazi) { 508 if (!spazi || !spazi.length) return null; 509 var t = text.toLowerCase(); 510 511 /* helper: parse slot data string to Date */ 512 function slotDate(s) { return new Date(+s.data.slice(0,4), +s.data.slice(4,6)-1, +s.data.slice(6,8)); } 513 function slotHour(s) { return +(s.ora_inizio.slice(0,2)); } 514 515 /* helper: filter candidates by time-of-day or spoken hour */ 516 function filterByTime(candidates) { 517 if (!candidates.length) return null; 518 var wantMorning = /\b(mattin[ao]|mattino)\b/.test(t); 519 var wantAfternoon = /\b(pomeriggi[oa]|sera)\b/.test(t); 520 /* try to extract a spoken hour: "alle dieci", "alle 15", "ore 9" */ 521 var hourMatch = t.match(/\b(?:alle|ore)\s+(\d{1,2})/); 522 if (!hourMatch) { 523 var hourWords = {"una":1,"due":2,"tre":3,"quattro":4,"cinque":5,"sei":6,"sette":7, 524 "otto":8,"nove":9,"dieci":10,"undici":11,"dodici":12,"tredici":13,"quattordici":14, 525 "quindici":15,"sedici":16,"diciassette":17,"diciotto":18,"diciannove":19,"venti":20}; 526 var hm = t.match(/\b(?:alle|ore)\s+([a-zà -ú]+)\b/); 527 if (hm && hourWords[hm[1]] !== undefined) hourMatch = [null, String(hourWords[hm[1]])]; 528 } 529 if (hourMatch) { 530 var wantH = parseInt(hourMatch[1], 10); 531 /* find closest matching hour */ 532 var best = null, bestDiff = 999; 533 candidates.forEach(function(s) { 534 var diff = Math.abs(slotHour(s) - wantH); 535 if (diff < bestDiff) { bestDiff = diff; best = s; } 536 }); 537 return bestDiff <= 1 ? best : candidates[0]; /* within 1 hour tolerance */ 538 } 539 if (wantMorning) { 540 var morn = candidates.filter(function(s) { return slotHour(s) < 13; }); 541 return morn.length ? morn[0] : candidates[0]; 542 } 543 if (wantAfternoon) { 544 var aft = candidates.filter(function(s) { return slotHour(s) >= 13; }); 545 return aft.length ? aft[0] : candidates[0]; 546 } 547 return candidates[0]; 548 } 549 550 /* === relative day matching: oggi, domani, dopodomani === */ 551 var now = new Date(); 552 var todayStr = String(now.getFullYear()) + String(now.getMonth()+1).padStart(2,"0") + String(now.getDate()).padStart(2,"0"); 553 var tom = new Date(now); tom.setDate(tom.getDate()+1); 554 var tomStr = String(tom.getFullYear()) + String(tom.getMonth()+1).padStart(2,"0") + String(tom.getDate()).padStart(2,"0"); 555 var dot = new Date(now); dot.setDate(dot.getDate()+2); 556 var dotStr = String(dot.getFullYear()) + String(dot.getMonth()+1).padStart(2,"0") + String(dot.getDate()).padStart(2,"0"); 557 558 if (/\boggi\b/.test(t)) { 559 var todaySl = spazi.filter(function(s) { return s.data === todayStr; }); 560 if (todaySl.length) return filterByTime(todaySl); 561 } 562 if (/\bdomani\b/.test(t)) { 563 var tomSl = spazi.filter(function(s) { return s.data === tomStr; }); 564 if (tomSl.length) return filterByTime(tomSl); 565 } 566 if (/\bdopodomani\b/.test(t)) { 567 var dotSl = spazi.filter(function(s) { return s.data === dotStr; }); 568 if (dotSl.length) return filterByTime(dotSl); 569 } 570 571 /* === "il primo disponibile" / "il prossimo" === */ 572 if (/\b(primo\s+disponibile|prossimo|prima\s+disponibile|prossima)\b/.test(t)) { 573 return filterByTime(spazi); 574 } 575 576 /* === day-of-week matching === */ 577 var dayMap = {"luned\u00ec":1,"lunedi":1,"marted\u00ec":2,"martedi":2, 578 "mercoled\u00ec":3,"mercoledi":3,"gioved\u00ec":4,"giovedi":4, 579 "venerd\u00ec":5,"venerdi":5,"sabato":6,"domenica":0}; 580 for (var dayName in dayMap) { 581 if (t.indexOf(dayName) !== -1) { 582 var dayCandidates = spazi.filter(function(s) { return slotDate(s).getDay() === dayMap[dayName]; }); 583 if (dayCandidates.length) return filterByTime(dayCandidates); 584 } 585 } 586 587 /* === specific date: "il 15", "il quindici", "15 aprile" === */ 588 var dateNum = null; 589 var dm = t.match(/\bil\s+(\d{1,2})\b/); 590 if (dm) dateNum = parseInt(dm[1], 10); 591 if (!dateNum) { 592 var dateWords = {"primo":1,"due":2,"tre":3,"quattro":4,"cinque":5,"sei":6,"sette":7,"otto":8,"nove":9, 593 "dieci":10,"undici":11,"dodici":12,"tredici":13,"quattordici":14,"quindici":15,"sedici":16, 594 "diciassette":17,"diciotto":18,"diciannove":19,"venti":20,"ventuno":21,"ventidue":22, 595 "ventitre":23,"ventiquattro":24,"venticinque":25,"ventisei":26,"ventisette":27,"ventotto":28, 596 "ventinove":29,"trenta":30,"trentuno":31}; 597 var dwm = t.match(/\bil\s+([a-zà -ú]+)\b/); 598 if (dwm && dateWords[dwm[1]] !== undefined) dateNum = dateWords[dwm[1]]; 599 } 600 if (!dateNum) { var dm2 = t.match(/\b(\d{1,2})\s+(gennaio|febbraio|marzo|aprile|maggio|giugno|luglio|agosto|settembre|ottobre|novembre|dicembre)\b/); if (dm2) dateNum = parseInt(dm2[1], 10); } 601 if (dateNum) { 602 var dateCandidates = spazi.filter(function(s) { return slotDate(s).getDate() === dateNum; }); 603 if (dateCandidates.length) return filterByTime(dateCandidates); 604 } 605 606 /* === just time without day: "alle dieci", "pomeriggio" === */ 607 var hasTimeOnly = /\b(alle|ore|mattin|pomeriggi|sera)\b/.test(t) && !dateNum; 608 if (hasTimeOnly) { 609 return filterByTime(spazi); 610 } 611 612 return null; 613 } 614 615 /** Match spoken text to a sede */ 616 function matchSedeByVoice(text, sedi) { 617 if (!text || !sedi || !sedi.length) return null; 618 var t = text.toLowerCase().trim(); 619 var best = null, bestScore = 0; 620 sedi.forEach(function(s) { 621 var name = ((s.descrizione || "") + " " + (s.citta || "")).toLowerCase(); 622 if (name.indexOf(t) !== -1 || t.indexOf((s.descrizione || "").toLowerCase()) !== -1) { 623 if (100 > bestScore) { bestScore = 100; best = s; } 624 } else { 625 var words = t.split(/\s+/).filter(function(w) { return w.length > 2; }); 626 var found = words.filter(function(w) { return name.indexOf(w) !== -1; }); 627 if (found.length > 0) {
628 var sc = 60 * found.length / words.length; 629 if (sc > bestScore) { bestScore = sc; best = s; } 630 } 631 } 632 }); 633 return bestScore >= 40 ? best : null; 634 } 635 636 function capitalizeFirst(str) { 637 if (!str) return str; 638 return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); 639 } 640 641 /** Convert spoken Italian number words to digits for phone numbers. 642 * Handles: single digits, tens, hundreds, compound numbers (trecentotrentotto), 643 * "doppio"/"triplo" prefixes, and "più" for + prefix. */ 644 function voiceToDigits(text) { 645 var t = " " + text.toLowerCase().replace(/['']/g, " ").replace(/\s+/g, " ").trim() + " "; 646 647 /* "doppio X" â X X, "triplo X" â X X X */ 648 t = t.replace(/\b(doppio|doppia)\s+(\S+)/gi, function(_,__,w){ return w + " " + w; }); 649 t = t.replace(/\b(triplo|tripla)\s+(\S+)/gi, function(_,__,w){ return w + " " + w + " " + w; }); 650 651 /* "più" â + (for international prefix) */ 652 t = t.replace(/\bpi[uù]\b/gi, "+"); 653 654 /* compound hundreds: trecentotrentotto â 338 etc. */ 655 var units = {"un":1,"uno":1,"due":2,"tre":3,"quattro":4,"cinque":5,"sei":6,"sette":7,"otto":8,"nove":9}; 656 var teens = {"dieci":10,"undici":11,"dodici":12,"tredici":13,"quattordici":14,"quindici":15,"sedici":16,"diciassette":17,"diciotto":18,"diciannove":19}; 657 var tens = {"venti":20,"trenta":30,"quaranta":40,"cinquanta":50,"sessanta":60,"settanta":70,"ottanta":80,"novanta":90}; 658 var hundreds = {"cento":100,"duecento":200,"trecento":300,"quattrocento":400,"cinquecento":500,"seicento":600,"settecento":700,"ottocento":800,"novecento":900}; 659 660 /* expand compound words like "trecentotrentotto" */ 661 function expandCompound(word) { 662 var w = word.toLowerCase(), n = 0, found = false; 663 /* try hundreds prefix */ 664 for (var hk in hundreds) { if (w.indexOf(hk) === 0) { n += hundreds[hk]; w = w.slice(hk.length); found = true; break; } } 665 if (!found && w === "cento") { return "100"; } 666 /* try teens */ 667 for (var tk in teens) { if (w === tk || w.indexOf(tk) === 0) { n += teens[tk]; w = w.slice(tk.length); return String(n); } } 668 /* try tens */ 669 for (var dk in tens) { if (w.indexOf(dk) === 0) { n += tens[dk]; w = w.slice(dk.length); break; } } 670 /* try units */ 671 for (var uk in units) { if (w === uk || (w.length > 0 && w.indexOf(uk) === 0 && w.length <= uk.length + 1)) { n += units[uk]; w = ""; break; } } 672 if (n > 0 && w.length === 0) return String(n); 673 if (found && n > 0) return String(n); /* partial compound */ 674 return null; /* not a compound number */ 675 } 676 677 /* first pass: try to expand compound number words */ 678 t = t.replace(/[a-zà -ú]+/gi, function(word) { 679 var exp = expandCompound(word); 680 return exp !== null ? exp : word; 681 }); 682
683 /* second pass: simple digit word replacements for any remaining */ 684 var simpleMap = {"zero":"0","uno":"1","un":"1","due":"2","tre":"3","quattro":"4", 685 "cinque":"5","sei":"6","sette":"7","otto":"8","nove":"9","dieci":"10"}; 686 for (var sw in simpleMap) { 687 t = t.replace(new RegExp("\\b" + sw + "\\b", "gi"), simpleMap[sw]); 688 } 689 690 return t.replace(/\s+/g, "").replace(/[^0-9+]/g, ""); 691 } 692 693 /** Clean spoken email: "chiocciola" â @, "punto" â ., "trattino" â -, etc. */ 694 function voiceToEmail(text) { 695 /* Apply word-boundary patterns BEFORE stripping spaces */ 696 return text.toLowerCase() 697 .replace(/\bchiocciola\b/g, "@").replace(/\bat\b/g, "@") 698 .replace(/\bpunto\b/g, ".").replace(/\bdot\b/g, ".") 699 .replace(/\btrattino\s*basso\b/g, "_").replace(/\bunderscore\b/g, "_") 700 .replace(/\btrattino\b/g, "-").replace(/\bmeno\b/g, "-") 701 .replace(/\bpi[uù]\b/g, "+") 702 .replace(/\s+/g, ""); 703 } 704 705 /* ââ planner date/time formatting ââââââââââââââââââââââââââââ */ 706 var B_DAY = ["Domenica","Luned\u00ec","Marted\u00ec","Mercoled\u00ec","Gioved\u00ec","Venerd\u00ec","Sabato"]; 707 var B_MON = ["Gennaio","Febbraio","Marzo","Aprile","Maggio","Giugno","Luglio","Agosto","Settembre","Ottobre","Novembre","Dicembre"]; 708 /* WCAG 3.1.2 (#C68): nomi giorno/mese localizzati via BK_I18N (cal_days_full/ 709 * cal_months); B_DAY/B_MON restano come fallback se le chiavi mancassero. */ 710 function fmtDate(s){if(!s||s.length<8)return s||"";var y=s.slice(0,4),m=s.slice(4,6),d=s.slice(6,8),dt=new Date(+y,+m-1,+d);if(isNaN(dt))return s;var dd=bkT("cal_days_full").split("|"),mm=bkT("cal_months").split("|");return (dd[dt.getDay()]||B_DAY[dt.getDay()])+" "+d+" "+(mm[+m-1]||B_MON[+m-1])+" "+y;} 711 function fmtTime(s){return(!s||s.length<4)?s||"":s.slice(0,2)+":"+s.slice(2,4);} 712 713 /* ââ codice fiscale (CF) â validazione algoritmica con checksum ââââââââ 714 * Algoritmo ufficiale Agenzia delle Entrate (DPR 605/1973 e s.m.i.). 715 * Restituisce { ok: bool, reason: string } per dare messaggi precisi. */ 716 var CF_CHK_ODD={"0":1,"1":0,"2":5,"3":7,"4":9,"5":13,"6":15,"7":17,"8":19,"9":21, 717 "A":1,"B":0,"C":5,"D":7,"E":9,"F":13,"G":15,"H":17,"I":19,"J":21,"K":2,"L":4,"M":18,"N":20, 718 "O":11,"P":3,"Q":6,"R":8,"S":12,"T":14,"U":16,"V":10,"W":22,"X":25,"Y":24,"Z":23}; 719 var CF_CHK_EVEN={"0":0,"1":1,"2":2,"3":3,"4":4,"5":5,"6":6,"7":7,"8":8,"9":9, 720 "A":0,"B":1,"C":2,"D":3,"E":4,"F":5,"G":6,"H":7,"I":8,"J":9,"K":10,"L":11,"M":12,"N":13, 721 "O":14,"P":15,"Q":16,"R":17,"S":18,"T":19,"U":20,"V":21,"W":22,"X":23,"Y":24,"Z":25}; 722 var CF_CHK_LET="ABCDEFGHIJKLMNOPQRSTUVWXYZ"; 723 function validateCodiceFiscale(raw){ 724 if(!raw)return{ok:false,reason:bkT("err_cf_required")}; 725 var cf=String(raw).toUpperCase().replace(/\s+/g,""); 726 if(cf.length<16)return{ok:false,reason:bkT("err_cf_short",(16-cf.length))}; 727 if(cf.length>16)return{ok:false,reason:bkT("err_cf_long",(cf.length-16))}; 728 if(!/^[A-Z0-9]{16}$/.test(cf))return{ok:false,reason:bkT("err_cf_chars")}; 729 /* layout standard CF persona fisica: 6 lettere + 2 cifre + 1 lettera + 2 cifre + 1 lettera + 3 alfanum + 1 lettera */ 730 if(!/^[A-Z]{6}[0-9LMNPQRSTUV]{2}[A-Z][0-9LMNPQRSTUV]{2}[A-Z][0-9LMNPQRSTUV]{3}[A-Z]$/.test(cf)){ 731 return{ok:false,reason:bkT("err_cf_format")}; 732 } 733 var sum=0; 734 for(var i=0;i<15;i++){var ch=cf.charAt(i);var v=(i%2===0)?CF_CHK_ODD[ch]:CF_CHK_EVEN[ch];if(v===undefined)return{ok:false,reason:bkT("err_cf_charpos",(i+1))};sum+=v;} 735 var expected=CF_CHK_LET.charAt(sum%26); 736 if(cf.charAt(15)!==expected)return{ok:false,reason:bkT("err_cf_checksum",expected,cf.charAt(15))}; 737 return{ok:true,reason:""}; 738 } 739 740 /* ââ #42 GDPR consent gating multi-tier + #43 auto-clear + #45 obfuscation ââ 741 * 742 * 3-TIER CONSENT MODEL (compliant con linee guida EDPB 03/2022): 743 * - "necessary": funzionamento del widget (cache regioni/sedi, lingua, 744 * lookup geo provincia approssimato dal browser). NO opt-in 745 * richiesto perché senza non funziona. 746 * - "functional": memorizzazione dati cliente (CF, nome, email, tel), 747 * history prenotazioni, resume state. Opt-in obbligatorio. 748 * - "analytics": sessionId persistente, UTM/gclid/fbclid, _bkLogEvent 749 * verso /api/planner/log-event. Opt-in obbligatorio SEPARATO (non si 750 * può vincolare il funzionale all'accettazione degli analytics). 751 * 752 * VERSIONING: se cambia il modello del consenso, invalidiamo i record esistenti 753 * incrementando BK_CONSENT_VERSION. */ 754 var BK_CONSENT_VERSION=2; 755 var BK_CLIENT_KEY="caf-bk-client-v2"+CAF_NS; 756 var BK_CLIENT_FAMILY_KEY="caf-bk-client-family-v2"+CAF_NS; 757 var BK_CONSENT_KEY="caf-bk-consent-v2"+CAF_NS; 758 var BK_CLIENT_TTL_MS=30*24*60*60*1000; /* 30 giorni inattività */ 759 /* legacy keys da migrare/cancellare al primo passaggio */ 760 var BK_LEGACY_KEYS=["caf-bk-client-v1","caf-bk-client-family-v1","caf-bk-consent-v1"]; 761 /* Chiavi scritte dalle versioni deployate PRIMA del namespacing per-tenant 762 * (CAF_NS): contengono PII/cronologia fuori dal gating attuale (GDPR 763 * artt. 5(1)(e), 17). Rimosse una tantum al bootstrap e incluse nel wipe. */ 764 var BK_PRE_NS_KEYS=["caf-bk-client-v2","caf-bk-client-family-v2","caf-bk-consent-v2", 765 "caf-bk-history-v1","caf-bk-resume-v1","caf-bk-freq-v1","caf-bk-sid","caf-bk-lang", 766 "caf-chat-inline-history","caf-chat-inline-conv-id","caf-chat-inline-email","caf-chat-consent-v1"]; 767 (function bkPurgePreNamespaceKeys(){ 768 try{ 769 /* con CAF_NS vuoto le chiavi "nude" SONO quelle attive: non toccarle */ 770 if(!CAF_NS)return;
771 BK_PRE_NS_KEYS.forEach(function(k){try{localStorage.removeItem(k);}catch(_e){}}); 772 for(var _i=localStorage.length-1;_i>=0;_i--){ 773 var _k=localStorage.key(_i)||""; 774 if((/^caf-bk-cache-v\d+:/.test(_k)||/^caf-bk-slotcache-v\d+:/.test(_k))&&_k.indexOf(CAF_NS)===-1){ 775 try{localStorage.removeItem(_k);}catch(_e){} 776 } 777 } 778 }catch(_e){} 779 })(); 780 781 function bkConsentLoad(){ 782 try{ 783 var r=localStorage.getItem(BK_CONSENT_KEY);if(!r)return null; 784 var c=JSON.parse(r);if(!c||typeof c!=="object")return null; 785 /* invalidate se versione obsoleta: l'utente deve dare nuovo consenso */ 786 if((+c.version||0)!==BK_CONSENT_VERSION)return null; 787 return c; 788 }catch(_e){return null;} 789 } 790 function bkConsentSave(consent){ 791 try{ 792 var payload={ 793 version:BK_CONSENT_VERSION, 794 functional:!!(consent&&consent.functional), 795 analytics:!!(consent&&consent.analytics), 796 ts:Date.now(), 797 /* mini audit trail: page where consent was given */ 798 page:(typeof location!=="undefined"&&location.pathname)||"", 799 ua:(typeof navigator!=="undefined"&&navigator.userAgent.
799slice(0,200))||"" 800 }; 801 localStorage.setItem(BK_CONSENT_KEY,JSON.stringify(payload)); 802 return payload; 803 }catch(_e){return null;} 804 } 805 /* bkConsentHas(scope) â scope = "functional" | "analytics" | (undefined â any) */ 806 function bkConsentHas(scope){ 807 var c=bkConsentLoad();if(!c)return false; 808 if(scope==="functional")return!!c.functional; 809 if(scope==="analytics")return!!c.analytics; 810 /* legacy compat: chiamate senza scope = check se almeno qualcosa è acconsentito */ 811 return!!(c.functional||c.analytics); 812 } 813 /* True se l'utente ha mai espresso una scelta esplicita (anche "rifiuta tutto"). 814 * Usato per decidere se mostrare il banner all'apertura widget. */ 815 function bkConsentExpressed(){return bkConsentLoad()!=null;} 816 function bkConsentDeny(){ 817 bkConsentSave({functional:false,analytics:false}); 818 /* Wipe immediato di TUTTI i dati personali e di tracking */ 819 try{ 820 [BK_CLIENT_KEY,BK_CLIENT_FAMILY_KEY,BK_HISTORY_KEY,BK_RESUME_KEY,BK_FREQ_KEY,BK_SID_KEY] 821 .concat(BK_LEGACY_KEYS).forEach(function(k){localStorage.removeItem(k);}); 822 }catch(_e){} 823 } 824 /* Helper: revoca un singolo scope (functional o analytics) e cancella i dati 825 * collegati. Utile per i toggle nel pannello "preferenze". */ 826 function bkConsentRevoke(scope){ 827 var c=bkConsentLoad()||{functional:false,analytics:false}; 828 var nc={functional:c.functional,analytics:c.analytics}; 829 if(scope==="functional"){ 830 nc.functional=false; 831 try{[BK_CLIENT_KEY,BK_CLIENT_FAMILY_KEY,BK_HISTORY_KEY,BK_RESUME_KEY,BK_FREQ_KEY].forEach(function(k){localStorage.removeItem(k);});}catch(_e){} 832 } 833 if(scope==="analytics"){ 834 nc.analytics=false; 835 try{localStorage.removeItem(BK_SID_KEY);}catch(_e){} 836 } 837 bkConsentSave(nc); 838 } 839 840 /* Light XOR obfuscation con key derivata. NON è crittografia: è camouflage 841 * per evitare di vedere CF/email in chiaro nei DevTools. */ 842 function _bkObfKey(){ 843 var seed=(navigator.userAgent||"")+(navigator.language||"")+location.host; 844 var h=0;for(var i=0;i<seed.length;i++)h=((h<<5)-h+seed.charCodeAt(i))|0; 845 return Math.abs(h%251)+5; 846 } 847 function _bkXor(s,k){var out="";for(var i=0;i<s.length;i++)out+=String.fromCharCode(s.charCodeAt(i)^k);return out;} 848 function _bkObfuscate(obj){try{var json=JSON.stringify(obj);var k=_bkObfKey();return"o2:"+k+":"+btoa(unescape(encodeURIComponent(_bkXor(json,k))));}catch(_e){return JSON.stringify(obj);}} 849 function _bkDeobfuscate(raw){ 850 if(!raw)return null; 851 try{ 852 if(raw.indexOf("o2:")===0){ 853 var parts=raw.split(":");var k=+parts[1];var b64=parts.slice(2).join(":"); 854 var dec=decodeURIComponent(escape(atob(b64)));return JSON.parse(_bkXor(dec,k)); 855 } 856 /* fallback compat con dati legacy in chiaro */ 857 return JSON.parse(raw); 858 }catch(_e){return null;} 859 } 860 861 /* Migrazione one-shot da legacy keys â nuovo formato cifrato. 862 * Si avvia all'init e fa nulla se la nuova key esiste già . */ 863 function bkMigrateLegacyClientKeys(){ 864 try{ 865 var legacyMap=[["caf-bk-client-v1",BK_CLIENT_KEY],["caf-bk-client-family-v1",BK_CLIENT_FAMILY_KEY]]; 866 /* Senza consenso funzionale non ri-persistiamo PII legacy: la rimuoviamo 867 * (GDPR artt. 6, 17) invece di migrarla nel nuovo formato cifrato. */ 868 var hasFn=bkConsentHas("functional"); 869 legacyMap.forEach(function(pair){ 870 var oldK=pair[0],newK=pair[1]; 871 var oldRaw=localStorage.getItem(oldK);if(!oldRaw)return; 872 if(!hasFn){try{localStorage.removeItem(oldK);}catch(_e){}return;} 873 if(localStorage.getItem(newK)){try{localStorage.removeItem(oldK);}catch(_e){}return;} /* già migrato */ 874 try{var obj=JSON.parse(oldRaw);if(obj&&typeof obj==="object"){localStorage.setItem(newK,_bkObfuscate(Object.assign({},obj,{ts:Date.now()})));}}catch(_e){} 875 localStorage.removeItem(oldK); 876 }); 877 }catch(_e){} 878 } 879 880 /* #43 â Auto-clear: se il payload ha ts > 30gg di inattività , rimuove. */ 881 function bkAutoClearStaleClient(){ 882 try{ 883 [BK_CLIENT_KEY,BK_CLIENT_FAMILY_KEY].forEach(function(k){ 884 var raw=localStorage.getItem(k);if(!raw)return; 885 var obj=_bkDeobfuscate(raw);if(!obj)return; 886 var ts=+obj.ts||0; 887 if(!ts||(Date.now()-ts)>BK_CLIENT_TTL_MS){ 888 localStorage.removeItem(k); 889 } 890 }); 891 }catch(_e){} 892 } 893 894 function clientStorageKey(forWhom){return forWhom==="family"?BK_CLIENT_FAMILY_KEY:BK_CLIENT_KEY;} 895 function loadStoredClient(forWhom){ 896 try{ 897 /* #PII â niente lettura di dati personali senza consenso funzionale 898 * (prima solo la scrittura era gated â su device condiviso i dati di una 899 * persona precedente potevano essere letti anche senza consenso). */ 900 if(!bkConsentHas("functional"))return null; 901 var raw=localStorage.getItem(clientStorageKey(forWhom));if(!raw)return null; 902 var p=_bkDeobfuscate(raw);if(!p||typeof p!=="object")return null; 903 /* Auto-clear inline: se stale, rimuovi e ritorna null */ 904 if(p.ts&&(Date.now()-p.ts)>BK_CLIENT_TTL_MS){localStorage.removeItem(clientStorageKey(forWhom));return null;} 905 return{nome:p.nome||"",cognome:p.cognome||"",codfis:p.codfis||"",email:p.email||"",cellulare:p.cellulare||""}; 906 }catch(e){return null;} 907 } 908 function saveStoredClient(c,forWhom){ 909 try{ 910 if(!c)return; 911 /* #42 â gating: niente persist di PII senza consenso FUNZIONALE (non basta 912 * l'analytics: i dati personali si salvano solo col consenso funzionale). */ 913 if(!bkConsentHas("functional"))return; 914 var payload={nome:c.nome||"",cognome:c.cognome||"",codfis:c.codfis||"",email:c.email||"",cellulare:c.
914cellulare||"",ts:Date.now()}; 915 localStorage.setItem(clientStorageKey(forWhom),_bkObfuscate(payload)); 916 }catch(e){} 917 } 918 919 /* ââ ICS calendar file generator âââââââââââââââââââââââââââââââââââââ */ 920 function pad2(n){n=+n;return n<10?"0"+n:""+n;} 921 function buildIcsForBooking(booking){ 922 /* booking: {bookingId, sede:{descrizione,indirizzo,citta}, servizio:{descrizione}, slot:{data:"YYYYMMDD", ora_inizio:"HHMM", ora_fine:"HHMM"}} */ 923 var slot=booking.slot||{};var data=slot.data||"";var oi=slot.ora_inizio||"0900";var of=slot.ora_fine||oi; 924 if(data.length<8)return null; 925 /* Costruisci datetime UTC approssimato: trattiamo l'orario come Europe/Rome. 926 * Per semplicità ICS uso TZID=Europe/Rome senza VTIMEZONE â la maggior parte 927 * dei client (Google/Apple/Outlook) lo gestisce correttamente. */ 928 var dtStart=data+"T"+oi.padStart(4,"0").slice(0,4)+"00"; 929 var dtEnd=data+"T"+of.padStart(4,"0").slice(0,4)+"00"; 930 var dtStartFmt=dtStart.slice(0,4)+dtStart.slice(4,6)+dtStart.slice(6,8)+"T"+dtStart.slice(9,11)+dtStart.slice(11,13)+"00"; 931 var dtEndFmt=dtEnd.slice(0,4)+dtEnd.slice(4,6)+dtEnd.slice(6,8)+"T"+dtEnd.slice(9,11)+dtEnd.slice(11,13)+"00"; 932 var sede=booking.sede||{};var srv=booking.servizio||{}; 933 var loc=[sede.descrizione,sede.indirizzo,sede.citta].filter(Boolean).join(", "); 934 var icsBrand=bkNeutralBrand("CAF CISL"); 935 var summary=icsBrand+" â "+(srv.descrizione||"Appuntamento"); 936 var desc="Prenotazione "+(booking.bookingId||"")+" â "+(srv.descrizione||"")+" presso "+loc; 937 var nowDt=new Date();var dtStamp=nowDt.getUTCFullYear()+pad2(nowDt.getUTCMonth()+1)+pad2(nowDt.getUTCDate())+"T"+pad2(nowDt.getUTCHours())+pad2(nowDt.getUTCMinutes())+pad2(nowDt.getUTCSeconds())+"Z"; 938 var ics=[ 939 "BEGIN:VCALENDAR","VERSION:2.0","PRODID:-//"+icsBrand.replace(/\/\//g,"/")+"//Booking Widget//IT","CALSCALE:GREGORIAN","METHOD:PUBLISH", 940 "BEGIN:VEVENT", 941 "UID:caf-cisl-"+(booking.bookingId||(""+Date.now()))+"@cafcisl.it", 942 "DTSTAMP:"+dtStamp, 943 "DTSTART;TZID=Europe/Rome:"+dtStartFmt, 944 "DTEND;TZID=Europe/Rome:"+dtEndFmt, 945 "SUMMARY:"+summary.replace(/[\r\n]/g," "), 946 "DESCRIPTION:"+desc.replace(/[\r\n]/g," "), 947 "LOCATION:"+loc.replace(/[\r\n]/g," "), 948 "END:VEVENT","END:VCALENDAR" 949 ].join("\r\n"); 950 return ics; 951 } 952 function downloadIcs(ics,filename){ 953 try{ 954 var blob=new Blob([ics],{type:"text/calendar;charset=utf-8"}); 955 var url=URL.createObjectURL(blob); 956 var a=document.createElement("a");a.href=url;a.download=filename||"appuntamento.ics";document.body.appendChild(a);a.click(); 957 setTimeout(function(){URL.revokeObjectURL(url);if(a.parentNode)a.parentNode.removeChild(a);},500); 958 }catch(e){} 959 } 960 function whatsappShareUrl(text){ 961 return "https://wa.me/?text="+encodeURIComponent(text); 962 } 963 964 /* ââ Email typo detection (Bayesian distance su domini comuni) âââââ */ 965 var EMAIL_DOMAINS=["gmail.com","hotmail.com","hotmail.it","yahoo.it","yahoo.com","libero.it","outlook.com","outlook.it","icloud.com","tin.it","alice.it","virgilio.it","tiscali.it","fastwebnet.it","pec.it","email.it","aruba.it","gmail.it"]; 966 function levenshtein(a,b){a=a||"";b=b||"";if(a===b)return 0;if(!a.length)return b.length;if(!b.length)return a.length;var m=[];for(var i=0;i<=b.length;i++)m[i]=[i];for(var j=0;j<=a.length;j++)m[0][j]=j;for(i=1;i<=b.length;i++){for(j=1;j<=a.length;j++){if(b.charAt(i-1)===a.charAt(j-1))m[i][j]=m[i-1][j-1];else m[i][j]=Math.min(m[i-1][j-1]+1,Math.min(m[i][j-1]+1,m[i-1][j]+1));}}return m[b.length][a.length];} 967 function suggestEmailDomain(email){ 968 if(!email||email.indexOf("@")<0)return null; 969 var parts=email.toLowerCase().trim().split("@");if(parts.length!==2||!parts[1])return null; 970 var dom=parts[1];if(EMAIL_DOMAINS.indexOf(dom)>=0)return null; /* già corretto */ 971 var best=null,bestDist=99; 972 for(var k=0;k<EMAIL_DOMAINS.length;k++){var d=levenshtein(dom,EMAIL_DOMAINS[k]);if(d<bestDist){bestDist=d;best=EMAIL_DOMAINS[k];}} 973 if(best&&bestDist>0&&bestDist<=2&&dom.length>=4)return parts[0]+"@"+best; 974 return null; 975 } 976
977 /* ââ Phone format (italiano: 333 123 4567 / 02 1234 5678) âââââââââââ */ 978 function formatPhoneIT(raw){ 979 var d=String(raw||"").replace(/\D/g,""); 980 if(!d)return ""; 981 /* mobile prefisso 3xx (3 + 3 + resto) */ 982 if(d.charAt(0)==="3"&&d.length>=10){return d.slice(0,3)+" "+d.slice(3,6)+" "+d.slice(6,10);} 983 /* fisso 0X (2-4 cifre prefisso + 6-8 cifre numero) â formato semplice 02 + spazi ogni 4 */ 984 if(d.charAt(0)==="0"&&d.length>=6){ 985 var pl=d.length<=10?2:3;return d.slice(0,pl)+" "+d.slice(pl,pl+4)+" "+d.slice(pl+4); 986 } 987 /* fallback: gruppi di 3 */ 988 return d.replace(/(\d{3})(?=\d)/g,"$1 ").trim(); 989 } 990 991 /* ââ Time-aware greeting âââââââââââââââââââââââââââââââââââââââââââââ */ 992 function timeAwareGreeting(){ 993 var h=new Date().getHours(); 994 if(h<5)return"Buonanotte";if(h<12)return"Buongiorno";if(h<18)return"Buon pomeriggio";return"Buonasera"; 995 } 996 997 /* ââ Mailto reminder generator ââââââââââââââââââââââââââââââââââââââ */ 998 function mailtoReminder(booking){ 999 var sp=booking.slot||{};var sd=booking.sede||{};var sv=booking.servizio||{};var to=booking.email||""; 1000 var mailBrand=bkNeutralBrand("CAF CISL"); 1001 var subj="Promemoria appuntamento "+mailBrand+" â "+(fmtDate(sp.data||"")||"appuntamento"); 1002 var body=[ 1003 "Promemoria del tuo appuntamento presso "+mailBrand+":", 1004 "", 1005 "Data: "+(fmtDate(sp.data||"")+" alle "+fmtTime(sp.ora_inizio||"")), 1006 "Servizio: "+(sv.descrizione||""), 1007 "Sede: "+(sd.descrizione||""), 1008 "Indirizzo: "+(sd.indirizzo?sd.indirizzo+(sd.citta?", "+sd.citta:""):""), 1009 sd.telefono?"Telefono sede: "+sd.telefono:"", 1010 booking.bookingId?"ID prenotazione: "+booking.bookingId:"", 1011 "", 1012 "Ricordati di portare i documenti necessari.", 1013 ].filter(Boolean).join("\n"); 1014 return "mailto:"+encodeURIComponent(to)+"?subject="+encodeURIComponent(subj)+"&body="+encodeURIComponent(body); 1015 } 1016 1017 /* ââ UTM tracking ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1018 * GDPR/ePrivacy: UTM/gclid/fbclid sono identificatori marketing â leggerli 1019 * e inviarli al backend richiede consenso "analytics". Senza consenso, 1020 * ritorniamo oggetto vuoto. */ 1021 function readUtmParams(){ 1022 if(!bkConsentHas("analytics"))return{}; 1023 try{ 1024 if(typeof location==="undefined"||!location.search)return{}; 1025 var u=new URLSearchParams(location.search); 1026 var out={};["utm_source","utm_medium","utm_campaign","utm_term","utm_content","gclid","fbclid"].forEach(function(k){var v=u.get(k);if(v)out[k]=v;}); 1027 return out; 1028 }catch(_e){return{};} 1029 } 1030 1031 /* ââ localStorage history per pattern detection (orario preferito) ââ 1032 * GDPR: gated da consenso "functional". Senza consenso, no save (no-op). */ 1033 var BK_HISTORY_KEY="caf-bk-history-v1"+CAF_NS;var BK_HISTORY_MAX=10; 1034 function pushBookingHistory(booking){ 1035 if(!bkConsentHas("functional"))return; /* GDPR gating */ 1036 try{ 1037 var raw=localStorage.getItem(BK_HISTORY_KEY);var arr=[];if(raw){try{arr=JSON.parse(raw)||[];}catch(_e){arr=[];}} 1038 arr.unshift({when:Date.now(),slot:booking.slot,servizio:booking.servizio,sede:booking.sede,bookingId:booking.bookingId,provincia:booking.provincia||null,regione:booking.regione||null}); 1039 if(arr.length>BK_HISTORY_MAX)arr=arr.slice(0,BK_HISTORY_MAX); 1040 localStorage.setItem(BK_HISTORY_KEY,JSON.stringify(arr)); 1041 }catch(_e){} 1042 } 1043 function loadBookingHistory(){ 1044 try{var raw=localStorage.getItem(BK_HISTORY_KEY);return raw?(JSON.parse(raw)||[]):[];}catch(_e){return[];} 1045 } 1046 /** Returns "morning" | "afternoon" | null based on past selections. */ 1047 function preferredTimeOfDay(){ 1048 var h=loadBookingHistory();if(h.length<2)return null; 1049 var morning=0,afternoon=0; 1050 h.forEach(function(b){var ora=(b&&b.slot&&b.slot.ora_inizio)||"";if(!ora)return;var hh=+ora.slice(0,2);if(isNaN(hh))return;if(hh<13)morning++;else afternoon++;}); 1051 if(morning>=afternoon*2&&morning>=2)return"morning"; 1052 if(afternoon>=morning*2&&afternoon>=2)return"afternoon"; 1053 return null; 1054 } 1055 1056 /* ââ Resume/abandonment state âââââââââââââââââââââââââââââââââââââââââ 1057 * GDPR: contiene client.codfis/email/cellulare â gated da "functional". */ 1058 var BK_RESUME_KEY="caf-bk-resume-v1"+CAF_NS;var BK_RESUME_TTL_MS=24*60*60*1000; 1059 function saveResumeState(state){ 1060 if(!bkConsentHas("functional"))return; /* GDPR gating */ 1061 try{var payload={ts:Date.now(),state:state};localStorage.setItem(BK_RESUME_KEY,JSON.stringify(payload));}catch(_e){} 1062 } 1063 function loadResumeState(){ 1064 try{var raw=localStorage.getItem(BK_RESUME_KEY);if(!raw)return null;var p=JSON.parse(raw);if(!p||!p.state)return null;if(Date.now()-(p.ts||0)>BK_RESUME_TTL_MS){localStorage.removeItem(BK_RESUME_KEY);return null;}return p.state;}catch(_e){return null;} 1065 } 1066 function clearResumeState(){try{localStorage.removeItem(BK_RESUME_KEY);}catch(_e){}} 1067 1068 /* ââ Static catalog cache (regioni/province/servizi) â TTL 24h âââââ 1069 * Riduce a 0ms il primo render dopo la prima visita evitando spinner. 1070 * IMPORTANTE: bump v2 per invalidare cache vecchie con "Toscana Regionale" 1071 * placeholder (ora il backend (PHP seeder) restituisce le 10 province reali). */ 1072 var BK_CACHE_PREFIX="caf-bk-cache-v3"+CAF_NS+":";var BK_CACHE_TTL_MS=24*60*60*1000; 1073 function bkCacheGet(key){ 1074 try{ 1075 var raw=localStorage.getItem(BK_CACHE_PREFIX+key);if(!raw)return null; 1076 var p=JSON.parse(raw);if(!p||(Date.now()-(p.ts||0))>BK_CACHE_TTL_MS){ 1077 localStorage.removeItem(BK_CACHE_PREFIX+key);return null; 1078 } 1079 return p.data; 1080 }catch(_e){return null;} 1081 } 1082 function bkCacheSet(key,data){ 1083 try{localStorage.setItem(BK_CACHE_PREFIX+key,JSON.stringify({ts:Date.now(),data:data}));}catch(_e){} 1084 } 1085 1086 /* ââ Haptic feedback (mobile only, no-op desktop) âââââââââââââââ */ 1087 function bkHaptic(ms){ 1088 try{if(navigator&&navigator.vibrate)navigator.vibrate(ms||8);}catch(_e){} 1089 } 1090 1091 /* ââ View Transitions API wrapper (Chrome/Safari TP) ââââââââââââ */ 1092 function bkViewTransition(fn){ 1093 try{ 1094 if(typeof document!=="undefined"&&document.startViewTransition){ 1095 return document.startViewTransition(fn); 1096 } 1097 }catch(_e){} 1098 try{fn();}catch(_e){} 1099 return null; 1100 } 1101 1102 /* ââ Normalize string: lower + remove accents + collapse spaces. ââ */ 1103 function bkNormalizeStr(s){ 1104 if(s==null)return""; 1105 s=String(s).toLowerCase(); 1106 try{s=s.normalize("NFD").replace(/[\u0300-\u036f]/g,"");}catch(_e){} 1107 return s.replace(/\s+/g," ").trim(); 1108 } 1109 1110 /* ââ Levenshtein distance (capped at maxDist for perf). ââ */ 1111 function bkLevenshtein(a,b,maxDist){ 1112 if(a===b)return 0; 1113 var la=a.length,lb=b.length; 1114 if(!la)return lb;if(!lb)return la; 1115 if(maxDist!=null&&Math.abs(la-lb)>
1115maxDist)return maxDist+1; 1116 var prev=new Array(lb+1),curr=new Array(lb+1); 1117 for(var j=0;j<=lb;j++)prev[j]=j; 1118 for(var i=1;i<=la;i++){ 1119 curr[0]=i;var rowMin=i; 1120 for(var k=1;k<=lb;k++){ 1121 var cost=a.charCodeAt(i-1)===b.charCodeAt(k-1)?0:1; 1122 curr[k]=Math.min(curr[k-1]+1,prev[k]+1,prev[k-1]+cost); 1123 if(curr[k]<rowMin)rowMin=curr[k]; 1124 } 1125 if(maxDist!=null&&rowMin>maxDist)return maxDist+1; 1126 var tmp=prev;prev=curr;curr=tmp; 1127 } 1128 return prev[lb]; 1129 } 1130 1131 /* ââ Sinonimi servizi: chiavi sono i match utente, valori sono token 1132 * canonici cercati nei nomi servizio. Tutto lowercased + senza accenti. ââ */ 1133 var BK_SINONIMI={ 1134 "dichiarazione redditi":["730","unico","modello redditi"], 1135 "dichiarazione dei redditi":["730","unico","modello redditi"], 1136 "dichiarazione":["730","unico","successione"], 1137 "redditi":["730","unico"], 1138 "tasse":["730","f24","imu","tari"], 1139 "tasse casa":["imu","tari","locazioni"], 1140 "casa":["imu","tari","locazioni","mutuo","successione"], 1141 "affitto":["locazioni","rli","cedolare"], 1142 "affitti":["locazioni","rli","cedolare"], 1143 "locazione":["locazioni","rli","cedolare"], 1144 "contratto affitto":["locazioni","redazione","rli"], 1145 "isee":["isee","dsu"], 1146 "reddito di cittadinanza":["isee","reddito"], 1147 "assegno unico":["isee","assegno"], 1148 "bonus":["isee","bonus","assegno"], 1149 "successione":["successione","eredita"], 1150 "eredità ":["successione"], 1151 "eredita":["successione"], 1152 "permessi soggiorno":["permesso","stranieri","soggiorno"], 1153 "cittadinanza":["cittadinanza","stranieri"], 1154 "trasporti":["atm","abbonamento"], 1155 "abbonamento":["atm","abbonamento","trasporti"], 1156 "patronato":["pensione","inps","disoccupazione","naspi"], 1157 "pensione":["pensione","inps"], 1158 "disoccupazione":["naspi","disoccupazione"], 1159 "naspi":["naspi","disoccupazione"], 1160 "colf":["colf","badanti","domestici"], 1161 "badanti":["badanti","colf","domestici"], 1162 "lavoratori domestici":["colf","badanti","domestici"], 1163 "f24":["f24","imu","tari"], 1164 "imu":["imu","f24"], 1165 "tari":["tari","f24"] 1166 }; 1167 1168 /* ââ Documenti richiesti per servizio (mapping client-side) ââââââââ 1169 * La chiave matcha case-insensitive su sotto-stringa nel nome servizio. */ 1170 var BK_DOC_REQUISITI=[ 1171 {match:["730","unico","modello redditi","dichiarazione redditi"],docs:["Codice fiscale","Documento d'identità ","Modello CU del datore","Spese detraibili (sanitarie, mutuo, scolastiche...)","IBAN per accredito rimborso"]}, 1172 {match:["isee","dsu"],docs:["Codice fiscale di tutti i componenti del nucleo","Documento d'identità ","Contratto affitto/atto di proprietà casa","Saldi e giacenza media conti correnti","Targhe veicoli posseduti"]}, 1173 {match:["successione"],docs:["Codice fiscale del defunto e degli eredi","Certificato di morte","Atto notorio o dichiarazione sostitutiva","Visure catastali immobili","Estratti conto e dossier titoli"]}, 1174 {match:["locazioni","rli","contratto affitto","cedolare"],docs:["Codice fiscale di locatore e conduttore","Dati catastali immobile","Copia contratto","Eventuale F24 per imposta di registro"]}, 1175 {match:["imu","f24"],docs:["Codice fiscale","Visure catastali immobili","Aliquote comunali (di solito le recuperiamo noi)"]}, 1176 {match:["pensione","inps"],docs:["Codice fiscale","Documento d'identità ","Buste paga / estratti contributivi","Eventuale Cedolino Pensione"]}, 1177 {match:["naspi","disoccupazione"],docs:["Codice fiscale","Documento d'identità ","Lettera di licenziamento o cessazione","IBAN per accredito"]}, 1178 {match:["colf","badanti","domestici"],docs:["Codice fiscale di datore e collaboratore","Documento d'identità del collaboratore (anche permesso soggiorno)","Dati del rapporto di lavoro (orario, retribuzione)"]}, 1179 {match:["atm","abbonamento"],docs:["Codice fiscale","Documento d'identità ","Eventuale ISEE per agevolazioni"]}, 1180 {match:["permesso","cittadinanza","stranieri"],docs:["Documento d'identità ","Codice fiscale","Permesso di soggiorno (se rinnovo)","Marca da bollo"]} 1181 ]; 1182 function bkDocsForServizio(srv){ 1183 if(!srv)return null; 1184 var name=bkNormalizeStr(srv.desonline||srv.descrizione||""); 1185 if(!name)return null; 1186 for(var i=0;i<BK_DOC_REQUISITI.length;i++){ 1187 var entry=BK_DOC_REQUISITI[i]; 1188 for(var j=0;j<entry.match.length;j++){ 1189 if(name.indexOf(entry.match[j])!==-1)return entry.docs; 1190 } 1191 } 1192 return null; 1193 } 1194 1195 /* ââ Mapping tipologia â categoria UI (icona + label + colore) ââââ */ 1196 var BK_TIPOLOGIE_CAT=[ 1197 {match:["730","fiscal","red","cu","unico","f24","imu","tari","tasse"],id:"fiscale",label:"Fiscale",icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 7h8M8 11h8M8 15h5"/></svg>',color:"#0ea5e9"}, 1198 /* "colf" PRIMA di "locazioni": il pattern "contratt" di locazioni 1199 * matchava "Colf-badanti: consegna nuovo contratto" (Belluno CBNEW) 1200 * mandando i servizi colf sotto l'icona Locazioni. Segnalato da 1201 * un operatore CAF il 21/05/26. Ora oltre al riordino abbiamo anche 1202 * la priorità per codtipologia (BK_CODTIPOLOGIA_TO_CAT) che è il 1203 * fix reale; questo riordino resta come safety net. */ 1204 {match:["colf","badant","domestic"],id:"colf",label:"Colf / Badanti",icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21v-2a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v2"/></svg>',color:"#ec4899"}, 1205 {match:["locazion","affitt","rli","cedolare","contratt"],id:"locazioni",label:"Locazioni",icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12 12 4l9 8"/><path d="M5 10v10h14V10"/></svg>',color:"#f59e0b"}, 1206 {match:["success","eredit"],id:"successioni",label:"Successioni",icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M5 9h14M5 15h14"/></svg>
1206',color:"#a855f7"}, 1207 {match:["isee","dsu","social","reddito","bonus","assegno"],id:"isee",label:"ISEE / Bonus",icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9 12h6M12 9v6"/></svg>',color:"#10b981"}, 1208 {match:["pension","inps","naspi","disoccup","patronato","contribut"],id:"patronato",label:"Patronato",icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11V7a3 3 0 0 1 6 0v4"/><rect x="5" y="11" width="14" height="10" rx="2"/></svg>',color:"#6366f1"}, 1209 {match:["permesso","cittadinanz","stranier","immigraz","anolf"],id:"stranieri",label:"Stranieri",icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>',color:"#0891b2"}, 1210 {match:["atm","abbonament","trasport"],id:"trasporti",label:"Trasporti",icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="14" rx="2"/><circle cx="8" cy="20" r="1.5"/><circle cx="16" cy="20" r="1.5"/><path d="M4 11h16"/></svg>',color:"#84cc16"} 1211 ]; 1212 1213 /* Mappa codtipologia (codice CANONICO esposto dal Planner) â id categoria 1214 * UI. à il segnale PRIMARIO per scegliere l'icona, perché i Planner di 1215 * tutte le regioni usano gli stessi codici tipologia (FIS, LOC, CB, SNC, 1216 * ALTRO, â¦) anche quando le descrizioni dei singoli servizi variano. 1217 * Chiavi UPPERCASE â confronto case-insensitive (alcune connessioni 1218 * espongono "ANOLF" o "anolf", "COLF" o "CB"). Aliases (FISCALE/FISBAS/ 1219 * FIS_01) derivano dalla probe live su tutte le 16 connessioni 1220 * (2026-05-21). Manca â si ricade sul match testuale. */ 1221 var BK_CODTIPOLOGIA_TO_CAT={ 1222 /* Fiscali: 730, IMU, ISE, REDDITI, ⦠1223 * NB: FIS_01 e FIS-INT NON sono qui: il primo è ambiguo (contiene 1224 * sia "Consulenze" fiscali sia "Dimissioni" sindacali), il secondo è 1225 * FISASCAT INTERNI (categoriale, non CAF). Lasciati cadere nel match 1226 * testuale o nel blocklist server-side. */ 1227 FIS:"fiscale", FISCALE:"fiscale", FISBAS:"fiscale", FIS_NAZ:"fiscale", FISCALI:"fiscale", "PR_005":"fiscale", 1228 /* Locazioni */ 1229 LOC:"locazioni", 1230 /* Colf e badanti */ 1231 CB:"colf", COLF:"colf", COLF_NAZ:"colf", 1232 /* Successioni */ 1233 SNC:"successioni", SUCCESSION:"successioni", 1234 /* Stranieri / ANOLF */ 1235 ANOLF:"stranieri", SPORT_IMMI:"stranieri", 1236 /* Patronato: NASpI, disoccupazioni */ 1237 DIS:"patronato" 1238 }; 1239 1240 /* Regex sul `codice` del servizio. Valutata PRIMA di codtipologia perché 1241 * il prefisso codice è un segnale semantico più affidabile della 1242 * categorizzazione Planner: in Puglia il servizio "CBCHIU Colf-badantiâ¦" 1243 * ha codtipologia=FIS (anomalia di catalogo), ma il codice "CB*" rivela 1244 * la natura reale del servizio e merita l'icona Colf. Per Veneto servizi 1245 * Anolf_* hanno codtipologia=ALTRO, ma il prefisso "Anolf" â Stranieri. */ 1246 var BK_CODICE_PREFIX_RULES=[ 1247 {re:/^cb/i, id:"colf"}, /* CBCONS, CBNEW, CBCHIU, CBNOCL */ 1248 {re:/^colf/i, id:"colf"}, 1249 {re:/^anolf/i, id:"stranieri"}, /* Anolf_2â¦_5, Anolf_RO, Anolf2_Ro⦠*/ 1250 {re:/^naspi/i, id:"patronato"}, 1251 {re:/^loc/i, id:"locazioni"}, /* LOCCONT, LOCRLI, LOCCOM, LOCCHI, LOCREG */ 1252 {re:/^scn/i, id:"successioni"}, /* SCNCONS, SCNFIRM, SCNUSU, SCNFINE */ 1253 {re:/^ise/i, id:"isee"} /* ISE, ISESP, ISEN, ISEDIF, ISECOR, ISESOSP */ 1254 ]; 1255 1256 function bkLookupCat(id){ 1257 for(var k=0;k<BK_TIPOLOGIE_CAT.length;k++){ 1258 if(BK_TIPOLOGIE_CAT[k].id===id)return BK_TIPOLOGIE_CAT[k]; 1259 } 1260 return null; 1261 } 1262 1263 function bkServizioCategoria(srv){ 1264 var altri={id:"altri",label:"Altri",icon:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 8v4M12 16h.01"/></svg>',color:"#64748b"}; 1265 if(!srv)return altri; 1266 /* 1) Match sul codice servizio (prefissi semantici come "CB*", 1267 * "Anolf_*", "LOC*"). Valutato PRIMA della codtipologia perché il 1268 * prefisso codice è univoco mentre la categorizzazione del Planner 1269 * può avere anomalie regionali (vedi Puglia CBCHIU con tip=FIS). */ 1270 var code=String(srv.codice||"").trim(); 1271 if(code){ 1272 for(var rp=0;rp<BK_CODICE_PREFIX_RULES.length;rp++){ 1273 if(BK_CODICE_PREFIX_RULES[rp].re.test(code)){ 1274 var pHit=bkLookupCat(BK_CODICE_PREFIX_RULES[rp].id); 1275 if(pHit)return pHit; 1276 } 1277 } 1278 } 1279 /* 2) Match canonico per codtipologia (Planner). Case-insensitive: 1280 * upper-case la chiave perché alcune connessioni espongono "anolf" 1281 * altre "ANOLF". */ 1282 var codTip=String(srv.codtipologia||"").trim().toUpperCase(); 1283 if(codTip&&Object.prototype.hasOwnProperty.call(BK_CODTIPOLOGIA_TO_CAT,codTip)){ 1284 var hit=bkLookupCat(BK_CODTIPOLOGIA_TO_CAT[codTip]); 1285 if(hit)return hit; 1286 } 1287 /* 3) Fallback: match testuale (per codtipologia sconosciute o legacy). 1288 * Pattern brevi (â¤2 char come "cu","red") usano word-boundary per 1289 * evitare falsi positivi tipo "scuola" â "cu" â fiscale. */ 1290 var name=bkNormalizeStr((srv.desonline||"")+" "+(srv.descrizione||"")+" "+(srv.descriptionTipologia||"")+" "+(srv.destipologia||"")); 1291 for(var i=0;i<BK_TIPOLOGIE_CAT.length;i++){ 1292 var c=BK_TIPOLOGIE_CAT[i]; 1293 for(var j=0;j<c.match.length;j++){ 1294 var m=c.match[j]; 1295 var found; 1296 if(m.length<=3){ 1297 /* Word-boundary regex per token brevi (cu, red, f24, imu, atm). */ 1298 found=new RegExp("\\b"+m.replace(/[.*+?^${}()|[\\]\\\\]/g,"\\$&")+"\\b").test(name); 1299 }else{ 1300 found=name.indexOf(m)!==-1; 1301 } 1302 if(found)return c; 1303 } 1304 } 1305 return altri; 1306 } 1307
1308 /* ââ Frequency tracker (province / servizi) per "più usati in alto" ââ */ 1309 var BK_FREQ_KEY="caf-bk-freq-v1"+CAF_NS; 1310 function bkFreqLoad(){try{var r=localStorage.getItem(BK_FREQ_KEY);return r?(JSON.parse(r)||{}):{};}catch(_e){return{};}} 1311 function bkFreqSave(o){try{localStorage.setItem(BK_FREQ_KEY,JSON.stringify(o));}catch(_e){}} 1312 function bkFreqInc(kind,id){if(!id)return;if(!bkConsentHas("functional"))return;var o=bkFreqLoad();var k=kind+":"+id;o[k]=(o[k]||0)+1;o[k+":ts"]=Date.now();bkFreqSave(o);} 1313 function bkFreqGet(kind,id){var o=bkFreqLoad();return o[kind+":"+id]||0;} 1314 function bkFreqSort(kind,items,getId){ 1315 if(!Array.isArray(items)||items.length<2)return items.slice(); 1316 var o=bkFreqLoad();var withFreq=items.map(function(it){var id=getId(it);return{it:it,f:o[kind+":"+id]||0,t:o[kind+":"+id+":ts"]||0};}); 1317 withFreq.sort(function(a,b){if(b.f!==a.f)return b.f-a.f;return b.t-a.t;}); 1318 return withFreq.map(function(x){return x.it;}); 1319 } 1320 1321 /* ââ Slot cache (TTL 5 min) per evitare refetch su back/forward. ââ */ 1322 var BK_SLOT_CACHE_PREFIX="caf-bk-slotcache-v1"+CAF_NS+":";var BK_SLOT_CACHE_TTL_MS=5*60*1000; 1323 function bkSlotCacheGet(key){ 1324 try{var raw=localStorage.getItem(BK_SLOT_CACHE_PREFIX+key);if(!raw)return null; 1325 var p=JSON.parse(raw);if(!p||(Date.now()-(p.ts||0))>BK_SLOT_CACHE_TTL_MS){localStorage.removeItem(BK_SLOT_CACHE_PREFIX+key);return null;}return p.data; 1326 }catch(_e){return null;} 1327 } 1328 function bkSlotCacheSet(key,data){ 1329 try{localStorage.setItem(BK_SLOT_CACHE_PREFIX+key,JSON.stringify({ts:Date.now(),data:data}));}catch(_e){} 1330 } 1331 function bkSlotCacheClear(){ 1332 try{for(var i=localStorage.length-1;i>=0;i--){var k=localStorage.key(i);if(k&&k.indexOf(BK_SLOT_CACHE_PREFIX)===0)localStorage.removeItem(k);}}catch(_e){} 1333 } 1334 1335 /* ââ Retry con exponential backoff per errori di rete/5xx. ââ 1336 * Wrap esterno a _bkFetch: re-tenta solo errori transitori, mai 4xx/validation. */ 1337 async function bkRetryWithBackoff(fn,opts){ 1338 opts=opts||{}; 1339 var maxAttempts=opts.maxAttempts||3; 1340 var baseMs=opts.baseMs||400; 1341 var maxMs=opts.maxMs||3000; 1342 var lastErr; 1343 for(var attempt=0;attempt<maxAttempts;attempt++){
1344 try{return await fn(attempt);} 1345 catch(err){ 1346 lastErr=err; 1347 var code=err&&err.code; 1348 var transient=(code==="timeout"||code==="network"||code==="server"||(typeof code==="string"&&/^5\d\d$/.test(code))); 1349 if(!transient||attempt===maxAttempts-1)throw err; 1350 var delay=Math.min(maxMs,baseMs*Math.pow(2,attempt))+Math.random()*120; 1351 await new Promise(function(r){setTimeout(r,delay);}); 1352 } 1353 } 1354 throw lastErr; 1355 } 1356 1357 /* UUID per idempotenza (es. clientRequestId della conferma prenotazione). 1358 * crypto.randomUUID dove disponibile, fallback compatibile altrove. */ 1359 function bkUuid(){ 1360 try{if(typeof crypto!=="undefined"&&crypto.randomUUID)return crypto.randomUUID();}catch(_e){} 1361 try{if(typeof crypto!=="undefined"&&crypto.getRandomValues){ 1362 var b=crypto.getRandomValues(new Uint8Array(16)); 1363 b[6]=(b[6]&0x0f)|0x40;b[8]=(b[8]&0x3f)|0x80; 1364 var h=[];for(var i=0;i<16;i++)h.push((b[i]+0x100).toString(16).slice(1)); 1365 return h[0]+h[1]+h[2]+h[3]+"-"+h[4]+h[5]+"-"+h[6]+h[7]+"-"+h[8]+h[9]+"-"+h[10]+h[11]+h[12]+h[13]+h[14]+h[15]; 1366 }}catch(_e){} 1367 return "rid-"+Date.now().toString(36)+"-"+Math.floor(Math.random()*1e9).toString(36); 1368 } 1369 1370 /* ââ Network info: tipo connessione (4g/3g/2g) + online/offline ââ */ 1371 function bkConnectionType(){ 1372 try{var c=navigator.connection||navigator.mozConnection||navigator.webkitConnection;return c&&c.effectiveType||"unknown";}catch(_e){return"unknown";} 1373 } 1374 function bkIsSlowNetwork(){var t=bkConnectionType();return t==="slow-2g"||t==="2g";} 1375 function bkIsOnline(){try{return navigator.onLine!==false;}catch(_e){return true;}} 1376 1377 /* NOCAF (env NOCAF=si â window.__CAF_NEUTRAL__): tenant NON-CAF. 1378 * I testi visibili all'utente non devono citare il CAF né le "pratiche": 1379 * si parla di operatore umano. Il brand negli artefatti (ICS, promemoria) 1380 * diventa il nome del chatbot del tenant. Senza flag: tutto IDENTICO. */ 1381 function bkNeutralMode(){try{return typeof window!=="undefined"&&window.__CAF_NEUTRAL__===true;}catch(_e){return false;}} 1382 function bkNeutralBrand(dflt){ 1383 if(!bkNeutralMode())return dflt; 1384 try{var n=(window.__CAF_CHATBOT_NAME__||"").trim();if(n&&!/caf/i.test(n))return n;}catch(_e){} 1385 return "Assistenza appuntamenti"; 1386 } 1387 1388 /* ââ Numero verde fallback (configurabile via attributo data-helpline). ââ */ 1389 function bkHelplineNumber(opts){ 1390 if(opts&&opts.helpline)return String(opts.helpline); 1391 try{var s=document.currentScript||document.querySelector("script[data-helpline]");if(s){var v=s.getAttribute("data-helpline");if(v)return v;}}catch(_e){} 1392 /* Numero "Parla con un operatore" per-tenant: env OPERATORE_TEL sul 1393 * progetto Vercel, iniettata dalla route come __CAF_HELPLINE__. 1394 * Valore "no" = bottone operatore nascosto. */ 1395 try{ 1396 if(typeof window!=="undefined"&&typeof window.__CAF_HELPLINE__==="string"&&window.__CAF_HELPLINE__){ 1397 var hv=String(window.__CAF_HELPLINE__).trim(); 1398 if(hv.toLowerCase()==="no")return""; 1399 if(hv)return hv; 1400 } 1401 }catch(_e){} 1402 return"800-800-730"; /* fallback default CAF CISL */ 1403 } 1404 1405 /* ââ Prefissi telefonici internazionali (subset comune). ââ */ 1406 var BK_PHONE_COUNTRIES=[ 1407 {code:"+39",flag:"ð®ð¹",name:"Italia"}, 1408 {code:"+1",flag:"ðºð¸",name:"USA/Canada"}, 1409 {code:"+33",flag:"ð«ð·",name:"Francia"}, 1410 {code:"+34",flag:"ðªð¸",name:"Spagna"}, 1411 {code:"+44",flag:"ð¬ð§",name:"Regno Unito"}, 1412 {code:"+49",flag:"ð©ðª",name:"Germania"}, 1413 {code:"+41",flag:"ð¨ð",name:"Svizzera"}, 1414 {code:"+43",flag:"ð¦ð¹",name:"Austria"}, 1415 {code:"+32",flag:"ð§ðª",name:"Belgio"}, 1416 {code:"+31",flag:"ð³ð±",name:"Paesi Bassi"}, 1417 {code:"+351",flag:"ðµð¹",name:"Portogallo"}, 1418 {code:"+40",flag:"ð·ð´",name:"Romania"}, 1419 {code:"+48",flag:"ðµð±",name:"Polonia"}, 1420 {code:"+30",flag:"ð¬ð·",name:"Grecia"}, 1421 {code:"+90",flag:"ð¹ð·",name:"Turchia"}, 1422 {code:"+212",flag:"ð²ð¦",name:"Marocco"}, 1423 {code:"+216",flag:"ð¹ð³",name:"Tunisia"},
1424 {code:"+20",flag:"ðªð¬",name:"Egitto"}, 1425 {code:"+62",flag:"ð®ð©",name:"Indonesia"}, 1426 {code:"+86",flag:"ð¨ð³",name:"Cina"}, 1427 {code:"+91",flag:"ð®ð³",name:"India"}, 1428 {code:"+63",flag:"ðµð",name:"Filippine"}, 1429 {code:"+380",flag:"ðºð¦",name:"Ucraina"}, 1430 {code:"+7",flag:"ð·ðº",name:"Russia"}, 1431 {code:"+55",flag:"ð§ð·",name:"Brasile"}, 1432 {code:"+54",flag:"ð¦ð·",name:"Argentina"}, 1433 {code:"+57",flag:"ð¨ð´",name:"Colombia"}, 1434 {code:"+51",flag:"ðµðª",name:"Perù"}, 1435 {code:"+593",flag:"ðªð¨",name:"Ecuador"}, 1436 {code:"+221",flag:"ð¸ð³",name:"Senegal"}, 1437 {code:"+234",flag:"ð³ð¬",name:"Nigeria"}, 1438 {code:"+225",flag:"ð¨ð®",name:"Costa d'Avorio"}, 1439 {code:"+961",flag:"ð±ð§",name:"Libano"}, 1440 {code:"+963",flag:"ð¸ð¾",name:"Siria"}, 1441 {code:"+92",flag:"ðµð°",name:"Pakistan"}, 1442 {code:"+880",flag:"ð§ð©",name:"Bangladesh"} 1443 ]; 1444 1445 /* ââ Codifica codice fiscale italiano: estrae sesso, data nascita, 1446 * comune/codice catastale dalla CF (algoritmo deterministico). ââ */ 1447 function bkParseCodiceFiscale(cf){ 1448 if(!cf||typeof cf!=="string")return null; 1449 cf=cf.toUpperCase().replace(/\s/g,""); 1450 if(!/^[A-Z]{6}\d{2}[A-EHLMPRST]\d{2}[A-Z]\d{3}[A-Z]$/.test(cf))return null; 1451 var monthMap={A:0,B:1,C:2,D:3,E:4,H:5,L:6,M:7,P:8,R:9,S:10,T:11}; 1452 var year=parseInt(cf.slice(6,8),10); 1453 var monthCh=cf.charAt(8);if(!(monthCh in monthMap))return null; 1454 var month=monthMap[monthCh]; 1455 var day=parseInt(cf.slice(9,11),10); 1456 var sesso=day>40?"F":"M";if(sesso==="F")day-=40; 1457 var fullYear=year<=(new Date().getFullYear()%100)?(2000+year):(1900+year); 1458 var d=new Date(fullYear,month,day); 1459 if(d.getFullYear()!==fullYear||d.getMonth()!==month||d.getDate()!==day)return null; 1460 return{sesso:sesso,giorno:day,mese:month+1,anno:fullYear,codiceCatastale:cf.slice(11,15),cf:cf}; 1461 } 1462 function bkValidaCF(cf){return!!bkParseCodiceFiscale(cf);} 1463 function bkValidaEmail(em){return!!em&&/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(String(em).trim());} 1464 function bkValidaTelefono(tel){if(!tel)return false;var s=String(tel).replace(/[\s\-().]/g,"");if(/^\+?\d{6,15}$/.test(s))return true;return false;} 1465 1466 /* ââ i18n: dizionari multilingua per il booking flow. 1467 * Lingue supportate: it (default), en, fr, es, de, ro. Per il resto si 1468 * fallback al testo italiano. La lingua è auto-rilevata da navigator.language 1469 * con override possibile via opts.lang / ?caf-lang= / data-lang / localStorage. */ 1470 var BK_I18N={ 1471 it:{ 1472 /* a11y accessible names */ aria_open_chat:"Apri chat", aria_close_chat:"Chiudi chat", ph_write_message:"Scrivi un messaggio", aria_send:"Invia messaggio", aria_scroll_bottom:"Torna in fondo", aria_options:"Opzioni", aria_close:"Chiudi", aria_fullscreen:"Schermo intero", aria_exit_fullscreen:"Esci da schermo intero", aria_voice:"Riconoscimento vocale", aria_book:"Apri prenotazione", aria_open_book:"Apri chat e prenota un appuntamento", skip_to_input:"Vai al campo messaggio", aria_history:"Cronologia conversazione", sr_assistant:"Assistente", sr_you_said:"Tu hai scritto:", sr_said:"ha scritto:", assistant_typing:"L'assistente sta scrivendoâ¦", label_error:"Errore", booking_confirmed:"Prenotazione confermata", menu_contact:"Parla con un operatore", aria_email:"Indirizzo email", aria_pwd_email:"Email per il reset della password", aria_view_on_map:"Vedi {0} sulla mappa", aria_copy:"Copia messaggio", aria_translate:"Traduci messaggio", aria_scroll_left:"Scorri a sinistra", aria_scroll_right:"Scorri a destra", aria_remove:"Rimuovi", aria_add:"Aggiungi", aria_speak:"Parla", aria_more_info:"Maggiori info", aria_hide:"Nascondi", aria_expand_booking:"Apri prenotazione a schermo intero", aria_book_appointment:"Prenota il tuo appuntamento", aria_booking:"Prenotazione", aria_search_office:"Cerca sede", 1473 hero_title:"Prenota il tuo appuntamento", 1474 hero_subtitle:"â± ~2 minuti · ", 1475 step_zone:"Zona", step_service:"Servizio", step_office:"Sede", step_time:"Orario", step_data:"Dati", 1476 h_region:"In quale regione desideri prenotare?", s_region:"Scegli la tua regione per vedere le sedi e i servizi disponibili.", 1477 h_province:"In quale provincia desideri prenotare?", s_province:"Usa la tua posizione per trovarla automaticamente oppure scegli manualmente.", 1478 h_service:"Scegli il servizio", s_service:"Seleziona il servizio che vuoi prenotare.", 1479 h_service_hint:"Quale servizio ti interessa?", s_service_hint:'Ho filtrato per "{0}
1479". Puoi affinare la ricerca qui sotto.', 1480 h_office:"Scegli la sede", s_office:"Ecco tutti i centri disponibili. Usa la geolocalizzazione per trovare la più vicina a te.", 1481 h_slot:"Scegli data e ora", s_slot:"Seleziona un giorno sul calendario per vedere gli orari disponibili.", 1482 h_data:"Inserisci i tuoi dati", s_data:"Quasi fatto! Ci servono i dati dell'intestatario per finalizzare la prenotazione.", 1483 h_confirm:"Conferma la tua prenotazione", s_confirm:"Controlla i dettagli. Se tutto è corretto, premi Conferma per salvare l'appuntamento.", 1484 btn_back:"Indietro", btn_cancel:"Annulla", btn_confirm:"Conferma prenotazione", btn_confirming:"Salvataggio in corso...", 1485 consent_before:"Ho letto l'", consent_link:"informativa sulla privacy", consent_after:" e acconsento al trattamento dei miei dati personali per la gestione della prenotazione.", consent_required:"Per procedere devi accettare il trattamento dei dati personali.", 1486 chatgate_h:"La tua privacy", chatgate_body:"Prima di iniziare la conversazione ti chiediamo di prendere visione dell'informativa sulla privacy e di acconsentire al trattamento dei dati personali.", chatgate_before:"Ho letto l'", chatgate_link:"informativa sulla privacy", chatgate_after:" e acconsento al trattamento dei miei dati personali.", chatgate_btn:"Accetta e inizia la chat", 1487 ai_disclosure:"Stai parlando con un assistente virtuale basato sull'intelligenza artificiale (IA), non con una persona. Le risposte sono generate automaticamente e possono contenere errori: verifica le informazioni importanti con un operatore. La tua pratica sarà seguita da operatori umani del CAF.", 1488 ai_badge:"Assistente IA · risposte generate automaticamente", ai_voice_notice:"Stai parlando con un assistente vocale automatico basato sull'intelligenza artificiale. Le risposte sono generate automaticamente e possono contenere errori.", 1489 email_gate_body:"Per iniziare la conversazione, inserisci il tuo indirizzo email.", email_gate_ph:"La tua email", email_gate_btn:"Inizia la chat", email_gate_note:"Useremo la tua email solo per gestire questa conversazione e ricontattarti sulla tua richiesta.", email_gate_privacy:"Informativa privacy", aria_prev_month:"Mese precedente", aria_next_month:"Mese successivo", hold_hint:"Ti consigliamo di completare entro {0}", hold_expired:"Gli orari potrebbero essere nel frattempo cambiati: verifica la disponibilità prima di confermare.", 1490 age_gate_label:"Dichiaro di avere almeno 14 anni. Per i minori di 14 anni è necessario il consenso di un genitore o di chi esercita la responsabilità genitoriale.", 1491 translate_ai_note:"Traduzione automatica generata da IA", demo_ribbon:"DEMO â i dati mostrati sono di esempio. Nessuna prenotazione reale viene effettuata.", 1492 btn_retry:"Riprova", btn_change:"Cambia selezione", btn_call:"Chiama", btn_restart:"Ricomincia", btn_close:"Chiudi", btn_edit:"Modifica", 1493 btn_use_location:"Usa la mia posizione", btn_use_location_sub:"Trovo automaticamente la tua provincia", 1494 btn_find_nearest:"Trova la sede più vicina", btn_find_nearest_again:"Ricerca di nuovo", 1495 btn_next:"Prosegui al riepilogo", btn_load_more:"Carica altri orari", 1496 label_or_choose:"oppure scegli manualmente", 1497 label_morning:"Mattina", label_afternoon:"Pomeriggio", 1498 label_today:"OGGI", label_tomorrow:"DOMANI", label_this_week:"QUESTA SETTIMANA", 1499 label_count_slots_one:"{0} orario", label_count_slots_other:"{0} orari", 1500 label_first_avail:"Prima disponibilità ", label_quickpick_arrow:"â", 1501 label_when:"Quando", label_where:"Dove", label_what:"Cosa", 1502 label_owner:"Intestatario", label_contacts:"Contatti", 1503 label_summary_premium_h:"Tutto pronto, controlla e conferma", 1504 label_summary_premium_s:"Controlla i dettagli prima di salvare l'appuntamento.", 1505 label_summary_disclaimer:"Potrai modificare o cancellare la prenotazione fino a 24 ore prima dell'appuntamento.", 1506 label_form_for_self:"Per me", label_form_for_family:"Per un familiare", 1507 label_form_nome:"Nome", label_form_cognome:"Cognome", label_form_codfis:"Codice Fiscale", label_form_email:"Email", label_form_cellulare:"Cellulare", 1508 label_form_duration_min:"Durata ~{0} min", 1509 label_data_safe:"Dati protetti", label_data_caf:"Assistenza CAF", label_data_real:"Appuntamento con operatori reali del CAF", 1510 label_done_h:"Prenotazione confermata!", label_done_s:"Ti abbiamo inviato un'email di conferma con tutti i dettagli dell'appuntamento.", 1511 label_done_id:"ID prenotazione: ",
1512 label_add_calendar:"Aggiungi al calendario", label_share_whatsapp:"Condividi su WhatsApp", label_email_reminder:"Promemoria via email", 1513 label_done_survey:"Come è stata l'esperienza?", label_done_thanks:"Grazie per il feedback!", 1514 label_done_good:"Bene", label_done_meh:"Così così", label_done_bad:"Male", 1515 ph_search_service:"Cerca (es. 730, ISEE, casa, dichiarazione redditi...)", 1516 ph_search_office:"Cerca sede per nome, città o indirizzo (es. Milano, Garibaldi...)", 1517 ph_search_province:"Cerca città o provincia (es. Milano, Roma...)", 1518 ph_no_results_one:'Nessun servizio trovato per "{0}"', 1519 label_did_you_mean:"Forse intendevi:", 1520 label_popular:"ð¥ Più richiesto", label_recent:"â± Recente", label_frequent:"â Frequente", 1521 label_resume_h:"Riprendi da dove avevi lasciato", 1522 label_resume_btn:"Riprendi", label_resume_discard:"Ricomincia", 1523 label_offline_b:"Sei offline.", label_offline_msg:" Connettiti per completare la prenotazione. Lo stato è salvato e potrai riprendere.", 1524 label_slow:"Connessione lenta â il caricamento potrebbe richiedere qualche secondo in più.", 1525 err_h_default:"Si è verificato un errore", err_s_default:"Riprova oppure chiama il numero verde per assistenza.", 1526 err_h_timeout:"Connessione lenta", err_s_timeout:"Il server non risponde. Verifica la connessione e riprova.", 1527 err_h_network:"Niente connessione", err_s_network:"Sembra che tu sia offline. Controlla la rete e riprova.", 1528 err_h_no_services:"Nessun servizio disponibile", err_s_no_services:"Per la tua provincia al momento non sono disponibili servizi prenotabili online.", 1529 err_h_no_sedi:"Nessuna sede disponibile", err_s_no_sedi:"Non ci sono sedi configurate per questo servizio nella tua provincia.", 1530 err_h_no_spazi:"Nessuno slot disponibile", err_s_no_spazi:"Per ora non ci sono orari liberi. Riprova tra qualche giorno o cambia sede.", 1531 err_h_already_booked:"Hai già una prenotazione", err_s_already_booked:"Risulti già prenotato per un servizio in queste date.", 1532 err_h_slot_taken:"Orario appena occupato", err_s_slot_taken:"L'orario che hai scelto è stato appena prenotato da un'altra persona. Scegli un altro orario tra quelli aggiornati.", 1533 err_h_validation:"Dati non validi", err_s_validation:"Controlla i campi inseriti e riprova.", 1534 err_h_disabled:"Servizio non disponibile", err_s_disabled:"La prenotazione online è temporaneamente disabilitata.", 1535 err_h_server:"Errore del server", err_s_server:"Risposta non valida dal server. Riprova tra qualche istante.", 1536 toast_slow_title:"Connessione lenta", toast_slow_msg:"Sto riprovando ({0}/3)...", 1537 gdpr_h:"Privacy e protezione dei tuoi dati", 1538 gdpr_body:"Possiamo memorizzare su questo dispositivo i tuoi dati (nome, codice fiscale, email, telefono) per pre-compilarli la prossima volta. Questi dati di pre-compilazione restano nel tuo browser e si auto-cancellano dopo 30 giorni di inattività . Quando confermi una prenotazione o scrivi in chat, i dati necessari vengono inviati ai sistemi indicati nella sezione Destinatari.", 1539 gdpr_body_v2:"Per offrirti il servizio di prenotazione online, {0} utilizza alcune tecnologie di memorizzazione locale (localStorage) sul tuo dispositivo. Puoi scegliere quali categorie accettare. La tua scelta è registrata e revocabile in qualsiasi momento dal menu â¯.", 1540 gdpr_more:"Dettagli completi", gdpr_accept:"Sì, ricorda i miei dati", gdpr_deny:"No, grazie", 1541 gdpr_accept_all:"Accetta tutti", gdpr_reject_all:"Rifiuta tutti", gdpr_customize:"Personalizza", gdpr_save_prefs:"Salva preferenze", 1542 gdpr_privacy_policy:"Informativa Privacy completa", 1543 gdpr_cat_necessary:"Necessari (sempre attivi)", 1544 gdpr_cat_necessary_d:"Tecnologie indispensabili al funzionamento del widget: cache delle regioni/sedi/servizi, lingua. Senza questi il widget non funziona. Base giuridica: strettamente necessarie al servizio richiesto (esenti da consenso).", 1545 gdpr_cat_functional:"Funzionali", 1546 gdpr_cat_functional_d:"Memorizzazione dei tuoi dati di prenotazione (nome, codice fiscale, email, telefono) per pre-compilarli la prossima volta. Lo stato di prenotazione in corso (resume) e la cronologia recente.", 1547 gdpr_cat_analytics:"Analytics e marketing", 1548 gdpr_cat_analytics_d:"Identificatore di sessione, parametri di campagna (UTM, gclid, fbclid) e raccolta degli eventi del flusso di prenotazione per migliorare il servizio. Dati pseudonimizzati.", 1549 gdpr_info_controller:"Titolare del trattamento:", 1550 gdpr_info_address:"Sede:", gdpr_info_email:"Email/PEC:", gdpr_info_phone:"Telefono:", gdpr_info_vat:"P.IVA / C.F.:", gdpr_info_dpo:"Resp. protezione dati (DPO):", 1551 gdpr_info_legal_basis:"Base giuridica:", gdpr_info_legal_basis_d:"Necessari = tecnologie strettamente necessarie al servizio richiesto (esenti da consenso, art. 122 Codice privacy); i dati di prenotazione sono trattati per dare esecuzione al servizio richiesto (art. 6.1.b GDPR). Funzionali e Analytics = consenso (art. 6.1.a GDPR).", 1552 gdpr_info_storage:"Dove sono conservati:", gdpr_info_storage_d:"I dati di pre-compilazione, la cronologia e le preferenze restano nel localStorage del tuo browser, su questo dispositivo (offuscati ma non cifrati con standard crittografici). Quando prenoti o scrivi in chat, i dati corrispondenti vengono inviati e conservati sui server dei responsabili del trattamento indicati in 'Destinatari'.",
1553 gdpr_info_retention:"Periodo di conservazione:", gdpr_info_retention_d:"Sul dispositivo: 30 giorni dall'ultima attività , poi auto-cancellazione. Sui server: le conversazioni sono cancellate e i log di prenotazione anonimizzati entro i termini stabiliti dal Titolare (a titolo indicativo: conversazioni circa 180 giorni; prenotazioni secondo gli obblighi contabili/fiscali applicabili). Il consenso resta valido fino a revoca.", 1554 gdpr_info_recipients:"Destinatari:", gdpr_info_recipients_d:"I dati di prenotazione sono inviati al sistema Planner CAF (in UE) per finalizzare l'appuntamento. Le conversazioni sono elaborate dalla piattaforma conversazionale Mantis e dai provider di IA collegati, anche extra-UE con clausole contrattuali tipo (SCC): assistenza conversazionale e classificazione degli intenti (Anthropic) e analisi di eventuali documenti caricati (OpenAI). Per individuare la sede più vicina si usano OpenStreetMap/Nominatim e servizi di geolocalizzazione via IP. Distribuzione tecnica: Vercel e Bunny CDN; conservazione su Google Firebase. Vedi l'Informativa Privacy del Titolare.", 1555 gdpr_info_rights:"I tuoi diritti:", gdpr_info_rights_d:"Accesso, rettifica, cancellazione, limitazione, portabilità , opposizione (artt. 15-22 GDPR). Reclamo al Garante Privacy.", 1556 gdpr_info_revoke:"Come revocare:", gdpr_info_revoke_d:"Apri la chat â menu (â¯) â Privacy. Puoi modificare le preferenze o cancellare tutti i dati.", 1557 menu_new:"Nuova conversazione", 1558 menu_revoke:"Revoca consenso e cancella dati", 1559 menu_privacy:"Privacy e dati personali", 1560 revoke_confirm:"Vuoi cancellare i dati salvati (nome, codice fiscale, email, telefono) e revocare il consenso?", 1561 revoke_done_h:"Dati cancellati", revoke_done_s:"I tuoi dati personali sono stati rimossi da questo dispositivo.", 1562 privacy_panel_h:"Privacy e i tuoi dati", 1563 privacy_panel_s:"Qui puoi modificare le preferenze di consenso, scaricare i dati che abbiamo memorizzato sul tuo dispositivo, o cancellarli completamente.", 1564 privacy_panel_consent_h:"Le tue preferenze di consenso", 1565 privacy_panel_data_h:"Dati attualmente memorizzati su questo dispositivo", 1566 privacy_panel_data_client:"Dati anagrafici (nome, CF, email, telefono)", 1567 privacy_panel_data_history:"Cronologia prenotazioni (numero)", 1568 privacy_panel_data_resume:"Prenotazione in corso (resume)", 1569 privacy_panel_data_sid:"Identificatore di sessione", 1570 privacy_panel_present:"presenti", privacy_panel_absent:"assenti", 1571 privacy_panel_export:"Scarica i miei dati", privacy_panel_delete_all:"Cancella tutto", 1572 privacy_panel_export_h:"Download avviato", privacy_panel_export_s:"I tuoi dati sono stati esportati in un file JSON.", 1573 privacy_panel_saved_h:"Preferenze salvate", privacy_panel_saved_s:"Le tue scelte di consenso sono state aggiornate.", 1574 cf_valid_ok:"CF valido.", cf_valid_recognized:"Riconosciuto: {0}, nato/a il {1}, comune (codice {2})", 1575 cf_invalid:"Codice fiscale non valido. Verifica i caratteri inseriti.", 1576 cf_male:"â Maschio", cf_female:"â Femmina", 1577 /* EAA/WCAG 2026-07-19: chiavi a11y + i18n wizard/voice */ 1578 sr_booking_launch:"à disponibile un pulsante per aprire la prenotazione.", sr_results_count:"{0} risultati", sr_no_results:"Nessun risultato", aria_select_office:"Seleziona {0}", 1579 aria_stop_listening:"Interrompi l'ascolto", sr_listening:"Sto ascoltando", sr_processing:"Elaborazione in corso", aria_back_to_step:"Torna allo step {0}: {1}", 1580 step_of:"Passo {0} di {1}", cal_months:"Gennaio|Febbraio|Marzo|Aprile|Maggio|Giugno|Luglio|Agosto|Settembre|Ottobre|Novembre|Dicembre", cal_months_short:"gen|feb|mar|apr|mag|giu|lug|ago|set|ott|nov|dic", cal_days_full:"Domenica|Lunedì|Martedì|Mercoledì|Giovedì|Venerdì|Sabato", 1581 cal_days_short:"Lun|Mar|Mer|Gio|Ven|Sab|Dom", label_available:"Disponibile", label_selected:"Selezionato", label_nearest:"Più vicina", 1582 label_view_map:"Vedi sulla mappa", label_no_slots_h:"Nessun orario disponibile", label_no_slots_s:"Al momento non ci sono slot disponibili. Prova a cambiare sede o riprova più tardi.", label_no_dates_h:"Orari non disponibili", 1583 label_no_dates_s:"Non sono riuscito a leggere le date disponibili. Prova a cambiare sede.", label_pick_day:"Seleziona un giorno", label_no_slots_day:"Nessun orario in questo giorno. Prova un altro giorno disponibile.", ph_no_office_found:"Nessuna sede trovata per \"{0}\"", 1584 ph_no_results_generic:"Nessun risultato per \"{0}\"", label_reuse_data_h:"Riusa i tuoi dati salvati?", btn_its_me:"Sì, sono io", btn_not_me:"No, sono un'altra persona", 1585 label_saved_data:"Dati salvati", label_submitting:"Invio prenotazione...", label_submitting_sub:"Sto registrando il tuo appuntamento, ci vorrà qualche secondo.", label_loading_hint:"Cerco disponibilità per \"{0}\"...", 1586 label_loading_options:"Caricamento opzioni...", label_loading_short:"Caricamentoâ¦", label_error_code:"Codice: {0}", label_booking_inprogress:"Prenotazione in corso", 1587 label_expand:"Espandi", label_expand_sub:"Apri la vista a schermo intero per un'esperienza migliore", t_region:"Seleziona la regione", t_province:"Seleziona la provincia", 1588 t_data:"I tuoi dati", label_srv_hint_found:"Ho trovato questi servizi per \"{0}\". Quale ti interessa?", label_srv_hint:"Quale servizio vuoi prenotare?", label_sede_hint:"Perfetto! Ora scegli la sede più comoda per te.", 1589 btn_find_nearest_sub:"Usa la tua posizione GPS", btn_find_nearest_again_sub:"Usa la posizione aggiornata", label_slots_hint:"Ecco gli appuntamenti disponibili. Scegli quello che preferisci!", label_pref_time:"Di solito preferisci: {0}.", 1590 label_data_hint:"Quasi fatto! Inserisci i tuoi dati per completare la prenotazione.", label_date_at:"{0} alle {1}", label_appointment:"Appuntamento", btn_use:"Usa", 1591 btn_calendar:"Calendario", btn_email_reminder:"Inviami il promemoria via email", btn_choose_other:"Scegli un altro", btn_choose_other_slot:"Scegli un altro orario", 1592 label_done_email:"Prenotazione registrata per {0}", err_cf_required:"Inserisci il codice fiscale.", err_cf_short:"Il codice fiscale deve essere di 16 caratteri (mancano {0}
1592 caratteri).", err_cf_long:"Il codice fiscale deve essere di 16 caratteri (ne hai inserito {0} in più).", 1593 err_cf_chars:"Il codice fiscale può contenere solo lettere e cifre.", err_cf_format:"Il formato del codice fiscale non è valido. Verifica i caratteri inseriti.", err_cf_charpos:"Carattere non valido alla posizione {0}.", err_cf_checksum:"La cifra di controllo non torna (attesa '{0}' al posto di '{1}'). Ricontrolla.", 1594 err_cf_first:"Inserisci prima un codice fiscale valido", err_cf_proceed:"Inserisci un codice fiscale valido prima di proseguire.", err_required_fields:"Compila tutti i campi obbligatori.", err_email_invalid:"Inserisci un indirizzo email valido.", 1595 err_phone_invalid:"Inserisci un numero di cellulare valido (almeno 9 cifre).", err_uncertain:"Non abbiamo ricevuto conferma dal server: la prenotazione potrebbe essere comunque andata a buon fine. Controlla la tua email prima di riprovare.", err_unexpected:"Errore imprevisto. Riprova.", help_codfis:"Lo trovi sulla tua tessera sanitaria o tessera fiscale. à di 16 caratteri.", 1596 help_email:"Ti useremo solo per inviarti la conferma e il promemoria 24h prima.", help_cellulare:"Per eventuali comunicazioni urgenti dalla sede.", voice_tap_to_talk:"Tocca per parlare", voice_press_to_start:"Premi il microfono per iniziare", 1597 voice_speak_now:"Parla ora", voice_listening_hint:"Ti sto ascoltando... parla pure", voice_responding:"Sto rispondendo", voice_tap_interrupt:"Tocca il microfono per interrompere", 1598 voice_thinking:"Sto pensando...", voice_thinking_hint:"Un momento, sto preparando la risposta", voice_back_keyboard:"Torna alla tastiera", ph_your_city:"Scrivi la tua città ...", ph_follow_steps:"Segui i passaggi sopra per prenotare..." 1599 }, 1600 en:{ 1601 /* a11y accessible names */ aria_open_chat:"Open chat", aria_close_chat:"Close chat", ph_write_message:"Write a message", aria_send:"Send message", aria_scroll_bottom:"Scroll to latest", aria_options:"Options", aria_close:"Close", aria_fullscreen:"Full screen", aria_exit_fullscreen:"Exit full screen", aria_voice:"Voice input", aria_book:"Open booking", aria_open_book:"Open chat and book an appointment", skip_to_input:"Skip to message field", aria_history:"Conversation history", sr_assistant:"Assistant", sr_you_said:"You said:", sr_said:"said:", assistant_typing:"The assistant is typingâ¦", label_error:"Error", booking_confirmed:"Booking confirmed", menu_contact:"Talk to an operator", aria_email:"Email address", aria_pwd_email:"Password reset email", aria_view_on_map:"View {0} on the map", aria_copy:"Copy message", aria_translate:"Translate message", aria_scroll_left:"Scroll left", aria_scroll_right:"Scroll right", aria_remove:"Remove", aria_add:"Add", aria_speak:"Speak", aria_more_info:"More info", aria_hide:"Hide", aria_expand_booking:"Open booking full screen", aria_book_appointment:"Book your appointment", aria_booking:"Booking", aria_search_office:"Search office", 1602 hero_title:"Book your appointment", 1603 hero_subtitle:"â± ~2 minutes · ", 1604 step_zone:"Area", step_service:"Service", step_office:"Office", step_time:"Time", step_data:"Details", 1605 h_region:"Which region do you want to book in?", s_region:"Choose your region to see available offices and services.", 1606 h_province:"Which province do you want to book in?", s_province:"Use your location to find it automatically, or pick manually.", 1607 h_service:"Choose the service", s_service:"Select the service you want to book.", 1608 h_service_hint:"Which service do you need?", s_service_hint:'Filtered by "{0}". You can refine the search below.', 1609 h_office:"Choose the office", s_office:"Here are all the available offices. Use geolocation to find the nearest one.", 1610 h_slot:"Choose date and time", s_slot:"Select a day on the calendar to see available time slots.", 1611 h_data:"Enter your details", s_data:"Almost done! We need the booker's details to finalize.", 1612 h_confirm:"Confirm your booking", s_confirm:"Review the details. If everything is correct, press Confirm to save.", 1613 btn_back:"Back", btn_cancel:"Cancel", btn_confirm:"Confirm booking", btn_confirming:"Saving...", 1614 consent_before:"I have read the ", consent_link:"privacy policy", consent_after:" and I consent to the processing of my personal data to manage the booking.", consent_required:"To proceed you must accept the processing of your personal data.", 1615 chatgate_h:"Your privacy", chatgate_body:"Before starting the conversation, please read the privacy policy and consent to the processing of your personal data.", chatgate_before:"I have read the ", chatgate_link:"privacy policy", chatgate_after:" and I consent to the processing of my personal data.", chatgate_btn:"Accept and start chatting", 1616 ai_disclosure:"You are chatting with a virtual assistant powered by artificial intelligence (AI), not a human. Replies are generated automatically and may contain errors: check important information with an operator. Your case will be handled by human CAF staff.", 1617 ai_badge:"AI assistant · automatically generated replies", ai_voice_notice:"You are talking to an automated voice assistant powered by artificial intelligence. Replies are generated automatically and may contain errors.", 1618 email_gate_body:"To start the conversation, please enter your email address.", email_gate_ph:"Your email", email_gate_btn:"Start the chat", email_gate_note:"We only use your email to handle this conversation and follow up on your request.", email_gate_privacy:"Privacy policy", aria_prev_month:"Previous month", aria_next_month:"Next month", hold_hint:"We recommend completing within {0}", hold_expired:"Availability may have changed in the meantime: please double-check the time slots before confirming.", 1619 age_gate_label:"I declare that I am at least 14 years old. For children under 14, the consent of a parent or legal guardian is required.", 1620 translate_ai_note:"Automatic translation generated by AI", demo_ribbon:"DEMO â the data shown is sample data. No real booking is made.", 1621 btn_retry:"Retry", btn_change:"Change selection", btn_call:"Call", btn_restart:"Start over", btn_close:"Close", btn_edit:"Edit", 1622 btn_use_location:"Use my location", btn_use_location_sub:"I'll find your province automatically", 1623 btn_find_nearest:"Find the nearest office", btn_find_nearest_again:"Search again", 1624 btn_next:"Continue to summary", btn_load_more:"Load more times", 1625 label_or_choose:"or choose manually", 1626 label_morning:"Morning", label_afternoon:"Afternoon", 1627 label_today:"TODAY", label_tomorrow:"TOMORROW", label_this_week:"THIS WEEK", 1628 label_count_slots_one:"{0} slot", label_count_slots_other:"{0} slots", 1629 label_first_avail:"First available", label_quickpick_arrow:"â", 1630 label_when:"When", label_where:"Where", label_what:"What", 1631 label_owner:"Holder", label_contacts:"Contacts", 1632 label_summary_premium_h:"All set, review and confirm", 1633 label_summary_premium_s:"Check the details before saving the appointment.", 1634 label_summary_disclaimer:"You can edit or cancel the booking up to 24 hours before the appointment.", 1635 label_form_for_self:"For me", label_form_for_family:"For a family member", 1636 label_form_nome:"First name", label_form_cognome:"Last name", label_form_codfis:"Tax code", label_form_email:"Email", label_form_cellulare:"Mobile", 1637 label_form_duration_min:"Duration ~{0} min", 1638 label_data_safe:"Data protected", label_data_caf:"CAF support", label_data_real:"Appointment with real CAF operators", 1639 label_done_h:"Booking confirmed!", label_done_s:"We've sent you a confirmation email with all the appointment details.", 1640 label_done_id:"Booking ID: ", 1641 label_add_calendar:"Add to calendar", label_share_whatsapp:"Share on WhatsApp", label_email_reminder:"Email reminder", 1642 label_done_survey:"How was your experience?", label_done_thanks:"Thanks for the feedback!", 1643 label_done_good:"Good", label_done_meh:"So-so", label_done_bad:"Bad", 1644 ph_search_service:"Search (e.g. tax return, ISEE, home...)", 1645 ph_search_office:"Search office by name, city or address", 1646 ph_search_province:"Search city or province", 1647 ph_no_results_one:'No service found for "{0}"', 1648 label_did_you_mean:"Did you mean:", 1649 label_popular:"ð¥ Most requested", label_recent:"â± Recent", label_frequent:"â Frequent", 1650 label_resume_h:"Resume where you left off", 1651 label_resume_btn:"Resume", label_resume_discard:"Start over", 1652 label_offline_b:"You're offline.", label_offline_msg:" Connect to complete the booking. State is saved and you can resume.", 1653 label_slow:"Slow connection â loading may take a few extra seconds.", 1654 err_h_default:"An error occurred", err_s_default:"Retry or call the helpline for assistance.", 1655 err_h_timeout:"Slow connection", err_s_timeout:"The server is not responding. Check your connection and retry.", 1656 err_h_network:"No connection", err_s_network:"You appear to be offline. Check your network and retry.", 1657 err_h_no_services:"No services available", err_s_no_services:"There are currently no online-bookable services for your province.", 1658 err_h_no_sedi:"No offices available", err_s_no_sedi:"No offices configured for this service in your province.", 1659 err_h_no_spazi:"No slots available", err_s_no_spazi:"No free times right now. Try again in a few days or change office.", 1660 err_h_already_booked:"You already have a booking", err_s_already_booked:"You're already booked for a service on these dates.", 1661 err_h_slot_taken:"Time slot just taken", err_s_slot_taken:"The time you selected was just booked by someone else. Please pick another time from the updated list.", 1662 err_h_validation:"Invalid data", err_s_validation:"Check the entered fields and retry.", 1663 err_h_disabled:"Service unavailable", err_s_disabled:"Online booking is temporarily disabled.", 1664 err_h_server:"Server error", err_s_server:"Invalid response from server. Retry shortly.", 1665 toast_slow_title:"Slow connection", toast_slow_msg:"Retrying ({0}/3)...", 1666 gdpr_h:"Privacy and your data", 1667 gdpr_body:"We can store your details (name, tax code, email, phone) on this device to pre-fill them next time. This pre-fill data stays in your browser and auto-deletes after 30 days of inactivity. When you confirm a booking or write in chat, the necessary data is sent to the systems listed under Recipients.", 1668 gdpr_body_v2:"To offer the online booking service, {0} uses some local storage technologies (localStorage) on your device. You can choose which categories to accept. Your choice is recorded and revocable at any time from the ⯠menu.", 1669 gdpr_more:"Full details", gdpr_accept:"Yes, remember my details", gdpr_deny:"No, thanks", 1670 gdpr_accept_all:"Accept all", gdpr_reject_all:"Reject all", gdpr_customize:"Customize", gdpr_save_prefs:"Save preferences", 1671 gdpr_privacy_policy:"Full Privacy Policy", 1672 gdpr_cat_necessary:"Necessary (always on)", 1673 gdpr_cat_necessary_d:"Technologies essential for the widget to work: regions/offices/services cac
1673he, language. Without these the widget cannot function. Legal basis: strictly necessary for the requested service (exempt from consent).", 1674 gdpr_cat_functional:"Functional", 1675 gdpr_cat_functional_d:"Storage of your booking details (name, tax code, email, phone) to pre-fill them next time. In-progress booking state (resume) and recent history.", 1676 gdpr_cat_analytics:"Analytics and marketing", 1677 gdpr_cat_analytics_d:"Session identifier, campaign parameters (UTM, gclid, fbclid) and collection of booking flow events to improve the service. Pseudonymized data.", 1678 gdpr_info_controller:"Data controller:", 1679 gdpr_info_address:"Registered office:", gdpr_info_email:"Email/PEC:", gdpr_info_phone:"Phone:", gdpr_info_vat:"VAT / Tax ID:", gdpr_info_dpo:"Data Protection Officer (DPO):", 1680 gdpr_info_legal_basis:"Legal basis:", gdpr_info_legal_basis_d:"Necessary = technologies strictly necessary for the requested service (exempt from consent, Art. 122 Italian Privacy Code); booking data is processed to perform the requested service (Art. 6.1.b GDPR). Functional and Analytics = consent (Art. 6.1.a GDPR).", 1681 gdpr_info_storage:"Where stored:", gdpr_info_storage_d:"Pre-fill data, history and preferences stay in your browser's localStorage, on this device (obfuscated but not encrypted with cryptographic standards). When you book or write in chat, the corresponding data is sent to and stored on the processors' servers listed under 'Recipients'.", 1682 gdpr_info_retention:"Retention period:", gdpr_info_retention_d:"On the device: 30 days from last activity, then auto-deletion. On the servers: conversations are deleted and booking logs anonymized within the periods set by the Controller (indicatively: conversations about 180 days; bookings according to applicable accounting/tax obligations). Consent remains valid until revoked.", 1683 gdpr_info_recipients:"Recipients:", gdpr_info_recipients_d:"Booking data is sent to the CAF Planner system (in EU) to finalize the appointment. Conversations are processed by the Mantis conversational platform and connected AI providers, including outside the EU under Standard Contractual Clauses (SCC): conversational assistance and intent classification (Anthropic) and analysis of any uploaded documents (OpenAI). OpenStreetMap/Nominatim and IP geolocation services are used to find the nearest office. Technical delivery: Vercel and Bunny CDN; storage on Google Firebase. See the Controller's Privacy Policy.", 1684 gdpr_info_rights:"Your rights:", gdpr_info_rights_d:"Access, rectification, erasure, restriction, portability, objection (Arts. 15-22 GDPR). Complaint to the Privacy Authority.", 1685 gdpr_info_revoke:"How to revoke:", gdpr_info_revoke_d:"Open the chat â menu (â¯) â Privacy. You can change preferences or delete all data.", 1686 menu_new:"New conversation", 1687 menu_revoke:"Revoke consent and delete data", 1688 menu_privacy:"Privacy and personal data", 1689 revoke_confirm:"Delete saved data (name, tax code, email, phone) and revoke consent?", 1690 revoke_done_h:"Data deleted", revoke_done_s:"Your personal data has been removed from this device.", 1691 privacy_panel_h:"Privacy and your data", 1692 privacy_panel_s:"Here you can change consent preferences, download the data we've stored on your device, or delete it completely.", 1693 privacy_panel_consent_h:"Your consent preferences", 1694 privacy_panel_data_h:"Data currently stored on this device", 1695 privacy_panel_data_client:"Personal data (name, tax code, email, phone)", 1696 privacy_panel_data_history:"Booking history (count)", 1697 privacy_panel_data_resume:"In-progress booking (resume)", 1698 privacy_panel_data_sid:"Session identifier", 1699 privacy_panel_present:"present", privacy_panel_absent:"absent", 1700 privacy_panel_export:"Download my data", privacy_panel_delete_all:"Delete everything", 1701 privacy_panel_export_h:"Download started", privacy_panel_export_s:"Your data has been exported to a JSON file.", 1702 privacy_panel_saved_h:"Preferences saved", privacy_panel_saved_s:"Your consent choices have been updated.",
1703 cf_valid_ok:"Valid tax code.", cf_valid_recognized:"Recognized: {0}, born {1}, municipality (code {2})", 1704 cf_invalid:"Invalid tax code. Check the entered characters.", 1705 cf_male:"â Male", cf_female:"â Female", 1706 /* EAA/WCAG 2026-07-19: chiavi a11y + i18n wizard/voice */ 1707 sr_booking_launch:"A button is available to open the booking.", sr_results_count:"{0} results", sr_no_results:"No results", aria_select_office:"Select {0}", 1708 aria_stop_listening:"Stop listening", sr_listening:"Listening", sr_processing:"Processing", aria_back_to_step:"Back to step {0}: {1}", 1709 step_of:"Step {0} of {1}", cal_months:"January|February|March|April|May|June|July|August|September|October|November|December", cal_months_short:"Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec", cal_days_full:"Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday", 1710 cal_days_short:"Mon|Tue|Wed|Thu|Fri|Sat|Sun", label_available:"Available", label_selected:"Selected", label_nearest:"Nearest", 1711 label_view_map:"View on map", label_no_slots_h:"No times available", label_no_slots_s:"There are no available slots at the moment. Try another office or try again later.", label_no_dates_h:"Times not available", 1712 label_no_dates_s:"I could not read the available dates. Try another office.", label_pick_day:"Select a day", label_no_slots_day:"No times on this day. Try another available day.", ph_no_office_found:"No office found for \"{0}\"", 1713 ph_no_results_generic:"No results for \"{0}\"", label_reuse_data_h:"Reuse your saved details?", btn_its_me:"Yes, it's me", btn_not_me:"No, I'm someone else", 1714 label_saved_data:"Saved details", label_submitting:"Sending booking...", label_submitting_sub:"I'm registering your appointment, it will take a few seconds.", label_loading_hint:"Looking for availability for \"{0}\"...", 1715 label_loading_options:"Loading options...", label_loading_short:"Loadingâ¦", label_error_code:"Code: {0}", label_booking_inprogress:"Booking in progress", 1716 label_expand:"Expand", label_expand_sub:"Open the full screen view for a better experience", t_region:"Select your region", t_province:"Select your province", 1717 t_data:"Your details", label_srv_hint_found:"I found these services for \"{0}\". Which one are you interested in?", label_srv_hint:"Which service would you like to book?", label_sede_hint:"Great! Now choose the most convenient office for you.", 1718 btn_find_nearest_sub:"Use your GPS position", btn_find_nearest_again_sub:"Use your updated position", label_slots_hint:"Here are the available appointments. Pick the one you prefer!", label_pref_time:"You usually prefer: {0}.", 1719 label_data_hint:"Almost done! Enter your details to complete the booking.", label_date_at:"{0} at {1}", label_appointment:"Appointment", btn_use:"Use", 1720 btn_calendar:"Calendar", btn_email_reminder:"Send me the reminder by email", btn_choose_other:"Choose another", btn_choose_other_slot:"Choose another time", 1721 label_done_email:"Booking registered for {0}", err_cf_required:"Enter your tax code.", err_cf_short:"The tax code must be 16 characters long ({0} characters missing).", err_cf_long:"The tax code must be 16 characters long ({0} too many).", 1722 err_cf_chars:"The tax code can only contain letters and digits.", err_cf_format:"The tax code format is not valid. Check the characters you entered.", err_cf_charpos:"Invalid character at position {0}.", err_cf_checksum:"The check digit does not match (expected '{0}' instead of '{1}'). Please check.", 1723 err_cf_first:"Enter a valid tax code first", err_cf_proceed:"Enter a valid tax code before proceeding.", err_required_fields:"Fill in all required fields.", err_email_invalid:"Enter a valid email address.", 1724 err_phone_invalid:"Enter a valid mobile number (at least 9 digits).", err_uncertain:"We did not receive a confirmation from the server: your booking may still have gone through. Check your email before trying again.", err_unexpected:"Unexpected error. Please try again.", help_codfis:"You can find it on your Italian health card. It is 16 characters long.", 1725 help_email:"We will only use it to send you the confirmation and the reminder 24h before.", help_cellulare:"For urgent communications from the office.", voice_tap_to_talk:"Tap to talk", voice_press_to_start:"Press the microphone to start", 1726 voice_speak_now:"Speak now", voice_listening_hint:"I'm listening... go ahead", voice_responding:"I'm answering", voice_tap_interrupt:"Tap the microphone to interrupt", 1727 voice_thinking:"Thinking...", voice_thinking_hint:"One moment, I'm preparing the answer", voice_back_keyboard:"Back to keyboard", ph_your_city:"Type your city...", ph_follow_steps:"Follow the steps above to book..." 1728 }, 1729 fr:{ 1730 /* --- GDPR / privacy (i18n completo, art. 12-13 GDPR) --- */ 1731 gdpr_accept_all:"Tout accepter", 1732 gdpr_body_v2:"Pour vous proposer le service de réservation en ligne, {0} utilise certaines technologies de stockage local (localStorage) sur votre appareil. Vous pouvez choisir les catégories à accepter. Votre choix est enregistré et révocable à tout moment depuis le menu â¯.", 1733 gdpr_cat_analytics:"Analytique et marketing", 1734 gdpr_cat_analytics_d:"Identifiant de session, paramètres de campagne (UTM, gclid, fbclid) et collecte des événements du parcours de réservation afin d'améliorer le service. Données pseudonymisées.", 1735 gdpr_cat_functional:"Fonctionnels", 1736 gdpr_cat_functional_d:"Mémorisation de vos données de réservation (nom, code fiscal, e-mail, téléphone) afin de les pré-remplir la prochaine fois. L'état de la réservation en cours (reprise) et l'historique récent.", 1737 gdpr_cat_necessary:"Nécessaires (toujours actifs)", 1738 gdpr_cat_necessary_d:"Technologies indispensables au fonctionnement du widget : cache des régions/agences/services, langue. Sans elles, le widget ne fonctionne pas. Base juridique : strictement nécessaires au service demandé (exemptées de consentement).", 1739 gdpr_customize:"Personnaliser", 1740 gdpr_info_address:"Siège :", 1741 gdpr_info_controller:"Responsable du traitement :", 1742 gdpr_info_dpo:"Délégué à la protection des données (DPO) :", 1743 gdpr_info_email:"E-mail/PEC :", 1744 gdpr_info_legal_basis:"Base juridique :", 1745 gdpr_info_legal_basis_d:"Nécessaires = technologies strictement nécessaires au service demandé (exemptées de consentement, art. 122 de la réglementation nationale applicable) ; les données de réservation sont traitées pour exécuter le service demandé (art. 6.1.b GDPR). Fonctionnels et Analytique = consentement (art. 6.1.a GDPR).", 1746 gdpr_info_phone:"Téléphone :", 1747 gdpr_info_recipients:"Destinataires :", 1748 gdpr_info_recipients_d:"Les données de réservation sont envoyées au système Planner CAF (dans l'UE) pour finaliser le rendez-vous. Les conversations sont traitées par la plateforme conversationnelle Mantis et les fournisseurs d'IA connectés, y compris hors UE au titre de clauses contractuelles types (SCC) : assistance conversationnelle et
1748classification des intentions (Anthropic) et analyse des éventuels documents téléversés (OpenAI). Pour localiser l'agence la plus proche, on utilise OpenStreetMap/Nominatim et des services de géolocalisation par IP. Distribution technique : Vercel et Bunny CDN ; conservation sur Google Firebase. Consultez la Politique de confidentialité du Responsable du traitement.", 1749 gdpr_info_retention:"Durée de conservation :", 1750 gdpr_info_retention_d:"Sur l'appareil : 30 jours à compter de la dernière activité, puis suppression automatique. Sur les serveurs : les conversations sont supprimées et les journaux de réservation anonymisés dans les délais fixés par le Responsable du traitement (à titre indicatif : conversations environ 180 jours ; réservations selon les obligations comptables/fiscales applicables). Le consentement reste valable jusqu'à sa révocation.", 1751 gdpr_info_revoke:"Comment révoquer :", 1752 gdpr_info_revoke_d:"Ouvrez le chat â menu (â¯) â Confidentialité. Vous pouvez modifier les préférences ou supprimer toutes les données.", 1753 gdpr_info_rights:"Vos droits :", 1754 gdpr_info_rights_d:"Accès, rectification, effacement, limitation, portabilité, opposition (artt. 15-22 GDPR). Réclamation auprès de l'autorité de contrôle compétente.", 1755 gdpr_info_storage:"Où sont-elles conservées :", 1756 gdpr_info_storage_d:"Les données de pré-remplissage, l'historique et les préférences restent dans le localStorage de votre navigateur, sur cet appareil (masquées mais non chiffrées selon des standards cryptographiques). Lorsque vous réservez ou écrivez dans le chat, les données correspondantes sont envoyées et conservées sur les serveurs des sous-traitants indiqués dans 'Destinataires'.", 1757 gdpr_info_vat:"N° TVA / Code fiscal :", 1758 gdpr_privacy_policy:"Politique de confidentialité complète", 1759 gdpr_reject_all:"Tout refuser", 1760 gdpr_save_prefs:"Enregistrer les préférences", 1761 menu_privacy:"Confidentialité et données personnelles", 1762 privacy_panel_absent:"absentes", 1763 privacy_panel_consent_h:"Vos préférences de consentement", 1764 privacy_panel_data_client:"Données d'identité (nom, code fiscal, e-mail, téléphone)", 1765 privacy_panel_data_h:"Données actuellement mémorisées sur cet appareil", 1766 privacy_panel_data_history:"Historique des réservations (nombre)", 1767 privacy_panel_data_resume:"Réservation en cours (reprise)", 1768 privacy_panel_data_sid:"Identifiant de session", 1769 privacy_panel_delete_all:"Tout supprimer", 1770 privacy_panel_export:"Télécharger mes données", 1771 privacy_panel_export_h:"Téléchargement lancé", 1772 privacy_panel_export_s:"Vos données ont été exportées dans un fichier JSON.", 1773 privacy_panel_h:"Confidentialité et vos données", 1774 privacy_panel_present:"présentes", 1775 privacy_panel_s:"Ici, vous pouvez modifier les préférences de consentement, télécharger les données que nous avons mémorisées sur votre appareil, ou les supprimer complètement.", 1776 privacy_panel_saved_h:"Préférences enregistrées", 1777 privacy_panel_saved_s:"Vos choix de consentement ont été mis à jour.", 1778 /* a11y accessible names */ aria_open_chat:"Ouvrir le chat", aria_close_chat:"Fermer le chat", ph_write_message:"Ãcrire un message", aria_send:"Envoyer le message", aria_scroll_bottom:"Aller au dernier message", aria_options:"Options", aria_close:"Fermer", aria_fullscreen:"Plein écran", aria_exit_fullscreen:"Quitter le plein écran", aria_voice:"Reconnaissance vocale", aria_book:"Ouvrir la réservation", aria_open_book:"Ouvrir le chat et prendre rendez-vous", skip_to_input:"Aller au champ de message", aria_history:"Historique de la conversation", sr_assistant:"Assistant", sr_you_said:"Vous avez écrit :", sr_said:"a écrit :", assistant_typing:"L'assistant écritâ¦", label_error:"Erreur", booking_confirmed:"Réservation confirmée", menu_contact:"Parler à un opérateur", aria_email:"Adresse e-mail", aria_pwd_email:"E-mail de réinitialisation du mot de passe", aria_view_on_map:"Voir {0} sur la carte", aria_copy:"Copier le message", aria_translate:"Traduire le message", aria_scroll_left:"Défiler à gauche", aria_scroll_right:"Défiler à droite", aria_remove:"Retirer", aria_add:"Ajouter", aria_speak:"Parler", aria_more_info:"Plus d'infos", aria_hide:"Masquer", aria_expand_booking:"Ouvrir la réservation en plein écran", aria_book_appointment:"Prendre rendez-vous", aria_booking:"Réservation", aria_search_office:"Rechercher un bureau", 1779 hero_title:"Réservez votre rendez-vous", 1780 hero_subtitle:"â± ~2 minutes · ", 1781 step_zone:"Zone", step_service:"Service", step_office:"Bureau", step_time:"Horaire", step_data:"Données", 1782 h_region:"Dans quelle région voulez-vous réserver ?", s_region:"Choisissez votre région pour voir les bureaux et services disponibles.", 1783 h_province:"Dans quelle province voulez-vous réserver ?", s_province:"Utilisez votre position pour la trouver automatiquement ou choisissez manuellement.", 1784 h_service:"Choisissez le service", s_service:"Sélectionnez le service que vous souhaitez réserver.", 1785 h_service_hint:"Quel service vous intéresse ?", s_service_hint:'Filtré par "{0}". Vous pouvez affiner la recherche ci-dessous.', 1786 h_office:"Choisissez le bureau", s_office:"Voici tous les bureaux disponibles. Utilisez la géolocalisation pour trouver le plus proche.", 1787 h_slot:"Choisissez date et heure", s_slot:"Sélectionnez un jour sur le calendrier pour voir les créneaux disponibles.", 1788 h_data:"Entrez vos données", s_data:"Presque fini ! Nous avons besoin des données du titulaire.", 1789 h_confirm:"Confirmez votre réservation", s_confirm:"Vérifiez les détails. Si tout est correct, appuyez sur Confirmer.", 1790 btn_back:"Retour", btn_cancel:"Annuler", btn_confirm:"Confirmer la réservation", btn_confirming:"Enregistrement...", 1791 consent_before:"J'ai lu la ", consent_link:"politique de confidentialité", consent_after:" et je consens au traitement de mes données personnelles pour gérer le rendez-vous.", consent_required:"Pour continuer, vous devez accepter le traitement de vos données personnelles.", 1792 chatgate_h:"Votre vie privée", chatgate_body:"Avant de commencer la conversation, veuillez lire la politique de confidentialité et consentir au traitement de vos données personnelles.", chatgate_before:"J'ai lu la ", chatgate_link:"politique de confidentialité", chatgate_after:" et je consens au traitement de mes données personnelles.", chatgate_btn:"Accepter et commencer", 1793 ai_disclosure:"Vous discutez avec un assistant virtuel basé sur l'intelligence artificielle (IA), et non avec une personne. Les réponses sont générées automatiquement et peuvent contenir des erreurs : vérifiez les informations importantes auprès d'un opérateur. Votre dossier sera traité par le personnel humain du CAF.", 1794 ai_badge:"Assistant IA · réponses générées automatiquement", ai_voice_notice:"Vous parlez avec un assistant vocal automatique basé sur l'intelligence artificielle. Les réponses sont générées automatiquement et peuvent contenir des erreurs.", 1795 email_gate_body:"Pour commencer la conversation, saisissez votre adresse e-mail.", email_gate_ph:"Votre e-mail", email_gate_btn:"Démarrer le chat", email_gate_note:"Votre e-mail sert uniquement à gérer cette c
1795onversation et à donner suite à votre demande.", email_gate_privacy:"Politique de confidentialité", aria_prev_month:"Mois précédent", aria_next_month:"Mois suivant", hold_hint:"Nous vous conseillons de terminer dans un délai de {0}", hold_expired:"Les horaires ont pu changer entre-temps : vérifiez la disponibilité avant de confirmer.", 1796 age_gate_label:"Je déclare avoir au moins 14 ans. Pour les mineurs de moins de 14 ans, le consentement d'un parent ou du représentant légal est requis.", 1797 translate_ai_note:"Traduction automatique générée par IA", demo_ribbon:"DÃMO â les données affichées sont des exemples. Aucune réservation réelle n'est effectuée.", 1798 btn_retry:"Réessayer", btn_change:"Changer de sélection", btn_call:"Appeler", btn_restart:"Recommencer", btn_close:"Fermer", btn_edit:"Modifier", 1799 btn_use_location:"Utiliser ma position", btn_use_location_sub:"Je trouve votre province automatiquement", 1800 btn_find_nearest:"Trouver le bureau le plus proche", btn_find_nearest_again:"Rechercher à nouveau", 1801 btn_next:"Continuer vers le récapitulatif", btn_load_more:"Charger plus d'horaires", 1802 label_or_choose:"ou choisissez manuellement", 1803 label_morning:"Matin", label_afternoon:"Après-midi", 1804 label_today:"AUJOURD'HUI", label_tomorrow:"DEMAIN", label_this_week:"CETTE SEMAINE", 1805 label_count_slots_one:"{0} créneau", label_count_slots_other:"{0} créneaux", 1806 label_first_avail:"Première disponibilité", label_quickpick_arrow:"â", 1807 label_when:"Quand", label_where:"Où", label_what:"Quoi", 1808 label_owner:"Titulaire", label_contacts:"Contacts", 1809 label_summary_premium_h:"Tout est prêt, vérifiez et confirmez", 1810 label_summary_premium_s:"Vérifiez les détails avant d'enregistrer.", 1811 label_summary_disclaimer:"Vous pourrez modifier ou annuler jusqu'à 24h avant le rendez-vous.", 1812 label_form_for_self:"Pour moi", label_form_for_family:"Pour un proche", 1813 label_form_nome:"Prénom", label_form_cognome:"Nom", label_form_codfis:"Code fiscal", label_form_email:"E-mail", label_form_cellulare:"Mobile", 1814 label_form_duration_min:"Durée ~{0} min", 1815 label_data_safe:"Données protégées", label_data_caf:"Assistance CAF", label_data_real:"Rendez-vous avec de vrais opérateurs du CAF", 1816 label_done_h:"Réservation confirmée !", label_done_s:"Nous vous avons envoyé un e-mail de confirmation avec tous les détails.", 1817 label_done_id:"ID de réservation : ", 1818 label_add_calendar:"Ajouter au calendrier", label_share_whatsapp:"Partager sur WhatsApp", label_email_reminder:"Rappel par e-mail", 1819 label_done_survey:"Comment s'est passée l'expérience ?", label_done_thanks:"Merci pour votre retour !", 1820 label_done_good:"Bien", label_done_meh:"Bof", label_done_bad:"Mauvais", 1821 ph_search_service:"Rechercher (ex. impôts, ISEE, maison...)", 1822 ph_search_office:"Rechercher bureau par nom, ville ou adresse", 1823 ph_search_province:"Rechercher ville ou province", 1824 ph_no_results_one:'Aucun service trouvé pour "{0}"', 1825 label_did_you_mean:"Vouliez-vous dire :", 1826 label_popular:"ð¥ Plus demandé", label_recent:"â± Récent", label_frequent:"â Fréquent", 1827 label_resume_h:"Reprendre là où vous étiez", 1828 label_resume_btn:"Reprendre", label_resume_discard:"Recommencer", 1829 label_offline_b:"Vous êtes hors ligne.", label_offline_msg:" Connectez-vous pour terminer la réservation. L'état est sauvegardé.", 1830 label_slow:"Connexion lente â le chargement peut prendre quelques secondes de plus.", 1831 err_h_default:"Une erreur s'est produite", err_s_default:"Réessayez ou appelez le numéro vert pour assistance.", 1832 err_h_timeout:"Connexion lente", err_s_timeout:"Le serveur ne répond pas. Vérifiez la connexion et réessayez.", 1833 err_h_network:"Pas de connexion", err_s_network:"Vous semblez hors ligne. Vérifiez le réseau et réessayez.", 1834 err_h_no_services:"Aucun service disponible", err_s_no_services:"Aucun service réservable en ligne pour votre province en ce moment.", 1835 err_h_no_sedi:"Aucun bureau disponible", err_s_no_sedi:"Aucun bureau configuré pour ce service dans votre province.", 1836 err_h_no_spazi:"Aucun créneau disponible", err_s_no_spazi:"Aucun horaire libre pour le moment. Réessayez ou changez de bureau.", 1837 err_h_already_booked:"Vous avez déjà une réservation", err_s_already_booked:"Vous êtes déjà inscrit pour un service à ces dates.", 1838 err_h_slot_taken:"Créneau qui vient d'être pris", err_s_slot_taken:"L'horaire choisi vient d'être réservé par quelqu'un d'autre. Choisissez un autre horaire dans la liste mise à jour.", 1839 err_h_validation:"Données invalides", err_s_validation:"Vérifiez les champs saisis et réessayez.", 1840 err_h_disabled:"Service indisponible", err_s_disabled:"La réservation en ligne est temporairement désactivée.", 1841 err_h_server:"Erreur serveur", err_s_server:"Réponse invalide du serveur. Réessayez bientôt.", 1842 toast_slow_title:"Connexion lente", toast_slow_msg:"Nouvelle tentative ({0}/3)...", 1843 gdpr_h:"Voulez-vous accélérer vos prochaines réservations ?", 1844 gdpr_body:"Nous pouvons mémoriser vos données (nom, code fiscal, e-mail, téléphone) sur cet appareil pour les pré-remplir la prochaine fois. Les données restent dans votre navigateur et s'effacent après 30 jours d'inactivité.", 1845 gdpr_more:"Plus d'informations", gdpr_accept:"Oui, mémoriser mes données", gdpr_deny:"Non, merci", 1846 menu_new:"Nouvelle conversation", 1847 menu_revoke:"Révoquer le consentement et supprimer les données", 1848 revoke_confirm:"Supprimer les données enregistrées (nom, code fiscal, e-mail, téléphone) et révoquer le consentement ?", 1849 revoke_done_h:"Données supprimées", revoke_done_s:"Vos données personnelles ont été supprimées de cet appareil.",
1850 cf_valid_ok:"Code fiscal valide.", cf_valid_recognized:"Reconnu : {0}, né(e) le {1}, commune (code {2})", 1851 cf_invalid:"Code fiscal invalide. Vérifiez les caractères saisis.", 1852 cf_male:"â Homme", cf_female:"â Femme", 1853 /* EAA/WCAG 2026-07-19: chiavi a11y + i18n wizard/voice */ 1854 sr_booking_launch:"Un bouton est disponible pour ouvrir la réservation.", sr_results_count:"{0} résultats", sr_no_results:"Aucun résultat", aria_select_office:"Sélectionner {0}", 1855 aria_stop_listening:"Arrêter l'écoute", sr_listening:"Ãcoute en cours", sr_processing:"Traitement en cours", aria_back_to_step:"Revenir à l'étape {0} : {1}", 1856 step_of:"Ãtape {0} sur {1}", cal_months:"Janvier|Février|Mars|Avril|Mai|Juin|Juillet|Août|Septembre|Octobre|Novembre|Décembre", cal_months_short:"janv|févr|mars|avr|mai|juin|juil|août|sept|oct|nov|déc", cal_days_full:"Dimanche|Lundi|Mardi|Mercredi|Jeudi|Vendredi|Samedi", 1857 cal_days_short:"Lun|Mar|Mer|Jeu|Ven|Sam|Dim", label_available:"Disponible", label_selected:"Sélectionné", label_nearest:"La plus proche", 1858 label_view_map:"Voir sur la carte", label_no_slots_h:"Aucun horaire disponible", label_no_slots_s:"Il n'y a pas de créneaux disponibles pour le moment. Essayez un autre bureau ou réessayez plus tard.", label_no_dates_h:"Horaires indisponibles", 1859 label_no_dates_s:"Je n'ai pas pu lire les dates disponibles. Essayez un autre bureau.", label_pick_day:"Sélectionnez un jour", label_no_slots_day:"Aucun horaire ce jour-là . Essayez un autre jour disponible.", ph_no_office_found:"Aucun bureau trouvé pour « {0} »", 1860 ph_no_results_generic:"Aucun résultat pour « {0} »", label_reuse_data_h:"Réutiliser vos données enregistrées ?", btn_its_me:"Oui, c'est moi", btn_not_me:"Non, je suis quelqu'un d'autre", 1861 label_saved_data:"Données enregistrées", label_submitting:"Envoi de la réservation...", label_submitting_sub:"J'enregistre votre rendez-vous, cela prendra quelques secondes.", label_loading_hint:"Je cherche des disponibilités pour « {0} »...", 1862 label_loading_options:"Chargement des options...", label_loading_short:"Chargementâ¦", label_error_code:"Code : {0}", label_booking_inprogress:"Réservation en cours", 1863 label_expand:"Agrandir", label_expand_sub:"Ouvrez la vue plein écran pour une meilleure expérience", t_region:"Sélectionnez la région", t_province:"Sélectionnez la province", 1864 t_data:"Vos données", label_srv_hint_found:"J'ai trouvé ces services pour « {0} ». Lequel vous intéresse ?", label_srv_hint:"Quel service souhaitez-vous réserver ?", label_sede_hint:"Parfait ! Choisissez maintenant le bureau le plus pratique pour vous.", 1865 btn_find_nearest_sub:"Utiliser votre position GPS", btn_find_nearest_again_sub:"Utiliser la position mise à jour", label_slots_hint:"Voici les rendez-vous disponibles. Choisissez celui que vous préférez !", label_pref_time:"Vous préférez généralement : {0}.", 1866 label_data_hint:"Presque terminé ! Saisissez vos données pour finaliser la réservation.", label_date_at:"{0} à {1}", label_appointment:"Rendez-vous", btn_use:"Utiliser", 1867 btn_calendar:"Calendrier", btn_email_reminder:"M'envoyer le rappel par e-mail", btn_choose_other:"En choisir un autre", btn_choose_other_slot:"Choisir un autre horaire", 1868 label_done_email:"Réservation enregistrée pour {0}", err_cf_required:"Saisissez le code fiscal.", err_cf_short:"Le code fiscal doit comporter 16 caractères (il en manque {0}).", err_cf_long:"Le code fiscal doit comporter 16 caractères ({0} en trop).", 1869 err_cf_chars:"Le code fiscal ne peut contenir que des lettres et des chiffres.", err_cf_format:"Le format du code fiscal n'est pas valide. Vérifiez les caractères saisis.", err_cf_charpos:"Caractère non valide à la position {0}.", err_cf_checksum:"La clé de contrôle ne correspond pas ('{0}' attendu à la place de '{1}'). Vérifiez.", 1870 err_cf_first:"Saisissez d'abord un code fiscal valide", err_cf_proceed:"Saisissez un code fiscal valide avant de continuer.", err_required_fields:"Remplissez tous les champs obligatoires.", err_email_invalid:"Saisissez une adresse e-mail valide.", 1871 err_phone_invalid:"Saisissez un numéro de portable valide (au moins 9 chiffres).", err_uncertain:"Nous n'avons pas reçu de confirmation du serveur : votre réservation a peut-être quand même abouti. Vérifiez votre e-mail avant de réessayer.", err_unexpected:"Erreur inattendue. Réessayez.", help_codfis:"Vous le trouverez sur votre carte de santé. Il comporte 16 caractères.", 1872 help_email:"Utilisée uniquement pour la confirmation et le rappel 24 h avant.", help_cellulare:"Pour toute communication urgente du bureau.", voice_tap_to_talk:"Touchez pour parler", voice_press_to_start:"Appuyez sur le microphone pour commencer", 1873 voice_speak_now:"Parlez maintenant", voice_listening_hint:"Je vous écoute... parlez", voice_responding:"Je réponds", voice_tap_interrupt:"Touchez le microphone pour interrompre", 1874 voice_thinking:"Je réfléchis...", voice_thinking_hint:"Un instant, je prépare la réponse", voice_back_keyboard:"Retour au clavier", ph_your_city:"Ãcrivez votre ville...", ph_follow_steps:"Suivez les étapes ci-dessus pour réserver..." 1875 }, 1876 es:{ 1877 /* --- GDPR / privacy (i18n completo, art. 12-13 GDPR) --- */ 1878 gdpr_accept_all:"Aceptar todo", 1879 gdpr_body_v2:"Para ofrecerte el servicio de reserva en lÃnea, {0} utiliza algunas tecnologÃas de almacenamiento local (localStorage) en tu dispositivo. Puedes elegir qué categorÃas aceptar. Tu elección queda registrada y es revocable en cualquier momento desde el menú â¯.", 1880 gdpr_cat_analytics:"AnalÃtica y marketing", 1881 gdpr_cat_analytics_d:"Identificador de sesión, parámetros de campaña (UTM, gclid, fbclid) y recopilación de los eventos del flujo de reserva para mejorar el servicio. Datos seudonimizados.", 1882 gdpr_cat_functional:"Funcionales", 1883 gdpr_cat_functional_d:"Almacenamiento de tus datos de reserva (nombre, código fiscal, correo electrónico, teléfono) para rellenarlos previamente la próxima vez. El estado de la reserva en curso (reanudación) y el historial reciente.", 1884 gdpr_cat_necessary:"Necesarias (siempre activas)", 1885 gdpr_cat_necessary_d:"TecnologÃas imprescindibles para el funcionamiento del widget: caché de regiones/sedes/servicios, idioma. Sin ellas el widget no funciona. Base jurÃdica: estrictamente necesarias para el servicio solicitado (exentas de consentimiento).", 1886 gdpr_customize:"Personalizar", 1887 gdpr_info_address:"Sede:", 1888 gdpr_info_controller:"Responsable del tratamiento:", 1889 gdpr_info_dpo:"Delegado de protección de datos (DPO):", 1890 gdpr_info_email:"Correo electrónico/PEC:", 1891 gdpr_info_legal_basis:"Base jurÃdica:", 1892 gdpr_info_legal_basis_d:"Necesarias = tecnologÃas estrictamente necesarias para el servicio solicitado (exentas de consentimiento, art. 122 de la normativa nacional aplicable); los datos de reserva se tratan para ejecutar el servicio solicitado (art. 6.1.b GDPR). Funcionales y AnalÃtica = consentimiento (art. 6.1.a GDPR).", 1893 gdpr_info_phone:"Teléfono:", 1894 gdpr_info_recipients:"Destinatarios:", 1895 gdpr_info_recipients_d:"Los datos de reserva se envÃan al sistema Planner CAF (en la UE) para finalizar la cita. Las conversaciones son procesadas por la plataforma conversacional Mantis y los proveedores de IA conectados, incluso fuera de la UE mediante cláusulas contractuales tipo (SCC): asistencia conversacional y clasificación de intenciones (Anthropic) y análisis de los eventuales documentos cargados (OpenAI). Para localizar la sede más cercana se utilizan OpenStreetMap/Nominatim y servicios de geolocalización por IP. Distribución técnica: Vercel y Bunny CDN; conservación en Google Firebase. Consulta la PolÃtica de Privacidad del Responsable del tratamiento.",
1896 gdpr_info_retention:"Periodo de conservación:", 1897 gdpr_info_retention_d:"En el dispositivo: 30 dÃas desde la última actividad, después eliminación automática. En los servidores: las conversaciones se eliminan y los registros de reserva se anonimizan dentro de los plazos establecidos por el Responsable del tratamiento (a tÃtulo indicativo: conversaciones unos 180 dÃas; reservas según las obligaciones contables/fiscales aplicables). El consentimiento sigue siendo válido hasta su revocación.", 1898 gdpr_info_revoke:"Cómo revocar:", 1899 gdpr_info_revoke_d:"Abre el chat â menú (â¯) â Privacidad. Puedes modificar las preferencias o eliminar todos los datos.", 1900 gdpr_info_rights:"Tus derechos:", 1901 gdpr_info_rights_d:"Acceso, rectificación, supresión, limitación, portabilidad, oposición (artt. 15-22 GDPR). Reclamación ante la autoridad de control competente.", 1902 gdpr_info_storage:"Dónde se conservan:", 1903 gdpr_info_storage_d:"Los datos de rellenado previo, el historial y las preferencias permanecen en el localStorage de tu navegador, en este dispositivo (ofuscados pero no cifrados con estándares criptográficos). Cuando reservas o escribes en el chat, los datos correspondientes se envÃan y se conservan en los servidores de los encargados del tratamiento indicados en 'Destinatarios'.", 1904 gdpr_info_vat:"N.º de IVA / Código fiscal:", 1905 gdpr_privacy_policy:"PolÃtica de Privacidad completa", 1906 gdpr_reject_all:"Rechazar todo", 1907 gdpr_save_prefs:"Guardar preferencias", 1908 menu_privacy:"Privacidad y datos personales", 1909 privacy_panel_absent:"ausentes", 1910 privacy_panel_consent_h:"Tus preferencias de consentimiento", 1911 privacy_panel_data_client:"Datos personales (nombre, código fiscal, correo electrónico, teléfono)", 1912 privacy_panel_data_h:"Datos actualmente almacenados en este dispositivo", 1913 privacy_panel_data_history:"Historial de reservas (número)", 1914 privacy_panel_data_resume:"Reserva en curso (reanudación)", 1915 privacy_panel_data_sid:"Identificador de sesión", 1916 privacy_panel_delete_all:"Eliminar todo", 1917 privacy_panel_export:"Descargar mis datos", 1918 privacy_panel_export_h:"Descarga iniciada", 1919 privacy_panel_export_s:"Tus datos se han exportado a un archivo JSON.", 1920 privacy_panel_h:"Privacidad y tus datos", 1921 privacy_panel_present:"presentes", 1922 privacy_panel_s:"Aquà puedes modificar las preferencias de consentimiento, descargar los datos que hemos almacenado en tu dispositivo o eliminarlos por completo.", 1923 privacy_panel_saved_h:"Preferencias guardadas", 1924 privacy_panel_saved_s:"Tus opciones de consentimiento se han actualizado.", 1925 /* a11y accessible names */ aria_open_chat:"Abrir el chat", aria_close_chat:"Cerrar el chat", ph_write_message:"Escribe un mensaje", aria_send:"Enviar mensaje", aria_scroll_bottom:"Ir al último mensaje", aria_options:"Opciones", aria_close:"Cerrar", aria_fullscreen:"Pantalla completa", aria_exit_fullscreen:"Salir de pantalla completa", aria_voice:"Reconocimiento de voz", aria_book:"Abrir la reserva", aria_open_book:"Abrir el chat y reservar una cita", skip_to_input:"Ir al campo de mensaje", aria_history:"Historial de conversación", sr_assistant:"Asistente", sr_you_said:"Tú dijiste:", sr_said:"dijo:", assistant_typing:"El asistente está escribiendoâ¦", label_error:"Error", booking_confirmed:"Reserva confirmada", menu_contact:"Hablar con un operador", aria_email:"Dirección de correo electrónico", aria_pwd_email:"Correo para restablecer la contraseña", aria_view_on_map:"Ver {0} en el mapa", aria_copy:"Copiar mensaje", aria_translate:"Traducir mensaje", aria_scroll_left:"Desplazar a la izquierda", aria_scroll_right:"Desplazar a la derecha", aria_remove:"Quitar", aria_add:"Añadir", aria_speak:"Hablar", aria_more_info:"Más información", aria_hide:"Ocultar", aria_expand_booking:"Abrir la reserva en pantalla completa", aria_book_appointment:"Reserva tu cita", aria_booking:"Reserva", aria_search_office:"Buscar oficina", 1926 hero_title:"Reserva tu cita", 1927 hero_subtitle:"â± ~2 minutos · ", 1928 step_zone:"Zona", step_service:"Servicio", step_office:"Sede", step_time:"Horario", step_data:"Datos", 1929 h_region:"¿En qué región quieres reservar?", s_region:"Elige tu región para ver las sedes y servicios disponibles.", 1930 h_province:"¿En qué provincia quieres reservar?", s_province:"Usa tu ubicación para encontrarla automáticamente o elige manualmente.", 1931 h_service:"Elige el servicio", s_service:"Selecciona el servicio que quieres reservar.", 1932 h_service_hint:"¿Qué servicio te interesa?", s_service_hint:'Filtrado por "{0}". Puedes afinar la búsqueda abajo.', 1933 h_office:"Elige la sede", s_office:"Aquà están todas las sedes disponibles. Usa la geolocalización para encontrar la más cercana.", 1934 h_slot:"Elige fecha y hora", s_slot:"Selecciona un dÃa en el calendario para ver los horarios disponibles.", 1935 h_data:"Introduce tus datos", s_data:"¡Casi listo! Necesitamos los datos del titular para finalizar.", 1936 h_confirm:"Confirma tu reserva", s_confirm:"Revisa los detalles. Si todo es correcto, pulsa Confirmar.", 1937 btn_back:"Atrás", btn_cancel:"Cancelar", btn_confirm:"Confirmar reserva", btn_confirming:"Guardando...", 1938 consent_before:"He leÃdo la ", consent_link:"polÃtica de privacidad", consent_after:" y consiento el tratamiento de mis datos personales para gestionar la reserva.", consent_required:"Para continuar debes aceptar el tratamiento de tus datos personales.", 1939 chatgate_h:"Tu privacidad", chatgate_body:"Antes de iniciar la conversación, lee la polÃtica de privacidad y acepta el tratamiento de tus datos personales.", chatgate_before:"He leÃdo la ", chatgate_link:"polÃtica de privacidad", chatgate_after:" y consiento el tratamiento de mis datos personales.", chatgate_btn:"Aceptar y empezar", 1940 ai_disclosure:"Estás hablando con un asistente virtual basado en inteligencia artificial (IA), no con una persona. Las respuestas se generan automáticamente y pueden contener errores: verifica la información importante con un operador. Tu gestión será atendida por personal humano del CAF.", 1941 ai_badge:"Asistente IA · respuestas generadas automáticamente", ai_voice_notice:"Estás hablando con un asistente de voz automático basado en inteligencia artificial. Las respuestas se generan automáticamente y pueden contener errores.", 1942 email_gate_body:"Para iniciar la conversación, introduce tu dirección de correo electrónico.", email_gate_ph:"Tu correo electrónico", email_gate_btn:"Iniciar el chat", email_gate_note:"Solo usamos tu correo para gestionar esta conversación y responder a tu solicitud.", email_gate_privacy:"PolÃtica de privacidad", aria_prev_month:"Mes anterior", aria_next_month:"Mes siguiente", hold_hint:"Te recomendamos completar en un plazo de {0}", hold_expired:"Los horarios pueden haber cambiado mientras tanto: comprueba la disponibilidad antes de confirmar.", 1943 age_gate_label:"Declaro que tengo al menos 14 años. Para los menores de 14 años se requiere el consentimiento de un progenitor o tutor legal.", 1944 translate_ai_note:"Traducción automática generada por IA", demo_ribbon:"DEMO â los datos mostrados son de ejemplo. No se realiza ninguna reserva real.", 1945 btn_retry:"Reintentar", btn_change:"Cambiar selección", btn_call:"Llamar", btn_restart:"Empezar de nuevo", btn_close:"Cerrar", btn_edit:"Modificar", 1946 btn_use_location:"Usar mi ubicación", btn_use_location_sub:"Encuentro tu provincia automáticamente", 1947 btn_find_nearest:"Encuentra la sede más cercana", btn_find_nearest_again:"Buscar de nuevo", 1948 btn_next:"Continuar al resumen", btn_load_more:"Cargar más horarios", 1949 label_or_choose:"o elige manualmente", 1950 label_morning:"Mañana", label_afternoon:"Tarde", 1951 label_today:"HOY", label_tomorrow:"MAÃANA", label_this_week:"ESTA SEMANA", 1952 label_count_slots_one:"{0} hora", label_count_slots_other:"{0} horas", 1953 label_first_avail:"Primera disponibilidad", label_quickpick_arrow:"â", 1954 label_when:"Cuándo", label_where:"Dónde", label_what:"Qué", 1955 label_owner:"Titular", label_contacts:"Contactos", 1956 label_summary_premium_h:"Todo listo, revisa y confirma", 1957 label_summary_premium_s:"Verifica los detalles antes de guardar.", 1958 label_summary_disclaimer:"Podrás modificar o cancelar la reserva hasta 24 horas antes de la cita.", 1959 label_form_for_self:"Para mÃ", label_form_for_family:"Para un familiar", 1960 label_form_nome:"Nombre", label_form_cognome:"Apellido", label_form_codfis:"Código fiscal", label_form_email:"Email", label_form_cellulare:"Móvil", 1961 label_form_duration_min:"Duración ~{0} min", 1962 label_data_safe:"Datos protegidos", label_data_caf:"Soporte CAF", label_data_real:"Cita con operadores reales del CAF", 1963 label_done_h:"¡Reserva confirmada!", label_done_s:"Te hemos enviado un correo de confirmación con todos los detalles.", 1964 label_done_id:"ID de reserva: ", 1965 label_add_calendar:"Añadir al calendario", label_share_whatsapp:"Compartir en WhatsApp", label_email_reminder:"Recordatorio por email", 1966 label_done_survey:"¿Cómo fue la experiencia?", label_done_thanks:"¡Gracias por el feedback!", 1967 label_done_good:"Bien", label_done_meh:"Regular", label_done_bad:"Mal", 1968 ph_search_service:"Buscar (ej. 730, ISEE, casa...)", 1969 ph_search_office:"Buscar sede por nombre, ciudad o dirección", 1970 ph_search_province:"Buscar ciudad o provincia",
1971 ph_no_results_one:'Ningún servicio encontrado para "{0}"', 1972 label_did_you_mean:"¿Quizás querÃas decir?:", 1973 label_popular:"ð¥ Más solicitado", label_recent:"â± Reciente", label_frequent:"â Frecuente", 1974 label_resume_h:"Reanudar donde lo dejaste", 1975 label_resume_btn:"Reanudar", label_resume_discard:"Empezar de nuevo", 1976 label_offline_b:"Estás desconectado.", label_offline_msg:" Conéctate para completar la reserva. El estado se ha guardado.", 1977 label_slow:"Conexión lenta â la carga puede tardar unos segundos más.", 1978 err_h_default:"Se ha producido un error", err_s_default:"Reintenta o llama al número gratuito para asistencia.", 1979 err_h_timeout:"Conexión lenta", err_s_timeout:"El servidor no responde. Verifica la conexión y reintenta.", 1980 err_h_network:"Sin conexión", err_s_network:"Pareces estar desconectado. Verifica la red y reintenta.", 1981 err_h_no_services:"Ningún servicio disponible", err_s_no_services:"Actualmente no hay servicios reservables online en tu provincia.", 1982 err_h_no_sedi:"Ninguna sede disponible", err_s_no_sedi:"No hay sedes configuradas para este servicio en tu provincia.", 1983 err_h_no_spazi:"Ningún horario disponible", err_s_no_spazi:"Sin horarios libres ahora. Reintenta o cambia de sede.", 1984 err_h_already_booked:"Ya tienes una reserva", err_s_already_booked:"Ya estás registrado para un servicio en estas fechas.", 1985 err_h_slot_taken:"Horario recién ocupado", err_s_slot_taken:"El horario elegido acaba de ser reservado por otra persona. Elige otro horario de la lista actualizada.", 1986 err_h_validation:"Datos inválidos", err_s_validation:"Verifica los campos y reintenta.", 1987 err_h_disabled:"Servicio no disponible", err_s_disabled:"La reserva online está temporalmente deshabilitada.", 1988 err_h_server:"Error del servidor", err_s_server:"Respuesta inválida del servidor. Reintenta en breve.", 1989 toast_slow_title:"Conexión lenta", toast_slow_msg:"Reintentando ({0}/3)...", 1990 gdpr_h:"¿Quieres acelerar tus próximas reservas?", 1991 gdpr_body:"Podemos guardar tus datos (nombre, código fiscal, email, teléfono) en este dispositivo para precompletarlos la próxima vez. Los datos permanecen en tu navegador y se autoeliminan tras 30 dÃas de inactividad.", 1992 gdpr_more:"Más información", gdpr_accept:"SÃ, recordar mis datos", gdpr_deny:"No, gracias", 1993 menu_new:"Nueva conversación", 1994 menu_revoke:"Revocar consentimiento y borrar datos", 1995 revoke_confirm:"¿Borrar los datos guardados (nombre, código fiscal, email, teléfono) y revocar el consentimiento?", 1996 revoke_done_h:"Datos borrados", revoke_done_s:"Tus datos personales se han eliminado de este dispositivo.", 1997 cf_valid_ok:"Código fiscal válido.", cf_valid_recognized:"Reconocido: {0}, nacido/a el {1}, municipio (código {2})", 1998 cf_invalid:"Código fiscal inválido. Verifica los caracteres.", 1999 cf_male:"â Hombre", cf_female:"â Mujer", 2000 /* EAA/WCAG 2026-07-19: chiavi a11y + i18n wizard/voice */ 2001 sr_booking_launch:"Hay un botón disponible para abrir la reserva.", sr_results_count:"{0} resultados", sr_no_results:"Sin resultados", aria_select_office:"Seleccionar {0}", 2002 aria_stop_listening:"Detener la escucha", sr_listening:"Escuchando", sr_processing:"Procesando", aria_back_to_step:"Volver al paso {0}: {1}", 2003 step_of:"Paso {0} de {1}", cal_months:"Enero|Febrero|Marzo|Abril|Mayo|Junio|Julio|Agosto|Septiembre|Octubre|Noviembre|Diciembre", cal_months_short:"ene|feb|mar|abr|may|jun|jul|ago|sep|oct|nov|dic", cal_days_full:"Domingo|Lunes|Martes|Miércoles|Jueves|Viernes|Sábado", 2004 cal_days_short:"Lun|Mar|Mié|Jue|Vie|Sáb|Dom", label_available:"Disponible", label_selected:"Seleccionado", label_nearest:"La más cercana", 2005 label_view_map:"Ver en el mapa", label_no_slots_h:"No hay horarios disponibles", label_no_slots_s:"De momento no hay huecos disponibles. Prueba otra oficina o inténtalo más tarde.", label_no_dates_h:"Horarios no disponibles", 2006 label_no_dates_s:"No he podido leer las fechas disponibles. Prueba otra oficina.", label_pick_day:"Selecciona un dÃa", label_no_slots_day:"No hay horarios ese dÃa. Prueba otro dÃa disponible.", ph_no_office_found:"Ninguna oficina encontrada para \"{0}\"", 2007 ph_no_results_generic:"Sin resultados para \"{0}\"", label_reuse_data_h:"¿Reutilizar tus datos guardados?", btn_its_me:"SÃ, soy yo", btn_not_me:"No, soy otra persona",
2008 label_saved_data:"Datos guardados", label_submitting:"Enviando la reserva...", label_submitting_sub:"Estoy registrando tu cita, tardará unos segundos.", label_loading_hint:"Busco disponibilidad para \"{0}\"...", 2009 label_loading_options:"Cargando opciones...", label_loading_short:"Cargandoâ¦", label_error_code:"Código: {0}", label_booking_inprogress:"Reserva en curso", 2010 label_expand:"Ampliar", label_expand_sub:"Abre la vista a pantalla completa para una mejor experiencia", t_region:"Selecciona la región", t_province:"Selecciona la provincia", 2011 t_data:"Tus datos", label_srv_hint_found:"He encontrado estos servicios para \"{0}\". ¿Cuál te interesa?", label_srv_hint:"¿Qué servicio quieres reservar?", label_sede_hint:"¡Perfecto! Ahora elige la oficina más cómoda para ti.", 2012 btn_find_nearest_sub:"Usar tu posición GPS", btn_find_nearest_again_sub:"Usar la posición actualizada", label_slots_hint:"Aquà tienes las citas disponibles. ¡Elige la que prefieras!", label_pref_time:"Normalmente prefieres: {0}.", 2013 label_data_hint:"¡Casi listo! Introduce tus datos para completar la reserva.", label_date_at:"{0} a las {1}", label_appointment:"Cita", btn_use:"Usar", 2014 btn_calendar:"Calendario", btn_email_reminder:"Enviarme el recordatorio por correo", btn_choose_other:"Elegir otro", btn_choose_other_slot:"Elegir otro horario", 2015 label_done_email:"Reserva registrada para {0}", err_cf_required:"Introduce el código fiscal.", err_cf_short:"El código fiscal debe tener 16 caracteres (faltan {0}).", err_cf_long:"El código fiscal debe tener 16 caracteres ({0} de más).", 2016 err_cf_chars:"El código fiscal solo puede contener letras y cifras.", err_cf_format:"El formato del código fiscal no es válido. Revisa los caracteres introducidos.", err_cf_charpos:"Carácter no válido en la posición {0}.", err_cf_checksum:"El dÃgito de control no coincide (se esperaba '{0}' en lugar de '{1}'). RevÃsalo.", 2017 err_cf_first:"Introduce primero un código fiscal válido", err_cf_proceed:"Introduce un código fiscal válido antes de continuar.", err_required_fields:"Rellena todos los campos obligatorios.", err_email_invalid:"Introduce una dirección de correo válida.", 2018 err_phone_invalid:"Introduce un número de móvil válido (al menos 9 cifras).", err_uncertain:"No hemos recibido confirmación del servidor: tu reserva puede haberse completado igualmente. Comprueba tu correo antes de reintentar.", err_unexpected:"Error inesperado. Inténtalo de nuevo.", help_codfis:"Lo encuentras en tu tarjeta sanitaria. Tiene 16 caracteres.", 2019 help_email:"Solo la usaremos para enviarte la confirmación y el recordatorio 24 h antes.", help_cellulare:"Para comunicaciones urgentes de la oficina.", voice_tap_to_talk:"Toca para hablar", voice_press_to_start:"Pulsa el micrófono para empezar", 2020 voice_speak_now:"Habla ahora", voice_listening_hint:"Te estoy escuchando... habla", voice_responding:"Estoy respondiendo", voice_tap_interrupt:"Toca el micrófono para interrumpir", 2021 voice_thinking:"Estoy pensando...", voice_thinking_hint:"Un momento, estoy preparando la respuesta", voice_back_keyboard:"Volver al teclado", ph_your_city:"Escribe tu ciudad...", ph_follow_steps:"Sigue los pasos de arriba para reservar..." 2022 }, 2023 de:{ 2024 /* --- GDPR / privacy (i18n completo, art. 12-13 GDPR) --- */ 2025 gdpr_accept_all:"Alle akzeptieren", 2026 gdpr_body_v2:"Um Ihnen den Online-Buchungsdienst anzubieten, verwendet {0} bestimmte Technologien zur lokalen Speicherung (localStorage) auf Ihrem Gerät. Sie können wählen, welche Kategorien Sie akzeptieren. Ihre Wahl wird gespeichert und ist jederzeit über das Menü ⯠widerrufbar.", 2027 gdpr_cat_analytics:"Analyse und Marketing", 2028 gdpr_cat_analytics_d:"Sitzungskennung, Kampagnenparameter (UTM, gclid, fbclid) und Erfassung der Ereignisse des Buchungsablaufs zur Verbesserung des Dienstes. Pseudonymisierte Daten.", 2029 gdpr_cat_functional:"Funktionale", 2030 gdpr_cat_functional_d:"Speicherung Ihrer Buchungsdaten (Name, Steuernummer, E-Mail, Telefon), um sie beim nächsten Mal vorauszufüllen. Der Status der laufenden Buchung (Fortsetzung) und der jüngste Verlauf.", 2031 gdpr_cat_necessary:"Notwendige (immer aktiv)", 2032 gdpr_cat_necessary_d:"Für den Betrieb des Widgets unverzichtbare Technologien: Zwischenspeicher der Regionen/Standorte/Dienste, Sprache. Ohne diese funktioniert das Widget nicht. Rechtsgrundlage: unbedingt erforderlich für den angeforderten Dienst (einwilligungsfrei).", 2033 gdpr_customize:"Anpassen", 2034 gdpr_info_address:"Sitz:", 2035 gdpr_info_controller:"Verantwortlicher:", 2036 gdpr_info_dpo:"Datenschutzbeauftragter (DPO):", 2037 gdpr_info_email:"E-Mail/PEC:", 2038 gdpr_info_legal_basis:"Rechtsgrundlage:", 2039 gdpr_info_legal_basis_d:"Notwendige = für den angeforderten Dienst unbedingt erforderliche Technologien (einwilligungsfrei, art. 122 der geltenden nationalen Rechtsvorschriften); die Buchungsdaten werden zur Erfüllung des angeforderten Dienstes verarbeitet (art. 6.1.b GDPR). Funktionale und Analyse = Einwilligung (art. 6.1.a GDPR).", 2040 gdpr_info_phone:"Telefon:", 2041 gdpr_info_recipients:"Empfänger:", 2042 gdpr_info_recipients_d:"Die Buchungsdaten werden an das System Planner CAF (in der EU) übermittelt, um den Termin abzuschlieÃen. Die Konversationen werden von der Konversationsplattform Mantis und den angebundenen KI-Anbietern verarbeitet, auch auÃerhalb der EU auf Grundlage von Standardvertragsklauseln (SCC): konversationelle Unterstützung und Klassifizierung der Absichten (Anthropic) sowie Analyse eventuell hochgeladener Dokumente (Ope
2042nAI). Zur Ermittlung des nächstgelegenen Standorts werden OpenStreetMap/Nominatim und IP-Geolokalisierungsdienste verwendet. Technische Bereitstellung: Vercel und Bunny CDN; Speicherung bei Google Firebase. Siehe die Datenschutzerklärung des Verantwortlichen.", 2043 gdpr_info_retention:"Speicherdauer:", 2044 gdpr_info_retention_d:"Auf dem Gerät: 30 Tage ab der letzten Aktivität, danach automatische Löschung. Auf den Servern: die Konversationen werden gelöscht und die Buchungsprotokolle innerhalb der vom Verantwortlichen festgelegten Fristen anonymisiert (als Richtwert: Konversationen etwa 180 Tage; Buchungen gemäà den geltenden buchhalterischen/steuerlichen Pflichten). Die Einwilligung bleibt bis zum Widerruf gültig.", 2045 gdpr_info_revoke:"Wie widerrufen:", 2046 gdpr_info_revoke_d:"Ãffnen Sie den Chat â Menü (â¯) â Datenschutz. Sie können die Einstellungen ändern oder alle Daten löschen.", 2047 gdpr_info_rights:"Ihre Rechte:", 2048 gdpr_info_rights_d:"Auskunft, Berichtigung, Löschung, Einschränkung, Ãbertragbarkeit, Widerspruch (artt. 15-22 GDPR). Beschwerde bei der zuständigen Aufsichtsbehörde.", 2049 gdpr_info_storage:"Wo werden sie gespeichert:", 2050 gdpr_info_storage_d:"Die Vorausfülldaten, der Verlauf und die Einstellungen verbleiben im localStorage Ihres Browsers, auf diesem Gerät (verschleiert, aber nicht mit kryptografischen Standards verschlüsselt). Wenn Sie buchen oder im Chat schreiben, werden die entsprechenden Daten an die Server der unter 'Empfänger' angegebenen Auftragsverarbeiter übermittelt und dort gespeichert.", 2051 gdpr_info_vat:"USt-IdNr. / Steuernummer:", 2052 gdpr_privacy_policy:"Vollständige Datenschutzerklärung", 2053 gdpr_reject_all:"Alle ablehnen", 2054 gdpr_save_prefs:"Einstellungen speichern", 2055 menu_privacy:"Datenschutz und personenbezogene Daten", 2056 privacy_panel_absent:"nicht vorhanden", 2057 privacy_panel_consent_h:"Ihre Einwilligungseinstellungen", 2058 privacy_panel_data_client:"Stammdaten (Name, Steuernummer, E-Mail, Telefon)", 2059 privacy_panel_data_h:"Aktuell auf diesem Gerät gespeicherte Daten", 2060 privacy_panel_data_history:"Buchungsverlauf (Anzahl)", 2061 privacy_panel_data_resume:"Laufende Buchung (Fortsetzung)", 2062 privacy_panel_data_sid:"Sitzungskennung", 2063 privacy_panel_delete_all:"Alles löschen", 2064 privacy_panel_export:"Meine Daten herunterladen", 2065 privacy_panel_export_h:"Download gestartet", 2066 privacy_panel_export_s:"Ihre Daten wurden in eine JSON-Datei exportiert.", 2067 privacy_panel_h:"Datenschutz und Ihre Daten", 2068 privacy_panel_present:"vorhanden", 2069 privacy_panel_s:"Hier können Sie die Einwilligungseinstellungen ändern, die auf Ihrem Gerät gespeicherten Daten herunterladen oder sie vollständig löschen.", 2070 privacy_panel_saved_h:"Einstellungen gespeichert", 2071 privacy_panel_saved_s:"Ihre Einwilligungsentscheidungen wurden aktualisiert.", 2072 /* a11y accessible names */ aria_open_chat:"Chat öffnen", aria_close_chat:"Chat schlieÃen", ph_write_message:"Nachricht schreiben", aria_send:"Nachricht senden", aria_scroll_bottom:"Zum letzten Beitrag", aria_options:"Optionen", aria_close:"SchlieÃen", aria_fullscreen:"Vollbild", aria_exit_fullscreen:"Vollbild verlassen", aria_voice:"Spracheingabe", aria_book:"Buchung öffnen", aria_open_book:"Chat öffnen und Termin buchen", skip_to_input:"Zum Nachrichtenfeld springen", aria_history:"Gesprächsverlauf", sr_assistant:"Assistent", sr_you_said:"Sie schrieben:", sr_said:"schrieb:", assistant_typing:"Der Assistent schreibtâ¦", label_error:"Fehler", booking_confirmed:"Buchung bestätigt", menu_contact:"Mit einem Mitarbeiter sprechen", aria_email:"E-Mail-Adresse", aria_pwd_email:"E-Mail zum Zurücksetzen des Passworts", aria_view_on_map:"{0} auf der Karte ansehen", aria_copy:"Nachricht kopieren", aria_translate:"Nachricht übersetzen", aria_scroll_left:"Nach links scrollen", aria_scroll_right:"Nach rechts scrollen", aria_remove:"Entfernen", aria_add:"Hinzufügen", aria_speak:"Sprechen", aria_more_info:"Mehr Infos", aria_hide:"Ausblenden", aria_expand_booking:"Buchung im Vollbild öffnen", aria_book_appointment:"Termin buchen", aria_booking:"Buchung", aria_search_office:"Büro suchen", 2073 hero_title:"Termin buchen", 2074 hero_subtitle:"â± ~2 Minuten · ", 2075 step_zone:"Region", step_service:"Service", step_office:"Filiale", step_time:"Zeit", step_data:"Daten", 2076 h_region:"In welcher Region möchten Sie buchen?", s_region:"Wählen Sie Ihre Region, um verfügbare Filialen und Services zu sehen.", 2077 h_province:"In welcher Provinz möchten Sie buchen?", s_province:"Nutzen Sie Ihren Standort oder wählen Sie manuell.", 2078 h_service:"Service auswählen", s_service:"Wählen Sie den Service, den Sie buchen möchten.", 2079 h_service_hint:"Welcher Service interessiert Sie?", s_service_hint:'Gefiltert nach "{0}". Verfeinern Sie die Suche unten.', 2080 h_office:"Filiale auswählen", s_office:"Hier alle verfügbaren Filialen. Nutzen Sie GPS für die nächste.", 2081 h_slot:"Datum und Uhrzeit wählen", s_slot:"Wählen Sie einen Tag im Kalender, um verfügbare Zeiten zu sehen.", 2082 h_data:"Daten eingeben", s_data:"Fast fertig! Wir benötigen die Daten des Termininhabers.", 2083 h_confirm:"Buchung bestätigen", s_confirm:"Ãberprüfen Sie die Details. Wenn alles stimmt, klicken Sie Bestätigen.", 2084 btn_back:"Zurück", btn_cancel:"Abbrechen", btn_confirm:"Buchung bestätigen", btn_confirming:"Speichere...", 2085 consent_before:"Ich habe die ", consent_link:"Datenschutzerklärung", consent_after:" gelesen und willige in die Verarbeitung meiner personenbezogenen Daten zur Verwaltung des Termins ein.", consent_required:"Um fortzufahren, müssen Sie der Verarbeitung Ihrer personenbezogenen Daten zustimmen.", 2086 chatgate_h:"Ihre Privatsphäre", chatgate_body:"Bevor Sie das Gespräch beginnen, lesen Sie bitte die Datenschutzerklärung und stimmen Sie der Verarbeitung Ihrer personenbezogenen Daten zu.", chatgate_before:"Ich habe die ", chatgate_link:"Datenschutzerklärung", chatgate_after:" gelesen und willige in die Verarbeitung meiner personenbezogenen Daten ein.", chatgate_btn:"Akzeptieren und starten", 2087 ai_disclosure:"Sie chatten mit einem virtuellen Assistenten auf Basis künstlicher Intelligenz (KI), nicht mit einer Person. Die Antworten werden automatisch generiert und können Fehler enthalten: Ãberprüfen Sie wichtige Informationen bei einem Mitarbeiter. Ihr Anliegen wird von menschlichen CAF-Mitarbeitern bearbeitet.", 2088 ai_badge:"KI-Assistent · automatisch generierte Antworten", ai_voice_notice:"Sie sprechen mit einem automatischen Sprachassistenten auf Basis künstlicher Intelligenz. Die Antworten werden automatisch generiert und können Fehler enthalten.", 2089 email_gate_body:"Um das Gespräch zu beginnen, geben Sie bitte Ihre E-Mail-Adresse ein.", email_gate_ph:"Ihre E-Mail", email_gate_btn:"Chat starten", email_gate_note:"Ihre E-Mail wird nur zur Bearbeitung dieses Gesprächs und Ihrer Anfrage verwendet.", email_gate_privacy:"Datenschutzerklärung", aria_prev_month:"Vormonat", aria_next_month:"Nächster Monat", hold_hint:"Wir empfehlen, innerhalb von {0} abzuschlieÃen", hold_expired:"Die verfügbaren Zeiten können sich inzwischen geändert haben: Bitte prüfen Sie die Verfügbarkeit, bevor Sie bestätigen.", 2090 age_gate_label:"Ich erkläre, dass ich mindestens 14 Jahre alt bin. Für Kinder unter 14 Jahren ist die Zustimmung eines Elternteils oder Erziehungsberechtigten erforderlich.", 2091 translate_ai_note:"Automatische Ãbersetzung, generiert von KI", demo_ribbon:"DEMO â die angezeigten Daten sind Beispieldaten. Es wird keine echte Buchung vorgenommen.", 2092 btn_retry:"Erneut versuchen", btn_change:"Auswahl ändern", btn_call:"Anrufen", btn_restart:"Neu starten", btn_close:"SchlieÃen", btn_edit:"Bearbeiten", 2093 btn_use_location:"Standort nutzen", btn_use_location_sub:"Ich finde Ihre Provinz automatisch", 2094 btn_find_nearest:"Nächste Filiale finden", btn_find_nearest_again:"Erneut suchen", 2095 btn_next:"Weiter zur Ãbersicht", btn_load_more:"Weitere Zeiten laden", 2096 label_or_choose:"oder manuell wählen",
2097 label_morning:"Vormittag", label_afternoon:"Nachmittag", 2098 label_today:"HEUTE", label_tomorrow:"MORGEN", label_this_week:"DIESE WOCHE", 2099 label_count_slots_one:"{0} Termin", label_count_slots_other:"{0} Termine", 2100 label_first_avail:"Frühester Termin", label_quickpick_arrow:"â", 2101 label_when:"Wann", label_where:"Wo", label_what:"Was", 2102 label_owner:"Inhaber", label_contacts:"Kontakte", 2103 label_summary_premium_h:"Alles bereit, prüfen und bestätigen", 2104 label_summary_premium_s:"Ãberprüfen Sie die Details vor dem Speichern.", 2105 label_summary_disclaimer:"Sie können bis 24 Stunden vor dem Termin ändern oder stornieren.", 2106 label_form_for_self:"Für mich", label_form_for_family:"Für ein Familienmitglied", 2107 label_form_nome:"Vorname", label_form_cognome:"Nachname", label_form_codfis:"Steuernummer", label_form_email:"E-Mail", label_form_cellulare:"Mobil", 2108 label_form_duration_min:"Dauer ~{0} Min", 2109 label_data_safe:"Daten geschützt", label_data_caf:"CAF-Support", label_data_real:"Termin mit echten CAF-Beratern", 2110 label_done_h:"Buchung bestätigt!", label_done_s:"Wir haben Ihnen eine Bestätigungs-E-Mail mit allen Details gesendet.", 2111 label_done_id:"Buchungs-ID: ", 2112 label_add_calendar:"Zum Kalender hinzufügen", label_share_whatsapp:"Auf WhatsApp teilen", label_email_reminder:"E-Mail-Erinnerung", 2113 label_done_survey:"Wie war die Erfahrung?", label_done_thanks:"Danke für das Feedback!", 2114 label_done_good:"Gut", label_done_meh:"Mittel", label_done_bad:"Schlecht", 2115 ph_search_service:"Suchen (z.B. Steuer, ISEE, Wohnung...)", 2116 ph_search_office:"Filiale nach Name, Stadt oder Adresse suchen", 2117 ph_search_province:"Stadt oder Provinz suchen", 2118 ph_no_results_one:'Kein Service gefunden für "{0}"', 2119 label_did_you_mean:"Meinten Sie:", 2120 label_popular:"ð¥ Am gefragtesten", label_recent:"â± Kürzlich", label_frequent:"â Häufig", 2121 label_resume_h:"Da fortfahren, wo Sie aufgehört haben", 2122 label_resume_btn:"Fortsetzen", label_resume_discard:"Neu starten", 2123 label_offline_b:"Sie sind offline.", label_offline_msg:" Verbinden Sie sich, um die Buchung abzuschlieÃen. Status ist gespeichert.", 2124 label_slow:"Langsame Verbindung â das Laden kann etwas länger dauern.", 2125 err_h_default:"Ein Fehler ist aufgetreten", err_s_default:"Erneut versuchen oder die Hotline anrufen.", 2126 err_h_timeout:"Langsame Verbindung", err_s_timeout:"Der Server antwortet nicht. Verbindung prüfen und erneut versuchen.", 2127 err_h_network:"Keine Verbindung", err_s_network:"Sie scheinen offline zu sein. Netzwerk prüfen und erneut versuchen.", 2128 err_h_no_services:"Keine Services verfügbar", err_s_no_services:"Aktuell keine online buchbaren Services für Ihre Provinz.", 2129 err_h_no_sedi:"Keine Filialen verfügbar", err_s_no_sedi:"Keine Filialen für diesen Service in Ihrer Provinz konfiguriert.", 2130 err_h_no_spazi:"Keine Termine verfügbar", err_s_no_spazi:"Aktuell keine freien Zeiten. In ein paar Tagen erneut versuchen.", 2131 err_h_already_booked:"Sie haben bereits eine Buchung", err_s_already_booked:"Sie sind bereits für einen Service an diesen Daten gebucht.", 2132 err_h_slot_taken:"Termin gerade vergeben", err_s_slot_taken:"Der gewählte Termin wurde soeben von jemand anderem gebucht. Bitte wählen Sie einen anderen Termin aus der aktualisierten Liste.", 2133 err_h_validation:"Ungültige Daten", err_s_validation:"Felder prüfen und erneut versuchen.", 2134 err_h_disabled:"Service nicht verfügbar", err_s_disabled:"Online-Buchung ist vorübergehend deaktiviert.", 2135 err_h_server:"Server-Fehler", err_s_server:"Ungültige Antwort vom Server. Bitte gleich erneut versuchen.", 2136 toast_slow_title:"Langsame Verbindung", toast_slow_msg:"Erneuter Versuch ({0}/3)...", 2137 gdpr_h:"Möchten Sie zukünftige Buchungen beschleunigen?", 2138 gdpr_body:"Wir können Ihre Daten (Name, Steuernummer, E-Mail, Telefon) auf diesem Gerät speichern, um sie nächstes Mal vorauszufüllen. Daten bleiben im Browser und werden nach 30 Tagen Inaktivität gelöscht.", 2139 gdpr_more:"Mehr Infos", gdpr_accept:"Ja, Daten merken", gdpr_deny:"Nein, danke", 2140 menu_new:"Neue Konversation", 2141 menu_revoke:"Einwilligung widerrufen und Daten löschen", 2142 revoke_confirm:"Gespeicherte Daten (Name, Steuernummer, E-Mail, Telefon) löschen und Einwilligung widerrufen?", 2143 revoke_done_h:"Daten gelöscht", revoke_done_s:"Ihre persönlichen Daten wurden von diesem Gerät entfernt.", 2144 cf_valid_ok:"Gültige Steuernummer.", cf_valid_recognized:"Erkannt: {0}, geboren am {1}, Gemeinde (Code {2})", 2145 cf_invalid:"Ungültige Steuernummer. Zeichen prüfen.", 2146 cf_male:"â Männlich", cf_female:"â Weiblich", 2147 /* EAA/WCAG 2026-07-19: chiavi a11y + i18n wizard/voice */ 2148 sr_booking_launch:"Eine Schaltfläche zum Ãffnen der Buchung ist verfügbar.", sr_results_count:"{0} Ergebnisse", sr_no_results:"Keine Ergebnisse", aria_select_office:"{0} auswählen", 2149 aria_stop_listening:"Zuhören beenden", sr_listening:"Ich höre zu", sr_processing:"Verarbeitung läuft", aria_back_to_step:"Zurück zu Schritt {0}: {1}", 2150 step_of:"Schritt {0} von {1}", cal_months:"Januar|Februar|März|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember", cal_months_short:"Jan|Feb|Mär|Apr|Mai|Jun|Jul|Aug|Sep|Okt|Nov|Dez", cal_days_full:"Sonntag|Montag|Dienstag|Mittwoch|Donnerstag|Freitag|Samstag", 2151 cal_days_short:"Mo|Di|Mi|Do|Fr|Sa|So", label_available:"Verfügbar", label_selected:"Ausgewählt", label_nearest:"Nächstgelegene", 2152 label_view_map:"Auf der Karte ansehen", label_no_slots_h:"Keine Uhrzeiten verfügbar", label_no_slots_s:"Derzeit sind keine Termine verfügbar. Versuchen Sie ein anderes Büro oder später erneut.", label_no_dates_h:"Uhrzeiten nicht verfügbar", 2153 label_no_dates_s:"Die verfügbaren Termine konnten nicht gelesen werden. Versuchen Sie ein anderes Büro.", label_pick_day:"Wählen Sie einen Tag", label_no_slots_day:"Keine Uhrzeiten an diesem Tag. Versuchen Sie einen anderen verfügbaren Tag.", ph_no_office_found:"Kein Büro gefunden für \"{0}\"", 2154 ph_no_results_generic:"Keine Ergebnisse für \"{0}\"", label_reuse_data_h:"Gespeicherte Daten wiederverwenden?", btn_its_me:"Ja, das bin ich", btn_not_me:"Nein, ich bin jemand anderes", 2155 label_saved_data:"Gespeicherte Daten", label_submitting:"Buchung wird gesendet...", label_submitting_sub:"Ihr Termin wird registriert, das dauert einige Sekunden.", label_loading_hint:"Ich suche Verfügbarkeiten für \"{0}\"...", 2156 label_loading_options:"Optionen werden geladen...", label_loading_short:"Wird geladenâ¦", label_error_code:"Code: {0}", label_booking_inprogress:"Buchung läuft", 2157 label_expand:"VergröÃern", label_expand_sub:"Ãffnen Sie die Vollbildansicht für ein besseres Erlebnis", t_region:"Region auswählen", t_province:"Provinz auswählen", 2158 t_data:"Ihre Daten", label_srv_hint_found:"Ich habe diese Leistungen für \"{0}\" gefunden. Welche interessiert Sie?", label_srv_hint:"Welche Leistung möchten Sie buchen?", label_sede_hint:"Perfekt! Wählen Sie jetzt das für Sie günstigste Büro.", 2159 btn_find_nearest_sub:"Ihre GPS-Position verwenden", btn_find_nearest_again_sub:"Aktualisierte Position verwenden", label_slots_hint:"Hier sind die verfügbaren Termine. Wählen Sie Ihren Favoriten!", label_pref_time:"Sie bevorzugen normalerweise: {0}.", 2160 label_data_hint:"Fast geschafft! Geben Sie Ihre Daten ein, um die Buchung abzuschlieÃen.", label_date_at:"{0} um {1}", label_appointment:"Termin", btn_use:"Ãbernehmen", 2161 btn_calendar:"Kalender", btn_email_reminder:"Erinnerung per E-Mail senden", btn_choose_other:"Anderen wählen", btn_choose_other_slot:"Andere Uhrzeit wählen", 2162 label_done_email:"Buchung registriert für {0}
2162", err_cf_required:"Geben Sie die Steuernummer ein.", err_cf_short:"Die Steuernummer muss 16 Zeichen lang sein (es fehlen {0} Zeichen).", err_cf_long:"Die Steuernummer muss 16 Zeichen lang sein ({0} zu viel).", 2163 err_cf_chars:"Die Steuernummer darf nur Buchstaben und Ziffern enthalten.", err_cf_format:"Das Format der Steuernummer ist ungültig. Prüfen Sie die eingegebenen Zeichen.", err_cf_charpos:"Ungültiges Zeichen an Position {0}.", err_cf_checksum:"Die Prüfziffer stimmt nicht (erwartet '{0}' statt '{1}'). Bitte prüfen.", 2164 err_cf_first:"Geben Sie zuerst eine gültige Steuernummer ein", err_cf_proceed:"Geben Sie eine gültige Steuernummer ein, bevor Sie fortfahren.", err_required_fields:"Füllen Sie alle Pflichtfelder aus.", err_email_invalid:"Geben Sie eine gültige E-Mail-Adresse ein.", 2165 err_phone_invalid:"Geben Sie eine gültige Handynummer ein (mindestens 9 Ziffern).", err_uncertain:"Wir haben keine Bestätigung vom Server erhalten: Ihre Buchung kann trotzdem erfolgreich gewesen sein. Prüfen Sie Ihre E-Mail, bevor Sie es erneut versuchen.", err_unexpected:"Unerwarteter Fehler. Versuchen Sie es erneut.", help_codfis:"Sie finden sie auf Ihrer Gesundheitskarte. Sie ist 16 Zeichen lang.", 2166 help_email:"Nur für die Bestätigung und die Erinnerung 24 Std. vorher.", help_cellulare:"Für dringende Mitteilungen des Büros.", voice_tap_to_talk:"Zum Sprechen tippen", voice_press_to_start:"Drücken Sie das Mikrofon, um zu beginnen", 2167 voice_speak_now:"Sprechen Sie jetzt", voice_listening_hint:"Ich höre zu... sprechen Sie", voice_responding:"Ich antworte", voice_tap_interrupt:"Tippen Sie auf das Mikrofon, um zu unterbrechen", 2168 voice_thinking:"Ich denke nach...", voice_thinking_hint:"Einen Moment, ich bereite die Antwort vor", voice_back_keyboard:"Zurück zur Tastatur", ph_your_city:"Geben Sie Ihre Stadt ein...", ph_follow_steps:"Folgen Sie den Schritten oben, um zu buchen..." 2169 }, 2170 ro:{ 2171 /* --- GDPR / privacy (i18n completo, art. 12-13 GDPR) --- */ 2172 gdpr_accept_all:"AcceptÄ toate", 2173 gdpr_body_v2:"Pentru a-Èi oferi serviciul de rezervare online, {0} utilizeazÄ anumite tehnologii de stocare localÄ (localStorage) pe dispozitivul tÄu. PoÈi alege ce categorii sÄ accepÈi. Alegerea ta este înregistratÄ Èi revocabilÄ Ã®n orice moment din meniul â¯.", 2174 gdpr_cat_analytics:"AnalizÄ Èi marketing", 2175 gdpr_cat_analytics_d:"Identificator de sesiune, parametri de campanie (UTM, gclid, fbclid) Èi colectarea evenimentelor din fluxul de rezervare pentru a îmbunÄtÄÈi serviciul. Date pseudonimizate.", 2176 gdpr_cat_functional:"FuncÈionale", 2177 gdpr_cat_functional_d:"Memorarea datelor tale de rezervare (nume, cod fiscal, e-mail, telefon) pentru a le precompleta data viitoare. Starea rezervÄrii în curs (reluare) Èi istoricul recent.", 2178 gdpr_cat_necessary:"Necesare (întotdeauna active)", 2179 gdpr_cat_necessary_d:"Tehnologii indispensabile funcÈionÄrii widgetului: memoria cache a regiunilor/sediilor/serviciilor, limba. FÄrÄ acestea widgetul nu funcÈioneazÄ. Temei juridic: strict necesare serviciului solicitat (scutite de consimÈÄmânt).", 2180 gdpr_customize:"PersonalizeazÄ", 2181 gdpr_info_address:"Sediu:", 2182 gdpr_info_controller:"Operator de date:", 2183 gdpr_info_dpo:"Responsabil cu protecÈia datelor (DPO):", 2184 gdpr_info_email:"E-mail/PEC:", 2185 gdpr_info_legal_basis:"Temei juridic:", 2186 gdpr_info_legal_basis_d:"Necesare = tehnologii strict necesare serviciului solicitat (scutite de consimÈÄmânt, art. 122 din reglementÄrile naÈionale aplicabile); datele de rezervare sunt prelucrate pentru a executa serviciul solicitat (art. 6.1.b GDPR). FuncÈionale Èi AnalizÄ = consimÈÄmânt (art. 6.1.a GDPR).", 2187 gdpr_info_phone:"Telefon:", 2188 gdpr_info_recipients:"Destinatari:", 2189 gdpr_info_recipients_d:"Datele de rezervare sunt trimise sistemului Planner CAF (în UE) pentru a finaliza programarea. ConversaÈiile sunt prelucrate de platforma conversaÈionalÄ Mantis Èi de furnizorii de IA conectaÈi, inclusiv în afara UE în baza clauzelor contractuale standard (SCC): asistenÈÄ conversaÈionalÄ Èi clasificarea intenÈiilor (Anthropic) Èi analiza eventualelor documente încÄrcate (Ope
2189nAI). Pentru a identifica cel mai apropiat sediu se utilizeazÄ OpenStreetMap/Nominatim Èi servicii de geolocalizare prin IP. DistribuÈie tehnicÄ: Vercel Èi Bunny CDN; pÄstrare pe Google Firebase. ConsultÄ Politica de confidenÈialitate a Operatorului de date.", 2190 gdpr_info_retention:"Perioada de pÄstrare:", 2191 gdpr_info_retention_d:"Pe dispozitiv: 30 de zile de la ultima activitate, apoi Ètergere automatÄ. Pe servere: conversaÈiile sunt Èterse, iar jurnalele de rezervare sunt anonimizate în termenele stabilite de Operatorul de date (cu titlu indicativ: conversaÈii aproximativ 180 de zile; rezervÄri conform obligaÈiilor contabile/fiscale aplicabile). ConsimÈÄmântul rÄmâne valabil pânÄ la revocare.", 2192 gdpr_info_revoke:"Cum revoci:", 2193 gdpr_info_revoke_d:"Deschide chatul â meniu (â¯) â ConfidenÈialitate. PoÈi modifica preferinÈele sau Èterge toate datele.", 2194 gdpr_info_rights:"Drepturile tale:", 2195 gdpr_info_rights_d:"Acces, rectificare, Ètergere, restricÈionare, portabilitate, opoziÈie (artt. 15-22 GDPR). Plângere la autoritatea de supraveghere competentÄ.", 2196 gdpr_info_storage:"Unde sunt pÄstrate:", 2197 gdpr_info_storage_d:"Datele de precompletare, istoricul Èi preferinÈele rÄmân în localStorage-ul browserului tÄu, pe acest dispozitiv (ofuscate, dar necriptate cu standarde criptografice). Când faci o rezervare sau scrii în chat, datele corespunzÄtoare sunt trimise Èi pÄstrate pe serverele persoanelor împuternicite indicate la 'Destinatari'.", 2198 gdpr_info_vat:"Cod TVA / Cod fiscal:", 2199 gdpr_privacy_policy:"Politica de confidenÈialitate completÄ", 2200 gdpr_reject_all:"Respinge toate", 2201 gdpr_save_prefs:"SalveazÄ preferinÈele", 2202 menu_privacy:"ConfidenÈialitate Èi date personale", 2203 privacy_panel_absent:"absente", 2204 privacy_panel_consent_h:"PreferinÈele tale de consimÈÄmânt", 2205 privacy_panel_data_client:"Date de identificare (nume, cod fiscal, e-mail, telefon)", 2206 privacy_panel_data_h:"Date stocate în prezent pe acest dispozitiv", 2207 privacy_panel_data_history:"Istoricul rezervÄrilor (numÄr)", 2208 privacy_panel_data_resume:"Rezervare în curs (reluare)", 2209 privacy_panel_data_sid:"Identificator de sesiune", 2210 privacy_panel_delete_all:"Èterge tot", 2211 privacy_panel_export:"DescarcÄ datele mele", 2212 privacy_panel_export_h:"DescÄrcare iniÈiatÄ", 2213 privacy_panel_export_s:"Datele tale au fost exportate într-un fiÈier JSON.", 2214 privacy_panel_h:"ConfidenÈialitate Èi datele tale", 2215 privacy_panel_present:"prezente", 2216 privacy_panel_s:"Aici poÈi modifica preferinÈele de consimÈÄmânt, descÄrca datele pe care le-am stocat pe dispozitivul tÄu sau le poÈi Èterge complet.", 2217 privacy_panel_saved_h:"PreferinÈe salvate", 2218 privacy_panel_saved_s:"Alegerile tale privind consimÈÄmântul au fost actualizate.", 2219 /* a11y accessible names */ aria_open_chat:"Deschide chatul", aria_close_chat:"Ãnchide chatul", ph_write_message:"Scrie un mesaj", aria_send:"Trimite mesajul", aria_scroll_bottom:"Mergi la ultimul mesaj", aria_options:"OpÈiuni", aria_close:"Ãnchide", aria_fullscreen:"Ecran complet", aria_exit_fullscreen:"IeÈi din ecran complet", aria_voice:"RecunoaÈtere vocalÄ", aria_book:"Deschide rezervarea", aria_open_book:"Deschide chatul Èi rezervÄ o programare", skip_to_input:"Sari la câmpul de mesaj", aria_history:"Istoricul conversaÈiei", sr_assistant:"Asistent", sr_you_said:"Ai scris:", sr_said:"a scris:", assistant_typing:"Asistentul scrieâ¦", label_error:"Eroare", booking_confirmed:"Rezervare confirmatÄ", menu_contact:"VorbeÈte cu un operator", aria_email:"AdresÄ de email", aria_pwd_email:"Email pentru resetarea parolei", aria_view_on_map:"Vezi {0} pe hartÄ", aria_copy:"CopiazÄ mesajul", aria_translate:"Traduce mesajul", aria_scroll_left:"DeruleazÄ la stânga", aria_scroll_right:"DeruleazÄ la dreapta", aria_remove:"EliminÄ", aria_add:"AdaugÄ", aria_speak:"VorbeÈte", aria_more_info:"Mai multe informaÈii", aria_hide:"Ascunde", aria_expand_booking:"Deschide rezervarea pe tot ecranul", aria_book_appointment:"RezervÄ-Èi programarea", aria_booking:"Rezervare", aria_search_office:"CautÄ sediu", 2220 hero_title:"RezervÄ-Èi programarea", 2221 hero_subtitle:"â± ~2 minute · ", 2222 step_zone:"ZonÄ", step_service:"Serviciu", step_office:"Sediu", step_time:"OrÄ", step_data:"Date", 2223 h_region:"Ãn ce regiune vrei sÄ rezervi?", s_region:"Alege regiunea pentru a vedea sediile Èi serviciile disponibile.", 2224 h_province:"Ãn ce provincie vrei sÄ rezervi?", s_province:"FoloseÈte locaÈia sau alege manual.", 2225 h_service:"Alege serviciul", s_service:"SelecteazÄ serviciul pe care vrei sÄ-l rezervi.", 2226 h_service_hint:"Ce serviciu te intereseazÄ?", s_service_hint:'Filtrat dupÄ "{0}". PoÈi rafina cÄutarea mai jos.', 2227 h_office:"Alege sediul", s_office:"IatÄ toate sediile disponibile. FoloseÈte geolocaÈia pentru cel mai apropiat.", 2228 h_slot:"Alege data Èi ora", s_slot:"SelecteazÄ o zi din calendar pentru a vedea orele disponibile.", 2229 h_data:"Introdu datele", s_data:"Aproape gata! Avem nevoie de datele titularului.", 2230 h_confirm:"ConfirmÄ rezervarea", s_confirm:"VerificÄ detaliile. DacÄ totul e corect, apasÄ ConfirmÄ.", 2231 btn_back:"Ãnapoi", btn_cancel:"AnuleazÄ", btn_confirm:"ConfirmÄ rezervarea", btn_confirming:"Se salveazÄ...", 2232 consent_before:"Am citit ", consent_link:"politica de confidenÈialitate", consent_after:" Èi sunt de acord cu prelucrarea datelor mele personale pentru gestionarea programÄrii.", consent_required:"Pentru a continua trebuie sÄ accepÈi prelucrarea datelor tale personale.", 2233 chatgate_h:"ConfidenÈialitatea ta", chatgate_body:"Ãnainte de a începe conversaÈia, te rugÄm sÄ citeÈti politica de confidenÈialitate Èi sÄ Ã®Èi dai acordul pentru prelucrarea datelor personale.", chatgate_before:"Am citit ", chatgate_link:"politica de confidenÈialitate", chatgate_after:" Èi sunt de acord cu prelucrarea datelor mele personale.", chatgate_btn:"AcceptÄ Èi începe", 2234 ai_disclosure:"VorbeÈti cu un asistent virtual bazat pe inteligenÈÄ artificialÄ (IA), nu cu o persoanÄ. RÄspunsurile sunt generate automat Èi pot conÈine erori: verificÄ informaÈiile importante cu un operator. Dosarul tÄu va fi gestionat de personalul uman al CAF.", 2235 ai_badge:"Asistent IA · rÄspunsuri generate automat", ai_voice_notice:"VorbeÈti cu un asistent vocal automat bazat pe inteligenÈÄ artificialÄ. RÄspunsurile sunt generate automat Èi pot conÈine erori.", 2236 email_gate_body:"Pentru a începe conversaÈia, introdu adresa ta de e-mail.", email_gate_ph:"E-mailul tÄu", email_gate_btn:"Ãncepe conversaÈia", email_gate_note:"Folosim e-mailul tÄu doar pentru a gestiona aceastÄ conversaÈie Èi a rÄspunde solicitÄrii tale.", email_gate_privacy:"Politica de confidenÈialitate", aria_prev_month:"Luna precedentÄ", aria_next_month:"Luna urmÄtoare", hold_hint:"ÃÈi recomandÄm sÄ finalizezi în {0}", hold_expired:"Orarele s-ar putea sÄ se fi schimbat între timp: verificÄ disponibilitatea înainte de a confirma.", 2237 age_gate_label:"Declar cÄ am cel puÈin 14 ani. Pentru minorii sub 14 ani este necesar consimÈÄmântul unui pÄrinte sau al tutorelui legal.", 2238 translate_ai_note:"Traducere automatÄ generatÄ de IA", demo_ribbon:"DEMO â datele afiÈate sunt exemple. Nu se face nicio rezervare realÄ.", 2239 btn_retry:"ReîncearcÄ", btn_change:"SchimbÄ selecÈia", btn_call:"SunÄ", btn_restart:"Reîncepe", btn_close:"Ãnchide", btn_edit:"ModificÄ", 2240 btn_use_location:"FoloseÈte locaÈia mea", btn_use_location_sub:"GÄsesc provincia automat", 2241 btn_find_nearest:"GÄseÈte sediul cel mai apropiat", btn_find_nearest_again:"CautÄ din nou", 2242 btn_next:"ContinuÄ la rezumat", btn_load_more:"ÃncarcÄ mai multe ore", 2243 label_or_choose:"sau alege manual", 2244 label_morning:"DimineaÈa", label_afternoon:"DupÄ-amiaza", 2245 label_today:"AZI", label_tomorrow:"MÃINE", label_this_week:"SÄPTÄMÃNA ASTA", 2246 label_count_slots_one:"{0} orÄ", label_count_slots_other:"{0} ore", 2247 label_first_avail:"Prima disponibilitate", label_quickpick_arrow:"â", 2248 label_when:"Când", label_where:"Unde", label_what:"Ce", 2249 label_owner:"Titular", label_contacts:"Contacte", 2250 label_summary_premium_h:"Totul e gata, verificÄ Èi confirmÄ", 2251 label_summary_premium_s:"VerificÄ detaliile înainte de a salva.", 2252 label_summary_disclaimer:"PoÈi modifica sau anula rezervarea cu pânÄ la 24 de ore înainte.", 2253 label_form_for_self:"Pentru mine", label_form_for_family:"Pentru un membru al familiei", 2254 label_form_nome:"Prenume", label_form_cognome:"Nume", label_form_codfis:"Cod fiscal", label_form_email:"Email", label_form_cellulare:"Mobil", 2255 label_form_duration_min:"DuratÄ ~{0} min", 2256 label_data_safe:"Date protejate", label_data_caf:"Suport CAF", label_data_real:"Programare cu operatori reali CAF", 2257 label_done_h:"Rezervare confirmatÄ!", label_done_s:"Èi-am trimis un email de confirmare cu toate detaliile.", 2258 label_done_id:"ID rezervare: ", 2259 label_add_calendar:"AdaugÄ Ã®n calendar", label_share_whatsapp:"Distribuie pe WhatsApp", label_email_reminder:"Memento prin email", 2260 label_done_survey:"Cum a fost experienÈa?", label_done_thanks:"MulÈumim pentru feedback!", 2261 label_done_good:"Bine", label_done_meh:"AÈa Èi aÈa", label_done_bad:"RÄu", 2262 ph_search_service:"CautÄ (ex. 730, ISEE, casÄ...)", 2263 ph_search_office:"CautÄ sediu dupÄ nume, oraÈ sau adresÄ", 2264 ph_search_province:"CautÄ oraÈ sau provincie", 2265 ph_no_results_one:'Niciun serviciu gÄsit pentru "{0}"', 2266 label_did_you_mean:"Ai vrut sÄ spui:", 2267 label_popular:"ð¥ Cel mai cerut", label_recent:"â± Recent", label_frequent:"â Frecvent",
2268 label_resume_h:"Reia de unde ai rÄmas", 2269 label_resume_btn:"Reia", label_resume_discard:"Reîncepe", 2270 label_offline_b:"EÈti offline.", label_offline_msg:" ConecteazÄ-te pentru a finaliza rezervarea. Starea e salvatÄ.", 2271 label_slow:"Conexiune lentÄ â încÄrcarea poate dura câteva secunde în plus.", 2272 err_h_default:"A apÄrut o eroare", err_s_default:"ReîncearcÄ sau sunÄ la numÄrul verde pentru asistenÈÄ.", 2273 err_h_timeout:"Conexiune lentÄ", err_s_timeout:"Serverul nu rÄspunde. VerificÄ conexiunea Èi reîncearcÄ.", 2274 err_h_network:"FÄrÄ conexiune", err_s_network:"Pari sÄ fii offline. VerificÄ reÈeaua Èi reîncearcÄ.", 2275 err_h_no_services:"Niciun serviciu disponibil", err_s_no_services:"Momentan nu existÄ servicii rezervabile online în provincia ta.", 2276 err_h_no_sedi:"Niciun sediu disponibil", err_s_no_sedi:"Niciun sediu configurat pentru acest serviciu în provincia ta.", 2277 err_h_no_spazi:"Nicio orÄ disponibilÄ", err_s_no_spazi:"Momentan nu sunt ore libere. ReîncearcÄ sau schimbÄ sediul.", 2278 err_h_already_booked:"Ai deja o rezervare", err_s_already_booked:"EÈti deja înregistrat pentru un serviciu în aceste date.", 2279 err_h_slot_taken:"Interval tocmai ocupat", err_s_slot_taken:"Ora aleasÄ tocmai a fost rezervatÄ de altcineva. Alege altÄ orÄ din lista actualizatÄ.", 2280 err_h_validation:"Date invalide", err_s_validation:"VerificÄ câmpurile Èi reîncearcÄ.", 2281 err_h_disabled:"Serviciu indisponibil", err_s_disabled:"Rezervarea online este temporar dezactivatÄ.", 2282 err_h_server:"Eroare server", err_s_server:"RÄspuns invali
2282d de la server. ReîncearcÄ Ã®n scurt timp.", 2283 toast_slow_title:"Conexiune lentÄ", toast_slow_msg:"Se reîncearcÄ ({0}/3)...", 2284 gdpr_h:"Vrei sÄ accelerezi rezervÄrile viitoare?", 2285 gdpr_body:"Putem memora datele tale (nume, cod fiscal, email, telefon) pe acest dispozitiv pentru a le precompleta data viitoare. Datele rÄmân în browser Èi se autoÈterg dupÄ 30 de zile de inactivitate.", 2286 gdpr_more:"Mai multe informaÈii", gdpr_accept:"Da, memoreazÄ datele mele", gdpr_deny:"Nu, mulÈumesc", 2287 menu_new:"ConversaÈie nouÄ", 2288 menu_revoke:"RevocÄ consimÈÄmântul Èi Èterge datele", 2289 revoke_confirm:"Ètergi datele salvate (nume, cod fiscal, email, telefon) Èi revoci consimÈÄmântul?", 2290 revoke_done_h:"Date Èterse", revoke_done_s:"Datele tale personale au fost eliminate de pe acest dispozitiv.", 2291 cf_valid_ok:"Cod fiscal valid.", cf_valid_recognized:"Recunoscut: {0}, nÄscut/Ä pe {1}, comunÄ (cod {2})", 2292 cf_invalid:"Cod fiscal invalid. VerificÄ caracterele introduse.", 2293 cf_male:"â BÄrbat", cf_female:"â Femeie", 2294 /* EAA/WCAG 2026-07-19: chiavi a11y + i18n wizard/voice */ 2295 sr_booking_launch:"Este disponibil un buton pentru a deschide rezervarea.", sr_results_count:"{0} rezultate", sr_no_results:"Niciun rezultat", aria_select_office:"SelecteazÄ {0}", 2296 aria_stop_listening:"OpreÈte ascultarea", sr_listening:"Ascult", sr_processing:"Se proceseazÄ", aria_back_to_step:"Ãnapoi la pasul {0}: {1}", 2297 step_of:"Pasul {0} din {1}", cal_months:"Ianuarie|Februarie|Martie|Aprilie|Mai|Iunie|Iulie|August|Septembrie|Octombrie|Noiembrie|Decembrie", cal_months_short:"ian|feb|mar|apr|mai|iun|iul|aug|sep|oct|nov|dec", cal_days_full:"DuminicÄ|Luni|MarÈi|Miercuri|Joi|Vineri|SâmbÄtÄ", 2298 cal_days_short:"Lun|Mar|Mie|Joi|Vin|Sâm|Dum", label_available:"Disponibil", label_selected:"Selectat", label_nearest:"Cea mai apropiatÄ", 2299 label_view_map:"Vezi pe hartÄ", label_no_slots_h:"Niciun orar disponibil", label_no_slots_s:"Momentan nu existÄ intervale disponibile. ÃncearcÄ alt sediu sau revino mai târziu.", label_no_dates_h:"Orare indisponibile", 2300 label_no_dates_s:"Nu am putut citi datele disponibile. ÃncearcÄ alt sediu.", label_pick_day:"SelecteazÄ o zi", label_no_slots_day:"Niciun orar în aceastÄ zi. ÃncearcÄ o altÄ zi disponibilÄ.", ph_no_office_found:"Niciun sediu gÄsit pentru \"{0}\"", 2301 ph_no_results_generic:"Niciun rezultat pentru \"{0}\"", label_reuse_data_h:"RefoloseÈti datele salvate?", btn_its_me:"Da, sunt eu", btn_not_me:"Nu, sunt altÄ persoanÄ", 2302 label_saved_data:"Date salvate", label_submitting:"Se trimite rezervarea...", label_submitting_sub:"ÃÈi înregistrez programarea, dureazÄ câteva secunde.", label_loading_hint:"Caut disponibilitate pentru \"{0}\"...", 2303 label_loading_options:"Se încarcÄ opÈiunile...", label_loading_short:"Se încarcÄâ¦", label_error_code:"Cod: {0}", label_booking_inprogress:"Rezervare în curs", 2304 label_expand:"Extinde", label_expand_sub:"Deschide vizualizarea pe tot ecranul pentru o experienÈÄ mai bunÄ", t_region:"SelecteazÄ regiunea", t_province:"SelecteazÄ provincia", 2305 t_data:"Datele tale", label_srv_hint_found:"Am gÄsit aceste servicii pentru \"{0}\". Care te intereseazÄ?", label_srv_hint:"Ce serviciu vrei sÄ rezervi?", label_sede_hint:"Perfect! Acum alege sediul cel mai convenabil pentru tine.", 2306 btn_find_nearest_sub:"FoloseÈte poziÈia ta GPS", btn_find_nearest_again_sub:"FoloseÈte poziÈia actualizatÄ", label_slots_hint:"IatÄ programÄrile disponibile. Alege-o pe cea preferatÄ!", label_pref_time:"De obicei preferi: {0}.", 2307 label_data_hint:"Aproape gata! Introdu datele tale pentru a finaliza rezervarea.", label_date_at:"{0} la {1}", label_appointment:"Programare", btn_use:"FoloseÈte", 2308 btn_calendar:"Calendar", btn_email_reminder:"Trimite-mi memento-ul pe email", btn_choose_other:"Alege altul", btn_choose_other_slot:"Alege alt orar", 2309 label_done_email:"Rezervare înregistratÄ pentru {0}", err_cf_required:"Introdu codul fiscal.", err_cf_short:"Codul fiscal trebuie sÄ aibÄ 16 caractere (lipsesc {0}
2309).", err_cf_long:"Codul fiscal trebuie sÄ aibÄ 16 caractere ({0} în plus).", 2310 err_cf_chars:"Codul fiscal poate conÈine doar litere Èi cifre.", err_cf_format:"Formatul codului fiscal nu este valid. VerificÄ caracterele introduse.", err_cf_charpos:"Caracter nevalid la poziÈia {0}.", err_cf_checksum:"Cifra de control nu corespunde (se aÈtepta '{0}' în loc de '{1}'). VerificÄ.", 2311 err_cf_first:"Introdu mai întâi un cod fiscal valid", err_cf_proceed:"Introdu un cod fiscal valid înainte de a continua.", err_required_fields:"CompleteazÄ toate câmpurile obligatorii.", err_email_invalid:"Introdu o adresÄ de email validÄ.", 2312 err_phone_invalid:"Introdu un numÄr de mobil valid (cel puÈin 9 cifre).", err_uncertain:"Nu am primit confirmare de la server: rezervarea ta poate totuÈi sÄ fi reuÈit. VerificÄ emailul înainte de a încerca din nou.", err_unexpected:"Eroare neaÈteptatÄ. ÃncearcÄ din nou.", help_codfis:"Ãl gÄseÈti pe cardul tÄu de sÄnÄtate. Are 16 caractere.", 2313 help_email:"O folosim doar pentru confirmare Èi memento-ul cu 24 h înainte.", help_cellulare:"Pentru comunicÄri urgente de la sediu.", voice_tap_to_talk:"Atinge pentru a vorbi", voice_press_to_start:"ApasÄ microfonul pentru a începe", 2314 voice_speak_now:"VorbeÈte acum", voice_listening_hint:"Te ascult... vorbeÈte", voice_responding:"RÄspund", voice_tap_interrupt:"Atinge microfonul pentru a întrerupe", 2315 voice_thinking:"MÄ gândesc...", voice_thinking_hint:"Un moment, pregÄtesc rÄspunsul", voice_back_keyboard:"Ãnapoi la tastaturÄ", ph_your_city:"Scrie oraÈul tÄu...", ph_follow_steps:"UrmeazÄ paÈii de mai sus pentru a rezerva..." 2316 } 2317 }; 2318 2319 /* NOCAF: override chirurgico delle sole chiavi che citano il CAF o le 2320 * "pratiche". Attivo SOLO con window.__CAF_NEUTRAL__===true (env NOCAF=si): 2321 * senza flag il dizionario resta byte-per-byte identico (altri tenant intatti). */ 2322 try{ 2323 if(bkNeutralMode()){ 2324 var BK_NEUTRAL_I18N={ 2325 it:{ai_disclosure:"Stai parlando con un assistente virtuale basato sull'intelligenza artificiale (IA), non con una persona. Le risposte sono generate automaticamente e possono contenere errori: verifica le informazioni importanti con un operatore umano. La tua richiesta sarà seguita da un operatore umano.",label_data_caf:"Assistenza di operatori umani",label_data_real:"Appuntamento con operatori umani reali"}, 2326 en:{ai_disclosure:"You are chatting with a virtual assistant powered by artificial intelligence (AI), not a human. Replies are generated automatically and may contain errors: check important information with a human operator. Your request will be handled by human operators.",label_data_caf:"Human operator support",label_data_real:"Appointment with real human operators"}, 2327 fr:{ai_disclosure:"Vous discutez avec un assistant virtuel basé sur l'intelligence artificielle (IA), et non avec une personne. Les réponses sont générées automatiquement et peuvent contenir des erreurs : vérifiez les informations importantes auprès d'un opérateur humain. Votre demande sera traitée par des opérateurs humains.",label_data_caf:"Assistance d'opérateurs humains",label_data_real:"Rendez-vous avec de vrais opérateurs humains"}, 2328 es:{ai_disclosure:"Estás hablando con un asistente virtual basado en inteligencia artificial (IA), no con una persona. Las respuestas se generan automáticamente y pueden contener errores: verifica la información importante con un operador humano. Tu solicitud será atendida por operadores humanos.",label_data_caf:"Asistencia de operadores humanos",label_data_real:"Cita con operadores humanos reales"}, 2329 de:{ai_disclosure:"Sie chatten mit einem virtuellen Assistenten auf Basis künstlicher Intelligenz (KI), nicht mit einer Person. Die Antworten werden automatisch generiert und können Fehler enthalten: Ãberprüfen Sie wichtige Informationen bei einem menschlichen Mitarbeiter. Ihre Anfrage wird von menschlichen Mitarbeitern bearbeitet.",label_data_caf:"Betreuung durch menschliche Mitarbeiter",label_data_real:"Termin mit echten menschlichen Mitarbeitern"}, 2330 ro:{ai_disclosure:"VorbeÈti cu un asistent virtual bazat pe inteligenÈÄ artificialÄ (IA), nu cu o persoanÄ. RÄspunsurile sunt generate automat Èi pot conÈine erori: verificÄ informaÈiile importante cu un operator uman. Solicitarea ta va fi gestionatÄ de operatori umani.",label_data_caf:"AsistenÈÄ de la operatori umani",label_data_real:"Programare cu operatori umani reali"} 2331 }; 2332 for(var _nl in BK_NEUTRAL_I18N){if(BK_I18N[_nl]){for(var _nk in BK_NEUTRAL_I18N[_nl])BK_I18N[_nl][_nk]=BK_NEUTRAL_I18N[_nl][_nk];}} 2333 /* Destinatari GDPR: "Planner CAF" â "Planner" in tutte le lingue. */ 2334 for(var _gl in BK_I18N){var _gd=BK_I18N[_gl]&&BK_I18N[_gl].gdpr_info_recipients_d; 2335 if(typeof _gd==="string")BK_I18N[_gl].gdpr_info_recipients_d=_gd.replace(/Planner CAF/g,"Planner").replace(/CAF Planner/g,"Planner");} 2336 } 2337 }catch(_e){} 2338 2339 /* Detect lingua: priorità a opts.lang/?caf-lang/data-lang/localStorage, 2340 * poi navigator.language (primi 2 char), fallback "it". */ 2341 function bkDetectLang(opts){ 2342 try{ 2343 if(opts&&opts.lang){var forced=String(opts.lang).slice(0,2).toLowerCase();if(BK_I18N[forced])return forced;} 2344 var url="";try{var m=location.search.match(/[?&]caf-lang=([a-z]{2})/i);if(m)url=m[1].toLowerCase();}catch(_e){} 2345 if(url&&BK_I18N[url])return url; 2346 var sct=null;try{sct=document.querySelector("script[data-lang]");}catch(_e){} 2347 if(sct){var a=sct.getAttribute("data-lang");if(a&&BK_I18N[a.toLowerCase()])return a.toLowerCase();} 2348 var stored="";try{stored=localStorage.getItem(BK_LANG_KEY)||"";}catch(_e){} 2349 if(stored&&BK_I18N[stored])return stored; 2350 var nav=(navigator.language||navigator.userLanguage||"it").slice(0,2).toLowerCase(); 2351 if(BK_I18N[nav])return nav; 2352 }catch(_e){} 2353 return"it"; 2354 } 2355 2356 /* Module-scope active language; viene impostato in CafChat constructor. */ 2357 var BK_LANG="it"; 2358 function bkT(key,a,b,c){ 2359 var dict=BK_I18N[BK_LANG]||BK_I18N.it;var s=dict[key];if(s==null)s=BK_I18N.it[key];if(s==null)return key; 2360 /* placeholder substitution: {0} {1} {2} */ 2361 if(a!==undefined)s=s.replace("{0}",String(a)); 2362 if(b!==undefined)s=s.replace("{1}",String(b)); 2363 if(c!==undefined)s=s.replace("{2}",String(c)); 2364 return s; 2365 } 2366 2367 /* ââ Slot helpers: classifica fascia mattina/pomeriggio + density score ââ */ 2368 function bkSlotFascia(slot){ 2369 if(!slot||!slot.ora_inizio)return"unknown"; 2370 var hh=parseInt(String(slot.ora_inizio).slice(0,2),10); 2371 if(isNaN(hh))return"unknown"; 2372 return hh<13?"morning":"afternoon"; 2373 } 2374 /* density: 0..1 â più alto = più slot disponibili in quel range orario. */ 2375 function bkSlotDensityMap(slots){ 2376 var map={};if(!Array.isArray(slots))return map; 2377 var counts={};
2377slots.forEach(function(s){var hh=parseInt(String(s.ora_inizio||"").slice(0,2),10);if(isNaN(hh))return;counts[hh]=(counts[hh]||0)+1;}); 2378 var max=0;Object.keys(counts).forEach(function(k){if(counts[k]>max)max=counts[k];}); 2379 if(!max)return map; 2380 Object.keys(counts).forEach(function(k){map[k]=counts[k]/max;}); 2381 return map; 2382 } 2383 2384 /* ââ Lista hardcoded province italiane (mirror di GenerateProvincesForTenant.php). 2385 * Usata come FALLBACK quando l'API restituisce solo entries placeholder 2386 * tipo "Toscana Regionale" invece delle 10 province reali. external_id 2387 * (sigla) viene usato come `codice` perché è ciò che il backend Laravel 2388 * mappa a Province.external_id. */ 2389 var BK_IT_PROVINCES=[ 2390 {n:"Agrigento",s:"AG",r:"Sicilia"},{n:"Alessandria",s:"AL",r:"Piemonte"},{n:"Ancona",s:"AN",r:"Marche"}, 2391 {n:"Arezzo",s:"AR",r:"Toscana"},{n:"Ascoli Piceno",s:"AP",r:"Marche"},{n:"Asti",s:"AT",r:"Piemonte"}, 2392 {n:"Avellino",s:"AV",r:"Campania"},{n:"Bari",s:"BA",r:"Puglia"},{n:"Belluno",s:"BL",r:"Veneto"}, 2393 {n:"Benevento",s:"BN",r:"Campania"},{n:"Bergamo",s:"BG",r:"Lombardia"},{n:"Biella",s:"BI",r:"Piemonte"}, 2394 {n:"Bologna",s:"BO",r:"Emilia-Romagna"},{n:"Bolzano/Bozen",s:"BZ",r:"Trentino-Alto Adige/Südtirol"}, 2395 {n:"Brescia",s:"BS",r:"Lombardia"},{n:"Brindisi",s:"BR",r:"Puglia"},{n:"Cagliari",s:"CA",r:"Sardegna"}, 2396 {n:"Caltanissetta",s:"CL",r:"Sicilia"},{n:"Campobasso",s:"CB",r:"Molise"},{n:"Carbonia-Iglesias",s:"CI",r:"Sardegna"}, 2397 {n:"Caserta",s:"CE",r:"Campania"},{n:"Catania",s:"CT",r:"Sicilia"},{n:"Catanzaro",s:"CZ",r:"Calabria"}, 2398 {n:"Chieti",s:"CH",r:"Abruzzo"},{n:"Como",s:"CO",r:"Lombardia"},{n:"Cosenza",s:"CS",r:"Calabria"}, 2399 {n:"Cremona",s:"CR",r:"Lombardia"},{n:"Crotone",s:"KR",r:"Calabria"},{n:"Cuneo",s:"CN",r:"Piemonte"}, 2400 {n:"Enna",s:"EN",r:"Sicilia"},{n:"Fermo",s:"FM",r:"Marche"},{n:"Ferrara",s:"FE",r:"Emilia-Romagna"}, 2401 {n:"Firenze",s:"FI",r:"Toscana"},{n:"Foggia",s:"FG",r:"Puglia"},{n:"Forlì-Cesena",s:"FC",r:"Emilia-Romagna"}, 2402 {n:"Frosinone",s:"FR",r:"Lazio"},{n:"Genova",s:"GE",r:"Liguria"},{n:"Gorizia",s:"GO",r:"Friuli-Venezia Giulia"}, 2403 {n:"Grosseto",s:"GR",r:"Toscana"},{n:"Imperia",s:"IM",r:"Liguria"},{n:"Isernia",s:"IS",r:"Molise"}, 2404 {n:"L'Aquila",s:"AQ",r:"Abruzzo"},{n:"La Spezia",s:"SP",r:"Liguria"},{n:"Latina",s:"LT",r:"Lazio"}, 2405 {n:"Lecce",s:"LE",r:"Puglia"},{n:"Lecco",s:"LC",r:"Lombardia"},{n:"Livorno",s:"LI",r:"Toscana"}, 2406 {n:"Lodi",s:"LO",r:"Lombardia"},{n:"Lucca",s:"LU",r:"Toscana"},{n:"Macerata",s:"MC",r:"Marche"}, 2407 {n:"Mantova",s:"MN",r:"Lombardia"},{n:"Massa-Carrara",s:"MS",r:"Toscana"},{n:"Matera",s:"MT",r:"Basilicata"}, 2408 {n:"Medio Campidano",s:"VS",r:"Sardegna"},{n:"Messina",s:"ME",r:"Sicilia"},{n:"Milano",s:"MI",r:"Lombardia"}, 2409 {n:"Modena",s:"MO",r:"Emilia-Romagna"},{n:"Monza e della Brianza",s:"MB",r:"Lombardia"}, 2410 {n:"Napoli",s:"NA",r:"Campania"},{n:"Novara",s:"NO",r:"Piemonte"},{n:"Nuoro",s:"NU",r:"Sardegna"}, 2411 {n:"Ogliastra",s:"OG",r:"Sardegna"},{n:"Olbia-Tempio",s:"OT",r:"Sardegna"},{n:"Oristano",s:"OR",r:"Sardegna"}, 2412 {n:"Padova",s:"PD",r:"Veneto"},{n:"Palermo",s:"PA",r:"Sicilia"},{n:"Parma",s:"PR",r:"Emilia-Romagna"}, 2413 {n:"Pavia",s:"PV",r:"Lombardia"},{n:"Perugia",s:"PG",r:"Umbria"},{n:"Pesaro e Urbino",s:"PU",r:"Marche"}, 2414 {n:"Pescara",s:"PE",r:"Abruzzo"},{n:"Piacenza",s:"PC",r:"Emilia-Romagna"},{n:"Pisa",s:"PI",r:"Toscana"}, 2415 {n:"Pistoia",s:"PT",r:"Toscana"},{n:"Pordenone",s:"PN",r:"Friuli-Venezia Giulia"},{n:"Potenza",s:"PZ",r:"Basilicata"}, 2416 {n:"Prato",s:"PO",r:"Toscana"},{n:"Ragusa",s:"RG",r:"Sicilia"},{n:"Ravenna",s:"RA",r:"Emilia-Romagna"}, 2417 {n:"Reggio di Calabria",s:"RC",r:"Calabria"},{n:"Reggio nell'Emilia",s:"RE",r:"Emilia-Romagna"}, 2418 {n:"Rieti",s:"RI",r:"Lazio"},{n:"Rimini",s:"RN",r:"Emilia-Romagna"},{n:"Roma",s:"RM",r:"Lazio"}, 2419 {n:"Rovigo",s:"RO",r:"Veneto"},{n:"Salerno",s:"SA",r:"Campania"},{n:"Sassari",s:"SS",r:"Sardegna"}, 2420 {n:"Savona",s:"SV",r:"Liguria"},{n:"Siena",s:"SI",r:"Toscana"},{n:"Siracusa",s:"SR",r:"Sicilia"}, 2421 {n:"Sondrio",s:"SO",r:"Lombardia"},{n:"Taranto",s:"TA",r:"Puglia"},{n:"Teramo",s:"TE",r:"Abruzzo"}, 2422 {n:"Terni",s:"TR",r:"Umbria"},{n:"Torino",s:"TO",r:"Piemonte"},{n:"Trapani",s:"TP",r:"Sicilia"}, 2423 {n:"Trento",s:"TN",r:"Trentino-Alto Adige/Südtirol"},{n:"Treviso",s:"TV",r:"Veneto"}, 2424 {n:"Trieste",s:"TS",r:"Friuli-Venezia Giulia"},{n:"Udine",s:"UD",r:"Friuli-Venezia Giulia"}, 2425 {n:"Valle d'Aosta/Vallée d'Aoste",s:"AO",r:"Valle d'Aosta/Vallée d'Aoste"},{n:"Varese",s:"VA",r:"Lombardia"}, 2426 {n:"Venezia",s:"VE",r:"Veneto"},{n:"Verbano-Cusio-Ossola",s:"VB",r:"Piemonte"},{n:"Vercelli",s:"VC",r:"Piemonte"}, 2427 {n:"Verona",s:"VR",r:"Veneto"},{n:"Vibo Valentia",s:"VV",r:"Calabria"},{n:"Vicenza",s:"VI",r:"Veneto"}, 2428 {n:"Viterbo",s:"VT",r:"Lazio"} 2429 ]; 2430 2431 /** Restituisce true se la lista provincie è "vuota di senso", cioè: 2432 * - vuota 2433 * - contiene solo entries placeholder ("regionale", "regione X", nome 2434 * regione stesso, o nome regione con separator diverso es. "Abruzzo 2435 * / Molise" vs "Abruzzo-Molise") */ 2436 function bkIsPlaceholderProvinceList(provList,regionName){ 2437 if(!provList||!provList.length)return true; 2438 var rn=(regionName||"").toLowerCase().trim(); 2439 /* normalizza: rimuovi separator / - e collassa spazi â matcha 2440 * "Abruzzo / Molise" con "Abruzzo-Molise" / "Abruzzo Molise" */ 2441 var rnNorm=rn.replace(/[\/\-]/g," ").replace(/\s+/g," ").trim(); 2442 var allPlaceholder=true; 2443 for(var i=0;i<provList.length;i++){ 2444 var d=((provList[i]&&provList[i].descrizione)||"").toLowerCase().trim(); 2445 var dNorm=d.replace(/[\/\-]/g," ").replace(/\s+/g," ").trim(); 2446 var isPh=/regional[ei]?\b/.test(d) 2447 || (rn&&d===rn)
2448 || (rn&&d===("regione "+rn)) 2449 || (rn&&d.indexOf(rn)===0&&/regional/.test(d)) 2450 || (rnNorm&&dNorm===rnNorm); // "abruzzo-molise" === "abruzzo molise" 2451 if(!isPh){allPlaceholder=false;break;} 2452 } 2453 return allPlaceholder; 2454 } 2455 2456 /** Normalizza una stringa: lowercase + rimuove punteggiatura e accenti 2457 * e collassa whitespace. Permette match tolleranti tra "Emilia Romagna", 2458 * "Emilia-Romagna", "EMILIA ROMAGNA", ecc. */ 2459 function bkNormalizeRegion(s){ 2460 if(!s)return ""; 2461 return String(s).toLowerCase() 2462 .replace(/[\u00e0\u00e1\u00e2]/g,"a").replace(/[\u00e8\u00e9\u00ea]/g,"e") 2463 .replace(/[\u00ec\u00ed\u00ee]/g,"i").replace(/[\u00f2\u00f3\u00f4]/g,"o") 2464 .replace(/[\u00f9\u00fa\u00fb]/g,"u") 2465 .replace(/[\-\.\/']/g," ").replace(/\s+/g," ").trim(); 2466 } 2467 2468 /** Mapping di sub-regioni / connessioni regionali noti che NON hanno il nome 2469 * pieno della regione ma vi appartengono comunque. Esteso quando emergono 2470 * nuovi naming sui tenant. */ 2471 var BK_SUBREGION_MAP={ 2472 "romagna":"Emilia-Romagna", 2473 "emilia centrale":"Emilia-Romagna", 2474 "emilia":"Emilia-Romagna" 2475 }; 2476 2477 /** Estrae il nome regione da una descrizione placeholder. 2478 * "TOSCANA REGIONALE" â "Toscana" 2479 * "Lazio Regionale" â "Lazio" 2480 * "Emilia Romagna" / "EMILIA-ROMAGNA" â "Emilia-Romagna" 2481 * "Romagna" â "Emilia-Romagna" (sub-regione) 2482 * Restituisce "" se nessuna regione italiana riconosciuta. */ 2483 var BK_REGION_NAMES=null; 2484 function bkExtractRegionName(text){ 2485 if(!text)return ""; 2486 if(!BK_REGION_NAMES){ 2487 var seen={};BK_REGION_NAMES=[]; 2488 for(var i=0;i<BK_IT_PROVINCES.length;i++){ 2489 var r=BK_IT_PROVINCES[i].r;if(!seen[r]){seen[r]=true;BK_REGION_NAMES.push(r);} 2490 } 2491 } 2492 var t=bkNormalizeRegion(text); 2493 if(!t)return ""; 2494 /* Sub-region check (Romagna â Emilia-Romagna, ecc.) */ 2495 var subKeys=Object.keys(BK_SUBREGION_MAP); 2496 /* Più lungo prima per evitare match parziali ("emilia" vs "emilia centrale") */ 2497 subKeys.sort(function(a,b){return b.length-a.length;}); 2498 for(var sk=0;sk<subKeys.length;sk++){ 2499 if(t.indexOf(subKeys[sk])!==-1)return BK_SUBREGION_MAP[subKeys[sk]]; 2500 } 2501 /* Match diretto contro nomi regione, normalizzati. Lungo prima. */ 2502 var sorted=BK_REGION_NAMES.slice().sort(function(a,b){return b.length-a.length;}); 2503 for(var j=0;j<sorted.length;j++){ 2504 var keyN=bkNormalizeRegion(sorted[j]); 2505 var parts=keyN.split(" "); 2506 /* Prova match sull'intero, poi sui pezzi separati da / (es. "valle d aosta vallee d aoste"). */ 2507 if(t.indexOf(keyN)!==-1)return sorted[j]; 2508 /* Per regioni composite, prova anche solo la prima parte (Trentino, Friuli, Valle) */ 2509 if(parts.length>1&&t.indexOf(parts[0])!==-1&&parts[0].length>=4)return sorted[j]; 2510 } 2511 return ""; 2512 } 2513 2514 /** Determina la regione italiana a cui appartiene un'entry "provincia". 2515 * Usa BK_IT_PROVINCES (per nomi standard tipo Bologna, Bergamo) 2516 * + bkExtractRegionName (per sub-regioni e placeholder). 2517 * 2518 * Match TOLLERANTE per gestire varianti API: 2519 * - "Monza Brianza" â "Monza e della Brianza" 2520 * - "Reggio Calabria" â "Reggio di Calabria" 2521 * - "Bolzano" â "Bolzano/Bozen" 2522 * Logica: esatto, poi inclusione bidirezionale, poi prima parola distintiva. */ 2523 function bkRegionForProvinceEntry(entry){ 2524 if(!entry||!entry.descrizione)return ""; 2525 var d=bkNormalizeRegion(entry.descrizione); 2526 if(!d)return ""; 2527 /* 1) Match esatto */ 2528 for(var i=0;i<BK_IT_PROVINCES.length;i++){ 2529 if(bkNormalizeRegion(BK_IT_PROVINCES[i].n)===d)return BK_IT_PROVINCES[i].r; 2530 } 2531 /* 2) Match per parole intere (NON substring per evitare "Romagna" 2532 * che matcha "Roma"). Una entry matcha un province ufficiale se 2533 * l'insieme di parole "significative" (â¥4 char) coincide come set 2534 * o uno è sottoinsieme dell'altro. 2535 * - "Monza Brianza" â words=["monza","brianza"] 2536 * - "Monza e della Brianza" â significant words=["monza","brianza"] 2537 * â set uguale â match. 2538 * - "Romagna" â words=["romagna"] 2539 * - "Roma" â words=["roma"] â diversi â NO match. */ 2540 var dWords=d.split(" ").filter(function(w){return w.length>=4;}); 2541 if(dWords.length){ 2542 for(var k=0;k<BK_IT_PROVINCES.length;k++){ 2543 var pn=bkNormalizeRegion(BK_IT_PROVINCES[k].n); 2544 var pnWords=pn.split(" ").filter(function(w){return w.length>=4;}); 2545 if(!pnWords.length)continue; 2546 /* Tutti dWords â pnWords? */ 2547 var allIn=dWords.every(function(dw){return pnWords.indexOf(dw)!==-1;}); 2548 /* Oppure tutti pnWords â dWords? */ 2549 var revIn=pnWords.every(function(pw){return dWords.indexOf(pw)!==-1;}); 2550 if(allIn||revIn)return BK_IT_PROVINCES[k].r; 2551 } 2552 } 2553 /* 3) Fallback: estrai regione dal nome (sub-regioni e placeholder). */ 2554 return bkExtractRegionName(entry.descrizione); 2555 } 2556 2557 /** Filtra una lista di province per tenere solo quelle che geograficamente 2558 * appartengono alla regione data. Se nessuna entry matcha, ritorna la lista 2559 * originale (safety: meglio mostrare il picker pollutato che bloccare il flow). */ 2560 function bkFilterProvincesByRegion(provList,regionName){ 2561 if(!provList||!provList.length||!regionName)return provList||[]; 2562 var target=bkNormalizeRegion(regionName); 2563 if(!target)return provList; 2564 var filtered=provList.filter(function(p){ 2565 var prRegion=bkRegionForProvinceEntry(p); 2566 return prRegion&&bkNormalizeRegion(prRegion)===target; 2567 }); 2568 /* Se il filtro NON trova nulla, NON degradiamo l'esperienza: 2569 * potrebbe essere un tenant atipico â mostriamo la lista intera. */ 2570 return filtered.length>0?filtered:provList; 2571 } 2572 2573 /** Trova nella lista bkRegioni l'entry placeholder "X REGIONALE" che 2574 * corrisponde a una data regione geografica (es. detected via GPS). 2575 * Ritorna l'entry trovata o null. */ 2576 function bkFindRegionalEntry(regioniList,regionName){ 2577 if(!regioniList||!regioniList.length||!regionName)return null; 2578 var rn=String(regionName).toLowerCase(); 2579 for(var i=0;i<regioniList.length;i++){ 2580 var d=((regioniList[i]&®ioniList[i].descrizione)||"").toLowerCase(); 2581 if(d.indexOf(rn)!==-1)return regioniList[i]; 2582 } 2583 return null; 2584 } 2585 2586 /** Espande la lista placeholder con le province hardcoded della regi
2586one, 2587 * formattate come l'API: {descrizione, codice}. 2588 * Supporta nomi multi-regione (es. "Abruzzo / Molise") splittando su 2589 * "/" e facendo union delle province di entrambe â perché il Planner 2590 * abruzzomolise espone una sola "provincia" placeholder per entrambe 2591 * le regioni e i codici reali (CH/AQ/PE/TE/CB/IS) vanno bene tutti. */ 2592 function bkExpandProvinceFallback(regionName){ 2593 if(!regionName)return []; 2594 var rl=String(regionName).toLowerCase().trim(); 2595 var parts=rl.split("/").map(function(s){return s.trim();}).filter(Boolean); 2596 var seen={}; 2597 var out=[]; 2598 for(var i=0;i<parts.length;i++){ 2599 var pn=parts[i]; 2600 for(var j=0;j<BK_IT_PROVINCES.length;j++){ 2601 var pr=BK_IT_PROVINCES[j]; 2602 if(pr.r.toLowerCase()===pn&&!seen[pr.s]){ 2603 seen[pr.s]=true; 2604 out.push({descrizione:pr.n,codice:pr.s,sigla:pr.s,_fallback:true}); 2605 } 2606 } 2607 } 2608 return out; 2609 } 2610 2611 /* ââ Timezone â default region (smart pre-select, no permission needed) */ 2612 var BK_TZ_REGION_MAP={ 2613 "Europe/Rome":["Veneto","Lombardia","Toscana","Emilia-Romagna","Lazio","Piemonte","Campania","Sicilia"] 2614 }; 2615 function bkGuessRegionByTimezone(){ 2616 try{ 2617 var tz=Intl.DateTimeFormat().resolvedOptions().timeZone; 2618 return BK_TZ_REGION_MAP[tz]||null; 2619 }catch(_e){return null;} 2620 } 2621 2622 /* ââ configuration ââââââââââââââââââââââââââââââââââââââââââ */ 2623 function resolveOpts(userOpts) { 2624 var o = userOpts || {}; 2625 /* DEMOPROMO: il server (via /api/widget-inline) inietta 2626 window.__CAF_DEMOPROMO__ === true quando l'env ha DEMOPROMO=si. In quel 2627 caso il SOLO sistema di prenotazione gira su dati di esempio (vedi 2628 _bkDemoFetch) e il nome tenant viene forzato a __CAF_CHATBOT_NAME__ 2629 (NEXT_PUBLIC_CHATBOT_NAME, default "CAF CISL Veneto"), sovrascrivendo 2630 eventuale data-name dell'host. Flag spento â nessuna variazione. */ 2631 var demopromo = (typeof window !== "undefined" && window.__CAF_DEMOPROMO__ === true); 2632 var demoName = (typeof window !== "undefined" && window.__CAF_CHATBOT_NAME__) || "CAF CISL Veneto"; 2633 /* PIEMONTE: iniettato da /api/widget-inline quando l'env ha PIEMONTE=si. 2634 Quando true, allo step DATA e ORA del wizard prenotazione il calendario 2635 carica TUTTA la disponibilità PRIMA di mostrarsi (drena la paginazione 2636 "Carica altri orari" fino in fondo, vedi _bkDrainAllSpazi): tutti i mesi 2637 appaiono già completi e navigabili, senza il bottone "Carica altri orari". 2638 Flag spento â nessuna variazione. */ 2639 var piemonte = (typeof window !== "undefined" && window.__CAF_PIEMONTE__ === true); 2640 return { 2641 baseUrl: (o.baseUrl || getAttr("base-url") || "").replace(/\/$/, ""), 2642 color: o.color || getAttr("color") || "#059669", 2643 name: demopromo ? demoName : (o.name || getAttr("name") || "CAF CISL - Toscana"), 2644 logo: 2645 o.logo || 2646 getAttr("logo") || 2647 "https://indesignstudio.b-cdn.net/logoCAFCISL.png", 2648 chatLogo: 2649 o.chatLogo || 2650 getAttr("chat-logo") || 2651 "https://indesignstudio.b-cdn.net/logoChat.png", 2652 welcome: 2653 o.welcome || 2654 getAttr("welcome") || 2655 (demopromo 2656 ? "Ciao, sono l'assistente virtuale di " + demoName + ". Come posso esserti utile?" 2657 : "Ciao, sono l'assistente virtuale di CAF CISL - Toscana. Come posso esserti utile?"), 2658 /* DOMANDEFISSE: se il server (via /api/widget-inline) inietta 2659 window.__CAF_DOMANDEFISSE__ === false, le domande preimpostate 2660 sotto il welcome non vengono mai mostrate. Override forte: vale 2661 anche quando il consumer passa quickReplies o data-quick-replies. */ 2662 quickReplies: (typeof window !== "undefined" && window.__CAF_DOMANDEFISSE__ === false) 2663 ? [] 2664 : (o.quickReplies 2665 ? o.quickReplies 2666 : getAttr("quick-replies") 2667 ? getAttr("quick-replies").split("|").filter(Boolean) 2668 : [ 2669 "Che spese si possono detrarre?", 2670 "Vorrei ricevere più informazioni", 2671 "Qual è il centro CAF più vicino a me?", 2672 ]), 2673 zIndex: o.zIndex || getAttr("z-index") || "9999", 2674 position: o.position || getAttr("position") || "right", // "right" | "left" 2675 /* a11y #C63: data-lang sul tag script (o opts.lang) FORZA la locale del 2676 * widget, con precedenza su navigator.language (vedi bkDetectLang). */ 2677 lang: o.lang || getAttr("lang") || "", 2678 showBubble: o.showBubble !== undefined 2679 ? !!o.showBubble
2680 : getAttr("show-bubble") !== "false", 2681 voice: o.voice !== undefined 2682 ? !!o.voice 2683 : (getAttr("voice") || "").trim().toLowerCase() === "si", 2684 chiedomail: o.chiedomail !== undefined 2685 ? !!o.chiedomail 2686 : (getAttr("chiedomail") || "").trim().toLowerCase() === "si", 2687 privacy: o.privacy !== undefined 2688 ? !!o.privacy 2689 : (getAttr("privacy") || "").trim().toLowerCase() === "si" 2690 || (typeof window !== "undefined" && window.__CAF_PRIVACY__ === true), 2691 /* CONSENSO: gate di consenso privacy alla prima apertura della chat 2692 (checkbox + link all'informativa + bottone di approvazione). Attivo 2693 con opts.consenso, data-consenso="si" o window.__CAF_CONSENSO__ 2694 iniettato da /api/widget-inline quando l'env ha CONSENSO=si. 2695 Default OFF â nessuna variazione per i tenant esistenti. */ 2696 /* CONSENSO â default COMPLIANT-ON (gate consenso privacy + eta' >=14, 2697 L. 132/2025 art. 4 c.4). Attivo salvo opt-out esplicito: 2698 data-consenso="no", opts.consenso===false o __CAF_CONSENSO__===false. */ 2699 consenso: o.consenso !== undefined 2700 ? !!o.consenso 2701 : (getAttr("consenso") || "").trim().toLowerCase() === "no" 2702 ? false 2703 : !(typeof window !== "undefined" && window.__CAF_CONSENSO__ === false), 2704 /* AI DISCLOSURE (AI Act art. 50) â default COMPLIANT-ON: la natura di 2705 IA e' sempre dichiarata e NON e' rimovibile dall'integrazione host 2706 (nessun attributo data-*): l'unico opt-out, riservato a usi fuori 2707 ambito AI Act, e' l'env server AI_DISCLOSURE=no (iniettata dalla 2708 route come __CAF_AI_DISCLOSURE__===false). */ 2709 aiDisclosure: !(typeof window !== "undefined" && window.__CAF_AI_DISCLOSURE__ === false), 2710 /* URL dell'informativa privacy del sito ospite (opts / data-privacy-url 2711 / env NEXT_PUBLIC_PRIVACY_URL iniettata come __CAF_PRIVACY_URL__). 2712 Usato dal gate consenso chat e, gia' oggi, dai link privacy del 2713 wizard prenotazione che leggono opts.privacyUrl. */ 2714 privacyUrl: o.privacyUrl 2715 || getAttr("privacy-url") 2716 || (typeof window !== "undefined" && typeof window.__CAF_PRIVACY_URL__ === "string" ? window.__CAF_PRIVACY_URL__ : "") 2717 || "", 2718 ecommerce: o.ecommerce !== undefined 2719 ? !!o.ecommerce 2720 : (getAttr("ecommerce") || "").trim().toLowerCase() === "si", 2721 productsUrl: o.productsUrl || getAttr("products-url") || "", 2722 demopromo: demopromo, 2723 piemonte: piemonte, 2724 }; 2725 } 2726 2727 /* ââ table renderer helper âââââââââââââââââââââââââââââââââââ */ 2728 function renderTableBlock(rows, c) { 2729 var parsed = []; 2730 var sepIdx = -1; 2731 var maxCols = 0; 2732 for (var r = 0; r < rows.length; r++) { 2733 var row = rows[r]; 2734 if (row.charAt(0) === "|") row = row.substring(1); 2735 if (row.charAt(row.length - 1) === "|") row = row.substring(0, row.length - 1); 2736 var cells = row.split("|").map(function (s) { return s.trim(); }); 2737 parsed.push(cells); 2738 if (cells.length > maxCols) maxCols = cells.length; 2739 /* Riga separatrice: ogni cella è solo trattini (":" opzionali) e la 2740 * riga contiene almeno "--" o ":". Così una riga DATI di soli "-" 2741 * (es. "| - | - |") non viene scambiata per separatore e cancellata. */ 2742 if ( 2743 sepIdx < 0 && 2744 cells.every(function (cl) { return cl === "" || /^:?-+:?$/.test(cl); }) && 2745 /--|:/.test(row) && 2746 cells.join("") !== "" 2747 ) { 2748 sepIdx = r; 2749 } 2750 } 2751 var h = '<div style="overflow-x:auto;margin:8px 0;border-radius:10px;border:1px solid #e2e8f0">'; 2752 h += '<table style="width:100%;border-collapse:collapse;font-size:12.5px">'; 2753 var dataIdx = 0; 2754 for (var i = 0; i < parsed.length; i++) { 2755 if (i === sepIdx) continue; 2756 var isHdr = sepIdx >= 0 && i < sepIdx; 2757 h += "<tr>"; 2758 /* pad a maxCols: righe irregolari non producono tabelle sghembe */ 2759 for (var j = 0; j < maxCols; j++) { 2760 var cell = j < parsed[i].length ? parsed[i][j] : ""; 2761 var tag = isHdr ? "th" : "td"; 2762 var st = isHdr 2763 ? "background:" + c + "1a;color:#065f46;padding:7px 10px;text-align:left;font-weight:600;font-size:11.5px;border-bottom:2px solid " + c + "40" 2764 : "padding:7px 10px;border-bottom:1px solid #f1f5f9;color:#334155" + (dataIdx % 2 === 1 ? ";background:#f8fafc" : ""); 2765 h += "<" + tag + (isHdr ? ' scope="col"' : "") + ' style="' + st + '">' + cell + "</" + tag + ">"; 2766 } 2767 h += "</tr>"; 2768 if (!isHdr) dataIdx++; 2769 } 2770 h += "</table></div>"; 2771 return h; 2772 } 2773 2774 /* ââ markdown renderer ââââââââââââââââââââââââââââââââââââââââ */ 2775 function renderMarkdown(text, c) { 2776 c = c || "#059669"; 2777 var p = String(text == null ? "" : text) 2778 .replace(/\r\n?/g, "\n") 2779 .replace(/&/g, "&") 2780 .replace(/</g, "<") 2781 .replace(/>/g, ">"); 2782 2783 /* ââ STEP 0: Extract code blocks (``` ... ```) before any processing ââ */ 2784 var codeBlocks = []; 2785 p = p.replace(/```[\w+#-]*[^\S\n]*\n?([\s\S]*?)```/g, function (_, co
2785de) { 2786 var idx = codeBlocks.length; 2787 codeBlocks.push(code.replace(/\n$/, "")); 2788 return "\n%%CODEBLOCK_" + idx + "%%\n"; 2789 }); 2790 /* Fence rimasta aperta (streaming troncato o LLM che dimentica la 2791 * chiusura): tutto ciò che segue diventa comunque un code block, così 2792 * non restano mai backtick tripli grezzi visibili. */ 2793 p = p.replace(/```[\w+#-]*[^\S\n]*\n?([\s\S]*)$/, function (_, code) { 2794 var idx = codeBlocks.length; 2795 codeBlocks.push(code.replace(/\n$/, "")); 2796 return "\n%%CODEBLOCK_" + idx + "%%\n"; 2797 }); 2798 2799 /* ââ STEP 0a: estrazione a placeholder di TUTTO ciò che non deve 2800 * essere toccato da bold/italic/euristiche: inline code, link 2801 * markdown, URL bare, URL www., marker prodotto. Questo è critico 2802 * per una linkificazione affidabile al 100%: se gli URL venissero 2803 * processati DOPO bold/italic, "**Header:**https://..." produrrebbe 2804 * "</strong>https://..." e i regex fallirebbero silenziosamente. 2805 * I placeholder sono pass-through per tutti gli step successivi e 2806 * vengono ripristinati alla fine (STEP 5). */ 2807 var inlinePH = []; 2808 var pushPH = function (html) { 2809 var idx = inlinePH.length; 2810 inlinePH.push(html); 2811 return "%%CAFPH_" + idx + "%%"; 2812 }; 2813 var safeHref = function (url) { return url.replace(/"/g, """); }; 2814 /* Il testo è GIà HTML-escaped: per le label visibili niente secondo 2815 * escape (produrrebbe "&amp;" â "amp;" visibile). decodeEnt serve 2816 * solo per troncare la label senza tagliare a metà una entity. */ 2817 var decodeEnt = function (s) { return s.replace(/&/g, "&"); }; 2818 var escBase = function (s) { 2819 return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); 2820 }; 2821 /* Enfasi inline (usata anche dentro il testo dei link markdown). 2822 * L'ordine è: barrato â triplo â bold â italic. Il contenuto italic 2823 * esclude <> così non può attraversare un tag <strong> appena iniettato 2824 * dal bold (altrimenti "5*3=15***x**" produce <em>â¦<strong></em>). */ 2825 var inlineEmph = function (s) { 2826 s = s.replace(/~~([^~\n]+)~~/g, "<s>$1</s>"); 2827 s = s.replace(/\*\*\*([^\n*|]+)\*\*\*/g, '<strong class="caf-strong"><em>$1</em></strong>'); 2828 s = s.replace(/\*\*([^\n|]+?)\*\*/g, '<strong class="caf-strong">$1</strong>'); 2829 s = s.replace(/(?<!\*)\*([^*\n<>|]+)\*(?!\*)/g, "<em>$1</em>"); 2830 /* Safety net: sequenze di 2+ asterischi rimaste orfane (bold non 2831 * bilanciato dallo streaming/LLM) non devono MAI restare visibili. 2832 * Le rimuovo, tranne quando racchiuse tra cifre su entrambi i lati 2833 * ("5**3" = potenza, non enfasi). Poi ritento l'italic: coppie di 2834 * "*" diventate adiacenti ("*a**b*" â "*ab*") vengono recuperate. */ 2835 s = s.replace(/(?<!\d)\*{2,}|\*{2,}(?!\d)/g, ""); 2836 s = s.replace(/(?<!\*)\*([^*\n<>|]+)\*(?!\*)/g, "<em>$1</em>"); 2837 return s; 2838 }; 2839 2840 /* 0a.1) Inline code `...` PRIMA di tutto: il contenuto resta letterale 2841 * (niente bold/URL/telefono dentro il codice). */ 2842 p = p.replace(/`([^`\n]+)`/g, function (_, code) { 2843 return pushPH('<code class="caf-code-inline">' + code + "</code>"); 2844 }); 2845 2846 /* Whitelist of known official URLs used by the CAF CISL Veneto bot. 2847 * Sorted by length DESC so longest-prefix-match wins (prevents a shorter 2848 * URL from capturing when the captured text is actually a longer one). */ 2849 var KNOWN_URLS = [ 2850 "https://prenotazioni.cafcisl.it/env.php/prenotazione/provincia/VEN", 2851 "https://www.cafcislveneto.it/contatti/", 2852 "https://www.cislpadovarovigo.it/", 2853 "https://www.cislbellunotreviso.it/", 2854 "https://www.cislverona.it/", 2855 "https://www.cislvicenza.it/", 2856 "https://www.cislvenezia.it/" 2857 ]; 2858 2859 /* 0a.2) Markdown link syntax [text](url) â must run before bare URL 2860 * match. Le parentesi bilanciate nell'URL (es. wikipedia "..._(fisco)") 2861 * sono ammesse un livello. Il testo del link può contenere enfasi. */ 2862 p = p.replace( 2863 /\[([^\]\n]+)\]\((https?:\/\/(?:[^()\s]|\([^()\s]*\))+)\)/g, 2864 function (_, txt, url) { 2865 return pushPH( 2866 '<a href="' + safeHref(url) + '" target="_blank" rel="noopener noreferrer" style="color:#047857;text-decoration:underline;text-underline-offset:2px">' + inlineEmph(txt) + "</a>" 2867 ); 2868 } 2869 ); 2870 2871 /* 0a.3) Bare URLs anywhere in the text â whitelist + trim euristico. */ 2872 p = p.replace( 2873 /(https?:\/\/[^\s<"*`]+)/g, 2874 function (url) { 2875 var trailing = ""; 2876 /* a) Whitelist match: URL ufficiale â href bloccato su quello 2877 * esatto, il resto è testo. Immune agli errori dell'LLM. */ 2878 var matched = null; 2879 for (var i = 0; i < KNOWN_URLS.length; i++) { 2880 if (url.indexOf(KNOWN_URLS[i]) === 0) { matched = KNOWN_URLS[i]; break; } 2881 } 2882 if (matched) { 2883 trailing = url.slice(matched.length); 2884 url = matched; 2885 } else { 2886 /* b) trim punteggiatura/markdown in coda (inclusi ** e backtick) */ 2887 var punctMatch = url.match(/[.,;:!?)\]}>'"*`]+$/); 2888 if (punctMatch) { 2889 trailing = punctMatch[0] + trailing; 2890 url = url.slice(0, -punctMatch[0].length); 2891 } 2892 /* le ")" di parentesi APERTE nell'URL tornano dentro l'href 2893 * (es. wikipedia .../CAF_(fisco)) */ 2894 while ( 2895 trailing.charAt(0) === ")" && 2896 (url.split("(").length - 1) > (url.split(")").length - 1) 2897 ) { 2898 url += ")"; 2899 trailing = trailing.slice(1); 2900 } 2901 /* c) parola TitleCase incollata SUBITO dopo il TLD (senza slash): 2902 * "https://sito.itVaiSubito" â url sito.it + testo "VaiSubito". 2903 * I path TitleCase legittimi (/Prenotazioni) restano intatti. */ 2904 var tc = url.match(/^(https?:\/\/[a-z0-9.\-]+\.[a-z]{2,10})([A-ZÃ-Ã][a-zà -ü][\w\-Ã-Ãà -ü]*)$/); 2905 if (tc) { 2906 trailing = tc[2] + trailing; 2907 url = tc[1]; 2908 } 2909 } 2910 var phCut = url.indexOf("%%"); 2911 if (phCut >= 0) { trailing = url.slice(phCut) + trailing; url = url.slice(0, phCut); } 2912 var label = decodeEnt(url).replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, ""); 2913 if (label.length > 40) label = label.slice(0, 37) + "â¦"; 2914 return pushPH( 2915 '<a href="' + safeHref(url) + '" target="_blank" rel="noopener noreferrer" class="caf-url-badge"><span aria-hidden="true">ð</span>' + escBase(label) + "</a>" 2916 ) + trailing; 2917 } 2918 ); 2919 2920 /* 0a.4) URL "www." senza protocollo â stesso badge, href https:// */ 2921 p = p.replace(/(^|[\s(*_])(www\.[^\s<"*`]+)/gm, function (m, pre, url) { 2922 var trailing = ""; 2923 var punctMatch = url.match(/[.,;:!?)\]}>'"*`]+$/); 2924 if (punctMatch) { 2925 trailing = punctMatch[0]; 2926 url = url.slice(0, -punctMatch[0].length); 2927 } 2928 if (!/^www\.[a-z0-9\-]+(\.[a-z0-9\-]+)+/i.test(url)) return m; 2929 var phCutW = url.indexOf("%%"); 2930 if (phCutW >= 0) { trailing = url.slice(phCutW) + trailing; url = url.slice(0, phCutW); } 2931 var label = decodeEnt(url).replace(/^www\./, "").replace(/\/$/, ""); 2932 if (label.length > 40) label = label.slice(0, 37) + "â¦"; 2933 return pre + pushPH( 2934 '<a href="https://' + safeHref(url) + '" target="_blank" rel="noopener noreferrer" class="caf-url-badge"><span aria-hidden="true">ð</span>' + escBase(label) + "</a>" 2935 ) + trailing; 2936 }); 2937 2938 /* ââ STEP 0a-bis: Estrazione marker prodotti ecommerce 2939 * Sintassi accettata nel testo del bot: 2940 * [[PROD:ID]] â ID = ID prodotto (es. 2401) 2941 * [[PROD:CODE]] â ID = CODE prodotto (es. 3015000782) 2942 * [[PROD:CODE_xxx]] o [[PROD:ID_xxx]] â forma esplicita 2943 * I marker vengono sostituiti con un anchor HTML che, in modalitÃ
2944 * ecommerce, viene poi convertito in una card prodotto interattiva 2945 * dopo che innerHTML è stato applicato (vedi _renderEcommerceCards). */ 2946 p = p.replace(/\[\[PROD:([A-Z0-9_]+)\]\]/gi, function (_, key) { 2947 var cleaned = String(key).trim(); 2948 var dataKey; 2949 if (/^ID_/i.test(cleaned)) dataKey = "ID:" + cleaned.replace(/^ID_/i, ""); 2950 else if (/^CODE_/i.test(cleaned)) dataKey = "CODE:" + cleaned.replace(/^CODE_/i, ""); 2951 else if (/^\d{8,}$/.test(cleaned)) dataKey = "CODE:" + cleaned; 2952 else dataKey = "ID:" + cleaned; 2953 return pushPH( 2954 '<span class="caf-prod-anchor" data-prod-key="' + escapeHtml(dataKey) + '"></span>' 2955 ); 2956 }); 2957 2958 /* ââ STEP 0b: Re-insert newlines that the streaming API strips ââ 2959 * Ogni regola di "glue" è pipe-guarded: NON scatta se la riga corrente 2960 * contiene "|" (una cella di tabella non va mai spezzata). */ 2961 var lineHasPipe = function (s, off) { 2962 var ls = s.lastIndexOf("\n", off); 2963 var le = s.indexOf("\n", off); 2964 return s.substring(ls + 1, le === -1 ? s.length : le).indexOf("|") !== -1; 2965 }; 2966 // Normalise arrow-dashes (--->, ---->) to plain --- so HR detection 2967 // works. NB: la freccia "-->" (2 trattini) è testo legittimo e resta. 2968 p = p.replace(/-{3,}>/g, "---"); 2969 // Table: split concatenated rows (streaming joins "| ⦠|| ⦠|" into one 2970 // line). Solo su righe con abbastanza pipe da contenere DUE righe 2971 // (>= 6): una cella vuota "| a || b |" non viene spezzata in due righe. 2972 p = p.split("\n").map(function (line) { 2973 if (line.indexOf("||") === -1) return line; 2974 var pipes = line.match(/\|/g).length; 2975 return pipes >= 6 ? line.replace(/\|\|/g, "|\n|") : line; 2976 }).join("\n"); 2977 // Table: split first row from preceding non-table text (e.g. "text:| Header | Value |") 2978 p = p.replace(/^([^|\n]+?)(\|.+\|)\s*$/gm, "$1\n$2"); 2979 // Table: split trailing non-table content after last | in a table row 2980 p = p.replace(/^(\|.+\|)([^|\n]+)$/gm, "$1\n$2"); 2981 // Heading glue: mai spezzare DENTRO una sequenza di # (prima 2982 // "## Titolo" a inizio riga diventava "#\n\n# Titolo": il primo # 2983 // faceva da prefisso [^\n] e il break cadeva in mezzo ai cancelletti). 2984 p = p.replace(/([^\n#])(#{2,3}\s)/g, function (m, pre, hd, off, s) { 2985 return lineHasPipe(s, off) ? m : pre + "\n\n" + hd; 2986 }); 2987 // Il singolo # scatta solo dopo punteggiatura ("C# e F#" resta testo). 2988 p = p.replace(/([.!?;:)\]])[ \t]*(#\s)/g, function (m, pre, hd, off, s) { 2989 return lineHasPipe(s, off) ? m : pre + "\n\n" + hd; 2990 }); 2991 p = p.replace(/([.!?;:)\]])\s*-\s(\*\*|[A-ZÃ-Ã])/g, function (m, pre, it, off, s) { 2992 return lineHasPipe(s, off) ? m : pre + "\n- " + it; 2993 }); 2994 // Handle "- emoji" list items (e.g. "necessari:- ð" or "determinato)- ð") 2995 p = p.replace(/([.!?;:)\]\w])\s*-\s(\p{Extended_Pictographic})/gu, function (m, pre, it, off, s) { 2996 return lineHasPipe(s, off) ? m : pre + "\n- " + it; 2997 }); 2998 // Handle "emoji- emoji" (e.g. list item after emoji) 2999 p = p.replace(/(\p{Extended_Pictographic})\s*-\s(\p{Extended_Pictographic}|\*\*|[A-ZÃ-Ãa-z])/gu, function (m, pre, it, off, s) { 3000 return lineHasPipe(s, off) ? m : pre + "\n- " + it; 3001 }); 3002 p = p.replace(/([^\n\s])(- \*\*)/g, function (m, pre, it, off, s) { 3003 return lineHasPipe(s, off) ? m : pre + "\n" + it; 3004 }); 3005 // Glue "parola- Maiuscola": trattino attaccato alla parola precedente e 3006 // seguito da spazio + Maiuscola/bold/emoji = item di lista incollato 3007 // (es. "Documenti- CIE- Tessera"). La classe include "*" così anche 3008 // "**grassetto**- Voce" (dopo la chiusura del bold) spezza il bullet. 3009 p = p.replace(/([\p{L}\d*])- (?=\*\*|[A-ZÃ-Ã]|\p{Extended_Pictographic})/gu, function (m, pre, off, s) { 3010 return lineHasPipe(s, off) ? m : pre + "\n- "; 3011 }); 3012 // Liste numerate incollate: "testo. 1. Vai" â newline prima di "1.". 3013 // (?<!\d) blocca gli orari ("ore 9.30. Vieni": cifra prima del punto); 3014 // i lookbehind su n./nr./art./num. bloccano "legge n. 104. Per". 3015 p = p.replace(/(?<!\d)(?<!\b[Nn])(?<!\b[Nn]r)(?<![Aa]rt)(?<![Nn]um)([.!?;:)\]])\s*(\d{1,3}[.)]\s)(?=[A-ZÃ-Ã*])/g, function (m, pre, it, off, s) { 3016 return lineHasPipe(s, off) ? m : pre + "\n" + it; 3017 }); 3018 p = p.replace(/([^\n])(â¢\s)/g, function (m, pre, it, off, s) { 3019 return lineHasPipe(s, off) ? m : pre + "\n" + it; 3020 }); 3021 p = p.replace(/(#{2,3}\s[^#\n]{3,}?[.!?)\]])([A-ZÃ-Ã*\-â¢\d])/g, function (m, pre, it, off, s) { 3022 return lineHasPipe(s, off) ? m : pre + "\n\n" + it; 3023 }); 3024 // Heading incollato al corpo SENZA punteggiatura né spazio 3025 // ("## ScadenzeLa prima rataâ¦"): in italiano una minuscola seguita SUBITO 3026 // da Maiuscola+minuscola (camelCase) è sempre un confine di riga perso 3027 // dallo streaming. Si separa il titolo (almeno 3 char) dal corpo al primo 3028 // di questi confini. Gli acronimi ("IMU", "ISEE") non hanno il pattern 3029 // minuscolaâMaiuscolaâminuscola e restano intatti. 3030 p = p.replace(/(#{1,6}\s+[^\n]{2,}?[a-zà -ÿ])([A-ZÃ-Ã][a-zà -ÿ]+[^\n]*)$/gm, function (m, head, body, off, s) { 3031 return lineHasPipe(s, off) ? m : head + "\n" + body; 3032 }); 3033 // Emoji SUBITO dopo punteggiatura di fine frase (senza spazio) = testo 3034 // incollato dallo streaming â nuovo paragrafo. Con lo spazio in mezzo 3035 // ("Perfetto! ð Andiamo") la frase resta intera. 3036 p = p.replace(/([.!?])(\p{Extended_Pictographic})/gu, function (m, pre, emo, off, s) { 3037 return lineHasPipe(s, off) ? m : pre + "\n\n" + emo; 3038 }); 3039 // HR: add newline before ---, but skip --- inside table separator rows 3040 p = p.replace(/([^\n])---/g, function (m, pre, off, s) { 3041 var ls = s.lastIndexOf("\n", off); 3042 if (s.substring(ls + 1, off + 1).indexOf("|") >= 0) return m; 3043 return pre + "\n---"; 3044 }); 3045 // HR: split content after --- onto new line, but skip table separators 3046 p = p.replace(/---[^\S\n]*([^\s-])/gu, function (m, after, off, s) { 3047 var ls = s.lastIndexOf("\n", off); 3048 if (s.substring(ls + 1, off).indexOf("|") >= 0) return m; 3049 return "---\n" + after; 3050 }); 3051 // Bullet markdown "* item" â normalizzato a "- item" PRIMA dell'enfasi, 3052 // così l'asterisco del bullet non viene mangiato da bold/italic. 3053 p = p.replace(/(^|\n)\*[ \t]+(?=\S)/g, "$1- "); 3054 3055 /* ââ STEP 1: Enfasi inline ââ 3056 * ~~barrato~~, __bold__, _corsivo_ (con confini di parola: snake_case 3057 * resta intatto), poi ***`, **, * via inlineEmph. Il bold è vincolato
3058 * alla singola riga: un "**" orfano non trascina in grassetto tutto 3059 * il resto del messaggio. */ 3060 p = p.replace(/(^|[\s(])__([^_\n]+)__(?=$|[\s).,:;!?])/gm, '$1<strong class="caf-strong">$2</strong>'); 3061 p = p.replace(/(^|[\s(])_([^_\n]+)_(?=$|[\s).,:;!?])/gm, "$1<em>$2</em>"); 3062 p = inlineEmph(p); 3063 3064 /* ââ STEP 2: Split into lines and process block elements ââ */ 3065 var SPACER = '<div style="height:8px"></div>'; 3066 var lines = p.split("\n"); 3067 var html = []; 3068 var inList = false; 3069 var inBlockquote = false; 3070 var inTable = false; 3071 var tableRows = []; 3072 3073 for (var i = 0; i < lines.length; i++) { 3074 var line = lines[i]; 3075 var trimmed = line.trim(); 3076 3077 if (!trimmed) { 3078 if (inTable) { html.push(renderTableBlock(tableRows, c)); tableRows = []; inTable = false; } 3079 if (inList) { html.push("</div>"); inList = false; } 3080 if (inBlockquote) { html.push("</div>"); inBlockquote = false; } 3081 /* righe vuote consecutive = UN solo spacer (niente buchi giganti) */ 3082 if (html[html.length - 1] !== SPACER) html.push(SPACER); 3083 continue; 3084 } 3085 3086 /* Marker di lista orfano su riga a sé ("* ", "- ", "â¢" senza testo): 3087 * bullet vuoto lasciato dallo streaming/LLM. Non mostrare il carattere 3088 * grezzo: la riga si ignora del tutto. */ 3089 if (/^[-*â¢]$/.test(trimmed)) continue; 3090 3091 // Table row (lines starting and ending with |) 3092 if (/^\|.+\|$/.test(trimmed)) { 3093 if (inList) { html.push("</div>"); inList = false; } 3094 if (inBlockquote) { html.push("</div>"); inBlockquote = false; } 3095 tableRows.push(trimmed); 3096 inTable = true; 3097 continue; 3098 } 3099 if (inTable) { html.push(renderTableBlock(tableRows, c)); tableRows = []; inTable = false; } 3100 3101 // Code block placeholder (guardia: indice fuori range = testo utente) 3102 var cbMatch = trimmed.match(/^%%CODEBLOCK_(\d+)%%$/); 3103 if (cbMatch && parseInt(cbMatch[1], 10) < codeBlocks.length) { 3104 if (inList) { html.push("</div>"); inList = false; } 3105 if (inBlockquote) { html.push("</div>"); inBlockquote = false; } 3106 html.push('<pre class="caf-code-block"><code>' + codeBlocks[parseInt(cbMatch[1], 10)] + "</code></pre>"); 3107 continue; 3108 } 3109 3110 // Blockquote (">", anche annidata ">>": appiattita su un livello) 3111 if (/^>/.test(trimmed)) { 3112 if (inList) { html.push("</div>"); inList = false; } 3113 var bqMatch = trimmed.match(/^(?:>\s*)+(.*)/); 3114 if (!inBlockquote) { html.push('<div class="caf-blockquote">'); inBlockquote = true; } 3115 html.push("<div>" + (bqMatch[1] || "") + "</div>"); 3116 continue; 3117 } 3118 if (inBlockquote) { html.push("</div>"); inBlockquote = false; } 3119 3120 // Heading #..###### (con spazio) oppure ##..###### attaccato al testo 3121 // ("##Titolo"): livelli 1 / 2 / 3+ con gli stili originali. 3122 var hMatch = trimmed.match(/^(#{1,6})\s+(.+)/) || trimmed.match(/^(#{2,6})([^\s#].*)/); 3123 if (hMatch) { 3124 if (inList) { html.push("</div>"); inList = false; } 3125 var lvl = hMatch[1].length; 3126 var htxt = hMatch[2].replace(/^\s+/, ""); 3127 if (lvl === 1) { 3128 html.push('<div role="heading" aria-level="3" style="display:flex;align-items:center;gap:7px;font-weight:700;font-size:1.15em;margin:12px 0 6px;color:#0f172a"><span style="display:inline-block;width:4px;min-height:18px;border-radius:999px;background:' + c + ';flex-shrink:0;align-self:stretch"></span><span style="flex:1;min-width:0">' + htxt + "</span></div>"); 3129 } else if (lvl === 2) { 3130 html.push('<div role="heading" aria-level="4" style="display:flex;align-items:center;gap:7px;font-weight:700;font-size:1.05em;margin:10px 0 5px;color:#1e293b"><span style="display:inline-block;width:3px;min-height:16px;border-radius:999px;background:' + c + ';flex-shrink:0;align-self:stretch"></span><span style="flex:1;min-width:0">' + htxt + "</span></div>"); 3131 } else { 3132 html.push('<div role="heading" aria-level="5" style="font-weight:700;font-size:0.95em;margin:10px 0 4px;color:#334155">' + htxt + "</div>"); 3133 } 3134 continue; 3135 } 3136 3137 // Horizontal rule 3138 if (/^---+$/.test(trimmed)) { 3139 if (inList) { html.push("</div>"); inList = false; } 3140 html.push('<hr style="border:none;border-top:1px solid #e2e8f0;margin:10px 0">'); 3141 continue; 3142 } 3143 3144 // Unordered list item 3145 if (/^[-â¢]\s+(.+)/.test(trimmed)) { 3146 if (!inList) { html.push('<div role="list" style="margin:6px 0;display:flex;flex-direction:column;gap:3px">'); inList = true; } 3147 var liMatch = trimmed.match(/^[-â¢]\s+(.+)/); 3148 html.push('<div role="listitem" style="display:flex;align-items:flex-start;gap:6px;animation:caf-li-in .25s ease-out both"><span aria-hidden="true" style="color:' + c + ';font-weight:700;font-size:10px;flex-shrink:0;margin-top:4px">
3148â¸</span><span style="flex:1;min-width:0">' + liMatch[1] + "</span></div>"); 3149 continue; 3150 } 3151 3152 // Numbered list item ("1." o "1)"; max 3 cifre: "2024. Testo" resta frase) 3153 if (/^(\d{1,3})[.)]\s+(.+)/.test(trimmed)) { 3154 if (!inList) { html.push('<div role="list" style="margin:6px 0;display:flex;flex-direction:column;gap:3px">'); inList = true; } 3155 var nlMatch = trimmed.match(/^(\d{1,3})[.)]\s+(.+)/); 3156 html.push('<div role="listitem" style="display:flex;align-items:flex-start;gap:6px;animation:caf-li-in .25s ease-out both"><span style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:50%;background:' + c + '1a;color:#065f46;font-weight:700;font-size:10px;flex-shrink:0;margin-top:2px">' + nlMatch[1] + '</span><span style="flex:1;min-width:0">' + nlMatch[2] + "</span></div>"); 3157 continue; 3158 } 3159 3160 // Regular paragraph 3161 if (inList) { html.push("</div>"); inList = false; } 3162 html.push('<div style="margin:4px 0">' + trimmed + "</div>"); 3163 } 3164 if (inTable) html.push(renderTableBlock(tableRows, c)); 3165 if (inList) html.push("</div>"); 3166 if (inBlockquote) html.push("</div>"); 3167 3168 var result = html.join(""); 3169 3170 /* ââ STEP 5: Restore placeholders ââ 3171 * Guardie: un indice fuori range è testo scritto dall'utente 3172 * ("%%CAFPH_9%%" letterale) e resta com'è, mai "undefined". */ 3173 result = result.replace(/%%CODEBLOCK_(\d+)%%/g, function (m, idx) { 3174 var k = parseInt(idx, 10); 3175 return k < codeBlocks.length 3176 ? '<code class="caf-code-inline">' + codeBlocks[k] + "</code>" 3177 : m; 3178 }); 3179 result = result.replace(/%%CAFPH_(\d+)%%/g, function (m, idx) { 3180 var k = parseInt(idx, 10); 3181 return k < inlinePH.length ? inlinePH[k] : m; 3182 }); 3183 3184 /* ââ STEP 6: Phone numbers ââ 3185 * Entrambe le pass saltano l'INTERNO dei tag (l'alternanza <[^>]*> 3186 * consuma i tag): niente <span> iniettati dentro href/style con 3187 * numeri tipo "https://sito.it/doc-123-4567". */ 3188 // Pass A: "tel. XXXXX" / "telefono: XXXXX" â badge ð con numero nowrap. 3189 result = result.replace( 3190 /<[^>]*>|\btel(?:efono)?\.?\s*:?\s*(\+?[\d][\d\s\.\-]{5,16}[\d])/gi, 3191 function (m, num) { 3192 if (num === undefined) return m; 3193 return '<span class="caf-phone-badge">ð <span class="caf-phone-lbl">Il telefono è</span> <span style="white-space:nowrap">' + num.trim() + "</span></span>"; 3194 } 3195 ); 3196 // Pass B: gruppi di cifre con spazi ("0583 21 64 84") â nowrap. 3197 result = result.replace( 3198 /<[^>]*>|\b\d{3,5}(?:[ \-]\d{2,6}){1,5}\b/g, 3199 function (m) { 3200 if (m.charAt(0) === "<") return m; 3201 return '<span style="white-space:nowrap">' + m + "</span>"; 3202 } 3203 ); 3204 3205 return result; 3206 } 3207 3208 /* ââ CSS âââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 3209 /* ââ a11y: derive WCAG-safe foreground tokens from the tenant color ââ */ 3210 function cafHexNorm(h){h=String(h||"").trim().replace(/^#/,"");if(h.length===3)h=h[0]+h[0]+h[1]+h[1]+h[2]+h[2];return /^[0-9a-fA-F]{6}$/.test(h)?h.toLowerCase():"1e293b";} 3211 function cafHexLum(h){h=cafHexNorm(h);var r=parseInt(h.slice(0,2),16)/255,g=parseInt(h.slice(2,4),16)/255,b=parseInt(h.slice(4,6),16)/255;function f(x){return x<=0.03928?x/12.92:Math.pow((x+0.055)/1.055,2.4);}return 0.2126*f(r)+0.7152*f(g)+0.0722*f(b);} 3212 function cafContrast(a,b){var L1=cafHexLum(a),L2=cafHexLum(b),hi=Math.max(L1,L2),lo=Math.min(L1,L2);return (hi+0.05)/(lo+0.05);} 3213 /* Colore di testo/icone SUL brand (--caf-on-c). Preferisce il BIANCO sui 3214 * brand saturi (verde/blu/rosso: e' il look storico e voluto del widget, 3215 * ed e' AA per icone e testo grande â la soglia 3.0 e' il minimo WCAG per 3216 * componenti non testuali/testo grande, cioe' cio' che sta su questi 3217 * sfondi: send/close/calendario/CTA/bolla utente). Passa allo scuro SOLO 3218 * quando il bianco sarebbe illeggibile (tinte chiare: giallo, arancio, 3219 * verde pallido). Prima era >=4.5 e su #059669 (bianco 3.77) sceglieva 3220 * nero -> testo/icone neri sul verde, illeggibili di fatto. */ 3221 function cafReadableOn(hex){if(cafContrast("#ffffff",hex)>=3.0)return "#ffffff";if(cafContrast("#0f172a",hex)>=4.5)return "#0f172a";return cafContrast("#000000",hex)>=cafContrast("#ffffff",hex)?"#000000":"#ffffff";} 3222 function cafDarken(hex,k){var h=cafHexNorm(hex);function h2(n){n=Math.max(0,Math.min(255,Math.round(n)));var s=n.toString(16);return s.length<2?"0"+s:s;}return "#"+h2(parseInt(h.slice(0,2),16)*k)+h2(parseInt(h.slice(2,4),16)*k)+h2(parseInt(h.slice(4,6),16)*k);} 3223 /* a11y #C41: target 4.9 (non 4.5 esatto) così ink regge >=4.5 anche sulle 3224 * tinte brand usate come sfondo (c+"14"/c+"0f"/c+"0c" su bianco, #f1f5f9): 3225 * verificato sui 6 brand critici (worst case #e30613: 4.62 su c14). */ 3226 function cafInk(hex){var ink="#"+cafHexNorm(hex),k=1,guard=0;while(cafContrast(ink,"#ffffff")<4.9&&guard<40){k-=0.05;if(k<=0.05){ink=cafDarken(hex,0.05);break;}ink=cafDarken(hex,k);guard++;}return cafContrast(ink,"#ffffff")<4.9?"#0f172a":ink;} 3227 /* a11y: mix del brand verso il bianco (t=0 brand puro, t=1 bianco) */ 3228 function cafLightenTo(hex,t){var h=cafHexNorm(hex);function ch(i){return parseInt(h.slice(i,i+2),16);}function h2(n){n=Math.max(0,Math.min(255,Math.round(n)));var s=n.toString(16);return s.length<2?"0"+s:s;}return "#"+h2(ch(0)+(255-ch(0))*t)+h2(ch(2)+(255-ch(2))*t)+h2(ch(4)+(255-ch(4))*t);} 3229 /* a11y dark mode #C5: variante del brand leggibile sui fondi scuri del 3230 * wizard. Calibrata sul fondo dark PIU' CHIARO usato (#1e293b) con margine 3231 * (>=4.6): garantisce >=4.5 anche su #0f172a e sui riquadri 3232 * rgba(30,41,59,.78). Speculare a cafInk (che lavora sul bianco). */ 3233 function cafInkDark(hex){var t=0,cand="#"+cafHexNorm(hex);while(t<=1){cand=cafLightenTo(hex,t);if(cafContrast(cand,"#1e293b")>
3233=4.6)return cand;t+=0.05;}return "#f1f5f9";} 3234 /* a11y #C66: estremo chiaro dei gradienti CTA. Parte dall'equivalente 3235 * SOLIDO del vecchio c+"cc" composto su bianco (mix 20%) e riduce lo 3236 * schiarimento finche' il testo onC non regge >=4.6 anche sull'estremo 3237 * piu' chiaro (worst case verificato #e30613 -> #e51a26, 4.66). Colore 3238 * pieno = niente dipendenza dal backdrop (il vecchio alpha "cc" scendeva 3239 * a 3.48 composto sui fondi scuri). */ 3240 function cafGradEnd(hex,onC){var t=0.2,cand;while(t>0){cand=cafLightenTo(hex,t);if(cafContrast(onC,cand)>=4.6)return cand;t-=0.02;}return "#"+cafHexNorm(hex);} 3241 function buildCSS(opts) { 3242 var c = opts.color; 3243 var onC = cafReadableOn(c); 3244 /* EAA/WCAG 1.4.3: quando testo e icone sul brand sono BIANCHI ma il bianco 3245 * non raggiunge 4.5:1 sul brand (es. #059669 = 3.77), scurisci il brand 3246 * usato come RIEMPIMENTO quel minimo che porta il bianco a >=4.5 â cosi' 3247 * anche il testo PICCOLO (bolle utente 13.5px, bottoni 11px) e' conforme, 3248 * non solo le icone. Solo se on-c e' bianco: sui colori chiari (on-c 3249 * scuro) il brand resta identico. Scurimento ~10%, impercettibile. */ 3250 if(onC==="#ffffff"&&cafContrast("#ffffff",c)<4.5){ 3251 for(var _kAA=0.99;_kAA>=0.5;_kAA-=0.01){var _dAA=cafDarken(c,_kAA);if(cafContrast("#ffffff",_dAA)>=4.5){c=_dAA;break;}} 3252 } 3253 var cInk = cafInk(c); 3254 var cInkDark = cafInkDark(c); 3255 var cGradEnd = cafGradEnd(c, onC); 3256 return ( 3257 /* reset */ 3258 ":host{all:initial;font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;font-size:14px;color:#1e293b;line-height:1.5;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;--caf-c:" + c + ";--caf-on-c:" + onC + ";--caf-c-ink:" + cInk + ";--caf-c-ink-dark:" + cInkDark + ";}" + 3259 "*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}" + 3260 3261 /* ââ keyframes ââââââââââââââââââââââââââââââââââââ */ 3262 "@keyframes caf-pop-in{0%{opacity:0;transform:translateY(14px) scale(.90)}55%{opacity:1;transform:translateY(-3px) scale(1.015)}100%{opacity:1;transform:translateY(0) scale(1)}}" + 3263 "@keyframes caf-logo-breathe{0%,100%{transform:translateY(0) scale(1);filter:brightness(1)}50%{transform:translateY(-3px) scale(1.07);filter:brightness(1.1)}}" + 3264 "@keyframes caf-pulse-ring{0%{transform:scale(1);opacity:.4}70%,100%{transform:scale(1.4);opacity:0}
3264}" + 3265 "@keyframes caf-pulse-ring-2{0%{transform:scale(1);opacity:.25}70%,100%{transform:scale(1.6);opacity:0}}" + 3266 "@keyframes caf-toggle-entrance{0%{opacity:0;transform:scale(.4) translateY(24px)}50%{opacity:1;transform:scale(1.1) translateY(-5px)}100%{opacity:1;transform:scale(1) translateY(0)}}" + 3267 "@keyframes caf-bubble-slide{0%{opacity:0;transform:translateX(20px) scale(.92)}100%{opacity:1;transform:translateX(0) scale(1)}}" + 3268 "@keyframes caf-wave{0%{transform:rotate(0deg)}20%{transform:rotate(14deg)}40%{transform:rotate(-8deg)}60%{transform:rotate(12deg)}80%{transform:rotate(-4deg)}100%{transform:rotate(0deg)}}" + 3269 "@keyframes caf-shimmer{0%{transform:translateX(-80%);opacity:.25}100%{transform:translateX(180%);opacity:0}}" + 3270 "@keyframes caf-caret{0%{opacity:.12}50%{opacity:.85}100%{opacity:.12}}" + 3271 "@keyframes caf-dots{0%,80%,100%{transform:scale(0)}40%{transform:scale(1)}}" + 3272 "@keyframes caf-online-pulse{0%,100%{box-shadow:0 0 0 0 " + c + "55}50%{box-shadow:0 0 0 4px " + c + "00}}" + 3273 "@keyframes caf-stagger{0%{opacity:0;transform:translateY(8px)}100%{opacity:1;transform:translateY(0)}}" + 3274 "@keyframes caf-highlight{0%{background:" + c + "4d}100%{background:" + c + "1a}}" + 3275 "@keyframes caf-badge-in{0%{opacity:0;transform:scale(.88) translateY(3px)}70%{opacity:1;transform:scale(1.03) translateY(-1px)}100%{opacity:1;transform:scale(1) translateY(0)}}" + 3276 "@keyframes caf-li-in{0%{opacity:0;transform:translateX(-8px)}100%{opacity:1;transform:translateX(0)}}" + 3277 3278 /* ââ toggle button ââââââââââââââââââââââââââââââââ */ 3279 ".caf-toggle{position:fixed;bottom:24px;z-index:" + opts.zIndex + ";width:64px;height:64px;border-radius:50%;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;background:" + c + ";color:var(--caf-on-c);" + 3280 "box-shadow:0 6px 24px -2px " + c + "66,0 2px 8px -2px rgba(0,0,0,.12);" + 3281 "transition:transform .3s cubic-bezier(.34,1.56,.64,1),box-shadow .3s ease;" + 3282 "animation:caf-toggle-entrance .7s cubic-bezier(.34,1.56,.64,1) both}" + 3283 ".caf-toggle:hover{transform:scale(1.06);box-shadow:0 10px 36px -2px " + c + "88,0 4px 12px -4px rgba(0,0,0,.15)}" + 3284 ".caf-toggle:active{transform:scale(.9)}" + 3285 ".caf-toggle img{width:30px;height:30px;object-fit:contain;animation:caf-logo-breathe 3.5s ease-in-out infinite;filter:drop-shadow(0 1px 3px rgba(0,0,0,.2))}" + 3286 ".caf-toggle .caf-close-icon{display:none;font-size:26px;font-weight:300;letter-spacing:-1px;transition:transform .5s cubic-bezier(.34,1.56,.64,1)}" + 3287 ".caf-toggle.open img{display:none}" + 3288 ".caf-toggle.open .caf-close-icon{display:block}" + 3289 ".caf-toggle .caf-pulse{position:absolute;inset:0;border-radius:50%;background:" + c + ";animation:caf-pulse-ring 2.6s cubic-bezier(.4,0,.6,1) infinite;pointer-events:none}" + 3290 ".caf-toggle .caf-pulse-2{position:absolute;inset:0;border-radius:50%;background:" + c + ";animation:caf-pulse-ring-2 2.6s cubic-bezier(.4,0,.6,1) .8s infinite;pointer-events:none}" + 3291 ".caf-toggle .caf-glow{position:absolute;inset:-8px;border-radius:50%;background:" + c + ";opacity:.45;filter:blur(20px);pointer-events:none;z-index:-1;transition:opacity .5s}" + 3292 ".caf-toggle.open .caf-pulse,.caf-toggle.open .caf-pulse-2{display:none}" + 3293 ".caf-toggle.open .caf-glow{opacity:0}" + 3294 /* booking enabled: pulse + glow più evidenti per attirare attenzione */ 3295 ".caf-toggle.caf-bk-active .caf-glow{opacity:.75;filter:blur(28px)}" + 3296 ".caf-toggle.caf-bk-active .caf-pulse{animation-duration:1.8s}" + 3297 ".caf-toggle.caf-bk-active .caf-pulse-2{animation-duration:1.8s;animation-delay:.6s}" + 3298 ".caf-toggle.caf-bk-active.open .caf-glow{opacity:0}" + 3299 ".caf-pos-right{right:24px}" + 3300 ".caf-pos-left{left:24px}" + 3301 3302 /* ââ bubble preview âââââââââââââââââââââââââââââââ */ 3303 ".caf-bubble{position:fixed;bottom:32px;z-index:" + opts.zIndex + ";display:flex;align-items:center;gap:10px;background:#fff;border-radius:20px;padding:12px 20px;font-size:13.5px;font-weight:500;color:#334155;box-shadow:0 6px 24px -6px rgba(0,0,0,.1),0 1px 3px rgba(0,0,0,.06);
3303cursor:pointer;transition:transform .3s cubic-bezier(.34,1.56,.64,1),box-shadow .3s ease;animation:caf-bubble-slide .5s cubic-bezier(.34,1.56,.64,1) .4s both}" + 3304 ".caf-bubble:hover{transform:translateY(-3px);box-shadow:0 10px 36px -6px rgba(0,0,0,.14),0 2px 6px rgba(0,0,0,.06)}" + 3305 ".caf-bubble.caf-pos-right{right:104px}" + 3306 ".caf-bubble.caf-pos-left{left:104px}" + 3307 /* booking enabled: bubble con look "promo" colorato e CTA più visibile */ 3308 "@keyframes caf-bk-bubble-glow{0%,100%{box-shadow:0 6px 24px -6px " + c + "55,0 0 0 1px " + c + "30}50%{box-shadow:0 10px 32px -4px " + c + "88,0 0 0 2px " + c + "55}}" + 3309 "@keyframes caf-bk-arrow-bounce{0%,100%{transform:translateX(0)}50%{transform:translateX(4px)}}" + 3310 ".caf-bubble.caf-bk-active{background:linear-gradient(135deg," + c + " 0%,color-mix(in srgb," + c + " 80%,#1e293b) 100%);color:#fff;font-weight:600;font-size:14px;padding:13px 22px;animation:caf-bubble-slide .5s cubic-bezier(.34,1.56,.64,1) .4s both,caf-bk-bubble-glow 2.4s ease-in-out 1s infinite}" + 3311 ".caf-bubble.caf-bk-active:hover{transform:translateY(-3px) scale(1.02)}" + 3312 ".caf-bubble.caf-bk-active .caf-bk-arrow{display:inline-block;animation:caf-bk-arrow-bounce 1.2s ease-in-out infinite;font-weight:700}" + 3313 ".caf-bubble .caf-wave-emoji{display:inline-block;animation:caf-wave 1.4s ease-in-out infinite;transform-origin:70% 70%;font-size:16px}" + 3314 3315 /* ââ panel ââââââââââââââââââââââââââââââââââââââââ */ 3316 ".caf-panel{position:fixed;bottom:104px;z-index:" + opts.zIndex + ";width:400px;max-width:calc(100vw - 48px);max-height:min(640px,82vh);border-radius:24px;background:#fff;border:none;box-shadow:0 25px 60px -12px rgba(0,0,0,.2),0 0 0 1px rgba(0,0,0,.05);display:flex;flex-direction:column;overflow:hidden;" + 3317 "transition:transform .4s cubic-bezier(.34,1.56,.64,1),opacity .3s ease,visibility 0s .4s;" + 3318 "opacity:0;transform:translateY(16px) scale(.92);pointer-events:none;visibility:hidden}" + 3319 ".caf-panel.open{opacity:1;transform:translateY(0) scale(1);pointer-events:auto;visibility:visible;transition:transform .4s cubic-bezier(.34,1.56,.64,1),opacity .3s ease,visibility 0s 0s}" + 3320 ".caf-panel.caf-pos-right{right:24px}" + 3321 ".caf-panel.caf-pos-left{left:24px}" + 3322 3323 /* ââ header âââââââââââââââââââââââââââââââââââââââ */ 3324 ".caf-header{display:flex;align-items:center;justify-content:space-between;padding:18px 20px;color:#fff;position:relative;border-bottom:none;flex-shrink:0;min-height:72px;overflow:visible}" + 3325 ".caf-header::before{content:'';position:absolute;inset:0;background:radial-gradient(circle at 20% 50%,rgba(255,255,255,.08) 0%,transparent 60%);pointer-events:none}" + 3326 ".caf-header::after{content:'';position:absolute;inset:0;background:rgba(255,255,255,.1);mask-image:linear-gradient(110deg,transparent 0%,rgba(255,255,255,.8) 18%,transparent 36%);-webkit-mask-image:linear-gradient(110deg,transparent 0%,rgba(255,255,255,.8) 18%,transparent 36%);animation:caf-shimmer 4s linear infinite;pointer-events:none}" + 3327 ".caf-header-left{display:flex;align-items:center;gap:14px;position:relative;z-index:1}" + 3328 ".caf-header-avatar{width:42px;height:42px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.12);flex-shrink:0}" + 3329 ".caf-header-avatar img{width:80%;height:80%;object-fit:contain}" + 3330 ".caf-header-info{display:flex;flex-direction:column;gap:2px}" + 3331 ".caf-header-name{font-size:15px;font-weight:700;letter-spacing:-.01em;text-shadow:0 1px 2px rgba(0,0,0,.1)}" + 3332 ".caf-header-status{display:flex;align-items:center;gap:6px;font-size:12px;opacity:.9;font-weight:400}" + 3333 ".caf-online-dot{width:7px;height:7px;border-radius:50%;background:#86efac;flex-shrink:0;animation:caf-online-pulse 2s ease-in-out infinite}" + 3334 ".caf-header-actions{display:flex;gap:6px;align-items:center;position:relative;z-index:1}" + 3335 ".caf-header-btn{width:36px;height:36px;border-radius:12px;border:1px solid rgba(255,255,255,.2);background:rgba(255,255,255,.08);color:rgba(255,255,255,.95);
3335cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:400;transition:all .2s;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}" + 3336 ".caf-header-btn:hover{background:rgba(255,255,255,.2);transform:scale(1.05)}" + 3337 ".caf-header-btn:active{transform:scale(.92)}" + 3338 3339 /* ââ menu ââââââââââââââââââââââââââââââââââââââââââ */ 3340 ".caf-menu{position:absolute;right:16px;top:62px;width:220px;background:#fff;border-radius:16px;border:none;box-shadow:0 12px 40px -8px rgba(0,0,0,.15),0 0 0 1px rgba(0,0,0,.04);z-index:10;overflow:hidden;transition:transform .25s cubic-bezier(.34,1.56,.64,1),opacity .2s ease,visibility 0s .25s;opacity:0;transform:translateY(-8px) scale(.95);pointer-events:none;visibility:hidden}" + 3341 ".caf-menu.open{opacity:1;transform:translateY(0) scale(1);pointer-events:auto;visibility:visible;transition:transform .25s cubic-bezier(.34,1.56,.64,1),opacity .2s ease,visibility 0s 0s}" + 3342 ".caf-menu-item{display:flex;align-items:center;gap:10px;width:100%;padding:13px 16px;border:none;background:transparent;cursor:pointer;font-size:13.5px;color:#334155;text-decoration:none;text-align:left;transition:background .15s;font-weight:500}" + 3343 ".caf-menu-item:hover{background:" + c + "0a}" + 3344 ".caf-menu-item .caf-icon{width:30px;height:30px;border-radius:10px;background:" + c + "14;color:" + c + ";display:flex;align-items:center;justify-content:center;font-size:15px;flex-shrink:0}" + 3345 ".caf-menu-divider{height:1px;background:#f1f5f9;margin:4px 0}" + 3346 ".caf-menu-label{padding:10px 16px 6px;font-size:11px;font-weight:600;color:#5b6573;text-transform:uppercase;letter-spacing:.06em}" + 3347 ".caf-rating-row{display:flex;gap:8px;padding:8px 16px 12px}" + 3348 ".caf-rate-btn{flex:1;display:flex;align-items:center;justify-content:center;gap:6px;padding:8px 0;border-radius:12px;border:1.5px solid #e2e8f0;background:#fff;color:#475569;font-size:13px;font-weight:500;cursor:pointer;transition:all .2s cubic-bezier(.34,1.56,.64,1)}" + 3349 ".caf-rate-btn:hover{border-color:" + c + "40;background:" + c + "06}" + 3350 ".caf-rate-btn:active{transform:scale(.95)}" + 3351 ".caf-rate-btn.selected-good{border-color:#10b981;background:#ecfdf5;color:#047857}" + 3352 ".caf-rate-btn.selected-bad{border-color:#f43f5e;background:#fff1f2;color:#be123c}" + 3353 ".caf-rate-btn .caf-rate-icon{font-size:16px;line-height:1}" + 3354 3355 /* ââ chat area ââââââââââââââââââââââââââââââââââââ */ 3356 ".caf-messages{flex:1;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;padding:24px 18px;background:linear-gradient(180deg,#f8fafc 0%,#f1f5f9 100%);display:flex;flex-direction:column;gap:16px}" + 3357 3358 /* ââ messages âââââââââââââââââââââââââââââââââââââ */ 3359 ".caf-msg-row{display:flex;gap:10px}" + 3360 ".caf-msg-row.caf-new{animation:caf-pop-in .45s cubic-bezier(.34,1.56,.64,1)}" + 3361 ".caf-msg-row.user{justify-content:flex-end}" + 3362 ".caf-msg-avatar{width:34px;height:34px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0;box-shadow:0 1px 4px rgba(0,0,0,.08)}" + 3363 ".caf-msg-avatar img{width:75%;height:75%;object-fit:contain}" + 3364 ".caf-msg-bubble{padding:14px 18px;border-radius:20px;font-size:13.5px;line-height:1.7;word-break:break-word;transition:box-shadow .25s ease,transform .25s ease}" + 3365 ".caf-msg-bubble.assistant{background:#fff;color:#334155;border-bottom-left-radius:6px;box-shadow:0 1px 3px rgba(0,0,0,.04),0 0 0 1px rgba(0,0,0,.03)}" + 3366 ".caf-msg-bubble.assistant:hover{box-shadow:0 4px 16px rgba(0,0,0,.08),0 0 0 1px rgba(0,0,0,.03)}" + 3367 ".caf-msg-bubble.assistant p{margin:5px 0}" + 3368 ".caf-msg-bubble.assistant p:first-child{margin-top:0}" + 3369 ".caf-msg-bubble.assistant p:last-child{margin-bottom:0}" + 3370 ".caf-msg-bubble.user{color:#fff;border-bottom-right-radius:6px;white-space:pre-wrap;box-shadow:0 4px 16px -4px " + c + "40}" + 3371 ".caf-msg-bubble a{color:" + c + ";text-decoration:underline;text-underline-offset:2px}" + 3372 ".caf-msg-bubble.user a{color:#fff}" + 3373 ".caf-msg-bubble strong{font-weight:600}" + 3374 ".caf-strong{font-weight:700!important;background:" + c + "1a;border-radius:4px;padding:1px 5px;color:#065f46;animation:caf-highlight .7s ease-out forwards}" + 3375 ".caf-url-badge{display:inline-flex!important;align-items:center;gap:4px;background:" + c + "0d;border:1px solid " + c + "40;border-radius:20px;padding:2px 9px;font-weight:600;font-size:12px;text-decoration:none!important;color:var(--caf-c-ink)!important;animation:caf-badge-in .4s ease-out forwards}" + 3376 ".caf-url-badge:hover{opacity:.8}" + 3377 ".caf-phone-badge{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;background:" + c + "0d;border:1px solid " + c + "33;border-radius:20px;padding:2px 10px;font-size:12.5px;font-weight:600;color:" + c + ";animation:caf-badge-in .4s ease-out forwards}" + 3378 ".caf-phone-lbl{font-weight:400;color:#5b6573;font-size:12px}" + 3379 ".caf-msg-bubble hr{border:none;border-top:1px solid #e2e8f0;margin:10px 0}" + 3380 3381 /* ââ ecommerce: product card âââââââââââââââââââââââââ */ 3382 ".caf-prod-card{--caf-prod-c:" + c + ";display:flex;gap:12px;background:linear-gradient(180deg,#fff,#fafbfc);border:1px solid #e5e7eb;border-radius:14px;padding:12px;margin:10px 0;box-shadow:0 1px 2px rgba(15,23,42,.04),0 4px 12px rgba(15,23,42,.06);transition:transform .2s ease,box-shadow .2s ease,border-color .2s ease;animation:caf-prod-in .35s cubic-bezier(.2,.9,.3,1.2) both}" + 3383 ".caf-prod-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(15,23,42,.08),0 12px 28px rgba(15,23,42,.10);border-color:" + c + "55}" + 3384 ".caf-prod-img-wrap{position:relative;flex:0 0 92px;width:92px;height:92px;border-radius:10px;overflow:hidden;background:#f1f5f9;display:flex;align-items:center;justify-content:center}" + 3385 ".caf-prod-img{width:100%;height:100%;object-fit:contain;display:block;transition:transform .35s ease}" + 3386 ".caf-prod-card:hover .caf-prod-img{transform:scale(1.05)}" + 3387 ".caf-prod-promo-badge{position:absolute;top:5px;left:5px;background:linear-gradient(135deg,#ef4444,#dc2626);color:#fff;font-weight:800;font-size:10.5px;padding:2px 7px;border-radius:6px;letter-spacing:.3px;box-shadow:0 2px 6px rgba(220,38,38,.4);animation:caf-prod-pulse 2.4s ease-in-out infinite;z-index:2}" + 3388 ".caf-prod-stock-badge{position:absolute;bottom:5px;left:5px;font-weight:700;font-size:9.5px;padding:2px 6px;border-radius:5px;letter-spacing:.2px;text-transform:uppercase;z-index:2}" + 3389 ".caf-prod-stock-badge.low{background:#fef3c7;color:#92400e;border:1px solid #fbbf24}" + 3390 ".caf-prod-stock-badge.out{background:#fee2e2;color:#991b1b;border:1px solid #fca5a5}" + 3391 ".caf-prod-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:5px}" + 3392 ".caf-prod-cat{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:#5b6573}" + 3393 ".caf-prod-title{font-size:13px;font-weight:700;color:#0f172a;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;user-select:none}" + 3394 ".caf-prod-card.expanded .caf-prod-title{-webkit-line-clamp:99;display:block}" + 3395 ".caf-prod-meta{display:flex;align-items:center;gap:10px;font-size:11px;color:#5b6573;flex-wrap:wrap}" + 3396 ".caf-prod-pcs{display:inline-flex;align-items:center;gap:3px;background:#f1f5f9;color:#475569;padding:2px 7px;border-radius:6px;font-weight:600}" + 3397 ".caf-prod-pcs svg{width:11px;height:11px}" + 3398 ".caf-prod-perpiece{font-style:italic}" + 3399 ".caf-prod-prow{display:flex;align-items:baseline;gap:8px;margin-top:2px}" + 3400 ".caf-prod-price{font-size:18px;font-weight:800;color:" + c + ";letter-spacing:-.5px}" + 3401 ".caf-prod-price-old{font-size:12px;color:#5b6573;text-decoration:line-through;font-weight:500}" + 3402 ".caf-prod-desc{font-size:11.5px;color:#5b6573;line-height:1.45;display:none}" + 3403 ".caf-prod-card.expanded .caf-prod-desc{display:block;animation:caf-prod-fadein .25s ease-out}" + 3404 ".caf-prod-cta{display:flex;align-items:center;gap:8px;margin-top:8px;flex-wrap:wrap}" + 3405 ".caf-prod-qty{display:inline-flex;align-items:center;border:1px solid #e2e8f0;border-radius:8px;background:#fff;overflow:hidden}" + 3406 ".caf-prod-qty-btn{width:26px;height:28px;border:none;background:transparent;font-size:16px;font-weight:700;color:" + c + ";cursor:pointer;transition:background .15s ease;line-height:1}" + 3407 ".caf-prod-qty-btn:hover{background:" + c + "14}" + 3408 ".caf-prod-qty-btn:active{background:" + c + "26}" + 3409 ".caf-prod-qty-inp{width:36px;height:28px;border:none;border-left:1px solid #e2e8f0;border-right:1px solid #e2e8f0;text-align:center;font-size:13px;font-weight:700;color:#0f172a;outline:none;background:transparent}" + 3410 ".caf-prod-add{flex:1;min-width:110px;display:inline-flex;align-items:center;justify-content:center;gap:6px;background:linear-gradient(135deg," + c + "," + c + "d6);color:#fff;border:none;border-radius:8px;padding:7px 12px;font-size:12.5px;font-weight:700;cursor:pointer;letter-spacing:.2px;box-shadow:0 2px 6px " + c + "40;transition:transform .15s ease,box-shadow .2s ease,background .2s ease}" + 3411 ".caf-prod-add:hover{transform:translateY(-1px);box-shadow:0 4px 10px " + c + "55}" + 3412 ".caf-prod-add:active{transform:translateY(0)}" + 3413 ".caf-prod-add:disabled{background:#cbd5e1;cursor:not-allowed;box-shadow:none;opacity:.7}" + 3414 ".caf-prod-add svg{width:14px;
3414height:14px}" + 3415 ".caf-prod-add.added{background:linear-gradient(135deg,#10b981,#059669);animation:caf-prod-added .5s cubic-bezier(.2,.9,.3,1.2)}" + 3416 "@keyframes caf-prod-in{0%{opacity:0;transform:translateY(8px) scale(.98)}100%{opacity:1;transform:translateY(0) scale(1)}}" + 3417 "@keyframes caf-prod-fadein{0%{opacity:0;transform:translateY(-3px)}100%{opacity:1;transform:translateY(0)}}" + 3418 "@keyframes caf-prod-pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.06)}}" + 3419 "@keyframes caf-prod-added{0%{transform:scale(1)}40%{transform:scale(1.06)}100%{transform:scale(1)}}" + 3420 "@media(max-width:480px){.caf-prod-card{flex-direction:column}.caf-prod-img-wrap{flex:0 0 auto;width:100%;height:140px}.caf-prod-img{object-fit:contain}}" + 3421 ".caf-prod-anchor{display:none}" + 3422 3423 /* ââ carousel âââââââââââââââââââââââââââââââââââââ */ 3424 ".caf-prod-carousel{margin:14px -4px 4px;padding:10px 4px 4px;border-top:1px dashed #e2e8f0;animation:caf-prod-in .4s ease-out both}" + 3425 ".caf-prod-carousel-hdr{display:flex;align-items:center;justify-content:space-between;margin:0 4px 8px;font-size:12.5px}" + 3426 ".caf-prod-carousel-hdr-l{display:inline-flex;align-items:center;gap:6px;font-weight:700;color:#0f172a}" + 3427 ".caf-prod-carousel-hdr-l svg{width:14px;height:14px;color:" + c + "}" + 3428 ".caf-prod-carousel-count{color:#5b6573;font-size:11px;font-weight:600;background:#f1f5f9;padding:2px 8px;border-radius:10px}" + 3429 ".caf-prod-carousel-sw{position:relative}" + 3430 ".caf-prod-carousel-scroll{display:flex;gap:10px;overflow-x:auto;overflow-y:hidden;scroll-snap-type:x mandatory;scroll-behavior:smooth;-webkit-overflow-scrolling:touch;padding:6px 4px 14px;scrollbar-width:thin;scrollbar-color:#cbd5e1 transparent}" + 3431 ".caf-prod-carousel-scroll::-webkit-scrollbar{height:6px}" + 3432 ".caf-prod-carousel-scroll::-webkit-scrollbar-track{background:transparent}" + 3433 ".caf-prod-carousel-scroll::-webkit-scrollbar-thumb{background:#cbd5e1;border-radius:3px}" + 3434 ".caf-prod-carousel-scroll::-webkit-scrollbar-thumb:hover{background:#94a3b8}" + 3435 ".caf-prod-carousel-arrow{position:absolute;top:50%;transform:translateY(-50%);z-index:5;width:30px;height:30px;border-radius:50%;background:#fff;border:1px solid #e2e8f0;color:#0f172a;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(15,23,42,.12);transition:opacity .25s ease,transform .15s ease,box-shadow .2s ease;opacity:0;pointer-events:none}" + 3436 ".caf-prod-carousel-arrow:hover{transform:translateY(-50%) scale(1.08);box-shadow:0 4px 14px rgba(15,23,42,.18)}" + 3437 ".caf-prod-carousel-arrow.l{left:-6px}" + 3438 ".caf-prod-carousel-arrow.r{right:-6px}" + 3439 ".caf-prod-carousel-arrow svg{width:14px;height:14px}" + 3440 /* Mini card: design ELEGANTE con foto dominante, no CTA, scrolling orizzontale. */ 3441 ".caf-prod-mini{flex:0 0 190px;width:190px;background:#ffffff;border:1px solid #eef2f7;border-radius:16px;padding:0;display:flex;flex-direction:column;scroll-snap-align:start;box-shadow:0 1px 2px rgba(15,23,42,.04),0 4px 14px rgba(15,23,42,.04);transition:transform .3s cubic-bezier(.2,.9,.3,1.1),box-shadow .3s ease,border-color .25s ease;animation:caf-prod-in .4s cubic-bezier(.2,.9,.3,1.2) both;overflow:hidden;cursor:pointer}" + 3442 ".caf-prod-mini:hover{transform:translateY(-5px);border-color:" + c + "55;box-shadow:0 14px 32px rgba(15,23,42,.12),0 6px 12px rgba(15,23,42,.06)}" + 3443 ".caf-prod-mini-img-wrap{position:relative;width:100%;aspect-ratio:1/1;background:linear-gradient(135deg,#fafbfc 0%,#f1f5f9 100%);display:flex;align-items:center;justify-content:center;overflow:hidden}" + 3444 ".caf-prod-mini-img{width:88%;height:88%;object-fit:contain;display:block;transition:transform .45s cubic-bezier(.2,.9,.3,1.1);filter:drop-shadow(0 4px 8px rgba(15,23,42,.08))}" + 3445 ".caf-prod-mini:hover .caf-prod-mini-img{transform:scale(1.12) rotate(-1deg)}" + 3446 ".caf-prod-mini-promo{position:absolute;top:8px;left:8px;background:linear-gradient(135deg,#ef4444,#dc2626);color:#fff;font-weight:800;font-size:11px;padding:3px 8px;border-radius:8px;box-shadow:0 3px 8px rgba(220,38,38,.4);z-index:2;letter-spacing:.4px}" + 3447 ".caf-prod-mini-stock{position:absolute;bottom:8px;right:8px;font-weight:700;font-size:9px;padding:3px 7px;border-radius:6px;letter-spacing:.4px;text-transform:uppercase;z-index:2;backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px)}" + 3448 ".caf-prod-mini-stock.low{background:rgba(251,191,36,.95);color:#7c2d12;border:1px solid rgba(245,158,11,.4)}" + 3449 ".caf-prod-mini-stock.out{background:rgba(185,28,28,.95);color:#fff;border:1px solid rgba(220,38,38,.4)}" + 3450 ".caf-prod-mini-body{padding:12px 13px 14px;display:flex;flex-direction:column;gap:8px;flex:1}" + 3451 ".caf-prod-mini-title{font-size:12.5px;font-weight:700;color:#0f172a;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:2.8em;letter-spacing:-.15px}" + 3452 ".caf-prod-mini-prow{display:flex;align-items:baseline;gap:7px;flex-wrap:wrap;margin-top:auto}" + 3453 ".caf-prod-mini-price{font-size:18px;font-weight:800;color:" + c + ";letter-spacing:-.6px;line-height:1}" + 3454 ".caf-prod-mini-price-old{font-size:12px;color:#5b6573;text-decoration:line-through;font-weight:600}" + 3455 "@media(max-width:480px){.caf-prod-mini{flex:0 0 160px;width:160px}.caf-prod-mini-body{padding:10px 11px 12px}.caf-prod-mini-price{font-size:16px}}" + 3456 3457 3458 3459 /* ââ typing dots ââââââââââââââââââââââââââââââââââ */ 3460 ".caf-typing{display:flex;align-items:center;gap:5px;padding:4px 0}" + 3461 ".caf-typing span{width:7px;height:7px;border-radius:50%;background:" + c + "66;display:inline-block;animation:caf-dots 1.4s infinite ease-in-out both}" + 3462 ".caf-typing span:nth-child(1){animation-delay:-.32s}" + 3463 ".caf-typing span:nth-child(2){animation-delay:-.16s}" + 3464 3465 /* ââ streaming caret ââââââââââââââââââââââââââââââ */ 3466 ".caf-caret{display:inline-block;width:2.5px;height:1.15em;margin-left:3px;border-radius:999px;vertical-align:text-bottom;animation:caf-caret 1s ease-in-out infinite}" + 3467 3468 /* ââ quick replies ââââââââââââââââââââââââââââââââ */
3469 /* ââ QUICK REPLIES: premium cards ââ */ 3470 "@keyframes caf-quick-in{0%{opacity:0;transform:translateY(8px) scale(.96)}100%{opacity:1;transform:translateY(0) scale(1)}}" + 3471 "@keyframes caf-quick-shine{0%{transform:translateX(-100%) skewX(-20deg)}60%,100%{transform:translateX(260%) skewX(-20deg)}}" + 3472 ".caf-quick{display:flex;flex-direction:column;gap:8px;padding:4px 0 2px;margin-left:44px}" + 3473 ".caf-quick-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:#5b6573;margin-bottom:2px;display:flex;align-items:center;gap:6px}" + 3474 ".caf-quick-label::before,.caf-quick-label::after{content:'';flex:0 0 18px;height:1px;background:linear-gradient(90deg,transparent," + c + "30,transparent)}" + 3475 ".caf-quick-label::before{background:linear-gradient(90deg," + c + "30,transparent)}" + 3476 ".caf-quick button{position:relative;display:flex;align-items:center;gap:10px;padding:11px 14px 11px 13px;border-radius:14px;border:1.5px solid " + c + "20;background:linear-gradient(135deg,#fff," + c + "05);color:#1e293b;font-size:12.5px;cursor:pointer;text-align:left;font-weight:600;line-height:1.35;font-family:inherit;overflow:hidden;transition:transform .25s cubic-bezier(.34,1.56,.64,1),box-shadow .3s,border-color .25s,background .25s;animation:caf-quick-in .4s cubic-bezier(.34,1.56,.64,1) both;-webkit-tap-highlight-color:transparent;box-shadow:0 1px 2px rgba(15,23,42,.04)}" + 3477 ".caf-quick button::after{content:'';position:absolute;top:0;bottom:0;width:40%;background:linear-gradient(100deg,transparent 0%,rgba(255,255,255,.45) 50%,transparent 100%);transform:translateX(-100%) skewX(-20deg);pointer-events:none;transition:opacity .3s;opacity:0}" + 3478 ".caf-quick button:hover::after{opacity:1;animation:caf-quick-shine 1s ease-out}" +
3479 ".caf-quick button:nth-child(2){animation-delay:.08s}" + 3480 ".caf-quick button:nth-child(3){animation-delay:.16s}" + 3481 ".caf-quick button:nth-child(4){animation-delay:.24s}" + 3482 ".caf-quick button:nth-child(5){animation-delay:.32s}" + 3483 ".caf-quick button:hover{border-color:" + c + ";background:linear-gradient(135deg," + c + "," + cGradEnd + ");color:var(--caf-on-c);transform:translateY(-2px);box-shadow:0 10px 24px " + c + "38}" + 3484 ".caf-quick button:hover .caf-quick-ic{background:rgba(255,255,255,.25);color:#fff;transform:rotate(-6deg) scale(1.08)}" + 3485 ".caf-quick button:hover .caf-quick-ar{opacity:1;transform:translateX(2px);color:#fff}" + 3486 ".caf-quick button:active{transform:scale(.98)}" + 3487 ".caf-quick-ic{width:30px;height:30px;border-radius:10px;background:" + c + "15;color:" + c + ";display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .25s,color .25s,transform .3s cubic-bezier(.34,1.56,.64,1);position:relative;z-index:1}" + 3488 ".caf-quick-ic svg{width:15px;height:15px}" + 3489 ".caf-quick-txt{flex:1;min-width:0;position:relative;z-index:1}" + 3490 ".caf-quick-ar{flex-shrink:0;opacity:.4;color:#5b6573;transition:opacity .25s,transform .25s,color .25s;position:relative;z-index:1}" + 3491 ".caf-quick-ar svg{width:14px;height:14px}" + 3492 3493 /* ââ timestamps ââââââââââââââââââââââââââââââââââââ */ 3494 ".caf-msg-time{font-size:10px;color:#5b6573;margin-top:4px;padding:0 2px;user-select:none}" + 3495 ".caf-msg-row.user .caf-msg-time{text-align:right}" + 3496 3497 /* ââ copy button ââââââââââââââââââââââââââââââââââââ */ 3498 ".caf-msg-wrap{position:relative;max-width:80%}" + 3499 ".caf-copy-btn{position:absolute;top:6px;right:6px;width:28px;height:28px;border-radius:8px;border:none;background:rgba(0,0,0,.06);color:#5b6573;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:13px;opacity:0;transition:opacity .2s,background .15s;z-index:1;pointer-events:none}" + 3500 ".caf-msg-wrap:hover .caf-copy-btn{opacity:1;pointer-events:auto}" + 3501 ".caf-copy-btn:hover{background:rgba(0,0,0,.1)}" + 3502 ".caf-copy-btn.copied{background:" + c + "18;color:" + c + "}" + 3503 ".caf-copy-toast{position:absolute;top:-28px;right:0;background:#1e293b;color:#fff;font-size:11px;font-weight:500;padding:4px 10px;border-radius:8px;pointer-events:none;animation:caf-pop-in .25s cubic-bezier(.34,1.56,.64,1);white-space:nowrap}" + 3504 3505 /* ââ translate button & picker âââââââââââââââââââââââ */ 3506 ".caf-translate-btn{position:absolute;top:6px;right:38px;width:28px;height:28px;border-radius:8px;border:none;background:rgba(0,0,0,.06);color:#5b6573;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px;opacity:0;transition:opacity .2s,background .15s;z-index:1;pointer-events:none}" + 3507 ".caf-msg-wrap:hover .caf-translate-btn{opacity:1;pointer-events:auto}" + 3508 ".caf-translate-btn:hover{background:rgba(0,0,0,.1)}" + 3509 3510 /* ââ mobile action bar (replaces absolute overlay) ââ */ 3511 ".caf-msg-actions{display:none}" + 3512 ".caf-lang-picker{position:absolute;top:38px;right:0;background:#fff;border-radius:14px;box-shadow:0 10px 36px -8px rgba(0,0,0,.16),0 0 0 1px rgba(0,0,0,.04);z-index:20;padding:8px;display:grid;grid-template-columns:repeat(4,1fr);gap:4px;animation:caf-pop-in .2s cubic-bezier(.34,1.56,.64,1);width:220px;max-height:260px;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch}" + 3513 ".caf-lang-btn{display:flex;flex-direction:column;align-items:center;gap:2px;padding:8px 4px;border:none;background:transparent;border-radius:10px;cursor:pointer;transition:background .15s;font-size:11px;color:#475569;font-weight:500;font-family:inherit}" + 3514 ".caf-lang-btn:hover{background:" + c + "0a}" + 3515 ".caf-lang-btn:active{transform:scale(.94)}" + 3516 ".caf-lang-btn .caf-flag{font-size:20px;line-height:1}" + 3517 ".caf-translating{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:#5b6573;padding:4px 0}" + 3518 3519 /* ââ date separator ââââââââââââââââââââââââââââââââââ */
3520 ".caf-date-sep{display:flex;align-items:center;gap:12px;padding:8px 0;user-select:none}" + 3521 ".caf-date-sep::before,.caf-date-sep::after{content:'';flex:1;height:1px;background:#e2e8f0}" + 3522 ".caf-date-sep span{font-size:10.5px;font-weight:600;color:#5b6573;text-transform:uppercase;letter-spacing:.05em;white-space:nowrap}" + 3523 3524 /* ââ unread badge ââââââââââââââââââââââââââââââââââââ */ 3525 ".caf-badge{position:absolute;top:-4px;right:-4px;min-width:20px;height:20px;border-radius:10px;background:#dc2626;color:#fff;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;padding:0 5px;pointer-events:none;animation:caf-pop-in .3s cubic-bezier(.34,1.56,.64,1);box-shadow:0 2px 6px rgba(239,68,68,.4);line-height:1}" + 3526 3527 /* ââ code rendering ââââââââââââââââââââââââââââââââââ */ 3528 ".caf-code-inline{background:#f1f5f9;padding:1px 6px;border-radius:4px;font-family:'SF Mono',SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:.88em;color:#334155;border:1px solid #e2e8f0}" + 3529 ".caf-code-block{background:#1e293b;color:#e2e8f0;padding:14px 16px;border-radius:12px;font-family:'SF Mono',SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:12px;line-height:1.6;overflow-x:auto;white-space:pre;margin:8px 0;-webkit-overflow-scrolling:touch}" + 3530 ".caf-blockquote{border-left:3px solid " + c + ";padding:4px 0 4px 14px;color:#5b6573;margin:8px 0;font-style:italic}" + 3531 3532 /* ââ error ââââââââââââââââââââââââââââââââââââââââ */ 3533 ".caf-error{background:#fef2f2;color:#b91c1c;font-size:12.5px;padding:10px 18px;border-radius:14px;text-align:center;animation:caf-pop-in .3s cubic-bezier(.34,1.56,.64,1);margin:0 18px;font-weight:500}" + 3534 3535 /* ââ scroll-to-bottom âââââââââââââââââââââââââââââ */ 3536 ".caf-scroll-btn{position:absolute;bottom:10px;left:50%;transform:translateX(-50%);width:34px;height:34px;border-radius:50%;border:none;background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 10px rgba(0,0,0,.1),0 0 0 1px rgba(0,0,0,.04);transition:all .2s;font-size:15px;color:" + c + ";z-index:2}" + 3537 ".caf-scroll-btn:hover{box-shadow:0 4px 16px rgba(0,0,0,.14);transform:translateX(-50%) scale(1.08)}" + 3538 3539 /* ââ input area âââââââââââââââââââââââââââââââââââ */ 3540 ".caf-input-area{display:flex;align-items:center;gap:10px;padding:14px 18px 16px;border-top:none;background:#fff;flex-shrink:0;box-shadow:0 -1px 0 rgba(0,0,0,.04)}" + 3541 ".caf-input{flex:1;border:1.5px solid #818c9c;border-radius:16px;padding:12px 18px;font-size:13.5px;outline:none;color:#1e293b;font-family:inherit;transition:border-color .2s,box-shadow .2s;background:#f8fafc;font-weight:400}" + 3542 ".caf-input:focus{border-color:" + c + ";box-shadow:0 0 0 3px " + c + "15;background:#fff}" + 3543 ".caf-input::placeholder{color:#5b6573;font-weight:400}" + 3544 ".caf-send{width:44px;height:44px;border-radius:14px;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s cubic-bezier(.34,1.56,.64,1);flex-shrink:0}" + 3545 ".caf-send:disabled{opacity:.35;cursor:not-allowed}" + 3546 ".caf-send:not(:disabled):hover{transform:scale(1.06);box-shadow:0 4px 14px " + c + "33}" + 3547 ".caf-send:not(:disabled):active{transform:scale(.92)}" + 3548 ".caf-send svg{width:18px;height:18px}" + 3549 3550 /* ââ mic button (matches iframe ChatWidget style) ââ */ 3551 ".caf-mic{width:40px;height:40px;border-radius:50%;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .3s;flex-shrink:0;position:relative;color:#fff;box-shadow:0 2px 12px -2px " + c + "73}" + 3552 ".caf-mic:disabled{opacity:.4;cursor:not-allowed;transform:none!important}" + 3553 ".caf-mic:not(:disabled):hover{transform:scale(1.10);box-shadow:0 4px 20px -2px " + c + "99}" + 3554 ".caf-mic:not(:disabled):active{transform:scale(.95)}" + 3555 ".caf-mic svg{width:18px;height:18px;position:relative;z-index:1;filter:drop-shadow(0 1px 1px rgba(0,0,0,.1))}" + 3556 ".caf-mic .caf-mic-pulse{position:absolute;inset:0;border-radius:50%;background:" + c + ";animation:caf-pulse-ring 2.8s ease-in-out infinite;pointer-events:none}" + 3557 ".caf-mic.listening .caf-mic-pulse{animation:caf-pulse-ring 1.2s ease-in-out infinite}" + 3558 ".caf-mic.listening{animation:caf-mic-glow 1.5s ease-in-out infinite}" + 3559 "@keyframes caf-mic-glow{0%,100%{box-shadow:0 0 0 0 " + c + "55}50%{box-shadow:0 0 0 8px " + c + "00}}" + 3560 3561 /* ââ voice mode panel âââââââââââââââââââââââââââââââ */ 3562 ".caf-voice-panel{display:none;flex-direction:column;flex:1;min-height:0;overflow:hidden}" + 3563 ".caf-voice-panel.active{display:flex;animation:caf-voice-enter .45s cubic-bezier(.34,1.56,.64,1) both}" + 3564 3565 ".caf-voice-transcript{flex:1;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;padding:16px 14px;background:linear-gradient(180deg,#f8fafc,#f1f5f9);display:flex;flex-direction:column;gap:10px}" + 3566 ".caf-vt-row{display:flex;gap:8px;animation:caf-pop-in .3s ease-out}" + 3567 ".caf-vt-row.user{justify-content:flex-end}" + 3568 ".caf-vt-avatar{width:28px;height:28px;border-radius:50%;background:#fff;display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0;box-shadow:0 1px 3px rgba(0,0,0,.06)}" + 3569 ".caf-vt-avatar img{width:75%;height:75%;object-fit:contain}" + 3570 ".caf-vt-bubble{max-width:85%;padding:10px 14px;border-radius:16px;font-size:13px;line-height:1.6;word-break:break-word}" + 3571 ".caf-vt-bubble.assistant{background:#fff;color:#334155;border-bottom-left-radius:4px;box-shadow:0 1px 2px rgba(0,0,0,.04)}" + 3572 ".caf-vt-bubble.user{color:#fff;border-bottom-right-radius:4px;box-shadow:0 2px 8px " + c + "30}" + 3573 ".caf-vt-ghost{max-width:85%;padding:10px 14px;border-radius:16px;font-size:13px;line-height:1.6;border-bottom-right-radius:4px;color:rgba(255,255,255,.9);animation:caf-pop-in .2s ease-out}" + 3574 ".caf-vt-dots{display:flex;align-items:center;gap:4px;padding:2px 0}" + 3575 ".caf-vt-dots span{width:6px;height:6px;border-radius:50%;display:inline-block;animation:caf-dots 1.4s infinite ease-in-out both}" + 3576 ".caf-vt-dots span:nth-child(1){animation-delay:-.32s}" + 3577 ".caf-vt-dots span:nth-child(2){animation-delay:-.16s}" + 3578
3579 ".caf-voice-controls{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:20px;padding:24px 24px 28px;min-height:260px;border-top:1px solid rgba(0,0,0,.04)}" + 3580 ".caf-voice-glow{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);border-radius:50%;filter:blur(40px);pointer-events:none;transition:all .8s ease}" + 3581 ".caf-voice-status{font-size:15px;font-weight:700;letter-spacing:.02em;transition:color .3s,opacity .3s;text-align:center;min-height:20px}" + 3582 ".caf-voice-hint{font-size:11.5px;font-weight:400;text-align:center;margin-top:-12px;transition:color .3s,opacity .3s;opacity:1;min-height:16px}" + 3583 3584 ".caf-voice-mic-wrap{position:relative;display:flex;align-items:center;justify-content:center}" + 3585 ".caf-voice-ring{position:absolute;border-radius:50%;border:2px solid;transition:all .7s}" + 3586 ".caf-voice-ripple{position:absolute;border-radius:50%;pointer-events:none}" + 3587 ".caf-voice-bars{position:absolute;display:flex;align-items:center;justify-content:center;width:130px;height:130px;pointer-events:none}" + 3588 ".caf-voice-bar{position:absolute;width:3px;border-radius:3px;transform-origin:center center}" + 3589 3590 ".caf-voice-mic-btn{position:relative;z-index:1;width:96px;height:96px;border-radius:50%;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#fff;transition:background .3s,box-shadow .4s,transform .3s}" + 3591 ".caf-voice-mic-btn:active{transform:scale(.95)}" + 3592 ".caf-voice-mic-btn svg{width:40px;height:40px;fill:currentColor;filter:drop-shadow(0 1px 2px rgba(0,0,0,.15))}" + 3593 3594 ".caf-voice-exit{margin-top:4px;display:inline-flex;align-items:center;gap:8px;border-radius:999px;border:1px solid rgba(0,0,0,.08);background:rgba(255,255,255,.7);padding:10px 20px;font-size:12px;font-weight:500;color:#5b6573;cursor:pointer;transition:all .2s;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}" + 3595 ".caf-voice-exit:hover{border-color:" + c + "40;color:" + c + ";transform:translateY(-2px);box-shadow:0 2px 8px rgba(0,0,0,.06)}" + 3596 ".caf-voice-exit:active{transform:scale(.95)}" + 3597 ".caf-voice-exit svg{width:16px;height:16px;fill:currentColor}" + 3598 3599 "@keyframes caf-voice-enter{0%{opacity:0;transform:translateY(40px) scale(.96)}60%{opacity:1;transform:translateY(-4px) scale(1.01)}100%{opacity:1;transform:translateY(0) scale(1)}}" + 3600 "@keyframes caf-voice-ripple{0%{transform:scale(1);opacity:.45}100%{transform:scale(2.6);opacity:0}}" + 3601 "@keyframes caf-voice-bar{0%,100%{transform:scaleY(.25)}50%{transform:scaleY(1)}}" + 3602 "@keyframes caf-mic-breathe{0%,100%{transform:scale(1);box-shadow:0 0 0 0 " + c + "40,0 0 40px 4px " + c + "1a}50%{transform:scale(1.06);box-shadow:0 0 0 8px " + c + "14,0 0 60px 8px " + c + "2e}}" + 3603 "@keyframes caf-speaking-glow{0%,100%{box-shadow:0 0 0 0 rgba(16,185,129,.2),0 0 30px 4px rgba(16,185,129,.08)}50%{box-shadow:0 0 0 12px rgba(16,185,129,.1),0 0 50px 12px rgba(16,185,129,.2)}}" + 3604 3605 /* ââ footer âââââââââââââââââââââââââââââââââââââââ */ 3606 ".caf-footer{text-align:center;padding:7px 8px;font-size:11px;line-height:1.3;color:#475569;background:#f8fafc;border-top:1px solid #e2e8f0;flex-shrink:0}" + 3607 3608 /* ââ desktop/tablet fullscreen âââââââââââââââââââââ */ 3609 ".caf-panel.open.caf-desktop-fs{position:fixed;inset:0;width:100vw;max-width:100vw;height:100vh;height:100dvh;max-height:100vh;border-radius:0;bottom:0;right:0;left:0;box-shadow:none;transition:transform .35s cubic-bezier(.4,0,.2,1),opacity .25s ease,border-radius .35s ease,width .35s ease,height .35s ease,inset .35s ease,visibility 0s 0s}" + 3610 ".caf-panel.open.caf-desktop-fs .caf-header{min-height:64px}" + 3611 ".caf-toggle.caf-fs-hide-desk{display:none!important}" + 3612 ".caf-bubble.caf-fs-hide-desk{display:none!important}" + 3613 3614 /* ââ mobile fullscreen ââââââââââââââââââââââââââââ */ 3615 "@media(max-width:640px){" + 3616 ".caf-panel.open.caf-fullscreen{position:fixed;inset:0;width:100vw;max-width:100vw;height:100vh;height:100dvh;max-height:100vh;border-radius:0;bottom:0;box-shadow:none;touch-action:pan-y}" + 3617 ".caf-panel.open.caf-fullscreen .caf-header{padding-top:calc(env(safe-area-inset-top,0px) + 18px)}" + 3618 ".caf-panel.open.caf-fullscreen .caf-input-area{padding-bottom:calc(env(safe-area-inset-bottom,0px) + 16px)}" + 3619 /* CRITICAL iOS: prevent input auto-zoom by forcing >=16px on all inputs */ 3620 ".caf-input{font-size:16px!important}" + 3621 ".caf-email-input{font-size:16px!important}" + 3622 ".caf-bk-fs-search input{font-size:16px!important;padding:13px 16px 13px 42px!important}" + 3623 ".caf-bk-fs-form .caf-bk-inp{font-size:16px!important;padding:14px 40px 14px 40px!important}" + 3624 /* bigger tap targets (WCAG â¥44px) */ 3625 ".caf-header-btn{width:40px;height:40px}" + 3626 ".caf-mic{width:44px;height:44px}" + 3627 ".caf-send{width:46px;height:46px}" + 3628 /* prevent 300ms tap delay and double-tap zoom on interactive elements */ 3629 ".caf-bk-card,.caf-bk-fs-tile,.caf-bk-fs-cal-chip,.caf-bk-fs-monthcal-day,.caf-bk-fs-btn,.caf-bk-pri,.caf-bk-act,.caf-bk-act2,.caf-quick button,.caf-bk-cta,.caf-bk-cta-btn,.caf-bk-locate,.caf-bk-expand-banner,.caf-bk-nearby-btn{touch-action:manipulation}" + 3630 ".caf-toggle.caf-fs-hide{display:none!important}" + 3631 ".caf-bubble.caf-fs-hide{display:none!important}" + 3632 ".caf-fs-btn{display:none!important}" + 3633 /* hide absolute-positioned buttons, show action bar instead */ 3634 ".caf-copy-btn,.caf-translate-btn{display:none!important}" + 3635 ".caf-msg-actions{display:flex!important;align-items:center;gap:6px;margin-top:4px;padding:2px 0}" + 3636 ".caf-msg-actions button{display:inline-flex;align-items:center;gap:4px;border:none;background:transparent;color:#5b6573;font-size:11px;font-weight:500;font-family:inherit;padding:4px 8px;border-radius:8px;
3636cursor:pointer;-webkit-tap-highlight-color:transparent;touch-action:manipulation;transition:color .2s,background .15s}" + 3637 ".caf-msg-actions button:active{background:rgba(0,0,0,.06);color:#5b6573}" + 3638 ".caf-msg-actions button svg{width:13px;height:13px;flex-shrink:0}" + 3639 ".caf-msg-actions button.copied{color:var(--caf-c-ink)}" + 3640 ".caf-msg-actions .caf-lang-picker{position:absolute;bottom:28px;left:0;top:auto;right:auto}" + 3641 3642 /* ââ mobile booking optimizations ââ */ 3643 /* grid collapses to single column on very small screens */ 3644 ".caf-bk-grid{grid-template-columns:1fr!important;gap:6px}" + 3645 /* bigger touch targets for cards */ 3646 ".caf-bk-card{padding:14px 14px 14px 42px;font-size:13px;min-height:48px;-webkit-tap-highlight-color:transparent}" + 3647 ".caf-bk-card-icon{left:12px;top:14px}" + 3648 /* bigger slot chips for fat fingers */ 3649 ".caf-bk-slot-chip{padding:10px 16px;font-size:13px;min-height:44px;-webkit-tap-highlight-color:transparent}" + 3650 ".caf-bk-slot-time{font-size:13.5px}" + 3651 ".caf-bk-slot-period{font-size:10px;padding:3px 8px}" + 3652 /* day slots wrap nicely */ 3653 ".caf-bk-day-slots{gap:8px}" + 3654 ".caf-bk-day-group{margin-bottom:14px}" + 3655 /* bigger form inputs for easy tapping */ 3656 ".caf-bk-inp{padding:13px 38px 13px 38px;font-size:16px;border-radius:14px}" + 3657 ".caf-bk-inp-icon{left:14px}" + 3658 ".caf-bk-inp-icon svg{width:16px;height:16px}" + 3659 ".caf-bk-inp-check{right:14px}" + 3660 ".caf-bk-inp-label{font-size:11px;margin-left:6px}" + 3661 ".caf-bk-fg{gap:10px}" + 3662 /* search input bigger */ 3663 ".caf-bk-search input{padding:12px 14px 12px 38px;font-size:16px;border-radius:14px}" + 3664 ".caf-bk-search-icon{left:14px}" + 3665 /* bigger primary button */ 3666 ".caf-bk-pri{padding:15px;font-size:14px;border-radius:16px;min-height:50px}" + 3667 /* Sticky CTA: tiene il bottone sopra la tastiera + safe-area iOS */ 3668 ".caf-bk-cta-sticky{position:sticky;bottom:8px;bottom:calc(8px + env(safe-area-inset-bottom,0px));z-index:5;backdrop-filter:saturate(1.4)}" + 3669 /* gender selector bigger touch */ 3670 ".caf-bk-rb{padding:8px 16px;font-size:12px;min-height:40px}" + 3671 ".caf-bk-rr{gap:10px;padding:10px 0}" + 3672 /* step dots slightly bigger */ 3673 ".caf-bk-step-dot{width:28px;height:28px;font-size:11px}" + 3674 ".caf-bk-step-lbl{font-size:10px;width:28px}" + 3675 /* crumb with more padding */ 3676 ".caf-bk-crumb{padding:6px 14px;font-size:11px}" + 3677 /* back/cancel bigger tap areas */ 3678 ".caf-bk-back,.caf-bk-cancel{padding:8px 4px;font-size:12px;min-height:36px;display:inline-flex;align-items:center}" + 3679 ".caf-bk-bot{padding-top:10px;margin-top:14px}" + 3680 /* more button bigger */ 3681 ".caf-bk-more{padding:12px;font-size:12px;min-height:44px}" + 3682 /* summary card more spacious */ 3683 ".caf-bk-sum{padding:16px;font-size:12px;border-radius:18px}" + 3684 ".caf-bk-sr{padding:5px 0}" + 3685 /* confirmation buttons */ 3686 ".caf-bk-act,.caf-bk-act2{padding:10px 16px;font-size:12px;min-height:44px}" + 3687 /* error state bigger touch */ 3688 ".caf-bk-acts{gap:8px}" + 3689 /* hint text bigger */ 3690 ".caf-bk-title{font-size:15px}" + 3691 /* progress bar thicker */ 3692 ".caf-bk-progress{height:4px;margin-top:8px}" + 3693 ".caf-bk-step-counter{font-size:11px;margin-top:6px}" + 3694 /* done page spacing */ 3695 ".caf-bk-done-ic{width:64px;height:64px;margin-bottom:14px}" + 3696 ".caf-bk-done-t{font-size:17px}" + 3697 ".caf-bk-done-id{font-size:11px;padding:6px 16px}" + 3698 ".caf-bk-email{padding:12px 16px;font-size:11.5px;border-radius:14px}" + 3699 "}" + 3700 3701 /* ââ extra small screens (< 380px, e.g. iPhone SE) ââ */ 3702 "@media(max-width:380px){" + 3703 ".caf-bk-steps-wrap{margin-bottom:8px}" + 3704 ".caf-bk-step-dot{width:24px;height:24px;font-size:9px}" + 3705 ".caf-bk-step-lbl{font-size:8px;width:24px}" + 3706 ".caf-bk-step-labels{padding:0}" + 3707 ".caf-bk-title{font-size:14px;gap:4px}" + 3708 ".caf-bk-card{padding:12px 12px 12px 38px;font-size:12.5px}" + 3709 ".caf-bk-slot-chip{padding:9px 12px;font-size:12.5px}" + 3710 ".caf-bk-sum{padding:12px}" + 3711 "}" + 3712 "@media(prefers-reduced-motion:reduce){" + 3713 "*,*::before,*::after{animation-duration:0.01ms!important;animation-iteration-count:1!important;transition-duration:0.01ms!important}" + 3714 ".caf-typing span,.caf-vt-dots span{animation:none!important;transform:scale(1)!important;opacity:.6!important}" + 3715 "}" + 3716 3717 /* ââ booking UI âââââââââââââââââââââââââââââââââââ */ 3718 "@keyframes caf-spin{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}" + 3719 "@keyframes caf-confetti{0%{opacity:1;transform:translateY(0) rotate(0)}100%{opacity:0;transform:translateY(-60px) rotate(720deg)}}" + 3720 "@keyframes caf-check-draw{0%{stroke-dashoffset:24}100%{stroke-dashoffset:0}}" + 3721 "@keyframes caf-pulse-soft{0%,100%{opacity:.6}50%{opacity:1}}" + 3722 "@keyframes caf-shimmer-bar{0%{transform:translateX(-100%)}100%{transform:translateX(200%)}}" + 3723 "@keyframes caf-slide-in{0%{opacity:0;transform:translateX(30px)}100%{opacity:1;transform:translateX(0)}}" + 3724 "@keyframes caf-slide-in-back{0%{opacity:0;transform:translateX(-30px)}100%{opacity:1;transform:translateX(0)}}" + 3725 "@keyframes caf-fade-up{0%{opacity:0;transform:translateY(12px)}100%{opacity:1;transform:translateY(0)}}" + 3726 "@keyframes caf-ripple{0%{transform:scale(0);opacity:.4}100%{transform:scale(2.5);opacity:0}
3726}" + 3727 "@keyframes caf-progress-fill{0%{width:0}100%{width:var(--caf-prog,0%)}}" + 3728 ".caf-bk{animation:caf-pop-in .35s ease-out}" + 3729 ".caf-bk-slide-fwd{animation:caf-slide-in .35s ease-out}" + 3730 ".caf-bk-slide-back{animation:caf-slide-in-back .35s ease-out}" + 3731 3732 /* step indicator â checkmarks on completed */ 3733 ".caf-bk-steps-wrap{margin-bottom:12px}" + 3734 ".caf-bk-steps{display:flex;align-items:center;gap:4px;margin-bottom:4px}" + 3735 ".caf-bk-step-dot{width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;transition:all .4s cubic-bezier(.34,1.56,.64,1);flex-shrink:0}" + 3736 ".caf-bk-step-dot.done{color:#fff;transform:scale(1)}" + 3737 ".caf-bk-step-dot.active{border:2.5px solid " + c + ";color:" + c + ";background:" + c + "0a;box-shadow:0 0 0 4px " + c + "12}" + 3738 ".caf-bk-step-dot.todo{border:2px solid #e2e8f0;color:#5b6573;background:#fff}" + 3739 ".caf-bk-step-line{flex:1;height:2px;border-radius:1px;transition:background .4s}" + 3740 ".caf-bk-step-labels{display:flex;justify-content:space-between;padding:0 2px}" + 3741 ".caf-bk-step-lbl{font-size:9px;color:#5b6573;text-align:center;width:26px;flex-shrink:0;transition:color .3s}" + 3742 ".caf-bk-step-lbl.active{color:" + c + ";font-weight:600}" + 3743 ".caf-bk-step-lbl.done{color:" + c + "90}" + 3744 ".caf-bk-progress{width:100%;height:3px;border-radius:2px;background:#e2e8f0;margin-top:6px;overflow:hidden}" + 3745 ".caf-bk-progress-bar{height:100%;border-radius:2px;background:linear-gradient(90deg," + c + "," + c + "cc);transition:width .5s cubic-bezier(.34,1.56,.64,1)}" + 3746 ".caf-bk-step-counter{font-size:10px;color:#5b6573;text-align:right;margin-top:4px}" + 3747 ".caf-bk-title{font-size:14px;font-weight:700;color:#1e293b;margin-bottom:8px;display:flex;align-items:center;gap:6px}" + 3748 3749 /* crumb with icon */ 3750 ".caf-bk-crumb{display:inline-flex;align-items:center;gap:5px;border-radius:99px;background:" + c + "0a;border:1px solid " + c + "20;padding:4px 12px;font-size:10px;color:" + c + ";font-weight:500;margin-bottom:8px}" + 3751 ".caf-bk-crumb svg{flex-shrink:0;opacity:.7}" + 3752 3753 /* cards with icon stripe */ 3754 ".caf-bk-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}" + 3755 ".caf-bk-list{display:flex;flex-direction:column;gap:8px}" + 3756 ".caf-bk-card{position:relative;overflow:hidden;border-radius:14px;border:1.5px solid #e2e8f0;background:#fff;padding:12px 14px 12px 40px;text-align:left;cursor:pointer;transition:all .25s cubic-bezier(.34,1.56,.64,1);font-family:inherit;font-size:12.5px;font-weight:600;color:#334155;-webkit-tap-highlight-color:transparent;touch-action:manipulation}" + 3757 ".caf-bk-card::before{content:'';position:absolute;left:0;top:0;bottom:0;width:4px;background:" + c + "30;border-radius:14px 0 0 14px;transition:width .3s,background .3s}" + 3758 ".caf-bk-card:hover{border-color:" + c + "50;box-shadow:0 6px 24px " + c + "18;transform:translateY(-2px)}" + 3759 ".caf-bk-card:hover::before{width:6px;background:" + c + "}" + 3760 ".caf-bk-card:active{transform:scale(.97)}" + 3761 ".caf-bk-card .caf-ripple{position:absolute;border-radius:50%;background:" + c + "20;animation:caf-ripple .5s ease-out forwards;pointer-events:none}" + 3762 ".caf-bk-card-icon{position:absolute;left:10px;top:12px;width:22px;height:22px;border-radius:7px;background:" + c + "12;color:" + c + ";display:flex;align-items:center;justify-content:center}" + 3763 ".caf-bk-card-icon svg{width:13px;height:13px}" + 3764 ".caf-bk-sub{display:flex;align-items:center;gap:4px;margin-top:3px;font-size:10px;font-weight:400;color:#5b6573}" + 3765 ".caf-bk-sub svg{flex-shrink:0;opacity:.6}" + 3766 3767 /* slot badge */ 3768 ".caf-bk-badge{display:inline-flex;align-items:center;gap:4px;border-radius:99px;padding:3px 8px;font-size:10px;font-weight:600;margin-top:5px}" + 3769 ".caf-bk-badge svg{width:10px;height:10px}" + 3770 3771 /* spinner â shimmer bar + animated text */ 3772 ".caf-bk-spin{display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 0}" + 3773 ".caf-bk-spin-wrap{position:relative;width:48px;height:48px}" + 3774 ".caf-bk-ring{width:48px;height:48px;border-radius:50%;border:3px solid #e2e8f0;border-top-color:" + c + ";animation:caf-spin .7s linear infinite}" + 3775 ".caf-bk-spin-icon{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:18px;animation:caf-pulse-soft 1.5s ease-in-out infinite}" + 3776 ".caf-bk-spin-lbl{font-size:12.5px;color:#475569;font-weight:500;text-align:center;
3776max-width:200px}" + 3777 ".caf-bk-spin-bar{width:120px;height:3px;border-radius:2px;background:#e2e8f0;overflow:hidden;margin-top:2px}" + 3778 ".caf-bk-spin-bar-inner{width:40%;height:100%;border-radius:2px;background:linear-gradient(90deg," + c + "," + c + "80);animation:caf-shimmer-bar 1.2s ease-in-out infinite}" + 3779 3780 /* slot day group headers */ 3781 ".caf-bk-day-group{margin-bottom:10px}" + 3782 ".caf-bk-day-hdr{display:flex;align-items:center;gap:8px;margin-bottom:6px;padding:0 2px}" + 3783 ".caf-bk-day-name{font-size:12px;font-weight:700;color:#334155;text-transform:capitalize}" + 3784 ".caf-bk-day-date{font-size:10px;color:#5b6573;font-weight:500}" + 3785 ".caf-bk-day-line{flex:1;height:1px;background:#e2e8f0}" + 3786 ".caf-bk-day-slots{display:flex;flex-wrap:wrap;gap:6px}" + 3787 ".caf-bk-slot-chip{position:relative;overflow:hidden;display:inline-flex;align-items:center;gap:5px;border-radius:12px;border:1.5px solid #e2e8f0;background:#fff;padding:8px 14px;font-size:12px;font-weight:600;color:#334155;cursor:pointer;transition:all .25s cubic-bezier(.34,1.56,.64,1);font-family:inherit;-webkit-tap-highlight-color:transparent;touch-action:manipulation}" + 3788 ".caf-bk-slot-chip:hover{border-color:" + c + "50;background:" + c + "06;box-shadow:0 4px 16px " + c + "14;transform:translateY(-1px)}" + 3789 ".caf-bk-slot-chip:active{transform:scale(.96)}" + 3790 ".caf-bk-slot-chip .caf-ripple{position:absolute;border-radius:50%;background:" + c + "20;animation:caf-ripple .5s ease-out forwards;pointer-events:none}" + 3791 ".caf-bk-slot-time{font-weight:700;color:#1e293b}" + 3792 ".caf-bk-slot-period{font-size:9px;font-weight:600;border-radius:6px;padding:2px 6px}" + 3793 ".caf-bk-slot-period.matt{background:#eff6ff;color:#2563eb}" + 3794 ".caf-bk-slot-period.pom{background:#fff7ed;color:#c2410c}" + 3795 3796 /* search filter */ 3797 ".caf-bk-search{position:relative;margin-bottom:10px}" + 3798 ".caf-bk-search-icon{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:#5b6573;pointer-events:none;display:flex}" + 3799 ".caf-bk-search-icon svg{width:14px;height:14px}" + 3800 ".caf-bk-search input{width:100%;border:1.5px solid #818c9c;border-radius:12px;padding:9px 14px 9px 36px;font-size:12px;color:#1e293b;font-family:inherit;outline:none;background:#f8fafc;transition:border-color .2s,box-shadow .2s}" + 3801 ".caf-bk-search input:focus{border-color:" + c + ";box-shadow:0 0 0 3px " + c + "15;background:#fff}" + 3802 ".caf-bk-search input::placeholder{color:#5b6573}" + 3803 ".caf-bk-no-results{text-align:center;padding:16px;font-size:12px;color:#5b6573;animation:caf-fade-up .3s ease-out}" + 3804 3805 /* form inputs with icon */ 3806 ".caf-bk-fg{display:flex;flex-direction:column;gap:8px}" + 3807 ".caf-bk-inp-wrap{position:relative}" + 3808 ".caf-bk-inp-icon{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:#5b6573;pointer-events:none;display:flex;transition:color .2s}" + 3809 ".caf-bk-inp-icon svg{width:14px;height:14px}" + 3810 ".caf-bk-inp-valid .caf-bk-inp-icon{color:#047857}" + 3811 ".caf-bk-inp-check{position:absolute;right:12px;top:50%;transform:translateY(-50%) scale(0);color:#047857;display:flex;transition:transform .3s cubic-bezier(.34,1.56,.64,1)}" + 3812 ".caf-bk-inp-check svg{width:14px;height:14px}" + 3813 ".caf-bk-inp-valid .caf-bk-inp-check{transform:translateY(-50%) scale(1)}" + 3814 ".caf-bk-inp{width:100%;border:1.5px solid #818c9c;border-radius:12px;padding:10px 36px 10px 36px;font-size:12.5px;color:#1e293b;font-family:inherit;outline:none;background:#f8fafc;transition:border-color .25s,box-shadow .25s,background .25s}" + 3815 ".caf-bk-inp:focus{border-color:" + c + ";box-shadow:0 0 0 3px " + c + "15;background:#fff}" + 3816 ".caf-bk-inp::placeholder{color:#5b6573}" + 3817 ".caf-bk-inp-valid .caf-bk-inp{border-color:#10b981;background:#f0fdf4}" + 3818 ".caf-bk-inp-valid .caf-bk-inp:focus{border-color:" + c + ";box-shadow:0 0 0 3px " + c + "15;background:#fff}" + 3819 ".caf-bk-inp-label{font-size:10px;font-weight:600;color:#5b6573;margin-bottom:-4px;margin-left:4px}" + 3820 ".caf-bk-rr{display:flex;align-items:center;gap:8px;padding:8px 0}" + 3821 ".caf-bk-rl{font-size:11px;color:#5b6573;margin-right:4px}" + 3822 ".caf-bk-rb{display:inline-flex;align-items:center;gap:4px;border-radius:99px;padding:5px 12px;font-size:11px;font-weight:500;cursor:pointer;transition:all .2s;border:none}" + 3823 ".caf-bk-rb.on{color:#fff}" + 3824 ".caf-bk-rb.off{background:#f1f5f9;color:#5b6573}" + 3825 ".caf-bk-rb.off:hover{background:#e2e8f0}" + 3826 ".caf-bk-ferr{display:flex;align-items:center;gap:6px;border-radius:10px;background:#fff1f2;padding:8px 12px;font-size:11px;color:#be123c;margin-top:6px;animation:caf-pop-in .2s ease-out}" +
3827 ".caf-bk-pri{width:100%;display:flex;align-items:center;justify-content:center;gap:6px;border-radius:14px;border:none;padding:13px;font-size:13px;font-weight:700;color:#fff;cursor:pointer;transition:all .25s;font-family:inherit;margin-top:10px;letter-spacing:.2px;-webkit-tap-highlight-color:transparent;touch-action:manipulation}" + 3828 ".caf-bk-pri:hover{transform:translateY(-2px);filter:brightness(1.08);box-shadow:0 8px 24px " + c + "40}" + 3829 ".caf-bk-pri:active{transform:scale(.96);filter:brightness(.95)}" + 3830 ".caf-bk-pri:disabled{opacity:.5;cursor:not-allowed;transform:none;filter:none;box-shadow:none}" + 3831 ".caf-bk-sum{border-radius:16px;border:1px solid " + c + "20;background:linear-gradient(135deg," + c + "06," + c + "02,#fff);padding:14px;font-size:11.5px;color:#475569;backdrop-filter:blur(8px)}" + 3832 ".caf-bk-sr{display:flex;align-items:flex-start;gap:8px;padding:4px 0}" + 3833 ".caf-bk-si{flex-shrink:0;color:" + c + ";margin-top:1px}" + 3834 ".caf-bk-sv{font-weight:600;color:#334155}" + 3835 ".caf-bk-ss{font-size:10px;color:#5b6573;display:block}" + 3836 ".caf-bk-sum hr{border:none;border-top:1px solid " + c + "15;margin:6px 0}" + 3837 ".caf-bk-done-ic{position:relative;display:flex;align-items:center;justify-content:center;width:56px;height:56px;border-radius:50%;margin:0 auto 10px;color:#fff}" + 3838 ".caf-bk-done-ic svg{animation:caf-check-draw .6s ease-out .3s both}" + 3839 ".caf-bk-done-ic svg path{stroke-dasharray:24;stroke-dashoffset:24}" + 3840 ".caf-bk-confetti{position:absolute;width:6px;height:6px;border-radius:2px;animation:caf-confetti 1s ease-out forwards}" + 3841 ".caf-bk-done-t{font-size:15px;font-weight:700;color:#1e293b;text-align:center}" + 3842 ".caf-bk-done-id{display:inline-flex;align-items:center;gap:4px;border-radius:99px;padding:4px 14px;font-size:10px;font-weight:600;margin:8px auto;text-align:center}" + 3843 ".caf-bk-email{display:flex;align-items:center;gap:8px;border-radius:12px;background:linear-gradient(135deg,#eff6ff,#f0fdf4);border:1px solid #bfdbfe;padding:10px 14px;font-size:10.5px;color:#1d4ed8;margin-top:10px}" + 3844 ".caf-bk-ew{display:flex;align-items:flex-start;gap:10px;margin-bottom:10px}" + 3845 ".caf-bk-ei{flex-shrink:0;width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center}" + 3846 ".caf-bk-et{font-size:13px;font-weight:700}" + 3847 ".caf-bk-em{font-size:11px;color:#5b6573;margin-top:3px;line-height:1.5}" + 3848 ".caf-bk-acts{display:flex;flex-wrap:wrap;gap:6px}" + 3849 ".caf-bk-act{display:inline-flex;align-items:center;gap:4px;border-radius:12px;border:1px solid " + c + "25;background:" + c + "08;padding:8px 14px;font-size:11px;font-weight:600;color:" + c + ";cursor:pointer;transition:all .25s;font-family:inherit}" + 3850 ".caf-bk-act:hover{transform:translateY(-1px);box-shadow:0 4px 12px " + c + "18}" + 3851 ".caf-bk-act2{display:inline-flex;align-items:center;gap:4px;border-radius:12px;border:1px solid #e2e8f0;background:transparent;padding:8px 14px;font-size:11px;color:#5b6573;cursor:pointer;transition:all .2s;font-family:inherit}" + 3852 ".caf-bk-act2:hover{border-color:#cbd5e1;color:#5b6573}" + 3853 ".caf-bk-bot{display:flex;align-items:center;justify-content:space-between;padding-top:8px;border-top:1px solid #f1f5f9;margin-top:10px}" + 3854 ".caf-bk-back{display:inline-flex;align-items:center;gap:3px;font-size:11px;color:#5b6573;cursor:pointer;border:none;background:none;font-family:inherit;padding:0;transition:color .2s}" + 3855 ".caf-bk-back:hover{color:var(--caf-c-ink)}" + 3856 ".caf-bk-cancel{display:inline-flex;align-items:center;gap:3px;font-size:11px;color:#5b6573;cursor:pointer;border:none;background:none;font-family:inherit;padding:0;transition:color .2s}" + 3857 ".caf-bk-cancel:hover{color:#be123c}" + 3858 ".caf-bk-more{width:100%;display:flex;align-items:center;justify-content:center;gap:6px;border-radius:12px;border:1px dashed " + c + "40;background:" + c + "06;padding:8px;font-size:11px;font-weight:600;color:" + c + ";cursor:pointer;transition:all .25s;font-family:inherit;margin-top:6px}" + 3859 ".caf-bk-more:hover{border-color:" + c + "80;background:" + c + "12}" + 3860 3861 /* âââ persistent booking CTA (above input) âââ */ 3862 "@keyframes caf-pulse-ring{0%{opacity:.8;transform:scale(1)}100%{opacity:0;transform:scale(1.55)}}" + 3863 "@keyframes caf-cta-slide{0%{opacity:0;transform:translateY(8px)}100%{opacity:1;transform:translateY(0)}}" + 3864 "@keyframes caf-cta-shimmer{0%{transform:translateX(-120%)}60%,100%{transform:translateX(220%)}}" + 3865 "@keyframes caf-dot-pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.5;transform:scale(1.25)}}" + 3866 "@keyframes caf-aurora{0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}" + 3867 ".caf-bk-cta{position:relative;display:flex;align-items:center;gap:11px;padding:10px 12px 10px 14px;margin:0 14px 8px;border-radius:14px;background:linear-gradient(135deg," + c + "14," + c + "04 60%,#fff);border:1px solid " + c + "35;box-shadow:0 2px 10px " + c + "18;animation:caf-cta-slide .4s ease-out;cursor:pointer;transition:all .3s cubic-bezier(.34,1.56,.64,1);font-family:inherit;flex-shrink:0;overflow:hidden;transform-style:preserve-3d;perspective:900px}" + 3868 ".caf-bk-cta::after{content:'';position:absolute;top:0;bottom:0;width:40%;background:linear-gradient(100deg,transparent 0%,rgba(255,255,255,.6) 50%,transparent 100%);transform:translateX(-120%);animation:caf-cta-shimmer 7s ease-in-out infinite;animation-delay:2s;pointer-events:none}" + 3869 ".caf-bk-cta:hover{transform:translateY(-2px) rotateX(2deg);box-shadow:0 12px 30px " + c + "38;border-color:" + c + "70}" + 3870 ".caf-bk-cta:active{transform:scale(.99)}" + 3871 ".caf-bk-cta-ic{width:36px;height:36px;border-radius:11px;background:linear-gradient(135deg," + c + "," + cGradEnd + ");color:var(--caf-on-c);display:flex;align-items:center;justify-content:center;flex-shrink:0;position:relative;box-shadow:0 4px 14px " + c + "44;transition:transform .4s cubic-bezier(.34,1.56,.64,1)}" + 3872 ".caf-bk-cta:hover .caf-bk-cta-ic{transform:rotate(-8deg)
3872scale(1.08)}" + 3873 ".caf-bk-cta-ic::before{content:'';position:absolute;inset:-2px;border-radius:13px;border:2px solid " + c + ";animation:caf-pulse-ring 2s ease-out infinite;pointer-events:none}" + 3874 ".caf-bk-cta-ic svg{width:17px;height:17px;position:relative;z-index:1;transition:transform .35s}" + 3875 ".caf-bk-cta-txt{flex:1;min-width:0;position:relative;z-index:1}" + 3876 ".caf-bk-cta-t{font-size:12.5px;font-weight:700;color:#0f172a;line-height:1.2;letter-spacing:-.1px;display:flex;align-items:center;gap:6px}" + 3877 ".caf-bk-cta-s{font-size:10.5px;color:#5b6573;margin-top:2px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}" + 3878 ".caf-bk-cta-badge{display:inline-flex;align-items:center;gap:4px;font-size:9.5px;font-weight:700;color:#047857;background:#d1fae5;border-radius:99px;padding:2px 7px;letter-spacing:.2px;text-transform:uppercase;flex-shrink:0}" + 3879 ".caf-bk-cta-badge .d{width:6px;height:6px;border-radius:50%;background:#10b981;animation:caf-dot-pulse 1.4s ease-in-out infinite}" + 3880 ".caf-bk-cta-btn{position:relative;z-index:1;flex-shrink:0;border:none;background:linear-gradient(135deg," + c + "," + cGradEnd + ");color:#fff;border-radius:99px;padding:8px 14px;font-size:11px;font-weight:700;cursor:pointer;font-family:inherit;box-shadow:0 4px 12px " + c + "44;transition:all .2s;display:inline-flex;align-items:center;gap:4px;letter-spacing:.2px;white-space:nowrap;-webkit-tap-highlight-color:transparent}" + 3881 ".caf-bk-cta-btn:hover{transform:scale(1.05);box-shadow:0 6px 16px " + c + "55;filter:brightness(1.08)}" + 3882 ".caf-bk-cta-btn:active{transform:scale(.96)}" + 3883 ".caf-bk-cta-btn svg{width:11px;height:11px}" + 3884 ".caf-bk-cta-dismiss{position:absolute;top:4px;right:4px;width:20px;height:20px;border-radius:50%;border:none;background:transparent;color:#5b6573;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;transition:all .2s;font-family:inherit}" + 3885 ".caf-bk-cta-dismiss:hover{background:rgba(0,0,0,.05);color:#475569}" + 3886 ".caf-bk-cta-dismiss svg{width:12px;height:12px}" + 3887 /* proactive enlarged state */ 3888 ".caf-bk-cta.proactive{padding:14px 14px 14px 16px;gap:14px;background:linear-gradient(135deg," + c + "22," + c + "08 55%,#fff);border-color:" + c + "55;box-shadow:0 8px 28px " + c + "28}" + 3889 ".caf-bk-cta.proactive .caf-bk-cta-ic{width:42px;height:42px;border-radius:12px}" + 3890 ".caf-bk-cta.proactive .caf-bk-cta-ic svg{width:20px;height:20px}" + 3891 ".caf-bk-cta.proactive .caf-bk-cta-t{font-size:13.5px}" + 3892 ".caf-bk-cta.proactive .caf-bk-cta-s{font-size:11px;white-space:normal;line-height:1.4}" + 3893 /* mini pill (dismissed) */ 3894 ".caf-bk-cta-mini{position:absolute;right:14px;bottom:88px;z-index:6;border:none;background:linear-gradient(135deg," + c + "," + cGradEnd + ");color:#fff;border-radius:99px;padding:10px 14px 10px 10px;font-size:11.5px;font-weight:700;font-family:inherit;cursor:pointer;display:inline-flex;align-items:center;gap:6px;box-shadow:0 8px 22px " + c + "55;transition:all .25s;animation:caf-cta-slide .35s ease-out;-webkit-tap-highlight-color:transparent}" + 3895 ".caf-bk-cta-mini:hover{transform:translateY(-2px);box-shadow:0 12px 28px " + c + "66}" + 3896 ".caf-bk-cta-mini:active{transform:scale(.96)}" + 3897 ".caf-bk-cta-mini-ic{width:22px;height:22px;border-radius:50%;background:rgba(255,255,255,.25);display:flex;align-items:center;justify-content:center}" + 3898 ".caf-bk-cta-mini-ic svg{width:12px;height:12px}" + 3899 "@media(max-width:380px){.caf-bk-cta-s{display:none}.caf-bk-cta{gap:8px;padding:9px 10px}.caf-bk-cta-btn{padding:7px 11px;font-size:10.5px}.caf-bk-cta.proactive{padding:12px}}" + 3900 3901 /* ââ ghost suggestion chips (idle input hints) âââ */ 3902 "@keyframes caf-ghost-up{0%{opacity:0;transform:translateY(6px)}100%{opacity:1;transform:translateY(0)}}" + 3903 ".caf-ghost-sug{display:flex;gap:6px;padding:0 14px 6px;flex-wrap:wrap;animation:caf-ghost-up .35s ease-out;flex-shrink:0}" + 3904 ".caf-ghost-sug-chip{border:1px dashed " + c + "45;background:" + c + "06;color:" + c + ";border-radius:99px;padding:5px 11px;font-size:10.5px;font-weight:600;cursor:pointer;font-family:inherit;transition:all .2s;-webkit-tap-highlight-color:transparent;display:inline-flex;align-items:center;gap:4px}" + 3905 ".caf-ghost-sug-chip:hover{background:" + c + "14;border-style:solid;transform:translateY(-1px)}" + 3906 ".caf-ghost-sug-chip svg{width:10px;height:10px}" + 3907 3908 /* ââ inline message chip (prenota su singolo servizio citato) ââ */ 3909 ".caf-inline-bk-chip{display:inline-flex;align-items:center;gap:5px;margin-top:8px;border:1px solid " + c + "40;background:linear-gradient(135deg," + c + "12," + c + "04);color:" + c + ";border-radius:99px;padding:6px 12px;font-size:11px;font-weight:700;cursor:pointer;font-family:inherit;transition:all .2s;-webkit-tap-highlight-color:transparent}" + 3910 ".caf-inline-bk-chip:hover{background:" + c + "20;transform:translateY(-1px);box-shadow:0 4px 12px " + c + "30}" + 3911 ".caf-inline-bk-chip svg{width:11px;height:11px}" + 3912 3913 /* âââ prominent expand BANNER (outside conversation, under header) âââ */ 3914 "@keyframes caf-banner-slide{0%{opacity:0;transform:translateY(-14px)}100%{opacity:1;transform:translateY(0)}}" + 3915 ".caf-bk-expand-banner{display:none;align-items:center;gap:12px;width:100%;padding:11px 14px;background:linear-gradient(135deg," + c + "," + cGradEnd + ");color:#fff;border:none;cursor:pointer;font-family:inherit;flex-shrink:0;text-align:left;box-shadow:0 4px 14px " + c + "40;transition:filter .2s,box-shadow .2s;position:relative;overflow:hidden;animation:caf-banner-slide .4s cubic-bezier(.34,1.56,.64,1);z-index:4;-webkit-tap-highlight-color:transparent}" + 3916 ".caf-bk-expand-banner::after{content:'';position:absolute;top:0;bottom:0;width:40%;background:linear-gradient(100deg,transparent 0%,rgba(255,255,255,.35) 50%,transparent 100%);transform:translateX(-120%);animation:caf-cta-shimmer 5s ease-in-out infinite;animation-delay:1.5s;pointer-events:none}" + 3917 ".caf-bk-expand-banner.active{display:flex}" + 3918 ".caf-bk-expand-banner:hover{filter:brightness(1.08);box-shadow:0 8px 22px " + c + "55}" + 3919 ".caf-bk-expand-banner:active{transform:scale(.995)}" + 3920 ".caf-bk-expand-banner-ic{width:32px;height:32px;border-radius:10px;background:rgba(255,255,255,.22);backdrop-filter:blur(8px);display:flex;align-items:center;justify-content:center;flex-shrink:0;position:relative;z-index:1}" + 3921 ".caf-bk-expand-banner-ic svg{width:16px;height:16px}" + 3922 ".caf-bk-expand-banner-txt{flex:1;min-width:0;position:relative;z-index:1}" + 3923 ".caf-bk-expand-banner-t{font-size:13px;font-weight:700;letter-spacing:-.1px;line-height:1.2;display:flex;align-items:center;gap:6px}" + 3924 ".caf-bk-expand-banner-t .live-dot{width:7px;height:7px;border-radius:50%;background:#fff;animation:caf-dot-pulse 1.4s ease-in-out infinite;flex-shrink:0}" + 3925 ".caf-bk-expand-banner-s{font-size:10.5px;opacity:.9;margin-top:2px;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}" + 3926 ".caf-bk-expand-banner-arrow{flex-shrink:0;display:flex;align-items:center;gap:4px;font-size:11px;font-weight:700;padding:6px 10px;background:rgba(255,255,255,.2);border-radius:99px;transition:all .25s;position:relative;z-index:1}" + 3927 ".caf-bk-expand-banner:hover .caf-bk-expand-banner-arrow{background:rgba(255,255,255,.32);transform:translateX(3px)}" + 3928 ".caf-bk-expand-banner-arrow svg{width:11px;height:11px}" + 3929 "@media(max-width:380px){.caf-bk-expand-banner-s{display:none}.caf-bk-expand-banner{padding:10px 12px;gap:10px}.caf-bk-expand-banner-arrow{padding:5px 8px;font-size:10.5px}}" + 3930 3931 /* (legacy inline small button â kept hidden for backward compat) */ 3932 ".caf-bk{position:relative}" + 3933 ".caf-bk-expand{display:none}" + 3934 3935 /* âââ FULLSCREEN BOOKING OVERLAY âââ */ 3936 ".caf-bk-fs{position:absolute;inset:0;background:linear-gradient(180deg,#f8fafc 0%,#eef2f6 100%);display:flex;flex-direction:column;z-index:60;animation:caf-pop-in .35s ease-out;overflow:hidden;font-family:inherit;-webkit-overflow-scrolling:touch}" + 3937 ".caf-panel.open .caf-bk-fs{border-radius:inherit}" + 3938 "@media(max-width:640px){.caf-bk-fs{border-radius:0}}" + 3939 3940 /* hero with animated aurora gradient */ 3941 ".caf-bk-fs-hero{position:relative;padding:18px 20px 16px;background:linear-gradient(135deg," + c + " 0%," + c + "cc 30%," + c + "d9 55%," + c + "99 80%," + c + " 100%);background-size:250% 250%;animation:caf-aurora 14s ease infinite;color:#fff;overflow:hidden;flex-shrink:0}" + 3942 "@media(max-width:640px){.caf-bk-fs-hero{padding:calc(env(safe-area-inset-top,0px) + 14px) 16px 14px}}" + 3943 ".caf-bk-fs-hero::before{content:'';position:absolute;top:-50%;right:-10%;width:300px;height:300px;background:radial-gradient(circle,rgba(255,255,255,.22),transparent 65%);pointer-events:none}" + 3944 ".caf-bk-fs-hero::after{content:'';position:absolute;bottom:-80%;left:-15%;width:340px;height:340px;background:radial-gradient(circle,rgba(255,255,255,.10),transparent 65%);pointer-events:none}" + 3945 ".caf-bk-fs-hrow{display:flex;align-items:center;gap:12px;position:relative;z-index:1}" + 3946 ".caf-bk-fs-hlogo{width:40px;height:40px;border-radius:12px;background:rgba(255,255,255,.2);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1px solid rgba(255,255,255,.25)}" + 3947 ".caf-bk-fs-hlogo svg{width:20px;height:20px}" + 3948 ".caf-bk-fs-htxt{flex:1;min-width:0}" + 3949 ".caf-bk-fs-htitle{font-size:17px;font-weight:800;letter-spacing:-.3px;line-height:1.2}" + 3950 ".caf-bk-fs-hsub{font-size:11.5px;opacity:.88;margin-top:3px;font-weight:500}" + 3951 ".caf-bk-fs-hclose{border:none;background:rgba(255,255,255,.18);color:#fff;width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all .2s;font-family:inherit;flex-shrink:0}" + 3952 ".caf-bk-fs-hclose:hover{background:rgba(255,255,255,.32);transform:rotate(90deg)}" + 3953 ".caf-bk-fs-hclose svg{width:16px;height:16px}" + 3954 3955 /* horizontal step rail inside hero */ 3956 ".caf-bk-fs-rail{margin-top:14px;position:relative;z-index:1}" + 3957 ".caf-bk-fs-rail-track{display:flex;align-items:center;gap:4px;margin-bottom:5px}" + 3958 ".caf-bk-fs-rail-dot{width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;flex-shrink:0;transition:all .4s cubic-bezier(.34,1.56,.64,1);background:rgba(255,255,255,.18);color:rgba(255,255,255,.65);border:2px solid transparent}" + 3959 ".caf-bk-fs-rail-dot.done{background:#fff;color:" + c + "}" + 3960 ".caf-bk-fs-rail-dot.active{background:#fff;color:" + c + ";box-shadow:0 0 0 5px rgba(255,255,255,.25);transform:scale(1.08)}" + 3961 ".caf-bk-fs-rail-line{flex:1;height:2px;border-radius:1px;background:rgba(255,255,255,.22);transition:background .35s}" + 3962 ".caf-bk-fs-rail-line.done{background:#fff}" + 3963 ".caf-bk-fs-rail-labels{display:flex;justify-content:space-between;gap:4px;font-size:9.5px;color:rgba(255,255,255,.8);font-weight:600;letter-spacing:.2px}" + 3964 ".caf-bk-fs-rail-lbl{text-align:center;flex:1;min-width:0;text-transform:uppercase}" + 3965 ".caf-bk-fs-rail-lbl.active{color:#fff}" + 3966 "@media(max-width:480px){.caf-bk-fs-hlogo{width:34px;height:34px;border-radius:10px}.caf-bk-fs-hlogo svg{width:17px;height:17px}.caf-bk-fs-htitle{font-size:15px}.caf-bk-fs-hsub{font-size:10.5px}.caf-bk-fs-rail-dot{width:22px;height:22px;font-size:10px}.caf-bk-fs-rail-labels{font-size:8.5px}}" + 3967 3968 /* continuous progress bar (Apple-style) under rail */ 3969 ".caf-bk-fs-progress-wrap{position:relative;z-index:1;height:4px;background:rgba(255,255,255,.2);border-radius:99px;margin-top:10px;overflow:hidden}" + 3970 ".caf-bk-fs-progress-bar{height:100%;border-radius:99px;background:linear-gradient(90deg,#fff,#fff,rgba(255,255,255,.85));box-shadow:0 0 10px rgba(255,255,255,.6);transition:width .6s cubic-bezier(.34,1.56,.64,1);position:relative}" + 3971 ".caf-bk-fs-progress-bar::after{content:'';position:absolute;right:0;top:50%;width:8px;height:8px;border-radius:50%;background:#fff;box-shadow:0 0 12px rgba(255,255,255,.9);transform:translate(50%,-50%)}" + 3972 3973 /* calendar month picker (slot step) */ 3974 ".caf-bk-fs-monthcal{background:#fff;border:1px solid #e2e8f0;border-radius:18px;padding:16px;box-shadow:0 2px 14px rgba(15,23,42,.05);max-width:480px;animation:caf-fade-up .4s ease-out}" + 3975 ".caf-bk-fs-monthcal-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}" + 3976 ".caf-bk-fs-monthcal-title{font-size:15px;font-weight:800;color:#0f172a;text-transform:capitalize;letter-spacing:-.2px}" + 3977 ".caf-bk-fs-monthcal-nav{display:flex;gap:6px}" + 3978 ".caf-bk-fs-monthcal-nav button{width:30px;height:30px;border-radius:8px;border:1px solid #e2e8f0;background:#fff;color:#475569;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s;font-family:inherit}" + 3979 ".caf-bk-fs-monthcal-nav button:hover:not(:disabled){border-color:" + c + ";color:" + c + ";background:" + c + "08}" + 3980 ".caf-bk-fs-monthcal-nav button:disabled{opacity:.4;cursor:not-allowed}" + 3981 ".caf-bk-fs-monthcal-nav button svg{width:14px;
3981height:14px}" + 3982 ".caf-bk-fs-monthcal-week{display:grid;grid-template-columns:repeat(7,1fr);gap:4px;margin-bottom:6px}" + 3983 ".caf-bk-fs-monthcal-wd{font-size:10px;font-weight:700;color:#5b6573;text-align:center;text-transform:uppercase;letter-spacing:.5px;padding:4px 0}" + 3984 ".caf-bk-fs-monthcal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:4px}" + 3985 ".caf-bk-fs-monthcal-day{position:relative;aspect-ratio:1;border:1px solid transparent;background:transparent;border-radius:10px;font-size:12.5px;font-weight:600;color:#334155;cursor:pointer;font-family:inherit;transition:all .2s;-webkit-tap-highlight-color:transparent;display:flex;align-items:center;justify-content:center}" + 3986 ".caf-bk-fs-monthcal-day.empty{cursor:default}" + 3987 ".caf-bk-fs-monthcal-day.past{color:#5b6573;cursor:not-allowed}" + 3988 ".caf-bk-fs-monthcal-day.available{color:" + c + ";background:" + c + "0c;border-color:" + c + "25;font-weight:700}" + 3989 ".caf-bk-fs-monthcal-day.available:hover{background:" + c + ";color:#fff;transform:scale(1.08);box-shadow:0 6px 16px " + c + "40}" + 3990 ".caf-bk-fs-monthcal-day.selected{background:" + c + ";color:#fff;border-color:" + c + ";box-shadow:0 6px 16px " + c + "50}" + 3991 ".caf-bk-fs-monthcal-day.today{font-weight:800}" + 3992 ".caf-bk-fs-monthcal-day.today::after{content:'';position:absolute;bottom:3px;left:50%;transform:translateX(-50%);width:3px;height:3px;border-radius:50%;background:currentColor}" + 3993 ".caf-bk-fs-monthcal-day.available .dot{position:absolute;top:3px;right:3px;width:5px;height:5px;border-radius:50%;background:#10b981}" + 3994 ".caf-bk-fs-monthcal-legend{display:flex;gap:12px;align-items:center;margin-top:12px;padding-top:10px;border-top:1px solid #eef2f6;font-size:10.5px;color:#5b6573}" + 3995 ".caf-bk-fs-monthcal-legend .lg{display:inline-flex;align-items:center;gap:5px}" + 3996 ".caf-bk-fs-monthcal-legend .lg .sq{width:12px;height:12px;border-radius:4px;border:1px solid " + c + "25;background:" + c + "0c}" + 3997 ".caf-bk-fs-monthcal-legend .lg .sq.sel{background:" + c + ";border-color:" + c + "}" + 3998 3999 /* time slots panel (beside or below calendar) */ 4000 ".caf-bk-fs-slotpanel{background:#fff;border:1px solid #e2e8f0;border-radius:18px;padding:16px;box-shadow:0 2px 14px rgba(15,23,42,.05);animation:caf-fade-up .4s ease-out}" + 4001 ".caf-bk-fs-slotpanel-hdr{font-size:13px;font-weight:700;color:#0f172a;margin-bottom:10px;display:flex;align-items:center;gap:6px}" + 4002 ".caf-bk-fs-slotpanel-hdr svg{width:14px;height:14px;color:" + c + "}" + 4003 ".caf-bk-fs-slotpanel-empty{padding:20px;text-align:center;color:#5b6573;font-size:12.5px}" + 4004 ".caf-bk-fs-slotlayout{display:grid;grid-template-columns:1fr;gap:12px}" + 4005 "@media(min-width:720px){.caf-bk-fs-slotlayout{grid-template-columns:minmax(300px,360px) 1fr;gap:16px}}" + 4006 /* Mobile (â¤640): tap target â¥44px, calendario più ariato, chip slot più 4007 * grandi e leggibili, header del giorno selezionato come banner colorato 4008 * sticky così l'utente sa sempre quale data sta guardando. */ 4009 "@media(max-width:640px){" + 4010 ".caf-bk-fs-monthcal{padding:14px;border-radius:18px;box-shadow:0 4px 18px rgba(15,23,42,.06)}" + 4011 ".caf-bk-fs-monthcal-title{font-size:15px;font-weight:800}" + 4012 ".caf-bk-fs-monthcal-hdr{margin-bottom:14px}" + 4013 ".caf-bk-fs-monthcal-week{gap:2px;margin-bottom:4px}" + 4014 ".caf-bk-fs-monthcal-grid{gap:4px}" + 4015 ".caf-bk-fs-monthcal-day{font-size:14px;min-height:44px;border-radius:11px;font-weight:700}" + 4016 ".caf-bk-fs-monthcal-day.available{background:linear-gradient(135deg," + c + "0e," + c + "05);box-shadow:inset 0 0 0 1px " + c + "30}" + 4017 ".caf-bk-fs-monthcal-day.available:active{transform:scale(.94)}" + 4018 ".caf-bk-fs-monthcal-day.available .dot{top:5px;right:5px;width:6px;height:6px;box-shadow:0 0 0 2px #fff}" + 4019 ".caf-bk-fs-monthcal-day.selected{background:linear-gradient(135deg," + c + "," + c + "dd);color:#fff;box-shadow:0 8px 22px " + c + "55,inset 0 0 0 1px " + c + ";transform:scale(1.05)}" + 4020 ".caf-bk-fs-monthcal-day.today::after{width:4px;height:4px;bottom:5px}" + 4021 ".caf-bk-fs-monthcal-nav button{width:38px;height:38px;border-radius:10px}" + 4022 ".caf-bk-fs-monthcal-nav button svg{width:16px;height:16px}" + 4023 ".caf-bk-fs-monthcal-legend{display:none}" + /* superflua su mobile */ 4024 4025 /* SLOT PANEL â hero header colorato che fa da "banner del giorno" */ 4026 ".caf-bk-fs-slotpanel{padding:0;border-radius:18px;overflow:hidden;border:1px solid #e2e8f0;box-shadow:0 4px 18px rgba(15,23,42,.06)}" + 4027 ".caf-bk-fs-slotpanel-hdr{position:sticky;top:0;z-index:2;padding:14px 16px;margin:0;background:linear-gradient(135deg," + c + "f0," + c + "d0);color:#fff;font-size:15px;font-weight:800;border-bottom:none;letter-spacing:-.2px;display:flex;align-items:center;gap:10px;text-transform:capitalize}" + 4028 ".caf-bk-fs-slotpanel-hdr svg{width:18px;height:18px;color:#fff;opacity:.95;flex-shrink:0}" + 4029 ".caf-bk-fs-slotpanel-hdr > span:first-of-type{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}" + 4030 ".caf-bk-fs-slotpanel > .caf-bk-fs-slotgroup{padding:0 16px}" + 4031 ".caf-bk-fs-slotpanel > .caf-bk-fs-slotgroup:first-of-type{padding-top:14px}" + 4032 ".caf-bk-fs-slotpanel > .caf-bk-fs-slotgroup:last-child{padding-bottom:16px}" + 4033 ".caf-bk-fs-slotpanel-empty{padding:30px 20px}" + 4034 4035 /* SLOTGROUP HEADER â pill compatta invece di solo testo grigio */ 4036 ".caf-bk-fs-slotgroup-hdr{display:inline-flex;align-items:center;gap:5px;padding:5px 10px;background:#f1f5f9;border-radius:99px;font-size:10.5px;font-weight:800;color:#475569;margin-bottom:10px}" + 4037 4038 /* CHIP ORARI â più grandi, più premibili, più tridimensionali */ 4039 ".caf-bk-fs-cal-slots{grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:9px}" + 4040 ".caf-bk-fs-cal-chip{padding:14px 6px;font-size:14.5px;min-height:50px;border-radius:13px;background:#fff;box-shadow:0 1px 2px rgba(15,23,42,.04),0 0 0 1px #e2e8f0;border:none;font-weight:700}" + 4041 ".caf-bk-fs-cal-chip:active{transform:scale(.94);background:" + c + "08;box-shadow:0 1px 2px rgba(15,23,42,.04),0 0 0 1.5px " + c + "}" + 4042 ".caf-bk-fs-cal-chip .d{width:7px;height:7px}" + 4043 "}" + 4044 /* Phone piccolo (â¤380): griglia 3-col fissa per evitare chip troppo strette. */ 4045 "@media(max-width:380px){" + 4046 ".caf-bk-fs-monthcal{padding:12px}" + 4047 ".caf-bk-fs-monthcal-day{font-size:13.5px;min-height:42px}" + 4048 ".caf-bk-fs-cal-slots{grid-template-columns:repeat(3,1fr);gap:8px}" + 4049 ".caf-bk-fs-cal-chip{padding:13px 4px;font-size:14px;min-height:48px}" + 4050 "}" + 4051 ".caf-bk-fs-slotgroup{margin-bottom:12px}" + 4052 ".caf-bk-fs-slotgroup:last-child{margin-bottom:0}" + 4053 ".caf-bk-fs-slotgroup-hdr{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#5b6573;margin-bottom:6px;display:flex;align-items:center;gap:4px}" + 4054 4055 /* "trova sede più vicina" button + distance badge */ 4056 ".caf-bk-nearby{display:flex;flex-direction:column;gap:10px;margin-bottom:14px;animation:caf-fade-up .3s ease-out}" + 4057 ".caf-bk-nearby-btn{display:inline-flex;align-items:center;gap:8px;border:1.5px solid " + c + ";background:linear-gradient(135deg," + c + "10," + c + "04);color:" + c + ";border-radius:14px;padding:12px 16px;font-size:13px;font-weight:700;cursor:pointer;font-family:inherit;transition:all .2s;-webkit-tap-highlight-color:transparent;align-self:flex-start;position:relative;overflow:hidden;max-width:100%}" + 4058 ".caf-bk-nearby-btn:hover{background:" + c + ";color:#fff;transform:translateY(-1px);box-shadow:0 6px 18px " + c + "44}" + 4059 ".caf-bk-nearby-btn:active{transform:scale(.98)}" + 4060 ".caf-bk-nearby-btn:disabled{opacity:.6;cursor:not-allowed;transform:none}" + 4061 ".caf-bk-nearby-btn svg{width:15px;height:15px;flex-shrink:0}" + 4062 ".caf-bk-nearby-btn .spin{width:14px;
4062height:14px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:caf-spin .7s linear infinite;flex-shrink:0}" + 4063 ".caf-bk-nearby-status{font-size:11.5px;color:#5b6573;display:flex;align-items:center;gap:6px;line-height:1.4;animation:caf-fade-up .25s ease-out}" + 4064 ".caf-bk-nearby-status.ok{color:#047857}" + 4065 ".caf-bk-nearby-status.err{color:#b91c1c}" + 4066 ".caf-bk-nearby-status svg{width:13px;height:13px;flex-shrink:0}" + 4067 /* INLINE prominent "use my location" button (filled gradient + pulse) */ 4068 "@keyframes caf-locate-pulse{0%,100%{box-shadow:0 4px 16px " + c + "44}50%{box-shadow:0 6px 22px " + c + "66,0 0 0 4px " + c + "22}}" + 4069 ".caf-bk-locate{position:relative;display:flex;align-items:center;gap:10px;width:100%;padding:12px 14px;margin:2px 0 10px;border:none;background:linear-gradient(135deg," + c + "," + cGradEnd + ");color:#fff;border-radius:14px;font-size:13px;font-weight:700;cursor:pointer;font-family:inherit;box-shadow:0 4px 16px " + c + "44;transition:filter .2s,transform .2s;animation:caf-locate-pulse 2.8s ease-in-out infinite,caf-fade-up .35s ease-out;overflow:hidden;-webkit-tap-highlight-color:transparent;text-align:left}" + 4070 ".caf-bk-locate::after{content:'';position:absolute;top:0;bottom:0;width:40%;background:linear-gradient(100deg,transparent 0%,rgba(255,255,255,.32) 50%,transparent 100%);transform:translateX(-120%);animation:caf-cta-shimmer 4s ease-in-out infinite;animation-delay:1s;pointer-events:none}" + 4071 ".caf-bk-locate:hover{filter:brightness(1.08);transform:translateY(-1px)}" + 4072 ".caf-bk-locate:active{transform:scale(.99)}" + 4073 ".caf-bk-locate:disabled{opacity:.85;cursor:not-allowed;animation:none}" + 4074 ".caf-bk-locate-ic{width:30px;height:30px;border-radius:9px;background:rgba(255,255,255,.22);display:flex;align-items:center;justify-content:center;flex-shrink:0;position:relative;z-index:1}" + 4075 ".caf-bk-locate-ic svg{width:15px;height:15px}" + 4076 ".caf-bk-locate-ic .spin{width:14px;height:14px;border:2px solid rgba(255,255,255,.35);border-top-color:#fff;border-radius:50%;animation:caf-spin .7s linear infinite}" + 4077 ".caf-bk-locate-txt{flex:1;min-width:0;position:relative;z-index:1}" + 4078 ".caf-bk-locate-t{font-size:12.5px;font-weight:700;line-height:1.2;letter-spacing:-.1px}" + 4079 ".caf-bk-locate-s{font-size:10.5px;opacity:.9;margin-top:2px;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}" + 4080 ".caf-bk-locate-arrow{position:relative;z-index:1;flex-shrink:0;font-size:16px;line-height:1;opacity:.9}" + 4081 ".caf-bk-locate-status{font-size:11.5px;margin:0 0 10px;line-height:1.4;display:flex;align-items:flex-start;gap:6px;padding:8px 12px;border-radius:10px;animation:caf-fade-up .25s ease-out}" + 4082 ".caf-bk-locate-status.ok{background:#d1fae5;color:#047857}" + 4083 ".caf-bk-locate-status.err{background:#fee2e2;color:#b91c1c}" + 4084 ".caf-bk-locate-status.loading{background:" + c + "12;color:" + c + "}" + 4085 ".caf-bk-locate-status svg{width:13px;height:13px;flex-shrink:0;margin-top:1px}" + 4086 ".caf-bk-fs-tile-dist{position:absolute;top:10px;right:12px;display:inline-flex;align-items:center;gap:4px;font-size:10px;font-weight:800;color:" + c + ";background:" + c + "15;padding:3px 8px;border-radius:99px;border:1px solid " + c + "30;z-index:2;letter-spacing:.2px}" + 4087 ".caf-bk-fs-tile-dist svg{width:9px;height:9px}" + 4088 ".caf-bk-fs-tile.nearest{border-color:" + c + ";box-shadow:0 8px 24px " + c + "28}" + 4089 ".caf-bk-fs-tile.nearest::before{width:4px;background:linear-gradient(180deg," + c + "," + c + "aa)}" + 4090 ".caf-bk-fs-tile-nearest-ribbon{position:absolute;top:10px;left:-1px;background:linear-gradient(135deg," + c + "," + cGradEnd + ");color:var(--caf-on-c);font-size:9px;font-weight:800;padding:3px 10px 3px 8px;border-radius:0 8px 8px 0;letter-spacing:.5px;text-transform:uppercase;z-index:2;box-shadow:0 2px 8px " + c + "44;transform-origin:left center}" + 4091 4092 /* nearest reveal: shake + golden glow + ribbon pop */ 4093 "@keyframes caf-nearest-shake{" +
4094 "0%,100%{transform:translate3d(0,0,0) rotate(0)}" + 4095 "12%{transform:translate3d(0,-8px,0) rotate(-1deg) scale(1.04)}" + 4096 "28%{transform:translate3d(0,0,0) rotate(1deg) scale(1.02)}" + 4097 "44%{transform:translate3d(0,-4px,0) rotate(-.5deg) scale(1.03)}" + 4098 "62%{transform:translate3d(0,0,0) rotate(.3deg) scale(1.02)}" + 4099 "80%{transform:translate3d(0,-1px,0) rotate(-.1deg) scale(1.01)}" + 4100 "}" + 4101 "@keyframes caf-nearest-glow{" + 4102 "0%,100%{box-shadow:0 8px 24px " + c + "30,0 0 0 0 " + c + "00}" + 4103 "50%{box-shadow:0 20px 60px " + c + "66,0 0 0 6px " + c + "22}" + 4104 "}" + 4105 "@keyframes caf-ribbon-pop{" + 4106 "0%{opacity:0;transform:scale(0) rotate(-25deg)}" + 4107 "55%{opacity:1;transform:scale(1.2) rotate(4deg)}" + 4108 "75%{transform:scale(.95) rotate(-2deg)}" + 4109 "100%{opacity:1;transform:scale(1) rotate(0)}" + 4110 "}" + 4111 "@keyframes caf-dist-pop{" + 4112 "0%{opacity:0;transform:scale(0) rotate(-90deg)}" + 4113 "60%{opacity:1;transform:scale(1.25) rotate(8deg)}" + 4114 "100%{opacity:1;transform:scale(1) rotate(0)}" + 4115 "}" + 4116 ".caf-bk-fs-tile.nearest-reveal{animation:caf-nearest-shake 1s cubic-bezier(.36,.07,.19,.97) both,caf-nearest-glow 2.4s ease-in-out 1s infinite;z-index:5}" + 4117 ".caf-bk-fs-tile.nearest-reveal .caf-bk-fs-tile-nearest-ribbon{animation:caf-ribbon-pop .7s cubic-bezier(.34,1.56,.64,1) .35s both}" + 4118 ".caf-bk-fs-tile-dist{animation:caf-dist-pop .5s cubic-bezier(.34,1.56,.64,1) .1s both}" + 4119 4120 /* sparkle particles (celebration) */ 4121 "@keyframes caf-sparkle{" + 4122 "0%{opacity:0;transform:translate(0,0) scale(0) rotate(0)}" + 4123 "20%{opacity:1;transform:translate(calc(var(--sx,0)*.3),calc(var(--sy,0)*.3)) scale(1) rotate(90deg)}" + 4124 "70%{opacity:.9;transform:translate(calc(var(--sx,0)*.85),calc(var(--sy,0)*.85)) scale(.8) rotate(270deg)}" + 4125 "100%{opacity:0;transform:translate(var(--sx,0),var(--sy,0)) scale(.1) rotate(360deg)}" + 4126 "}" + 4127 ".caf-sparkle{position:absolute;left:50%;top:50%;margin:-7px 0 0 -7px;width:14px;height:14px;pointer-events:none;z-index:10;animation:caf-sparkle 1.2s cubic-bezier(.34,1.56,.64,1) both;will-change:transform,opacity}" + 4128 ".caf-sparkle svg{width:100%;height:100%;filter:drop-shadow(0 0 6px currentColor)}" + 4129 4130 /* ââ smooth step stage transitions (fullscreen) with blur + scale + translate ââ */ 4131 "@keyframes caf-stage-fwd{" + 4132 "0%{opacity:0;transform:translate3d(30px,6px,0) scale(.96);filter:blur(4px)}" + 4133 "35%{opacity:.85;filter:blur(0)}" + 4134 "100%{opacity:1;transform:translate3d(0,0,0) scale(1);filter:blur(0)}" + 4135 "}" + 4136 "@keyframes caf-stage-back{" + 4137 "0%{opacity:0;transform:translate3d(-30px,6px,0) scale(.96);filter:blur(4px)}" + 4138 "35%{opacity:.85;filter:blur(0)}" + 4139 "100%{opacity:1;transform:translate3d(0,0,0) scale(1);filter:blur(0)}" + 4140 "}" + 4141 "@keyframes caf-stage-fade{0%{opacity:0;transform:scale(.985);filter:blur(3px)}40%{filter:blur(0)}100%{opacity:1;transform:scale(1);filter:blur(0)}}" + 4142 ".caf-bk-fs-stage{animation-duration:.55s;animation-timing-function:cubic-bezier(.22,.61,.36,1);animation-fill-mode:both;will-change:transform,opacity,filter}" + 4143 ".caf-bk-fs-stage.fwd{animation-name:caf-stage-fwd}" + 4144 ".caf-bk-fs-stage.back{animation-name:caf-stage-back}" + 4145 ".caf-bk-fs-stage.fade{animation-name:caf-stage-fade;animation-duration:.38s}" + 4146 4147 /* ââ skeleton loaders (replace spinner with content shape) ââ */ 4148 "@keyframes caf-skel-shimmer{0%{background-position:-150% 0}100%{background-position:150% 0}}" + 4149 ".caf-bk-fs-skel{background:linear-gradient(90deg,#edf2f7 0%,#e2e8f0 50%,#edf2f7 100%);background-size:200% 100%;animation:caf-skel-shimmer 1.6s ease-in-out infinite;border-radius:16px;position:relative;overflow:hidden}" + 4150 ".caf-bk-fs-skel-tile{height:104px;animation-delay:var(--d,0ms)}" + 4151 ".caf-bk-fs-skel-row{height:14px;border-radius:7px;margin:0 0 8px}" + 4152 ".caf-bk-fs-skel-row.sm{height:10px;width:60%}" + 4153 ".caf-bk-fs-skel-row.md{height:14px;width:80%}" + 4154 ".caf-bk-fs-skel-row.lg{height:18px;width:90%}" + 4155 ".caf-bk-fs-skel-ping{position:absolute;bottom:0;left:0;height:2px;width:30%;background:linear-gradient(90deg,transparent," + c + ",transparent);animation:caf-shimmer-bar 1.2s ease-in-out infinite}" + 4156 ".caf-bk-fs-skel-caption{text-align:center;font-size:13px;color:#5b6573;margin-top:16px;display:flex;align-items:center;justify-content:center;gap:8px;animation:caf-pulse-soft 1.6s ease-in-out infinite}" + 4157 ".caf-bk-fs-skel-caption .sp{width:14px;
4157height:14px;border:2px solid " + c + "33;border-top-color:" + c + ";border-radius:50%;animation:caf-spin .7s linear infinite}" + 4158 4159 /* ââ tile spotlight: when user picks one, dim the others ââ */ 4160 "@keyframes caf-tile-dim{to{opacity:.12;transform:scale(.9);filter:blur(2px)}}" + 4161 "@keyframes caf-tile-focus{" + 4162 "0%{transform:scale(1)}" + 4163 "35%{transform:scale(1.06);box-shadow:0 20px 50px " + c + "50}" + 4164 "100%{transform:scale(1.03);box-shadow:0 18px 45px " + c + "44}" + 4165 "}" + 4166 ".caf-bk-fs-tile-dim{animation:caf-tile-dim .35s cubic-bezier(.22,.61,.36,1) forwards;pointer-events:none}" + 4167 ".caf-bk-fs-tile-focus{animation:caf-tile-focus .4s cubic-bezier(.34,1.56,.64,1) forwards;z-index:5;pointer-events:none;border-color:" + c + "!important}" + 4168 4169 /* ââ sidebar item completion (pending â filled with checkmark draw) ââ */ 4170 "@keyframes caf-side-complete{0%{transform:scale(1)}35%{transform:scale(1.2)}70%{transform:scale(.95)}100%{transform:scale(1)}}" + 4171 "@keyframes caf-side-check-draw{0%{stroke-dashoffset:16}100%{stroke-dashoffset:0}}" + 4172 ".caf-bk-fs-side-item.just-completed .caf-bk-fs-side-ic{animation:caf-side-complete .55s cubic-bezier(.34,1.56,.64,1)}" + 4173 ".caf-bk-fs-side-item.just-completed .caf-bk-fs-side-val{animation:caf-fade-up .5s cubic-bezier(.22,.61,.36,1)}" + 4174 ".caf-bk-fs-side-check{position:absolute;top:-4px;right:-4px;width:16px;height:16px;border-radius:50%;background:#10b981;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 6px rgba(16,185,129,.45);animation:caf-pop-in .4s cubic-bezier(.34,1.56,.64,1)}" + 4175 ".caf-bk-fs-side-check svg{width:10px;height:10px;stroke-dasharray:16;animation:caf-side-check-draw .45s ease-out .15s both}" + 4176 ".caf-bk-fs-side-ic{position:relative}" + 4177 4178 /* ââ rail active dot pulse (continuous, subtle) ââ */ 4179 "@keyframes caf-rail-pulse{0%,100%{box-shadow:0 0 0 5px rgba(255,255,255,.25)}50%{box-shadow:0 0 0 8px rgba(255,255,255,.12)}}" + 4180 ".caf-bk-fs-rail-dot.active{animation:caf-rail-pulse 2.2s ease-in-out infinite}" + 4181 4182 /* ââ form field entry cascade ââ */ 4183 "@keyframes caf-field-in{0%{opacity:0;transform:translateY(8px) scale(.98)}100%{opacity:1;transform:translateY(0) scale(1)}}" + 4184 ".caf-bk-fs-form .caf-bk-inp-wrap{animation:caf-field-in .45s cubic-bezier(.22,.61,.36,1) both}" + 4185 4186 /* ââ submit â loading â done morph ââ */ 4187 "@keyframes caf-submit-pulse{0%,100%{transform:scale(1);box-shadow:0 6px 20px " + c + "44}50%{transform:scale(1.03);box-shadow:0 10px 32px " + c + "66}}" + 4188 ".caf-bk-fs-btn-pri.submitting{animation:caf-submit-pulse 1s ease-in-out infinite;pointer-events:none}" + 4189 "@keyframes caf-done-grow{0%{opacity:0;transform:scale(.3) rotate(-25deg)}60%{opacity:1;transform:scale(1.15) rotate(8deg)}100%{opacity:1;transform:scale(1) rotate(0)}}" + 4190 ".caf-bk-fs-done-ic{animation:caf-done-grow .75s cubic-bezier(.34,1.56,.64,1) both!important}" + 4191 4192 /* smoother transitions on existing elements */ 4193 ".caf-bk-fs-tile,.caf-bk-fs-tile-dim,.caf-bk-fs-tile-focus{will-change:transform,opacity}" + 4194 4195 /* better calendar day pop + chip feedback */ 4196 "@keyframes caf-day-pop{0%{transform:scale(1)}40%{transform:scale(1.25)}70%{transform:scale(.94)}100%{transform:scale(1)}}" + 4197 ".caf-bk-fs-monthcal-day.selected{animation:caf-day-pop .4s cubic-bezier(.34,1.56,.64,1)}" + 4198 "@keyframes caf-chip-select{0%{transform:scale(1)}40%{transform:scale(1.15) rotate(-3deg)}70%{transform:scale(.95) rotate(1deg)}100%{transform:scale(1) rotate(0)}}" + 4199 ".caf-bk-fs-cal-chip.active{animation:caf-chip-select .4s cubic-bezier(.34,1.56,.64,1)}" + 4200 4201 /* polished tile hover/press */ 4202 ".caf-bk-fs-tile{transition:transform .35s cubic-bezier(.34,1.56,.64,1),box-shadow .35s,border-color .25s,filter .25s}" + 4203 ".caf-bk-fs-tile:hover{filter:saturate(1.1)}" + 4204 ".caf-bk-fs-tile.picking{animation:caf-chip-select .35s cubic-bezier(.34,1.56,.64,1);pointer-events:none}" + 4205 ".caf-bk-fs-btn{transition:transform .18s cubic-bezier(.34,1.56,.64,1),box-shadow .25s,background .25s,filter .2s,color .2s,border-color .2s}" + 4206 4207 /* sede map iframe */ 4208 ".caf-bk-fs-map{margin-top:12px;border-radius:14px;overflow:hidden;border:1px solid #e2e8f0;box-shadow:0 2px 10px rgba(15,23,42,.06);height:220px;background:#f1f5f9;position:relative}" + 4209 ".caf-bk-fs-map iframe{width:100%;height:100%;border:0;display:block}" + 4210 ".caf-bk-fs-map-fallback{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px;color:#5b6573;font-size:12px;padding:20px;text-align:center}" + 4211 ".caf-bk-fs-map-fallback svg{width:30px;height:30px;opacity:.5}" + 4212 4213 /* mobile bottom sheet summary (fs) â lives between body and footer */ 4214 ".caf-bk-fs-mbsum{display:none;background:rgba(255,255,255,.96);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-top:1px solid #e2e8f0;padding:10px 14px;flex-shrink:0;box-shadow:0 -6px 20px rgba(15,23,42,.06);animation:caf-cta-slide .3s ease-out}" + 4215 "@media(max-width:820px){.caf-bk-fs-mbsum{display:block}}" + 4216 ".caf-bk-fs-mbsum-row{display:flex;align-items:center;gap:10px}" + 4217 ".caf-bk-fs-mbsum-handle{width:36px;height:4px;background:#cbd5e1;border-radius:2px;margin:0 auto 8px;display:block}" + 4218 ".caf-bk-fs-mbsum-count{flex-shrink:0;width:28px;height:28px;border-radius:8px;background:" + c + "15;color:" + c + ";font-size:12px;font-weight:800;display:flex;align-items:center;justify-content:center}" + 4219 ".caf-bk-fs-mbsum-txt{flex:1;min-width:0;font-size:11.5px;color:#475569;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}" + 4220 ".caf-bk-fs-mbsum-txt b{color:#0f172a}" + 4221 ".caf-bk-fs-mbsum-toggle{flex-shrink:0;border:none;background:transparent;color:" + c + ";font-size:11px;font-weight:700;cursor:pointer;font-family:inherit;padding:4px 8px;border-radius:8px;transition:background .2s;display:inline-flex;align-items:center;gap:3px}" + 4222 ".caf-bk-fs-mbsum-toggle:hover{background:" + c + "12}" + 4223 ".caf-bk-fs-mbsum-exp{display:none;margin-top:10px;padding-top:10px;border-top:1px dashed #e2e8f0}" + 4224 ".caf-bk-fs-mbsum.expanded .caf-bk-fs-mbsum-exp{display:block;animation:caf-fade-up .25s ease-out}" + 4225 ".caf-bk-fs-mbsum-item{display:flex;align-items:flex-start;gap:8px;padding:5px 0;font-size:11.5px}" + 4226 ".caf-bk-fs-mbsum-item .lbl{color:#5b6573;min-width:70px;font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.3px}" + 4227 ".caf-bk-fs-mbsum-item .val{color:#0f172a;font-weight:600;flex:1}" + 4228 4229 /* body layout: sidebar (desktop) + main */ 4230 ".caf-bk-fs-body{flex:1;display:grid;grid-template-columns:1fr;overflow:hidden;min-height:0}" + 4231 "@media(min-width:820px){.caf-bk-fs-body{grid-template-columns:minmax(260px,300px) 1fr}}" + 4232 4233 /* sidebar summary */ 4234 ".caf-bk-fs-side{display:none;overflow-y:auto;padding:20px 18px;background:rgba(255,255,255,.55);backdrop-filter:blur(6px);border-right:1px solid #e2e8f0}" + 4235 "@media(min-width:820px){.caf-bk-fs-side{display:block}}" + 4236 ".caf-bk-fs-side-title{font-size:10px;text-transform:uppercase;letter-spacing:1.2px;font-weight:800;color:#5b6573;margin-bottom:12px;display:flex;align-items:center;gap:6px}" + 4237 ".caf-bk-fs-side-title svg{width:12px;height:12px}" + 4238 ".caf-bk-fs-side-card{background:#fff;border:1px solid #e2e8f0;border-radius:14px;padding:14px;box-shadow:0 1px 3px rgba(0,0,0,.04);margin-bottom:12px}" + 4239 ".caf-bk-fs-side-item{display:flex;align-items:flex-start;gap:10px;padding:9px 0;border-bottom:1px dashed #eef2f6;transition:opacity .3s}" + 4240 ".caf-bk-fs-side-item:last-child{border-bottom:none}" + 4241 ".caf-bk-fs-side-item.pending{opacity:.45}" + 4242 ".caf-bk-fs-side-ic{width:30px;height:30px;border-radius:9px;background:" + c + "15;color:" + c + ";display:flex;align-items:center;justify-content:center;flex-shrink:0}" + 4243 ".caf-bk-fs-side-ic.pending{background:#f1f5f9;color:#5b6573}" + 4244 ".caf-bk-fs-side-ic svg{width:14px;
4244height:14px}" + 4245 ".caf-bk-fs-side-txt{flex:1;min-width:0}" + 4246 ".caf-bk-fs-side-lbl{font-size:9.5px;color:#5b6573;font-weight:700;text-transform:uppercase;letter-spacing:.4px}" + 4247 ".caf-bk-fs-side-val{font-size:12.5px;color:#0f172a;font-weight:600;margin-top:2px;word-wrap:break-word;line-height:1.3}" + 4248 ".caf-bk-fs-side-val.muted{color:#5b6573;font-weight:500;font-style:italic}" + 4249 ".caf-bk-fs-side-help{font-size:11px;color:#5b6573;line-height:1.5;padding:10px 12px;background:" + c + "06;border:1px solid " + c + "18;border-radius:10px;display:flex;gap:8px;align-items:flex-start}" + 4250 ".caf-bk-fs-side-help svg{width:13px;height:13px;color:" + c + ";flex-shrink:0;margin-top:2px}" + 4251 4252 /* main step area */ 4253 ".caf-bk-fs-main{overflow-y:auto;padding:22px 26px 28px;display:flex;flex-direction:column;gap:18px;-webkit-overflow-scrolling:touch}" + 4254 "@media(max-width:820px){.caf-bk-fs-main{padding:16px 14px 20px;gap:14px}}" + 4255 "@media(max-width:480px){.caf-bk-fs-main{padding:14px 12px 18px}}" + 4256 ".caf-bk-fs-step-head{animation:caf-fade-up .35s ease-out}" + 4257 ".caf-bk-fs-step-num{display:inline-flex;align-items:center;gap:5px;font-size:10px;font-weight:800;letter-spacing:1.2px;text-transform:uppercase;color:" + c + ";background:" + c + "14;padding:5px 12px;border-radius:99px;margin-bottom:10px;border:1px solid " + c + "25}" + 4258 ".caf-bk-fs-step-h1{font-size:22px;font-weight:800;color:#0f172a;letter-spacing:-.4px;line-height:1.25}" + 4259 "@media(max-width:820px){.caf-bk-fs-step-h1{font-size:19px}}" + 4260 "@media(max-width:480px){.caf-bk-fs-step-h1{font-size:17px}}" + 4261 ".caf-bk-fs-step-h2{font-size:13.5px;color:#5b6573;margin-top:5px;line-height:1.55;max-width:640px}" + 4262 "@media(max-width:480px){.caf-bk-fs-step-h2{font-size:12.5px}}" + 4263 4264 /* large tile grid */ 4265 ".caf-bk-fs-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px;animation:caf-fade-up .4s ease-out}" + 4266 "@media(max-width:520px){.caf-bk-fs-grid{grid-template-columns:1fr}}" + 4267 ".caf-bk-fs-tile{position:relative;background:#fff;border:1.5px solid #e2e8f0;border-radius:16px;padding:16px 40px 16px 18px;text-align:left;cursor:pointer;font-family:inherit;transition:all .25s cubic-bezier(.34,1.56,.64,1);overflow:hidden;-webkit-tap-highlight-color:transparent;min-height:96px;touch-action:manipulation}" + 4268 "@media(max-width:480px){.caf-bk-fs-tile{padding:14px 36px 14px 14px;min-height:80px}.caf-bk-fs-tile-ic{width:32px;height:32px;border-radius:9px;margin-bottom:8px}.caf-bk-fs-tile-ic svg{width:16px;height:16px}.caf-bk-fs-tile-name{font-size:13.5px}}" + 4269 ".caf-bk-fs-tile::before{content:'';position:absolute;left:0;top:0;bottom:0;width:0;background:linear-gradient(180deg," + c + "," + c + "aa);transition:width .3s}" + 4270 ".caf-bk-fs-tile:hover{border-color:" + c + ";transform:translateY(-3px);box-shadow:0 18px 44px " + c + "25}" + 4271 ".caf-bk-fs-tile:hover::before{width:4px}" + 4272 ".caf-bk-fs-tile:active{transform:scale(.98)}" + 4273 ".caf-bk-fs-tile-ic{width:38px;height:38px;border-radius:11px;background:linear-gradient(135deg," + c + "18," + c + "08);color:" + c + ";display:flex;align-items:center;justify-content:center;margin-bottom:10px}" + 4274 ".caf-bk-fs-tile-ic svg{width:18px;height:18px}" + 4275 ".caf-bk-fs-tile-name{font-size:14px;font-weight:700;color:#0f172a;letter-spacing:-.1px;line-height:1.35}" + 4276 ".caf-bk-fs-tile-sub{font-size:11.5px;color:#5b6573;margin-top:5px;display:flex;align-items:center;gap:5px;line-height:1.4}" + 4277 ".caf-bk-fs-tile-sub svg{width:11px;height:11px;flex-shrink:0;opacity:.7}" + 4278 ".caf-bk-fs-tile-arrow{position:absolute;right:14px;top:50%;transform:translateY(-50%);color:#5b6573;transition:all .25s;display:flex}" + 4279 ".caf-bk-fs-tile-arrow svg{width:16px;height:16px}" + 4280 ".caf-bk-fs-tile:hover .caf-bk-fs-tile-arrow{color:" + c + ";transform:translateY(-50%) translateX(4px)}" + 4281 4282 /* search in fs */ 4283 ".caf-bk-fs-search{position:relative;margin-bottom:4px}" + 4284 ".caf-bk-fs-search-ic{position:absolute;
4284left:14px;top:50%;transform:translateY(-50%);color:#5b6573;pointer-events:none;display:flex}" + 4285 ".caf-bk-fs-search-ic svg{width:16px;height:16px}" + 4286 ".caf-bk-fs-search input{width:100%;border:1.5px solid #818c9c;border-radius:14px;padding:12px 16px 12px 42px;font-size:14px;color:#0f172a;font-family:inherit;outline:none;background:#fff;transition:border-color .2s,box-shadow .2s}" + 4287 ".caf-bk-fs-search input:focus{border-color:" + c + ";box-shadow:0 0 0 4px " + c + "18}" + 4288 4289 /* slot calendar */ 4290 ".caf-bk-fs-cal{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px;animation:caf-fade-up .4s ease-out}" + 4291 ".caf-bk-fs-cal-day{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:14px 14px 12px;box-shadow:0 1px 3px rgba(0,0,0,.04)}" + 4292 ".caf-bk-fs-cal-day-hdr{display:flex;align-items:center;gap:10px;padding-bottom:10px;margin-bottom:10px;border-bottom:1px solid #eef2f6}" + 4293 ".caf-bk-fs-cal-day-badge{width:38px;height:38px;border-radius:10px;background:linear-gradient(135deg," + c + "18," + c + "08);color:" + c + ";display:flex;flex-direction:column;align-items:center;justify-content:center;flex-shrink:0;font-weight:800}" + 4294 ".caf-bk-fs-cal-day-badge .d{font-size:14px;line-height:1}" + 4295 ".caf-bk-fs-cal-day-badge .m{font-size:8px;text-transform:uppercase;letter-spacing:.5px;margin-top:1px;opacity:.85}" + 4296 ".caf-bk-fs-cal-day-txt{flex:1;min-width:0}" + 4297 ".caf-bk-fs-cal-day-n{font-size:13px;font-weight:700;color:#0f172a;text-transform:capitalize}" + 4298 ".caf-bk-fs-cal-day-d{font-size:11px;color:#5b6573;margin-top:1px}" + 4299 ".caf-bk-fs-cal-slots{display:grid;grid-template-columns:repeat(auto-fill,minmax(70px,1fr));gap:6px}" + 4300 ".caf-bk-fs-cal-chip{position:relative;border:1.5px solid #e2e8f0;background:#f8fafc;border-radius:10px;padding:9px 4px;font-size:12.5px;font-weight:700;color:#334155;cursor:pointer;font-family:inherit;transition:all .2s;-webkit-tap-highlight-color:transparent;display:flex;align-items:center;justify-content:center;gap:3px}" + 4301 ".caf-bk-fs-cal-chip:hover{border-color:" + c + ";background:#fff;color:" + c + ";transform:scale(1.04);box-shadow:0 4px 12px " + c + "22}" + 4302 ".caf-bk-fs-cal-chip:active{transform:scale(.96)}" + 4303 ".caf-bk-fs-cal-chip .d{width:6px;height:6px;border-radius:50%;background:#cbd5e1;flex-shrink:0}" + 4304 ".caf-bk-fs-cal-chip .d.pm{background:#fb923c}" + 4305 ".caf-bk-fs-cal-chip .d.am{background:#3b82f6}" + 4306 4307 /* form (fs) */ 4308 ".caf-bk-fs-form{display:grid;grid-template-columns:1fr 1fr;gap:14px;max-width:680px;animation:caf-fade-up .4s ease-out}" + 4309 "@media(max-width:640px){.caf-bk-fs-form{grid-template-columns:1fr}}" + 4310 ".caf-bk-fs-form .caf-bk-inp-wrap{width:100%}" + 4311 ".caf-bk-fs-form .caf-bk-inp-label{font-size:11px;margin-bottom:-2px}" + 4312 ".caf-bk-fs-form .caf-bk-inp{padding:13px 40px 13px 40px;font-size:14px;border-radius:13px;background:#fff}" + 4313 ".caf-bk-fs-form .caf-bk-inp-icon{left:14px}" + 4314 ".caf-bk-fs-form .caf-bk-inp-icon svg{width:16px;height:16px}" + 4315 ".caf-bk-fs-form .caf-bk-inp-check{right:14px}" + 4316 ".caf-bk-fs-form-full{grid-column:1/-1}" + 4317 ".caf-bk-fs-form-full .caf-bk-rr{padding:0}" + 4318 4319 /* summary panel (confirm step) */ 4320 ".caf-bk-fs-summary{background:#fff;border:1px solid #e2e8f0;border-radius:18px;padding:20px;box-shadow:0 4px 20px rgba(15,23,42,.05);max-width:680px;animation:caf-fade-up .4s ease-out}" + 4321 ".caf-bk-fs-summary-hdr{font-size:11px;font-weight:800;letter-spacing:1.2px;text-transform:uppercase;color:#5b6573;padding-bottom:10px;border-bottom:1px solid #eef2f6;margin-bottom:12px;display:flex;align-items:center;gap:6px}" + 4322 ".caf-bk-fs-summary-hdr svg{width:13px;height:13px;color:" + c + "}" + 4323 ".caf-bk-fs-summary-row{display:flex;align-items:flex-start;gap:12px;padding:10px 0;border-bottom:1px dashed #eef2f6}" + 4324 ".caf-bk-fs-summary-row:last-child{border-bottom:none}" + 4325 ".caf-bk-fs-summary-ic{width:34px;height:34px;border-radius:10px;background:" + c + "12;
4325color:" + c + ";display:flex;align-items:center;justify-content:center;flex-shrink:0}" + 4326 ".caf-bk-fs-summary-ic svg{width:15px;height:15px}" + 4327 ".caf-bk-fs-summary-body{flex:1;min-width:0}" + 4328 ".caf-bk-fs-summary-lbl{font-size:10.5px;color:#5b6573;font-weight:700;text-transform:uppercase;letter-spacing:.4px}" + 4329 ".caf-bk-fs-summary-val{font-size:14px;color:#0f172a;font-weight:600;margin-top:2px;line-height:1.35}" + 4330 ".caf-bk-fs-summary-sub{font-size:11.5px;color:#5b6573;margin-top:2px}" + 4331 4332 /* footer actions */ 4333 ".caf-bk-fs-foot{border-top:1px solid #e2e8f0;background:rgba(255,255,255,.92);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);padding:12px 20px;display:flex;align-items:center;justify-content:space-between;gap:10px;flex-shrink:0}" + 4334 "@media(max-width:640px){.caf-bk-fs-foot{padding:10px 14px calc(env(safe-area-inset-bottom,0px) + 10px)}}" + 4335 "@media(max-width:480px){.caf-bk-fs-foot{padding:9px 12px calc(env(safe-area-inset-bottom,0px) + 9px);gap:6px}}" + 4336 ".caf-bk-fs-foot-l,.caf-bk-fs-foot-r{display:flex;gap:8px;align-items:center}" + 4337 ".caf-bk-fs-btn{display:inline-flex;align-items:center;gap:6px;border-radius:12px;padding:11px 18px;font-size:12.5px;font-weight:700;cursor:pointer;font-family:inherit;border:1.5px solid transparent;transition:all .2s;-webkit-tap-highlight-color:transparent;letter-spacing:.2px}" + 4338 "@media(max-width:480px){.caf-bk-fs-btn{padding:10px 12px;font-size:12px}}" + 4339 ".caf-bk-fs-btn-ghost{background:transparent;color:#5b6573;border-color:#e2e8f0}" + 4340 ".caf-bk-fs-btn-ghost:hover{color:#0f172a;background:#f1f5f9;border-color:#cbd5e1}" + 4341 ".caf-bk-fs-btn-danger{background:transparent;color:#5b6573;border-color:transparent}" + 4342 ".caf-bk-fs-btn-danger:hover{color:#be123c;background:#fff1f2}" + 4343 ".caf-bk-fs-btn-pri{background:linear-gradient(135deg," + c + "," + cGradEnd + ");color:#fff;box-shadow:0 6px 20px " + c + "44;border-color:transparent}" + 4344 ".caf-bk-fs-btn-pri:hover{transform:translateY(-2px);box-shadow:0 10px 28px " + c + "55;filter:brightness(1.05)}" + 4345 ".caf-bk-fs-btn-pri:disabled{opacity:.5;cursor:not-allowed;transform:none;box-shadow:none}" + 4346 ".caf-bk-fs-btn svg{width:14px;height:14px}" + 4347 4348 /* loading / empty / error in fs */ 4349 ".caf-bk-fs-center{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px 20px;text-align:center;gap:14px}" + 4350 ".caf-bk-fs-loader{width:56px;height:56px;border:4px solid #e2e8f0;border-top-color:" + c + ";border-radius:50%;animation:caf-spin .8s linear infinite}" + 4351 ".caf-bk-fs-loader-txt{font-size:14px;color:#475569;font-weight:500;max-width:360px;line-height:1.5}" + 4352 ".caf-bk-fs-empty-ic{width:68px;height:68px;border-radius:50%;background:#fef3c7;color:#b45309;display:flex;align-items:center;justify-content:center}" + 4353 ".caf-bk-fs-empty-ic svg{width:30px;height:30px}" + 4354 ".caf-bk-fs-empty-t{font-size:18px;font-weight:800;color:#0f172a}" + 4355 ".caf-bk-fs-empty-s{font-size:13.5px;color:#5b6573;max-width:420px;line-height:1.5}" + 4356 ".caf-bk-fs-error-ic{width:68px;height:68px;border-radius:50%;background:#ffe4e6;color:#be123c;display:flex;align-items:center;justify-content:center}" + 4357 4358 /* done screen (fs) */ 4359 ".caf-bk-fs-done{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-start;padding:40px 24px;text-align:center;overflow-y:auto}" + 4360 ".caf-bk-fs-done-ic{width:88px;height:88px;border-radius:50%;background:linear-gradient(135deg," + c + "," + c + "b3);display:flex;align-items:center;justify-content:center;margin-bottom:18px;box-shadow:0 20px 50px " + c + "55;position:relative}" + 4361 ".caf-bk-fs-done-ic svg{width:44px;height:44px}" + 4362 ".caf-bk-fs-done-t{font-size:26px;font-weight:800;color:#0f172a;letter-spacing:-.5px;line-height:1.2}" + 4363 ".caf-bk-fs-done-s{font-size:14px;color:#5b6573;margin-top:8px;max-width:480px;line-height:1.55}" + 4364 ".caf-bk-fs-done-id{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:700;padding:7px 16px;border-radius:99px;margin-top:14px;background:" + c + "15;color:" + c + ";border:1px solid " + c + "30}" + 4365 ".caf-bk-fs-done-sum{margin-top:22px;max-width:520px;width:100%;text-align:left}" + 4366 ".caf-bk-fs-done-actions{display:flex;gap:10px;margin-top:20px;flex-wrap:wrap;justify-content:center}" + 4367 4368 /* ââ #5 â content-visibility: skip layout/paint per griglie fuori viewport ââ */ 4369 ".caf-bk-fs-grid,.caf-bk-fs-cal,.caf-bk-fs-cal-slots{contain:layout style}" + 4370 ".caf-bk-fs-tile,.caf-bk-fs-cal-day{content-visibility:auto;contain-intrinsic-size:auto 96px}" + 4371 4372 /* ââ #9 â glassmorphism coerente sulle card overlay ââ */ 4373 ".caf-bk-fs .caf-bk-fs-monthcal,.caf-bk-fs .caf-bk-fs-slotpanel,.caf-bk-fs .caf-bk-fs-summary,.caf-bk-fs .caf-bk-fs-side-card,.caf-bk-fs .caf-bk-fs-cal-day{background:rgba(255,255,255,.78);backdrop-filter:blur(10px) saturate(1.1);-webkit-backdrop-filter:blur(10px) saturate(1.1)}" + 4374 ".caf-bk-fs .caf-bk-fs-tile{background:rgba(255,255,255,.85);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}" + 4375 4376 /* ââ #19 â progress bar interno shimmer (oltre alla width transition) ââ */ 4377 "@keyframes caf-progshim{0%{background-position:-100% 0}100%{background-position:200% 0}}" + 4378 ".caf-bk-fs-progress-bar{background:linear-gradient(90deg,#fff,rgba(255,255,255,.7),#fff,#fff);background-size:200% 100%;animation:caf-progshim 2.4s linear infinite}" + 4379 4380 /* ââ #12 â empty-state illustrato (calendario stilizzato + ombra) ââ */ 4381 ".caf-bk-fs-empty-ic{position:relative;background:linear-gradient(135deg,#fef3c7,#fde68a);box-shadow:0 8px 28px rgba(217,119,6,.22);overflow:visible}" + 4382 ".caf-bk-fs-empty-ic::after{content:'';position:absolute;
4382left:50%;bottom:-10px;width:54px;height:6px;background:rgba(217,119,6,.18);border-radius:50%;filter:blur(4px);transform:translateX(-50%)}" + 4383 ".caf-bk-fs-empty-ic::before{content:'';position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:50%;background:linear-gradient(135deg,#fb923c,#f59e0b);box-shadow:0 4px 12px rgba(251,146,60,.45);animation:caf-dot-pulse 2s ease-in-out infinite}" + 4384 4385 /* ââ #13 â dark mode auto via prefers-color-scheme ââ */ 4386 "@media(prefers-color-scheme:dark){" + 4387 ".caf-bk-fs{background:linear-gradient(180deg,#0f172a 0%,#1e293b 100%)}" + 4388 ".caf-bk-fs .caf-bk-fs-monthcal,.caf-bk-fs .caf-bk-fs-slotpanel,.caf-bk-fs .caf-bk-fs-summary,.caf-bk-fs .caf-bk-fs-side-card,.caf-bk-fs .caf-bk-fs-cal-day,.caf-bk-fs .caf-bk-fs-tile{background:rgba(30,41,59,.78);border-color:#334155;color:#e2e8f0}" + 4389 ".caf-bk-fs .caf-bk-fs-tile-name,.caf-bk-fs .caf-bk-fs-step-h1,.caf-bk-fs .caf-bk-fs-monthcal-title,.caf-bk-fs .caf-bk-fs-slotpanel-hdr,.caf-bk-fs .caf-bk-fs-cal-day-n,.caf-bk-fs .caf-bk-fs-summary-val,.caf-bk-fs .caf-bk-fs-side-val{color:#f1f5f9}" + 4390 ".caf-bk-fs .caf-bk-fs-step-h2,.caf-bk-fs .caf-bk-fs-tile-sub,.caf-bk-fs .caf-bk-fs-monthcal-day{color:#94a3b8}" + 4391 ".caf-bk-fs .caf-bk-fs-search input,.caf-bk-fs .caf-bk-inp{background:rgba(15,23,42,.6);color:#f1f5f9;border-color:#334155}" + 4392 ".caf-bk-fs .caf-bk-fs-search input::placeholder,.caf-bk-fs .caf-bk-inp::placeholder{color:#94a3b8}" + 4393 ".caf-bk-fs .caf-bk-fs-cal-chip{background:rgba(15,23,42,.5);color:#e2e8f0;border-color:#334155}" + 4394 ".caf-bk-fs .caf-bk-fs-monthcal-day{color:#94a3b8}" + 4395 ".caf-bk-fs .caf-bk-fs-foot{background:rgba(15,23,42,.92);border-color:#334155}" + 4396 ".caf-bk-fs .caf-bk-fs-side{background:rgba(15,23,42,.55);border-color:#334155}" + 4397 ".caf-bk-fs .caf-bk-fs-skel{background:linear-gradient(90deg,#1e293b 0%,#334155 50%,#1e293b 100%)}" + 4398 /* a11y dark #C6: testo secondario/bordi rimasti "da fondo chiaro" */ 4399 ".caf-bk-fs .caf-bk-fs-btn-ghost{color:#cbd5e1;border-color:#334155}" + 4400 ".caf-bk-fs .caf-bk-fs-btn-danger{color:#94a3b8}" + 4401 ".caf-bk-fs .caf-bk-fs-side-lbl,.caf-bk-fs .caf-bk-fs-summary-lbl,.caf-bk-fs .caf-bk-fs-summary-sub,.caf-bk-fs .caf-bk-fs-summary-hdr,.caf-bk-fs .caf-bk-fs-side-title,.caf-bk-fs .caf-bk-fs-side-help,.caf-bk-fs .caf-bk-fs-monthcal-wd,.caf-bk-fs .caf-bk-fs-monthcal-legend,.caf-bk-fs .caf-bk-fs-slotpanel-empty,.caf-bk-fs .caf-bk-fs-slotgroup-hdr,.caf-bk-fs .caf-bk-fs-cal-day-d,.caf-bk-fs .caf-bk-fs-empty-s,.caf-bk-fs .caf-bk-fs-done-s,.caf-bk-fs .caf-bk-fs-skel-caption,.caf-bk-fs .caf-bk-fs-search-ic,.caf-bk-fs .caf-bk-fs-tile-arrow,.caf-bk-fs .caf-bk-fs-side-val.muted{color:#94a3b8}" + 4402 ".caf-bk-fs .caf-bk-fs-side-help{background:rgba(15,23,42,.5);border-color:#334155}" + 4403 ".caf-bk-fs .caf-bk-fs-monthcal-day.past{color:#64748b}" + 4404 /* a11y dark #C5: dentro il wizard dark il token ink (calibrato sul 4405 * bianco) diventa la variante chiara calibrata sui fondi scuri: tutte 4406 * le regole che usano var(--caf-c-ink) restano giuste in dark. Scope 4407 * su .caf-bk-fs (NON :host): il pannello chat resta a tema chiaro. */ 4408 ".caf-bk-fs{--caf-c-ink:var(--caf-c-ink-dark)}" + 4409 /* ...tranne gli elementi del wizard che poggiano su card CHIARE anche 4410 * in dark (mbsum bianco, summary premium #fff): ink reale letterale. */ 4411 ".caf-bk-fs .caf-bk-fs-mbsum-count,.caf-bk-fs .caf-bk-fs-mbsum-toggle,.caf-bk-fs .caf-bk-sum-premium-edit{color:" + cInk + "}" + 4412 "}" + 4413 4414 /* ââ #4 â View Transitions: lascio il default cross-fade del browser 4415 * (durata predefinita ~250ms) per non interferire con le animazioni 4416 * caf-bk-fs-stage già scope-applicate a .caf-bk-fs-main. ââ */ 4417 4418 /* ââ BOOKING-ACTIVE proactive cues (FAB wiggle/shake/tilt, mobile banner, tooltip) ââ */ 4419 "@keyframes caf-bk-wiggle{0%,100%{transform:rotate(0)}15%{transform:rotate(-8deg) scale(1.04)}30%{transform:rotate(7deg) scale(1.04)}45%{transform:rotate(-5deg) scale(1.02)}60%{transform:rotate(4deg) scale(1.02)}75%{transform:rotate(-2deg)}}" + 4420 "@keyframes caf-bk-shake{0%,100%{transform:translateX(0)}20%{transform:translateX(-6px)}40%{transform:translateX(6px)}60%{transform:translateX(-4px)}80%{transform:translateX(4px)}}" + 4421 ".caf-toggle.caf-bk-wiggle{animation:caf-bk-wiggle .9s ease-in-out 1}" + 4422 ".caf-toggle.caf-bk-shake{animation:caf-bk-shake .55s ease-in-out 2}" + 4423 ".caf-toggle.caf-bk-active:hover{transform:scale(1.08) rotate(-4deg)}" + 4424 /* badge tooltip "1 nuovo" */ 4425 ".caf-toggle .caf-badge.caf-bk-newdot{display:flex!important;min-width:18px;height:18px;padding:0 5px;border-radius:9px;background:#dc2626;color:#fff;font-size:11px;font-weight:700;align-items:center;justify-content:center;position:absolute;top:-2px;right:-2px;box-shadow:0 2px 6px rgba(239,68,68,.5);animation:caf-pulse-soft 1.6s ease-in-out infinite}" + 4426 /* mobile: FAB più grande quando booking attivo + sticky banner */ 4427 "@media(max-width:640px){" + 4428 ".caf-toggle.caf-bk-active{width:72px;height:72px}" + 4429 ".caf-toggle.caf-bk-active img{width:34px;height:34px}" + 4430 "}" + 4431 /* rispetto delle preferenze di motion ridotto: disabilita tutto ciò che attira l'occhio */ 4432 "@media(prefers-reduced-motion:reduce){" + 4433 ".caf-toggle .caf-pulse,.caf-toggle .caf-pulse-2{animation:none!important;display:none}" + 4434 ".caf-toggle.caf-bk-active .caf-glow{opacity:.4!important;animation:none!important}" + 4435 ".caf-bubble.caf-bk-active{animation:none!important}" + 4436 ".caf-bubble.caf-bk-active .caf-bk-arrow{animation:none!important}" + 4437 ".caf-toggle.caf-bk-wiggle,.caf-toggle.caf-bk-shake{animation:none!important}" + 4438 ".caf-wave-emoji{animation:none!important}" + 4439 "}" + 4440 /* pausa animazioni quando il tab non è in focus per non sprecare CPU */ 4441 ".caf-bk-pause-anim .caf-toggle .caf-pulse,.caf-bk-pause-anim .caf-toggle .caf-pulse-2,.caf-bk-pause-anim .caf-bubble.caf-bk-active,.caf-bk-pause-anim .caf-bubble.caf-bk-active .caf-bk-arrow{animation-play-state:paused!important}" + 4442 4443 /* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4444 * BOOKING UX 2.0 â categorie servizi, popular, docs tooltip, 4445 * slot fasce, prima disponibilità , error boundary, stepper 4446 * persistente, summary premium, skeleton realistici. 4447 * âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 4448 4449 /* ââ #6/#8 categorie e card servizi più ricche ââ */ 4450 ".caf-bk-srv-cats{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 14px;padding:0 2px}" + 4451 ".caf-bk-srv-cat{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:999px;border:1.5px solid #e2e8f0;background:#fff;color:#475569;font-size:12.5px;font-weight:600;cursor:pointer;transition:all .2s ease;white-space:nowrap}" + 4452 ".caf-bk-srv-cat svg{width:14px;
4452height:14px}" + 4453 ".caf-bk-srv-cat:hover{border-color:#cbd5e1;background:#f8fafc;transform:translateY(-1px)}" + 4454 ".caf-bk-srv-cat.active{background:" + c + ";color:#fff;border-color:" + c + ";box-shadow:0 4px 12px -3px " + c + "55}" + 4455 ".caf-bk-srv-cat-count{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;border-radius:9px;background:rgba(0,0,0,.08);font-size:10.5px;font-weight:700}" + 4456 ".caf-bk-srv-cat.active .caf-bk-srv-cat-count{background:rgba(255,255,255,.25)}" + 4457 ".caf-bk-fs-tile.has-cat{padding-left:64px;position:relative}" + 4458 ".caf-bk-fs-tile-cat-badge{position:absolute;left:14px;top:14px;width:38px;height:38px;border-radius:11px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:16px}" + 4459 ".caf-bk-fs-tile-cat-badge svg{width:18px;height:18px}" + 4460 ".caf-bk-fs-tile-popular{position:absolute;top:8px;right:36px;display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:999px;background:linear-gradient(135deg,#f97316,#ef4444);color:#fff;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;box-shadow:0 2px 6px rgba(239,68,68,.4)}" + 4461 ".caf-bk-fs-tile-recent{position:absolute;top:8px;right:36px;display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:999px;background:#0369a1;color:#fff;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.3px}" + 4462 ".caf-bk-fs-tile-docs{display:inline-flex;align-items:center;gap:4px;margin-top:6px;padding:3px 8px;border-radius:6px;background:#f1f5f9;color:#475569;font-size:11px;font-weight:600;cursor:pointer;transition:background .15s}" + 4463 ".caf-bk-fs-tile-docs:hover{background:#e2e8f0;color:#1e293b}" + 4464 ".caf-bk-fs-tile-docs svg{width:11px;height:11px}" + 4465 4466 /* ââ #9 documenti modal/popover ââ */ 4467 "@keyframes caf-bk-fade-in{0%{opacity:0;transform:translateY(8px)}100%{opacity:1;transform:translateY(0)}}" + 4468 ".caf-bk-docs-pop{position:absolute;left:14px;right:14px;top:100%;margin-top:6px;z-index:50;background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:14px 16px;box-shadow:0 12px 32px rgba(0,0,0,.12);animation:caf-bk-fade-in .25s ease-out}" + 4469 ".caf-bk-docs-pop-h{font-size:12px;font-weight:700;color:#0f172a;margin:0 0 8px;text-transform:uppercase;letter-spacing:.5px;display:flex;align-items:center;gap:6px}" + 4470 ".caf-bk-docs-pop-h svg{width:14px;height:14px;color:" + c + "}" + 4471 ".caf-bk-docs-pop-l{margin:0;padding:0;list-style:none}" + 4472 ".caf-bk-docs-pop-l li{position:relative;padding:4px 0 4px 20px;font-size:12.5px;line-height:1.5;color:#334155}" + 4473 ".caf-bk-docs-pop-l li::before{content:'';position:absolute;left:6px;top:11px;width:6px;height:6px;border-radius:50%;background:" + c + "}" + 4474 ".caf-bk-docs-pop-foot{margin-top:8px;padding-top:8px;border-top:1px solid #f1f5f9;font-size:11px;color:#5b6573}" + 4475 4476 /* ââ #11 spelling tolerance suggestion ââ */ 4477 ".caf-bk-suggest{padding:14px 16px;background:linear-gradient(135deg,#fef3c7,#fde68a);border:1px solid #fcd34d;border-radius:12px;color:#78350f;font-size:13px;line-height:1.55;display:flex;gap:10px;align-items:flex-start}" + 4478 ".caf-bk-suggest svg{flex:0 0 auto;width:18px;height:18px;color:#b45309;margin-top:1px}" + 4479 ".caf-bk-suggest button{background:#fff;border:1px solid #f59e0b;color:#92400e;padding:5px 12px;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer;margin-top:4px;display:inline-flex;align-items:center;gap:5px}" + 4480 ".caf-bk-suggest button:hover{background:#fef9c3}" + 4481 4482 /* ââ #20/#21 slot fasce e prima disponibilità ââ */ 4483 "@keyframes caf-bk-shimmer{0%{background-position:-100% 0}100%{background-position:200% 0}}" + 4484 /* background: prima un solid color come fallback (iOS Safari <16.2 / Chrome <111 4485 * non supportano color-mix() â senza fallback il riquadro resta trasparente 4486 * e il testo bianco diventa illeggibile su sfondo bianco). Poi il gradient. */ 4487 ".caf-bk-quickpick{margin:0 0 16px;padding:22px 22px;background:" + c + ";background:linear-gradient(135deg," + c + " 0%,color-mix(in srgb," + c + " 75%,#1e293b) 100%);border-radius:16px;color:#fff;display:flex;align-items:flex-start;gap:14px;cursor:pointer;box-shadow:0 8px 24px -6px " + c + "66;transition:transform .25s ease,box-shadow .25s ease;position:relative;overflow:hidden;width:100%;box-sizing:border-box;min-height:96px}" + 4488 ".caf-bk-quickpick::before{content:'';position:absolute;inset:0;background:linear-gradient(120deg,transparent 30%,rgba(255,255,255,.18) 50%,transparent 70%);background-size:200% 100%;animation:caf-bk-shimmer 3.5s ease-in-out infinite;pointer-events:none}" + 4489 ".caf-bk-quickpick:hover{transform:translateY(-2px);box-shadow:0 12px 32px -6px " + c + "88}" + 4490 ".caf-bk-quickpick-ic{flex:0 0 auto;width:48px;height:48px;border-radius:13px;background:rgba(255,255,255,.22);display:flex;align-items:center;justify-content:center;font-size:22px;backdrop-filter:blur(8px);align-self:flex-start;margin-top:2px}" + 4491 ".caf-bk-quickpick-body{flex:1 1 auto;min-width:0}" + 4492 ".caf-bk-quickpick-lbl{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;opacity:.85;margin-bottom:5px}" + 4493 ".caf-bk-quickpick-when{font-size:16px;font-weight:700;line-height:1.35;word-wrap:break-word;overflow-wrap:anywhere}" + 4494 ".caf-bk-quickpick-where{font-size:13px;opacity:.92;margin-top:6px;line-height:1.45;word-wrap:break-word;overflow-wrap:anywhere}" + 4495 ".caf-bk-quickpick-arrow{flex:0 0 auto;font-size:22px;font-weight:700;opacity:.9;align-self:center;padding-top:4px}" + 4496 /* Mobile: bumpiamo dimensioni testo, padding e min-height per assicurare 4497 * leggibilità del contenuto (la card è il CTA principale dello step). */ 4498 "@media(max-width:480px){" + 4499 ".caf-bk-quickpick{padding:18px 16px;gap:14px;border-radius:14px;min-height:108px;align-items:center}" + 4500 ".caf-bk-quickpick-ic{width:48px;height:48px;border-radius:12px;font-size:22px;margin-top:0}" + 4501 ".caf-bk-quickpick-lbl{font-size:11px;letter-spacing:.7px;margin-bottom:6px;opacity:.9}" + 4502 ".caf-bk-quickpick-when{font-size:17px;font-weight:800;line-height:1.3;letter-spacing:-.1px}" + 4503 ".caf-bk-quickpick-where{font-size:13.5px;line-height:1.4;margin-top:5px;opacity:.95}" + 4504 ".caf-bk-quickpick-arrow{font-size:22px;align-self:center;padding-top:0}" + 4505 "}" + 4506 /* Mobile molto stretto (< 360px): ulteriore compattamento per evitare 4507 * troncamenti, ma testo ancora leggibile. */ 4508 "@media(max-width:360px){" + 4509 ".caf-bk-quickpick{padding:16px 14px;gap:12px;min-height:100px}" + 4510 ".caf-bk-quickpick-ic{width:42px;height:42px;font-size:20px}" + 4511 ".caf-bk-quickpick-when{font-size:15.5px}" + 4512 ".caf-bk-quickpick-where{font-size:12.5px}" + 4513 ".caf-bk-quickpick-arrow{font-size:18px}" + 4514 "}" + 4515 ".caf-bk-slot-fasce{display:flex;gap:8px;margin:0 0 14px;padding:4px;background:#f1f5f9;border-radius:12px}" + 4516 ".caf-bk-slot-fascia{flex:1;padding:10px 12px;border:none;background:transparent;border-radius:9px;font-size:13px;font-weight:600;color:#5b6573;cursor:pointer;transition:all .2s ease;display:flex;align-items:center;justify-content:center;gap:6px}" + 4517 ".caf-bk-slot-fascia:hover{color:#334155}" + 4518 ".caf-bk-slot-fascia.active{background:#fff;color:" + c + ";box-shadow:0 1px 3px rgba(0,0,0,.08)}" + 4519 ".caf-bk-slot-fascia-count{font-size:11px;padding:1px 7px;border-radius:999px;background:#e2e8f0;color:#5b6573;font-weight:700}" + 4520 ".caf-bk-slot-fascia.active .caf-bk-slot-fascia-count{background:" + c + "22;color:" + c + "}" + 4521 /* slot density (caldi/freddi): tinta sottile sui chip via data-density */ 4522 ".caf-bk-fs-cal-chip[data-density='hot']{background:linear-gradient(135deg,#dcfce7,#bbf7d0);border-color:#86efac;color:#166534}" + 4523 ".caf-bk-fs-cal-chip[data-density='hot']:hover{background:#bbf7d0;border-color:#22c55e;color:#15803d}" + 4524 ".caf-bk-fs-cal-chip[data-density='warm']{background:#f0fdf4;border-color:#bbf7d0;color:#15803d}" + 4525 ".caf-bk-fs-cal-chip[data-density='cool']{opacity:.85}" + 4526 ".caf-bk-fs-cal-chip[data-density] .d{display:none}" + 4527 4528 /* ââ #35 summary premium ââ */ 4529 ".caf-bk-sum-premium{padding:0;border-radius:18px;overflow:hidden;background:#fff;box-shadow:0 8px 32px -8px rgba(0,0,0,.14);border:1px solid #e2e8f0;margin:0 0 14px}" + 4530 ".caf-bk-sum-premium-head{padding:20px 22px;background:linear-gradient(135deg," + c + ",color-mix(in srgb," + c + " 78%,#1e293b));color:#fff;display:flex;align-items:center;gap:12px}" + 4531 ".caf-bk-sum-premium-check{width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.22);display:flex;align-items:center;justify-content:center;backdrop-filter:blur(8px)}" + 4532 ".caf-bk-sum-premium-check svg{width:22px;height:22px}" + 4533 ".caf-bk-sum-premium-h{font-size:17px;font-weight:700;margin:0;line-height:1.3}" + 4534 ".caf-bk-sum-premium-s{font-size:12.5px;opacity:.85;margin-top:2px}" + 4535 ".caf-bk-sum-premium-cols{display:grid;grid-template-columns:repeat(3,1fr);gap:0;padding:0;background:#f8fafc}" + 4536 "@media(max-width:540px){.caf-bk-sum-premium-cols{grid-template-columns:1fr}}" + 4537 ".caf-bk-sum-premium-col{padding:18px;border-right:1px solid #e2e8f0;background:#fff}" + 4538 ".caf-bk-sum-premium-col:last-child{border-right:none}" + 4539 "@media(max-width:540px){.caf-bk-sum-premium-col{border-right:none;border-bottom:1px solid #e2e8f0}.caf-bk-sum-premium-col:last-child{border-bottom:none}}" + 4540 ".caf-bk-sum-premium-col-h{font-size:10.5px;font-weight:700;color:#5b6573;text-transform:uppercase;letter-spacing:.7px;margin:0 0 8px;display:flex;align-items:center;gap:6px}" + 4541 ".caf-bk-sum-premium-col-h svg{width:13px;height:13px;color:" + c + "}" + 4542 ".caf-bk-sum-premium-line{font-size:14px;color:#0f172a;font-weight:600;line-height:1.4;margin-bottom:3px}" + 4543 ".caf-bk-sum-premium-sub{font-size:12px;color:#5b6573;line-height:1.4;overflow-wrap:anywhere;min-width:0}" + 4544 ".caf-bk-sum-premium-edit{display:inline-flex;align-items:center;gap:4px;margin-top:8px;font-size:11px;color:" + c + ";background:transparent;border:none;cursor:pointer;font-weight:600;padding:0}" + 4545 ".caf-bk-sum-premium-edit:hover{text-decoration:underline}" + 4546 ".caf-bk-sum-premium-edit svg{width:11px;height:11px}" + 4547 ".caf-bk-sum-disclaimer{padding:11px 16px;font-size:12px;color:#5b6573;text-align:center;line-height:1.5;background:#f8fafc;border-top:1px solid #e2e8f0;display:flex;align-items:center;justify-content:center;gap:6px}" + 4548 ".caf-bk-sum-disclaimer svg{width:14px;
4548height:14px;color:#5b6573;flex:0 0 auto}" + 4549 4550 /* ââ #37 conferma prominente con loader ââ */ 4551 "@keyframes caf-bk-confirm-pulse{0%,100%{box-shadow:0 8px 24px -6px " + c + "55,0 0 0 0 " + c + "33}50%{box-shadow:0 12px 32px -4px " + c + "88,0 0 0 8px " + c + "00}}" + 4552 ".caf-bk-confirm-mega{display:flex;align-items:center;justify-content:center;gap:10px;width:100%;padding:18px 24px;background:linear-gradient(135deg," + c + ",color-mix(in srgb," + c + " 80%,#1e293b));color:#fff;border:none;border-radius:14px;font-size:15.5px;font-weight:700;cursor:pointer;animation:caf-bk-confirm-pulse 2.4s ease-in-out infinite;transition:transform .15s ease}" + 4553 ".caf-bk-confirm-mega:hover{transform:translateY(-1px)}" + 4554 ".caf-bk-confirm-mega:active{transform:translateY(0) scale(.98)}" + 4555 ".caf-bk-confirm-mega:disabled{opacity:.7;cursor:wait;animation:none}" + 4556 ".caf-bk-confirm-mega svg{width:18px;height:18px}" + 4557 ".caf-bk-confirm-spinner{display:inline-block;width:18px;height:18px;border:2.5px solid rgba(255,255,255,.4);border-right-color:#fff;border-radius:50%;animation:caf-spin .8s linear infinite}" + 4558 4559 /* ââ #38 toast errore strutturato ââ */ 4560 "@keyframes caf-bk-toast-in{0%{opacity:0;transform:translateY(-10px) scale(.96)}100%{opacity:1;transform:translateY(0) scale(1)}}" + 4561 ".caf-bk-toast{position:absolute;left:50%;top:14px;transform:translateX(-50%);z-index:100;max-width:520px;width:calc(100% - 24px);padding:14px 16px;background:#fef2f2;border:1px solid #fca5a5;border-radius:12px;color:#991b1b;display:flex;align-items:flex-start;gap:10px;box-shadow:0 12px 32px -6px rgba(239,68,68,.3);animation:caf-bk-toast-in .35s cubic-bezier(.34,1.56,.64,1) both}" + 4562 ".caf-bk-toast.warn{background:#fef3c7;border-color:#fbbf24;color:#92400e}" + 4563 ".caf-bk-toast.info{background:#dbeafe;border-color:#60a5fa;color:#1e40af}" + 4564 ".caf-bk-toast-ic{flex:0 0 auto;width:20px;height:20px}" + 4565 ".caf-bk-toast-body{flex:1;font-size:13px;line-height:1.5}" + 4566 ".caf-bk-toast-h{font-weight:700;font-size:13.5px;margin-bottom:2px}" + 4567 ".caf-bk-toast-x{flex:0 0 auto;background:transparent;border:none;color:inherit;opacity:.7;cursor:pointer;font-size:18px;line-height:1;padding:2px 4px}" + 4568 ".caf-bk-toast-x:hover{opacity:1}" + 4569 4570 /* ââ #48 stepper visivo persistente ââ */ 4571 ".caf-bk-stepper{display:flex;align-items:center;gap:0;padding:14px 18px;background:#fff;border-bottom:1px solid #e2e8f0;position:sticky;top:0;z-index:10}" + 4572 ".caf-bk-stepper-item{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;position:relative;cursor:pointer;font-size:11px;color:#5b6573;font-weight:600;transition:color .2s}" + 4573 ".caf-bk-stepper-item.done{color:" + c + ";cursor:pointer}" + 4574 ".caf-bk-stepper-item.active{color:#0f172a;font-weight:700}" + 4575 ".caf-bk-stepper-item.locked{cursor:not-allowed}" + 4576 ".caf-bk-stepper-dot{width:26px;height:26px;border-radius:50%;background:#f1f5f9;color:#5b6573;border:2px solid #e2e8f0;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;transition:all .25s ease;z-index:1;position:relative}" + 4577 ".caf-bk-stepper-item.done .caf-bk-stepper-dot{background:" + c + ";color:#fff;border-color:" + c + "}" + 4578 ".caf-bk-stepper-item.active .caf-bk-stepper-dot{background:#fff;color:" + c + ";border-color:" + c + ";box-shadow:0 0 0 4px " + c + "22;transform:scale(1.08)}" + 4579 ".caf-bk-stepper-bar{position:absolute;top:13px;height:2px;background:#e2e8f0;width:100%;z-index:0}" + 4580 ".caf-bk-stepper-bar-fill{position:absolute;top:13px;left:0;height:2px;background:" + c + ";z-index:0;transition:width .4s cubic-bezier(.65,0,.35,1)}" + 4581 "@media(max-width:480px){.caf-bk-stepper{padding:12px 10px;gap:0}.caf-bk-stepper-item{font-size:9.5px}.caf-bk-stepper-dot{width:22px;height:22px;font-size:11px}.caf-bk-stepper-bar,.caf-bk-stepper-bar-fill{top:11px}}" + 4582 4583 /* ââ #49 skeleton realistici per ogni step ââ */ 4584 "@keyframes caf-bk-skel-shimmer{0%{background-position:-100% 0}100%{background-position:200% 0}}" + 4585 ".caf-bk-skel{background:linear-gradient(90deg,#f1f5f9 0%,#e2e8f0 50%,#f1f5f9 100%);background-size:200% 100%;animation:caf-bk-skel-shimmer 1.4s ease-in-out infinite;border-radius:8px}" + 4586 ".caf-bk-skel-tile{height:74px;border-radius:14px;margin-bottom:10px}" + 4587 ".caf-bk-skel-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px}" + 4588 ".caf-bk-skel-slot{height:48px;border-radius:10px}" + 4589 ".caf-bk-skel-slots{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:8px}" + 4590 4591 /* ââ #57 error boundary visivo ââ */ 4592 ".caf-bk-errbox{padding:24px 22px;background:#fff;border-radius:16px;border:1px solid #fee2e2;box-shadow:0 8px 24px -8px rgba(239,68,68,.18);display:flex;flex-direction:column;align-items:center;text-align:center;gap:8px}" + 4593 ".caf-bk-errbox-ic{width:60px;height:60px;border-radius:50%;background:linear-gradient(135deg,#fee2e2,#fecaca);display:flex;align-items:center;justify-content:center;color:#b91c1c;margin-bottom:6px}" + 4594 ".caf-bk-errbox-ic svg{width:28px;height:28px}" + 4595 ".caf-bk-errbox-h{font-size:17px;font-weight:700;color:#0f172a;margin:0}" + 4596 ".caf-bk-errbox-msg{font-size:13.5px;color:#475569;line-height:1.55;margin:0;max-width:420px}" + 4597 ".caf-bk-errbox-actions{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:14px}" + 4598 ".caf-bk-errbox-btn{display:inline-flex;align-items:center;gap:6px;padding:10px 16px;border-radius:10px;font-size:13px;font-weight:600;cursor:pointer;border:1.5px solid transparent;transition:all .2s}" + 4599 ".caf-bk-errbox-btn.primary{background:" + c + ";color:#fff;border-color:" + c + "}" + 4600 ".caf-bk-errbox-btn.primary:hover{filter:brightness(1.08)}" + 4601 ".caf-bk-errbox-btn.ghost{background:#fff;color:#475569;border-color:#e2e8f0}" + 4602 ".caf-bk-errbox-btn.ghost:hover{background:#f8fafc;border-color:#cbd5e1}" + 4603 ".caf-bk-errbox-btn.helpline{background:#fef3c7;color:#92400e;border-color:#fbbf24}" + 4604 ".caf-bk-errbox-btn.helpline:hover{background:#fde68a}" + 4605 ".caf-bk-errbox-btn svg{width:14px;
4605height:14px}" + 4606 ".caf-bk-errbox-code{margin-top:10px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:#5b6573;letter-spacing:.3px}" + 4607 4608 /* ââ #60 resume banner ââ */ 4609 ".caf-bk-resume{padding:14px 16px;background:linear-gradient(135deg,#dbeafe,#bfdbfe);border:1px solid #93c5fd;border-radius:12px;margin:0 0 14px;display:flex;align-items:center;gap:12px;color:#1e3a8a}" + 4610 ".caf-bk-resume-ic{flex:0 0 auto;width:36px;height:36px;border-radius:10px;background:rgba(255,255,255,.55);display:flex;align-items:center;justify-content:center;font-size:18px}" + 4611 ".caf-bk-resume-body{flex:1;min-width:0;font-size:13px;line-height:1.45}" + 4612 ".caf-bk-resume-h{font-weight:700;margin-bottom:2px}" + 4613 ".caf-bk-resume-actions{display:flex;gap:6px;flex-shrink:0}" + 4614 ".caf-bk-resume-btn{padding:7px 12px;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer;border:none}" + 4615 ".caf-bk-resume-btn.primary{background:#1e40af;color:#fff}" + 4616 ".caf-bk-resume-btn.ghost{background:transparent;color:#1e3a8a}" + 4617 4618 /* ââ #34 phone with country prefix ââ */ 4619 ".caf-bk-tel-wrap{display:flex;gap:6px;align-items:stretch}" + 4620 ".caf-bk-tel-cc{position:relative;flex:0 0 auto}" + 4621 ".caf-bk-tel-cc-btn{display:inline-flex;align-items:center;gap:5px;padding:0 12px;height:100%;border:1.5px solid #e2e8f0;border-radius:10px;background:#fff;font-size:13px;font-weight:600;color:#334155;cursor:pointer}" + 4622 ".caf-bk-tel-cc-btn:hover{border-color:#cbd5e1}" + 4623 ".caf-bk-tel-cc-pop{position:absolute;top:calc(100% + 4px);left:0;background:#fff;border:1px solid #e2e8f0;border-radius:10px;box-shadow:0 12px 32px rgba(0,0,0,.12);max-height:240px;overflow:auto;width:240px;z-index:50;animation:caf-bk-fade-in .2s ease-out}" + 4624 ".caf-bk-tel-cc-item{display:flex;align-items:center;gap:8px;padding:9px 12px;cursor:pointer;font-size:13px;color:#334155;border-bottom:1px solid #f1f5f9}" + 4625 ".caf-bk-tel-cc-item:hover{background:#f8fafc}" + 4626 ".caf-bk-tel-cc-item-flag{font-size:18px}" + 4627 ".caf-bk-tel-cc-item-code{flex:0 0 auto;color:#5b6573;font-weight:600;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}" + 4628 ".caf-bk-tel-input{flex:1;min-width:0}" + 4629 4630 /* ââ #33 ricorda dati checkbox ââ */ 4631 ".caf-bk-remember{display:flex;align-items:flex-start;gap:9px;padding:10px 0;cursor:pointer;font-size:12.5px;line-height:1.5;color:#475569}" + 4632 ".caf-bk-remember input{margin-top:2px;flex:0 0 auto;accent-color:" + c + "}" + 4633 ".caf-bk-remember-sub{font-size:11px;color:#5b6573;margin-top:2px}" + 4634 4635 /* ââ network/offline banner ââ */ 4636 ".caf-bk-net-banner{padding:9px 14px;font-size:12.5px;display:flex;align-items:center;gap:8px;line-height:1.45}" + 4637 ".caf-bk-net-banner.offline{background:#fef2f2;color:#991b1b;border-bottom:1px solid #fecaca}" + 4638 ".caf-bk-net-banner.slow{background:#fef3c7;color:#92400e;border-bottom:1px solid #fcd34d}" + 4639 ".caf-bk-net-banner svg{flex:0 0 auto;width:14px;height:14px}" + 4640 4641 /* ââ #52 reduced motion esteso a tutto il booking ââ */ 4642 "@media(prefers-reduced-motion:reduce){" + 4643 ".caf-bk-fs-tile,.caf-bk-fs-stage,.caf-bk-quickpick,.caf-bk-toast,.caf-bk-skel,.caf-bk-confirm-mega{animation:none!important;transition:none!important}" + 4644 ".caf-bk-quickpick::before{animation:none!important}" + 4645 ".caf-bk-fs-tile-popular{animation:none!important}" + 4646 "}" + 4647 4648 /* ââ #42 GDPR consent banner âââââââââââââââââââââââââââââââââââââ */ 4649 "@keyframes caf-bk-gdpr-in{0%{opacity:0;transform:translateY(20px)}100%{opacity:1;transform:translateY(0)}}" + 4650 /* Banner consenso CONFINATO al chat panel: backdrop assoluto interno + card. 4651 * Niente position:fixed sul viewport â non deve mai coprire la pagina host. */ 4652 ".caf-bk-gdpr-backdrop{position:absolute;inset:0;z-index:100;background:rgba(15,23,42,.45);display:flex;align-items:flex-end;justify-content:center;padding:10px;border-radius:inherit;animation:caf-bk-fade-in .25s ease-out}" + 4653 ".caf-bk-gdpr{position:relative;width:100%;max-width:100%;background:#fff;border:1px solid #e2e8f0;border-radius:14px;box-shadow:0 10px 30px rgba(0,0,0,.2);padding:14px 16px;font-family:inherit;animation:caf-bk-gdpr-in .35s cubic-bezier(.34,1.56,.64,1) both;color:#0f172a;max-height:100%;overflow:auto}" + 4654 ".caf-bk-gdpr-row{display:flex;align-items:flex-start;gap:14px}" + 4655 ".caf-bk-gdpr-ic{flex:0 0 auto;width:38px;height:38px;border-radius:11px;background:linear-gradient(135deg," + c + "22," + c + "11);color:" + c + ";display:flex;align-items:center;justify-content:center}" + 4656 ".caf-bk-gdpr-ic svg{width:20px;height:20px}" + 4657 ".caf-bk-gdpr-body{flex:1;min-width:0;font-size:13.5px;line-height:1.55;color:#334155}" + 4658 ".caf-bk-gdpr-h{font-size:14.5px;font-weight:700;color:#0f172a;margin:0 0 4px}" + 4659 ".caf-bk-gdpr-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}" + 4660 ".caf-bk-gdpr-btn{display:inline-flex;align-items:center;gap:6px;padding:9px 16px;border-radius:9px;font-size:13px;font-weight:600;cursor:pointer;border:1.5px solid transparent;font-family:inherit;transition:all .2s}" + 4661 ".caf-bk-gdpr-btn.primary{background:" + c + ";color:#fff;border-color:" + c + "}" + 4662 ".caf-bk-gdpr-btn.primary:hover{filter:brightness(1.08)}" + 4663 /* primary-outline = stessa enfasi di primary ma stile inverso (per "Rifiuta tutti") 4664 * GDPR EDPB 03/2022: accept e reject devono essere visualmente equivalenti */ 4665 ".caf-bk-gdpr-btn.primary-outline{background:#fff;color:" + c + ";border-color:" + c + "}" + 4666 ".caf-bk-gdpr-btn.primary-outline:hover{background:" + c + "10}" + 4667 ".caf-bk-gdpr-btn.ghost{background:#fff;color:#475569;border-color:#e2e8f0}" + 4668 ".caf-bk-gdpr-btn.ghost:hover{background:#f8fafc;border-color:#cbd5e1}" + 4669 ".caf-bk-gdpr-link{color:" + c + ";text-decoration:underline;cursor:pointer;font-size:12px}" + 4670 ".caf-bk-gdpr-info{margin-top:8px;font-size:12px;color:#5b6573}" + 4671 ".caf-bk-gdpr-prefs{margin:14px 0 8px;display:flex;flex-direction:column;gap:8px;padding:12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px}" + 4672 ".caf-bk-gdpr-pref{display:flex;align-items:flex-start;gap:10px;font-size:12.5px;line-height:1.5;cursor:pointer;color:#334155}" + 4673 ".caf-bk-gdpr-pref input{margin-top:3px;flex:0 0 auto;accent-color:" + c + "}" + 4674 ".caf-bk-gdpr-pref input:disabled{opacity:.6;cursor:not-allowed}" + 4675 ".caf-bk-gdpr-pref small{display:block;color:#5b6573;margin-top:2px;font-size:11.5px;line-height:1.45}" + 4676 "@media(max-width:480px){.caf-bk-gdpr{padding:12px 14px}.caf-bk-gdpr-row{gap:10px}.caf-bk-gdpr-body{font-size:12.5px}}" + 4677 4678 /* ââ #1 CF auto-fill feedback chip âââââââââââââââââââââââââââââââ */ 4679 ".caf-bk-cf-chip{display:flex;align-items:center;gap:8px;margin-top:6px;padding:8px 12px;background:#ecfdf5;border:1px solid #86efac;border-radius:8px;font-size:12.5px;color:#166534;animation:caf-bk-fade-in .3s ease-out}" + 4680 ".caf-bk-cf-chip.warn{background:#fef3c7;border-color:#fcd34d;color:#92400e}" + 4681 ".caf-bk-cf-chip svg{width:14px;
4681height:14px;flex:0 0 auto}" + 4682 4683 /* ââ #2 smart re-booking card ââââââââââââââââââââââââââââââââââââ */ 4684 ".caf-bk-rebook{margin:0 0 14px;padding:16px 18px;background:linear-gradient(135deg,#f0f9ff,#e0f2fe);border:1px solid #bae6fd;border-radius:14px;display:flex;align-items:center;gap:14px;cursor:pointer;transition:all .25s ease;color:#0f172a}" + 4685 ".caf-bk-rebook:hover{transform:translateY(-2px);box-shadow:0 8px 20px rgba(14,165,233,.15);border-color:#7dd3fc}" + 4686 ".caf-bk-rebook-ic{flex:0 0 auto;width:42px;height:42px;border-radius:12px;background:#0369a1;color:#fff;display:flex;align-items:center;justify-content:center}" + 4687 ".caf-bk-rebook-ic svg{width:20px;height:20px}" + 4688 ".caf-bk-rebook-body{flex:1;min-width:0;line-height:1.45}" + 4689 ".caf-bk-rebook-lbl{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#0369a1;margin-bottom:3px}" + 4690 ".caf-bk-rebook-h{font-size:14.5px;font-weight:700;color:#0f172a}" + 4691 ".caf-bk-rebook-s{font-size:12.5px;color:#475569;margin-top:2px;overflow-wrap:anywhere}" + 4692 ".caf-bk-rebook-arrow{flex:0 0 auto;font-size:18px;font-weight:700;color:#0369a1}" + 4693 4694 /* ââ #13 mobile bottom-sheet con grab handle e snap âââââââââââââââ */ 4695 ".caf-bk-fs-mbsum{position:relative}" + 4696 ".caf-bk-fs-mbsum-grab{display:none;position:absolute;left:50%;top:6px;transform:translateX(-50%);width:36px;height:4px;background:#cbd5e1;border-radius:2px;cursor:grab}" + 4697 "@media(max-width:820px){.caf-bk-fs-mbsum-grab{display:block}.caf-bk-fs-mbsum{padding-top:18px;touch-action:pan-y}.caf-bk-fs-mbsum.dragging{transition:none}}" + 4698 4699 /* ââ #14 form keyboard handling: padding extra quando input focused ââ */ 4700 ".caf-bk-fs-main.kb-padded{padding-bottom:280px!important;transition:padding-bottom .2s ease}" + 4701 4702 /* ââ #31 SVG confetti più ricco ââââââââââââââââââââââââââââââââââââ */ 4703 "@keyframes caf-bk-confetti-fall{0%{transform:translate(0,-120%) rotate(0)}100%{transform:translate(var(--tx,0),120vh) rotate(var(--tr,720deg))}}" + 4704 ".caf-bk-confetti-svg{position:absolute;width:14px;height:14px;pointer-events:none;animation:caf-bk-confetti-fall var(--td,1.6s) cubic-bezier(.4,0,.6,1) forwards;animation-delay:var(--tdelay,0s);will-change:transform}" + 4705 "@media(prefers-reduced-motion:reduce){.caf-bk-confetti-svg{display:none}}" + 4706 /* ââ a11y overrides (appended last to win the cascade) ââ */ 4707 ".caf-sr-only{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}" + 4708 ".caf-skip{position:absolute;left:8px;top:8px;z-index:20;transform:translateY(-160%);transition:transform .15s ease;background:#fff;color:var(--caf-c-ink);border:2px solid var(--caf-c-ink);border-radius:8px;padding:8px 12px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit}" + 4709 ".caf-skip:focus,.caf-skip:focus-visible{transform:translateY(0)}" + 4710 /* Anello di focus: NIENTE border-radius forzato â l'outline segue la 4711 * forma REALE dell'elemento (sul pallino rotondo -> anello rotondo; 4712 * l'8px imposto lo faceva sembrare un riquadro squadrato). */ 4713 ":host :focus-visible{outline:3px solid var(--caf-c-ink)!important;outline-offset:2px!important;box-shadow:0 0 0 2px #fff}"+ 4714 /* scroll-margin SOLO nello scroller dei messaggi: applicato globalmente 4715 * faceva scrollare a Chrome anche il PANNELLO (overflow:hidden e' pur 4716 * sempre uno scroll container): contenuto shiftato in alto e fascia 4717 * bianca permanente sotto il footer (bug "spazio bianco" Chrome-only). */ 4718 ".caf-messages :focus-visible{scroll-margin:96px 0}" + 4719 ".caf-input:focus-visible,.caf-bk-inp:focus-visible,.caf-bk-search input:focus-visible,.caf-bk-fs-search input:focus-visible{outline:2px solid var(--caf-c-ink)!important;outline-offset:1px!important;box-shadow:none}" + 4720 ".caf-msg-wrap:focus-within .caf-copy-btn,.caf-msg-wrap:focus-within .caf-translate-btn{opacity:1;pointer-events:auto}" + 4721 ".caf-msg-bubble.user,.caf-msg-bubble.user a,.caf-vt-bubble.user,.caf-mic,.caf-bk-pri,.caf-bk-fs-btn-pri,.caf-bk-cta-btn,.caf-bk-cta-mini,.caf-bk-locate,.caf-bk-expand-banner,.caf-bk-quickpick,.caf-bk-gdpr-btn.primary,.caf-bk-errbox-btn.primary,.caf-bk-srv-cat.active,.caf-bk-fs-monthcal-day.selected,.caf-bk-fs-monthcal-day.available:hover,.caf-bk-nearby-btn:hover,.caf-quick button:hover .caf-quick-ic,.caf-quick button:hover .caf-quick-ar,.caf-bk-step-dot.done,.caf-bk-rb.on{color:var(--caf-on-c)}" + 4722 ".caf-scroll-btn,.caf-msg-bubble a,.caf-url-badge,.caf-phone-badge,.caf-bk-crumb,.caf-bk-act,.caf-bk-more,.caf-inline-bk-chip,.caf-ghost-sug-chip,.caf-bk-fs-step-num,.caf-bk-step-lbl.active,.caf-bk-slot-fascia.active,.caf-bk-fs-mbsum-count,.caf-bk-fs-mbsum-toggle,.caf-bk-fs-side-ic,.caf-bk-fs-done-id,.caf-bk-sum-premium-edit,.caf-bk-fs-monthcal-day.available{color:var(--caf-c-ink)}" + 4723 ".caf-bk-confirm-mega{background:linear-gradient(135deg,var(--caf-c-ink),color-mix(in srgb,var(--caf-c-ink) 80%,#1e293b))}" + 4724 ".caf-bk-sum-premium-head{background:linear-gradient(135deg,var(--caf-c-ink),color-mix(in srgb,var(--caf-c-ink) 78%,#1e293b))}" + 4725 ".caf-bubble.caf-bk-active{background:linear-gradient(135deg,var(--caf-c-ink),color-mix(in srgb,var(--caf-c-ink) 80%,#1e293b))}" + 4726 /* a11y #C40: quickpick come confirm-mega: gradiente su base ink + testo 4727 * bianco (vince sull'override onC r.4602 perche' successivo in cascata) */ 4728 ".caf-bk-quickpick{background:linear-gradient(135deg,var(--caf-c-ink),color-mix(in srgb,var(--caf-c-ink) 75%,#1e293b));color:#fff}" + 4729 ".caf-bk-cta-dismiss,.caf-bk-toast-x{min-width:24px;min-height:24px}" + 4730 ".caf-bk-back,.caf-bk-cancel,.caf-bk-sum-premium-edit,.caf-bk-fs-tile-docs,.caf-bk-fs-mbsum-toggle{min-height:24px}" + 4731 "" 4732 ); 4733 } 4734 4735 /* ââ WIDGET CLASS âââââââââââââââââââââââââââââââââââââââââââ */ 4736 function CafChat(opts) { 4737 this.opts = opts; 4738 /* i18n: rileva lingua una sola volta all'avvio dell'istanza */ 4739 BK_LANG = bkDetectLang(opts); 4740 this.isOpen = false;
4741 this.isLoading = false; 4742 this.messages = []; 4743 this.menuOpen = false; 4744 this.isAtBottom = true; 4745 this.unreadCount = 0; 4746 this.rating = null; 4747 this._saveTimeout = null; 4748 this._scrollRaf = null; 4749 this._voiceMode = false; 4750 this._isListening = false; 4751 this._isSpeaking = false; 4752 this._recognition = null; 4753 this._audioQueue = []; 4754 this._ttsAbortControllers = []; 4755 this._ttsEpoch = 0; 4756 this._isPlayingQueue = false; 4757 this._currentAudio = null; 4758 this._chatAbort = null; 4759 this._lastMessageWasVoice = false; 4760 this._listenTimer = null; 4761 this._voiceInterimText = ""; 4762 this._vadInterval = null; 4763 this._vadStream = null; 4764 this._voiceStreamingBubble = null; 4765 this._voiceGhostEl = null; 4766 this._lastFinalIdx = 0; 4767 this._finalBuffer = ""; 4768 this._sendDebounce = null; 4769 this.isFullscreen = false; 4770 /* booking state */ 4771 this.bkStep = "idle"; this._bkDir = "fwd"; 4772 this.bkRegioni = []; this.bkSelRegione = null; 4773 this.bkProvince = []; this.bkSelProv = null; 4774 this.bkServizi = []; this.bkSelServ = null; 4775 this.bkSedi = []; this.bkSelSede = null; 4776 this.bkSpazi = []; this.bkNextG = ""; this.bkSelSlot = null; 4777 /* Default: prenotazione "per me". */ 4778 this.bkBookingFor = "self"; 4779 /* #PII device-condiviso â NON pre-compiliamo ciecamente i dati salvati: li 4780 * teniamo come "candidato" e chiediamo conferma esplicita ("Sei tu?") allo 4781 * step collectData prima di adottarli. bkClient parte SEMPRE vuoto, così su 4782 * un PC condiviso (sportello) la persona successiva non invia per sbaglio i 4783 * dati di quella precedente. loadStoredClient è gated sul consenso functional. */ 4784 this._bkStoredCandidate = loadStoredClient(this.bkBookingFor); 4785 this.bkClient = {nome:"",cognome:"",codfis:"",email:"",cellulare:"",sesso:"M"}; 4786 this.bkId = null; this.bkErrMsg = ""; this.bkErrCode = null; this.bkErrReturn = null; 4787 this.bkFormErr = null; this._bkBusy = false; this._bkEl = null; 4788 this._bkOpGen = 0; this._bkActiveCtrl = null; this._bkLoadingReturnStep = null; this._bkLoadingMore = false; 4789 this._bkFsMode = false; this._bkFsEl = null; 4790 this._bkEnabled = undefined; 4791 this.bkHint = ""; /* extracted service name from user message */ 4792 /* password reset card state */ 4793 this.pwdStep = "idle"; /* idle | form | submitting | done | error */ 4794 this.pwdEmail = ""; 4795 this.pwdErr = ""; 4796 this._pwdEl = null; 4797 this.STORAGE_KEY = "caf-chat-inline-history"+CAF_NS; 4798 this.CONV_ID_KEY = "caf-chat-inline-conv-id"+CAF_NS; 4799 this.EMAIL_KEY = "caf-chat-inline-email"+CAF_NS; 4800 this.conversationId = this._loadConversationId(); 4801 this.userEmail = this._loadEmail(); 4802 this._init(); 4803 } 4804 4805 /* ââ Ecommerce: rendering card prodotti dentro un bubble. 4806 * Attivato dopo che bubble.innerHTML è stato impostato dal markdown. 4807 * Trova tutti gli anchor "<span class='caf-prod-anchor' data-prod-key='â¦'>" 4808 * e li sostituisce con card DOM ricche con event listener. 4809 * Idempotente: anchor già processati hanno data-prod-rendered="1". */ 4810 CafChat.prototype._renderEcommerceCards = function (bubble, userQuery) { 4811 if (!bubble || !this._ecommerceEnabled) return; 4812 if (!this._ecommerceProductIndex) return; 4813 var anchors = bubble.querySelectorAll(".caf-prod-anchor:not([data-prod-rendered])"); 4814 var self = this; 4815 /* I marker [[PROD:ID]] del bot vengono raccolti in UN UNICO carosello 4816 * orizzontale (foto in evidenza + scroll). Niente più card grandi 4817 * inline: esperienza coerente e ordinata. */ 4818 var collectedProducts = []; 4819 var seenKeys = {}; 4820 for (var i = 0; i < anchors.length; i++) { 4821 var a = anchors[i]; 4822 var key = a.getAttribute("data-prod-key") || ""; 4823 var prod = self._ecommerceProductIndex[key]; 4824 a.setAttribute("data-prod-rendered", "1"); 4825 /* L'anchor è invisibile (display:none); lasciarlo nel DOM preserva 4826 * la posizione testuale del marker senza romperla. */ 4827 if (prod && !seenKeys[key]) { 4828 seenKeys[key] = true; 4829 collectedProducts.push(prod); 4830 } 4831 } 4832 if (collectedProducts.length > 0) {
4833 /* Idempotenza: se il bubble ha già il carosello dei markers, skip. */ 4834 if (!bubble.querySelector(".caf-prod-carousel[data-prod-source='markers']")) { 4835 var label = collectedProducts.length === 1 ? "Prodotto" : "Prodotti consigliati"; 4836 var carousel = self._buildProductCarousel(collectedProducts, label); 4837 carousel.setAttribute("data-prod-source", "markers"); 4838 bubble.appendChild(carousel); 4839 } 4840 /* Se ci sono markers espliciti, NON aggiungere il carosello 4841 * auto-detected (sarebbe ridondante). */ 4842 return; 4843 } 4844 /* Nessun marker: prova intent detection su query utente. */ 4845 self._maybeAppendAutoCarousel(bubble, userQuery); 4846 }; 4847 4848 /** Appende un carosello orizzontale di prodotti se la query utente 4849 * associata a questo bubble esprime un intento "lista prodotti". 4850 * 4851 * Usa Claude (via /api/product-intent) per la classificazione semantica 4852 * â capisce qualsiasi forma di domanda, anche colloquiale o creativa. 4853 * Cache per-istanza delle risposte (stessa query = stesso risultato). 4854 * Se l'API fallisce/è down, fallback al regex locale (best-effort). */ 4855 CafChat.prototype._maybeAppendAutoCarousel = function (bubble, userQuery) { 4856 if (!bubble || !this._ecommerceEnabled || !this._ecommerceProducts) { 4857 console.debug("[CafChat][carousel] skip: ecommerce non pronto", { 4858 enabled: !!this._ecommerceEnabled, 4859 productsLoaded: !!this._ecommerceProducts 4860 }); 4861 return; 4862 } 4863 if (bubble.querySelector(".caf-prod-carousel")) return; 4864 if (bubble.getAttribute("data-prod-carousel-checked") === "1") return; 4865 if (!bubble.classList || !bubble.classList.contains("assistant")) return; 4866 if (!userQuery || typeof userQuery !== "string") return; 4867 bubble.setAttribute("data-prod-carousel-checked", "1"); 4868 var self = this; 4869 var queryKey = userQuery.trim().toLowerCase(); 4870 self._intentCache = self._intentCache || {}; 4871 4872 /* ââ FAST-PATH locale ââââââââââââââââââââââââââââââââââââââââ 4873 * Per query ovvie ("mostra card", "mostrami i prodotti", "voglio 4874 * vedere", "in offerta", "novità ", ecc.) renderizziamo il carosello 4875 * IMMEDIATAMENTE senza aspettare Claude. Garantisce comportamento 4876 * istantaneo e robustezza anche se /api/product-intent è offline o 4877 * mal interpreta la query (es. "card" come parola inglese ambigua). */ 4878 var fastIntent = self._fastLocalIntent(userQuery); 4879 if (fastIntent && fastIntent.show) { 4880 self._intentCache[queryKey] = fastIntent; 4881 self._renderCarouselFromIntent(bubble, fastIntent); 4882 return; 4883 } 4884 4885 /* Cache hit (Claude già consultato per questa query). */ 4886 if (self._intentCache[queryKey] !== undefined) { 4887 self._renderCarouselFromIntent(bubble, self._intentCache[queryKey]); 4888 return; 4889 } 4890 4891 /* Query ambigua: chiediamo a Claude. */ 4892 var url = (self.opts.baseUrl || "") + "/api/product-intent"; 4893 var ctrl = new AbortController(); 4894 var tid = setTimeout(function () { ctrl.abort(); }, 6000); 4895 fetch(url, { 4896 method: "POST", 4897 headers: { "Content-Type": "application/json" }, 4898 body: JSON.stringify({ 4899 message: userQuery, 4900 categories: self._ecommerceCategories || [], 4901 }), 4902 signal: ctrl.signal, 4903 }).then(function (r) { 4904 clearTimeout(tid); 4905 if (!r.ok) console.warn("[CafChat][carousel] /api/product-intent HTTP " + r.status); 4906 return r.ok ? r.json() : null; 4907 }).then(function (intent) { 4908 if (!intent) intent = self._detectProductIntentLocal(userQuery); 4909 console.debug("[CafChat][carousel] intent:", intent); 4910 self._intentCache[queryKey] = intent; 4911 /* Se il bubble è stato rimpiazzato da un re-render mentre attendevamo, 4912 * rifletti sull'ultimo bubble assistant invece di perdere il render. */ 4913 if (!bubble.isConnected) { 4914 self._reapplyCarouselToLatest(intent); 4915 return; 4916 } 4917 self._renderCarouselFromIntent(bubble, intent); 4918 }).catch(function (err) { 4919 clearTimeout(tid); 4920 console.warn("[CafChat][carousel] product-intent error:", err && err.message ? err.message : err); 4921 var intent = self._detectProductIntentLocal(userQuery); 4922 self._intentCache[queryKey] = intent; 4923 if (!bubble.isConnected) { 4924 self._reapplyCarouselToLatest(intent); 4925 return; 4926 } 4927 self._renderCarouselFromIntent(bubble, intent); 4928 }); 4929 }; 4930 4931 /** Fast-path locale: cattura le query ovvie senza chiamare Claude. 4932 * Più aggressivo del legacy _detectProductIntent perché è il primo 4933 * filtro: vogliamo che "mostra card" / "mostrami i prodotti" / etc. 4934 * triggerino IMMEDIATAMENTE il carosello. 4935 * NOTA: NON usiamo \b dopo lettere accentate (à ,è,é,ì,ò,ù) perché in 4936 * JS regex \b funziona solo tra ASCII word chars. Usiamo invece 4937 * lookahead/lookbehind di non-word ASCII oppure niente. */ 4938 CafChat.prototype._fastLocalIntent = function (text) { 4939 var t = String(text || "").toLowerCase().trim(); 4940 if (!t) return null; 4941 /* Promo / sconto / offerta */
4942 if (/(?:^|[^a-z0-9])(prom\w*|offert\w*|scont\w*|risparm\w*|sald\w*|svendit\w*|affar\w*)/i.test(t)) { 4943 return { show: true, type: "promo", category: "", label: "In offerta" }; 4944 } 4945 /* Novità (gestione speciale per accento finale) */ 4946 if (/(?:^|[^a-z0-9])(novit[aà ]|novita|nuov[oaie]\w*|appena\s*arrivat\w*)/i.test(t)) { 4947 return { show: true, type: "new", category: "", label: "Novità " }; 4948 } 4949 /* Popolari espliciti */ 4950 if (/(?:^|[^a-z0-9])(popolar\w*|piu\s*vendut\w*|più\s*vendut\w*|best\s*sell\w*|miglior\w*|top\b|trend\w*|gettonat\w*|consigl\w*|raccoman\w*|suggeri\w*)/i.test(t)) { 4951 return { show: true, type: "popular", category: "", label: "I più popolari" }; 4952 } 4953 /* Verbo "vedere" in qualsiasi forma + opzionali parole prima */ 4954 if (/(?:^|[^a-z0-9])(vogli\w*|vorrei|fammi|fai|fatemi|fateci|posso|potete)\s+(veder\w+|vis\w+)/i.test(t)) { 4955 return { show: true, type: "popular", category: "", label: "Selezione per te" }; 4956 } 4957 /* Verbi imperativi/azione che chiedono prodotti */ 4958 if (/(?:^|[^a-z0-9])(most\w+|mostra|mostrami|elenc\w*|catalog\w*|lista|vetrin\w*|vendet\w*|dammi|date\w*|fammi\s+veder\w*|fai\s+veder\w*)/i.test(t)) { 4959 return { show: true, type: "popular", category: "", label: "Selezione per te" }; 4960 } 4961 /* "cosa avete" / "cosa vendete" / "cosa c'è" / "che prodotti avete". 4962 * Restrittivo: NON matcha "che orari avete" o "che giorni siete aperti". 4963 * Richiede un sostantivo prodotto-correlato dopo "che". */ 4964 if (/(?:^|[^a-z0-9])(cosa\s+(avet\w*|vendet\w*|c[èe])|che\s+(prodott\w*|articol\w*|robe?\b|cose\b|tipi)\s+(avet\w*|vendet\w*))/i.test(t)) { 4965 return { show: true, type: "popular", category: "", label: "Selezione per te" }; 4966 } 4967 return null; 4968 }; 4969 4970 /** Quando il bubble originale è stato rimpiazzato da un re-render, 4971 * rendi il carosello sull'ultimo bubble assistente del DOM. */ 4972 CafChat.prototype._reapplyCarouselToLatest = function (intent) { 4973 if (!intent || !intent.show || !this.messagesEl) return; 4974 var bubbles = this.messagesEl.querySelectorAll(".caf-msg-bubble.assistant"); 4975 if (!bubbles.length) return; 4976 var last = bubbles[bubbles.length - 1]; 4977 if (!last || last.querySelector(".caf-prod-carousel")) return; 4978 this._renderCarouselFromIntent(last, intent); 4979 }; 4980 4981 /** Render carosello dato un intent oggetto {show, type, category, label}. */ 4982 CafChat.prototype._renderCarouselFromIntent = function (bubble, intent) { 4983 if (!intent || !intent.show) return; 4984 if (bubble.querySelector(".caf-prod-carousel")) return; 4985 var products = this._selectProductsByIntent( 4986 { type: intent.type, value: intent.category, label: intent.label }, 4987 15 4988 ); 4989 if (!products.length) return; 4990 var carousel = this._buildProductCarousel(products, intent.label); 4991 bubble.appendChild(carousel); 4992 }; 4993 4994 /** Fallback regex locale (usato solo se Claude API fallisce). */ 4995 CafChat.prototype._detectProductIntentLocal = function (text) { 4996 var legacy = this._detectProductIntent(text); 4997 if (!legacy) return { show: false, type: "none", category: "", label: "" }; 4998 return { 4999 show: true, 5000 type: legacy.type, 5001 category: legacy.value || "", 5002 label: legacy.label || "", 5003 }; 5004 }; 5005 5006 /** Riconosce l'intento "mostra lista prodotti" dal testo utente. 5007 * Ritorna {type, value, label} oppure null. 5008 * Categoria viene matchata solo se la query è breve O contiene un 5009 * "verbo d'intento" â così "ieri ho bevuto una birra" non triggera. */ 5010 CafChat.prototype._detectProductIntent = function (text) { 5011 var t = String(text || "").toLowerCase().trim(); 5012 if (!t) return null; 5013 /* Popolari / più venduti / consigliati / top / best seller / migliori */ 5014 if (/\b(popolar\w*|piu\s*vendut\w*|più\s*vendut\w*|best\s*sell\w*|consigl\w*|raccoman\w*|suggeri\w*|top\b|miglior\w*|preferit\w*|favorit\w*|trend\w*|gettonat\w*|gradit\w*)/i.test(t)) { 5015 return { type: "popular", label: "I più popolari" }; 5016 } 5017 /* Promo / sconto / offerta */ 5018 if (/\b(prom\w*|offert\w*|scont\w*|risparm\w*|sald\w*|svendit\w*|deal\b|affar\w*|conveni\w*)/i.test(t)) { 5019 return { type: "promo", label: "In offerta" }; 5020 } 5021 /* Novità / nuovi */ 5022 if (/\b(novit[aà ]\w*|nuov[oaie]\w*|appena\s*arrivat\w*|ultim[oi]\s*arriv\w*|ultime\s*nov)/i.test(t)) { 5023 return { type: "new", label: "Novità " }; 5024 } 5025 /* Verbo d'intento: usato per validare le categorie su query lunghe. */ 5026 var hasIntentVerb = /\b(most\w*|vogli\w*|vorrei|cerc\w*|avet\w*|dammi|fatemi|fammi|elenc\w*|trov\w*|catalog|vendet\w*|qual[ie])\b/i.test(t); 5027 /* Categoria specifica: cerca matching su MASTER_CATEGORY/CATEGORY */ 5028 var cats = this._ecommerceCategories || []; 5029 for (var i = 0; i < cats.length; i++) { 5030 var cat = cats[i]; 5031 var catLower = cat.toLowerCase(); 5032 if (catLower.length < 4) continue; 5033 var escaped = catLower.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 5034 var re = new RegExp("(^|[^a-z\u00e0-\u00ff])" + escaped + "([^a-z\u00e0-\u00ff]|$)", "i"); 5035 if (re.test(t)) { 5036 /* Accetta categoria solo se: query corta OR ha verbo d'intento */ 5037 if (t.length < 60 || hasIntentVerb) { 5038 return { type: "category", value: cat, label: "Categoria: " + cat }; 5039 } 5040 } 5041 } 5042 /* "Tutti i prodotti" / "cosa avete" / "mostrami" / "elenco" / "lista" */ 5043 if (/\b(tutti\s*i\s*prodott|cosa\s*avet|che\s*cosa\s*avet|mostr\w*|elenc\w*|catalog\w*|lista\s*prodott|vetrina|che\s*\w*\s*vendet)/i.test(t)) { 5044 return { type: "popular", label: "Selezione per te" }; 5045 } 5046 return null; 5047 }; 5048 5049 /** Seleziona N prodotti in base all'intento. */ 5050 CafChat.prototype._selectProductsByIntent = function (intent, n) { 5051 var rows = (this._ecommerceProducts || []).slice(); 5052 n = n || 15; 5053 function safeFloat(v){var x=parseFloat(v);return isFinite(x)?x:0;} 5054 function inStock(p){var s=safeFloat(p.WAREHOUSE_STOCK);return s>0;} 5055 function hasPromo(p){var pr=safeFloat(p.PRICE),pm=safeFloat(p.PRICE_PROMO);return pm>0&&pm<pr;} 5056 var pool; 5057 switch (intent.type) { 5058 case "promo": 5059 pool = rows.filter(function(p){return hasPromo(p)&&inStock(p)&&p.IMAGE_URL;}); 5060 pool.sort(function(a,b){ 5061 var sa=(safeFloat(a.PRICE)-safeFloat(a.PRICE_PROMO))/Math.max(1,safeFloat(a.PRICE)); 5062 var sb=(safeFloat(b.PRICE)-safeFloat(b.PRICE_PROMO))/Math.max(1,safeFloat(b.PRICE)); 5063 return sb-sa; 5064 }); 5065 break; 5066 case "category": 5067 pool = rows.filter(function(p){ 5068 var mc=String(p.MASTER_CATEGORY||"").toLowerCase(); 5069 var cc=String(p.CATEGORY||"").toLowerCase(); 5070 var v=intent.value.toLowerCase(); 5071 return (mc===v||cc.indexOf(v)>=0||mc.indexOf(v)>=0)&&inStock(p)&&p.IMAGE_URL; 5072 }); 5073 /* Promo prima, poi stock alto */ 5074 pool.sort(function(a,b){ 5075 var pa=hasPromo(a)?1:0,pb=hasPromo(b)?1:0; 5076 if(pa!==pb)return pb-pa; 5077 return safeFloat(b.WAREHOUSE_STOCK)-safeFloat(a.WAREHOUSE_STOCK); 5078 }); 5079 break; 5080 case "new": 5081 /* Heuristic: ID più alto = aggiunto di recente */ 5082 pool = rows.filter(function(p){return inStock(p)&&p.IMAGE_URL;}); 5083 pool.sort(function(a,b){return parseInt(b.ID||"0",10)-parseInt(a.ID||"0",10);}); 5084 break; 5085 case "popular": 5086 default: 5087 /* Popolare = ha promo + stock alto + immagine + categoria utile */ 5088 pool = rows.filter(function(p){ 5089 if(!inStock(p))return false;
5090 if(!p.IMAGE_URL)return false; 5091 if(String(p.MASTER_CATEGORY||"").toUpperCase()==="ALTRO")return false; 5092 return true; 5093 }); 5094 pool.sort(function(a,b){ 5095 var sa=(hasPromo(a)?2:0)+Math.min(10,safeFloat(a.WAREHOUSE_STOCK)/100); 5096 var sb=(hasPromo(b)?2:0)+Math.min(10,safeFloat(b.WAREHOUSE_STOCK)/100); 5097 return sb-sa; 5098 }); 5099 break; 5100 } 5101 return pool.slice(0, n); 5102 }; 5103 5104 /** Costruisce un carosello orizzontale scrollabile di card prodotto. */ 5105 CafChat.prototype._buildProductCarousel = function (products, label) { 5106 var self = this; 5107 var c = this.opts.color; 5108 var carousel = document.createElement("div"); 5109 carousel.className = "caf-prod-carousel"; 5110 5111 var hdr = document.createElement("div"); 5112 hdr.className = "caf-prod-carousel-hdr"; 5113 var hLeft = document.createElement("div"); 5114 hLeft.className = "caf-prod-carousel-hdr-l"; 5115 hLeft.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg><span>' + escapeHtml(label || "Prodotti") + '</span>'; 5116 hdr.appendChild(hLeft); 5117 var hCount = document.createElement("span"); 5118 hCount.className = "caf-prod-carousel-count"; 5119 hCount.textContent = products.length + " prodott" + (products.length === 1 ? "o" : "i"); 5120 hdr.appendChild(hCount); 5121 carousel.appendChild(hdr); 5122 5123 /* Scroller wrapper con frecce */ 5124 var sw = document.createElement("div"); 5125 sw.className = "caf-prod-carousel-sw"; 5126 var btnL = document.createElement("button"); 5127 btnL.type = "button"; 5128 btnL.className = "caf-prod-carousel-arrow l"; 5129 btnL.setAttribute("aria-label", bkT("aria_scroll_left")); 5130 btnL.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="15 18 9 12 15 6"/></svg>'; 5131 var btnR = document.createElement("button"); 5132 btnR.type = "button"; 5133 btnR.className = "caf-prod-carousel-arrow r"; 5134 btnR.setAttribute("aria-label", bkT("aria_scroll_right")); 5135 btnR.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>'; 5136 var scroller = document.createElement("div"); 5137 scroller.className = "caf-prod-carousel-scroll"; 5138 5139 products.forEach(function (p, i) { 5140 var miniCard = self._buildProductMiniCard(p, c); 5141 miniCard.style.animationDelay = (i * 40) + "ms"; 5142 scroller.appendChild(miniCard); 5143 }); 5144 5145 btnL.addEventListener("click", function () { 5146 scroller.scrollBy({ left: -240, behavior: bkScrollBehavior() }); 5147 }); 5148 btnR.addEventListener("click", function () { 5149 scroller.scrollBy({ left: 240, behavior: bkScrollBehavior() }); 5150 }); 5151 /* Update arrow visibility on scroll */ 5152 function updateArrows() { 5153 btnL.style.opacity = scroller.scrollLeft > 4 ? "1" : "0"; 5154 btnL.style.pointerEvents = scroller.scrollLeft > 4 ? "auto" : "none"; 5155 var maxScroll = scroller.scrollWidth - scroller.clientWidth - 4; 5156 btnR.style.opacity = scroller.scrollLeft < maxScroll ? "1" : "0"; 5157 btnR.style.pointerEvents = scroller.scrollLeft < maxScroll ? "auto" : "none"; 5158 } 5159 scroller.addEventListener("scroll", updateArrows); 5160 setTimeout(updateArrows, 50); 5161 5162 sw.appendChild(btnL); 5163 sw.appendChild(scroller); 5164 sw.appendChild(btnR); 5165 carousel.appendChild(sw); 5166 return carousel; 5167 }; 5168 5169 /** Mini card prodotto (più compatta, per uso nel carosello). */ 5170 CafChat.prototype._buildProductMiniCard = function (p, c) { 5171 var self = this; 5172 function safeNum(v) { var n = parseFloat(v); return isFinite(n) && n >= 0 ? n : 0; } 5173 var price = safeNum(p.PRICE); 5174 var promo = safeNum(p.PRICE_PROMO); 5175 var hasPromo = promo > 0 && promo < price; 5176 var displayPrice = hasPromo ? promo : price; 5177 var savings = hasPromo ? Math.round(((price - promo) / price) * 100) : 0; 5178 var rawStock = parseFloat(p.WAREHOUSE_STOCK); 5179 var stockKnown = isFinite(rawStock); 5180 var outStock = stockKnown && rawStock <= 0; 5181 var lowStock = stockKnown && rawStock > 0 && rawStock < 20; 5182 var hasPrice = price > 0; 5183 var fmtPrice = function (n) { return "â¬" + (Math.round(n * 100) / 100).toFixed(2).replace(".", ","); }; 5184 5185 var card = document.createElement("div"); 5186 card.className = "caf-prod-mini"; 5187 5188 var imgWrap = document.createElement("div"); 5189 imgWrap.className = "caf-prod-mini-img-wrap"; 5190 if (hasPromo) { 5191 var pb = document.createElement("div"); 5192 pb.className = "caf-prod-mini-promo"; 5193 pb.textContent = "-" + savings + "%"; 5194 imgWrap.appendChild(pb); 5195 } 5196 if (outStock) { 5197 var ob = document.createElement("div"); 5198 ob.className = "caf-prod-mini-stock out"; 5199 ob.textContent = "Esaurito"; 5200 imgWrap.appendChild(ob); 5201 } else if (lowStock) { 5202 var lb = document.createElement("div"); 5203 lb.className = "caf-prod-mini-stock low"; 5204 lb.textContent = "Ultimi"; 5205 imgWrap.appendChild(lb); 5206 } 5207 var img = document.createElement("img"); 5208 img.className = "caf-prod-mini-img"; 5209 img.loading = "lazy"; 5210 img.alt = p.ITEM || ""; 5211 /* referrerpolicy=no-referrer: alcuni CDN bloccano via Referer header. */ 5212 img.referrerPolicy = "no-referrer"; 5213 /* Genera placeholder colorato + iniziali per fallback elegante. */ 5214 function genFallback(item, category) { 5215 var name = String(item || "Prodotto"); 5216 /* Iniziali: prime 2 parole significative (>2 char). */ 5217 var words = name.split(/\s+/).filter(function (w) { return w.length > 2; }); 5218 var initials = (words[0] ? words[0][0] : "?") + (words[1] ? words[1][0] : ""); 5219 initials = initials.toUpperCase().slice(0, 2); 5220 /* Colore deterministico in base al nome â palette coordinata. */ 5221 var palette = [ 5222 ["#fef3c7", "#92400e"], ["#dbeafe", "#1e40af"], ["#fce7f3", "#9d174d"], 5223 ["#dcfce7", "#166534"], ["#fed7aa", "#9a3412"], ["#e0e7ff", "#3730a3"], 5224 ["#f3e8ff", "#6b21a8"], ["#cffafe", "#155e75"], ["#fee2e2", "#991b1b"], 5225 ["#ecfccb", "#3f6212"] 5226 ]; 5227 var hash = 0; 5228 for (var i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) | 0; 5229 var colors = palette[Math.abs(hash) % palette.length]; 5230 var bg = colors[0], fg = colors[1]; 5231 var svg = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'>" + 5232 "<rect width='200' height='200' fill='" + bg + "'/>" + 5233 "<text x='100' y='100' text-anchor='middle' dominant-baseline='central' " + 5234 "font-family='-apple-system,Segoe UI,Roboto,sans-serif' font-weight='800' " + 5235 "font-size='84' fill='" + fg + "' letter-spacing='-2'>" + initials + "</text></svg>"; 5236 return "data:image/svg+xml;utf8," + encodeURIComponent(svg); 5237 } 5238 var fallbackSvg = genFallback(p.ITEM, p.MASTER_CATEGORY); 5239 /* URL rewrite: il JSON ha www.pizzone.com (404) ma le foto sono su 5240 * shop.pizzone.com (200). Sostituiamo automaticamente. Normalizziamo 5241 * anche eventuali doppi slash come "/upload//images.jpg". */ 5242 function rewriteImageUrl(u) { 5243 if (!u) return u; 5244 return String(u) 5245 .replace(/^https?:\/\/(?:www\.)?pizzone\.com\//i, "https://shop.pizzone.com/") 5246 .replace(/([^:])\/{2,}/g, "$1/"); 5247 } 5248 var imageUrl = rewriteImageUrl(p.IMAGE_URL); 5249 img.src = imageUrl || fallbackSvg; 5250 /* Se anche shop.pizzone.com fallisce, mostra il placeholder colorato. */ 5251 var retryDone = false;
5252 img.addEventListener("error", function () { 5253 if (!retryDone && imageUrl && img.src === imageUrl) { 5254 retryDone = true; 5255 img.removeAttribute("referrerpolicy"); 5256 img.src = imageUrl + (imageUrl.indexOf("?") >= 0 ? "&" : "?") + "_=" + Date.now(); 5257 return; 5258 } 5259 img.src = fallbackSvg; 5260 }); 5261 imgWrap.appendChild(img); 5262 card.appendChild(imgWrap); 5263 5264 var body = document.createElement("div"); 5265 body.className = "caf-prod-mini-body"; 5266 5267 var title = document.createElement("div"); 5268 title.className = "caf-prod-mini-title"; 5269 title.textContent = p.ITEM || "Prodotto"; 5270 body.appendChild(title); 5271 5272 if (hasPrice) { 5273 var prow = document.createElement("div"); 5274 prow.className = "caf-prod-mini-prow"; 5275 var pmain = document.createElement("span"); 5276 pmain.className = "caf-prod-mini-price"; 5277 pmain.textContent = fmtPrice(displayPrice); 5278 prow.appendChild(pmain); 5279 if (hasPromo) { 5280 var pold = document.createElement("span"); 5281 pold.className = "caf-prod-mini-price-old"; 5282 pold.textContent = fmtPrice(price); 5283 prow.appendChild(pold); 5284 } 5285 body.appendChild(prow); 5286 } 5287 card.appendChild(body); 5288 5289 return card; 5290 }; 5291 5292 /** Costruisce la card DOM per un prodotto. */ 5293 CafChat.prototype._buildProductCard = function (p, c) { 5294 var self = this; 5295 /* Safe numeric parsing: protezione da NaN/negativi/dati corrotti. 5296 * Se un valore è non-finito o negativo, lo trattiamo come 0 e 5297 * nascondiamo il relativo elemento in UI (no "â¬NaN" o prezzi negativi). */ 5298 function safeNum(v) { 5299 var n = parseFloat(v); 5300 return isFinite(n) && n >= 0 ? n : 0; 5301 } 5302 function safeInt(v) { 5303 var n = parseInt(v, 10); 5304 return isFinite(n) && n >= 0 ? n : 0; 5305 } 5306 var price = safeNum(p.PRICE); 5307 var promo = safeNum(p.PRICE_PROMO); 5308 var hasPromo = promo > 0 && promo < price; 5309 var displayPrice = hasPromo ? promo : price; 5310 var savings = hasPromo ? Math.round(((price - promo) / price) * 100) : 0; 5311 var perPiece = safeNum(p.PRICE_FOR_SINGLE_PIECE); 5312 var perPack = safeInt(p.PIECES_PER_PACK); 5313 /* Stock: WAREHOUSE_STOCK assente o NaN â trattiamo come unknown (NON 5314 * disabilitiamo l'add). Esaurito SOLO se è esplicitamente <= 0. */ 5315 var rawStock = parseFloat(p.WAREHOUSE_STOCK); 5316 var stockKnown = isFinite(rawStock); 5317 var stock = stockKnown ? rawStock : 1; /* unknown â consideriamo disponibile */ 5318 var lowStock = stockKnown && stock > 0 && stock < 20; 5319 var outStock = stockKnown && stock <= 0; 5320 var hasPrice = price > 0; 5321 var fmtPrice = function (n) { 5322 return "â¬" + (Math.round(n * 100) / 100).toFixed(2).replace(".", ","); 5323 }; 5324 5325 var card = document.createElement("div"); 5326 card.className = "caf-prod-card"; 5327 card.style.setProperty("--caf-prod-c", c); 5328 5329 /* image */ 5330 var imgWrap = document.createElement("div"); 5331 imgWrap.className = "caf-prod-img-wrap"; 5332 if (hasPromo) { 5333 var badge = document.createElement("div"); 5334 badge.className = "caf-prod-promo-badge"; 5335 badge.textContent = "-" + savings + "%"; 5336 imgWrap.appendChild(badge); 5337 } 5338 if (outStock) { 5339 var oo = document.createElement("div"); 5340 oo.className = "caf-prod-stock-badge out"; 5341 oo.textContent = "Esaurito"; 5342 imgWrap.appendChild(oo); 5343 } else if (lowStock) { 5344 var ls = document.createElement("div"); 5345 ls.className = "caf-prod-stock-badge low"; 5346 ls.textContent = "Ultimi pezzi"; 5347 imgWrap.appendChild(ls); 5348 } 5349 var img = document.createElement("img"); 5350 img.className = "caf-prod-img"; 5351 img.loading = "lazy"; 5352 img.alt = p.ITEM || ""; 5353 img.src = p.IMAGE_URL || "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect fill='%23f1f5f9' width='100' height='100'/%3E%3Ctext x='50' y='55' text-anchor='middle' fill='%2394a3b8' font-size='12'%3ENo image%3C/text%3E%3C/svg%3E"; 5354 img.addEventListener("error", function () { 5355 img.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect fill='%23f1f5f9' width='100' height='100'/%3E%3Cpath d='M30 35h40v30h-40z' fill='%23cbd5e1'/%3E%3Ccircle cx='42' cy='45' r='3' fill='%2394a3b8'/%3E%3Cpath d='M30 60l12-10 8 6 10-8 10 7v10h-40z' fill='%2394a3b8'/%3E%3C/svg%3E"; 5356 }); 5357 imgWrap.appendChild(img); 5358 card.appendChild(imgWrap); 5359 5360 /* body */ 5361 var body = document.createElement("div"); 5362 body.className = "caf-prod-body"; 5363 5364 if (p.MASTER_CATEGORY) { 5365 var cat = document.createElement("div"); 5366 cat.className = "caf-prod-cat"; 5367 cat.textContent = p.MASTER_CATEGORY; 5368 body.appendChild(cat); 5369 } 5370 5371 var title = document.createElement("div"); 5372 title.className = "caf-prod-title"; 5373 title.textContent = p.ITEM || "Prodotto"; 5374 body.appendChild(title); 5375 5376 /* meta row: pieces per pack + per-piece price */ 5377 var meta = document.createElement("div"); 5378 meta.className = "caf-prod-meta"; 5379 if (perPack > 0) { 5380 var pcs = document.createElement("span"); 5381 pcs.className = "caf-prod-pcs"; 5382 pcs.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg> ' + perPack + " pz"; 5383 meta.appendChild(pcs); 5384 } 5385 if (perPiece > 0) { 5386 var pp = document.createElement("span");
5387 pp.className = "caf-prod-perpiece"; 5388 pp.textContent = fmtPrice(perPiece) + "/pz"; 5389 meta.appendChild(pp); 5390 } 5391 if (meta.children.length) body.appendChild(meta); 5392 5393 /* prices row â solo se abbiamo un prezzo valido */ 5394 if (hasPrice) { 5395 var prow = document.createElement("div"); 5396 prow.className = "caf-prod-prow"; 5397 var pmain = document.createElement("span"); 5398 pmain.className = "caf-prod-price"; 5399 pmain.textContent = fmtPrice(displayPrice); 5400 prow.appendChild(pmain); 5401 if (hasPromo) { 5402 var pold = document.createElement("span"); 5403 pold.className = "caf-prod-price-old"; 5404 pold.textContent = fmtPrice(price); 5405 prow.appendChild(pold); 5406 } 5407 body.appendChild(prow); 5408 } 5409 5410 /* description (collapsible) */ 5411 if (p.DESCRIPTION) { 5412 var desc = document.createElement("div"); 5413 desc.className = "caf-prod-desc"; 5414 desc.textContent = String(p.DESCRIPTION).replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim(); 5415 body.appendChild(desc); 5416 } 5417 5418 /* CTA: + and qty + Aggiungi */ 5419 var cta = document.createElement("div"); 5420 cta.className = "caf-prod-cta"; 5421 var qtyBox = document.createElement("div"); 5422 qtyBox.className = "caf-prod-qty"; 5423 var minus = document.createElement("button"); 5424 minus.type = "button"; minus.className = "caf-prod-qty-btn"; minus.setAttribute("aria-label", bkT("aria_remove")); 5425 minus.textContent = "â"; 5426 var qtyInp = document.createElement("input"); 5427 qtyInp.type = "text"; qtyInp.inputMode = "numeric"; qtyInp.className = "caf-prod-qty-inp"; qtyInp.value = "1"; 5428 var plus = document.createElement("button"); 5429 plus.type = "button"; plus.className = "caf-prod-qty-btn"; plus.setAttribute("aria-label", bkT("aria_add")); 5430 plus.textContent = "+"; 5431 qtyBox.appendChild(minus); qtyBox.appendChild(qtyInp); qtyBox.appendChild(plus); 5432 cta.appendChild(qtyBox); 5433 5434 var addBtn = document.createElement("button"); 5435 addBtn.type = "button"; addBtn.className = "caf-prod-add"; 5436 addBtn.disabled = outStock; 5437 addBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.7 13.4a2 2 0 0 0 2 1.6h9.7a2 2 0 0 0 2-1.6L23 6H6"/></svg><span>' + (outStock ? "Esaurito" : "Aggiungi") + "</span>"; 5438 cta.appendChild(addBtn); 5439 5440 body.appendChild(cta); 5441 card.appendChild(body); 5442 5443 /* interactions */ 5444 minus.addEventListener("click", function () { 5445 var v = Math.max(1, (parseInt(qtyInp.value, 10) || 1) - 1); 5446 qtyInp.value = String(v); 5447 }); 5448 plus.addEventListener("click", function () { 5449 var v = Math.max(1, (parseInt(qtyInp.value, 10) || 1) + 1); 5450 qtyInp.value = String(v); 5451 }); 5452 qtyInp.addEventListener("input", function () { 5453 qtyInp.value = qtyInp.value.replace(/[^0-9]/g, "").slice(0, 4) || "1"; 5454 }); 5455 addBtn.addEventListener("click", function () { 5456 if (outStock) return; 5457 var qty = Math.max(1, parseInt(qtyInp.value, 10) || 1); 5458 self._ecommerceCart = self._ecommerceCart || []; 5459 self._ecommerceCart.push({ id: p.ID, code: p.CODE, name: p.ITEM, qty: qty, price: displayPrice }); 5460 /* feedback visivo */ 5461 addBtn.classList.add("added"); 5462 var lbl = addBtn.querySelector("span"); 5463 var prev = lbl ? lbl.textContent : "Aggiungi"; 5464 if (lbl) lbl.textContent = "â Aggiunto"; 5465 setTimeout(function () { 5466 addBtn.classList.remove("added"); 5467 if (lbl) lbl.textContent = prev; 5468 }, 1400); 5469 /* hook esterno (opzionale): l'host può ascoltare l'evento */ 5470 try { 5471 window.dispatchEvent(new CustomEvent("caf-product-add", { 5472 detail: { id: p.ID, code: p.CODE, name: p.ITEM, qty: qty, price: displayPrice, raw: p } 5473 })); 5474 } catch (_e) {} 5475 }); 5476 5477 /* expand/collapse description on click */ 5478 if (p.DESCRIPTION) { 5479 title.style.cursor = "pointer"; 5480 title.setAttribute("role", "button"); 5481 title.setAttribute("tabindex", "0"); 5482 title.setAttribute("aria-expanded", "false"); 5483 var _tglDesc = function () { 5484 var _ex = card.classList.toggle("expanded"); 5485 title.setAttribute("aria-expanded", _ex ? "true" : "false"); 5486 }; 5487 title.addEventListener("click", _tglDesc); 5488 title.addEventListener("keydown", function (ev) { 5489 if (ev.key === "Enter" || ev.key === " " || ev.key === "Spacebar") { ev.preventDefault(); _tglDesc(); } 5490 }); 5491 } 5492 5493 return card; 5494 }; 5495 5496 CafChat.prototype._init = function () { 5497 /* host element */ 5498 this.host = document.createElement("div"); 5499 this.host.id = "caf-chat-inline-root"; 5500 this.host.style.cssText = "position:fixed;z-index:" + this.opts.zIndex + ";top:0;left:0;width:0;height:0;pointer-events:none;"; 5501 document.body.appendChild(this.host); 5502 5503 /* ââ Ecommerce mode: AUTO-DETECT. 5504 * Tentiamo sempre il fetch del listino prodotti. Se il server lo
5505 * serve (HTTP 200) â ecommerce mode attivo automaticamente. Se 5506 * risponde 403 (env ECOMMERCE non impostato) â modalità chat 5507 * standard, nessun errore. Niente più dipendenza da 5508 * data-ecommerce sul tag <script>. */ 5509 this._ecommerceProducts = null; 5510 this._ecommerceProductIndex = null; 5511 this._ecommerceEnabled = false; 5512 var _ecSelf = this; 5513 var _ecUrl = this.opts.productsUrl || (this.opts.baseUrl + "/api/products"); 5514 fetch(_ecUrl, { headers: { Accept: "application/json" } }) 5515 .then(function (r) { 5516 if (r.status === 403) { 5517 /* Server dice "ecommerce non attivo" â modalità chat standard. */ 5518 return null; 5519 } 5520 if (!r.ok) { 5521 console.warn("[CafChat] /api/products HTTP " + r.status + " â modalità chat standard."); 5522 return null; 5523 } 5524 return r.json(); 5525 }) 5526 .then(function (data) { 5527 if (!data || !Array.isArray(data.rows) || !data.rows.length) return; 5528 _ecSelf._ecommerceProducts = data.rows; 5529 _ecSelf._ecommerceEnabled = true; 5530 var idx = {}; 5531 var catSet = {}; 5532 data.rows.forEach(function (p) { 5533 if (p.ID) idx["ID:" + String(p.ID).trim()] = p; 5534 if (p.CODE) idx["CODE:" + String(p.CODE).trim()] = p; 5535 if (p.MASTER_CATEGORY) catSet[String(p.MASTER_CATEGORY).trim()] = true; 5536 if (p.CATEGORY) catSet[String(p.CATEGORY).trim()] = true; 5537 }); 5538 _ecSelf._ecommerceProductIndex = idx; 5539 _ecSelf._ecommerceCategories = Object.keys(catSet) 5540 .filter(function (k) { return k && k.length >= 4; }) 5541 .sort(function (a, b) { return b.length - a.length; }); 5542 console.info("[CafChat] ecommerce mode attivo: " + data.rows.length + " prodotti, " + _ecSelf._ecommerceCategories.length + " categorie."); 5543 /* Re-render: se ci sono già messaggi nel DOM, le card/caroselli appaiono. */ 5544 if (_ecSelf._renderMessages) _ecSelf._renderMessages(); 5545 }) 5546 .catch(function (e) { 5547 console.warn("[CafChat] /api/products fetch failed:", e && e.message ? e.message : e); 5548 }); 5549 5550 /* Abandonment recovery: avvisa l'utente prima di chiudere se ha 5551 * una prenotazione in corso; logga evento al server. */ 5552 var _self=this; 5553 window.addEventListener("beforeunload",function(ev){ 5554 var midFlow=_self.bkStep&&["selectProvincia","selectServizio","selectSede","selectSlot","collectData","confirm"].indexOf(_self.bkStep)>=0; 5555 if(midFlow){ 5556 try{_self._bkLogEvent&&_self._bkLogEvent("abandonment_detected",{step:_self.bkStep});}catch(_e){} 5557 ev.preventDefault();ev.returnValue="";return""; 5558 } 5559 }); 5560 5561 /* Annual 730 reminder: se utente ha prenotato 730 negli ultimi 14 mesi 5562 * e siamo nella stagione 730 (mar/apr/mag/giu), mostra prompt. */ 5563 try{ 5564 var hist=loadBookingHistory();var now=new Date();var month=now.getMonth(); /* 0-11 */ 5565 var inSeason=month>=2&&month<=5; /* marzo-giugno */ 5566 if(inSeason&&hist&&hist.length){ 5567 for(var hi=0;hi<hist.length;hi++){ 5568 var b=hist[hi];if(!b||!b.servizio)continue; 5569 var srvDesc=(b.servizio.descrizione||b.servizio.desonline||"").toLowerCase(); 5570 if(srvDesc.indexOf("730")<0)continue; 5571 var ageMs=Date.now()-(b.when||0);var ageMonths=ageMs/(1000*60*60*24*30); 5572 if(ageMonths>=10&&ageMonths<=14){ 5573 this._bkAnnualReminder=b;break; 5574 } 5575 } 5576 } 5577 }catch(_e){} 5578 5579 /* #43 â auto-clear dati cliente stale (>30gg) + migrazione legacy keys */ 5580 try{bkMigrateLegacyClientKeys();bkAutoClearStaleClient();}catch(_e){} 5581 5582 /* shadow DOM */ 5583 this.shadow = this.host.attachShadow({ mode: "open" }); 5584 5585 /* styles */ 5586 var style = document.createElement("style"); 5587 style.textContent = buildCSS(this.opts); 5588 this.shadow.appendChild(style); 5589 5590 /* wrapper */ 5591 this.wrapper = document.createElement("div"); 5592 this.wrapper.setAttribute("lang", BK_LANG || "it"); 5593 this.shadow.appendChild(this.wrapper); 5594 5595 /* load messages */ 5596 this._loadMessages(); 5597 5598 /* build UI */ 5599 this._buildBubble();
5600 this._buildToggle(); 5601 this._buildPanel(); 5602 this._renderMessages(); 5603 5604 /* events */ 5605 var self = this; 5606 document.addEventListener("click", function () { 5607 if (self.menuOpen) { 5608 self.menuOpen = false; 5609 self._updateMenu(); 5610 } 5611 }); 5612 5613 /* Escape to close */ 5614 document.addEventListener("keydown", function (e) { 5615 if (e.key === "Escape" && self.isOpen) { 5616 /* 1.4.13: ESC chiude PRIMA eventuali popover/picker transitori aperti 5617 * (selettore lingua, popover documenti, prefisso telefonico) senza 5618 * chiudere l'intero pannello. */ 5619 if (self.shadow) { 5620 var _pops = self.shadow.querySelectorAll(".caf-lang-picker,.caf-bk-docs-pop,.caf-bk-tel-cc-pop"); 5621 if (_pops && _pops.length) { 5622 for (var _pi = 0; _pi < _pops.length; _pi++) { try { if (_pops[_pi]._cafCleanup) _pops[_pi]._cafCleanup(); _pops[_pi].remove(); } catch (_e) {} } 5623 return; 5624 } 5625 } 5626 /* Durante il booking fullscreen Escape e' gestito da _bkFsEscHandler 5627 * (chiude SOLO l'overlay, con ripristino del focus): qui non dobbiamo 5628 * chiudere l'intero pannello â prima i due handler correvano entrambi 5629 * e l'utente perdeva chat e wizard insieme (WCAG 3.2.1). */ 5630 if (self._bkFsMode) return; 5631 /* NON chiudere durante l'invio prenotazione (non idempotente, no 5632 * auto-retry): chiudere il pannello abbandonerebbe la POST "prenota" in 5633 * volo â prenotazione creata lato gestionale e persa lato client. */ 5634 if (self.bkStep === "submitting") return; 5635 self.close(); 5636 } 5637 }); 5638
5639 /* visualViewport & TASTIERA â MODELLO "NESSUN PIN" (round 3). 5640 * Storia (ERROREMOLTOGRAVE_IPHONE, ERRORECEANCORA): le versioni 5641 * precedenti pinnavano il pannello alla quota del visual viewport 5642 * (height, poi top+occlusione) per tenerlo incollato all'area visibile 5643 * con la tastiera aperta. Su iOS non puo' essere affidabile: iOS 26.0 ha 5644 * un bug DI SISTEMA confermato da Apple (WebKit #297779: vv.height 5645 * stabilmente minore del reale, offsetTop bloccato a ~24px dopo la 5646 * chiusura, oscillazioni ~10px al cambio direzione di scroll) e gia' 5647 * prima il vv escludeva porzioni variabili di UI Safari (pillola URL, 5648 * barra accessori): OGNI pin basato su vv lasciava una striscia tra 5649 * input e tastiera (pagina host o vuoto). 5650 * 5651 * Modello attuale: NESSUN PIN. Il pannello fullscreen resta inset:0 / 5652 * 100dvh e copre SEMPRE l'intero layout viewport: qualunque pan della 5653 * tastiera puo' mostrare solo pannello, mai la pagina host (l'area 5654 * visibile e' per costruzione un sottoinsieme del layout viewport). 5655 * A portare l'input sopra la tastiera e' il reveal NATIVO del browser 5656 * sull'elemento focused: l'unico meccanismo che conosce la geometria
5657 * VERA della tastiera, indipendente dai valori visualViewport. 5658 * Qui resta solo: (a) chat ancorata all'ultimo messaggio all'apertura 5659 * tastiera, con guard scale!=1 per non scroll-jackare il pinch-zoom 5660 * (WCAG 1.4.4); (b) pulizia difensiva di quote inline residue di 5661 * versioni precedenti col pin. */ 5662 if (window.visualViewport) { 5663 window.visualViewport.addEventListener("resize", function () { 5664 if (!self.isOpen) return; 5665 if (!self.panelEl.classList.contains("caf-fullscreen")) return; 5666 var vv = window.visualViewport; 5667 if (self.panelEl.style.height || self.panelEl.style.top || self.panelEl.style.borderBottom || self.panelEl.style.transition) { 5668 self.panelEl.style.transition = ""; 5669 self.panelEl.style.height = ""; 5670 self.panelEl.style.top = ""; 5671 self.panelEl.style.borderBottom = ""; 5672 } 5673 var scaled = Math.abs((vv.scale || 1) - 1) > 0.01; 5674 var kbOpen = !scaled && (window.innerHeight - vv.height) > 80; 5675 if (kbOpen) self._scrollToBottom(); 5676 }); 5677 } 5678 5679 /* Overlay diagnostico viewport (SOLO con flag esplicito: "cafvvdbg=1" 5680 * nell'URL della pagina host oppure window.__CAF_VV_DEBUG__ === true). 5681 * Mostra live innerHeight / visualViewport / rect del pannello con un 5682 * marcatore di versione: serve a MISURARE dal dispositivo reale i bug 5683 * di geometria iOS (WebKit #297779) da uno screenshot, senza devtools. 5684 * Nessun dato utente, nessuno storage, mai attivo di default. */ 5685 var _vvDbgOn = false; 5686 try { 5687 _vvDbgOn = window.__CAF_VV_DEBUG__ === true || /[?#&]cafvvdbg=1/.test(String(location.href)); 5688 } catch (_e) {} 5689 if (_vvDbgOn) { 5690 var _vvDbgEl = document.createElement("div"); 5691 _vvDbgEl.className = "caf-vv-debug"; 5692 _vvDbgEl.setAttribute("aria-hidden", "true"); 5693 _vvDbgEl.style.cssText = "position:absolute;top:6px;right:6px;z-index:2147483647;background:rgba(0,0,0,.78);color:#7cfc00;font:11px/1.45 monospace;padding:6px 9px;border-radius:8px;pointer-events:none;white-space:pre;text-align:left"; 5694 self.panelEl.appendChild(_vvDbgEl); 5695 setInterval(function () { 5696 try { 5697 var v = window.visualViewport; 5698 var r = self.panelEl.getBoundingClientRect(); 5699 _vvDbgEl.textContent = 5700 "kb3-20260720" + 5701 "\niH " + window.innerHeight + 5702 "\nvvH " + (v ? Math.round(v.height) : "-") + 5703 "\nvvT " + (v ? Math.round(v.offsetTop) : "-") + 5704 "\nsc " + (v ? (v.scale || 1).toFixed(2) : "-") + 5705 "\nsY " + Math.round(window.scrollY || 0) + 5706 "\npnl " + Math.round(r.top) + " / " + Math.round(r.bottom) + 5707 "\nae " + ((self.shadow && self.shadow.activeElement && self.shadow.activeElement.tagName) || "-"); 5708 } catch (_e) {} 5709 }, 300); 5710 } 5711 5712 /* #50/#51 â Online/offline + slow-network: re-render booking quando 5713 * cambia lo stato di rete così il banner appare/sparisce in tempo reale. */ 5714 var refreshNet = function() { 5715 if (self._bkFsMode && self._bkRenderFs) self._bkRenderFs(); 5716 else if (self.bkStep && self._bkRender) self._bkRender(); 5717 }; 5718 window.addEventListener("online", refreshNet); 5719 window.addEventListener("offline", refreshNet); 5720 try { 5721 var conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection; 5722 if (conn && conn.addEventListener) conn.addEventListener("change", refreshNet); 5723 } catch (_e) { /* unsupported */ } 5724 }; 5725 5726 /* ââ persistence ââââââââââââââââââââââââââââââââââââââââââââ */ 5727 CafChat.prototype._loadMessages = function () { 5728 if (this.opts.privacy) { 5729 this.messages = [ 5730 { id: "welcome", role: "assistant", content: timeAwareGreeting()+"! "+(this.opts.welcome||"")+((this.opts.aiDisclosure!==false)?("\n\n"+bkT("ai_disclosure")):"") }, 5731 ]; 5732 return; 5733 } 5734 try { 5735 var raw = localStorage.getItem(this.STORAGE_KEY); 5736 if (raw) { 5737 var stored = JSON.parse(raw); 5738 /* Retro-compat: legacy = array nudo; nuovo = {ts, msgs}. */ 5739 var parsed = Array.isArray(stored) 5740 ? stored 5741 : (stored && Array.isArray(stored.msgs) ? stored.msgs : null); 5742 /* TTL cronologia chat (GDPR art. 5(1)(e)): scaduta -> scarta. */ 5743 if (stored && !Array.isArray(stored) && typeof stored.ts === "number" && 5744 (Date.now() - stored.ts) > CHAT_HISTORY_TTL_MS) { 5745 try { localStorage.removeItem(this.STORAGE_KEY); } catch (_e) {} 5746 parsed = null; 5747 } 5748 if (parsed && parsed.length > 0) { 5749 /* Defense in depth: utenti che hanno usato il widget prima del fix 5750 * sentinel-parser possono avere `<<<MANTIS_*>>>` salvato in 5751 * localStorage. Lo ripuliamo al caricamento così non viene mai 5752 * mostrato all'utente. */
5753 for (var pi = 0; pi < parsed.length; pi++) { 5754 var pm = parsed[pi]; 5755 if (pm && typeof pm.content === "string" && 5756 (pm.content.indexOf("<<<MANTIS_") !== -1 || /\[F\d/.test(pm.content))) { 5757 try { pm.content = parseMantisSentinels(pm.content).clean; } 5758 catch (_e) { /* leave as-is on error */ } 5759 } 5760 } 5761 /* AI Act art. 50(1): utenti di RITORNO con cronologia salvata da 5762 * versioni pre-disclosure devono ricevere la dichiarazione di 5763 * natura IA anche a livello di messaggi (non solo badge/footer). */ 5764 if (this.opts.aiDisclosure !== false) { 5765 var _hasDiscl = false; 5766 for (var _di = 0; _di < parsed.length; _di++) { 5767 var _dmm = parsed[_di]; 5768 if (_dmm && _dmm.role === "assistant" && (_dmm.id === "ai-disclosure" || 5769 (typeof _dmm.content === "string" && _dmm.content.indexOf(bkT("ai_disclosure")) !== -1))) { 5770 _hasDiscl = true; break; 5771 } 5772 } 5773 if (!_hasDiscl) { 5774 parsed.push({ id: "ai-disclosure", role: "assistant", content: bkT("ai_disclosure"), timestamp: Date.now() }); 5775 } 5776 } 5777 this.messages = parsed; 5778 try { this._saveMessages(); } catch (_e) {} 5779 return; 5780 } 5781 } 5782 } catch (e) { 5783 localStorage.removeItem(this.STORAGE_KEY); 5784 } 5785 this.messages = [ 5786 { id: "welcome", role: "assistant", content: timeAwareGreeting()+"! "+(this.opts.welcome||"")+((this.opts.aiDisclosure!==false)?("\n\n"+bkT("ai_disclosure")):"") }, 5787 ]; 5788 this._maybePushAnnualReminder(); 5789 }; 5790 5791 /** Pushes a re-engagement message in chat if user has booked 730 ~12 months ago. */ 5792 CafChat.prototype._maybePushAnnualReminder = function(){ 5793 if(!this._bkAnnualReminder)return; 5794 /* skip se già visto in questa sessione */ 5795 try{var _ark=nsKey("caf-annual-reminder-shown");if(sessionStorage.getItem(_ark))return;sessionStorage.setItem(_ark,"1");}catch(_e){} 5796 var b=this._bkAnnualReminder;var lastDate=(b.slot&&b.slot.data)||""; 5797 var lastDateStr=fmtDate(lastDate); 5798 var msg="ð L'anno scorso hai prenotato il **730**"+(lastDateStr?" il "+lastDateStr:"")+". Vuoi rifarlo anche quest'anno? Posso aiutarti subito."; 5799 this.messages.push({id:quickId(),role:"assistant",content:msg,timestamp:Date.now()}); 5800 this._bkLogEvent&&this._bkLogEvent("annual_reminder_shown",{lastBookingId:b.bookingId||null}); 5801 }; 5802 5803 CafChat.prototype._saveMessages = function () { 5804 if (this.opts.privacy) return; 5805 var self = this; 5806 if (self._saveTimeout) clearTimeout(self._saveTimeout); 5807 self._saveTimeout = setTimeout(function () { 5808 try { 5809 /* @@BOOKING_LAUNCH@@ è salvato in localStorage di proposito: dopo 5810 * un refresh _renderMessages ricrea il bottone dal marker, così 5811 * la cronologia chat resta coerente. */ 5812 var toSave = self.messages.filter(function(m){return m.content!=="@@BOOKING@@"&&m.content!=="@@PWDRESET@@";}); 5813 if (toSave.length > 100) { 5814 var welcome = toSave[0] && toSave[0].id === "welcome" ? [toSave[0]] : []; 5815 toSave = welcome.concat(toSave.slice(-99)); 5816 } 5817 localStorage.setItem(self.STORAGE_KEY, JSON.stringify({ ts: Date.now(), msgs: toSave })); 5818 } catch (e) { /* quota */ } 5819 }, 500); 5820 }; 5821 5822 /* ââ conversation id âââââââââââââââââââââââââââââââââââââââ */ 5823 /* Retention (GDPR art. 5(1)(e)): l'id conversazione ruota oltre questa eta' 5824 * assoluta cosi' il documento server smette di essere aggiornato e rientra 5825 * nella purge; la cronologia chat locale scade sull'inattivita'. */ 5826 var CONV_ID_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000; 5827 var CHAT_HISTORY_TTL_MS = 90 * 24 * 60 * 60 * 1000; 5828 CafChat.prototype._loadConversationId = function () { 5829 if (this.opts.privacy) { 5830 /* ephemeral, in-memory only: no persistence, no cross-session tracking */ 5831 return "anon_" + quickId() + quickId(); 5832 } 5833 try { 5834 var id = localStorage.getItem(this.CONV_ID_KEY); 5835 if (id) { 5836 var m = /^conv_(\d+)_/.exec(id); 5837 var fresh = true; 5838 if (m) { 5839 var ageMs = Date.now() - parseInt(m[1], 10); 5840 if (isFinite(ageMs) && ageMs > CONV_ID_MAX_AGE_MS) fresh = false;
5841 } 5842 if (fresh) return id; 5843 try { localStorage.removeItem(this.CONV_ID_KEY); } catch (_e) {} 5844 } 5845 } catch (e) { /* ignore */ } 5846 /* NON persistere all'init (ePrivacy art. 5(3)): id in memoria; sara' 5847 * persistito da _persistConversationId() dopo il gate consenso. */ 5848 return this._newConversationId(true); 5849 }; 5850 5851 CafChat.prototype._newConversationId = function (ephemeral) { 5852 if (this.opts.privacy) { 5853 return "anon_" + quickId() + quickId(); 5854 } 5855 var id = "conv_" + Date.now() + "_" + quickId(); 5856 if (!ephemeral) { 5857 try { 5858 localStorage.setItem(this.CONV_ID_KEY, id); 5859 } catch (e) { /* ignore */ } 5860 } 5861 return id; 5862 }; 5863 5864 CafChat.prototype._persistConversationId = function () { 5865 if (this.opts.privacy || !this.conversationId) return; 5866 try { localStorage.setItem(this.CONV_ID_KEY, this.conversationId); } catch (e) { /* ignore */ } 5867 }; 5868 5869 /* ââ email persistence ââââââââââââââââââââââââââââââââââââââ */ 5870 CafChat.prototype._loadEmail = function () { 5871 if (this.opts.privacy) return ""; 5872 try { 5873 return localStorage.getItem(this.EMAIL_KEY) || ""; 5874 } catch (e) { return ""; } 5875 }; 5876 5877 CafChat.prototype._saveEmail = function (email) { 5878 this.userEmail = email; 5879 if (this.opts.privacy) return; 5880 try { localStorage.setItem(this.EMAIL_KEY, email); } catch (e) { /* ignore */ } 5881 }; 5882 5883 /* ââ sync conversation: DUAL WRITE (fire-and-forget) ââââââ 5884 * 5885 * Scrive su Firestore tramite DUE percorsi indipendenti, idempotenti: 5886 * 5887 * 1. SERVER-SIDE: POST `<baseUrl>/api/conversations` â il backend Next.js 5888 * scrive su Firestore via REST API. Vantaggi: nessuna restrizione di 5889 * dominio sull'API key, controllo Security Rules server-side. 5890 * 5891 * 2. CLIENT-SIDE: PATCH diretto a Firestore REST API dal browser. Funziona 5892 * anche se `/api/conversations` non è ancora deployato sul backend 5893 * (es. solo modifiche locali non in produzione). 5894 * 5895 * Entrambi sono PATCH idempotenti con stesso conversationId: la doppia
5896 * scrittura non duplica nulla. Almeno una delle due quasi certamente 5897 * andrà a buon fine, garantendo persistenza affidabile. 5898 * 5899 * In privacy mode, NULLA viene inviato. 5900 * Schema: conversazioniPubbliche/{convId} + /messaggi/{msgId} 5901 */ 5902 CafChat.prototype._syncConversation = function () { 5903 if (this.opts.privacy) return; 5904 /* Nessuna persistenza finche' il gate consenso non e' superato. */ 5905 if (this.opts.consenso && this._needsConsent && this._needsConsent()) return; 5906 if (!this.conversationId) return; 5907 /* Consenso superato: ora e' lecito persistere l'id conversazione 5908 * (all'init resta solo in memoria per non scrivere pre-consenso). */ 5909 this._persistConversationId(); 5910 5911 var self = this; 5912 var msgs = this.messages.filter(function (m) { 5913 if (!m || !m.content || typeof m.content !== "string") return false; 5914 if (m.content === "@@BOOKING@@") return false; 5915 if (m.content === "@@PWDRESET@@") return false; 5916 if (m.content.indexOf("@@BOOKING_LAUNCH@@") === 0) return false; 5917 return m.role === "user" || m.role === "assistant"; 5918 }).map(function (m) { 5919 var _sm = { 5920 id: m.id, 5921 role: m.role, 5922 content: m.content, 5923 timestamp: m.timestamp || Date.now() 5924 }; 5925 /* AI Act art. 50(2): il modello viaggia con il messaggio assistant. */ 5926 if (m.role === "assistant" && m.model) _sm.model = String(m.model); 5927 return _sm; 5928 }); 5929 if (msgs.length === 0) return; 5930 5931 var nowIso = new Date().toISOString(); 5932 var createdAtIso = nowIso; 5933 var ct = /^conv_(\d+)_/.exec(self.conversationId); 5934 if (ct) { 5935 var tMs = parseInt(ct[1], 10); 5936 if (isFinite(tMs) && tMs > 0) { 5937 try { createdAtIso = new Date(tMs).toISOString(); } catch (_e) {} 5938 } 5939 } 5940 /* GDPR minimizzazione: solo origin+pathname, mai query/hash (token/PII). */ 5941 var pageUrl = ""; 5942 try { pageUrl = (typeof location !== "undefined") ? (location.origin + (location.pathname || "")) : ""; } catch (_pe) { pageUrl = ""; } 5943 5944 /* âââ 1. SERVER-SIDE write (preferito) âââ */ 5945 try { 5946 var apiUrl = (this.opts.baseUrl || "") + "/api/conversations"; 5947 fetch(apiUrl, { 5948 method: "POST", 5949 headers: { "Content-Type": "application/json" }, 5950 body: JSON.stringify({ 5951 conversationId: self.conversationId, 5952 messages: msgs, 5953 metadata: { 5954 pageUrl: pageUrl, 5955 source: "widget-inline" 5956 } 5957 }), 5958 keepalive: true 5959 }).then(function (r) { 5960 if (!r.ok) console.warn("[widget] server-side /api/conversations HTTP " + r.status); 5961 }).catch(function (e) { 5962 console.warn("[widget] server-side /api/conversations failed:", e && e.message); 5963 }); 5964 } catch (e1) { 5965 console.warn("[widget] server-side call exception:", e1 && e1.message); 5966 } 5967 5968 /* Ramo client-side diretto a Firestore RIMOSSO (2026-07-17) per conformita' 5969 * GDPR: esponeva la apiKey nel bundle e scriveva senza il percorso di 5970 * consenso server-side. La persistenza usa solo POST /api/conversations. */ 5971 }; 5972 5973 /* ââ bubble âââââââââââââââââââââââââââââââââââââââââââââââââ */ 5974 CafChat.prototype._BUBBLE_DEFAULT_HTML = '<span class="caf-wave-emoji">ð</span> Assistente IA · posso aiutarti?'; 5975 CafChat.prototype._BUBBLE_BOOKING_HTML = '<span class="caf-wave-emoji">ð </span> Prenota un appuntamento <span class="caf-bk-arrow">â</span>'; 5976 5977 CafChat.prototype._buildBubble = function () { 5978 var self = this; 5979 this.bubbleEl = document.createElement("div"); 5980 this.bubbleEl.className = "caf-bubble caf-pos-" + this.opts.position; 5981 /* Lasciamo il contenuto vuoto fino al check prenotazione: se prenotazione 5982 * è abilitata il primo messaggio deve essere quello di prenotazione, non 5983 * il default. Manteniamo lo spazio invisibile per evitare layout shift. */ 5984 this.bubbleEl.innerHTML = this._BUBBLE_DEFAULT_HTML; 5985 this.bubbleEl.style.pointerEvents = "auto"; 5986 if (!this.opts.showBubble) { 5987 this.bubbleEl.style.display = "none"; 5988 } else { 5989 /* nascondiamo finché il check booking non risolve, così l'utente 5990 * non vede mai prima "ciao" e poi "prenota". */ 5991 this.bubbleEl.style.visibility = "hidden"; 5992 } 5993 this.bubbleEl.addEventListener("click", function () { 5994 self.open(); 5995 }); 5996 this.wrapper.appendChild(this.bubbleEl); 5997 5998 /* Fallback: se il check booking non risponde entro 1.5s, mostra il 5999 * default per non lasciare la bubble nascosta a tempo indeterminato. */ 6000 var revealedByFallback = false;
6001 var fallbackTid = setTimeout(function () { 6002 if (!self.bubbleEl) return; 6003 if (self.bubbleEl.style.visibility === "hidden") { 6004 self.bubbleEl.innerHTML = self._BUBBLE_DEFAULT_HTML; 6005 if (self.opts.showBubble) self.bubbleEl.style.visibility = ""; 6006 revealedByFallback = true; 6007 } 6008 }, 1500); 6009 6010 /* Alternanza messaggio bubble (solo se prenotazione abilitata). 6011 * Il check `_bkCheckEnabled()` torna true quando il backend espone 6012 * /api/planner/province (cioè NEXT_PUBLIC_PRENOTAZIONE=SI lato server). */ 6013 self._bkCheckEnabled().then(function (enabled) { 6014 clearTimeout(fallbackTid); 6015 if (!self.bubbleEl) return; 6016 if (enabled) { 6017 self.bubbleEl.classList.add("caf-bk-active"); 6018 if (self.toggleEl) self.toggleEl.classList.add("caf-bk-active"); 6019 self._bubbleMessages = self._buildBubbleMessages(); 6020 /* _startBubbleAlternation imposta innerHTML = msgs[0] (BOOKING). 6021 * Se il fallback ha già rivelato "ciao", lo sovrascriviamo qui. */ 6022 self._startBubbleAlternation(); 6023 self._bkActivateProactiveCues(); 6024 } else if (!revealedByFallback) { 6025 self.bubbleEl.innerHTML = self._BUBBLE_DEFAULT_HTML; 6026 } 6027 if (self.opts.showBubble) self.bubbleEl.style.visibility = ""; 6028 }).catch(function () { 6029 clearTimeout(fallbackTid); 6030 if (!self.bubbleEl) return; 6031 if (!revealedByFallback) self.bubbleEl.innerHTML = self._BUBBLE_DEFAULT_HTML; 6032 if (self.opts.showBubble) self.bubbleEl.style.visibility = ""; 6033 }); 6034 }; 6035 6036 /* Costruisce l'array di messaggi della bubble in base a contesto: 6037 * booking + default + (returning user / annual reminder / URL keyword). 6038 * BOOKING è in posizione 0 così quando la prenotazione è abilitata il 6039 * primo testo mostrato accanto all'icona è la CTA di prenotazione, non 6040 * il saluto "ciao posso aiutarti". */ 6041 CafChat.prototype._buildBubbleMessages = function () { 6042 var msgs = [this._BUBBLE_BOOKING_HTML, this._BUBBLE_DEFAULT_HTML]; 6043 /* URL/page keyword contextual */ 6044 try { 6045 var hay = ((document.title || "") + " " + (location.pathname || "") + " " + (location.search || "")).toLowerCase(); 6046 if (/\b730\b/.test(hay)) msgs.push('<span class="caf-wave-emoji">ð</span> Prenota per il 730 <span class="caf-bk-arrow">â</span>'); 6047 else if (/\bisee\b/.test(hay)) msgs.push('<span class="caf-wave-emoji">ð</span> Prenota un appuntamento ISEE <span class="caf-bk-arrow">â</span>'); 6048 else if (/\bf24\b/.test(hay)) msgs.push('<span class="caf-wave-emoji">ð¶</span> Aiuto con F24? Prenota <span class="caf-bk-arrow">â</span>'); 6049 } catch (_e) { /* noop */ } 6050 /* Returning user */ 6051 try { 6052 var hist = (typeof loadBookingHistory === "function") ? loadBookingHistory() : []; 6053 if (hist && hist.length) { 6054 msgs.push('<span class="caf-wave-emoji">ð</span> Bentornato, nuova prenotazione? <span class="caf-bk-arrow">â</span>'); 6055 } 6056 } catch (_e) { /* noop */ } 6057 /* Annual reminder */ 6058 if (this._bkAnnualReminder) { 6059 msgs.push('<span class="caf-wave-emoji">ðï¸</span> à tempo del 730: prenota <span class="caf-bk-arrow">â</span>'); 6060 } 6061 return msgs; 6062 }; 6063 6064 /* Avvia (o riavvia) il loop di rotazione testo bubble (N messaggi). 6065 * Idempotente: chiamare due volte resetta il timer. 6066 * Si auto-ferma se il bubble viene nascosto o la chat aperta. */ 6067 CafChat.prototype._startBubbleAlternation = function () { 6068 var self = this; 6069 if (window.matchMedia && window.matchMedia("(prefers-reduced-motion:reduce)").matches) { 6070 if (self.bubbleEl) { var _m = self._bubbleMessages && self._bubbleMessages.length ? self._bubbleMessages : [self._BUBBLE_BOOKING_HTML, self._BUBBLE_DEFAULT_HTML]; self.bubbleEl.innerHTML = _m[0]; } 6071 return; 6072 } 6073 if (!self.bubbleEl) return; 6074 if (self._bubbleAltTimer) { clearInterval(self._bubbleAltTimer); self._bubbleAltTimer = null; } 6075 var msgs = self._bubbleMessages && self._bubbleMessages.length 6076 ? self._bubbleMessages 6077 : [self._BUBBLE_BOOKING_HTML, self._BUBBLE_DEFAULT_HTML]; 6078 /* a11y #C58 (WCAG 2.2.2): la rotazione automatica non e' infinita â si 6079 * ferma per sempre dopo max 5 cicli completi nella sessione o alla prima 6080 * apertura del pannello; resta il primo messaggio, statico. */ 6081 if (self._bubbleAltDone || (self._bubbleAltTicks || 0) >= msgs.length * 5) { 6082 self.bubbleEl.innerHTML = msgs[0]; 6083 return; 6084 } 6085 var idx = 0; 6086 self.bubbleEl.innerHTML = msgs[0]; 6087 self._bubbleAltTimer = setInterval(function () { 6088 if (!self.bubbleEl || self.isOpen 6089 || self.bubbleEl.style.display === "none") { 6090 clearInterval(self._bubbleAltTimer); self._bubbleAltTimer = null; return; 6091 } 6092 idx = (idx + 1) % msgs.length; 6093 self.bubbleEl.innerHTML = msgs[idx]; 6094 self._bubbleAltTicks = (self._bubbleAltTicks || 0) + 1; 6095 if (self._bubbleAltTicks >= msgs.length * 5) { 6096 clearInterval(self._bubbleAltTimer); self._bubbleAltTimer = null; 6097 self.bubbleEl.innerHTML = msgs[0]; 6098 } 6099 }, 2400); 6100 }; 6101 6102 CafChat.prototype._stopBubbleAlternation = function () { 6103 if (this._bubbleAltTimer) { clearInterval(this._bubbleAltTimer); this._bubbleAltTimer = null; } 6104 }; 6105 6106 /* ââ Booking proactive cues: wiggle FAB, inactivity shake, badge, 6107 * mobile sticky banner, exit-intent, scroll trigger, auto-open one-shot, 6108 * tab-visibility pause, aria-label aggiornato. Tutto si attiva solo 6109 * quando _bkEnabled === true (chiamato da _buildBubble). ââ */ 6110 CafChat.prototype._bkActivateProactiveCues = function () { 6111 var self = this; 6112 if (self._bkCuesActive) return; 6113 self._bkCuesActive = true; 6114 6115 /* aria-label più esplicito sulla FAB */ 6116 if (self.toggleEl) { self.toggleEl.setAttribute("aria-label", bkT("aria_open_book")); self._toggleLabelClosed = bkT("aria_open_book"); } 6117 6118 /* badge "1 nuovo" finché non si apre la chat in questa sessione */ 6119 try { 6120 var seen = sessionStorage.getItem(nsKey("caf-bk-seen")) === "1"; 6121 if (!seen && self.badgeEl) { 6122 self.badgeEl.textContent = "1"; 6123 self.badgeEl.classList.add("caf-bk-newdot"); 6124 self.badgeEl.style.display = "flex"; 6125 } 6126 } catch (_e) { /* noop */ } 6127 6128 /* tilt hover già via CSS; wiggle periodico ogni ~22s solo se chiusa */ 6129 self._bkWiggleTimer = setInterval(function () { 6130 if (self.isOpen || !self.toggleEl) return; 6131 if (self._bkPaused) return; 6132 self.toggleEl.classList.remove("caf-bk-wiggle"); 6133 /* reflow per ri-triggerare animazione */ 6134 void self.toggleEl.offsetWidth; 6135 self.toggleEl.classList.add("caf-bk-wiggle"); 6136 }, 22000); 6137 6138 /* shake su inattività (30s senza interazione utente) */ 6139 var resetInactivity = function () { 6140 if (self._bkInactivityTimer) clearTimeout(self._bkInactivityTimer); 6141 if (self.isOpen) return; 6142 self._bkInactivityTimer = setTimeout(function () { 6143 if (self.isOpen || !self.toggleEl || self._bkPaused) return; 6144 self.toggleEl.classList.remove("caf-bk-shake"); 6145 void self.toggleEl.offsetWidth; 6146 self.toggleEl.classList.add("caf-bk-shake"); 6147 }, 30000); 6148 }; 6149 ["mousemove", "keydown", "scroll", "touchstart"].forEach(function (ev) { 6150 window.addEventListener(ev, resetInactivity, { passive: true }); 6151 }); 6152 resetInactivity(); 6153 6154 /* auto-apertura panel: una volta per sessione, dopo 18s di permanenza, 6155 * solo se l'utente non ha mai aperto la chat in questa sessione. */ 6156 try { 6157 var autoFlag = sessionStorage.getItem(nsKey("caf-bk-autoopen")) === "1"; 6158 if (!autoFlag) { 6159 self._bkAutoOpenTimer = setTimeout(function () { 6160 if (self.isOpen) return; 6161 /* a11y #15: niente auto-open su mobile (open() <640px fa takeover 6162 * fullscreen + scroll-lock inattesi) ne' con prefers-reduced-motion 6163 * attivo. L'apertura manuale resta identica. */ 6164 if (window.innerWidth < 640) return; 6165 if (window.matchMedia && window.matchMedia("(prefers-reduced-motion:reduce)").matches) return; 6166 try { sessionStorage.setItem(nsKey("caf-bk-autoopen"), "1"); } catch (_e) {} 6167 /* apre solo se utente è ancora sulla pagina (visibile); 6168 * open(true) = apertura automatica senza furto di focus. */ 6169 if (document.visibilityState === "visible") self.open(true); 6170 }, 18000); 6171 } 6172 } catch (_e) { /* noop */ } 6173 6174 /* exit-intent: mouse esce dal viewport in alto â mostra bubble pulsante */ 6175 var exitFired = false;
6176 document.addEventListener("mouseleave", function (e) { 6177 if (exitFired || self.isOpen) return; 6178 if (e.clientY > 0) return; /* solo top */ 6179 exitFired = true; 6180 try { sessionStorage.setItem(nsKey("caf-bk-exit"), "1"); } catch (_e) {} 6181 if (self.bubbleEl) { 6182 self.bubbleEl.style.display = ""; 6183 self.bubbleEl.innerHTML = '<span class="caf-wave-emoji">â³</span> Aspetta, prenoto in 1 minuto <span class="caf-bk-arrow">â</span>'; 6184 self._stopBubbleAlternation(); 6185 /* riprendi rotazione dopo 6s */ 6186 setTimeout(function () { if (!self.isOpen) self._startBubbleAlternation(); }, 6000); 6187 } 6188 if (self.toggleEl) { 6189 self.toggleEl.classList.remove("caf-bk-shake"); 6190 void self.toggleEl.offsetWidth; 6191 self.toggleEl.classList.add("caf-bk-shake"); 6192 } 6193 }); 6194 6195 /* scroll trigger: > 70% della pagina â forza visibilità bubble */ 6196 var scrollFired = false; 6197 var onScroll = function () { 6198 if (scrollFired || self.isOpen) return; 6199 var doc = document.documentElement; 6200 var scrolled = (window.scrollY || doc.scrollTop) + window.innerHeight; 6201 var total = Math.max(doc.scrollHeight, document.body ? document.body.scrollHeight : 0); 6202 if (total > 0 && scrolled / total >= 0.7) { 6203 scrollFired = true; 6204 if (self.bubbleEl) { 6205 self.bubbleEl.style.display = ""; 6206 if (!self._bubbleAltTimer) self._startBubbleAlternation(); 6207 } 6208 } 6209 }; 6210 window.addEventListener("scroll", onScroll, { passive: true }); 6211 6212 /* visibility change: pausa animazioni quando il tab non è in focus */ 6213 document.addEventListener("visibilitychange", function () { 6214 var hidden = document.visibilityState !== "visible"; 6215 self._bkPaused = hidden; 6216 if (self.wrapper) self.wrapper.classList.toggle("caf-bk-pause-anim", hidden); 6217 }); 6218
6219 /* quando l'utente apre la chat (anche manualmente) marca come "visto" */ 6220 var origOpen = self.open; 6221 if (origOpen && !origOpen._bkWrapped) { 6222 var wrapped = function () { 6223 try { sessionStorage.setItem(nsKey("caf-bk-seen"), "1"); } catch (_e) {} 6224 if (self.badgeEl) { 6225 self.badgeEl.classList.remove("caf-bk-newdot"); 6226 self.badgeEl.style.display = "none"; 6227 } 6228 if (self._bkAutoOpenTimer) { clearTimeout(self._bkAutoOpenTimer); self._bkAutoOpenTimer = null; } 6229 return origOpen.apply(self, arguments); 6230 }; 6231 wrapped._bkWrapped = true; 6232 self.open = wrapped; 6233 } 6234 }; 6235 6236 /* A11y â focus trap centrale a stack (WCAG 2.1.2/2.4.3). 6237 * Il vecchio trap era un listener keydown sull'overlay stesso: agiva solo se 6238 * il focus era GIA' dentro l'overlay (wrap ai bordi first/last). Se il focus 6239 * finiva FUORI (autofocus dell'input chat, re-render, apertura del banner 6240 * con focus altrove) i Tab non transitavano mai per l'overlay e il "modale" 6241 * era attraversabile (bug verificato in CDP: 45 Tab non entravano mai nel 6242 * banner GDPR). Ora un unico listener document-level (capture, key composta) 6243 * governa l'overlay ATTIVO piu' in alto nello stack: se il focus e' fuori, 6244 * il primo Tab lo riporta dentro; se e' dentro, wrappa ai bordi. In piu', 6245 * quando il pannello copre l'intero viewport (mobile fullscreen o 6246 * desktop-fs) il pannello stesso fa da contenitore di ultima istanza: il 6247 * pannello flottante desktop resta volutamente non-modale. */ 6248 CafChat.prototype._trapFocusables = function(ov){ 6249 var _all=Array.prototype.slice.call(ov.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),textarea,select,[tabindex]:not([tabindex="-1"])')); 6250 var _notHidden=_all.filter(function(el){return !(el.hidden||(el.closest&&el.closest("[hidden]")));}); 6251 var f=_notHidden.filter(function(el){return el.offsetWidth>0||el.offsetHeight>0||(el.getClientRects&&el.getClientRects().length>0);}); 6252 if(!f.length)f=_notHidden; 6253 return f; 6254 }; 6255 CafChat.prototype._trapIsActive = function(ov){ 6256 if(!ov||!ov.isConnected||ov.hidden)return false; 6257 if(!(ov.offsetWidth>0||ov.offsetHeight>0||(ov.getClientRects&&ov.getClientRects().length>0)))return false; 6258 return true; 6259 }; 6260 CafChat.prototype._trapTop = function(){ 6261 var st=this._trapStack||[]; 6262 for(var i=st.length-1;i>=0;i--){ 6263 if(!st[i]||!st[i].isConnected){st.splice(i,1);continue;} 6264 if(this._trapIsActive(st[i]))return st[i]; 6265 } 6266 var p=this.panelEl; 6267 if(p&&this.isOpen&&(p.classList.contains("caf-fullscreen")||p.classList.contains("caf-desktop-fs")))return p; 6268 return null; 6269 }; 6270 CafChat.prototype._trapTab = function(ov){ 6271 this._trapStack=this._trapStack||[]; 6272 if(ov&&this._trapStack.indexOf(ov)===-1)this._trapStack.push(ov); 6273 if(this._trapHandlerBound)return; 6274 this._trapHandlerBound=true; 6275 var self=this; 6276 document.addEventListener("keydown",function(e){ 6277 if(e.key!=="Tab")return; 6278 var top=self._trapTop(); 6279 if(!top)return; 6280 var f=self._trapFocusables(top); 6281 if(!f.length)return; 6282 var first=f[0],last=f[f.length-1]; 6283 var cur=(self.shadow&&self.shadow.activeElement)||document.activeElement; 6284 if(!cur||!top.contains(cur)){e.preventDefault();(e.shiftKey?last:first).focus();return;} 6285 if(e.shiftKey&&cur===first){e.preventDefault();last.focus();} 6286 else if(!e.shiftKey&&cur===last){e.preventDefault();first.focus();} 6287 },true); 6288 }; 6289 6290 /* ââ toggle button ââââââââââââââââââââââââââââââââââââââââââ */ 6291 CafChat.prototype._buildToggle = function () { 6292 var self = this; 6293 this.toggleEl = document.createElement("button"); 6294 this.toggleEl.className = "caf-toggle caf-pos-" + this.opts.position; 6295 this.toggleEl.style.pointerEvents = "auto"; 6296 this.toggleEl.setAttribute("aria-label", bkT("aria_open_chat")); 6297 this.toggleEl.setAttribute("aria-expanded", "false"); 6298 this.toggleEl.setAttribute("aria-haspopup", "dialog"); 6299 this._toggleLabelClosed = bkT("aria_open_chat"); 6300 this.toggleEl.innerHTML = 6301 '<span class="caf-pulse"></span>' + 6302 '<span class="caf-pulse-2"></span>' + 6303 '<span class="caf-glow"></span>' + 6304 '<img src="' + escapeHtml(this.opts.chatLogo) + '" alt="">' + 6305 '<span class="caf-close-icon">×</span>'; 6306 this.badgeEl = document.createElement("span"); 6307 this.badgeEl.className = "caf-badge"; 6308 this.badgeEl.style.display = "none"; 6309 this.toggleEl.appendChild(this.badgeEl); 6310 this.toggleEl.addEventListener("click", function (e) { 6311 e.stopPropagation(); 6312 self.isOpen ? self.close() : self.open(); 6313 }); 6314 this.wrapper.appendChild(this.toggleEl); 6315 }; 6316 6317 /* ââ chat panel âââââââââââââââââââââââââââââââââââââââââââââ */ 6318 CafChat.prototype._buildPanel = function () { 6319 var self = this; 6320 var o = this.opts; 6321 6322 this.panelEl = document.createElement("div"); 6323 this.panelEl.className = "caf-panel caf-pos-" + o.position; 6324 /* Chrome scrolla anche i contenitori overflow:hidden per portare in vista 6325 * un elemento focalizzato: il pannello restava internamente scrollato 6326 * (scrollTop>0) senza scrollbar per rimediare -> contenuto shiftato e 6327 * fascia bianca sotto. Qui qualsiasi scroll del pannello viene annullato 6328 * appena accade (l'unico scroller legittimo e' l'area messaggi). */ 6329 var _panelNoScroll = this.panelEl; 6330 _panelNoScroll.addEventListener("scroll", function () { 6331 if (_panelNoScroll.scrollTop) _panelNoScroll.scrollTop = 0; 6332 if (_panelNoScroll.scrollLeft) _panelNoScroll.scrollLeft = 0; 6333 }); 6334 this.panelEl.style.pointerEvents = "auto"; 6335 var hdrNameId = "caf-hdr-name-" + quickId(); 6336 var menuId = "caf-menu-" + quickId(); 6337 this._hdrNameId = hdrNameId; 6338 this.panelEl.setAttribute("role", "dialog"); 6339 this.panelEl.setAttribute("aria-labelledby", hdrNameId); 6340 var skip = document.createElement("button"); 6341 skip.type = "button"; 6342 skip.className = "caf-skip"; 6343 skip.textContent = bkT("skip_to_input"); 6344 skip.addEventListener("click", function () { if (self.inputEl) { tr
6344y { self.inputEl.focus(); } catch (_e) {} } }); 6345 this.panelEl.appendChild(skip); 6346 6347 /* header */ 6348 var header = document.createElement("div"); 6349 header.className = "caf-header"; 6350 header.style.background = "linear-gradient(135deg, var(--caf-c-ink) 0%, color-mix(in srgb, var(--caf-c-ink) 78%, #1e293b) 100%)"; 6351 header.innerHTML = 6352 '<div class="caf-header-left">' + 6353 '<div class="caf-header-avatar"><img src="' + escapeHtml(o.logo) + '" alt=""></div>' + 6354 '<div class="caf-header-info"><div class="caf-header-name" role="heading" aria-level="2" id="' + hdrNameId + '">' + escapeHtml(o.name) + '</div>' + 6355 '<div class="caf-header-status">' + (o.aiDisclosure!==false ? escapeHtml(bkT("ai_badge")) : '<span class="caf-online-dot"></span>Online') + '</div></div>' + 6356 '</div>' + 6357 '<div class="caf-header-actions">' + 6358 '<button class="caf-header-btn caf-fs-btn" aria-label="' + escapeHtml(bkT("aria_fullscreen")) + '">' + 6359 '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px">' + 6360 '<polyline points="6 1 1 1 1 6"/><polyline points="14 1 19 1 19 6"/>
6360<polyline points="6 19 1 19 1 14"/><polyline points="14 19 19 19 19 14"/>' + 6361 '</svg>' + 6362 '</button>' + 6363 '<button class="caf-header-btn caf-menu-trigger" aria-label="' + escapeHtml(bkT("aria_options")) + '" aria-haspopup="true" aria-expanded="false" aria-controls="' + menuId + '">⋯</button>' + 6364 '<button class="caf-header-btn caf-close-btn" aria-label="' + escapeHtml(bkT("aria_close")) + '">×</button>' + 6365 '</div>'; 6366 6367 /* menu */ 6368 this.menuEl = document.createElement("div"); 6369 this.menuEl.className = "caf-menu"; 6370 this.menuEl.id = menuId; 6371 var telNum = String(bkHelplineNumber(o) || "").replace(/[^0-9+]/g, ""); 6372 this.menuEl.innerHTML = 6373 '<button class="caf-menu-item caf-new-conv">' + 6374 '<span class="caf-icon">+</span> ' + escapeHtml(bkT("menu_new")) + 6375 '</button>' + 6376 '<div class="caf-menu-divider"></div>' + 6377 '<div class="caf-menu-label">Valuta la conversazione</div>' + 6378 '<div class="caf-rating-row">' + 6379 '<button class="caf-rate-btn caf-rate-good" type="button" aria-pressed="false">' + 6380 '<span class="caf-rate-icon">ð</span> Positivo' + 6381 '</button>' + 6382 '<button class="caf-rate-btn caf-rate-bad" type="button" aria-pressed="false">' + 6383 '<span class="caf-rate-icon">ð</span> Negativo' + 6384 '</button>' + 6385 '</div>' + 6386 '<div class="caf-menu-divider"></div>' + 6387 (telNum ? ('<a class="caf-menu-item caf-operator" href="tel:' + telNum + '"><span class="caf-icon" aria-hidden="true">ð</span> ' + escapeHtml(bkT("menu_contact")) + '</a><div class="caf-menu-divider"></div>') : '') + 6388 '<button class="caf-menu-item caf-privacy-panel" type="button">' + 6389 '<span class="caf-icon">ð</span> ' + escapeHtml(bkT("menu_privacy")) + 6390 '</button>'; 6391 header.appendChild(this.menuEl); 6392 6393 header.querySelector(".caf-menu-trigger").addEventListener("click", function (e) { 6394 e.stopPropagation(); 6395 self.menuOpen = !self.menuOpen; 6396 self._updateMenu(); 6397 }); 6398 header.querySelector(".caf-close-btn").addEventListener("click", function () { 6399 self.close(); 6400 }); 6401 this._fsBtnEl = header.querySelector(".caf-fs-btn"); 6402 this._fsBtnEl.addEventListener("click", function (e) { 6403 e.stopPropagation(); 6404 self._toggleFullscreen(); 6405 }); 6406 this.menuEl.querySelector(".caf-new-conv").addEventListener("click", function () { 6407 if (self._voiceMode) self._exitVoiceMode(); 6408 self._interruptResponse(); 6409 self.messages = [ 6410 { id: "welcome", role: "assistant", content: timeAwareGreeting()+"! "+(self.opts.welcome||"")+((self.opts.aiDisclosure!==false)?("\n\n"+bkT("ai_disclosure")):"") }, 6411 ]; 6412 self.conversationId = self._newConversationId(true); 6413 self.rating = null; 6414 self._updateRatingButtons(); 6415 self._bkReset(true); 6416 self._saveMessages(); 6417 self._renderMessages(); 6418 self._updateSendBtn(); 6419 self.menuOpen = false; 6420 self._updateMenu(); 6421 }); 6422 this.menuEl.querySelector(".caf-rate-good").addEventListener("click", function (e) { 6423 e.stopPropagation(); 6424 self.rating = self.rating === "good" ? null : "good"; 6425 self._updateRatingButtons(); 6426 self._syncRating(); 6427 }); 6428 this.menuEl.querySelector(".caf-rate-bad").addEventListener("click", function (e) { 6429 e.stopPropagation(); 6430 self.rating = self.rating === "bad" ? null : "bad"; 6431 self._updateRatingButtons(); 6432 self._syncRating(); 6433 }); 6434 6435 /* #42 â Pannello Privacy: modifica preferenze + export + delete (Art. 15/17 GDPR). */ 6436 this.menuEl.querySelector(".caf-privacy-panel").addEventListener("click", function (e) { 6437 e.stopPropagation(); 6438 self.menuOpen = false; self._updateMenu(); 6439 self._bkShowPrivacyPanel(); 6440 }); 6441 6442 this.panelEl.appendChild(header); 6443 6444 /* prominent "expand booking" banner â shown under the header while booking is active */ 6445 var expandBanner = document.createElement("button"); 6446 expandBanner.type = "button"; 6447 expandBanner.className = "caf-bk-expand-banner"; 6448 /* WCAG 2.5.3 (#C64): il nome accessibile deve CONTENERE le etichette 6449 * visibili del bottone ("Prenotazione in corso", "Espandi", sottotitolo). */ 6450 expandBanner.setAttribute("aria-label", bkT("label_booking_inprogress")+" â "+bkT("label_expand")+". "+bkT("label_expand_sub")); 6451 expandBanner.innerHTML = 6452 '<div class="caf-bk-expand-banner-ic">' + 6453 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">' + 6454 '<polyline points="15 3 21 3 21 9"/>' + 6455 '<polyline points="9 21 3 21 3 15"/>' + 6456 '<line x1="21" y1="3" x2="14" y2="10"/>' +
6457 '<line x1="3" y1="21" x2="10" y2="14"/>' + 6458 '</svg>' + 6459 '</div>' + 6460 '<div class="caf-bk-expand-banner-txt">' + 6461 '<div class="caf-bk-expand-banner-t"><span class="live-dot"></span>' + escapeHtml(bkT("label_booking_inprogress")) + '</div>' + 6462 '<div class="caf-bk-expand-banner-s">' + escapeHtml(bkT("label_expand_sub")) + '</div>' + 6463 '</div>' + 6464 '<div class="caf-bk-expand-banner-arrow">' + escapeHtml(bkT("label_expand")) + ' ' + 6465 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>' + 6466 '</div>'; 6467 expandBanner.addEventListener("click", function () { 6468 if (self.bkStep === "idle" || self._bkFsMode) return; 6469 self._bkEnterFs(); 6470 }); 6471 this._bkExpandBannerEl = expandBanner; 6472 this.panelEl.appendChild(expandBanner); 6473 6474 /* messages container */ 6475 this.messagesEl = document.createElement("div"); 6476 this.messagesEl.className = "caf-messages"; 6477 this.messagesEl.setAttribute("role", "log"); 6478 this.messagesEl.setAttribute("aria-live", "off"); 6479 this.messagesEl.setAttribute("aria-relevant", "additions"); 6480 this.messagesEl.setAttribute("tabindex", "0"); 6481 this.messagesEl.setAttribute("aria-label", bkT("aria_history")); 6482 this.messagesEl.addEventListener("scroll", function () { 6483 if (self._scrollRaf) return; 6484 self._scrollRaf = requestAnimationFrame(function () { 6485 self._scrollRaf = null; 6486 var el = self.messagesEl; 6487 self.isAtBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 60; 6488 self._updateScrollBtn(); 6489 }); 6490 }, { passive: true }); 6491 this.panelEl.appendChild(this.messagesEl); 6492 this.srAnnouncer = document.createElement("div"); 6493 this.srAnnouncer.className = "caf-sr-only"; 6494 this.srAnnouncer.setAttribute("aria-live", "polite"); 6495 this.srAnnouncer.setAttribute("aria-atomic", "true"); 6496 this.panelEl.appendChild(this.srAnnouncer); 6497 6498 /* scroll-to-bottom button */ 6499 var scrollWrap = document.createElement("div"); 6500 scrollWrap.style.cssText = "position:relative;width:100%;height:0"; 6501 this.scrollBtn = document.createElement("button"); 6502 this.scrollBtn.className = "caf-scroll-btn"; 6503 this.scrollBtn.type = "button"; 6504 this.scrollBtn.setAttribute("aria-label", bkT("aria_scroll_bottom")); 6505 this.scrollBtn.innerHTML = '<span aria-hidden="true">â</span>'; 6506 this.scrollBtn.style.display = "none"; 6507 this.scrollBtn.addEventListener("click", function () { 6508 self._scrollToBottom(); 6509 }); 6510 scrollWrap.appendChild(this.scrollBtn); 6511 this._scrollWrap = scrollWrap; 6512 this.panelEl.appendChild(scrollWrap); 6513 6514 /* error area */ 6515 this.errorEl = document.createElement("div"); 6516 this.errorEl.className = "caf-error"; 6517 this.errorEl.setAttribute("role", "alert"); 6518 this.errorEl.style.display = "none"; 6519 this.panelEl.appendChild(this.errorEl); 6520 6521 /* input area */ 6522 var inputArea = document.createElement("form"); 6523 inputArea.className = "caf-input-area"; 6524 inputArea.addEventListener("submit", function (e) { 6525 e.preventDefault(); 6526 if (self._isListening) self._abortListening(); 6527 self._send(self.inputEl.value); 6528 }); 6529 6530 this.inputEl = document.createElement("input"); 6531 this.inputEl.className = "caf-input"; 6532 this.inputEl.type = "text"; 6533 this.inputEl.placeholder = bkT("ph_write_message"); 6534 this.inputEl.setAttribute("autocomplete", "off"); 6535 this.inputEl.setAttribute("aria-label", bkT("ph_write_message")); 6536 this.inputEl.addEventListener("focus", function () { self._scheduleGhostSug(); }); 6537 this.inputEl.addEventListener("input", function () { self._scheduleGhostSug(); }); 6538 this.inputEl.addEventListener("blur", function () { 6539 setTimeout(function(){if(self._ghostSugTimer){clearTimeout(self._ghostSugTimer);self._ghostSugTimer=null;}}, 200); 6540 }); 6541 /* Fix: alcuni temi/plugin WordPress agganciano listener globali su 6542 document/window che chiamano preventDefault() sulla barra spaziatrice 6543 (scroll-jacking, video player, lightbox, slideshow ecc.). Questo 6544 impedisce di digitare lo spazio nell'input. Fermiamo la propagazione 6545 degli eventi tastiera dall'input così i listener del sito host non 6546 possono cancellare il tasto. */ 6547 var _stopHostKey = function (e) { 6548 /* Escape deve raggiungere il gestore document che chiude il pannello 6549 * (WCAG 2.1.2): il blocco anti-tema-host vale per i tasti di digitazione, 6550 * non per la chiusura del dialog. */ 6551 if (e.key === "Escape" || e.key === "Esc") return; 6552 if (typeof e.stopPropagation === "function") e.stopPropagation(); 6553 if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation(); 6554 }; 6555 this.inputEl.addEventListener("keydown", _stopHostKey); 6556 this.inputEl.addEventListener("keypress", _stopHostKey); 6557 this.inputEl.addEventListener("keyup", _stopHostKey); 6558 6559 this.sendBtn = document.createElement("button"); 6560 this.sendBtn.className = "caf-send"; 6561 this.sendBtn.type = "submit"; 6562 this.sendBtn.style.background = "var(--caf-c)"; 6563 this.sendBtn.style.color = "var(--caf-on-c)"; 6564 this.sendBtn.setAttribute("aria-label", bkT("aria_send")); 6565 this.sendBtn.innerHTML = '<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M3.478 2.405a.75.75 0 0 0-.926.94l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519
65650 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.405Z"/></svg>'; 6566 6567 inputArea.appendChild(this.inputEl); 6568 6569 /* mic button (voice recognition) */ 6570 if (this.opts.voice) { 6571 this.micBtn = document.createElement("button"); 6572 this.micBtn.className = "caf-mic"; 6573 this.micBtn.type = "button"; 6574 this.micBtn.style.background = "var(--caf-c)"; 6575 this.micBtn.setAttribute("aria-label", bkT("aria_voice")); 6576 this.micBtn.innerHTML = 6577 '<span class="caf-mic-pulse"></span>' + 6578 '<svg viewBox="0 0 24 24" fill="currentColor" stroke="none">' + 6579 '<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>' + 6580 '<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>' + 6581 '</svg>'; 6582 var chatSelf = this; 6583 this.micBtn.addEventListener("click", function () { 6584 chatSelf._enterVoiceMode(); 6585 }); 6586 inputArea.appendChild(this.micBtn); 6587 } 6588 6589 inputArea.appendChild(this.sendBtn); 6590 this._inputAreaEl = inputArea; 6591 6592 /* persistent booking CTA (visible when PRENOTAZIONE=SI and booking not active) */ 6593 var cta = document.createElement("div"); 6594 cta.className = "caf-bk-cta"; 6595 cta.style.display = "none"; 6596 this._bkCtaEl = cta; 6597 this.panelEl.appendChild(cta); 6598 6599 /* ghost suggestions bar (idle hints) */ 6600 var ghost = document.createElement("div"); 6601 ghost.className = "caf-ghost-sug"; 6602 ghost.style.display = "none"; 6603 this._ghostSugEl = ghost; 6604 this.panelEl.appendChild(ghost); 6605 6606 this.panelEl.appendChild(inputArea); 6607 /* Footer persistente: dichiarazione di natura IA sempre visibile (AI Act art. 50). */ 6608 if (o.aiDisclosure !== false) { 6609 var aiFooter = document.createElement("div"); 6610 aiFooter.className = "caf-footer caf-ai-footer"; 6611 aiFooter.setAttribute("role", "note"); 6612 aiFooter.textContent = bkT("ai_badge"); 6613 this.panelEl.appendChild(aiFooter); 6614 this._aiFooterEl = aiFooter; 6615 } 6616 6617 /* minimized CTA pill (appears when user dismisses) */ 6618 var ctaMini = document.createElement("button"); 6619 ctaMini.type = "button"; 6620 ctaMini.className = "caf-bk-cta-mini"; 6621 ctaMini.style.display = "none"; 6622 ctaMini.setAttribute("aria-label", bkT("aria_book_appointment")); 6623 ctaMini.innerHTML = 6624 '<span class="caf-bk-cta-mini-ic">' + 6625 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">' + 6626 '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>' + 6627 '</svg>' + 6628 '</span>' + 6629 '<span>Prenota</span>'; 6630 ctaMini.addEventListener("click", function (e) { 6631 e.stopPropagation(); 6632 self._bkCtaDismissed = false; 6633 self._updateBkCta(); 6634 self._startBookingFromCta(); 6635 }); 6636 this._bkCtaMiniEl = ctaMini; 6637 this.panelEl.appendChild(ctaMini); 6638 6639 /* footer */ 6640 /* footer rimosso su richiesta */ 6641 6642 /* voice mode panel (hidden by default, shown when voice mode is active) */ 6643 if (this.opts.voice) { 6644 this._buildVoicePanel(); 6645 } 6646 6647 /* consent overlay (gate privacy: mostrato prima di TUTTO, email inclusa) */ 6648 if (this.opts.consenso) { 6649 this._buildConsentOverlay(); 6650 if (this._needsConsent()) { 6651 this.messagesEl.style.display = "none"; 6652 this._scrollWrap.style.display = "none"; 6653 this.errorEl.style.display = "none"; 6654 this._inputAreaEl.style.display = "none"; 6655 } else { 6656 this.consentOverlay.style.display = "none"; 6657 } 6658 } 6659 6660 /* email overlay (shown before chat if chiedomail is enabled) */ 6661 if (this.opts.chiedomail) { 6662 this._buildEmailOverlay(); 6663 // If email not yet collected, hide chat UI and show overlay 6664 // (a meno che il gate consenso non venga prima: in quel caso l'email 6665 // overlay parte nascosto e viene mostrato all'approvazione del gate) 6666 if (this._needsEmail() && !this._needsConsent()) { 6667 this.messagesEl.style.display = "none"; 6668 this._scrollWrap.style.display = "none"; 6669 this.errorEl.style.display = "none"; 6670 this._inputAreaEl.style.display = "none"; 6671 } else { 6672 this.emailOverlay.style.display = "none"; 6673 } 6674 } 6675 6676 this.wrapper.appendChild(this.panelEl); 6677 }; 6678 6679 /* ââ consent overlay (gate privacy all'apertura della chat) âââââââ 6680 * Attivo solo con opts.consenso (env CONSENSO=si / data-consenso="si"). 6681 * L'utente deve SPUNTARE la checkbox e APPROVARE con il bottone prima di 6682 * poter usare la chat; il link apre l'informativa del sito ospite 6683 * (opts.privacyUrl). Il consenso e' ricordato per tenant 6684 * (CHAT_CONSENT_KEY); chiudere il widget resta sempre possibile 6685 * (il rifiuto = non usare la chat, nessun dato viene trattato). */ 6686 CafChat.prototype._buildConsentOverlay = function () { 6687 var self = this; 6688 var o = this.opts; 6689 /* hardening: solo http(s) â una config errata non deve mai produrre 6690 un link javascript:/data: dentro l'overlay. */ 6691 var url = (o.privacyUrl || "").trim(); 6692 if (url && !/^https?:\/\//i.test(url)) url = ""; 6693 /* escapeHtml NON escapa gli apici doppi: neutralizziamo eventuali '"' 6694 con l'URL-encoding cosi' l'attributo href non e' scomponibile. */ 6695 if (url) url = url.replace(/"/g, "%22"); 6696 this._consentUrl = url; 6697 6698 this.consentOverlay = document.createElement("div"); 6699 this.consentOverlay.className = "caf-consent-overlay"; 6700 this.consentOverlay.setAttribute("role", "dialog"); 6701 this.consentOverlay.setAttribute("aria-modal", "true"); 6702 this.consentOverlay.setAttribute("aria-label", bkT("chatgate_h")); 6703 /* BUGFIX (pannello in finestra): con age gate + disclosure il contenuto 6704 * del gate supera l'altezza del pannello; senza min-height:0 il flex-item 6705 * non si stringe e senza overflow-y il bottone "Accetta" finisce sotto il 6706 * bordo, irraggiungibile. Centraggio delegato al margin:auto del wrapper 6707 * interno: justify-content:center renderebbe irraggiungibile la parte
6708 * ALTA del contenuto quando trabocca. */ 6709 this.consentOverlay.style.cssText = 6710 "flex:1;min-height:0;overflow-y:auto;z-index:100;background:#fff;display:flex;flex-direction:column;" + 6711 "align-items:center;justify-content:flex-start;padding:32px 24px;"; 6712 6713 var linkHtml = url 6714 ? '<a class="caf-consent-link" href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer" style="color:var(--caf-c-ink);text-decoration:underline;font-weight:600">' + escapeHtml(bkT("chatgate_link")) + '</a>' 6715 : '<a class="caf-consent-link caf-consent-open-panel" href="#" role="button" style="color:var(--caf-c-ink);text-decoration:underline;font-weight:600;cursor:pointer">' + escapeHtml(bkT("chatgate_link")) + '</a>'; 6716 6717 this.consentOverlay.innerHTML = 6718 '<div style="text-align:center;max-width:340px;width:100%;margin:auto 0">' + 6719 '<div style="margin-bottom:16px">' + 6720 '<img src="' + escapeHtml(o.logo) + '" alt="" style="display:block;width:60px;height:60px;border-radius:50%;object-fit:cover;margin:0 auto 12px">' + 6721 '<h3 style="margin:0 0 6px;font-size:18px;font-weight:700;color:#1e293b">' + escapeHtml(bkT("chatgate_h")) + '</h3>' + 6722 '<p style="margin:0;font-size:13.5px;color:#475569;line-height:1.5">' + escapeHtml(bkT("chatgate_body")) + '</p>' + 6723 (o.aiDisclosure!==false ? '<p style="margin:8px 0 0;font-size:12px;color:#475569;line-height:1.5;font-style:italic">' + escapeHtml(bkT("ai_disclosure")) + '</p>' : '') + 6724 '</div>' + 6725 '<label class="caf-consent-label" style="display:flex;align-items:flex-start;gap:10px;text-align:left;padding:12px 14px;background:#f8fafc;border:1.5px solid #e2e8f0;border-radius:12px;font-size:12.5px;line-height:1.5;color:#475569;cursor:pointer;margin-bottom:14px;transition:border-color .2s,background .2s">' + 6726 '<input type="checkbox" class="caf-consent-cb" aria-required="true" style="width:19px;height:19px;min-width:19px;margin-top:1px;accent-color:' + o.color + ';cursor:pointer">' + 6727 '<span>' + escapeHtml(bkT("chatgate_before")) + linkHtml + escapeHtml(bkT("chatgate_after")) + ' <span aria-hidden="true" style="color:#be123c;font-weight:700">*</span></span>' + 6728 '</label>' + 6729 '<label class="caf-consent-label caf-consent-age-label" style="display:flex;align-items:flex-start;gap:10px;text-align:left;padding:12px 14px;background:#f8fafc;border:1.5px solid #e2e8f0;border-radius:12px;font-size:12.5px;line-height:1.5;color:#475569;cursor:pointer;margin-bottom:14px;transition:border-color .2s,background .2s">' + 6730 '<input type="checkbox" class="caf-consent-age" aria-required="true" style="width:19px;height:19px;min-width:19px;margin-top:1px;accent-color:' + o.color + ';cursor:pointer">' + 6731 '<span>' + escapeHtml(bkT("age_gate_label")) + ' <span aria-hidden="true" style="color:#be123c;font-weight:700">*</span></span>' + 6732 '</label>' + 6733 '<button type="button" class="caf-consent-ok" disabled ' + 6734 'style="width:100%;padding:12px;border:none;border-radius:10px;background:var(--caf-c);color:var(--caf-on-c);' + 6735 'font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;transition:opacity .2s;opacity:.5">' + 6736 escapeHtml(bkT("chatgate_btn")) + 6737 '</button>' + 6738 '</div>'; 6739 6740 var cb = this.consentOverlay.querySelector(".caf-consent-cb"); 6741 var ageCb = this.consentOverlay.querySelector(".caf-consent-age"); 6742 var lbl = this.consentOverlay.querySelector(".caf-consent-label"); 6743 var ageLbl = this.consentOverlay.querySelector(".caf-consent-age-label"); 6744 var ok = this.consentOverlay.querySelector(".caf-consent-ok"); 6745 /* Fallback informativa: se manca PRIVACY_URL il link apre il pannello 6746 * privacy in-widget, cosi' il consenso resta informato (GDPR artt. 7, 13). */ 6747 var _openPanelLink = this.consentOverlay.querySelector(".caf-consent-open-panel"); 6748 if (_openPanelLink) { 6749 _openPanelLink.addEventListener("click", function (e) { 6750 e.preventDefault(); 6751 try { self._bkShowPrivacyPanel(); } catch (_e) {} 6752 }); 6753 } 6754 function _syncConsentBtn() { 6755 var both = cb.checked && ageCb.checked; 6756 ok.disabled = !both; 6757 ok.style.opacity = both ? "1" : ".5"; 6758 lbl.style.borderColor = cb.checked ? o.color : "#e2e8f0"; 6759 lbl.style.background = cb.checked ? (o.color + "0f") : "#f8fafc"; 6760 ageLbl.style.borderColor = ageCb.checked ? o.color : "#e2e8f0"; 6761 ageLbl.style.background = ageCb.checked ? (o.color + "0f") : "#f8fafc"; 6762 } 6763 cb.addEventListener("change", _syncConsentBtn); 6764 ageCb.addEventListener("change", _syncConsentBtn); 6765 ok.addEventListener("click", function () { 6766 if (!cb.checked || !ageCb.checked) return; 6767 /* flag in-memory PRIMA del save: se localStorage e' negato il gate 6768 resta comunque superato per questa sessione. */ 6769 self._chatConsentGiven = true; 6770 chatConsentSave(self._consentUrl, ageCb.checked); 6771 self.consentOverlay.style.display = "none"; 6772 if (self._syncAiFooter) setTimeout(function () { self._syncAiFooter(); }, 0); 6773 if (self._needsEmail() && self.emailOverlay) { 6774 self.emailOverlay.style.display = "flex"; 6775 setTimeout(function () { 6776 var emailInput = self.emailOverlay.querySelector(".caf-email-input"); 6777 if (emailInput) emailInput.focus(); 6778 }, 100); 6779 } else { 6780 self.messagesEl.style.display = ""; 6781 self._scrollWrap.style.display = ""; 6782 self._inputAreaEl.style.display = ""; 6783 self._scrollToBottom(); 6784 setTimeout(function () { self.inputEl.focus(); }, 100); 6785 } 6786 }); 6787 6788 this._trapTab(this.consentOverlay); 6789 this.panelEl.appendChild(this.consentOverlay); 6790 }; 6791 6792 CafChat.prototype._needsConsent = function () { 6793 if (!this.opts.consenso) return false;
6794 if (this._chatConsentGiven) return false; 6795 return !chatConsentLoad(); 6796 }; 6797 6798 /* ââ email overlay ââââââââââââââââââââââââââââââââââââââââââââ */ 6799 CafChat.prototype._buildEmailOverlay = function () { 6800 var self = this; 6801 var o = this.opts; 6802 var errId = "caf-email-err-" + quickId(); 6803 6804 this.emailOverlay = document.createElement("div"); 6805 this.emailOverlay.className = "caf-email-overlay"; 6806 /* BUGFIX: stesso rimedio del gate consenso (scroll quando trabocca). */ 6807 this.emailOverlay.style.cssText = 6808 "flex:1;min-height:0;overflow-y:auto;z-index:100;background:#fff;display:flex;flex-direction:column;" + 6809 "align-items:center;justify-content:flex-start;padding:32px 24px;"; 6810 6811 this.emailOverlay.innerHTML = 6812 '<div style="text-align:center;max-width:320px;width:100%;margin:auto 0">' + 6813 '<div style="margin-bottom:20px">' + 6814 '<img src="' + escapeHtml(o.logo) + '" alt="" style="display:block;width:60px;height:60px;border-radius:50%;object-fit:cover;margin:0 auto 12px">' + 6815 '<h3 style="margin:0 0 6px;font-size:18px;font-weight:700;color:#1e293b">' + escapeHtml(o.name) + '</h3>' + 6816 '<p style="margin:0;font-size:14px;color:#5b6573;line-height:1.4">' + escapeHtml(bkT("email_gate_body")) + '</p>' + 6817 '</div>' + 6818 '<form class="caf-email-form" style="width:100%">' + 6819 '<input type="email" class="caf-email-input" placeholder="' + escapeHtml(bkT("email_gate_ph")) + '" required ' + 6820 'autocomplete="email" inputmode="email" aria-label="' + escapeHtml(bkT("aria_email")) + '" aria-describedby="' + errId + '" ' + 6821 'style="width:100%;padding:12px 14px;border:1.5px solid #e2e8f0;border-radius:10px;font-size:15px;' + 6822 'outline:none;transition:border-color .2s;box-sizing:border-box;margin-bottom:12px;font-family:inherit">' + 6823 '<div class="caf-email-err" id="' + errId + '" role="alert" style="display:none;text-align:left;font-size:13px;color:#b91c1c;margin:-4px 0 10px;line-height:1.35"></div>' + 6824 '<button type="submit" class="caf-email-submit" ' + 6825 'style="width:100%;padding:12px;border:none;border-radius:10px;background:var(--caf-c);color:var(--caf-on-c);' + 6826 'font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;transition:opacity .2s">' + 6827 escapeHtml(bkT("email_gate_btn")) + 6828 '</button>' + 6829 '</form>' + 6830 '<p class="caf-email-privnote" style="margin:14px 0 0;font-size:11.5px;color:#475569;line-height:1.5;text-align:left">' + 6831 escapeHtml(bkT("email_gate_note")) + ' ' + 6832 ((o.privacyUrl && /^https?:\/\//i.test(o.privacyUrl)) 6833 ? '<a href="' + escapeHtml(String(o.privacyUrl).replace(/"/g, "%22")) + '" target="_blank" rel="noopener noreferrer" style="color:var(--caf-c-ink);text-decoration:underline;font-weight:600">' + escapeHtml(bkT("email_gate_privacy")) + '</a>' 6834 : '<a href="#" class="caf-email-open-panel" role="button" style="color:var(--caf-c-ink);text-decoration:underline;font-weight:600;cursor:pointer">' + escapeHtml(bkT("email_gate_privacy")) + '</a>') + 6835 '</p>' + 6836 '</div>'; 6837 6838 var form = this.emailOverlay.querySelector(".caf-email-form"); 6839 var input = this.emailOverlay.querySelector(".caf-email-input"); 6840 var errEl = this.emailOverlay.querySelector(".caf-email-err"); 6841 var _eop = this.emailOverlay.querySelector(".caf-email-open-panel"); 6842 if (_eop) { 6843 var _eopSelf = this; 6844 _eop.addEventListener("click", function (e) { 6845 e.preventDefault(); 6846 try { _eopSelf._bkShowPrivacyPanel(); } catch (_e) {} 6847 }); 6848 } 6849 6850 input.addEventListener("focus", function () { 6851 this.style.borderColor = o.color; 6852 }); 6853 input.addEventListener("blur", function () { 6854 this.style.borderColor = "#e2e8f0"; 6855 }); 6856 6857 form.addEventListener("submit", function (e) { 6858 e.preventDefault(); 6859 var email = input.value.trim(); 6860 if (!bkValidaEmail(email)) { 6861 input.setAttribute("aria-invalid", "true"); 6862 if (errEl) { errEl.textContent = "Inserisci un indirizzo email valido."; errEl.style.display = ""; } 6863 input.focus(); 6864 return; 6865 } 6866 input.setAttribute("aria-invalid", "false"); 6867 if (errEl) { errEl.textContent = ""; errEl.style.display = "none"; } 6868 self._saveEmail(email); 6869 // Hide overlay and show chat UI 6870 self.emailOverlay.style.display = "none"; 6871 self.messagesEl.style.display = ""; 6872 self._scrollWrap.style.display = ""; 6873 self._inputAreaEl.style.display = ""; 6874 if (self._syncAiFooter) self._syncAiFooter(); 6875 self.inputEl.focus(); 6876 self._scrollToBottom(); 6877 // sync email to server immediately 6878 self._syncConversation(); 6879 }); 6880 6881 this.panelEl.appendChild(this.emailOverlay); 6882 }; 6883 6884 CafChat.prototype._needsEmail = function () { 6885 return this.opts.chiedomail && !this.userEmail; 6886 }; 6887 6888 /* ââ voice mode panel ââââââââââââââââââââââââââââââââââââââââ */ 6889 CafChat.prototype._buildVoicePanel = function () { 6890 var self = this; 6891 var o = this.opts; 6892 6893 this.voicePanelEl = document.createElement("div"); 6894 this.voicePanelEl.className = "caf-voice-panel"; 6895 6896 /* transcript area */ 6897 this.voiceTranscriptEl = document.createElement("div"); 6898 this.voiceTranscriptEl.className = "caf-voice-transcript"; 6899 this.voicePanelEl.appendChild(this.voiceTranscriptEl); 6900 6901 /* controls area */ 6902 var controls = document.createElement("div");
6903 controls.className = "caf-voice-controls"; 6904 controls.style.background = "radial-gradient(ellipse at 50% 80%, " + o.color + "20 0%, rgba(240,253,244,.5) 50%, rgba(255,255,255,.8) 100%)"; 6905 6906 /* ambient glow */ 6907 this.voiceGlowEl = document.createElement("span"); 6908 this.voiceGlowEl.className = "caf-voice-glow"; 6909 this.voiceGlowEl.style.width = "120px"; 6910 this.voiceGlowEl.style.height = "120px"; 6911 this.voiceGlowEl.style.backgroundColor = o.color; 6912 this.voiceGlowEl.style.opacity = "0.08"; 6913 controls.appendChild(this.voiceGlowEl); 6914 6915 /* status text */ 6916 this.voiceStatusEl = document.createElement("p"); 6917 this.voiceStatusEl.className = "caf-voice-status"; 6918 this.voiceStatusEl.style.color = "var(--caf-c-ink)"; /* a11y #C54: colore pieno */ 6919 this.voiceStatusEl.textContent = bkT("voice_tap_to_talk"); 6920 controls.appendChild(this.voiceStatusEl); 6921 6922 /* hint text */ 6923 this.voiceHintEl = document.createElement("p"); 6924 this.voiceHintEl.className = "caf-voice-hint"; 6925 this.voiceHintEl.style.color = "#5b6573"; 6926 this.voiceHintEl.textContent = bkT("voice_press_to_start"); 6927 controls.appendChild(this.voiceHintEl); 6928 6929 /* mic wrap */ 6930 var micWrap = document.createElement("div"); 6931 micWrap.className = "caf-voice-mic-wrap"; 6932 6933 /* outer ring */ 6934 this.voiceRingEl = document.createElement("span"); 6935 this.voiceRingEl.className = "caf-voice-ring"; 6936 this.voiceRingEl.style.width = "105px"; 6937 this.voiceRingEl.style.height = "105px"; 6938 this.voiceRingEl.style.borderColor = o.color + "30"; 6939 micWrap.appendChild(this.voiceRingEl); 6940 6941 /* ripple container */ 6942 this.voiceRipplesEl = document.createElement("span"); 6943 this.voiceRipplesEl.style.cssText = "position:absolute;display:none"; 6944 for (var r = 0; r < 3; r++) { 6945 var ripple = document.createElement("span"); 6946 ripple.className = "caf-voice-ripple"; 6947 ripple.style.cssText = "width:96px;height:96px;position:absolute;left:50%;top:50%;margin-left:-48px;margin-top:-48px;background:" + o.color; 6948 ripple.style.opacity = String(0.3 - r * 0.1); 6949 ripple.style.animation = "caf-voice-ripple 2s ease-out " + (r * 0.6) + "s infinite"; 6950 this.voiceRipplesEl.appendChild(ripple); 6951 } 6952 micWrap.appendChild(this.voiceRipplesEl); 6953 6954 /* waveform bars container */ 6955 this.voiceBarsEl = document.createElement("span"); 6956 this.voiceBarsEl.className = "caf-voice-bars"; 6957 this.voiceBarsEl.style.display = "none"; 6958 for (var b = 0; b < 12; b++) { 6959 var bar = document.createElement("span"); 6960 bar.className = "caf-voice-bar"; 6961 bar.style.height = (10 + (b % 3) * 4) + "px"; 6962 bar.style.transform = "rotate(" + (b * 30) + "deg) translateY(-58px)"; 6963 bar.style.animation = "caf-voice-bar 0.5s ease-in-out " + (b * 0.06) + "s infinite"; 6964 bar.style.background = o.color + "80"; 6965 this.voiceBarsEl.appendChild(bar); 6966 } 6967 micWrap.appendChild(this.voiceBarsEl); 6968 6969 /* the big mic button */ 6970 this.voiceMicBtn = document.createElement("button"); 6971 this.voiceMicBtn.className = "caf-voice-mic-btn"; 6972 this.voiceMicBtn.type = "button"; 6973 this.voiceMicBtn.style.background = o.color; 6974 this.voiceMicBtn.style.boxShadow = "0 8px 30px -4px " + o.color + "80"; 6975 this.voiceMicBtn.style.animation = "caf-mic-breathe 3s ease-in-out infinite"; 6976 this.voiceMicBtn.setAttribute("aria-label", bkT("aria_speak")); 6977 this.voiceMicBtn.setAttribute("aria-pressed", "false"); 6978 this._setVoiceMicIcon("mic"); 6979 this.voiceMicBtn.addEventListener("click", function () { 6980 self._micRetryCount = 0; /* user gesture: reset retry counter */ 6981 if (self._isSpeaking || self.isLoading) { 6982 self._interruptResponse(); 6983 if (!self._isListening) self._scheduleListening(200); 6984 } else if (self._isListening) { 6985 self._stopListening(); 6986 } else { 6987 self._startListening(); 6988 } 6989 }); 6990 micWrap.appendChild(this.voiceMicBtn); 6991 controls.appendChild(micWrap); 6992 6993 /* exit button */ 6994 var exitBtn = document.createElement("button"); 6995 exitBtn.className = "caf-voice-exit"; 6996 exitBtn.type = "button"; 6997 exitBtn.innerHTML = 6998 '<svg viewBox="0 0 20 20"><path fill-rule="evenodd" d="M2 4.75A.75.75 0 012.75 4h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 4.75zM2 10a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 10zm0 5.25a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75z" clip-rule="evenodd"/></svg>
6998' + 6999 escapeHtml(bkT("voice_back_keyboard")); 7000 exitBtn.addEventListener("click", function () { 7001 self._exitVoiceMode(); 7002 }); 7003 controls.appendChild(exitBtn); 7004 7005 this.voicePanelEl.appendChild(controls); 7006 7007 /* insert after header, before messages */ 7008 this.panelEl.insertBefore(this.voicePanelEl, this.messagesEl); 7009 }; 7010 7011 CafChat.prototype._setVoiceMicIcon = function (mode) { 7012 var svg; 7013 if (mode === "stop") { 7014 svg = '<svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="3"/></svg>'; 7015 } else if (mode === "speaker") { 7016 svg = '<svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3z"/><path d="M16.5 12A4.5 4.5 0 0014 8v8a4.47 4.47 0 002.5-4z"/><path d="M14 3.23v2.06a6.49 6.49 0 010 13.42v2.06A8.49 8.49 0 0022.5 12 8.49 8.49 0 0014 3.23z"/></svg>'; 7017 } else if (mode === "dots") { 7018 svg = '<span style="display:flex;align-items:center;gap:6px">' + 7019 '<span style="width:10px;height:10px;border-radius:50%;background:#fff;animation:caf-dots 1.4s infinite ease-in-out both;animation-delay:-.3s"></span>' + 7020 '<span style="width:10px;height:10px;border-radius:50%;background:#fff;animation:caf-dots 1.4s infinite ease-in-out both;animation-delay:-.15s"></span>' + 7021 '<span style="width:10px;height:10px;border-radius:50%;background:#fff;animation:caf-dots 1.4s infinite ease-in-out both"></span>' + 7022 '</span>'; 7023 } else { 7024 svg = '<svg viewBox="0 0 24 24"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/><path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>'; 7025 } 7026 this.voiceMicBtn.innerHTML = svg; 7027 }; 7028 7029 /* ââ date helpers âââââââââââââââââââââââââââââââââââââââââââ */ 7030 function formatDateLabel(ts) { 7031 var d = new Date(ts); 7032 var now = new Date(); 7033 var today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); 7034 var msgDay = new Date(d.getFullYear(), d.getMonth(), d.getDate()); 7035 var diff = (today - msgDay) / 86400000; 7036 if (diff === 0) return "Oggi"; 7037 if (diff === 1) return "Ieri"; 7038 return d.toLocaleDateString("it-IT", { day: "numeric", month: "long" }); 7039 } 7040 7041 function dayKey(ts) { 7042 if (!ts) return ""; 7043 var d = new Date(ts); 7044 return d.getFullYear() + "-" + (d.getMonth() + 1) + "-" + d.getDate(); 7045 } 7046 7047 /* ââ translation languages âââââââââââââââââââââââââââââââââ */ 7048 var TRANSLATE_LANGS = [ 7049 { flag: "ð¬ð§", code: "en", label: "EN" }, 7050 { flag: "ð«ð·", code: "fr", label: "FR" }, 7051 { flag: "ð©ðª", code: "de", label: "DE" }, 7052 { flag: "ðªð¸", code: "es", label: "ES" }, 7053 { flag: "ðµð¹", code: "pt", label: "PT" }, 7054 { flag: "ð·ð´", code: "ro", label: "RO" }, 7055 { flag: "ð¨ð³", code: "zh", label: "ZH" }, 7056 { flag: "ð¸ð¦", code: "ar", label: "AR" }, 7057 { flag: "ðºð¦", code: "uk", label: "UK" }, 7058 { flag: "ð·ðº", code: "ru", label: "RU" }, 7059 { flag: "ð¦ð±", code: "sq", label: "AL" }, 7060 { flag: "ð®ð³", code: "hi", label: "HI" }, 7061 { flag: "ðµð±", code: "pl", label: "PL" }, 7062 { flag: "ð³ð±", code: "nl", label: "NL" }, 7063 { flag: "ð¸ðª", code: "sv", label: "SV" }, 7064 { flag: "ð©ð°", code: "da", label: "DA" }, 7065 { flag: "ð³ð´", code: "no", label: "NO" }, 7066 { flag: "ð«ð®", code: "fi", label: "FI" }, 7067 { flag: "ð¬ð·", code: "el", label: "EL" }, 7068 { flag: "ððº", code: "hu", label: "HU" }, 7069 { flag: "ð¨ð¿", code: "cs", label: "CS" }, 7070 { flag: "ð¸ð°", code: "sk", label: "SK" }, 7071 { flag: "ð§ð¬", code: "bg", label: "BG" }, 7072 { flag: "ðð·", code: "hr", label: "HR" }, 7073 { flag: "ð·ð¸", code: "sr", label: "SR" }, 7074 { flag: "ð¸ð®", code: "sl", label: "SL" }, 7075 { flag: "ð±ð¹", code: "lt", label: "LT" }, 7076 { flag: "ð±ð»", code: "lv", label: "LV" }, 7077 { flag: "ðªðª", code: "et", label: "ET" }, 7078 { flag: "ð¹ð·", code: "tr", label: "TR" }, 7079 { flag: "ð¯ðµ", code: "ja", label: "JA" }, 7080 { flag: "ð°ð·", code: "ko", label: "KO" }, 7081 { flag: "ð¹ð", code: "th", label: "TH" }, 7082 { flag: "ð»ð³", code: "vi", label: "VI" }, 7083 { flag: "ð®ð©", code: "id", label: "ID" }, 7084 { flag: "ð²ð¾", code: "ms", label: "MS" }, 7085 { flag: "ðµð", code: "tl", label: "TL" }, 7086 { flag: "ð®ð±", code: "he", label: "HE" }, 7087 { flag: "ð®ð·", code: "fa", label: "FA" }, 7088 { flag: "ðµð°", code: "ur", label: "UR" }, 7089 { flag: "ð§ð©", code: "bn", label: "BN" }, 7090 { flag: "ðªð¹", code: "am", label: "AM" },
7091 { flag: "ð³ð¬", code: "yo", label: "YO" }, 7092 { flag: "ð°ðª", code: "sw", label: "SW" }, 7093 { flag: "ð§ð·", code: "pt-BR", label: "PT-BR" }, 7094 { flag: "ð²ð©", code: "mo", label: "MD" }, 7095 { flag: "ð¬ðª", code: "ka", label: "KA" }, 7096 { flag: "ð¦ð²", code: "hy", label: "HY" }, 7097 ]; 7098 7099 var LANG_NAMES = { 7100 en: "inglese", fr: "francese", de: "tedesco", es: "spagnolo", 7101 pt: "portoghese", ro: "rumeno", zh: "cinese", ar: "arabo", 7102 uk: "ucraino", ru: "russo", sq: "albanese", hi: "hindi", 7103 pl: "polacco", nl: "olandese", sv: "svedese", da: "danese", 7104 no: "norvegese", fi: "finlandese", el: "greco", hu: "ungherese", 7105 cs: "ceco", sk: "slovacco", bg: "bulgaro", hr: "croato", 7106 sr: "serbo", sl: "sloveno", lt: "lituano", lv: "lettone", 7107 et: "estone", tr: "turco", ja: "giapponese", ko: "coreano", 7108 th: "tailandese", vi: "vietnamita", id: "indonesiano", ms: "malese", 7109 tl: "filippino", he: "ebraico", fa: "persiano", ur: "urdu", 7110 bn: "bengalese", am: "amarico", yo: "yoruba", sw: "swahili", 7111 "pt-BR": "portoghese brasiliano", mo: "moldavo", ka: "georgiano", hy: "armeno" 7112 }; 7113 7114 /* ââ copy helper ââââââââââââââââââââââââââââââââââââââââââ */ 7115 function copyText(text, btn) { 7116 var done = function () { 7117 btn.classList.add("copied"); 7118 btn.textContent = "â"; 7119 var toast = document.createElement("div"); 7120 toast.className = "caf-copy-toast"; 7121 toast.textContent = "Copiato!"; 7122 btn.parentElement.appendChild(toast); 7123 setTimeout(function () { 7124 btn.classList.remove("copied"); 7125 btn.textContent = "â§"; 7126 if (toast.parentElement) toast.parentElement.removeChild(toast); 7127 }, 1500); 7128 }; 7129 if (navigator.clipboard && navigator.clipboard.writeText) { 7130 navigator.clipboard.writeText(text).then(done).catch(done); 7131 } else { 7132 var ta = document.createElement("textarea"); 7133 ta.value = text; 7134 ta.style.cssText = "position:fixed;opacity:0"; 7135 document.body.appendChild(ta); 7136 ta.select(); 7137 try { document.execCommand("copy"); } catch (e) { /* ok */ } 7138 document.body.removeChild(ta); 7139 done(); 7140 } 7141 } 7142 7143 /* ââ render messages (full rebuild) âââââââââââââââââââââââââââ */ 7144 CafChat.prototype._renderMessages = function () { 7145 var self = this; 7146 var o = this.opts; 7147 this._streamingBubble = null; 7148 var frag = document.createDocumentFragment(); 7149 7150 var totalMsgs = this.messages.length; 7151 var lastDay = ""; 7152 7153 for (var i = 0; i < totalMsgs; i++) { 7154 var msg = this.messages[i]; 7155 var isLast = i === totalMsgs - 1; 7156 var isNew = i >= totalMsgs - 2 && this.isLoading; 7157 7158 /* ââ date separator ââ */ 7159 var msgDay = dayKey(msg.timestamp); 7160 if (msg.timestamp && msgDay && msgDay !== lastDay && msg.id !== "welcome") { 7161 lastDay = msgDay; 7162 var sep = document.createElement("div"); 7163 sep.className = "caf-date-sep"; 7164 sep.innerHTML = "<span>" + formatDateLabel(msg.timestamp) + "</span>"; 7165 frag.appendChild(sep); 7166 } 7167 7168 var row = document.createElement("div"); 7169 row.className = "caf-msg-row" + (msg.role === "user" ? " user" : "") + (isNew ? " caf-new" : ""); 7170 var srSender = document.createElement("span"); 7171 srSender.className = "caf-sr-only"; 7172 srSender.textContent = (msg.role === "user" ? bkT("sr_you_said") : ((o.name || bkT("sr_assistant")) + " " + bkT("sr_said"))); 7173 row.appendChild(srSender); 7174 7175 if (msg.role === "assistant") { 7176 var avatar = document.createElement("div"); 7177 avatar.className = "caf-msg-avatar"; 7178 avatar.innerHTML = '<img src="' + escapeHtml(o.logo) + '" alt="">'; 7179 row.appendChild(avatar); 7180 } 7181 7182 /* wrap bubble + timestamp + copy btn */ 7183 var wrap = document.createElement("div"); 7184 wrap.className = "caf-msg-wrap"; 7185 7186 var bubble = document.createElement("div"); 7187 bubble.className = "caf-msg-bubble " + msg.role; 7188 if (msg.transLang) { bubble.setAttribute("lang", msg.transLang); if (/^(ar|he|fa|ur)/.test(msg.transLang)) bubble.setAttribute("dir", "rtl"); } 7189 if (msg.role === "assistant") { 7190 bubble.setAttribute("data-ai-generated", "true"); 7191 if (msg.model) bubble.setAttribute("data-model", String(msg.model)); 7192 } 7193 if (msg.role === "user") { 7194 bubble.style.background = "var(--caf-c)"; /* brand AA-scurito: bianco >=4.5 sul testo bolla */ 7195 } 7196 7197 if (msg.role === "assistant" && msg.content === "@@BOOKING@@") { 7198 /* La prenotazione ora è SEMPRE fullscreen: skip il bubble inline. */ 7199 continue; 7200 } else if (msg.role === "assistant" && typeof msg.content === "string" && msg.content.indexOf("@@BOOKING_LAUNCH@@") === 0) { 7201 /* Booking launch: messaggio con bottone cliccabile che apre il wizard. 7202 * Format: "@@BOOKING_LAUNCH@@<optional service hint>" */ 7203 var bklHint = msg.content.slice("@@BOOKING_LAUNCH@@".length).trim(); 7204 var bklSelf = this; 7205 var bklIntro = document.createElement("div"); 7206 bklIntro.className = "caf-bk-launch-intro"; 7207 bklIntro.style.cssText = "margin-bottom:10px;font-weight:500;"; 7208 bklIntro.textContent = bklHint 7209 ? "Perfetto! Clicca qui sotto per prenotare " + bklHint + " \u2192" 7210 : "Perfetto! Clicca qui sotto per aprire il wizard di prenotazione \u2192"; 7211 bubble.appendChild(bklIntro); 7212 var bklBtn = document.createElement("button"); 7213 bklBtn.type = "button"; 7214 bklBtn.className = "caf-inline-bk-chip caf-bk-launch-btn"; 7215 bklBtn.innerHTML = '<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M9 16l2 2 4-4"/></svg>
7215' + escapeHtml(bklHint ? "Apri il wizard per " + bklHint : "Apri il wizard di prenotazione"); 7216 bklBtn.addEventListener("click", function (e) { 7217 e.stopPropagation(); 7218 /* Apri il wizard direttamente. NON usiamo _startBookingFromCta 7219 * perché aggiungerebbe un secondo user-msg fantasma "Vorrei 7220 * prenotare un appuntamento" â l'utente ha già scritto la sua 7221 * richiesta sopra. Chiamiamo _bkStart() direttamente. */ 7222 if (bklSelf.bkStep !== "idle") return; 7223 if (bklHint) bklSelf.bkHint = bklHint; 7224 bklSelf._bkStart(); 7225 if (bklSelf._updateBkCta) bklSelf._updateBkCta(); 7226 }); 7227 bubble.appendChild(bklBtn); 7228 } else if (msg.role === "assistant" && msg.content === "@@PWDRESET@@") { 7229 var pwC = document.createElement("div"); 7230 pwC.className = "caf-pw"; 7231 bubble.appendChild(pwC); 7232 this._pwdEl = pwC; 7233 } else if (msg.role === "assistant" && msg.content === "" && this.isLoading) { 7234 bubble.innerHTML = 7235 '<div class="caf-typing" aria-hidden="true"><span></span><span></span><span></span></div>'; 7236 if (isLast) this._streamingBubble = bubble; 7237 } else if (msg.role === "assistant") { 7238 bubble.innerHTML = renderMarkdown(msg.content, o.color); 7239 /* Trova la query utente che ha PRECEDUTO questo specifico messaggio 7240 * assistente â non l'ultima della conversazione (sennò vecchi 7241 * bubble riceverebbero il carosello dell'ultima query). */ 7242 var _ecQuery = ""; 7243 for (var _qi = i - 1; _qi >= 0; _qi--) { 7244 if (this.messages[_qi].role === "user" && this.messages[_qi].content) { 7245 _ecQuery = this.messages[_qi].content; break; 7246 } 7247 } 7248 this._renderEcommerceCards && this._renderEcommerceCards(bubble, _ecQuery); 7249 if (this.isLoading && isLast) { 7250 var caret = document.createElement("span"); 7251 caret.className = "caf-caret"; 7252 caret.style.background = o.color + "e6"; 7253 bubble.appendChild(caret); 7254 this._streamingBubble = bubble; 7255 } 7256 /* inline "Prenota questo servizio" chip when assistant mentions a bookable service */ 7257 if (msg.content && msg.content.length > 0 && !this.isLoading && this._bkEnabled === true && this.bkStep === "idle" && msg.id !== "welcome") { 7258 /* SOLO servizi gestiti dal CAF. NASPI e Assegno Unico Universale 7259 * sono di competenza INCA/Patronato â non offriamo prenotazione. 7260 * Pattern più specifici PRIMA dei più generici (primo match vince). 7261 * Tutti i pattern hanno \b finale per evitare false positive 7262 * (es. "modello redditi" non deve matchare "modello red"). */ 7263 var svcPatterns = [ 7264 {k:/\b730\b/i, name:"730"}, 7265 {k:/\bise[ue]\b/i, name:"ISEE"}, 7266 {k:/\bcud\b/i, name:"CUD"}, 7267 {k:/\bsuccessi[oò]n/i, name:"Successione"}, 7268 {k:/\b(colf|badant\w*)\b/i, name:"Colf e Badanti"}, 7269 {k:/\b(imu|tasi|tari)\b/i, name:"IMU/TASI/TARI"}, 7270 {k:/\bmodello\s+redditi\b|\bunico\b/i, name:"Modello REDDITI"}, 7271 {k:/\bmodello\s+red\b|\bred\b/i, name:"Modello RED"}, 7272 {k:/\binvciv\b/i, name:"INVCIV"}, 7273 {k:/\baccas\/?ps\b|\bassegno\s+sociale\b/i, name:"ACCAS/PS"}, 7274 {k:/\bcedolare\s+secca\b|\blocazion[ei]\b/i, name:"Locazione"}, 7275 {k:/\bdelega\s+unica\b/i, name:"Delega Unica"}, 7276 {k:/\bvisur[ae]\s+catastal[ei]\b/i, name:"Visura Catastale"}, 7277 {k:/\bpartita\s+iva\b|\bsapi\b/i, name:"Partita IVA"} 7278 ]; 7279 var matched = null; 7280 for (var svcI = 0; svcI < svcPatterns.length; svcI++) { 7281 if (svcPatterns[svcI].k.test(msg.content)) { matched = svcPatterns[svcI].name; break; } 7282 } 7283 if (matched) { 7284 var chipSelf = this; 7285 var chipSvc = matched; 7286 var chip = document.createElement("button"); 7287 chip.type = "button"; 7288 chip.className = "caf-inline-bk-chip"; 7289 chip.innerHTML = '<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M9 16l2 2 4-4"/></svg>Prenota ' + escapeHtml(chipSvc); 7290 chip.addEventListener("click", function (e) { 7291 e.stopPropagation(); 7292 chipSelf.bkHint = chipSvc; 7293 chipSelf._startBookingFromCta(); 7294 }); 7295 bubble.appendChild(chip); 7296 } 7297 } 7298 /* copy & translate buttons for assistant messages with content */ 7299 if (msg.content && msg.content.length > 0 && !this.isLoading) { 7300 (function (content, wrapEl) { 7301 var copyBtn = document.createElement("button"); 7302 copyBtn.className = "caf-copy-btn"; 7303 copyBtn.type = "button"; 7304 copyBtn.textContent = "â§"; 7305 copyBtn.setAttribute("aria-label", bkT("aria_copy")); 7306 copyBtn.addEventListener("click", function (e) { 7307 e.stopPropagation(); 7308 copyText(content, copyBtn); 7309 }); 7310 wrapEl.appendChild(copyBtn); 7311 7312 var transBtn = document.createElement("button"); 7313 transBtn.className = "caf-translate-btn"; 7314 transBtn.type = "button"; 7315 transBtn.textContent = "ð"; 7316 transBtn.setAttribute("aria-label", bkT("aria_translate")); 7317 var pickerOpen = false;
7318 var pickerEl = null; 7319 transBtn.addEventListener("click", function (e) { 7320 e.stopPropagation(); 7321 if (pickerOpen && pickerEl) { 7322 pickerEl.remove(); 7323 pickerOpen = false; 7324 return; 7325 } 7326 pickerEl = document.createElement("div"); 7327 pickerEl.className = "caf-lang-picker"; 7328 TRANSLATE_LANGS.forEach(function (lang) { 7329 var btn = document.createElement("button"); 7330 btn.className = "caf-lang-btn"; 7331 btn.type = "button"; 7332 btn.innerHTML = '<span class="caf-flag">' + lang.flag + '</span>' + lang.label; 7333 btn.addEventListener("click", function (ev) { 7334 ev.stopPropagation(); 7335 pickerEl.remove(); 7336 pickerOpen = false; 7337 self._translateMessage(content, lang.code); 7338 }); 7339 pickerEl.appendChild(btn); 7340 }); 7341 wrapEl.appendChild(pickerEl); 7342 pickerOpen = true; 7343 var closePicker = function (ev) { 7344 if (pickerEl && !pickerEl.contains(ev.target) && ev.target !== transBtn) { 7345 pickerEl.remove(); 7346 pickerOpen = false; 7347 document.removeEventListener("click", closePicker); 7348 } 7349 }; 7350 /* BUGFIX: la chiusura con Esc rimuoveva il nodo ma lasciava 7351 * pickerOpen=true â servivano due attivazioni per riaprire. */ 7352 pickerEl._cafCleanup = function () { 7353 pickerOpen = false; 7354 try { document.removeEventListener("click", closePicker); } catch (_e) {} 7355 }; 7356 setTimeout(function () { document.addEventListener("click", closePicker); }, 0); 7357 }); 7358 wrapEl.appendChild(transBtn); 7359 7360 /* mobile action bar â sits below the bubble, no overlap */ 7361 var actBar = document.createElement("div"); 7362 actBar.className = "caf-msg-actions"; 7363 var mobCopy = document.createElement("button"); 7364 mobCopy.type = "button"; 7365 mobCopy.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copia'; 7366 mobCopy.addEventListener("click", function (e) { 7367 e.stopPropagation(); 7368 copyText(content, mobCopy); 7369 mobCopy.classList.add("copied"); 7370 var origHTML = mobCopy.innerHTML; 7371 mobCopy.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6L9 17l-5-5"/></svg> Copiato'; 7372 setTimeout(function () { mobCopy.classList.remove("copied"); mobCopy.innerHTML = origHTML; }, 1500); 7373 }); 7374 actBar.appendChild(mobCopy); 7375 var mobTrans = document.createElement("button"); 7376 mobTrans.type = "button"; 7377 mobTrans.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg> Traduci'; 7378 var mobPickerOpen = false, mobPickerEl = null; 7379 mobTrans.addEventListener("click", function (e) { 7380 e.stopPropagation(); 7381 if (mobPickerOpen && mobPickerEl) { mobPickerEl.remove(); mobPickerOpen = false; return; } 7382 mobPickerEl = document.createElement("div"); 7383 mobPickerEl.className = "caf-lang-picker"; 7384 mobPickerEl.style.cssText = "position:absolute;bottom:32px;left:0;top:auto;right:auto";
7385 TRANSLATE_LANGS.forEach(function (lang) { 7386 var btn = document.createElement("button"); 7387 btn.className = "caf-lang-btn"; btn.type = "button"; 7388 btn.innerHTML = '<span class="caf-flag">' + lang.flag + '</span>' + lang.label; 7389 btn.addEventListener("click", function (ev) { 7390 ev.stopPropagation(); mobPickerEl.remove(); mobPickerOpen = false; 7391 self._translateMessage(content, lang.code); 7392 }); 7393 mobPickerEl.appendChild(btn); 7394 }); 7395 actBar.style.position = "relative"; 7396 actBar.appendChild(mobPickerEl); mobPickerOpen = true; 7397 var closeMobPicker = function (ev) { 7398 if (mobPickerEl && !mobPickerEl.contains(ev.target) && ev.target !== mobTrans) { 7399 mobPickerEl.remove(); mobPickerOpen = false; document.removeEventListener("click", closeMobPicker); 7400 } 7401 }; 7402 setTimeout(function () { document.addEventListener("click", closeMobPicker); }, 0); 7403 }); 7404 actBar.appendChild(mobTrans); 7405 wrapEl.appendChild(actBar); 7406 })(msg.content, wrap); 7407 } 7408 } else { 7409 bubble.textContent = msg.content; 7410 } 7411 7412 wrap.appendChild(bubble); 7413 7414 /* timestamp */ 7415 if (msg.timestamp && msg.id !== "welcome") { 7416 var timeEl = document.createElement("div"); 7417 timeEl.className = "caf-msg-time"; 7418 timeEl.textContent = new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); 7419 wrap.appendChild(timeEl); 7420 } 7421 7422 row.appendChild(wrap); 7423 frag.appendChild(row); 7424 } 7425 7426 /* quick replies */ 7427 if ( 7428 this.messages.length === 1 && 7429 this.messages[0].id === "welcome" && 7430 o.quickReplies && 7431 o.quickReplies.length > 0 7432 ) { 7433 var qr = document.createElement("div"); 7434 qr.className = "caf-quick"; 7435 /* small label over the quick replies */ 7436 var qrLbl = document.createElement("div"); 7437 qrLbl.className = "caf-quick-label"; 7438 qrLbl.textContent = "Domande frequenti"; 7439 qr.appendChild(qrLbl); 7440 /* pick an icon based on the text content â contextually meaningful */ 7441 function pickQuickIcon(t) { 7442 var s = (t || "").toLowerCase(); 7443 /* 1. Booking / appointment â calendar */ 7444 if (/prenot|appuntamento|fissare\s+(un|l')/.test(s)) 7445 return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M8 14h.01M12 14h.01M16 14h.01"/></svg>'; 7446 /* 2. Location / nearest / where â map pin */ 7447 if (/vicin|dove|posizione|geoloc|indirizz|raggiun|tua\s+zona|a\s+me/.test(s)) 7448 return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>'; 7449 /* 3. CAF sede / centro â building */ 7450 if (/\b(sede|sedi|centro|centri|caf|uffic)/.test(s)&&!(/servizi|elenco|list/.test(s))) 7451 return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="1"/><line x1="9" y1="6" x2="15" y2="6"/><line x1="9" y1="10" x2="15" y2="10"/><line x1="9" y1="14" x2="15" y2="14"/><path d="M10 22v-4h4v4"/></svg>'; 7452 /* 4. Services / what we offer â grid of apps */ 7453 if (/servizi|elenco|offerta|offrit[ae]|fate|che cosa\s+fa|assistenza/.test(s)) 7454 return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/></svg>'; 7455 /* 5. Deductions / expenses / money â Euro symbol */ 7456 if (/detrarre|detrazion|detraibili|spes|rimborso|rimbors|bonus|agevolaz|sconto/.test(s)) 7457 return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M15.5 8.5A4.5 4.5 0 0 0 8 12a4.5 4.5 0 0 0 7.5 3.5"/><line x1="6" y1="10.5" x2="12" y2="10.5"/><line x1="6" y1="13.5" x2="12" y2="13.5"/></svg>'; 7458 /* 6. Tax codes / 730 / ISEE / forms â document with signature lines */ 7459 if (/\b(730|isee|cud|dichiarazion|modello|documento|successione|naspi|red|dsu)\b/.test(s)) 7460 return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><polyline points="14 2 14 8 20 8"/>
7460<line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>'; 7461 /* 7. IMU/TASI/TARI â house with small tag */ 7462 if (/\b(imu|tasi|tari|tass|casa|immobil|prima\s+casa)\b/.test(s)) 7463 return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 14 15 14 15 22"/></svg>'; 7464 /* 8. Hours / opening / when â clock */ 7465 if (/\b(orar|apertur|chius|quando|ora|che\s+ore)\b/.test(s)) 7466 return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>'; 7467 /* 9. Contacts / phone / email â phone */ 7468 if (/\b(contatt|telefono|numero|email|mail|scriv|chiam)\b/.test(s)) 7469 return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.37 1.9.72 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.35 1.85.59 2.81.72A2 2 0 0 1 22 16.92z"/></svg>'; 7470 /* 10. Documents needed / what to bring â folder or checklist */ 7471 if (/document|serv\w*\s+servon|\bportar|cosa\s+mi\s+serve/.test(s)) 7472 return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>'; 7473 /* 11. Pension â shield (security) */ 7474 if (/\bpension|assegno\s+unico|inps\b/.test(s)) 7475 return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>'; 7476 /* 12. Info / details â info circle */ 7477 if (/informazion|maggiori|più info|dettagl|approfond/.test(s)) 7478 return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>'; 7479 /* 13. Help / how-to â life buoy */ 7480 if (/\b(aiuto|aiut|come\s+(faccio|si|fare|posso))\b/.test(s)) 7481 return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="4"/><line x1="4.93" y1="4.93" x2="9.17" y2="9.17"/><line x1="14.83" y1="14.83" x2="19.07" y2="19.07"/><line x1="14.83" y1="9.17" x2="19.07" y2="4.93"/><line x1="4.93" y1="19.07" x2="9.17" y2="14.83"/></svg>'; 7482 /* 14. Generic question â question mark circle */ 7483 if (/^(che|cosa|come|perché|quali|quale|quando|dove)\b/.test(s)) 7484 return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>'; 7485 /* default: chat bubble */ 7486 return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>'; 7487 } 7488 var arrowSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>'; 7489 for (var q = 0; q < o.quickReplies.length; q++) { 7490 (function (text) { 7491 var btn = document.createElement("button"); 7492 btn.type = "button"; 7493 btn.innerHTML = 7494 '<span class="caf-quick-ic" aria-hidden="true">' + pickQuickIcon(text) + '</span>' + 7495 '<span class="caf-quick-txt">' + escapeHtml(text) + '</span>' + 7496 '<span class="caf-quick-ar" aria-hidden="true">' + arrowSvg + '</span>'; 7497 btn.addEventListener("click", function () { 7498 self._send(text); 7499 }); 7500 qr.appendChild(btn); 7501 })(o.quickReplies[q]); 7502 } 7503 frag.appendChild(qr); 7504 } 7505 7506 var wasAtBottom = this.isAtBottom !== false; 7507 this.messagesEl.innerHTML = ""; 7508 this.messagesEl.appendChild(frag);
7509 if (this._bkEl && this.bkStep !== "idle") this._bkRender(); 7510 if (this._pwdEl && this.pwdStep !== "idle") this._pwdRender(); 7511 if (wasAtBottom) { this._scrollToBottom(); } else { this._updateScrollBtn(); } 7512 /* keep voice transcript in sync when booking pushes messages */ 7513 if (this._voiceMode) this._renderVoiceTranscript(); 7514 }; 7515 7516 /* ââ update only the streaming bubble (no DOM rebuild) ââââââ */ 7517 CafChat.prototype._updateStreamingBubble = function (content) { 7518 var bubble = this._streamingBubble; 7519 if (!bubble) return; 7520 var o = this.opts; 7521 bubble.innerHTML = renderMarkdown(content, o.color); 7522 /* NOTA: NON renderizziamo le card durante lo streaming. 7523 * Il bubble.innerHTML viene riscritto ad ogni chunk (~80ms): se 7524 * costruissimo le card qui, ogni interazione utente con la qty o il 7525 * bottone "Aggiungi" verrebbe persa al chunk successivo. Gli anchor 7526 * sono nascosti via CSS (display:none); il _renderMessages() finale 7527 * (chiamato a streaming completato) costruirà le card una sola volta 7528 * con stato pulito. */ 7529 var caret = document.createElement("span"); 7530 caret.className = "caf-caret"; 7531 caret.style.background = o.color + "e6"; 7532 bubble.appendChild(caret); 7533 var el = this.messagesEl; 7534 var scrollTop = el.scrollTop; 7535 var clientH = el.clientHeight; 7536 var scrollH = el.scrollHeight; 7537 var nearBottom = scrollTop + clientH >= scrollH - 150; 7538 if (nearBottom) { 7539 el.scrollTop = el.scrollHeight; 7540 } 7541 }; 7542 7543 /* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 7544 ââ BOOKING SYSTEM â mirrors ChatWidget.tsx / useBookingFlow 7545 ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 7546 7547 /* spawn celebratory sparkles around a DOM element (position:relative) */ 7548 CafChat.prototype._bkSpawnSparkles = function(target,count){ 7549 if(!target||!target.appendChild)return; 7550 count=count||10; 7551 var colors=["#fbbf24","#f59e0b","#10b981","#3b82f6",this.opts.color,"#ec4899"]; 7552 var sparkleSvg='<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z"/></svg>'; 7553 var host=target; 7554 for(var i=0;i<count;i++){ 7555 var sp=document.createElement("span"); 7556 sp.className="caf-sparkle"; 7557 sp.style.color=colors[i%colors.length]; 7558 sp.innerHTML=sparkleSvg; 7559 var angle=(Math.PI*2*i)/count+(Math.random()*0.4-0.2); 7560 var dist=50+Math.random()*35; 7561 sp.style.setProperty("--sx",(Math.cos(angle)*dist)+"px"); 7562 sp.style.setProperty("--sy",(Math.sin(angle)*dist)+"px"); 7563 sp.style.animationDelay=(i*35)+"ms"; 7564 sp.style.animationDuration=(1000+Math.random()*400)+"ms"; 7565 host.appendChild(sp); 7566 (function(el){setTimeout(function(){if(el.parentNode)el.parentNode.removeChild(el);},1500);})(sp); 7567 } 7568 }; 7569 7570 /* ââ auto-detect user's province via geolocation + reverse geocoding ââ */ 7571 CafChat.prototype._bkAutoDetectProvince = async function(renderFn){ 7572 var self=this; 7573 /* DEMOPROMO: nessun GPS/Nominatim â simula il rilevamento e auto-seleziona 7574 * la prima provincia di esempio. */ 7575 if(self.opts&&self.opts.demopromo){ 7576 self._bkAutoProvStatus={type:"loading",msg:"Richiesta posizione (demo)..."};renderFn&&renderFn(); 7577 setTimeout(function(){ 7578 var p=(self.bkProvince&&self.bkProvince[0])||null; 7579 if(!p){self._bkAutoProvStatus={type:"err",msg:"Posizione non disponibile. Scegli manualmente qui sotto."};renderFn&&renderFn();return;} 7580 self._bkAutoProvStatus={type:"ok",msg:"Trovato: "+p.descrizione+" (demo). Ti porto ai servizi..."};renderFn&&renderFn(); 7581 setTimeout(function(){if(self.bkStep==="selectProvincia")self._bkPickProv(p);},700); 7582 },700); 7583 return; 7584 } 7585 if(window.isSecureContext===false){self._bkAutoProvStatus={type:"err",msg:"La geolocalizzazione richiede HTTPS."};renderFn&&renderFn();return;} 7586 var provSnapshot=self.bkProvince; 7587 self._bkAutoProvStatus={type:"loading",msg:"Richiesta posizione..."};renderFn&&renderFn(); 7588 /* Watchdog: alcuni browser (iOS Safari, Android+permissions glitch) 7589 * non chiamano mai i callback success/error di getCurrentPosition, 7590 * ignorando anche il timeout. Forziamo errore visibile dopo 18s â basta a 7591 * coprire il budget interno (GPS cityLevel 7s + IP parallelo ~3s + 7592 * Nominatim 6s) senza lasciare l'utente sul loading molto oltre la fine 7593 * reale del lavoro. */ 7594 if(self._bkAutoProvWatchdog){clearTimeout(self._bkAutoProvWatchdog);} 7595 self._bkAutoProvWatchdog=setTimeout(function(){ 7596 self._bkAutoProvWatchdog=null; 7597 if(self._bkAutoProvStatus&&self._bkAutoProvStatus.type==="loading"){ 7598 self._bkAutoProvStatus={type:"err",msg:"Posizione non disponibile. Scegli manualmente qui sotto."}; 7599 renderFn&&renderFn(); 7600 } 7601 },18000); 7602 7603 /* Strategia identica a _bkStart: GPS browser city-level â IP geo fallback. 7604 * Risolve il caso più comune di failure: "permesso autorizzato ma il
7605 * browser non sa calcolare la posizione" (desktop senza GPS, Wi-Fi 7606 * positioning fallito, Linux senza geoclue, ecc.). cityLevel: qui serve 7607 * solo il match della PROVINCIA, l'alta precisione non aggiunge nulla. */ 7608 var coords=null;var gpsErr=null; 7609 try{ 7610 coords=await self._bkGetGeoPosition({cityLevel:true}); 7611 }catch(e){ 7612 gpsErr=e; 7613 /* Permesso negato esplicitamente: NON proviamo IP (rispetta scelta utente). */ 7614 if(e&&e.code===1){ 7615 if(self._bkAutoProvWatchdog){clearTimeout(self._bkAutoProvWatchdog);self._bkAutoProvWatchdog=null;} 7616 self._bkAutoProvStatus={type:"err",msg:"Permesso di geolocalizzazione negato dal browser."}; 7617 renderFn&&renderFn();return; 7618 } 7619 /* Altri errori (timeout, position-unavailable): fallback IP. */ 7620 self._bkAutoProvStatus={type:"loading",msg:"Provo a stimare la posizione..."};renderFn&&renderFn(); 7621 try{ 7622 coords=await self._bkGetIpGeoPosition(); 7623 }catch(_e2){ 7624 if(self._bkAutoProvWatchdog){clearTimeout(self._bkAutoProvWatchdog);self._bkAutoProvWatchdog=null;} 7625 var emsg="Posizione non disponibile."; 7626 if(gpsErr&&gpsErr.code===2)emsg="Posizione non disponibile (segnale GPS/Wi-Fi assente)."; 7627 else if(gpsErr&&gpsErr.code===3)emsg="Richiesta posizione scaduta."; 7628 self._bkAutoProvStatus={type:"err",msg:emsg+" Scegli manualmente qui sotto."}; 7629 renderFn&&renderFn();return; 7630 } 7631 } 7632 if(self._bkAutoProvWatchdog){clearTimeout(self._bkAutoProvWatchdog);self._bkAutoProvWatchdog=null;} 7633 if(self.bkProvince!==provSnapshot||self.bkStep!=="selectProvincia")return; 7634 7635 try{ 7636 self._bkAutoProvStatus={type:"loading",msg:"Identifico la tua provincia..."};renderFn&&renderFn(); 7637 /* reverse-geocode via Nominatim to get Italian province/city */
7638 var ctrl=new AbortController();var tid=setTimeout(function(){ctrl.abort();},6000); 7639 /* GDPR minimizzazione: 2 decimali (~1 km) bastano per la provincia (zoom=10). */ 7640 var _glat=(typeof coords.lat==="number"?coords.lat:parseFloat(coords.lat)).toFixed(2); 7641 var _glon=(typeof coords.lon==="number"?coords.lon:parseFloat(coords.lon)).toFixed(2); 7642 var r=await fetch("https://nominatim.openstreetmap.org/reverse?format=json&zoom=10&addressdetails=1&lat="+_glat+"&lon="+_glon,{signal:ctrl.signal,headers:{"Accept":"application/json"}}); 7643 clearTimeout(tid); 7644 if(self.bkProvince!==provSnapshot||self.bkStep!=="selectProvincia")return; 7645 if(!r.ok){self._bkAutoProvStatus={type:"err",msg:"Impossibile identificare la zona. Scegli manualmente."};renderFn&&renderFn();return;} 7646 var data=await r.json(); 7647 var addr=(data&&data.address)||{}; 7648 var candidates=[addr.county,addr.province,addr.state_district,addr.city,addr.town,addr.municipality].filter(Boolean); 7649 var matched=null; 7650 for(var i=0;i<candidates.length&&!matched;i++){ 7651 matched=matchProvince(candidates[i],self.bkProvince); 7652 } 7653 if(!matched){ 7654 /* build a human-readable location string from reverse-geocoded address */ 7655 var locParts=[addr.city||addr.town||addr.village||addr.municipality,addr.county||addr.province||addr.state_district,addr.state].filter(Boolean); 7656 /* dedupe (city and county can be the same, e.g. "Roma, Roma") */ 7657 var seen={},locUnique=[]; 7658 for(var lp=0;lp<locParts.length;lp++){var k=locParts[lp].toLowerCase();if(!seen[k]){seen[k]=true;locUnique.push(locParts[lp]);}} 7659 var locStr=locUnique.join(", ")||(data&&data.display_name)||"posizione sconosciuta"; 7660 self._bkAutoProvStatus={type:"err",msg:"Sei a "+locStr+", ma questa provincia non è tra quelle disponibili. Scegli manualmente."}; 7661 renderFn&&renderFn();return; 7662 } 7663 self._bkAutoProvStatus={type:"ok",msg:"Trovato: "+matched.descrizione+". Ti porto ai servizi..."}; 7664 renderFn&&renderFn(); 7665 setTimeout(function(){if(self.bkStep==="selectProvincia")self._bkPickProv(matched);},900); 7666 }catch(e){ 7667 self._bkAutoProvStatus={type:"err",msg:"Errore nella ricerca. Riprova."};renderFn&&renderFn(); 7668 } 7669 }; 7670 7671 /* ââ geolocation + geocoding helpers âââââââââââââââââââââââ */ 7672 CafChat.prototype._bkHaversineKm = function(lat1,lon1,lat2,lon2){ 7673 var R=6371,dLat=(lat2-lat1)*Math.PI/180,dLon=(lon2-lon1)*Math.PI/180; 7674 var a=Math.sin(dLat/2)*Math.sin(dLat/2)+Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLon/2)*Math.sin(dLon/2); 7675 return R*2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a)); 7676 }; 7677 CafChat.prototype._bkGeocodeSede = async function(s){ 7678 if(s._lat!==undefined&&s._lon!==undefined)return{lat:s._lat,lon:s._lon}; 7679 /* 1) USA COORDINATE DAL BACKEND se presenti (campi possibili). Evita 7680 * completamente Nominatim e dà precisione massima. */ 7681 var bLat=null,bLon=null; 7682 if(typeof s.lat==="number"&&typeof s.lon==="number"){bLat=s.lat;bLon=s.lon;} 7683 else if(typeof s.latitude==="number"&&typeof s.longitude==="number"){bLat=s.latitude;bLon=s.longitude;} 7684 else if(typeof s.latitudine==="number"&&typeof s.longitudine==="number"){bLat=s.latitudine;bLon=s.longitudine;} 7685 else if(s.coordinate&&typeof s.coordinate.lat==="number"&&typeof s.coordinate.lon==="number"){bLat=s.coordinate.lat;bLon=s.coordinate.lon;} 7686 else if(s.geo&&typeof s.geo.lat==="number"&&typeof s.geo.lon==="number"){bLat=s.geo.lat;bLon=s.geo.lon;} 7687 /* Anche stringhe valide (es. "43.7696") */ 7688 else if(typeof s.lat==="string"&&typeof s.lon==="string"&&!isNaN(+s.lat)&&!isNaN(+s.lon)){bLat=+s.lat;bLon=+s.lon;} 7689 /* Sanity check rigoroso: dentro l'Italia. Scarta: 7690 * - (0,0) "Null Island" (backend con campo non popolato) 7691 * - Coords estere accidentali 7692 * - NaN/Infinity */ 7693 if(bLat!==null&&bLon!==null&&isFinite(bLat)&&isFinite(bLon) 7694 &&bLat>=35&&bLat<=48&&bLon>=6&&bLon<=19){ 7695 s._lat=bLat;s._lon=bLon;return{lat:bLat,lon:bLon}; 7696 } 7697 /* 2) FALLBACK: geocoding via Nominatim. Query strutturata per 7698 * massimizzare precisione: street + city + Italia + viewbox Italia. */ 7699 var street=(s.indirizzo||"").trim(); 7700 var city=(s.citta||"").trim(); 7701 if(!street&&!city)return null; 7702 /* Nominatim "structured" search ha più precisione del free-text 7703 * quando abbiamo street+city separati. */ 7704 var url="https://nominatim.openstreetmap.org/search?format=json&limit=1&countrycodes=it&addressdetails=0"; 7705 if(street)url+="&street="+encodeURIComponent(street); 7706 if(city)url+="&city="+encodeURIComponent(city); 7707 /* Bound dell'Italia (lon_min,lat_min,lon_max,lat_max): scarta risultati esteri */ 7708 url+="&viewbox=6.6,47.1,18.5,35.5&bounded=1"; 7709 try{
7710 /* Timeout 5s: leggermente più alto della versione precedente perché 7711 * la structured search può essere un filo più lenta. */ 7712 var ctrl=new AbortController();var tid=setTimeout(function(){ctrl.abort();},5000); 7713 var r=await fetch(url,{signal:ctrl.signal,headers:{"Accept":"application/json"}}); 7714 clearTimeout(tid); 7715 if(!r.ok)return null; 7716 var j=await r.json(); 7717 if(j&&j[0]&&j[0].lat&&j[0].lon){ 7718 var lat=+j[0].lat,lon=+j[0].lon; 7719 /* Sanity check: dentro l'Italia */ 7720 if(lat>=35&&lat<=48&&lon>=6&&lon<=19){ 7721 s._lat=lat;s._lon=lon;return{lat:lat,lon:lon}; 7722 } 7723 } 7724 /* 3) FALLBACK al free-text (q=) se structured fallisce. Capita per 7725 * indirizzi non standardizzati o numeri civici mancanti. */ 7726 var q=[street,city,"Italia"].filter(Boolean).join(", "); 7727 if(!q)return null; 7728 var ctrl2=new AbortController();var tid2=setTimeout(function(){ctrl2.abort();},5000); 7729 var r2=await fetch("https://nominatim.openstreetmap.org/search?format=json&limit=1&countrycodes=it&q="+encodeURIComponent(q),{signal:ctrl2.signal,headers:{"Accept":"application/json"}}); 7730 clearTimeout(tid2); 7731 if(!r2.ok)return null; 7732 var j2=await r2.json(); 7733 if(j2&&j2[0]&&j2[0].lat&&j2[0].lon){ 7734 var lat2=+j2[0].lat,lon2=+j2[0].lon; 7735 if(lat2>=35&&lat2<=48&&lon2>=6&&lon2<=19){ 7736 s._lat=lat2;s._lon=lon2;return{lat:lat2,lon:lon2}; 7737 } 7738 } 7739 }catch(e){} 7740 return null; 7741 }; 7742 CafChat.prototype._bkFindNearestSede = async function(renderFn){ 7743 var self=this; 7744 /* DEMOPROMO: nessun GPS/geocoding â assegna distanze fittizie crescenti e 7745 * marca la prima sede come la più vicina. */ 7746 if(self.opts&&self.opts.demopromo){ 7747 self._bkNearestStatus={type:"loading",msg:"Ricerca sede più vicina (demo)..."};renderFn&&renderFn(); 7748 setTimeout(function(){ 7749 var arr=self.bkSedi||[];var s=arr[0]||null; 7750 if(!s){self._bkNearestStatus={type:"err",msg:"Nessuna sede disponibile."};renderFn&&renderFn();return;} 7751 for(var i=0;i<arr.length;i++){arr[i]._distKm=+(0.8+i*1.7).toFixed(1);} 7752 self._bkNearestCode=s.codice;self._bkUserCoords={lat:45.49,lon:12.24}; 7753 self._bkNearestStatus={type:"ok",msg:"Sede più vicina (demo): "+s.descrizione+" · "+s._distKm+" km"}; 7754 renderFn&&renderFn(); 7755 },700); 7756 return; 7757 } 7758 if(window.isSecureContext===false){self._bkNearestStatus={type:"err",msg:"La geolocalizzazione richiede una connessione sicura (HTTPS)."};renderFn&&renderFn();return;} 7759 /* snapshot bkSedi reference to avoid race conditions if user navigates mid-search */ 7760 var sedeSnapshot=self.bkSedi; 7761 self._bkNearestStatus={type:"loading",msg:"Richiesta posizione precisa in corso..."};renderFn&&renderFn(); 7762 /* GPS HIGH-PRECISION OBBLIGATORIO per il calcolo distanza: 7763 * il vecchio low-accuracy + cache 10min + fallback IP-geo davano 7764 * coords city-level che facevano scegliere "una sede a caso" come la 7765 * più vicina (errore tipico fino a 15 km in città ). Ora pretendiamo 7766 * GPS fresco ad alta precisione e segnaliamo l'accuratezza all'utente. */ 7767 var userCoords=null; 7768 try{ 7769 userCoords=await self._bkGetGeoPositionPrecise(); 7770 }catch(e){ 7771 if(e&&e.code===1){ 7772 self._bkNearestStatus={type:"err",msg:"Permesso di geolocalizzazione negato. Abilitalo nelle impostazioni del browser per trovare la sede più vicina."}; 7773 renderFn&&renderFn();return; 7774 } 7775 var emsg="Posizione GPS non disponibile"; 7776 if(e&&e.code===2)emsg="Posizione non disponibile (segnale GPS/Wi-Fi assente)"; 7777 else if(e&&e.code===3)emsg="Richiesta posizione scaduta"; 7778 /* Se il primo tentativo è già scaduto sul watchdog (code 3 = prompt 7779 * ignorato / callback GPS mai chiamati), un SECONDO GPS completo 7780 * sbatterebbe sullo stesso muro RADDOPPIANDO l'attesa (era il famigerato 7781 * caso ~44s: due watchdog impilati). Errore esplicito subito. Il retry 7782 * low-acc ha senso solo per code 2 (posizione momentaneamente non 7783 * disponibile), dove una stima cache/low-accuracy può comunque riuscire. */ 7784 if(e&&e.code===3){ 7785 self._bkNearestStatus={type:"err",msg:emsg+". Riprova all'aperto o scegli la sede manualmente."}; 7786 renderFn&&renderFn();return; 7787 } 7788 /* Fallback al GPS standard (low-acc + cache) SE high-precision fallisce.
7789 * Avvisiamo che la precisione potrebbe essere ridotta. */ 7790 try{ 7791 self._bkNearestStatus={type:"loading",msg:emsg+". Provo con posizione approssimativaâ¦"};renderFn&&renderFn(); 7792 userCoords=await self._bkGetGeoPosition(); 7793 }catch(_e2){ 7794 /* Niente fallback IP-geo: con city-level accuracy, il "nearest" 7795 * sarebbe quasi sempre sbagliato e l'utente sceglierebbe una sede 7796 * a caso pensando sia la più vicina. Meglio errore esplicito. */ 7797 self._bkNearestStatus={type:"err",msg:emsg+". Riprova all'aperto o scegli la sede manualmente."}; 7798 renderFn&&renderFn();return; 7799 } 7800 } 7801 if(self.bkSedi!==sedeSnapshot||self.bkStep!=="selectSede")return; 7802 /* Salviamo le coords sull'istanza per usi futuri (background sort, ecc.) */ 7803 self._bkUserCoords={lat:userCoords.lat,lon:userCoords.lon}; 7804 /* Warning se l'accuracy è troppo bassa (>5km): probabile IP-geo o 7805 * Wi-Fi positioning impreciso. Ranking inaffidabile in città . */ 7806 var accKm=(userCoords.accuracy&&isFinite(userCoords.accuracy))?(userCoords.accuracy/1000):0; 7807 if(accKm>5){ 7808 self._bkLogEvent&&self._bkLogEvent("nearest_low_accuracy",{accuracy_km:accKm.toFixed(2)}); 7809 } 7810 /* Esegue il resto della pipeline (geocoding sedi + sort) come prima. */ 7811 (async function(){
7812 try{ 7813 var uLat=userCoords.lat,uLon=userCoords.lon; 7814 /* PRECISIONE > VELOCITÃ: cap altissimo (200) per coprire qualunque 7815 * regione italiana (Toscana ha 70+ sedi, Lombardia 80+). L'early-stop 7816 * conservativo (â¥15 geocoded E nearest<10km) garantisce comunque 7817 * tempi rapidi in città . Le sedi con coordinate backend (s.lat/lon) 7818 * sono istantanee â ordinarle prima accelera ulteriormente. */ 7819 var MAX_SEDI=200; 7820 var sortedForGeo=sedeSnapshot.slice(0,MAX_SEDI).slice().sort(function(a,b){ 7821 var ac=(a._lat!==undefined&&a._lon!==undefined)||(typeof a.lat==="number"&&typeof a.lon==="number")?0:1; 7822 var bc=(b._lat!==undefined&&b._lon!==undefined)||(typeof b.lat==="number"&&typeof b.lon==="number")?0:1; 7823 return ac-bc; 7824 }); 7825 var total=sortedForGeo.length; 7826 self._bkNearestStatus={type:"loading",msg:"Ricerca sede più vicina... (0/"+total+")"};renderFn&&renderFn(); 7827 var coords=[]; 7828 var batchSize=4; 7829 /* DEADLINE globale: con molte sedi senza coords backend il geocoding 7830 * Nominatim (2 fetch/sede, batch) poteva durare MINUTI. Cap a 15s: 7831 * usiamo i risultati raccolti finora (l'utente può comunque scegliere 7832 * manualmente o l'esito parziale è già utile). */ 7833 var nearestDeadline=Date.now()+15000; 7834 /* EARLY STOP CONDIZIONALE: ferma SOLO se abbiamo geocoded almeno 7835 * 15 sedi E la più vicina è < 10km (confidenza alta che le altre 7836 * non saranno più vicine). Altrimenti continua fino al 100%. */ 7837 var EARLY_MIN=15; 7838 function nearestKmSoFar(){ 7839 if(!coords.length)return Infinity; 7840 var min=Infinity; 7841 for(var z=0;z<coords.length;z++){ 7842 var d=self._bkHaversineKm(uLat,uLon,coords[z].lat,coords[z].lon); 7843 if(d<min)min=d; 7844 } 7845 return min; 7846 } 7847 for(var i=0;i<sortedForGeo.length;i+=batchSize){ 7848 /* bail out if user navigated away (bkSedi was replaced or step changed) */ 7849 if(self.bkSedi!==sedeSnapshot||self.bkStep!=="selectSede")return; 7850 var batch=sortedForGeo.slice(i,i+batchSize); 7851 var res=await Promise.all(batch.map(function(s){return self._bkGeocodeSede(s);})); 7852 for(var k=0;k<res.length;k++){if(res[k])coords.push({sede:batch[k],lat:res[k].lat,lon:res[k].lon});} 7853 /* Aggiorna status con progresso */ 7854 if(self.bkSedi===sedeSnapshot&&self.bkStep==="selectSede"){ 7855 self._bkNearestStatus={type:"loading",msg:"Ricerca sede più vicina... ("+Math.min(i+batchSize,total)+"/"+total+")"}; 7856 renderFn&&renderFn(); 7857 } 7858 /* Early stop conservativo */ 7859 if(coords.length>=EARLY_MIN&&nearestKmSoFar()<10)break; 7860 /* Cap temporale: oltre i 15s ci fermiamo con quanto raccolto. */ 7861 if(Date.now()>nearestDeadline){self._bkLogEvent&&self._bkLogEvent("nearest_deadline_hit",{geocoded:coords.length,of:total});break;} 7862 if(i+batchSize<sortedForGeo.length)await new Promise(function(r){setTimeout(r,200);}); 7863 } 7864 if(self.bkSedi!==sedeSnapshot||self.bkStep!=="selectSede")return; 7865 if(!coords.length){self._bkNearestStatus={type:"err",msg:"Non riesco a localizzare le sedi. Puoi sceglierne una manualmente."};renderFn&&renderFn();return;} 7866 coords.forEach(function(c){c.dist=self._bkHaversineKm(uLat,uLon,c.lat,c.lon);c.sede._distKm=c.dist;}); 7867 coords.sort(function(a,b){return a.dist-b.dist;}); 7868 /* sort sedi by distance (sedi without coords at the end) */ 7869 self.bkSedi.sort(function(a,b){ 7870 var da=a._distKm===undefined?Infinity:a._distKm; 7871 var db=b._distKm===undefined?Infinity:b._distKm; 7872 return da-db; 7873 }); 7874 self._bkNearestCode=coords[0].sede.codice||coords[0].sede.descrizione; 7875 var km=coords[0].dist; 7876 var kmStr=km<1?(Math.round(km*1000)+" m"):(km.toFixed(1)+" km"); 7877 /* Hint accuratezza: se la posizione utente era imprecisa (>5km), 7878 * avvisiamo che il ranking potrebbe non essere il più affidabile. */ 7879 var accHint=""; 7880 if(accKm>5)accHint=" â posizione approssimativa, verifica"; 7881 self._bkNearestStatus={type:"ok",msg:"Sede più vicina trovata: "+coords[0].sede.descrizione+" (a "+kmStr+" da te"+accHint+")"}; 7882 /* trigger the celebration animation on render */ 7883 self._bkNearestJustFound=true; 7884 renderFn&&renderFn(); 7885 /* spawn sparkles on the nearest tile AFTER render */ 7886 requestAnimationFrame(function(){ 7887 requestAnimationFrame(function(){ 7888 var tile=self._bkFsEl?self._bkFsEl.querySelector(".caf-bk-fs-tile.nearest-reveal"):null; 7889 if(tile)self._bkSpawnSparkles(tile,14); 7890 self._bkNearestJustFound=false; 7891 }); 7892 }); 7893 }catch(e){ 7894 self._bkNearestStatus={type:"err",msg:"Errore durante la ricerca. Riprova."}; 7895 renderFn&&renderFn(); 7896 } 7897 })(); 7898 }; 7899 7900 CafChat.prototype._bkFetchOnce = async function(action,body,opts){ 7901 var self=this;opts=opts||{}; 7902 var r; 7903 /* timeout configurabile (default 15s come plannerApi.ts; i fetch di catalogo 7904 * passano un valore più corto). `opts.signal` = cancellazione esterna 7905 * (bottone "Annulla"): la colleghiamo al nostro AbortController. */ 7906 var to=opts.timeoutMs||15000;
7907 var ctrl=new AbortController();var tid=setTimeout(function(){ctrl.abort();},to); 7908 var ext=opts.signal,onExt=function(){try{ctrl.abort();}catch(_e){}}; 7909 if(ext){if(ext.aborted)ctrl.abort();else ext.addEventListener("abort",onExt);} 7910 var cleanup=function(){clearTimeout(tid);if(ext)ext.removeEventListener("abort",onExt);}; 7911 try{r=await fetch(this.opts.baseUrl+"/api/planner/"+action,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body||{}),signal:ctrl.signal});} 7912 catch(netErr){cleanup(); 7913 if(netErr instanceof DOMException&&netErr.name==="AbortError"){ 7914 /* Annullamento utente: NON è transient â niente retry, niente popup. */ 7915 if(ext&&ext.aborted){var ce={error:"Operazione annullata.",code:"cancelled"};self._bkLastErr=null;throw ce;} 7916 var te={error:"Il server non risponde. Riprova tra qualche istante.",code:"timeout",plannerMessage:"AbortError timeout after "+to+"ms on "+action};self._bkLastErr=te;throw te;} 7917 var ne={error:"Impossibile raggiungere il server. Verifica la connessione.",code:"network",plannerMessage:(netErr&&netErr.message)?netErr.message:String(netErr)};self._bkLastErr=ne;throw ne;} 7918 finally{cleanup();} 7919 if(!r.ok){var e={};try{e=await r.json();}catch(x){e={error:"Errore di comunicazione con il server.",code:"unknown"};} 7920 if(!e.plannerMessage)e.plannerMessage="HTTP "+r.status+" on /api/planner/"+action; 7921 /* tag 5xx come transient per retry */ 7922 if(r.status>=500&&r.status<600&&!e.code)e.code=String(r.status); 7923 self._bkLastErr=e;throw e;} 7924 var json;try{json=await r.json();}catch(parseErr){var pe={error:"Risposta non valida dal server.",code:"server",plannerMessage:"JSON parse error on /api/planner/"+action};self._bkLastErr=pe;throw pe;} 7925 var data=json&&json.data; 7926 self._bkLastErr=null; 7927 return data||[]; 7928 }; 7929 7930 /* Wrapper con retry esponenziale (#58): re-tenta solo errori transient 7931 * (timeout/network/5xx). Mostra toast non bloccante al 2° tentativo 7932 * così l'utente sa che stiamo riprovando in background. */ 7933 CafChat.prototype._bkFetch = async function(action,body,opts){ 7934 var self=this;opts=opts||{}; 7935 /* DEMOPROMO: dirotta OGNI chiamata dati (regioni/province/servizi/sedi/ 7936 * spazi/prenota) su dati di esempio integrati. Nessuna richiesta esce mai 7937 * verso /api/planner. Unico choke-point: tutti i fetch del wizard passano 7938 * di qui. Flag spento â ramo standard sottostante, invariato. */ 7939 if(self.opts&&self.opts.demopromo)return self._bkDemoFetch(action,body); 7940 /* AbortController condiviso da TUTTA la catena retry: il bottone "Annulla" 7941 * (_bkCancelLoading) lo abortisce e ferma sia l'attesa sia i retry. 7942 * I fetch background (es. prefetch slot) passano opts.background=true e NON 7943 * registrano _bkActiveCtrl, per non far puntare l'Annulla a una fetch 7944 * non-bloccante. */
7945 var ctrl=new AbortController(); 7946 if(!opts.background)self._bkActiveCtrl=ctrl; 7947 var once=function(attempt){ 7948 if(attempt>0&&self._bkShowToast){ 7949 self._bkShowToast({type:"warn",title:bkT("toast_slow_title"),msg:bkT("toast_slow_msg",attempt+1)}); 7950 } 7951 return self._bkFetchOnce(action,body,{timeoutMs:opts.timeoutMs,signal:ctrl.signal}); 7952 }; 7953 try{ 7954 /* opts.noRetry: azioni NON idempotenti (es. "prenota") â un solo tentativo. */ 7955 if(opts.noRetry)return await once(0); 7956 return await bkRetryWithBackoff(once,{maxAttempts:3,baseMs:500,maxMs:2500}); 7957 }finally{ if(!opts.background&&self._bkActiveCtrl===ctrl)self._bkActiveCtrl=null; } 7958 }; 7959 7960 /* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 7961 * DEMOPROMO â sorgente dati di ESEMPIO (Veneto). Attiva SOLO con 7962 * window.__CAF_DEMOPROMO__ === true (env DEMOPROMO=si). Sostituisce le 7963 * risposte di /api/planner/{regioni,province,servizi,sedi,spazi,prenota} 7964 * con dati finti ma plausibili, nelle STESSE shape del backend reale, così 7965 * il resto del wizard li tratta in modo identico. Nessuna chiamata di rete. 7966 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 7967 /* DEMOPROMO: il brand delle sedi/dati di esempio NON deve essere fisso 7968 * "CAF CISL": deve riportare il nome del chatbot (NEXT_PUBLIC_CHATBOT_NAME 7969 * -> window.__CAF_CHATBOT_NAME__), iniettato dalla route prima di questo 7970 * script. Fallback al default storico solo se assente. */ 7971 var DEMO_BRAND = (typeof window !== "undefined" && window.__CAF_CHATBOT_NAME__) || "CAF CISL Veneto"; 7972 /* Profilo del dataset demo. "cafacli" => sedi/regioni NAZIONALI CAF ACLI; 7973 * qualsiasi altro valore => dataset storico Veneto (CAF CISL). Selezione 7974 * esplicita via env DEMO_PROFILE (iniettata come window.__CAF_DEMO_PROFILE__), 7975 * con fallback inferito dal brand: un nome che contiene "ACLI" attiva il 7976 * profilo cafacli senza bisogno della env. */ 7977 var DEMO_PROFILE = ((typeof window !== "undefined" && window.__CAF_DEMO_PROFILE__) || "").toString().trim().toLowerCase(); 7978 if(!DEMO_PROFILE && /acli/i.test(DEMO_BRAND)) DEMO_PROFILE = "cafacli"; 7979 7980 function demoPad2(n){return n<10?"0"+n:""+n;} 7981 function demoSlug(s){ 7982 return String(s||"").toLowerCase() 7983 .replace(/[à áâ]/g,"a").replace(/[èéê]/g,"e") 7984 .replace(/[ìÃî]/g,"i").replace(/[òóô]/g,"o") 7985 .replace(/[ùúû]/g,"u") 7986 .replace(/[^a-z0-9]+/g,".").replace(/^\.+/,"").replace(/\.+$/,""); 7987 } 7988 /* slot di esempio condivisi: prossimi ~8 giorni feriali, mattina+pomeriggio, 7989 * nelle shape compatte del Planner reale (data "YYYYMMDD", orari "HHMM"). */ 7990 function demoSpazi(){ 7991 var slots=[];var d=new Date();var businessDays=0;var guard=0; 7992 var times=[["0900","0930"],["0930","1000"],["1000","1030"],["1100","1130"],["1130","1200"],["1430","1500"],["1500","1530"],["1600","1630"]]; 7993 while(businessDays<8&&guard<20){ 7994 guard++;d.setDate(d.getDate()+1); 7995 var dow=d.getDay();if(dow===0||dow===6)continue; 7996 businessDays++; 7997 var ymd=""+d.getFullYear()+demoPad2(d.getMonth()+1)+demoPad2(d.getDate()); 7998 for(var t=0;t<times.length;t++){ 7999 slots.push({id_spazio:"DEMO-"+ymd+"-"+times[t][0],data:ymd,ora_inizio:times[t][0],ora_fine:times[t][1],sportello_id:"1"}); 8000 } 8001 } 8002 return slots; 8003 } 8004 8005 /* ââ Dataset VENETO (storico, CAF CISL) â invariato âââââââââââ */ 8006 var VEN_REGIONI=[{codice:"VEN",descrizione:"Veneto",connessione:"demo",codiceRegione:"05"}]; 8007 var VEN_PROVINCE=[ 8008 {codice:"VE",descrizione:"Venezia"}, 8009 {codice:"PD",descrizione:"Padova"}, 8010 {codice:"TV",descrizione:"Treviso"}, 8011 {codice:"VR",descrizione:"Verona"}, 8012 {codice:"VI",descrizione:"Vicenza"}, 8013 {codice:"RO",descrizione:"Rovigo"},
8014 {codice:"BL",descrizione:"Belluno"} 8015 ]; 8016 var VEN_SERVIZI=[ 8017 {codice:"730",descrizione:"Dichiarazione dei redditi â Modello 730/2026",desonline:"Modello 730/2026",tempo:"30",documenti:"CU, spese detraibili, eventuali deleghe",online:true,codtipologia:"FISCO",destipologia:"Servizi fiscali"}, 8018 {codice:"ISEE",descrizione:"ISEE / DSU 2026",desonline:"ISEE 2026",tempo:"30",documenti:"Documenti redditi e patrimonio nucleo familiare",online:true,codtipologia:"FISCO",destipologia:"Servizi fiscali"}, 8019 {codice:"IMU",descrizione:"Calcolo e versamento IMU",desonline:"IMU",tempo:"20",documenti:"Visure catastali immobili",online:true,codtipologia:"FISCO",destipologia:"Servizi fiscali"}, 8020 {codice:"SUCC",descrizione:"Successioni",desonline:"Successioni",tempo:"45",documenti:"Atto di morte, visure, documenti eredi",online:true,codtipologia:"PATR",destipologia:"Patrimonio"}, 8021 {codice:"COLF",descrizione:"Contratti Colf e Badanti",desonline:"Colf e Badanti",tempo:"30",documenti:"Documenti datore e lavoratore",online:true,codtipologia:"LAV",destipologia:"Lavoro domestico"}, 8022 {codice:"RED",descrizione:"Modello RED / dichiarazioni reddituali",desonline:"Modello RED",tempo:"20",documenti:"Lettera INPS, documenti reddituali",online:true,codtipologia:"PREV",destipologia:"Previdenza"} 8023 ]; 8024 var VEN_SEDI_BY_PROV={ 8025 VE:[ 8026 {codice:"SEDE-VE-01",descrizione:DEMO_BRAND+" Venezia â Mestre",indirizzo:"Via CÃ Marcello 10",cap:"30172",prov:"VE",citta:"Venezia",telefono:"041 000 0000",email:"[email protected]"}, 8027 {codice:"SEDE-VE-02",descrizione:DEMO_BRAND+" Venezia â Marghera",indirizzo:"Piazza Mercato 24",cap:"30175",prov:"VE",citta:"Venezia",telefono:"041 000 0001",email:"[email protected]"} 8028 ], 8029 PD:[ 8030 {codice:"SEDE-PD-01",descrizione:DEMO_BRAND+" Padova â Centro",indirizzo:"Via Longhin 2",cap:"35129",prov:"PD",citta:"Padova",telefono:"049 000 0000",email:"[email protected]"} 8031 ], 8032 TV:[ 8033 {codice:"SEDE-TV-01",descrizione:DEMO_BRAND+" Treviso",indirizzo:"Via Dandolo 4",cap:"31100",prov:"TV",citta:"Treviso",telefono:"0422 000 000",email:"[email protected]"} 8034 ] 8035 }; 8036 function venProvinceFor(body){return VEN_PROVINCE.slice();} 8037 function venSediFor(body){ 8038 var code=(body&&body.codiceProvincia)||"VE"; 8039 if(VEN_SEDI_BY_PROV[code])return VEN_SEDI_BY_PROV[code].slice(); 8040 var name=code;for(var i=0;i<VEN_PROVINCE.length;i++){if(VEN_PROVINCE[i].codice===code){name=VEN_PROVINCE[i].descrizione;break;}} 8041 return [{codice:"SEDE-"+code+"-01",descrizione:DEMO_BRAND+" "+name,indirizzo:"Via Roma 1",cap:"",prov:code,citta:name,telefono:"000 000 0000",email:"demo."+code.toLowerCase()+"@cafcislveneto.example"}]; 8042 } 8043 8044 /* ââ Dataset NAZIONALE CAF ACLI âââââââââââââââââââââââââââââââ */ 8045 var ACLI_EMAIL_DOMAIN="cafacli.example"; 8046 var ACLI_SERVIZI=[
8047 {codice:"730",descrizione:"Dichiarazione dei redditi â Modello 730/2026",desonline:"Modello 730/2026",tempo:"30",documenti:"CU, spese detraibili, eventuali deleghe",online:true,codtipologia:"FISCO",destipologia:"Servizi fiscali"}, 8048 {codice:"ISEE",descrizione:"ISEE / DSU 2026",desonline:"ISEE 2026",tempo:"30",documenti:"Documenti reddituali e patrimoniali del nucleo familiare",online:true,codtipologia:"FISCO",destipologia:"Servizi fiscali"}, 8049 {codice:"IMU",descrizione:"Calcolo e versamento IMU",desonline:"IMU",tempo:"20",documenti:"Visure catastali degli immobili",online:true,codtipologia:"FISCO",destipologia:"Servizi fiscali"}, 8050 {codice:"RED",descrizione:"Modello RED / dichiarazioni reddituali INPS",desonline:"Modello RED",tempo:"20",documenti:"Lettera INPS, documenti reddituali",online:true,codtipologia:"PREV",destipologia:"Previdenza"}, 8051 {codice:"SUCC",descrizione:"Dichiarazione di successione",desonline:"Successioni",tempo:"45",documenti:"Atto di morte, visure catastali, documenti degli eredi",online:true,codtipologia:"PATR",destipologia:"Patrimonio"}, 8052 {codice:"COLF",descrizione:"Contratti Colf, Badanti e lavoro domestico",desonline:"Colf e Badanti",tempo:"30",documenti:"Documenti del datore e del lavoratore",online:true,codtipologia:"LAV",destipologia:"Lavoro domestico"}, 8053 {codice:"LOC",descrizione:"Contratti di locazione e cedolare secca",desonline:"Locazioni",tempo:"30",documenti:"Contratto, dati catastali, documenti delle parti",online:true,codtipologia:"FISCO",destipologia:"Servizi fiscali"}, 8054 {codice:"AUU",descrizione:"Assegno Unico Universale e bonus sociali",desonline:"Assegno Unico",tempo:"20",documenti:"ISEE in corso di validità , IBAN",online:true,codtipologia:"PREV",destipologia:"Previdenza"} 8055 ]; 8056 var ACLI_SPEC=[ 8057 {reg:"Lombardia",cod:"LOM",prov:[ 8058 {n:"Milano",s:"MI",sedi:[ 8059 {label:"Milano Centro",via:"Via della Signora 3",cap:"20122",tel:"02 7700 1001"}, 8060 {label:"Milano Niguarda",via:"Viale Suzzani 15",cap:"20162",tel:"02 7700 1002"}]}, 8061 {n:"Monza e della Brianza",s:"MB",sedi:[{label:"Monza",via:"Via Vittorio Emanuele II 2",cap:"20900",tel:"039 200 2001"}]}, 8062 {n:"Bergamo",s:"BG",sedi:[{label:"Bergamo",via:"Via Zelasco 1",cap:"24122",tel:"035 459 3001"}]}, 8063 {n:"Brescia",s:"BS",sedi:[{label:"Brescia",via:"Via Cefalonia 70",cap:"25124",tel:"030 370 4001"}]} 8064 ]}, 8065 {reg:"Lazio",cod:"LAZ",prov:[ 8066 {n:"Roma",s:"RM",sedi:[ 8067 {label:"Roma Eur",via:"Viale Marco Polo 18",cap:"00154",tel:"06 5840 1001"}, 8068 {label:"Roma Tuscolano",via:"Via Tuscolana 235",cap:"00181",tel:"06 5840 1002"}]}, 8069 {n:"Latina",s:"LT",sedi:[{label:"Latina",via:"Via Emanuele Filiberto 12",cap:"04100",tel:"0773 690 001"}]}, 8070 {n:"Frosinone",s:"FR",sedi:[{label:"Frosinone",via:"Via Aldo Moro 466",cap:"03100",tel:"0775 210 001"}]} 8071 ]}, 8072 {reg:"Piemonte",cod:"PIE",prov:[ 8073 {n:"Torino",s:"TO",sedi:[ 8074 {label:"Torino Centro",via:"Via Perrone 3 bis",cap:"10122",tel:"011 432 1001"}, 8075 {label:"Torino Mirafiori",via:"Corso Unione Sovietica 220",cap:"10134",tel:"011 432 1002"}]}, 8076 {n:"Cuneo",s:"CN",sedi:[{label:"Cuneo",via:"Corso Giolitti 21",cap:"12100",tel:"0171 290 001"}]}, 8077 {n:"Novara",s:"NO",sedi:[{label:"Novara",via:"Via dei Caccia 7",cap:"28100",tel:"0321 390 001"}]} 8078 ]}, 8079 {reg:"Veneto",cod:"VNT",prov:[ 8080 {n:"Venezia",s:"VE",sedi:[{label:"Venezia Mestre",via:"Via Cà Marcello 10",cap:"30172",tel:"041 538 1001"}]}, 8081 {n:"Padova",s:"PD",sedi:[{label:"Padova",via:"Via Tommaseo 60",cap:"35131",tel:"049 870 2001"}]}, 8082 {n:"Verona",s:"VR",sedi:[{label:"Verona",via:"Via Città di Nimes 6",cap:"37123",tel:"045 800 3001"}]}, 8083 {n:"Treviso",s:"TV",sedi:[{label:"Treviso",via:"Via Dandolo 4",cap:"31100",tel:"0422 540 001"}]} 8084 ]}, 8085 {reg:"Emilia-Romagna",cod:"EMR",prov:[ 8086 {n:"Bologna",s:"BO",sedi:[{label:"Bologna",via:"Via Marconi 69",cap:"40122",tel:"051 600 1001"}]}, 8087 {n:"Modena",s:"MO",sedi:[{label:"Modena",via:"Viale Gramsci 75",cap:"41122",tel:"059 350 2001"}]}, 8088 {n:"Parma",s:"PR",sedi:[{label:"Parma",via:"Via Verdi 35",cap:"43121",tel:"0521 270 001"}]}, 8089 {n:"Reggio nell'Emilia",s:"RE",sedi:[{label:"Reggio Emilia",via:"Via Roma 18",cap:"42121",tel:"0522 430 001"}]} 8090 ]}, 8091 {reg:"Toscana",cod:"TOS",prov:[ 8092 {n:"Firenze",s:"FI",sedi:[{label:"Firenze",via:"Via de' Martelli 8",cap:"50129",tel:"055 270 1001"}]}, 8093 {n:"Pisa",s:"PI",sedi:[{label:"Pisa",via:"Via San Lorenzo 4",cap:"56127",tel:"050 500 001"}]}, 8094 {n:"Livorno",s:"LI",sedi:[{label:"Livorno",via:"Via Grande 92",cap:"57123",tel:"0586 880 001"}]} 8095 ]}, 8096 {reg:"Campania",cod:"CAM",prov:[ 8097 {n:"Napoli",s:"NA",sedi:[ 8098 {label:"Napoli Centro",via:"Via Toledo 156",cap:"80132",tel:"081 551 1001"}, 8099 {label:"Napoli Vomero",via:"Via Luca Giordano 60",cap:"80127",tel:"081 551 1002"}]}, 8100 {n:"Salerno",s:"SA",sedi:[{label:"Salerno",via:"Corso Vittorio Emanuele 58",cap:"84122",tel:"089 250 001"}]}, 8101 {n:"Caserta",s:"CE",sedi:[{label:"Caserta",via:"Via Roma 102",cap:"81100",tel:"0823 320 001"}]} 8102 ]}, 8103 {reg:"Puglia",cod:"PUG",prov:[ 8104 {n:"Bari",s:"BA",sedi:[{label:"Bari",via:"Via Napoli 5",cap:"70123",tel:"080 521 1001"}]}, 8105 {n:"Lecce",s:"LE",sedi:[{label:"Lecce",via:"Viale Marconi 12",cap:"73100",tel:"0832 240 001"}]}, 8106 {n:"Taranto",s:"TA",sedi:[{label:"Taranto",via:"Via Dante 45",cap:"74121",tel:"099 470 001"}]} 8107 ]} 8108 ];
8109 var ACLI_REGIONI=[]; 8110 var ACLI_PROV_BY_REG={}; 8111 var ACLI_SEDI_BY_PROV={}; 8112 (function buildAcliDemo(){ 8113 for(var i=0;i<ACLI_SPEC.length;i++){ 8114 var R=ACLI_SPEC[i]; 8115 var conn="acli-"+R.cod.toLowerCase(); 8116 ACLI_REGIONI.push({codice:R.cod,descrizione:R.reg,connessione:conn,codiceRegione:R.cod}); 8117 ACLI_PROV_BY_REG[R.cod]=[]; 8118 for(var j=0;j<R.prov.length;j++){ 8119 var P=R.prov[j]; 8120 ACLI_PROV_BY_REG[R.cod].push({codice:P.s,descrizione:P.n}); 8121 var list=[]; 8122 for(var k=0;k<P.sedi.length;k++){ 8123 var S=P.sedi[k]; 8124 var lbl=S.label||P.n; 8125 list.push({ 8126 codice:"SEDE-"+P.s+"-"+demoPad2(k+1), 8127 descrizione:DEMO_BRAND+" "+lbl, 8128 indirizzo:S.via, 8129 cap:S.cap, 8130 prov:P.s, 8131 citta:P.n, 8132 telefono:S.tel, 8133 email:"appuntamenti."+demoSlug(lbl)+"@"+ACLI_EMAIL_DOMAIN 8134 }); 8135 } 8136 ACLI_SEDI_BY_PROV[P.s]=list; 8137 } 8138 } 8139 })(); 8140 function acliProvinceFor(body){ 8141 var rc=(body&&body.codiceRegione)||""; 8142 if(rc&&ACLI_PROV_BY_REG[rc])return ACLI_PROV_BY_REG[rc].slice(); 8143 var all=[];for(var k in ACLI_PROV_BY_REG){if(ACLI_PROV_BY_REG.hasOwnProperty(k))all=all.concat(ACLI_PROV_BY_REG[k]);}return all; 8144 } 8145 function acliSediFor(body){ 8146 var pc=(body&&body.codiceProvincia)||""; 8147 if(pc&&ACLI_SEDI_BY_PROV[pc])return ACLI_SEDI_BY_PROV[pc].slice(); 8148 return [{codice:"SEDE-"+pc+"-01",descrizione:DEMO_BRAND+(pc?(" "+pc):""),indirizzo:"Via Roma 1",cap:"",prov:pc,citta:pc,telefono:"800 000 000",email:"appuntamenti@"+ACLI_EMAIL_DOMAIN}]; 8149 } 8150 8151 /* dataset attivo in base al profilo selezionato */ 8152 var DEMO_REGIONI, DEMO_SERVIZI, _demoProvinceFor, _demoSediFor; 8153 if(DEMO_PROFILE==="cafacli"){ 8154 DEMO_REGIONI=ACLI_REGIONI; DEMO_SERVIZI=ACLI_SERVIZI; 8155 _demoProvinceFor=acliProvinceFor; _demoSediFor=acliSediFor; 8156 } else { 8157 DEMO_REGIONI=VEN_REGIONI; DEMO_SERVIZI=VEN_SERVIZI; 8158 _demoProvinceFor=venProvinceFor; _demoSediFor=venSediFor; 8159 } 8160 8161 CafChat.prototype._bkDemoData = function(action,body){ 8162 switch(action){ 8163 case "regioni": return DEMO_REGIONI.slice(); 8164 case "province": return _demoProvinceFor(body); 8165 case "servizi": return DEMO_SERVIZI.slice(); 8166 case "sedi": return _demoSediFor(body); 8167 case "spazi": return demoSpazi(); 8168 case "prenota": return {id:"DEMO-"+bkUuid().replace(/-/g,"").slice(0,8).toUpperCase()}; 8169 default: return []; 8170 } 8171 }; 8172 8173 /* Risolve con i dati demo dopo una breve latenza simulata, così gli stati di 8174 * caricamento (spinner, "cerco disponibilità â¦") vengono comunque mostrati. */ 8175 CafChat.prototype._bkDemoFetch = function(action,body){ 8176 var payload=this._bkDemoData(action,body); 8177 var delay=(action==="prenota")?800:180; 8178 return new Promise(function(resolve){setTimeout(function(){resolve(payload);},delay);}); 8179 }; 8180 8181 /** Annulla il fetch di step in corso: aborta la catena, sblocca la UI e 8182 * riporta l'utente allo step precedente (memorizzato in _bkLoadingReturnStep) 8183 * invece di lasciarlo su uno spinner fino a ~47s. */ 8184 CafChat.prototype._bkCancelLoading = function(){ 8185 try{if(this._bkActiveCtrl)this._bkActiveCtrl.abort();}catch(_e){} 8186 /* Invalida ogni operazione in volo (anche prefetch già consumati): le 8187 * await stantie nei _bkPick* controllano _bkOpGen e abbandonano senza 8188 * saltare in avanti dopo che l'utente ha annullato. */ 8189 this._bkOpGen=(this._bkOpGen||0)+1; 8190 this._bkActiveCtrl=null;this._bkBusy=false; 8191 this._bkAutoProvStatus=null;this._bkNearestStatus=null;this._bkResumeLoading=null; 8192 this.bkStep=this._bkLoadingReturnStep||"selectRegione"; 8193 this._bkLogEvent&&this._bkLogEvent("step_cancelled",{back:this.bkStep}); 8194 if(this._bkFsMode)this._bkRenderFs();else this._bkRender(); 8195 }; 8196 8197 /** Navigazione manuale (back / jump-to-step / go-to-step): invalida ogni 8198 * _bkPick* ancora in volo. Senza questo bump dell'op-gen, una await stantia 8199 * (es. il fetch "spazi" di una sede appena deselezionata) supera il guard 8200 * `if(this._bkOpGen!==_g)return` e riscrive bkSpazi/bkSelSede/bkStep DOPO che 8201 * la navigazione li ha resettati â uno slot/sede NON più selezionato poteva 8202 * finire nel payload prenota (e nel record Firestore). Stesso identico 8203 * contratto di _bkCancelLoading/_bkReset: bump op-gen + abort + sblocca busy. */
8204 CafChat.prototype._bkInvalidateInflight = function(){ 8205 this._bkOpGen=(this._bkOpGen||0)+1; 8206 try{if(this._bkActiveCtrl)this._bkActiveCtrl.abort();}catch(_e){} 8207 this._bkActiveCtrl=null;this._bkBusy=false; 8208 }; 8209 8210 /* Popup diagnostico Planner â iniettato in document.body (fuori shadow DOM) */ 8211 CafChat.prototype._showPlannerPopup = function(diag){ 8212 var self=this; 8213 try{ 8214 /* Pulisci sia istanza legacy in body sia nuova istanza nel panel */ 8215 var prev=document.getElementById("caf-planner-error-popup"); 8216 if(prev&&prev.parentNode)prev.parentNode.removeChild(prev); 8217 if(self.panelEl){ 8218 var prevP=self.panelEl.querySelector("#caf-planner-error-popup"); 8219 if(prevP&&prevP.parentNode)prevP.parentNode.removeChild(prevP); 8220 } 8221 }catch(x){} 8222 var code=(diag&&diag.code?String(diag.code):"UNKNOWN").toUpperCase(); 8223 var userMsg=(diag&&diag.userMessage)||(diag&&diag.error)||"Motivo non disponibile."; 8224 var raw=(diag&&diag.plannerMessage)||""; 8225 var esc=function(s){return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");}; 8226 var overlay=document.createElement("div"); 8227 overlay.id="caf-planner-error-popup"; 8228 overlay.setAttribute("role","dialog"); 8229 overlay.setAttribute("aria-modal","true"); 8230 overlay.setAttribute("aria-labelledby","caf-planner-err-h"); 8231 /* Confinato al chat panel â non copre il sito host. z-index:100 per 8232 * stare sopra .caf-bk-fs (z-index:60) e tutti gli altri elementi interni. */ 8233 overlay.style.cssText="position:absolute;inset:0;z-index:100;background:rgba(0,0,0,0.55);display:flex;align-items:center;justify-content:center;padding:12px;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#0f172a;border-radius:inherit;"; 8234 overlay.innerHTML= 8235 '<div style="width:min(520px,100%);background:#fff;border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,0.35);padding:22px 22px 18px;">'+ 8236 '<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">'+ 8237 '<div style="width:36px;height:36px;border-radius:50%;background:#fee2e2;display:flex;align-items:center;justify-content:center;font-size:20px;">â ï¸</div>'+ 8238 '<h2 id="caf-planner-err-h" style="font-size:17px;font-weight:700;margin:0;color:#b91c1c;">Servizio prenotazione non disponibile</h2>'+ 8239 '</div>'+ 8240 '<div style="display:inline-block;background:#fef2f2;color:#b91c1c;border:1px solid #fecaca;border-radius:8px;padding:4px 10px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;font-weight:600;letter-spacing:0.02em;margin-bottom:12px;">CODICE: '+esc(code)+'</div>'+ 8241 '<p style="font-size:14px;line-height:1.5;margin:0 0 10px;color:#1e293b;">'+esc(userMsg)+'</p>'+ 8242 (raw?'<pre style="background:#f1f5f9;border:1px solid #e2e8f0;border-radius:8px;padding:10px 12px;font-size:12px;line-height:1.45;color:#334155;white-space:pre-wrap;word-break:break-word;max-height:180px;overflow:auto;margin:0 0 14px;">'+esc(raw)+'</pre>':'')+ 8243 '<div style="display:flex;justify-content:flex-end;gap:8px;">'+ 8244 '<button type="button" data-caf-copy style="border:1px solid #cbd5e1;background:#fff;color:#334155;padding:8px 14px;border-radius:8px;font-size:13px;cursor:pointer;">Copia</button>'+ 8245 '<button type="button" data-caf-close style="border:none;background:#b91c1c;color:#fff;padding:8px 16px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;">Chiudi</button>'+ 8246 '</div>'+ 8247 '</div>'; 8248 var _prevFocus=(self.shadow&&self.shadow.activeElement)||document.activeElement; 8249 var close=function(){
8250 try{overlay.parentNode.removeChild(overlay);}catch(x){} 8251 /* WCAG 2.4.3: il focus torna dov'era prima dell'apertura del popup */ 8252 try{if(_prevFocus&&_prevFocus.isConnected&&_prevFocus.focus)_prevFocus.focus();}catch(_e){} 8253 }; 8254 /* WCAG 2.1.2: Escape chiude il popup (solo il popup, non il pannello) */ 8255 overlay.addEventListener("keydown",function(ev){if(ev.key==="Escape"||ev.key==="Esc"){ev.stopPropagation();close();}}); 8256 overlay.addEventListener("click",function(ev){if(ev.target===overlay)close();}); 8257 overlay.querySelector("[data-caf-close]").addEventListener("click",close); 8258 overlay.querySelector("[data-caf-copy]").addEventListener("click",function(){ 8259 var txt="["+code+"] "+userMsg+(raw?"\n"+raw:""); 8260 try{if(navigator.clipboard&&navigator.clipboard.writeText)navigator.clipboard.writeText(txt);}catch(x){} 8261 }); 8262 overlay.querySelector("div").addEventListener("click",function(ev){ev.stopPropagation();}); 8263 /* Append nel chat panel â non in body â così l'overlay sta dentro il widget */ 8264 if(self.panelEl)self.panelEl.appendChild(overlay); 8265 else document.body.appendChild(overlay); 8266 self._trapTab(overlay); 8267 try { overlay.querySelector("[data-caf-close]").focus(); } catch(_e){} 8268 }; 8269 8270 /* Probe diagnostico: interroga /api/planner/health e mostra il popup. 8271 Se lastErr è già valorizzato (da un _bkFetch precedente) lo usa senza probe. */ 8272 CafChat.prototype._showPlannerDiagPopup = async function(){ 8273 var last=this._bkLastErr; 8274 if(last){this._showPlannerPopup(last);return;}
8275 var ctrl=new AbortController();var tid=setTimeout(function(){ctrl.abort();},8000); 8276 try{ 8277 var r=await fetch(this.opts.baseUrl+"/api/planner/health",{method:"POST",headers:{"Content-Type":"application/json"},body:"{}",signal:ctrl.signal}); 8278 clearTimeout(tid); 8279 var j={};try{j=await r.json();}catch(x){} 8280 this._showPlannerPopup({code:(j.code||"UNKNOWN"),userMessage:(j.userMessage||"Motivo non disponibile."),plannerMessage:(j.plannerMessage||("HTTP "+r.status))}); 8281 }catch(e){clearTimeout(tid); 8282 this._showPlannerPopup({code:"PROBE-FAIL",userMessage:"Impossibile contattare /api/planner/health.",plannerMessage:(e&&e.message)?e.message:String(e)}); 8283 } 8284 }; 8285 8286 CafChat.prototype._bkCheckEnabled = async function(){ 8287 /* DEMOPROMO: prenotazione sempre "abilitata", senza il probe di rete a 8288 * /api/planner/province. */ 8289 if(this.opts&&this.opts.demopromo){this._bkEnabled=true;return true;} 8290 if(this._bkEnabled!==undefined)return this._bkEnabled; 8291 var ctrl=new AbortController();var tid=setTimeout(function(){ctrl.abort();},5000); 8292 try{var r=await fetch(this.opts.baseUrl+"/api/planner/province",{method:"POST",headers:{"Content-Type":"application/json"},body:"{}",signal:ctrl.signal}); 8293 clearTimeout(tid); 8294 if(r.status===403){this._bkEnabled=false;return false;}this._bkEnabled=true;return true; 8295 }catch(e){clearTimeout(tid);return false;/* don't cache network/timeout errors */} 8296 }; 8297 8298 /* ââ password reset: render card into _pwdEl âââââââââââââââ */ 8299 CafChat.prototype._pwdRender = function(){ 8300 var self = this; 8301 var el = this._pwdEl; 8302 if(!el) return; 8303 el.innerHTML = ""; 8304 var c = this.opts.color; 8305 8306 var card = document.createElement("div"); 8307 card.className = "caf-pw-card"; 8308 card.style.cssText = 8309 "background:linear-gradient(135deg,#fff," + c + "05);" + 8310 "border:1.5px solid " + c + "30;border-radius:14px;padding:16px 16px 14px;" + 8311 "box-shadow:0 4px 16px " + c + "15;font-family:inherit;animation:caf-fade-up .35s ease-out"; 8312 8313 /* Header */ 8314 var hdr = document.createElement("div"); 8315 hdr.style.cssText = "display:flex;align-items:center;gap:10px;margin-bottom:10px"; 8316 var icon = document.createElement("div"); 8317 icon.style.cssText = "width:32px;height:32px;border-radius:10px;background:var(--caf-c);color:var(--caf-on-c);display:flex;align-items:center;justify-content:center;flex-shrink:0;box-shadow:0 3px 10px " + c + "44"; 8318 icon.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>'; 8319 hdr.appendChild(icon); 8320 var htxt = document.createElement("div"); 8321 htxt.style.cssText = "flex:1;min-width:0"; 8322 var ht = document.createElement("div"); 8323 ht.style.cssText = "font-size:13.5px;font-weight:700;color:#0f172a;letter-spacing:-.1px;line-height:1.25"; 8324 ht.textContent = "Reimposta password PersonalCAF"; 8325 htxt.appendChild(ht); 8326 var hs = document.createElement("div"); 8327 hs.style.cssText = "font-size:11px;color:#5b6573;margin-top:1px;line-height:1.35"; 8328 hs.textContent = "Inserisci la tua email, ti inviamo subito il link per il reset."; 8329 htxt.appendChild(hs); 8330 hdr.appendChild(htxt); 8331 card.appendChild(hdr); 8332 8333 if(this.pwdStep === "form" || this.pwdStep === "error"){ 8334 var form = document.createElement("form"); 8335 form.style.cssText = "display:flex;gap:8px;flex-wrap:wrap;margin-top:4px"; 8336 form.addEventListener("submit", function(ev){ ev.preventDefault(); self._pwdSubmit(); }); 8337 8338 var inpWrap = document.createElement("div"); 8339 inpWrap.style.cssText = "flex:1;min-width:180px;position:relative"; 8340 var inp = document.createElement("input"); 8341 inp.type = "email"; inp.required = true; inp.autocomplete = "email"; 8342 inp.placeholder = "[email protected]"; inp.setAttribute("aria-label", bkT("aria_pwd_email")); 8343 inp.value = this.pwdEmail || ""; 8344 inp.style.cssText = "width:100%;padding:10px 12px 10px 36px;border:1.5px solid #e2e8f0;border-radius:10px;font-size:13px;font-family:inherit;outline:none;background:#fff;transition:border-color .2s,box-shadow .2s;box-sizing:border-box"; 8345 inp.addEventListener("focus", function(){ inp.style.borderColor = c; inp.style.boxShadow = "0 0 0 3px " + c + "22"; }); 8346 inp.addEventListener("blur", function(){ inp.style.borderColor = "#e2e8f0"; inp.style.boxShadow = "none"; }); 8347 inp.addEventListener("input", function(){ self.pwdEmail = inp.value; }); 8348 inpWrap.appendChild(inp); 8349 var mailIc = document.createElement("span"); 8350 mailIc.style.cssText = "position:absolute;
8350left:11px;top:50%;transform:translateY(-50%);color:#5b6573;pointer-events:none;display:flex"; 8351 mailIc.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/></svg>'; 8352 inpWrap.appendChild(mailIc); 8353 form.appendChild(inpWrap); 8354 8355 var btn = document.createElement("button"); 8356 btn.type = "submit"; 8357 btn.style.cssText = "border:none;background:var(--caf-c);color:var(--caf-on-c);border-radius:10px;padding:10px 16px;font-size:12.5px;font-weight:700;cursor:pointer;font-family:inherit;box-shadow:0 4px 12px " + c + "44;display:inline-flex;align-items:center;gap:5px;transition:all .2s;-webkit-tap-highlight-color:transparent"; 8358 btn.innerHTML = 'Invia link <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>'; 8359 btn.addEventListener("mouseenter", function(){ btn.style.transform = "translateY(-1px)"; btn.style.boxShadow = "0 6px 16px " + c + "55"; }); 8360 btn.addEventListener("mouseleave", function(){ btn.style.transform = "none"; btn.style.boxShadow = "0 4px 12px " + c + "44"; }); 8361 form.appendChild(btn); 8362 card.appendChild(form); 8363 8364 if(this.pwdStep === "error" && this.pwdErr){ 8365 var er = document.createElement("div"); 8366 er.style.cssText = "margin-top:8px;font-size:11.5px;color:#b91c1c;background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:7px 10px;line-height:1.4"; 8367 er.textContent = this.pwdErr; 8368 card.appendChild(er); 8369 } 8370 } else if(this.pwdStep === "submitting"){ 8371 var sub = document.createElement("div"); 8372 sub.style.cssText = "display:flex;align-items:center;gap:10px;font-size:12.5px;color:#475569;padding:4px 0"; 8373 sub.innerHTML = '<span style="width:16px;height:16px;border:2px solid #e2e8f0;border-top-color:' + c + ';border-radius:50%;animation:caf-spin .8s linear infinite"></span> Invio la richiesta...'; 8374 card.appendChild(sub); 8375 } else if(this.pwdStep === "done"){ 8376 var ok = document.createElement("div"); 8377 ok.style.cssText = "display:flex;gap:10px;align-items:flex-start;margin-top:2px"; 8378 var okIc = document.createElement("div"); 8379 okIc.style.cssText = "width:26px;height:26px;border-radius:50%;background:#10b981;color:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;box-shadow:0 3px 10px #10b98155"; 8380 okIc.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'; 8381 ok.appendChild(okIc); 8382 var okTxt = document.createElement("div"); 8383 okTxt.style.cssText = "flex:1;min-width:0;font-size:12.5px;color:#0f172a;line-height:1.5"; 8384 okTxt.innerHTML = 8385 '<div style="font-weight:700;margin-bottom:3px">Richiesta inviata</div>' + 8386 'Riceverai a breve all\'indirizzo <strong>' + escapeHtml(self.pwdEmail) + '</strong> ' + 8387 'un\'email con il link per reimpostare la password. ' + 8388 'Se non la trovi entro pochi minuti, controlla la cartella <strong>Spam</strong> o <strong>Posta indesiderata</strong>.'; 8389 ok.appendChild(okTxt); 8390 card.appendChild(ok); 8391 } 8392 8393 el.appendChild(card); 8394 }; 8395 8396 /** Submit password reset request by writing the email to Firestore via REST. */ 8397 CafChat.prototype._pwdSubmit = async function(){ 8398 var self = this; 8399 var email = (this.pwdEmail || "").trim().toLowerCase(); 8400 if(!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email)){ 8401 this.pwdStep = "error"; this.pwdErr = "Inserisci un indirizzo email valido."; 8402 this._pwdRender(); return; 8403 } 8404 this.pwdStep = "submitting"; this.pwdErr = ""; this._pwdRender(); 8405 8406 /* GDPR minimizzazione (art. 5(1)(c)): niente URL con query/hash, userAgent troncato. */ 8407 var _prUrl = ""; try { _prUrl = (typeof location !== "undefined") ? (location.origin + (location.pathname || "")) : ""; } catch (_e) { _prUrl = ""; } 8408 8409 /* Percorso preferito: endpoint SERVER (niente scrittura client-side 8410 * diretta su Firestore â GDPR artt. 25, 32). Il write REST diretto resta 8411 * SOLO come fallback transitorio per finestre di deploy misto 8412 * (widget nuovo in CDN + server senza la route). */ 8413 var _pwdOk = false;
8414 try { 8415 var _srv = await fetch((this.opts.baseUrl || "") + "/api/password-reset-request", { 8416 method: "POST", 8417 headers: { "Content-Type": "application/json" }, 8418 body: JSON.stringify({ email: email, pageUrl: _prUrl, source: "widget-inline" }) 8419 }); 8420 _pwdOk = !!(_srv && _srv.ok); 8421 } catch (_e) { _pwdOk = false; } 8422 8423 if (!_pwdOk) { 8424 var cfg = (typeof window !== "undefined" && window.__CAF_FIREBASE_CONFIG__) || {}; 8425 if (!cfg.projectId || !cfg.apiKey) { 8426 this.pwdStep = "error"; this.pwdErr = "Impossibile inviare la richiesta ora. Riprova tra qualche istante."; 8427 this._pwdRender(); return; 8428 } 8429 var url = "https://firestore.googleapis.com/v1/projects/" + encodeURIComponent(cfg.projectId) + 8430 "/databases/(default)/documents/password_reset_requests?key=" + encodeURIComponent(cfg.apiKey); 8431 var body = { 8432 fields: { 8433 email: { stringValue: email }, 8434 createdAt: { timestampValue: new Date().toISOString() }, 8435 pageUrl: { stringValue: _prUrl }, 8436 userAgent: { stringValue: (typeof navigator !== "undefined" ? (navigator.userAgent || "").slice(0,200) : "") }, 8437 source: { stringValue: "widget-inline" } 8438 } 8439 }; 8440 try { 8441 var r = await fetch(url, { 8442 method: "POST", 8443 headers: { "Content-Type": "application/json" }, 8444 body: JSON.stringify(body) 8445 }); 8446 if (!r.ok) throw new Error("HTTP " + r.status); 8447 } catch (e) { 8448 self.pwdStep = "error"; 8449 self.pwdErr = "Impossibile inviare la richiesta ora. Riprova tra qualche istante."; 8450 self._pwdRender(); 8451 return; 8452 } 8453 } 8454 8455 self.pwdStep = "done"; 8456 self._pwdRender(); 8457 /* Add a follow-up assistant message summarising next steps */ 8458 var m = {id:quickId(),role:"assistant",content: 8459 "Se hai altre domande, sono qui. Ricorda di controllare anche la cartella **Spam** o **Posta indesiderata** se non vedi l'email.", 8460 timestamp:Date.now()}; 8461 self.messages.push(m); 8462 self._renderMessages(); self._saveMessages(); self._updateSendBtn(); 8463 }; 8464 8465 CafChat.prototype._pwdReset = function(silent){ 8466 this.pwdStep = "idle"; this.pwdEmail = ""; this.pwdErr = ""; this._pwdEl = null; 8467 if(!silent){ 8468 this.messages = this.messages.filter(function(m){return m.content!=="@@PWDRESET@@";}); 8469 this._saveMessages(); this._renderMessages(); this._updateSendBtn(); 8470 } 8471 }; 8472 8473 CafChat.prototype._bkReset = function(silent){ 8474 this.bkStep="idle";this.bkRegioni=[];this.bkSelRegione=null;this.bkProvince=[];this.bkSelProv=null;this.bkServizi=[];this.bkSelServ=null; 8475 this.bkSedi=[];this.bkSelSede=null;this.bkSpazi=[];this.bkNextG="";this.bkSelSlot=null; 8476 this.bkClient={nome:"",cognome:"",codfis:"",email:"",cellulare:"",sesso:"M"}; 8477 /* #PII â ricarica il candidato dallo storage (gated sul consenso) per la 8478 * prossima prenotazione e ripulisci il resume: su device condiviso, chiudere/ 8479 * abbandonare NON deve lasciare i dati personali pronti per il prossimo. */ 8480 this._bkStoredCandidate=loadStoredClient(this.bkBookingFor); 8481 clearResumeState(); 8482 this.bkId=null;this.bkErrMsg="";this.bkErrCode=null;this.bkErrReturn=null;this.bkFormErr=null; 8483 /* Consenso privacy: ogni nuova prenotazione richiede un nuovo opt-in esplicito. */ 8484 this.bkPrivacyOk=false; 8485 this._bkBusy=false;this._bkEl=null;this.bkHint="";this._bkDir="fwd"; 8486 this._bkVoiceField=null;this._bkVoiceAnnounced=null; 8487 this._bkCalMonth=null;this._bkCalSelected=null; 8488 this._bkNearestStatus=null;this._bkNearestCode=null;this._bkAutoProvStatus=null; 8489 this._bkSideFilled={};this._lastFsBkStep=null; 8490 this._bkPrevFsStep=undefined;this._bkSlotPrefetch={}; 8491 this._bkSedeSearch=""; 8492 /* Reset dello stato "loading annullabile": senza questo, alla prenotazione 8493 * SUCCESSIVA comparirebbe un bottone "Annulla" fantasma sul primo loading, 8494 * che salterebbe a uno step di ritorno stantio. Invalida anche un'eventuale 8495 * fetch ancora in volo (op-gen + abort) per non far ripartire await stantie. */ 8496 this._bkLoadingReturnStep=null;this._bkLoadingMore=false;this._bkSubmitReqId=null; 8497 this._bkOpGen=(this._bkOpGen||0)+1; 8498 try{if(this._bkActiveCtrl)this._bkActiveCtrl.abort();}catch(_e){} 8499 this._bkActiveCtrl=null; 8500 if(this._bkAutoProvWatchdog){clearTimeout(this._bkAutoProvWatchdog);this._bkAutoProvWatchdog=null;} 8501 if(this._bkFsMode){this._bkExitFs(true);} 8502 if(!silent){ 8503 /* Strip solo i placeholder @@BOOKING@@ (anchor wizard fullscreen). 8504 * Il bottone @@BOOKING_LAUNCH@@ resta nella cronologia: l'utente può 8505 * ri-cliccarlo per riaprire il wizard se ha cancellato. */ 8506 this.messages=this.messages.filter(function(m){return m.content!=="@@BOOKING@@";}); 8507 this._saveMessages();this._renderMessages();this._updateSendBtn(); 8508 } 8509 if(this._updateBkCta)this._updateBkCta(); 8510 if(this._updateBkExpandBanner)this._updateBkExpandBanner(); 8511 }; 8512 8513 CafChat.prototype._bkSetError = function(err,ret){ 8514 /* Annullamento volontario dell'utente (bottone "Annulla"): NON è un errore, 8515 * la navigazione l'ha già gestita _bkCancelLoading â no-op silenzioso. */ 8516 if(err&&err.code==="cancelled")return; 8517 var msg=bkT("err_unexpected"),code="unknown"; 8518 if(err&&typeof err==="object"&&err.code){msg=err.error||msg;code=err.code;} 8519 else if(err instanceof Error){msg=err.message;} 8520 this.bkStep="error";this.bkErrMsg=msg;this.bkErrCode=code;this.bkErrReturn=ret||null; 8521 this._bkBusy=false;this._bkRender(); 8522 }; 8523 8524 /* #42 â Banner GDPR multi-tier (compliant EDPB 03/2022). 8525 * 8526 * Mostra il banner con 3 categorie chiaramente distinte: 8527 * - NECESSARI (sempre on, non-toggleable): cache, lingua 8528 * - FUNZIONALI (opt-in): pre-fill cliente, history, resume 8529 * - ANALYTICS (opt-in separato): sessionId, UTM, log-event 8530 * 8531 * Tre azioni equivalenti per peso visivo (linee guida EDPB): 8532 * - "Accetta tutti" 8533 * - "Rifiuta tutti" (stesso peso) 8534 * - "Personalizza" (toggle granulari) 8535 * 8536 * Risolve la promise con un oggetto {functional, analytics} così il 8537 * chiamante sa quali scope sono stati concessi. */ 8538 CafChat.prototype._bkRequestConsent = function(opts){ 8539 opts=opts||{}; 8540 /* Se già espresso, ritorna il consenso esistente senza ri-prompt */ 8541 if(bkConsentExpressed()){ 8542 var c=bkConsentLoad(); 8543 return Promise.resolve({functional:!!(c&&c.functional),analytics:!!(c&&c.analytics)}); 8544 } 8545 var self=this; 8546 var privacyUrl=(self.opts&&self.opts.privacyUrl)||""; 8547 try{var sct=document.querySelector("script[data-privacy-url]");
8547if(sct&&!privacyUrl)privacyUrl=sct.getAttribute("data-privacy-url")||"";}catch(_e){} 8548 var _cafPi=(typeof window!=="undefined"&&window.__CAF_PRIVACY_INFO__)||{}; 8549 var _piGet=function(k){var v=_cafPi&&_cafPi[k];return(typeof v==="string"&&v.trim())?v.trim():"";}; 8550 var controller=(self.opts&&self.opts.dataController)||_piGet("controller")||"il titolare del trattamento (vedi Informativa Privacy del sito)"; 8551 return new Promise(function(resolve){ 8552 /* Pulisci eventuali residui (sia legacy in body sia nuova posizione in panel) */ 8553 try{ 8554 var prevLegacy=document.getElementById("caf-bk-gdpr-banner"); 8555 if(prevLegacy&&prevLegacy.parentNode)prevLegacy.parentNode.removeChild(prevLegacy); 8556 if(self.panelEl){ 8557 var prevPanel=self.panelEl.querySelector("#caf-bk-gdpr-backdrop"); 8558 if(prevPanel&&prevPanel.parentNode)prevPanel.parentNode.removeChild(prevPanel); 8559 if(self.shadow){ 8560 var shadowOld=self.shadow.querySelector("#caf-bk-gdpr-banner"); 8561 if(shadowOld&&shadowOld.parentNode)shadowOld.parentNode.removeChild(shadowOld); 8562 } 8563 } 8564 }catch(_e){} 8565 var bd=document.createElement("div");bd.id="caf-bk-gdpr-backdrop";bd.className="caf-bk-gdpr-backdrop"; 8566 var b=document.createElement("div");b.id="caf-bk-gdpr-banner";b.className="caf-bk-gdpr";b.setAttribute("role","dialog");b.setAttribute("aria-modal","true");b.setAttribute("aria-labelledby","caf-bk-gdpr-h"); 8567 b.innerHTML= 8568 '<div class="caf-bk-gdpr-row">' 8569 +'<div class="caf-bk-gdpr-ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div>' 8570 +'<div class="caf-bk-gdpr-body">' 8571 +'<h3 class="caf-bk-gdpr-h" id="caf-bk-gdpr-h">'+escapeHtml(bkT("gdpr_h"))+'</h3>' 8572 +'<div>'+escapeHtml(bkT("gdpr_body_v2",controller))+'</div>' 8573 +'<div class="caf-bk-gdpr-prefs" data-prefs hidden>' 8574 +'<label class="caf-bk-gdpr-pref"><input type="checkbox" checked disabled> <span><b>'+escapeHtml(bkT("gdpr_cat_necessary"))+'</b><br><small>'+escapeHtml(bkT("gdpr_cat_necessary_d"))+'</small></span></label>' 8575 +'<label class="caf-bk-gdpr-pref"><input type="checkbox" data-cat="functional"> <span><b>'+escapeHtml(bkT("gdpr_cat_functional"))+'</b><br><small>'+escapeHtml(bkT("gdpr_cat_functional_d"))+'</small></span></label>' 8576 +'<label class="caf-bk-gdpr-pref"><input type="checkbox" data-cat="analytics"> <span><b>'+escapeHtml(bkT("gdpr_cat_analytics"))+'</b><br><small>'+escapeHtml(bkT("gdpr_cat_analytics_d"))+'</small></span></label>' 8577 +'</div>' 8578 +'<div class="caf-bk-gdpr-info">' 8579 +'<a class="caf-bk-gdpr-link" href="#" data-act="more">'+escapeHtml(bkT("gdpr_more"))+'</a>' 8580 +(privacyUrl?' · <a class="caf-bk-gdpr-link" href="'+escapeHtml(privacyUrl)+'" target="_blank" rel="noopener noreferrer">'+escapeHtml(bkT("gdpr_privacy_policy"))+'</a>':'') 8581 +'</div>' 8582 +'<div class="caf-bk-gdpr-actions">' 8583 +'<button type="button" class="caf-bk-gdpr-btn primary" data-act="accept-all">'+escapeHtml(bkT("gdpr_accept_all"))+'</button>' 8584 +'<button type="button" class="caf-bk-gdpr-btn primary-outline" data-act="reject-all">'+escapeHtml(bkT("gdpr_reject_all"))+'</button>' 8585 +'<button type="button" class="caf-bk-gdpr-btn ghost" data-act="customize">'+escapeHtml(bkT("gdpr_customize"))+'</button>' 8586 +'<button type="button" class="caf-bk-gdpr-btn primary" data-act="save-prefs" hidden>'+escapeHtml(bkT("gdpr_save_prefs"))+'</button>' 8587 +'</div>' 8588 +'</div>' 8589 +'</div>'; 8590 bd.appendChild(b); 8591 /* Append DENTRO il chat panel (panelEl è position:fixed â containing block 8592 * per il backdrop assoluto). Fallback a body solo se il panel non esiste 8593 * ancora (es. consenso richiesto prima dell'init dell'UI). */ 8594 if(self.panelEl)self.panelEl.appendChild(bd); 8595 else document.body.appendChild(bd); 8596 self._trapTab(b);
8597 try { (b.querySelector(".caf-bk-gdpr-btn.primary")||b.querySelector("button,[href],input")||b).focus(); } catch(_e){} 8598 var done=function(consent){ 8599 try{bd.parentNode&&bd.parentNode.removeChild(bd);}catch(_e){} 8600 resolve(consent); 8601 }; 8602 b.querySelector("[data-act=accept-all]").addEventListener("click",function(){ 8603 bkConsentSave({functional:true,analytics:true}); 8604 done({functional:true,analytics:true}); 8605 }); 8606 b.querySelector("[data-act=reject-all]").addEventListener("click",function(){ 8607 bkConsentSave({functional:false,analytics:false}); 8608 done({functional:false,analytics:false}); 8609 }); 8610 b.querySelector("[data-act=customize]").addEventListener("click",function(){ 8611 var prefs=b.querySelector("[data-prefs]");prefs.hidden=false; 8612 b.querySelector("[data-act=customize]").hidden=true; 8613 b.querySelector("[data-act=save-prefs]").hidden=false; 8614 }); 8615 b.querySelector("[data-act=save-prefs]").addEventListener("click",function(){ 8616 var fn=!!b.querySelector("input[data-cat=functional]:checked"); 8617 var an=!!b.querySelector("input[data-cat=analytics]:checked"); 8618 bkConsentSave({functional:fn,analytics:an}); 8619 done({functional:fn,analytics:an}); 8620 }); 8621 b.querySelector("[data-act=more]").addEventListener("click",function(ev){ 8622 ev.preventDefault(); 8623 var existingMore=b.querySelector(".caf-bk-gdpr-more"); 8624 if(existingMore){existingMore.remove();return;} 8625 var more=document.createElement("div");more.className="caf-bk-gdpr-more"; 8626 more.style.cssText="margin-top:10px;padding:12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;font-size:12px;line-height:1.55;color:#475569"; 8627 more.innerHTML= 8628 '<p style="margin:0 0 6px">' 8629 +'<b>'+escapeHtml(bkT("gdpr_info_controller"))+'</b> '+escapeHtml(controller) 8630 +[["address","gdpr_info_address"],["email","gdpr_info_email"],["phone","gdpr_info_phone"],["vat","gdpr_info_vat"],["dpo","gdpr_info_dpo"]].map(function(f){var v=_piGet(f[0]);return v?('<br><b>'+escapeHtml(bkT(f[1]))+'</b> '+escapeHtml(v)):"";}).join("") 8631 +'</p>' 8632 +'<p style="margin:0 0 6px"><b>'+escapeHtml(bkT("gdpr_info_legal_basis"))+'</b> '+escapeHtml(bkT("gdpr_info_legal_basis_d"))+'</p>' 8633 +'<p style="margin:0 0 6px"><b>'+escapeHtml(bkT("gdpr_info_storage"))+'</b> '+escapeHtml(bkT("gdpr_info_storage_d"))+'</p>' 8634 +'<p style="margin:0 0 6px"><b>'+escapeHtml(bkT("gdpr_info_retention"))+'</b> '+escapeHtml(bkT("gdpr_info_retention_d"))+'</p>' 8635 +'<p style="margin:0 0 6px"><b>'+escapeHtml(bkT("gdpr_info_recipients"))+'</b> '+escapeHtml(bkT("gdpr_info_recipients_d"))+'</p>' 8636 +'<p style="margin:0 0 6px"><b>'+escapeHtml(bkT("gdpr_info_rights"))+'</b> '+escapeHtml(bkT("gdpr_info_rights_d"))+'</p>' 8637 +'<p style="margin:0"><b>'+escapeHtml(bkT("gdpr_info_revoke"))+'</b> '+escapeHtml(bkT("gdpr_info_revoke_d"))+'</p>'; 8638 var bd=b.querySelector(".caf-bk-gdpr-body");bd.insertBefore(more,bd.querySelector(".caf-bk-gdpr-actions")); 8639 }); 8640 }); 8641 }; 8642 8643 /* #42 GDPR â pannello Privacy: visualizza dati salvati, modifica preferenze 8644 * di consenso (Art. 7 GDPR), esporta dati (Art. 15/20 portabilità ), cancella 8645 * (Art. 17 right to erasure). Iniettato in document.body fuori shadow DOM. */ 8646 CafChat.prototype._bkShowPrivacyPanel = function(){ 8647 var self=this; 8648 /* Cleanup residui in entrambe le posizioni (body legacy e panel) */ 8649 try{ 8650 var prevLegacy=document.getElementById("caf-bk-privacy-overlay"); 8651 if(prevLegacy&&prevLegacy.parentNode)prevLegacy.parentNode.removeChild(prevLegacy); 8652 if(self.panelEl){ 8653 var prevPanel=self.panelEl.querySelector("#caf-bk-privacy-overlay"); 8654 if(prevPanel&&prevPanel.parentNode)prevPanel.parentNode.removeChild(prevPanel); 8655 } 8656 }catch(_e){} 8657 var c=this.opts.color; 8658 var consent=bkConsentLoad()||{functional:false,analytics:false}; 8659 /* Conta i dati salvati per dare visibilità all'utente di cosa abbiamo */ 8660 var stored={}; 8661 try{ 8662 stored.client=!!localStorage.getItem(BK_CLIENT_KEY)||!!localStorage.getItem(BK_CLIENT_FAMILY_KEY); 8663 stored.history=loadBookingHistory().length; 8664 stored.resume=!!localStorage.getItem(BK_RESUME_KEY); 8665 stored.sid=!!localStorage.getItem(BK_SID_KEY); 8666 stored.freq=!!localStorage.getItem(BK_FREQ_KEY); 8667 }catch(_e){} 8668 8669 var ov=document.createElement("div");ov.id="caf-bk-privacy-overlay"; 8670 /* Overlay CONFINATO al chat panel (no position:fixed sul viewport). 8671 * border-radius:inherit per seguire i bordi arrotondati del panel. 8672 * z-index:100 per stare sopra .caf-bk-fs (z-index:60). */ 8673 ov.style.cssText="position:absolute;inset:0;z-index:100;background:rgba(15,23,42,.55);
8673display:flex;align-items:center;justify-content:center;padding:12px;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;border-radius:inherit;animation:caf-bk-fade-in .25s ease-out"; 8674 var modal=document.createElement("div"); 8675 /* max-height:100% (NON 90vh): il modal vive DENTRO il chat panel, quindi 8676 * il limite giusto e' l'altezza dell'overlay (inset:0 nel panel), non il 8677 * viewport â con 90vh su un panel piu' basso del viewport i bottoni 8678 * finivano tagliati fuori senza scroll. border-box: il padding non deve 8679 * sommarsi a width/height oltre il contenitore. */ 8680 modal.style.cssText="width:min(560px,100%);max-height:100%;box-sizing:border-box;overflow:auto;background:#fff;border-radius:18px;box-shadow:0 24px 60px rgba(0,0,0,.3);padding:24px"; 8681 modal.innerHTML= 8682 '<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px">' 8683 +'<div style="width:40px;height:40px;border-radius:11px;background:linear-gradient(135deg,'+c+'22,'+c+'11);color:'+c+';display:flex;align-items:center;justify-content:center"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="width:22px;height:22px"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div>' 8684 +'<h2 style="margin:0;font-size:18px;font-weight:700;color:#0f172a">'+escapeHtml(bkT("privacy_panel_h"))+'</h2>' 8685 +'</div>' 8686 +'<p style="margin:0 0 16px;font-size:13.5px;color:#475569;line-height:1.55">'+escapeHtml(bkT("privacy_panel_s"))+'</p>' 8687 8688 +'<h3 style="margin:18px 0 8px;font-size:14px;font-weight:700;color:#0f172a">'+escapeHtml(bkT("privacy_panel_consent_h"))+'</h3>' 8689 +'<div style="display:flex;flex-direction:column;gap:8px;padding:12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px">' 8690 +'<label style="display:flex;align-items:flex-start;gap:10px;font-size:12.5px;line-height:1.5;color:#334155"><input type="checkbox" checked disabled style="margin-top:3px"> <span><b>'+escapeHtml(bkT("gdpr_cat_necessary"))+'</b><br><small style="color:#5b6573">'+escapeHtml(bkT("gdpr_cat_necessary_d"))+'</small></span></label>' 8691 +'<label style="display:flex;align-items:flex-start;gap:10px;font-size:12.5px;line-height:1.5;cursor:pointer;color:#334155"><input type="checkbox" data-cat="functional" '+(consent.functional?"checked":"")+' style="margin-top:3px;accent-color:'+c+'"> <span><b>'+escapeHtml(bkT("gdpr_cat_functional"))+'</b><br><small style="color:#5b6573">'+escapeHtml(bkT("gdpr_cat_functional_d"))+'</small></span></label>' 8692 +'<label style="display:flex;align-items:flex-start;gap:10px;font-size:12.5px;line-height:1.5;cursor:pointer;color:#334155"><input type="checkbox" data-cat="analytics" '+(consent.analytics?"checked":"")+' style="margin-top:3px;accent-color:'+c+'"> <span><b>'+escapeHtml(bkT("gdpr_cat_analytics"))+'</b><br><small style="color:#5b6573">'+escapeHtml(bkT("gdpr_cat_analytics_d"))+'</small></span></label>' 8693 +'</div>' 8694 8695 +'<h3 style="margin:18px 0 8px;font-size:14px;font-weight:700;color:#0f172a">'+escapeHtml(bkT("privacy_panel_data_h"))+'</h3>' 8696 +'<div style="padding:12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;font-size:12.5px;line-height:1.6;color:#475569">' 8697 +'<div>⢠'+escapeHtml(bkT("privacy_panel_data_client"))+': '+(stored.client?'<b style="color:#15803d">'+escapeHtml(bkT("privacy_panel_present"))+'</b>':'<span style="color:#5b6573">'+escapeHtml(bkT("privacy_panel_absent"))+'</span>')+'</div>' 8698 +'<div>⢠'+escapeHtml(bkT("privacy_panel_data_history"))+': '+(stored.history>0?'<b style="color:#15803d">'+stored.history+'</b>':'<span style="color:#5b6573">'+escapeHtml(bkT("privacy_panel_absent"))+'</span>')+'</div>' 8699 +'<div>⢠'+escapeHtml(bkT("privacy_panel_data_resume"))+': '+(stored.resume?'<b style="color:#15803d">'+escapeHtml(bkT("privacy_panel_present"))+'</b>':'<span style="color:#5b6573">'+escapeHtml(bkT("privacy_panel_absent"))+'</span>')+'</div>' 8700 +'<div>⢠'+escapeHtml(bkT("privacy_panel_data_sid"))+': '+(stored.sid?'<b style="color:#15803d">'+escapeHtml(bkT("privacy_panel_present"))+'</b>':'<span style="color:#5b6573">'+escapeHtml(bkT("privacy_panel_absent"))+'</span>')+'</div>' 8701 +'</div>' 8702 8703 +'<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:18px;justify-content:flex-end">' 8704 +'<button type="button" data-act="export" style="display:inline-flex;align-items:center;gap:6px;padding:9px 16px;border:1.5px solid #e2e8f0;background:#fff;color:#475569;border-radius:9px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;
8704height:14px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> '+escapeHtml(bkT("privacy_panel_export"))+'</button>' 8705 +'<button type="button" data-act="delete-all" style="display:inline-flex;align-items:center;gap:6px;padding:9px 16px;border:1.5px solid #fecaca;background:#fef2f2;color:#b91c1c;border-radius:9px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg> '+escapeHtml(bkT("privacy_panel_delete_all"))+'</button>' 8706 +'<button type="button" data-act="cancel" style="padding:9px 16px;border:1.5px solid #e2e8f0;background:#fff;color:#475569;border-radius:9px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit">'+escapeHtml(bkT("btn_close"))+'</button>' 8707 +'<button type="button" data-act="save" style="padding:9px 16px;border:none;background:var(--caf-c);color:var(--caf-on-c);border-radius:9px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit">'+escapeHtml(bkT("gdpr_save_prefs"))+'</button>' 8708 +'</div>'; 8709 8710 ov.appendChild(modal); 8711 var close=function(){try{ov.parentNode&&ov.parentNode.removeChild(ov);}catch(_e){}}; 8712 ov.addEventListener("click",function(ev){if(ev.target===ov)close();}); 8713 modal.querySelector("[data-act=cancel]").addEventListener("click",close); 8714 8715 /* SAVE: aggiorna consenso + cancella dati delle categorie revocate */ 8716 modal.querySelector("[data-act=save]").addEventListener("click",function(){ 8717 var fn=!!modal.querySelector("input[data-cat=functional]:checked"); 8718 var an=!!modal.querySelector("input[data-cat=analytics]:checked"); 8719 var prev=bkConsentLoad()||{functional:false,analytics:false}; 8720 bkConsentSave({functional:fn,analytics:an}); 8721 /* Se l'utente ha REVOCATO un consenso (era true â false), cancella i dati */ 8722 if(prev.functional&&!fn){bkConsentRevoke("functional");bkConsentSave({functional:false,analytics:an});} 8723 if(prev.analytics&&!an){bkConsentRevoke("analytics");bkConsentSave({functional:fn,analytics:false});} 8724 close(); 8725 if(self._bkShowToast)self._bkShowToast({type:"info",title:bkT("privacy_panel_saved_h"),msg:bkT("privacy_panel_saved_s")}); 8726 }); 8727 8728 /* EXPORT: download JSON con tutto ciò che è memorizzato (Art. 15/20 GDPR) */ 8729 modal.querySelector("[data-act=export]").addEventListener("click",function(){ 8730 var data={ 8731 exportedAt:new Date().toISOString(), 8732 consent:bkConsentLoad(), 8733 client_self:null,client_family:null, 8734 history:loadBookingHistory(), 8735 resume:null, 8736 sid:null, 8737 freq:null, 8738 language:BK_LANG, 8739 conversationId:self.conversationId||null, 8740 email:self.userEmail||(function(){try{return localStorage.getItem(self.EMAIL_KEY)||null;}catch(_e){return null;}})(), 8741 chatConsent:(typeof chatConsentLoad==="function"?chatConsentLoad():null), 8742 conversation:(self.messages||[]).map(function(m){return {role:m.role,content:m.content,timestamp:m.timestamp||null};}) 8743 }; 8744 try{var c1=localStorage.getItem(BK_CLIENT_KEY);if(c1)data.client_self=_bkDeobfuscate(c1);}catch(_e){} 8745 try{var c2=localStorage.getItem(BK_CLIENT_FAMILY_KEY);if(c2)data.client_family=_bkDeobfuscate(c2);}catch(_e){} 8746 try{var rsr=localStorage.getItem(BK_RESUME_KEY);if(rsr)data.resume=JSON.parse(rsr);}catch(_e){} 8747 try{data.sid=localStorage.getItem(BK_SID_KEY);}catch(_e){} 8748 try{var fr=localStorage.getItem(BK_FREQ_KEY);if(fr)data.freq=JSON.parse(fr);}catch(_e){} 8749 var blob=new Blob([JSON.stringify(data,null,2)],{type:"application/json;charset=utf-8"}); 8750 var url=URL.createObjectURL(blob); 8751 var a=document.createElement("a");a.href=url;a.download="cafcisl-i-miei-dati-"+(new Date().toISOString().slice(0,10))+".json"; 8752 document.body.appendChild(a);a.click(); 8753 setTimeout(function(){URL.revokeObjectURL(url);try{a.parentNode.removeChild(a);}catch(_e){}},100); 8754 if(self._bkShowToast)self._bkShowToast({type:"info",title:bkT("privacy_panel_export_h"),msg:bkT("privacy_panel_export_s")}); 8755 }); 8756
8757 /* DELETE-ALL: wipe completo + revoca consenso (Art. 17 GDPR) */ 8758 modal.querySelector("[data-act=delete-all]").addEventListener("click",function(){ 8759 if(!confirm(bkT("revoke_confirm")))return; 8760 try{ 8761 bkConsentDeny(); 8762 /* Cancellazione completa (GDPR art. 17): oltre alle chiavi wizard, 8763 * rimuoviamo cronologia chat, id conversazione, email e le eventuali 8764 * chiavi legacy non namespaced rimaste da versioni precedenti. */ 8765 [BK_CLIENT_KEY,BK_CLIENT_FAMILY_KEY,BK_HISTORY_KEY,BK_RESUME_KEY,BK_FREQ_KEY,BK_SID_KEY,BK_LANG_KEY,CHAT_CONSENT_KEY, 8766 self.STORAGE_KEY,self.CONV_ID_KEY,self.EMAIL_KEY] 8767 .concat(BK_LEGACY_KEYS).concat(BK_PRE_NS_KEYS) 8768 .forEach(function(k){try{localStorage.removeItem(k);}catch(_e){}}); 8769 /* stato in-memory: azzera email, rating, id conversazione e messaggi. */ 8770 self.userEmail = ""; 8771 self.rating = null; 8772 self.conversationId = self.opts.privacy ? ("anon_"+quickId()+quickId()) : self._newConversationId(true); 8773 try { 8774 self.messages = [{ id: "welcome", role: "assistant", content: timeAwareGreeting()+"! "+(self.opts.welcome||"")+((self.opts.aiDisclosure!==false)?("\n\n"+bkT("ai_disclosure")):"") }]; 8775 if (self._renderMessages) self._renderMessages(); 8776 } catch(_e){} 8777 /* GDPR Art. 17: revocato anche il consenso CHAT â il gate si ri-arma 8778 subito (checkbox azzerata) se il tenant lo usa (opts.consenso). */ 8779 self._chatConsentGiven = false; 8780 if (self.consentOverlay) { 8781 var _ccb = self.consentOverlay.querySelector(".caf-consent-cb"); 8782 var _cok = self.consentOverlay.querySelector(".caf-consent-ok"); 8783 var _clb = self.consentOverlay.querySelector(".caf-consent-label"); 8784 var _cage = self.consentOverlay.querySelector(".caf-consent-age"); 8785 var _calb = self.consentOverlay.querySelector(".caf-consent-age-label"); 8786 if (_ccb) _ccb.checked = false; 8787 if (_cage) _cage.checked = false; 8788 if (_cok) { _cok.disabled = true; _cok.style.opacity = ".5"; } 8789 if (_clb) { _clb.style.borderColor = "#e2e8f0"; _clb.style.background = "#f8fafc"; } 8790 if (_calb) { _calb.style.borderColor = "#e2e8f0"; _calb.style.background = "#f8fafc"; } 8791 if (self._needsConsent()) { 8792 self.consentOverlay.style.display = "flex"; 8793 self.messagesEl.style.display = "none"; 8794 self._scrollWrap.style.display = "none"; 8795 self._inputAreaEl.style.display = "none"; 8796 if (self._syncAiFooter) self._syncAiFooter(); 8797 } 8798 } 8799 /* svuota anche cache statiche e cache slot */ 8800 for(var i=localStorage.length-1;i>=0;i--){ 8801 var k=localStorage.key(i); 8802 if(k&&(k.indexOf(BK_CACHE_PREFIX)===0||k.indexOf(BK_SLOT_CACHE_PREFIX)===0)){ 8803 try{localStorage.removeItem(k);}catch(_e){} 8804 } 8805 } 8806 }catch(_e){} 8807 close(); 8808 if(self._bkShowToast)self._bkShowToast({type:"info",title:bkT("revoke_done_h"),msg:bkT("revoke_done_s")}); 8809 }); 8810 8811 /* Append DENTRO il chat panel così l'overlay è confinato al widget, 8812 * NON al viewport: il sito host non viene oscurato. Fallback a body 8813 * solo se panelEl non disponibile (caso patologico). */ 8814 if(self.panelEl)self.panelEl.appendChild(ov); 8815 else document.body.appendChild(ov); 8816 }; 8817 8818 /* #30 â wrapper che applica View Transitions ai cambi di step se l'utente 8819 * non ha reduced-motion. Fallback senza animazione su browser non supportati. */ 8820 CafChat.prototype._bkRenderWithTransition = function(){ 8821 var self=this; 8822 if(window.matchMedia&&window.matchMedia("(prefers-reduced-motion:reduce)").matches){ 8823 this._bkRender();return; 8824 } 8825 if(typeof document!=="undefined"&&document.startViewTransition){ 8826 document.startViewTransition(function(){self._bkRender();}); 8827 }else{ 8828 this._bkRender(); 8829 } 8830 }; 8831 8832 /* #31 â confetti SVG variegato (stelle, cerchi, cuori, diamanti). */ 8833 CafChat.prototype._bkSpawnConfetti = function(container, opts){ 8834 if(!container)return; 8835 if(window.matchMedia&&window.matchMedia("(prefers-reduced-motion:reduce)").matches)return; 8836 opts=opts||{}; 8837 var count=opts.count||32; 8838 var colors=opts.colors||[this.opts.color,"#f59e0b","#8b5cf6","#ef4444","#06b6d4","#10b981","#ec4899","#f97316"]; 8839 var shapes=[
8840 '<polygon points="10,2 12.4,7.6 18.5,8 13.7,12 15.3,18 10,14.7 4.7,18 6.3,12 1.5,8 7.6,7.6"/>', /* star */ 8841 '<circle cx="10" cy="10" r="6"/>', /* circle */ 8842 '<rect x="3" y="3" width="14" height="14" rx="2" transform="rotate(15 10 10)"/>', /* square */ 8843 '<path d="M10 16C10 16 3 11 3 7C3 4.79 4.79 3 7 3C8.5 3 10 4 10 4C10 4 11.5 3 13 3C15.21 3 17 4.79 17 7C17 11 10 16 10 16Z"/>', /* heart */ 8844 '<polygon points="10,2 16,10 10,18 4,10"/>' /* diamond */ 8845 ]; 8846 container.style.position=container.style.position||"relative"; 8847 for(var i=0;i<count;i++){ 8848 var el=document.createElement("div");el.className="caf-bk-confetti-svg"; 8849 var color=colors[i%colors.length]; 8850 var shape=shapes[i%shapes.length]; 8851 var leftPct=Math.random()*100; 8852 var tx=(Math.random()*200-100)+"px"; 8853 var tr=(Math.random()*1080-360)+"deg"; 8854 var td=(1.2+Math.random()*1.6)+"s"; 8855 var tdelay=(Math.random()*0.6)+"s"; 8856 var size=(8+Math.random()*10); 8857 el.style.cssText="left:"+leftPct+"%;top:-20px;width:"+size+"px;
8857height:"+size+"px;color:"+color+";--tx:"+tx+";--tr:"+tr+";--td:"+td+";--tdelay:"+tdelay; 8858 el.innerHTML='<svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:100%">'+shape+'</svg>'; 8859 container.appendChild(el); 8860 /* cleanup dopo l'animazione */ 8861 (function(node){setTimeout(function(){try{node.parentNode&&node.parentNode.removeChild(node);}catch(_e){}},3000);})(el); 8862 } 8863 }; 8864 8865 /* #14 â scroll input in centro su mobile quando prende focus. 8866 * La tastiera virtuale copre fino al 50% dello schermo: scrollare al 8867 * centro garantisce che l'input resti visibile sopra la tastiera. */ 8868 CafChat.prototype._bkAttachKeyboardScroll = function(rootEl){ 8869 if(!rootEl)return; 8870 if(!window.matchMedia||!window.matchMedia("(max-width:820px)").matches)return; 8871 var inputs=rootEl.querySelectorAll(".caf-bk-inp,input[type=text],input[type=email],input[type=tel]"); 8872 var scroller=rootEl.querySelector(".caf-bk-fs-main"); 8873 inputs.forEach(function(inp){ 8874 inp.addEventListener("focus",function(){ 8875 if(scroller)scroller.classList.add("kb-padded"); 8876 /* delay piccolo per attendere apertura tastiera */ 8877 setTimeout(function(){ 8878 try{inp.scrollIntoView({behavior:bkScrollBehavior(),block:"center",inline:"nearest"});}catch(_e){} 8879 },280); 8880 }); 8881 inp.addEventListener("blur",function(){ 8882 /* defer remove kb-padded fino a quando un altro input non prende focus */ 8883 setTimeout(function(){ 8884 if(scroller&&document.activeElement&&document.activeElement.matches&&!document.activeElement.matches(".caf-bk-inp,input,select,textarea")) 8885 scroller.classList.remove("kb-padded"); 8886 },50); 8887 }); 8888 }); 8889 }; 8890 8891 /* Toast non-bloccante (#38). Type: 'error' | 'warn' | 'info'. Auto-dismiss 4.5s. */ 8892 CafChat.prototype._bkShowToast = function(opts){ 8893 opts=opts||{}; 8894 var container=this._bkFsMode?this._bkFsEl:this.panelEl; 8895 if(!container)return; 8896 /* rimuovi eventuali toast precedenti */ 8897 var prev=container.querySelectorAll(".caf-bk-toast");prev.forEach(function(p){p.parentNode&&p.parentNode.removeChild(p);}); 8898 var t=document.createElement("div");t.className="caf-bk-toast "+(opts.type==="warn"?"warn":opts.type==="info"?"info":""); 8899 /* WCAG 4.1.3 (#C37/#C47): toast esposto alle AT â errore=alert, stato=status */ 8900 t.setAttribute("role",(opts.type==="warn"||opts.type==="info")?"status":"alert"); 8901 var ic={ 8902 error:'<svg class="caf-bk-toast-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/></svg>', 8903 warn:'<svg class="caf-bk-toast-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>', 8904 info:'<svg class="caf-bk-toast-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>' 8905 }[opts.type==="warn"?"warn":opts.type==="info"?"info":"error"]; 8906 t.innerHTML=ic 8907 +'<div class="caf-bk-toast-body">' 8908 +(opts.title?'<div class="caf-bk-toast-h">'+escapeHtml(opts.title)+'</div>':'') 8909 +escapeHtml(opts.msg||"") 8910 +'</div>' 8911 +'<button type="button" class="caf-bk-toast-x" aria-label="' + escapeHtml(bkT("aria_close")) + '">Ã</button>'; 8912 container.appendChild(t); 8913 var rm=function(){try{t.parentNode&&t.parentNode.removeChild(t);}catch(_e){}}; 8914 t.querySelector(".caf-bk-toast-x").addEventListener("click",rm); 8915 var ttl=opts.duration||4500;setTimeout(rm,ttl); 8916 }; 8917 8918 /* Mappa codice errore Planner â messaggio user-friendly + classe (#38, #57). 8919 * Ora i18n: usa bkT() per titolo + descrizione localizzati. */ 8920 CafChat.prototype._bkFriendlyError = function(code,msg){ 8921 var keyMap={ 8922 timeout:["err_h_timeout","err_s_timeout"], 8923 network:["err_h_network","err_s_network"], 8924 no_services:["err_h_no_services","err_s_no_services"], 8925 no_sedi:["err_h_no_sedi","err_s_no_sedi"], 8926 no_spazi:["err_h_no_spazi","err_s_no_spazi"], 8927 already_booked:["err_h_already_booked","err_s_already_booked"], 8928 slot_taken:["err_h_slot_taken","err_s_slot_taken"], 8929 validation:["err_h_validation","err_s_validation"], 8930 disabled:["err_h_disabled","err_s_disabled"], 8931 server:["err_h_server","err_s_server"], 8932 unknown:["err_h_default","err_s_default"] 8933 }; 8934 var keys=keyMap[code]||keyMap.unknown; 8935 return{title:bkT(keys[0]),msg:msg||bkT(keys[1])}; 8936 }; 8937 8938 /** Funnel event logger (fire-and-forget). Usa /api/planner/log-event. 8939 * Non solleva mai eccezioni: il logging non deve mai rompere il flow. 8940 * GDPR: gated da consenso "analytics". Senza consenso, no-op completo â 8941 * niente sessionId persistente, niente UTM, niente fetch. */ 8942 CafChat.prototype._bkLogEvent = function(eventType,extra){ 8943 if(this.opts&&this.opts.demopromo)return; /* DEMOPROMO: nessuna telemetria verso /api/planner/log-event */ 8944 if(!bkConsentHas("analytics"))return; /* GDPR gating: tracker = consenso */ 8945 try{ 8946 if(!this.opts||!this.opts.baseUrl)return; 8947 if(!this._sessionId){ 8948 try{ 8949 var k=BK_SID_KEY;var v=localStorage.getItem(k); 8950 if(!v){v=String(Date.now())+"-"+Math.random().toString(36).slice(2,9);localStorage.setItem(k,v);} 8951 this._sessionId=v; 8952 }catch(_e){this._sessionId="anon-"+Math.random().toString(36).slice(2,9);} 8953 } 8954 if(!this._bkUtm)this._bkUtm=readUtmParams(); 8955 var body={ 8956 event:eventType, 8957 sessionId:this._sessionId, 8958 source:"widget-inline", 8959 connessione:this.bkSelRegione&&this.bkSelRegione.connessione||null, 8960 regione:this.bkSelRegione||null, 8961 provincia:this.bkSelProv||null, 8962 servizio:this.bkSelServ?{codice:this.bkSelServ.codice,descrizione:this.bkSelServ.descrizione}:null, 8963 sede:this.bkSelSede?{codice:this.bkSelSede.codice,descrizione:this.bkSelSede.descrizione}:null, 8964 slot:this.bkSelSlot?{id_spazio:this.bkSelSlot.id_spazio,data:this.bkSelSlot.data,ora_inizio:this.bkSelSlot.ora_inizio}:null, 8965 extra:Object.assign({},this._bkUtm||{},extra||{}), 8966 context:{pageUrl:(typeof location!=="undefined"&&location.href)||"",userAgent:(typeof navigator!=="undefined"&&navigator.userAgent)||""} 8967 }; 8968 fetch(this.opts.baseUrl+"/api/planner/log-event",{ 8969 method:"POST",headers:{"Content-Type":"application/json"}, 8970 body:JSON.stringify(body),keepalive:true 8971 }).catch(function(){}); 8972 }catch(_e){} 8973 }; 8974 8975 /** Promise wrapper around navigator.geolocation.getCurrentPosition. 8976 * Resolves to {lat,lon} or rejects with the GeolocationPositionError. 8977 * 8978 * Strategia a 2 stadi per massimizzare il successo anche quando il 8979 * browser non ha una posizione cached: 8980 * 1) Tentativo veloce: cache permessa (maximumAge 10m), timeout breve. 8981 * Se il browser ha già un fix recente risponde quasi subito. 8982 * 2) Se il primo fallisce SOLO per timeout/unavailable (NON per permesso 8983 * negato), riprova senza cache con high-accuracy e timeout lungo. 8984 * Questo recupera molti casi su desktop (Wi-Fi positioning lento)
8985 * e su mobile a freddo (primo GPS-fix). */ 8986 /** Fast-path "permesso già negato": legge lo stato via Permissions API SENZA 8987 * far comparire alcun prompt. Risolve `true` SOLO se lo stato è 'denied', così 8988 * un utente che ha già bloccato la geolocalizzazione fallisce subito (~0ms) 8989 * invece di aspettare il watchdog. Affidabile su Chrome/Firefox/Edge. 8990 * Safari<16 lancia (TypeError) e Safari>=16 riporta 'prompt' anche se negato: 8991 * in entrambi i casi torniamo `false` e proseguiamo col GPS reale (il watchdog 8992 * resta il backstop cross-browser). Mai usato come unica difesa. */ 8993 CafChat.prototype._bkGeoPermissionDenied = function(){ 8994 try{ 8995 if(!navigator.permissions||!navigator.permissions.query)return Promise.resolve(false); 8996 return navigator.permissions.query({name:"geolocation"}) 8997 .then(function(st){return !!(st&&st.state==="denied");}) 8998 .catch(function(){return false;}); 8999 }catch(_e){return Promise.resolve(false);} 9000 }; 9001 9002 /** opts.cityLevel: il chiamante deve solo matchare la PROVINCIA (start / 9003 * "Usa la mia posizione") â basta un fix city-level. Salta lo Stage 2 9004 * high-accuracy (che non aggiunge nulla al match e ritarda solo il 9005 * fallback IP-geo) e stringe il watchdog 10sâ7s (Stage1 6s + margine). */ 9006 CafChat.prototype._bkGetGeoPosition = function(opts){ 9007 var self=this; 9008 var cityLevel=!!(opts&&opts.cityLevel); 9009 return new Promise(function(resolve,reject){ 9010 if(!navigator.geolocation){reject({code:0,message:"Geolocalizzazione non supportata dal browser."});return;} 9011 if(window.isSecureContext===false){reject({code:0,message:"La geolocalizzazione richiede HTTPS."});return;} 9012 var done=false; 9013 /* Watchdog: il `timeout` di getCurrentPosition NON scatta mentre il 9014 * prompt è in attesa di risposta (W3C Geolocation §7.2) e alcuni browser 9015 * (iOS Safari) lo ignorano del tutto â il watchdog è l'UNICO bound reale. 9016 * Armato SUBITO (sincrono), prima della Permissions API, così il bound 9017 * esiste da t=0. Default 10s: copre Stage1(6s)+Stage2(8s) clampati e un 9018 * fix lento legittimo, senza punire chi non concede il permesso. 9019 * cityLevel 7s: solo Stage1(6s)+margine. */ 9020 var watchdog=setTimeout(function(){ 9021 if(done)return;done=true; 9022 reject({code:3,message:"GPS non risponde."}); 9023 },cityLevel?7000:10000); 9024 var ok=function(pos){if(done)return;done=true;clearTimeout(watchdog);resolve({lat:pos.coords.latitude,lon:pos.coords.longitude,accuracy:pos.coords.accuracy});}; 9025 var fail=function(err){if(done)return;done=true;clearTimeout(watchdog);reject(err||{code:0,message:"Errore di geolocalizzazione."});}; 9026 self._bkGeoPermissionDenied().then(function(denied){ 9027 if(done)return; 9028 if(denied){fail({code:1,message:"Permesso di geolocalizzazione negato."});return;} 9029 /* Stage 1 â veloce, cache 10min, low-accuracy */ 9030 try{ 9031 navigator.geolocation.getCurrentPosition( 9032 ok, 9033 function(err1){ 9034 if(done)return; 9035 /* Permesso negato: niente retry, è inutile e peggiora UX. */ 9036 if(err1&&err1.code===1){fail(err1);return;} 9037 /* cityLevel: niente Stage 2 â meglio passare subito al 9038 * fallback IP del chiamante che insistere sul GPS. */ 9039 if(cityLevel){fail(err1);return;} 9040 /* Stage 2 â fresh, high-accuracy, timeout 8s. */ 9041 try{ 9042 navigator.geolocation.getCurrentPosition( 9043 ok, 9044 function(err2){fail(err2||err1);}, 9045 {enableHighAccuracy:true,timeout:8000,maximumAge:0} 9046 ); 9047 }catch(_e){fail(err1);} 9048 }, 9049 {enableHighAccuracy:false,timeout:6000,maximumAge:600000} 9050 ); 9051 }catch(syncErr){ 9052 fail({code:0,message:"Errore avvio GPS"}); 9053 } 9054 }); 9055 }); 9056 }; 9057 9058 /** Variante HIGH-PRECISION per il calcolo "sede più vicina". 9059 * Richiede SEMPRE GPS fresco ad alta accuratezza (no cache), perché 9060 * per il distance ranking ci serve precisione metrica, non city-level. 9061 * Fallisce subito se l'utente ha negato il permesso. 9062 * Watchdog 22s: alcuni browser (iOS Safari edge cases) ignorano il 9063 * parametro `timeout` di getCurrentPosition e la Promise non si 9064 * risolverebbe mai â bloccaremmo l'utente sul loading. */ 9065 CafChat.prototype._bkGetGeoPositionPrecise = function(){ 9066 var self=this; 9067 return new Promise(function(resolve,reject){ 9068 if(!navigator.geolocation){reject({code:0,message:"Geolocalizzazione non supportata dal browser."});return;} 9069 if(window.isSecureContext===false){reject({code:0,message:"La geolocalizzazione richiede HTTPS."});return;} 9070 var done=false; 9071 /* Watchdog 16s (più del low-acc): l'alta precisione a freddo può 9072 * richiedere più tempo e qui serve un fix metrico per la "sede più 9073 * vicina", quindi non tagliamo troppo. Armato sincrono prima della 9074 * Permissions API così il bound esiste da t=0. */ 9075 var watchdog=setTimeout(function(){ 9076 if(done)return;done=true; 9077 reject({code:3,message:"GPS non risponde."}); 9078 },16000); 9079 var ok=function(pos){if(done)return;done=true;clearTimeout(watchdog);resolve({lat:pos.coords.latitude,lon:pos.coords.longitude,accuracy:pos.coords.accuracy});}; 9080 var fail=function(err){if(done)return;done=true;clearTimeout(watchdog);
9080reject(err||{code:0,message:"Errore di geolocalizzazione."});}; 9081 self._bkGeoPermissionDenied().then(function(denied){ 9082 if(done)return; 9083 if(denied){fail({code:1,message:"Permesso di geolocalizzazione negato."});return;} 9084 try{ 9085 navigator.geolocation.getCurrentPosition( 9086 ok,fail, 9087 /* enableHighAccuracy:true â GPS hardware (mobile) o Wi-Fi triangulation 9088 * ad alta precisione (desktop). maximumAge:0 â niente cache, sempre 9089 * fix fresco. timeout:15s â GPS può richiedere 15+ s a freddo. */ 9090 {enableHighAccuracy:true,timeout:15000,maximumAge:0} 9091 ); 9092 }catch(syncErr){ 9093 fail({code:0,message:"Errore avvio GPS: "+(syncErr&&syncErr.message||syncErr)}); 9094 } 9095 }); 9096 }); 9097 }; 9098 9099 /** Fallback: geolocalizzazione via IP quando il browser non riesce 9100 * (desktop senza GPS, Wi-Fi positioning fallito, Linux senza geoclue, 9101 * mobile in interno con segnale assente, ecc.). Precisione ~città â più 9102 * che sufficiente per matchare la provincia. 9103 * 9104 * Prova più endpoint HTTPS in cascata (no API key richiesta) finché uno 9105 * risponde. Timeout per endpoint: 4s. */ 9106 CafChat.prototype._bkGetIpGeoPosition = function(){ 9107 var endpoints=[ 9108 {url:"https://ipapi.co/json/",pick:function(j){return (j&&typeof j.latitude==="number"&&typeof j.longitude==="number")?{lat:j.latitude,lon:j.longitude}:null;}}, 9109 {url:"https://get.geojs.io/v1/ip/geo.json",pick:function(j){var la=j&&parseFloat(j.latitude),lo=j&&parseFloat(j.longitude);return (isFinite(la)&&isFinite(lo))?{lat:la,lon:lo}:null;}}, 9110 {url:"https://ipwho.is/",pick:function(j){return (j&&j.success!==false&&typeof j.latitude==="number"&&typeof j.longitude==="number")?{lat:j.latitude,lon:j.longitude}:null;}} 9111 ]; 9112 /* Endpoint in PARALLELO: vince il primo che risponde con coords valide. 9113 * Era una cascata seriale (3 à 3s = fino a 9s) â ora ~3s (il più lento). 9114 * Promise.any è supportato su tutti i target (Chrome 85+/FF 79+/Safari 14+); 9115 * fallback con reduce per browser più vecchi. Normalizziamo l'errore alla 9116 * forma {code:0} che i chiamanti già si aspettano (mai un AggregateError). */ 9117 var attempts=endpoints.map(function(ep){
9118 var ctrl=new AbortController();var tid=setTimeout(function(){ctrl.abort();},3000); 9119 return fetch(ep.url,{signal:ctrl.signal,headers:{"Accept":"application/json"}}) 9120 .then(function(r){clearTimeout(tid);if(!r.ok)throw new Error("http "+r.status);return r.json();}) 9121 .then(function(j){var c=ep.pick(j);if(!c)throw new Error("no coords");return c;}) 9122 .catch(function(e){clearTimeout(tid);throw e;}); 9123 }); 9124 var race=Promise.any?Promise.any(attempts):attempts.reduce(function(p,n){return p.catch(function(){return n;});}); 9125 return race.catch(function(){return Promise.reject({code:0,message:"IP geolocation failed"});}); 9126 }; 9127 9128 /** Solo il fetch Nominatim â ritorna l'oggetto `address` ({} se assente), 9129 * o null su risposta non-ok. Separato dal match così può partire in 9130 * PREFETCH appena il GPS risolve, in parallelo al fan-out province. */ 9131 CafChat.prototype._bkReverseGeocodeFetch = async function(coords){ 9132 var ctrl=new AbortController();var tid=setTimeout(function(){ctrl.abort();},6000); 9133 var resp; 9134 try{ 9135 resp=await fetch("https://nominatim.openstreetmap.org/reverse?format=json&zoom=10&addressdetails=1&lat="+coords.lat+"&lon="+coords.lon, 9136 {signal:ctrl.signal,headers:{"Accept":"application/json"}}); 9137 }finally{clearTimeout(tid);} 9138 if(!resp.ok)return null; 9139 var data=await resp.json(); 9140 return (data&&data.address)||{}; 9141 }; 9142 9143 /** Reverse-geocode (Nominatim) + match against a flat all-Italy list. 9144 * Returns {provincia, regione} or null. 9145 * addrP (opzionale): promise di un _bkReverseGeocodeFetch già avviato. */ 9146 CafChat.prototype._bkReverseGeocodeAndMatch = async function(coords,allData,addrP){ 9147 if(!allData||!allData.items||!allData.items.length)return null; 9148 var addr=await (addrP||this._bkReverseGeocodeFetch(coords)); 9149 if(addr==null)return null; 9150 var candidates=[addr.county,addr.province,addr.state_district,addr.city,addr.town,addr.municipality].filter(Boolean); 9151 var allProv=allData.items.map(function(x){return x.provincia;}); 9152 var matched=null; 9153 for(var i=0;i<candidates.length&&!matched;i++){ 9154 matched=matchProvince(candidates[i],allProv); 9155 } 9156 if(!matched){ 9157 /* FALLBACK: nessuna provincia API matchata. 9158 * Se conosciamo la regione geografica (es. Nominatim addr.state="Toscana"), 9159 * cerchiamo nella lista regioni un entry placeholder "X REGIONALE" che 9160 * la corrisponde â così l'utente in Toscana viene auto-portato sulla 9161 * regione "TOSCANA REGIONALE" e da lì espandiamo alle province reali. */ 9162 var stateName=addr.state||addr["ISO3166-2-lvl4"]||""; 9163 if(stateName){ 9164 var regionalEntry=bkFindRegionalEntry(allData.regioni||[],stateName); 9165 if(regionalEntry){ 9166 return {matched:null,address:addr,regionalEntry:regionalEntry}; 9167 } 9168 } 9169 return {matched:null,address:addr}; 9170 } 9171 var hit=null; 9172 for(var k=0;k<allData.items.length;k++){ 9173 if(allData.items[k].provincia.codice===matched.codice){hit=allData.items[k];break;} 9174 } 9175 return {matched:matched,regione:hit?hit.regione:null,address:addr}; 9176 }; 9177 9178 CafChat.prototype._bkStart = async function(){ 9179 if(this._bkBusy)return;this._bkBusy=true; 9180 /* Generazione di QUESTA apertura: chiudi+riapri rapido (con _bkBusy già 9181 * liberato dal picker early) fa partire una seconda _bkStart mentre le 9182 * promise della prima (fan-out, GPS, Nominatim) sono ancora in volo â 9183 * senza questo token i loro handler clobberebbero lo stato della nuova 9184 * apertura (bkRegioni/bkStep/banner) quando finalmente risolvono. 9185 * NON riusiamo _bkOpGen: viene incrementato da ogni _bkPickRegione/Prov 9186 * (anche manuali) e invaliderebbe l'auto-jump provincia legacy. */ 9187 var _sg=this._bkStartGen=(this._bkStartGen||0)+1; 9188 if(this._updateBkCta)this._updateBkCta(); 9189 /* La prenotazione avviene SEMPRE in modalità fullscreen â niente più 9190 * widget inline nella conversazione. */ 9191 if(!this._bkFsMode){ 9192 try{this._bkEnterFs();}catch(_e){} 9193 } 9194 /* #42 â GDPR: chiediamo consenso PRIMA di qualsiasi altra cosa che 9195 * possa scrivere/leggere dati personali. Il banner appare una sola 9196 * volta (poi è persistito). Se l'utente dà rifiuta-tutti, la 9197 * prenotazione funziona comunque ma niente pre-fill / resume / log. */ 9198 if(!bkConsentExpressed()){ 9199 this._bkBusy=false; /* libera busy: il banner è non bloccante per il flow */
9200 try{await this._bkRequestConsent();}catch(_e){} 9201 this._bkBusy=true; 9202 } 9203 /* #60 â se c'è uno stato di resume valido (ultime 24h) e non l'abbiamo 9204 * già proposto in questa sessione, chiediamo se vuole riprendere. 9205 * loadResumeState ritorna null senza consenso functional â no resume. */ 9206 if(!this._bkResumeAsked){ 9207 this._bkResumeAsked=true; 9208 var rs=loadResumeState(); 9209 if(rs&&rs.step&&this._bkOfferResume){ 9210 var didResume=await this._bkOfferResume(rs); 9211 if(didResume){this._bkBusy=false;return;} 9212 } 9213 } 9214 /* Dopo gli await di consenso/resume: se nel frattempo è partita una 9215 * _bkStart più recente, questa apertura è superata â non deve creare un 9216 * secondo prompt GPS né toccare bkStep/banner della nuova. */ 9217 if(this._bkStartGen!==_sg)return; 9218 /* DEMOPROMO: percorso deterministico e OFFLINE â nessun GPS / IP-geo / 9219 * Nominatim. Mostra subito il picker delle regioni dei dati di esempio (o 9220 * auto-seleziona se il dataset ha una sola regione). Il rilevamento posizione 9221 * resta disponibile, simulato, dal bottone allo step provincia. */ 9222 if(this.opts&&this.opts.demopromo){ 9223 this._bkAutoProvStatus=null; 9224 var _regsD=[]; 9225 try{_regsD=await this._bkFetch("regioni");}catch(_e){_regsD=[];} 9226 if(this._bkStartGen!==_sg)return; 9227 /* Le fetch demo non sono abortabili (~180ms): se nel frattempo l'utente ha 9228 * chiuso il wizard (_bkExitFs -> _bkReset, che azzera _bkFsMode e pulisce il 9229 * resume), NON scrivere step/render qui o si salverebbe un resume spurio. */ 9230 if(!this._bkFsMode)return; 9231 if(!_regsD||!_regsD.length){this._bkSetError({error:"Nessuna regione disponibile (demo).",code:"no_services"});this._bkBusy=false;return;} 9232 this.bkRegioni=_regsD; 9233 this._bkProvinciaByConn={}; 9234 if(_regsD.length===1){this._bkBusy=false;return this._bkPickRegione(_regsD[0]);} 9235 this.bkStep="selectRegione"; 9236 this._bkBusy=false; 9237 this._bkRender(); 9238 return; 9239 } 9240 /* STEP 0 â GPS NON bloccante. Il picker delle regioni viene mostrato 9241 * appena le province sono pronte; il GPS lavora in background e, se 9242 * risolve PRIMA che l'utente abbia interagito, fa auto-jump alla 9243 * provincia rilevata. Su fallimento (timeout/permesso negato), nessun 9244 * messaggio d'errore mostrato all'utente: sceglie manualmente come 9245 * sempre. Questo elimina il famigerato "Richiesta posizione scaduta". */ 9246 this._bkAutoProvStatus={type:"loading",msg:"Caricamento..."}; 9247 this.bkStep="loading";this._bkRender(); 9248 var self=this; 9249 var earlyShown=false; 9250 /* Esito della geolocalizzazione condiviso fra i vari handler asincroni. 9251 * state: pending|ok|fail. addrP: prefetch Nominatim avviato appena il GPS 9252 * risolve (in parallelo al fan-out province). jumped: l'early-jump ha già 9253 * fatto avanzare la UI. allDataDone: fan-out province completato. */ 9254 var geoOutcome={state:"pending",err:null,coords:null,addrP:null,jumped:false}; 9255 var allDataDone=false; 9256 /* true quando una _bkStart più recente ha preso il controllo: gli handler 9257 * asincroni di QUESTA apertura devono diventare no-op. */ 9258 function _bkStale(){return self._bkStartGen!==_sg;} 9259 function _bkGeoFailText(ge){ 9260 /* Messaggio specifico in base al codice di errore così l'utente capisce 9261 * CHE problema c'è (permesso, segnale, timeout) invece di un generico 9262 * "impossibile rilevare". */ 9263 if(ge.code===1)return "Permesso di geolocalizzazione negato dal browser. Scegli manualmente qui sotto."; 9264 if(ge.code===2)return "Posizione non disponibile (segnale GPS/Wi-Fi assente). Scegli manualmente qui sotto."; 9265 if(ge.code===3)return "Richiesta posizione scaduta. Scegli manualmente qui sotto."; 9266 return (ge.message||"Impossibile rilevare la posizione")+". Scegli manualmente qui sotto."; 9267 } 9268 /* Riflette geoOutcome nel banner di stato APPENA l'esito è noto â senza 9269 * aspettare il fan-out province. Prima il banner veniva aggiornato solo 9270 * dal consumer agganciato dopo `await _bkFetchAllProvince`: con cache 9271 * fredda (fan-out 6-18s) chi negava il permesso fissava "Sto cercando la 9272 * tua posizioneâ¦" per tutto quel tempo, finché il watchdog 12s non lo 9273 * nascondeva SENZA alcun messaggio (misurato via CDP: 12s di finto 9274 * loading dopo il rifiuto). Aggiorna solo se il banner è ancora "nostro" 9275 * (loading) e l'utente non ha già preso il controllo del picker. */ 9276 function _bkGeoBannerNow(){ 9277 if(_bkStale())return; 9278 if(self._bkUserOverride||self.bkSelProv)return; 9279 if(self.bkStep==="selectRegione"&&self.bkSelRegione)return; 9280 if(self.bkStep!=="selectRegione"&&self.bkStep!=="selectProvincia")return; 9281 if(geoOutcome.state==="fail"){ 9282 if(self._bkAutoProvWatchdog){clearTimeout(self._bkAutoProvWatchdog);self._bkAutoProvWatchdog=null;} 9283 self._bkAutoProvStatus={type:"err",msg:_bkGeoFailText(geoOutcome.err||{})}; 9284 self._bkRender(); 9285 } else if(geoOutcome.state==="ok"&&!geoOutcome.jumped&&earlyShown){ 9286 /* Solo nel flusso nazionale multi-regione (earlyShown): lì un consumer 9287 * (early-jump o post-fan-out) consumerà SEMPRE questo loading. Nei 9288 * percorsi ZONA/single-region che ritornano prima di agganciare il 9289 * consumer, il banner resterebbe appeso per sempre (nessun watchdog). */ 9290 self._bkAutoProvStatus={type:"loading",msg:"Identifico la tua provinciaâ¦"}; 9291 self._bkRender(); 9292 } 9293 } 9294 /* Mostra il picker REGIONI appena arrivano le regioni â NON aspettare il 9295 * fan-out delle province (collo di bottiglia: N getProvince in parallelo, la 9296 * più lenta bloccava ~8s; in incognito/cookie bloccati la cache non salva mai 9297 * â ogni apertura ripagava il fan-out). Solo caso nazionale multi-regione: 9298 * single-region e ZONA hanno bisogno delle province e restano gestiti sotto. 9299 * Le province caricano in background; _bkPickRegione fa fetch on-demand se 9300 * l'utente sceglie una regione prima che il prefetch finisca. */ 9301 function _bkShowRegioniEarly(regs){ 9302 if(_bkStale())return; 9303 if(earlyShown||self.bkStep!=="loading")return; 9304 if(!regs||regs.length<=1)return; 9305 var z=(typeof window!=="undefined"&&window.__CAF_ZONA__||"").toString().trim().toLowerCase(); 9306 if(z&&z!=="nazionale")return; 9307 earlyShown=true; 9308 self.bkRegioni=regs; 9309 self.bkStep="selectRegione"; 9310 /* Il GPS potrebbe aver GIà un esito (es. permesso negato mentre le 9311 * regioni caricavano): mostra direttamente quello, non un finto 9312 * "Sto cercandoâ¦". */ 9313 if(geoOutcome.state==="fail"){ 9314 self._bkAutoProvStatus={type:"err",msg:_bkGeoFailText(geoOutcome.err||{})}; 9315 } else { 9316 self._bkAutoProvStatus={type:"loading", 9317 msg:geoOutcome.state==="ok"?"Identifico la tua provinciaâ¦":"Sto cercando la tua posizione⦠(puoi anche scegliere manualmente)"}; 9318 } 9319 self._bkRender(); 9320 self._bkBusy=false; 9321 if(self._bkAutoProvWatchdog){clearTimeout(self._bkAutoProvWatchdog);self._bkAutoProvWatchdog=null;} 9322 if(geoOutcome.state!=="fail"){ 9323 self._bkAutoProvWatchdog=setTimeout(function(){ 9324 self._bkAutoProvWatchdog=null; 9325 if(self._bkAutoProvStatus&&self._bkAutoProvStatus.type==="loading" 9326 &&(self.bkStep==="selectRegione"||self.bkStep==="selectProvincia")){ 9327 self._bkAutoProvStatus=null;self._bkRender(); 9328 } 9329 },12000); 9330 } 9331 /* Il GPS potrebbe essere risolto PRIMA che le regioni fossero pronte: 9332 * ora che il picker c'è, l'early-jump può partire. */ 9333 if(geoOutcome.state==="ok")_bkGeoEarlyJump().catch(function(){}); 9334 }
9335 /* EARLY-JUMP: GPS + reverse-geocode pronti ma fan-out province ancora in 9336 * corso â salta SUBITO alla regione rilevata riusando _bkPickRegione (lo 9337 * stesso percorso del click manuale: fetch on-demand della sola regione, 9338 * geo-filtri e auto-pick placeholder inclusi), poi prova il match della 9339 * provincia sulla lista appena caricata. Con cache fredda il fan-out 9340 * costa 6-18s: senza questo salto anche chi CONCEDE subito il permesso 9341 * aspettava l'intero fan-out prima dell'auto-jump (misurato: ~12s). 9342 * Se qualcosa non matcha si ritorna senza toccare nulla: il consumer 9343 * post-fan-out (match flat su TUTTA Italia) resta il fallback. */ 9344 async function _bkGeoEarlyJump(){ 9345 if(_bkStale()||allDataDone||geoOutcome.jumped||geoOutcome.state!=="ok")return; 9346 if(self._bkUserOverride||self.bkSelProv||self.bkSelRegione)return; 9347 if(self.bkStep!=="selectRegione")return; 9348 if(!self.bkRegioni||self.bkRegioni.length<=1)return; 9349 var addr=null; 9350 try{addr=await geoOutcome.addrP;}catch(_e){return;} 9351 if(!addr)return; 9352 /* Ri-verifica DOPO l'await: l'utente o il fan-out potrebbero aver agito. */ 9353 if(_bkStale()||allDataDone||geoOutcome.jumped)return; 9354 if(self._bkUserOverride||self.bkSelProv||self.bkSelRegione)return; 9355 if(self.bkStep!=="selectRegione")return; 9356 var stateName=addr.state||addr["ISO3166-2-lvl4"]||""; 9357 if(!stateName)return; 9358 var entry=bkFindRegionalEntry(self.bkRegioni,stateName); 9359 if(!entry)return; 9360 geoOutcome.jumped=true; 9361 self._bkUserCoords=geoOutcome.coords; 9362 if(self._bkAutoProvWatchdog){clearTimeout(self._bkAutoProvWatchdog);self._bkAutoProvWatchdog=null;} 9363 self._bkLogEvent&&self._bkLogEvent("regione_auto_picked_geo_early",{regione:entry.descrizione}); 9364 self._bkAutoProvStatus={type:"ok",msg:"Trovato: "+entry.descrizione+". Carico le province..."}; 9365 self._bkBusy=false;self._bkRender(); 9366 await self._bkPickRegione(entry); 9367 /* Se siamo arrivati al picker province (niente auto-pick placeholder), 9368 * prova ad auto-selezionare la provincia rilevata da Nominatim. */ 9369 if(_bkStale())return; 9370 if(self.bkStep!=="selectProvincia"||self.bkSelProv)return; 9371 var candidates=[addr.county,addr.province,addr.state_district,addr.city,addr.town,addr.municipality].filter(Boolean); 9372 var matched=null; 9373 for(var ci=0;ci<candidates.length&&!matched;ci++){matched=matchProvince(candidates[ci],self.bkProvince||[]);} 9374 if(matched){ 9375 self._bkAutoProvStatus={type:"ok",msg:"Trovato: "+matched.descrizione+" ("+entry.descrizione+"). Cerco i servizi..."}; 9376 self._bkBusy=false;self._bkRender(); 9377 self._bkPickProv(matched); 9378 } 9379 } 9380 /* Catena: browser-GPS â IP-geo fallback. Se il browser fallisce per 9381 * qualunque motivo che NON sia "permesso negato" (code 1), proviamo a 9382 * dedurre la posizione via IP. Per matchare la provincia è più che 9383 * sufficiente, e risolve i casi più comuni di failure: desktop senza 9384 * GPS che dipende dal Wi-Fi positioning fallito, mobile su rete dati 9385 * con segnale GPS assente, Linux senza geoclue, ecc. 9386 * cityLevel: per il match provincia basta un fix city-level â lo stage 9387 * high-accuracy allungava solo l'attesa prima del fallback IP. */ 9388 var posP=this._bkGetGeoPosition({cityLevel:true}).then(function(p){return {ok:true,coords:p};}).catch(function(err){ 9389 /* Permesso esplicitamente negato: rispetta la scelta utente, niente IP. */ 9390 if(err&&err.code===1)return {ok:false,err:err}; 9391 return self._bkGetIpGeoPosition().then(function(p){return {ok:true,coords:p,viaIp:true};}).catch(function(){return {ok:false,err:err};}); 9392 }); 9393 /* Handler IMMEDIATO (sganciato dal fan-out province): banner aggiornato 9394 * appena l'esito geo è noto + prefetch Nominatim + eventuale early-jump. */ 9395 posP.then(function(r){ 9396 if(r.ok){ 9397 geoOutcome.state="ok";geoOutcome.coords=r.coords; 9398 if(_bkStale())return; /* niente prefetch/banner per un'apertura superata */ 9399 geoOutcome.addrP=self._bkReverseGeocodeFetch(r.coords); 9400 geoOutcome.addrP.catch(function(){}); /* consumata altrove; evita unhandledrejection */ 9401 _bkGeoBannerNow(); 9402 _bkGeoEarlyJump().catch(function(){}); 9403 } else { 9404 geoOutcome.state="fail";geoOutcome.err=r.err||{}; 9405 _bkGeoBannerNow(); 9406 } 9407 }); 9408 try{ 9409 var allData=await this._bkFetchAllProvince({onRegioni:_bkShowRegioniEarly}).catch(function(){return {regioni:[],items:[]};}); 9410 allDataDone=true; /* da qui l'early-jump non parte più: match flat sotto */ 9411 if(_bkStale())return; /* un'apertura più recente ha preso il controllo */ 9412 if(!allData.regioni||!allData.regioni.length){ 9413 this._bkSetError({error:"Nessuna regione disponibile per la prenotazione.",code:"no_services"});return; 9414 } 9415 this.bkRegioni=allData.regioni; 9416 /* Cache province per connessione: evita re-fetch in _bkPickRegione */ 9417 var _selfP=this; 9418 this._bkProvinciaByConn={};
9419 allData.items.forEach(function(it){ 9420 var k=it.regione.connessione;if(!_selfP._bkProvinciaByConn[k])_selfP._bkProvinciaByConn[k]=[]; 9421 _selfP._bkProvinciaByConn[k].push(it.provincia); 9422 }); 9423 /* ZONA preselect (deploy multi-tenant): se window.__CAF_ZONA__ è 9424 * settato a "veneto" / "toscana" / ecc. e una regione con quella 9425 * descrizione esiste, la auto-pickiamo via _bkPickRegione (che gestisce 9426 * province, geo-filtri, placeholder auto-pick). "nazionale" o vuoto = 9427 * comportamento standard. */ 9428 var zonaPre=(typeof window!=="undefined"&&window.__CAF_ZONA__||"").toString().trim().toLowerCase(); 9429 if(zonaPre&&zonaPre!=="nazionale"){ 9430 var zonaMatch=null; 9431 for(var zi=0;zi<this.bkRegioni.length;zi++){ 9432 var zd=((this.bkRegioni[zi]&&this.bkRegioni[zi].descrizione)||"").trim().toLowerCase(); 9433 if(zd===zonaPre){zonaMatch=this.bkRegioni[zi];break;} 9434 } 9435 if(zonaMatch){ 9436 this._bkLogEvent&&this._bkLogEvent("regione_auto_picked_zona",{zona:zonaPre}); 9437 this._bkBusy=false; 9438 return this._bkPickRegione(zonaMatch); 9439 } 9440 this._bkLogEvent&&this._bkLogEvent("regione_zona_no_match",{zona:zonaPre,available:this.bkRegioni.map(function(r){return r.descrizione;}).join(",")}); 9441 } 9442 /* Mostra SUBITO il picker â niente attesa GPS. */ 9443 if(this.bkRegioni.length===1){ 9444 var only=this.bkRegioni[0];this.bkSelRegione=only; 9445 this.bkProvince=allData.items 9446 .filter(function(x){return x.regione.connessione===only.connessione;}) 9447 .map(function(x){return x.provincia;}); 9448 if(!this.bkProvince.length){this._bkSetError({error:"Nessuna provincia disponibile per "+only.descrizione+".",code:"no_services"});return;} 9449 /* FILTRO GEOGRAFICO: stessa logica di _bkPickRegione. */ 9450 var detectedR=bkExtractRegionName(only.descrizione); 9451 if(detectedR){ 9452 var beforeN0=this.bkProvince.length; 9453 this.bkProvince=bkFilterProvincesByRegion(this.bkProvince,detectedR); 9454 if(this.bkProvince.length<beforeN0){ 9455 this._bkLogEvent&&this._bkLogEvent("province_geo_filtered",{regione:only.descrizione,detected:detectedR,before:beforeN0,after:this.bkProvince.length,trigger:"single_region_start"}); 9456 } 9457 } 9458 /* AUTO-PICK PLACEHOLDER MATCH: stessa logica di _bkPickRegione. */ 9459 var rDescS=(only.descrizione||"").trim().toLowerCase(); 9460 var matchPS=null; 9461 if(rDescS){ 9462 for(var miS=0;miS<this.bkProvince.length;miS++){ 9463 var pdS=((this.bkProvince[miS]&&this.bkProvince[miS].descrizione)||"").trim().toLowerCase(); 9464 if(pdS&&pdS===rDescS){matchPS=this.bkProvince[miS];break;} 9465 } 9466 } 9467 if(matchPS){ 9468 this._bkLogEvent&&this._bkLogEvent("provincia_auto_picked_placeholder",{regione:only.descrizione,trigger:"single_region_start"}); 9469 this._bkBusy=false;this._bkPickProv(matchPS);return; 9470 } 9471 if(this.bkProvince.length===1){this._bkBusy=false;this._bkPickProv(this.bkProvince[0]);return;} 9472 this.bkStep="selectProvincia"; 9473 } else { 9474 if(!earlyShown)this.bkStep="selectRegione"; 9475 } 9476 /* Indicatore non-bloccante mentre il GPS lavora in background. 9477 * L'utente vede che la posizione è in cerca, MA può comunque scegliere 9478 * manualmente â il picker è già interattivo. Quando il GPS risolve 9479 * (success o fail) lo status viene aggiornato/nascosto. 9480 * SALTATO se il picker è già stato mostrato in anticipo 9481 * (_bkShowRegioniEarly ha già impostato step/status/render/watchdog): 9482 * ri-renderizzare qui sovrascriverebbe un'eventuale navigazione già fatta 9483 * dall'utente nei secondi di attesa del fan-out province. */ 9484 if(!earlyShown){ 9485 /* Come in _bkShowRegioniEarly: se il GPS ha GIà un esito, il banner 9486 * lo riflette subito invece di un finto "Sto cercandoâ¦". */ 9487 if(geoOutcome.state==="fail"){ 9488 this._bkAutoProvStatus={type:"err",msg:_bkGeoFailText(geoOutcome.err||{})}; 9489 } else { 9490 this._bkAutoProvStatus={type:"loading", 9491 msg:geoOutcome.state==="ok"?"Identifico la tua provinciaâ¦":"Sto cercando la tua posizione⦠(puoi anche scegliere manualmente)"}; 9492 } 9493 this._bkRender(); 9494 this._bkBusy=false; 9495 /* WATCHDOG: se entro 12s lo status loading non è cambiato 9496 * (es. GPS che non chiama mai callback, browser bug), lo 9497 * forziamo a null e l'utente vede solo il picker manuale. 12s copre 9498 * il budget GPS in background (watchdog interno â¤10s) + margine. */ 9499 if(self._bkAutoProvWatchdog){clearTimeout(self._bkAutoProvWatchdog);self._bkAutoProvWatchdog=null;} 9500 if(geoOutcome.state!=="fail"){ 9501 self._bkAutoProvWatchdog=setTimeout(function(){ 9502 self._bkAutoProvWatchdog=null; 9503 if(self._bkAutoProvStatus&&self._bkAutoProvStatus.type==="loading" 9504 &&(self.bkStep==="selectRegione"||self.bkStep==="selectProvincia")){ 9505 self._bkAutoProvStatus=null;self._bkRender(); 9506 } 9507 },12000); 9508 } 9509 } 9510 /* GPS in background â auto-jump alla provincia rilevata SE l'utente 9511 * non ha ancora interagito col picker. Failure: status nascosto. 9512 * Agganciato SEMPRE (anche se il picker è già visibile): consuma il 9513 * risultato GPS â qui `allData` è risolto, quindi il match provincia 9514 * dispone della lista completa. */ 9515 posP.then(async function(posResult){ 9516 if(_bkStale())return; 9517 /* Fallimento: già gestito in tempo reale dall'handler agganciato alla 9518 * creazione di posP (banner err immediato, watchdog ripulito). Qui non 9519 * c'è nulla da fare â e NON dobbiamo azzerare il banner di errore. */ 9520 if(!posResult.ok)return; 9521 if(self._bkAutoProvWatchdog){clearTimeout(self._bkAutoProvWatchdog);self._bkAutoProvWatchdog=null;} 9522 /* Early-jump già avvenuto (fan-out più lento di GPS+Nominatim): la UI 9523 * è già avanzata tramite _bkPickRegione, niente doppio salto. */ 9524 if(geoOutcome.jumped)return;
9525 /* Se l'utente ha già scelto qualcosa, ignora il GPS senza disturbare. 9526 * IMPORTANTE: chiamiamo SEMPRE _bkRender() prima di return per evitare 9527 * che il banner "Sto cercando..." resti visibile. */ 9528 /* Override esplicito (es. click sulla sidebar "Regione"): la scelta 9529 * manuale ha la precedenza assoluta, niente auto-jump da GPS. */ 9530 if(self._bkUserOverride){self._bkAutoProvStatus=null;self._bkRender();return;} 9531 if(self.bkSelProv){self._bkAutoProvStatus=null;self._bkRender();return;} 9532 if(self.bkStep==="selectRegione"&&self.bkSelRegione){self._bkAutoProvStatus=null;self._bkRender();return;} 9533 if(self.bkStep!=="selectRegione"&&self.bkStep!=="selectProvincia"){self._bkAutoProvStatus=null;return;} 9534 self._bkUserCoords=posResult.coords; 9535 self._bkAutoProvStatus={type:"loading",msg:"Identifico la tua provinciaâ¦"};self._bkRender(); 9536 try{ 9537 var geo=await self._bkReverseGeocodeAndMatch(posResult.coords,allData,geoOutcome.addrP); 9538 /* Re-check dopo l'await: l'utente potrebbe aver interagito */ 9539 if(_bkStale())return; 9540 if(self.bkSelProv){self._bkAutoProvStatus=null;self._bkRender();return;} 9541 if(self.bkStep==="selectRegione"&&self.bkSelRegione){self._bkAutoProvStatus=null;self._bkRender();return;} 9542 if(self.bkStep!=="selectRegione"&&self.bkStep!=="selectProvincia"){self._bkAutoProvStatus=null;return;} 9543 /* Cross-region guard: se l'utente ha già scelto una regione 9544 * diversa da quella rilevata dal GPS, NON sovrascrivere la 9545 * sua scelta. Questo si applica solo quando bkRegioni.length>1 9546 * (selezione multi-regione, non auto-set single-region). */ 9547 if(geo&&geo.matched&&geo.regione&&self.bkStep==="selectProvincia" 9548 &&self.bkRegioni&&self.bkRegioni.length>1 9549 &&self.bkSelRegione&&self.bkSelRegione.connessione!==geo.regione.connessione){ 9550 self._bkAutoProvStatus=null;self._bkRender();return; 9551 } 9552 if(geo&&geo.matched&&self._bkApplyMatchedProvincia(allData,geo.matched)){ 9553 self._bkAutoProvStatus={type:"ok",msg:"Trovato: "+geo.matched.descrizione+" ("+(self.bkSelRegione&&self.bkSelRegione.descrizione||"")+"). Cerco i servizi..."}; 9554 /* Forza il busy-flag a false: se il main flow di _bkStart per 9555 * qualche motivo ha lasciato il busy a true, _bkPickProv 9556 * uscirebbe in silenzio dal suo busy-guard e l'utente vedrebbe 9557 * solo "Trovato â¦" senza mai avanzare ai servizi. */ 9558 self._bkBusy=false; 9559 self._bkRender(); 9560 /* Fallback di sicurezza: se dopo 800ms siamo ancora su 9561 * selectRegione/selectProvincia (cioè _bkPickProv non è 9562 * partito o ha fallito senza errori visibili), lo invochiamo 9563 * di nuovo o portiamo l'utente a selectProvincia per dargli 9564 * controllo. */ 9565 self._bkPickProv(geo.matched); 9566 setTimeout(function(){ 9567 if(self.bkStep==="selectRegione"||self.bkStep==="selectProvincia"){ 9568 /* _bkPickProv non è andato a buon fine. Mostra picker 9569 * provincia (regione già impostata) così l'utente non 9570 * resta bloccato sul picker regioni. */ 9571 if(self.bkStep==="selectRegione"){ 9572 self.bkStep="selectProvincia"; 9573 self._bkAutoProvStatus={type:"ok",msg:"Sei in "+geo.matched.descrizione+" ("+(self.bkSelRegione&&self.bkSelRegione.descrizione||"")+"). Tocca per continuare."}; 9574 self._bkRender(); 9575 } 9576 } 9577 },1500); 9578 return; 9579 } 9580 /* Nessuna provincia matchata, MA l'API ha un'entry "X REGIONALE" 9581 * per la regione geografica rilevata â auto-pick di quella entry. 9582 * _bkPickRegione poi espanderà alle 10 province reali (tramite 9583 * BK_IT_PROVINCES) grazie a bkExtractRegionName. */ 9584 if(geo&&geo.regionalEntry){ 9585 self._bkAutoProvStatus={type:"ok",msg:"Trovato: "+geo.regionalEntry.descrizione+". Carico le province..."}; 9586 self._bkBusy=false;self._bkRender(); 9587 self._bkPickRegione(geo.regionalEntry); 9588 return; 9589 } 9590 /* Nessun match in zona: messaggio chiaro invece di null silenzioso, 9591 * altrimenti l'utente si chiede perché "non trova nulla". */ 9592 var _addr=geo&&geo.address?geo.address:{}; 9593 var _loc=_addr.city||_addr.town||_addr.village||_addr.county||""; 9594 self._bkAutoProvStatus={type:"err",
9595 msg:_loc?("Sei a "+_loc+", ma questa zona non è tra le province disponibili. Scegli manualmente."): 9596 "Posizione rilevata, ma non corrisponde a nessuna provincia disponibile. Scegli manualmente."}; 9597 self._bkRender(); 9598 }catch(_e){ 9599 if(_bkStale())return; 9600 self._bkAutoProvStatus={type:"err",msg:"Errore nel rilevare la zona. Scegli manualmente qui sotto."}; 9601 self._bkRender(); 9602 } 9603 }); 9604 return; 9605 }catch(e){if(!_bkStale())this._bkSetError(e);} 9606 /* Il busy-flag appartiene all'apertura più recente: una _bkStart superata 9607 * non deve azzerare il busy della nuova (ne romperebbe le guardie). */ 9608 finally{if(!_bkStale()&&this._bkBusy)this._bkBusy=false;} 9609 }; 9610 9611 /** Fetches ALL province across ALL regioni (parallel). 9612 * Returns {regioni:[...], items:[{regione, provincia}, ...]}. 9613 * Used by chat-driven flows (askLocation / intent detection) so the user 9614 * can name any Italian province without picking the regione first. */ 9615 CafChat.prototype._bkFetchAllProvince = async function(opts){ 9616 var self=this; 9617 /* Cache hit: return immediately, then revalidate in background (SWR-style). 9618 * NB: su cache hit il picker si renderizza comunque subito (i dati completi 9619 * ci sono già ), quindi `onRegioni` (early render) serve solo al cache MISS. */ 9620 var cached=bkCacheGet("allProvince"); 9621 if(cached&&cached.regioni&&cached.regioni.length){ 9622 /* Debounce della revalidation: rapid open/close non deve schedulare 9623 * fetch multipli in parallelo. Una sola revalidation per sessione+2s. */ 9624 if(!self._bkRevalidationScheduled){ 9625 self._bkRevalidationScheduled=true; 9626 setTimeout(function(){ 9627 self._bkRevalidateAllProvince().catch(function(){}).then(function(){ 9628 self._bkRevalidationScheduled=false; 9629 }); 9630 },2000); 9631 } 9632 return cached; 9633 } 9634 var fresh=await self._bkRevalidateAllProvince(opts); 9635 return fresh; 9636 }; 9637 CafChat.prototype._bkRevalidateAllProvince = async function(opts){ 9638 var self=this; 9639 var regs=await self._bkFetch("regioni"); 9640 if(!regs||!regs.length)return {regioni:[],items:[]}; 9641 /* ZONA preselect (deploy multi-tenant): se window.__CAF_ZONA__ punta a una 9642 * regione specifica, il backend (es. connessione "callcenter") può comunque 9643 * esporre TUTTE le regioni. Caricare le province di OGNUNA significa, per 9644 * ogni regione, una getProvince che a sua volta sonda i servizi di OGNI sua 9645 * provincia: decineâcentinaia di chiamate Planner per regioni che l'utente 9646 * non vedrà mai (la ZONA è fissa). Quando la ZONA fa match con una regione, 9647 * un deploy preimpostato à un tenant mono-regione: restringiamo a QUELLA 9648 * sola regione SIA il fan-out province (cold-open 1+N â 1+1 getProvince) SIA 9649 * la lista `regioni` ritornata â così TUTTI i consumatori (picker, resume, 9650 * "vicino a me"/askLocation, messaggi) restano coerenti e non offrono mai 9651 * regioni le cui province non sono state caricate. Vuoto / "nazionale" / 9652 * nessun match = comportamento full (national + fallback misconfig sicuro). 9653 * Il match per descrizione è identico a quello in _bkStart, così le due 9654 * logiche ZONA restano sempre allineate. 9655 * 9656 * RITORNA: { regioni: regione ZONA preselezionata se match, altrimenti 9657 * TUTTE; items: province della/e stessa/e regione/i }. */ 9658 var zonaPre=(typeof window!=="undefined"&&window.__CAF_ZONA__||"").toString().trim().toLowerCase(); 9659 var scopedRegs=regs; 9660 if(zonaPre&&zonaPre!=="nazionale"){ 9661 var onlyZona=regs.filter(function(r){return ((r&&r.descrizione)||"").trim().toLowerCase()===zonaPre;}); 9662 if(onlyZona.length)scopedRegs=onlyZona; 9663 } 9664 /* Early render: notifica le regioni appena pronte, PRIMA del fan-out delle 9665 * province (la chiamata getProvince più lenta bloccava il picker ~8s â e in 9666 * incognito/cookie bloccati la cache non persiste mai, quindi OGNI apertura 9667 * pagava il fan-out). _bkStart usa questo callback per mostrare subito il 9668 * picker regioni; le province continuano a caricare qui sotto in background. */ 9669 if(opts&&typeof opts.onRegioni==="function"){try{opts.onRegioni(scopedRegs);}catch(_e){}} 9670 var lists=await Promise.all(scopedRegs.map(function(r){ 9671 return self._bkFetch("province",{connessione:r.connessione,codiceRegione:r.codiceRegione}) 9672 .then(function(rawP){ 9673 var p=rawP||[]; 9674 /* FALLBACK: se la lista API è placeholder (es. "Toscana Regionale"), 9675 * espandi alle province italiane reali per quella regi
9675one. */ 9676 if(bkIsPlaceholderProvinceList(p,r.descrizione)){ 9677 var fb=bkExpandProvinceFallback(r.descrizione); 9678 if(fb.length>0)p=fb; 9679 } 9680 return p.map(function(prov){return {regione:r,provincia:prov};}); 9681 }) 9682 .catch(function(){return [];}); 9683 })); 9684 var flat=[];lists.forEach(function(l){l.forEach(function(x){flat.push(x);});}); 9685 var out={regioni:scopedRegs,items:flat}; 9686 /* Cache solo se il risultato è non vuoto, per non congelare un guasto API. */ 9687 if(out.regioni.length>0)bkCacheSet("allProvince",out); 9688 return out; 9689 }; 9690 9691 /** Given a matched provincia, set up state (regione+provincia+filtered list) 9692 * from the result of _bkFetchAllProvince. */ 9693 CafChat.prototype._bkApplyMatchedProvincia = function(allData,matched){ 9694 var hit=null; 9695 for(var i=0;i<allData.items.length;i++){ 9696 if(allData.items[i].provincia.codice===matched.codice){hit=allData.items[i];break;} 9697 } 9698 if(!hit)return false; 9699 this.bkRegioni=allData.regioni; 9700 this.bkSelRegione=hit.regione; 9701 this.bkProvince=allData.items 9702 .filter(function(x){return x.regione.connessione===hit.regione.connessione;}) 9703 .map(function(x){return x.provincia;}); 9704 this.bkSelProv=matched; 9705 return true; 9706 }; 9707 9708 CafChat.prototype._bkPickRegione = async function(r){ 9709 if(this._bkBusy)return;this._bkBusy=true;var _g=++this._bkOpGen;this.bkSelRegione=r;this._bkLoadingReturnStep="selectRegione";this.bkStep="loading";this._bkRender();bkHaptic(8); 9710 this._bkLogEvent&&this._bkLogEvent("regione_picked"); 9711 try{ 9712 /* Prefetch hit: usa cache se popolata (evita roundtrip) */ 9713 var cached=this._bkProvinciaByConn&&this._bkProvinciaByConn[r.connessione]; 9714 var p=(cached&&cached.length)?cached:await this._bkFetch("province",{connessione:r.connessione,codiceRegione:r.codiceRegione},{timeoutMs:15000}); 9715 if(this._bkOpGen!==_g)return; 9716 if(!p||!p.length){this._bkSetError({error:"Nessuna provincia disponibile per "+r.descrizione+". Scegli un'altra regione.",code:"no_services"},"selectRegione");return;} 9717 /* FILTRO GEOGRAFICO: se la regione selezionata corrisponde ad una 9718 * regione italiana riconosciuta (es. "Emilia Romagna"), filtra la 9719 * lista per tenere solo le province appartenenti a quella regione. 9720 * Risolve il caso in cui l'API ritorna una lista miscellanea 9721 * cross-regione anche per regioni standard. */ 9722 var detectedRegion=bkExtractRegionName(r.descrizione); 9723 if(detectedRegion){ 9724 var beforeN=p.length; 9725 p=bkFilterProvincesByRegion(p,detectedRegion); 9726 if(p.length<beforeN){ 9727 this._bkLogEvent&&this._bkLogEvent("province_geo_filtered",{regione:r.descrizione,detected:detectedRegion,before:beforeN,after:p.length}); 9728 } 9729 } 9730 this.bkProvince=p; 9731 /* AUTO-PICK PLACEHOLDER MATCH: 9732 * Se la regione selezionata è del tipo "X REGIONALE" e fra le 9733 * province torna un'entry con la STESSA descrizione (es. anche 9734 * "TOSCANA REGIONALE"), quella è l'unica realmente utilizzabile dal 9735 * backend per questo tenant â anche se l'API ritorna ulteriori 9736 * province cross-regione. Auto-pickiamo il match e saltiamo 9737 * direttamente al passo Servizio. */ 9738 var matchingPlaceholder=null; 9739 var rDesc=(r&&r.descrizione||"").trim().toLowerCase(); 9740 if(rDesc){ 9741 for(var mi=0;mi<p.length;mi++){ 9742 var pd=((p[mi]&&p[mi].descrizione)||"").trim().toLowerCase(); 9743 if(pd&&pd===rDesc){matchingPlaceholder=p[mi];break;} 9744 } 9745 } 9746 if(matchingPlaceholder){ 9747 this._bkLogEvent&&this._bkLogEvent("provincia_auto_picked_placeholder",{regione:r.descrizione}); 9748 this._bkBusy=false;this._bkPickProv(matchingPlaceholder);return; 9749 } 9750 /* DEFENSIVE FALLBACK (raro): se l'API torna SOLO placeholder 9751 * (es. solo "Toscana Regionale" senza match esatto col nome regione), 9752 * espande con la lista hardcoded delle province reali. */ 9753 if(bkIsPlaceholderProvinceList(p,r.descrizione)){
9754 var fb=bkExpandProvinceFallback(r.descrizione); 9755 if(fb.length>0){ 9756 p=fb;this.bkProvince=p; 9757 this._bkLogEvent&&this._bkLogEvent("province_fallback_used",{regione:r.descrizione,trigger:"api_placeholder",n:fb.length}); 9758 } 9759 } 9760 if(p.length===1){this._bkBusy=false;this._bkPickProv(p[0]);return;} 9761 this.bkStep="selectProvincia";this._bkRender(); 9762 }catch(e){this._bkSetError(e,"selectRegione");}finally{if(this._bkOpGen===_g)this._bkBusy=false;} 9763 }; 9764 9765 CafChat.prototype._bkPickProv = async function(p){ 9766 if(this._bkBusy)return;this._bkBusy=true;var _g=++this._bkOpGen;this.bkSelProv=p;this._bkLoadingReturnStep="selectProvincia";this.bkStep="loading"; 9767 this._bkAutoProvStatus={type:"loading",msg:"Cerco i servizi per "+p.descrizione+"..."}; 9768 this._bkRender();bkHaptic(8); 9769 this._bkLogEvent&&this._bkLogEvent("provincia_picked"); 9770 var conn=this.bkSelRegione&&this.bkSelRegione.connessione; 9771 var srvCacheKey="servizi:"+conn+":"+p.codice; 9772 var cachedSrv=bkCacheGet(srvCacheKey); 9773 /* `[]` è truthy in JS â il check `cachedSrv||fetch` userebbe array vuoto 9774 * intrappolando l'utente in "nessun servizio" anche se l'API è guarita. 9775 * Trattiamo come miss anche cache vuota o non-array. */ 9776 var hasValidCache=Array.isArray(cachedSrv)&&cachedSrv.length>0; 9777 try{var all=hasValidCache?cachedSrv:await this._bkFetch("servizi",{codiceProvincia:p.codice,connessione:conn},{timeoutMs:15000}); 9778 if(this._bkOpGen!==_g)return; 9779 /* Cache solo risultati non vuoti per evitare di "congelare" un guasto temporaneo. */ 9780 if(!hasValidCache&&Array.isArray(all)&&all.length>0)bkCacheSet(srvCacheKey,all); 9781 /* FILTRO PROCEDURA ONLINE â richiediamo solo il flag `online` truthy 9782 * (accetta "1", 1, true, "true", "SI", "Y"). La precedente richiesta 9783 * di `desonline` non vuoto nascondeva tutti i servizi senza 9784 * descrizione online configurata anche se prenotabili: ora la 9785 * descrizione fa fallback a `s.descrizione` lato render. Se `online` 9786 * è completamente assente (undefined/null), trattiamo il servizio 9787 * come prenotabile (default visibile) per non perdere voci dovute 9788 * a payload incompleti dal Planner. */ 9789 function _bkIsOnlineSrv(s){ 9790 if(!s)return false; 9791 var ol=s.online; 9792 if(ol==null||ol==="")return true; /* default: mostra */ 9793 if(ol===0||ol==="0"||ol===false||ol==="false")return false; 9794 if(ol===1||ol==="1"||ol===true||ol==="true")return true; 9795 if(typeof ol==="string"){ 9796 var t=ol.trim().toLowerCase(); 9797 if(/^(si|sì|s|y|yes|true|on|attivo|abilitato)$/.test(t))return true; 9798 if(/^(no|n|false|off|disattivo|disabilitato)$/.test(t))return false; 9799 } 9800 return true; /* in dubbio: mostra (l'utente vedrà eventuali errori a step successivi) */ 9801 } 9802 var srv=all.filter(_bkIsOnlineSrv); 9803 if(!srv.length){this._bkSetError({error:"Nessun servizio prenotabile online per "+p.descrizione+".",code:"no_services"},"selectProvincia");return;} 9804 this.bkServizi=srv; 9805 /* Smart: if user mentioned a specific service, try to auto-match */ 9806 if(this.bkHint){ 9807 var hint=this.bkHint; 9808 var scored=srv.map(function(s){return{s:s,score:matchService(s,hint)};}).filter(function(x){return x.score>0;}); 9809 /* prefer shorter names on tie (usually the "base" variant, e.g. "730" over "730 integrativo") */ 9810 scored.sort(function(a,b){ 9811 if(b.score!==a.score)return b.score-a.score; 9812 var nA=((a.s.desonline||a.s.descrizione)||"").length; 9813 var nB=((b.s.desonline||b.s.descrizione)||"").length; 9814 return nA-nB; 9815 }); 9816 /* AGGRESSIVE auto-pick: if top score >= 80, select it directly (user explicitly named the service) */ 9817 if(scored.length>=1&&scored[0].score>=80){ 9818 this._bkBusy=false; 9819 this._bkPickServ(scored[0].s); 9820 return; 9821 } 9822 /* weaker matches: filter the list to show only those with hint */ 9823 if(scored.length>0&&scored.length<srv.length){ 9824 this.bkServizi=scored.map(function(x){return x.s;}); 9825 } 9826 } 9827 this.bkStep="selectServizio";this._bkRender(); 9828 }catch(e){this._bkSetError(e,"selectProvincia");}finally{if(this._bkOpGen===_g)this._bkBusy=false;} 9829 }; 9830 9831 CafChat.prototype._bkPickServ = async function(s){ 9832 if(this._bkBusy)return;this._bkBusy=true;var _g=++this._bkOpGen;var p=this.bkSelProv;if(!p){this._bkBusy=false;return;} 9833 this.bkSelServ=s;this._bkLoadingReturnStep="selectServizio";this.bkStep="loading"; 9834 this._bkAutoProvStatus={type:"loading",msg:"Cerco le sedi per "+(s.desonline||s.descrizione||"il servizio")+"..."}; 9835 this._bkRender();bkHaptic(8); 9836 this._bkLogEvent&&this._bkLogEvent("servizio_picked"); 9837 var conn=this.bkSelRegione&&this.bkSelRegione.connessione; 9838 /* Cache sedi per conn+provincia+servizio (mirror della cache servizi): una 9839 * riselezione dello stesso servizio è istantanea invece di ripagare il 9840 * round-trip (+ retry Ã3) ad ogni click. Cache solo risultati non vuoti. */ 9841 var sediCacheKey="sedi:"+conn+":"+p.codice+":"+s.codice; 9842 var cachedSedi=bkCacheGet(sediCacheKey); 9843 var hasSediCache=Array.isArray(cachedSedi)&&cachedSedi.length>0; 9844 try{var sedi=hasSediCache?cachedSedi:await this._bkFetch("sedi",{codiceProvincia:p.codice,codiceServizio:s.codice,connessione:conn},{timeoutMs:15000}); 9845 if(this._bkOpGen!==_g)return; 9846 if(!sedi||!sedi.length){this._bkSetError({error:"Nessuna sede disponibile.",code:"no_sedi"},"selectServizio");return;} 9847 if(!hasSediCache)bkCacheSet(sediCacheKey,sedi); 9848 this.bkSedi=sedi;this.bkStep="selectSede";this._bkRender(); 9849 /* Prefetch slot per le top 3 sedi: quando l'utente clicca, lo slot panel 9850 * appare istantaneamente (cache hit) invece di aspettare il fetch. */
9851 try{this._bkPrefetchTopSlots(3);}catch(_e){} 9852 /* #7 â lazy-load geocoding: difendi il main thread schedulando il batch 9853 * geocode quando il browser è idle (fallback setTimeout). Non blocca il render. */ 9854 if(this._bkUserCoords){ 9855 var _self2=this;var _runGeo=function(){_self2._bkSortSediByDistanceInBg();}; 9856 if(typeof requestIdleCallback==="function")requestIdleCallback(_runGeo,{timeout:1500}); 9857 else setTimeout(_runGeo,200); 9858 } 9859 }catch(e){this._bkSetError(e,"selectServizio");}finally{if(this._bkOpGen===_g)this._bkBusy=false;} 9860 }; 9861 9862 /** Geocoda ogni sede in sequenza (per rispettare Nominatim ToS), 9863 * calcola distanza haversine, ordina e ri-renderizza. 9864 * Non blocca il render iniziale e gestisce silenziosamente gli errori. */ 9865 CafChat.prototype._bkSortSediByDistanceInBg = async function(){ 9866 if(!this._bkUserCoords||!this.bkSedi||!this.bkSedi.length)return; 9867 var self=this;var coords=this._bkUserCoords;var snap=this.bkSedi; 9868 /* Geocodiamo TUTTE le sedi (cap a 200 per coprire ogni regione IT). 9869 * Le sedi con coords backend (s.lat/lon) vanno SEMPRE prima e sono istantanee. */ 9870 var sortedSnap=snap.slice(0,200).slice().sort(function(a,b){ 9871 var ac=(a._lat!==undefined&&a._lon!==undefined)||(typeof a.lat==="number"&&typeof a.lon==="number")?0:1; 9872 var bc=(b._lat!==undefined&&b._lon!==undefined)||(typeof b.lat==="number"&&typeof b.lon==="number")?0:1; 9873 return ac-bc; 9874 }); 9875 var maxToGeocode=sortedSnap.length; 9876 for(var i=0;i<maxToGeocode;i++){ 9877 if(self.bkSedi!==snap||self.bkStep!=="selectSede")return; /* utente è andato avanti */ 9878 var sede=sortedSnap[i]; 9879 /* Salva lo stato PRIMA della call: se aveva già coords, _bkGeocodeSede 9880 * non hitta Nominatim â niente rate-limit necessario. */ 9881 var hadCoords=(sede._lat!==undefined&&sede._lon!==undefined) 9882 ||(typeof sede.lat==="number"&&typeof sede.lon==="number"); 9883 try{ 9884 var loc=await self._bkGeocodeSede(sede); 9885 if(loc&&typeof loc.lat==="number"&&typeof loc.lon==="number"){ 9886 /* Riusa _distKm (nome usato anche da _bkFindNearestSede) per coerenza con render */ 9887 sede._distKm=self._bkHaversineKm(coords.lat,coords.lon,loc.lat,loc.lon); 9888 } 9889 }catch(_e){} 9890 /* Rate-limit Nominatim ~1 req/sec. Skippato se backend ha già coords. */ 9891 if(!hadCoords)await new Promise(function(r){setTimeout(r,150);}); 9892 } 9893 if(self.bkSedi!==snap||self.bkStep!=="selectSede")return; 9894 /* #20 â NON ri-ordiniamo le tile sotto il dito (causava mis-tap: l'ordine 9895 * cambiava mentre l'utente stava per cliccare). Questo è il sort PASSIVO di 9896 * background (post-scelta servizio): calcoliamo solo la distanza minima per 9897 * marcare col badge la sede più vicina e mostrare le distanze in loco, 9898 * LASCIANDO INVARIATE le posizioni. Il sort esplicito resta in 9899 * _bkFindNearestSede, dove l'utente clicca "Trova la sede più vicina" e si 9900 * ASPETTA il riordino. */ 9901 var minKm=Infinity,nearestCode=null; 9902 for(var si=0;si<self.bkSedi.length;si++){ 9903 var dk=self.bkSedi[si]._distKm; 9904 if(typeof dk==="number"&&dk<minKm){minKm=dk;nearestCode=self.bkSedi[si].codice;} 9905 } 9906 if(nearestCode)self._bkNearestCode=nearestCode; 9907 if(self._bkFsMode)self._bkRenderFs();else self._bkRender(); 9908 }; 9909 9910 CafChat.prototype._bkPickSede = async function(sede,opts){ 9911 if(this._bkBusy)return;this._bkBusy=true;var _g=++this._bkOpGen;var p=this.bkSelProv,s=this.bkSelServ; 9912 if(!p||!s){this._bkBusy=false;return;}this.bkSelSede=sede;this._bkLoadingReturnStep="selectSede";this.bkStep="loading"; 9913 this._bkAutoProvStatus={type:"loading",msg:"Cerco gli orari disponibili presso "+sede.descrizione+"..."}; 9914 this._bkRender();bkHaptic(8); 9915 /* opts.internalRefetch: riuso interno (es. refresh slot dopo slot_taken) â 9916 * l'utente NON ha ri-scelto la sede, niente doppio evento funnel. */ 9917 if(!(opts&&opts.internalRefetch))this._bkLogEvent&&this._bkLogEvent("sede_picked"); 9918 var conn=this.bkSelRegione&&this.bkSelRegione.connessione; 9919 /* Usa eventuale prefetch già in volo (avviato post _bkPickServ). 9920 * Consumalo (delete): un secondo click sulla stessa sede dopo go-back 9921 * deve re-fetchare slot freschi (gli orari potrebbero essere stati presi). */ 9922 var pf=this._bkSlotPrefetch&&this._bkSlotPrefetch[sede.codice]; 9923 if(pf&&this._bkSlotPrefetch)delete this._bkSlotPrefetch[sede.codice]; 9924 var fetchP=pf||this._bkFetch("spazi",{codProv:p.codice,codSede:sede.codice,codServizio:s.codice,connessione:conn},{timeoutMs:15000}); 9925 try{var r=await fetchP; 9926 /* Annullato (o superato da una nuova selezione) mentre attendevamo un 9927 * prefetch in background? Non saltare in avanti. */ 9928 if(this._bkOpGen!==_g)return; 9929 /* Il prefetch slot è best-effort (budget ridotto) e la sua .catch risolve
9930 * a NULL se è fallito/scaduto. NON fidarci ciecamente del risultato del 9931 * prefetch: se è null facciamo un fetch FRESCO a 15s. Così una sede con 9932 * endpoint lento (8-15s) viene comunque servita â evita sia il TypeError 9933 * su null sia una regressione di affidabilità rispetto a prima. */ 9934 if(r==null){ 9935 r=await this._bkFetch("spazi",{codProv:p.codice,codSede:sede.codice,codServizio:s.codice,connessione:conn},{timeoutMs:15000}); 9936 if(this._bkOpGen!==_g)return; 9937 } 9938 var sp=Array.isArray(r)?r:(r.spazi||[]);var g=(typeof r==="object"&&!Array.isArray(r))?(r.giorno||""):""; 9939 if(!sp.length){this._bkSetError({error:"Nessun appuntamento disponibile presso "+sede.descrizione+" per "+s.descrizione+". Prova un'altra sede o riprova più tardi.",code:"no_spazi"},"selectSede");return;} 9940 this.bkSpazi=sp;this.bkNextG=g; 9941 /* PIEMONTE: drena TUTTA la disponibilità PRIMA di mostrare il calendario 9942 * (resta visibile lo spinner "Cerco gli orariâ¦"), così lo step DATA e ORA
9943 * appare con TUTTI i mesi disponibili già caricati e navigabili e SENZA il 9944 * bottone "Carica altri orari". Awaited dentro il flusso di _bkPickSede: 9945 * _bkBusy/_bkOpGen gestiscono la concorrenza; _g abortisce se l'utente 9946 * cambia selezione. Copre anche il resume (passa da _bkPickSede). */ 9947 if(this.opts&&this.opts.piemonte&&this.bkNextG){ 9948 await this._bkDrainAllSpazi(_g,p,s,sede,conn); 9949 if(this._bkOpGen!==_g)return; 9950 } 9951 this.bkStep="selectSlot";this._bkRender(); 9952 }catch(e){this._bkSetError(e,"selectSede");}finally{if(this._bkOpGen===_g)this._bkBusy=false;} 9953 }; 9954 9955 /** Prefetch slot per le top-N sedi (chiamato dopo _bkPickServ) â non blocca. */ 9956 CafChat.prototype._bkPrefetchTopSlots = function(maxN){ 9957 var p=this.bkSelProv,s=this.bkSelServ,conn=this.bkSelRegione&&this.bkSelRegione.connessione; 9958 if(!p||!s||!this.bkSedi||!this.bkSedi.length)return; 9959 var self=this;this._bkSlotPrefetch=this._bkSlotPrefetch||{}; 9960 var n=Math.min(maxN||3,this.bkSedi.length); 9961 for(var i=0;i<n;i++){ 9962 var sede=this.bkSedi[i];if(!sede||self._bkSlotPrefetch[sede.codice])continue; 9963 (function(sd){ 9964 self._bkSlotPrefetch[sd.codice]=self._bkFetch("spazi",{codProv:p.codice,codSede:sd.codice,codServizio:s.codice,connessione:conn},{background:true,timeoutMs:8000}) 9965 .catch(function(){delete self._bkSlotPrefetch[sd.codice];return null;}); 9966 })(sede); 9967 } 9968 }; 9969 9970 CafChat.prototype._bkLoadMore = async function(){ 9971 var self=this,p=self.bkSelProv,s=self.bkSelServ,sede=self.bkSelSede; 9972 if(self._bkBusy||self._bkLoadingMore||!p||!s||!sede||!self.bkNextG)return; 9973 self._bkBusy=true; 9974 /* Feedback immediato sul bottone "Cerca altri orari": flag letto dal render 9975 * (spinner + disabled) così il click non sembra inerte durante il fetch. */ 9976 self._bkLoadingMore=true;self._bkRender(); 9977 var conn=self.bkSelRegione&&self.bkSelRegione.connessione; 9978 try{var r=await self._bkFetch("spazi",{codProv:p.codice,codSede:sede.codice,codServizio:s.codice,giornoInizio:self.bkNextG,connessione:conn},{timeoutMs:15000}); 9979 var ns=Array.isArray(r)?r:(r.spazi||[]);var g=(typeof r==="object"&&!Array.isArray(r))?(r.giorno||""):""; 9980 if(!ns.length){self.bkNextG="";self._bkLoadingMore=false;self._bkRender();return;} 9981 self.bkSpazi=self.bkSpazi.concat(ns);self.bkNextG=g;self._bkLoadingMore=false;self._bkRender(); 9982 }catch(e){self.bkNextG="";self._bkLoadingMore=false;self._bkRender();}finally{self._bkBusy=false;self._bkLoadingMore=false;} 9983 }; 9984 9985 /** PIEMONTE only â drena TUTTA la paginazione degli spazi (getspazi è 9986 * cursor-based: bkNextG = prossimo giorno da chiedere; finisce quando il 9987 * Planner ritorna giorno/spazi vuoti). Accumula in bkSpazi finché il cursore 9988 * non si esaurisce. à chiamata e ATTESA dentro _bkPickSede mentre è ancora 9989 * visibile lo spinner "Cerco gli orariâ¦", quindi quando appare lo step DATA e 9990 * ORA TUTTI i mesi disponibili sono già caricati e navigabili (niente bottone 9991 * "Carica altri orari"). à ciò che l'utente farebbe cliccando il bottone fino 9992 * in fondo, automatizzato e front-loaded. 9993 * 9994 * Concorrenza: nessuno stato proprio; gira dentro il flusso awaited di 9995 * _bkPickSede (che tiene _bkBusy e possiede la generazione _g). Il controllo 9996 * `_bkOpGen!==genAtStart` DOPO ogni await + la guardia `bkSelSede===sede` 9997 * fermano il drain senza scrivere se l'utente cambia selezione. Su errore di 9998 * rete interrompe lasciando bkNextG valorizzato â resta il fallback manuale. */ 9999 CafChat.prototype._bkDrainAllSpazi = async function(genAtStart,p,s,sede,conn){ 10000 var self=this;var guard=0,MAX=60; /* backstop anti-loop: orizzonte Planner limitato (~4 mesi, ~20-25 req) */ 10001 while(self.bkNextG&&self._bkOpGen===genAtStart&&self.bkSelSede===sede&&guard++<MAX){ 10002 var r; 10003 try{r=await self._bkFetch("spazi",{codProv:p.codice,codSede:sede.codice,codServizio:s.codice,giornoInizio:self.bkNextG,connessione:conn},{timeoutMs:15000});} 10004 catch(e){break;} /* errore di rete: tieni ciò che si ha + fallback manuale */ 10005 if(self._bkOpGen!==genAtStart)return; /* superato da una nuova selezione: non scrivere */ 10006 var ns=Array.isArray(r)?r:(r.spazi||[]); 10007 var g=(typeof r==="object"&&!Array.isArray(r))?(r.giorno||""):""; 10008 if(!ns.length){self.bkNextG="";break;}
10009 self.bkSpazi=self.bkSpazi.concat(ns);self.bkNextG=g; 10010 } 10011 }; 10012 10013 CafChat.prototype._bkPickSlot = function(sp){ 10014 /* Nuovo slot = nuova intenzione di prenotazione â chiave idempotenza fresca 10015 * (la dedup vale solo per i retry dello STESSO slot). */ 10016 this._bkSubmitReqId=null; 10017 this.bkSelSlot=sp;this.bkStep="collectData"; 10018 /* Avvia il promemoria ORIENTATIVO "completa entro" (UI-only, 5 min: 10019 * nessun hold reale lato server, quindi nessun claim di slot bloccato). */ 10020 this._bkSlotLockedAt=Date.now();this._bkSlotLockMs=5*60*1000; 10021 this._bkRender(); 10022 this._bkLogEvent&&this._bkLogEvent("slot_picked"); 10023 }; 10024 10025 /** Crea/aggiorna il banner countdown ORIENTATIVO "Ti consigliamo di 10026 * completare entro X:YY" (a11y #C57: il vecchio "Slot bloccato"/"Tempo 10027 * scaduto" era un claim falso â nessun hold server-side). Idempotente: 10028 * pulisce eventuali interval precedenti tramite this._bkSlotTimerId. */ 10029 CafChat.prototype._bkRenderSlotTimer = function(container){ 10030 if(!this._bkSlotLockedAt||!this._bkSlotLockMs)return; 10031 var self=this; 10032 if(this._bkSlotTimerId){clearInterval(this._bkSlotTimerId);this._bkSlotTimerId=null;} 10033 var banner=document.createElement("div"); 10034 banner.style.cssText="display:flex;align-items:center;gap:8px;background:linear-gradient(90deg,#fef3c7,#fde68a);border:1px solid #f59e0b;border-radius:10px;padding:8px 12px;margin-bottom:10px;font-size:12px;color:#92400e;font-weight:600"; 10035 var ic=document.createElement("span");ic.style.cssText="display:inline-flex";ic.innerHTML='<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>'; 10036 banner.appendChild(ic); 10037 var txt=document.createElement("span");txt.style.cssText="flex:1";banner.appendChild(txt); 10038 container.appendChild(banner); 10039 function tick(){ 10040 var remaining=self._bkSlotLockMs-(Date.now()-self._bkSlotLockedAt); 10041 if(remaining<=0){ 10042 txt.textContent=bkT("hold_expired"); 10043 banner.style.background="#fee2e2";banner.style.borderColor="#ef4444";banner.style.color="#7f1d1d"; 10044 if(self._bkSlotTimerId){clearInterval(self._bkSlotTimerId);self._bkSlotTimerId=null;} 10045 self._bkLogEvent&&self._bkLogEvent("slot_timer_expired"); 10046 return; 10047 } 10048 var mm=Math.floor(remaining/60000);var ss=Math.floor((remaining%60000)/1000); 10049 txt.textContent=bkT("hold_hint",mm+":"+(ss<10?"0"+ss:ss)); 10050 } 10051 tick();this._bkSlotTimerId=setInterval(tick,1000); 10052 }; 10053 10054 CafChat.prototype._bkConfirm = function(){ 10055 var d=this.bkClient; 10056 /* trim to avoid leading/trailing spaces being accepted silently */ 10057 d.nome=(d.nome||"").trim();d.cognome=(d.cognome||"").trim(); 10058 d.codfis=(d.codfis||"").trim().toUpperCase(); 10059 d.email=(d.email||"").trim();d.cellulare=(d.cellulare||"").trim(); 10060 if(!d.nome||!d.cognome||!d.codfis||!d.email||!d.cellulare){this.bkFormErr=bkT("err_required_fields");this._bkRender();return;} 10061 var cfChk=validateCodiceFiscale(d.codfis); 10062 if(!cfChk.ok){this.bkFormErr=cfChk.reason;this._bkRender();return;} 10063 if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(d.email)){this.bkFormErr=bkT("err_email_invalid");this._bkRender();return;} 10064 var digits=d.cellulare.replace(/\D/g,""); 10065 if(digits.length<9||digits.length>15){this.bkFormErr=bkT("err_phone_invalid");this._bkRender();return;} 10066 this.bkFormErr=null;this.bkStep="confirm";this._bkRender(); 10067 /* Funnel event: dati raccolti correttamente */ 10068 this._bkLogEvent&&this._bkLogEvent("data_collected"); 10069 }; 10070 10071 /* ââ Privacy consent gate (checkbox obbligatoria a fine prenotazione) ââ 10072 * Costruisce un blocco checkbox "molto chiaro" con link all'informativa, 10073 * condiviso fra la confirm compatta (_bkRender) e quella fullscreen 10074 * (_bkRenderFsImpl). Legge/scrive this.bkPrivacyOk e notifica il chiamante 10075 * via onToggle(ok) così che possa abilitare/disabilitare il bottone Conferma. 10076 * Lo stato è azzerato a ogni nuova prenotazione in _bkReset â consenso fresco. */ 10077 CafChat.prototype._bkBuildConsentBox = function(onToggle){ 10078 var self=this,c=this.opts.color; 10079 var privacyUrl=(this.opts&&this.opts.privacyUrl)||""; 10080 try{var sct=document.querySelector("script[data-privacy-url]");
10080if(sct&&!privacyUrl)privacyUrl=sct.getAttribute("data-privacy-url")||"";}catch(_e){} 10081 /* Niente fallback hardcoded cross-titolare: senza PRIVACY_URL il link 10082 * apre il pannello privacy in-widget (GDPR artt. 7, 13); hardening: 10083 * solo http(s). */ 10084 if(privacyUrl&&!/^https?:\/\//i.test(privacyUrl))privacyUrl=""; 10085 var on=!!this.bkPrivacyOk; 10086 var box=document.createElement("div"); 10087 box.className="caf-bk-consent";box.setAttribute("data-caf-consent","1"); 10088 box.style.cssText="display:flex;align-items:flex-start;gap:10px;margin:14px auto 6px;max-width:680px;padding:12px 14px;background:"+(on?(c+"0f"):"#f8fafc")+";border:1.5px solid "+(on?c:"#e2e8f0")+";border-radius:12px;font-size:12.5px;line-height:1.5;color:#475569;transition:border-color .2s,background .2s"; 10089 var cb=document.createElement("input");cb.type="checkbox";cb.className="caf-bk-consent-cb";cb.checked=on;cb.setAttribute("aria-required","true");cb.setAttribute("aria-label",bkT("consent_before")+bkT("consent_link")+bkT("consent_after")); 10090 cb.style.cssText="width:19px;height:19px;min-width:19px;margin-top:1px;accent-color:"+c+";cursor:pointer"; 10091 var txt=document.createElement("div");txt.style.cursor="pointer"; 10092 txt.innerHTML=escapeHtml(bkT("consent_before")) 10093 +(privacyUrl 10094 ? '<a href="'+escapeHtml(privacyUrl.replace(/"/g,"%22"))+'" target="_blank" rel="noopener noreferrer" style="color:var(--caf-c-ink);text-decoration:underline;font-weight:600">'+escapeHtml(bkT("consent_link"))+'</a>' 10095 : '<a href="#" class="caf-bk-consent-open-panel" role="button" style="color:var(--caf-c-ink);text-decoration:underline;font-weight:600;cursor:pointer">'+escapeHtml(bkT("consent_link"))+'</a>') 10096 +escapeHtml(bkT("consent_after")) 10097 +' <span aria-hidden="true" style="color:#be123c;font-weight:700">*</span>'; 10098 function fire(){ 10099 self.bkPrivacyOk=!!cb.checked; 10100 box.style.borderColor=cb.checked?c:"#e2e8f0"; 10101 box.style.background=cb.checked?(c+"0f"):"#f8fafc"; 10102 self._bkLogEvent&&self._bkLogEvent("privacy_consent",{checked:!!cb.checked}); 10103 if(typeof onToggle==="function")onToggle(!!cb.checked); 10104 } 10105 cb.addEventListener("change",fire); 10106 txt.addEventListener("click",function(e){if(e.target&&e.target.tagName==="A")return;cb.checked=!cb.checked;fire();}); 10107 var _opl=txt.querySelector(".caf-bk-consent-open-panel"); 10108 if(_opl){_opl.addEventListener("click",function(e){e.preventDefault();e.stopPropagation();try{self._bkShowPrivacyPanel();}catch(_e){}});} 10109 box.appendChild(cb);box.appendChild(txt); 10110 return box; 10111 }; 10112 10113 CafChat.prototype._bkSubmit = async function(){ 10114 if(this._bkBusy)return; 10115 /* Safety net: nessuna prenotazione senza consenso esplicito al trattamento 10116 * dati. La UI già disabilita il bottone, ma questo blocca anche un eventuale 10117 * trigger programmatico/forzato. */ 10118 if(!this.bkPrivacyOk){this._bkShowToast&&this._bkShowToast({type:"error",msg:bkT("consent_required")});return;} 10119 this._bkBusy=true;var sp=this.bkSelSlot;if(!sp){this._bkBusy=false;return;} 10120 /* clientRequestId STABILE per questo tentativo di conferma, riusato se 10121 * l'utente riprova manualmente lo STESSO slot. ATTENZIONE: è inviato nel 10122 * body MA il server NON lo deduplica ancora (idempotenza server-side da 10123 * implementare). Oggi la protezione anti-doppione reale è: (a) niente 10124 * auto-retry su prenota (noRetry), (b) il gestionale che risponde 10125 * "già prenotato" sullo stesso slot. Azzerato a conferma riuscita / nuovo flusso. */ 10126 if(!this._bkSubmitReqId)this._bkSubmitReqId=bkUuid(); 10127 this.bkStep="submitting";this._bkRender(); 10128 try{var d=this.bkClient; 10129 /* Client payload: 5 fields the chatbot collects. The Next.js route 10130 (saveBooking) appends update:"1" before forwarding to the Planner so 10131 that an existing codfis gets nome/cognome/email/cellulare refreshed 10132 (doc Rest Service rev 1.13). 10133 Additional fields (provincia/servizio/sede/slot/...) are NOT sent to 10134 the Planner â they are passed through to the server-side audit logger. */ 10135 var pageUrl="";try{pageUrl=(typeof location!=="undefined"&&location.href)||"";}catch(_e){} 10136 var ua="";try{ua=(typeof navigator!=="undefined"&&navigator.userAgent)||"";}catch(_e){} 10137 var sid="";
10137try{sid=(this._sessionId||(this.opts&&this.opts.sessionId)||"");}catch(_e){} 10138 var r=await this._bkFetch("prenota",{ 10139 idSpazio:sp.id_spazio, 10140 connessione:this.bkSelRegione&&this.bkSelRegione.connessione, 10141 clientData:{ 10142 codfis:(d.codfis||"").toUpperCase(), 10143 nome:d.nome, 10144 cognome:d.cognome, 10145 email:d.email, 10146 cellulare:d.cellulare 10147 }, 10148 source:"widget-inline", 10149 regione:this.bkSelRegione?{ 10150 connessione:this.bkSelRegione.connessione, 10151 codiceRegione:this.bkSelRegione.codiceRegione, 10152 descrizione:this.bkSelRegione.descrizione 10153 }:null, 10154 provincia:this.bkSelProv?{codice:this.bkSelProv.codice,descrizione:this.bkSelProv.descrizione}:null, 10155 servizio:this.bkSelServ?{ 10156 codice:this.bkSelServ.codice,descrizione:this.bkSelServ.descrizione, 10157 desonline:this.bkSelServ.desonline,tempo:this.bkSelServ.tempo, 10158 documenti:this.bkSelServ.documenti,online:this.bkSelServ.online 10159 }:null, 10160 sede:this.bkSelSede?{ 10161 codice:this.bkSelSede.codice,descrizione:this.bkSelSede.descrizione, 10162 indirizzo:this.bkSelSede.indirizzo,cap:this.bkSelSede.cap,prov:this.bkSelSede.prov, 10163 citta:this.bkSelSede.citta,telefono:this.bkSelSede.telefono,email:this.bkSelSede.email 10164 }:null, 10165 slot:{id_spazio:sp.id_spazio,data:sp.data,ora_inizio:sp.ora_inizio,ora_fine:sp.ora_fine,sportello_id:sp.sportello_id}, 10166 context:{pageUrl:pageUrl,sessionId:sid,userAgent:ua}, 10167 clientRequestId:this._bkSubmitReqId 10168 },{noRetry:true}); 10169 /* prenota NON è idempotente: nessun retry automatico (un timeout-poi-retry 10170 * creava una SECONDA prenotazione). Su timeout mostriamo "esito incerto". */ 10171 this.bkId=(r&&r.id)?r.id:null;this.bkStep="done";this._bkSubmitReqId=null;this._bkRender();/* WCAG 4.1.3 (#C32): annuncio dentro il dialog aria-modal */this._bkFsAnnounce(bkT("booking_confirmed")+(this.bkId?(" "+bkT("label_done_id")+this.bkId):""));this._updateSendBtn(); 10172 /* #42 â il consenso DEVE essere già stato espresso prima di arrivare 10173 * qui (chiediamo all'apertura widget). Se per qualche motivo non c'è, 10174 * proviamo un'ultima volta. saveStoredClient è interno-gated, fa no-op 10175 * senza consenso "functional". */ 10176 var self=this; 10177 Promise.resolve().then(function(){return self._bkRequestConsent();}).then(function(consent){ 10178 if(consent&&consent.functional){saveStoredClient(d,self.bkBookingFor);} 10179 }); 10180 if(!this.opts.demopromo)pushBookingHistory({bookingId:this.bkId,slot:this.bkSelSlot,servizio:this.bkSelServ,sede:this.bkSelSede,provincia:this.bkSelProv,regione:this.bkSelRegione}); 10181 clearResumeState(); /* prenotazione completata: niente resume */ 10182 this._bkLogEvent&&this._bkLogEvent("submit_success",{bookingId:this.bkId,for:this.bkBookingFor}); 10183 }catch(e){ 10184 var code=(e&&e.code)||"unknown"; 10185 this._bkLogEvent&&this._bkLogEvent("submit_failure",{errorCode:code,errorMessage:(e&&(e.error||e.message))||""}); 10186 if(code==="timeout"||code==="network"||code==="server"||code==="unknown"||code==="500"||code==="502"||code==="503"||code==="504"){ 10187 /* Esito INCERTO: la richiesta potrebbe aver raggiunto il gestionale. 10188 * Include "server"/5xx: il server li classifica come incerti (route.ts 10189 * salva bookingStatus:"uncertain") perché la prenotazione PUà esistere 10190 * (es. HTTP 200 con messaggio non riconosciuto). NON trattarli come 10191 * errore definitivo â eviterebbe un secondo invio e un possibile doppione. 10192 * NON resettiamo _bkSubmitReqId (riusabile da un'eventuale dedup server 10193 * futura). NB: la dedup server NON è ancora implementata â se l'utente 10194 * riprova manualmente e il primo invio era andato a buon fine, il 10195 * gestionale lo blocca con "già prenotato". Per questo invitiamo a 10196 * controllare l'email PRIMA di riprovare. */ 10197 this._bkSetError({error:bkT("err_uncertain"),code:code},"confirm"); 10198 }else{ 10199 /* Errore definitivo (già prenotato / slot occupato / validazione / 4xx): 10200 * nessun doppione possibile â azzeriamo l'id così un nuovo flusso parte pulito. */ 10201 this._bkSubmitReqId=null; 10202 if(code==="slot_taken"){ 10203 /* cod 210: lo slot è stato preso da un altro cliente tra la scelta e il 10204 * save (il server ha già tentato il recupero per escludere il caso 10205 * "occupato da me stesso"). Svuotiamo gli spazi così il rientro su 10206 * DATA e ORA li ri-fetcha freschi invece di riproporre lo slot morto.
10207 * Azzeriamo anche lo stato calendario: la disponibilità fresca può 10208 * essere slittata a un altro giorno/mese â la vista si riallinea 10209 * alla prima disponibilità invece di mostrare un mese vuoto. */ 10210 this.bkSelSlot=null;this.bkSpazi=[];this.bkNextG=""; 10211 this._bkCalSelected=null;this._bkCalMonth=null; 10212 } 10213 this._bkSetError(e,(code==="already_booked"||code==="slot_taken")?"selectSlot":"confirm"); 10214 } 10215 } 10216 finally{this._bkBusy=false;} 10217 }; 10218 10219 /** Salta direttamente a uno step della prenotazione (usato dai click 10220 * sulla sidebar). Resetta solo le selezioni dello step e dei successivi 10221 * preservando quelle precedenti. Annulla anche l'auto-jump GPS in corso. */ 10222 CafChat.prototype._bkJumpToStep = function(step){ 10223 /* Cancella qualunque auto-jump GPS in arrivo: la scelta manuale dell'utente 10224 * deve avere la precedenza. */ 10225 this._bkUserOverride=true; 10226 this._bkAutoProvStatus=null; 10227 if(this._bkAutoProvWatchdog){clearTimeout(this._bkAutoProvWatchdog);this._bkAutoProvWatchdog=null;} 10228 /* invalida ogni _bkPick* in volo prima di resettare le selezioni a valle */
10229 this._bkInvalidateInflight(); 10230 /* Reset dello step puntato + tutti quelli a valle */ 10231 this.bkFormErr=null;this._bkVoiceAnnounced=null;this._bkDir="back"; 10232 /* track which sidebar items have already animated, so re-rendering after 10233 * jump non rifa l'animazione di "just-completed" su ciò che non cambia */ 10234 switch(step){ 10235 case "selectRegione": 10236 this.bkSelRegione=null;this.bkProvince=[];this.bkSelProv=null; 10237 this.bkServizi=[];this.bkSelServ=null;this.bkSedi=[];this.bkSelSede=null; 10238 this.bkSpazi=[];this.bkSelSlot=null; 10239 this.bkStep="selectRegione"; 10240 if(this._bkSideFilled){this._bkSideFilled.reg=false;this._bkSideFilled.prov=false;this._bkSideFilled.serv=false;this._bkSideFilled.sede=false;this._bkSideFilled.slot=false;} 10241 break; 10242 case "selectProvincia": 10243 if(!this.bkSelRegione){this.bkStep="selectRegione";break;} 10244 this.bkSelProv=null;this.bkServizi=[];this.bkSelServ=null; 10245 this.bkSedi=[];this.bkSelSede=null;this.bkSpazi=[];this.bkSelSlot=null; 10246 this.bkStep="selectProvincia"; 10247 if(this._bkSideFilled){this._bkSideFilled.prov=false;this._bkSideFilled.serv=false;this._bkSideFilled.sede=false;this._bkSideFilled.slot=false;} 10248 break; 10249 case "selectServizio": 10250 if(!this.bkSelProv){this.bkStep=this.bkSelRegione?"selectProvincia":"selectRegione";break;} 10251 this.bkSelServ=null;this.bkSedi=[];this.bkSelSede=null; 10252 this.bkSpazi=[];this.bkSelSlot=null; 10253 if(this._bkSideFilled){this._bkSideFilled.serv=false;this._bkSideFilled.sede=false;this._bkSideFilled.slot=false;} 10254 /* Se i servizi non sono già in cache (caso raro), ricarica via 10255 * _bkPickProv che li fetcha e popola bkServizi. 10256 * Cancelliamo bkHint per evitare l'auto-pick aggressivo. */ 10257 if(!this.bkServizi||!this.bkServizi.length){ 10258 this.bkHint=null;this._bkBusy=false; 10259 this._bkPickProv(this.bkSelProv); 10260 return; 10261 } 10262 this.bkStep="selectServizio"; 10263 break; 10264 case "selectSede": 10265 if(!this.bkSelServ){this.bkStep=this.bkSelProv?"selectServizio":"selectProvincia";break;} 10266 this.bkSelSede=null;this.bkSpazi=[];this.bkSelSlot=null; 10267 this.bkStep="selectSede"; 10268 if(this._bkSideFilled){this._bkSideFilled.sede=false;this._bkSideFilled.slot=false;} 10269 break; 10270 case "selectSlot": 10271 if(!this.bkSelSede){this.bkStep=this.bkSelServ?"selectSede":"selectServizio";break;} 10272 this.bkSelSlot=null; 10273 this.bkStep="selectSlot"; 10274 if(this._bkSideFilled){this._bkSideFilled.slot=false;} 10275 break; 10276 case "collectData": 10277 if(!this.bkSelSlot){this.bkStep=this.bkSelSede?"selectSlot":"selectSede";break;} 10278 this.bkStep="collectData"; 10279 break; 10280 default:return; 10281 } 10282 this._bkRender(); 10283 }; 10284 10285 /* #60 â propone un banner inline "Riprendi da dove hai lasciato?". 10286 * Ritorna Promise<bool> = true se l'utente ha scelto di riprendere 10287 * (in tal caso lo stato viene ripristinato e il render avviene). */ 10288 CafChat.prototype._bkOfferResume = function(rs){ 10289 var self=this; 10290 return new Promise(function(resolve){ 10291 var container=self._bkFsEl;if(!container){resolve(false);return;} 10292 /* Pulisci eventuali precedenti */ 10293 var prev=container.querySelector(".caf-bk-resume");if(prev)prev.parentNode.removeChild(prev); 10294 var stepLabels={selectRegione:bkT("step_zone"),selectProvincia:bkT("step_zone"),selectServizio:bkT("step_service"),selectSede:bkT("step_office"),selectSlot:bkT("step_time"),collectData:bkT("step_data"),confirm:bkT("h_confirm")}; 10295 var lbl=stepLabels[rs.step]||bkT("label_resume_h"); 10296 var detail=[]; 10297 if(rs.provincia&&rs.provincia.descrizione)detail.push(rs.provincia.descrizione); 10298 if(rs.servizio&&(rs.servizio.desonline||rs.servizio.descrizione))detail.push(rs.servizio.desonline||rs.servizio.descrizione); 10299 var b=document.createElement("div");b.className="caf-bk-resume"; 10300 b.innerHTML='<div class="caf-bk-resume-ic">â¶</div>' 10301 +'<div class="caf-bk-resume-body"><div class="caf-bk-resume-h">'+escapeHtml(bkT("label_resume_h"))+'</div>' 10302 +'<div>'+escapeHtml(lbl)+(detail.length?" â "+escapeHtml(detail.join(" · ")):"")+'</
10302div></div>' 10303 +'<div class="caf-bk-resume-actions">' 10304 +'<button type="button" class="caf-bk-resume-btn primary" data-act="resume">'+escapeHtml(bkT("label_resume_btn"))+'</button>' 10305 +'<button type="button" class="caf-bk-resume-btn ghost" data-act="discard">'+escapeHtml(bkT("label_resume_discard"))+'</button>' 10306 +'</div>'; 10307 container.appendChild(b); 10308 var done=function(answer){ 10309 try{b.parentNode&&b.parentNode.removeChild(b);}catch(_e){} 10310 resolve(answer); 10311 }; 10312 b.querySelector("[data-act=resume]").addEventListener("click",function(){ 10313 /* 1) Rimuovi banner + risolvi promise SUBITO (cosi _bkStart esce e 10314 * libera _bkBusy). Senza questo, _bkPickProv sotto fallirebbe il 10315 * check `if(_bkBusy)return` perché _bkStart è ancora busy. */ 10316 done(true); 10317 self._bkLogEvent&&self._bkLogEvent("resume_accepted",{step:rs.step}); 10318 /* 2) Mostra subito un loading con messaggio dedicato così l'utente 10319 * capisce che stiamo riprendendo (senza schermata vuota). */ 10320 self.bkStep="loading"; 10321 self._bkResumeLoading={ 10322 msg:bkT("label_resume_h")+"..." 10323 }; 10324 self._bkRender(); 10325 /* 3) Esegui la cascata in modo asincrono. Tra ogni step lasciamo 10326 * sliding via _bkPickXxx che gestiscono già bkBusy/render/error. */ 10327 (async function(){ 10328 /* Aspetta un microtask per essere sicuri che _bkStart abbia liberato 10329 * _bkBusy=false. Senza questo, _bkPickProv torna immediatamente. */ 10330 await new Promise(function(r){setTimeout(r,0);}); 10331 /* Cascata di resume ANNULLABILE: il bottone "Annulla" sullo step 10332 * loading (Group A) torna a selectRegione abortendo la fetch in corso, 10333 * invece di lasciare l'utente su uno spinner fino a decine di secondi. */ 10334 self._bkLoadingReturnStep="selectRegione"; 10335 try{ 10336 /* Pre-popola i dati base PRIMA delle chiamate API: così se 10337 * qualcuna fallisce, la rete di sicurezza ha valori sensati. */ 10338 /* #PII â resume cross-sessione su device condiviso: i dati cliente 10339 * salvati NON entrano direttamente in bkClient (eviterebbero il gate). 10340 * Diventano "candidato": il gate "Sei tu?" a collectData li adotta solo 10341 * su conferma esplicita. */ 10342 if(rs.client){self._bkStoredCandidate=rs.client;self.bkClient={nome:"",cognome:"",codfis:"",email:"",cellulare:"",sesso:"M"};} 10343 if(rs.bookingFor){self.bkBookingFor=rs.bookingFor;} 10344 if(rs.regione){self.bkSelRegione=rs.regione;} 10345 /* Carica regioni se mancano. Necessario se l'utente apre la chat 10346 * da fresh load: bkRegioni è vuoto. ALSO populate _bkProvinciaByConn 10347 * per evitare un fetch in più dentro _bkPickRegione subito dopo. */ 10348 if(!self.bkRegioni||!self.bkRegioni.length){ 10349 var allFetched=null; 10350 try{allFetched=await self._bkFetchAllProvince();}catch(_e){allFetched=null;} 10351 if(!allFetched||!allFetched.regioni||!allFetched.regioni.length){ 10352 /* Nessuna regione disponibile: errore esplicito invece di lista vuota silenziosa */ 10353 throw new Error("regioni non disponibili dopo resume"); 10354 } 10355 self.bkRegioni=allFetched.regioni; 10356 /* Popola _bkProvinciaByConn (come fa _bkStart) così _bkPickRegione 10357 * non rifa la stessa fetch dopo. Cache hit per la regione coperta 10358 * da allFetched.items (su deploy ZONA = la regione preselezionata); 10359 * un resume di una regione diversa (stato stale) fa miss e ricade 10360 * sulla fetch on-demand di _bkPickRegione poco sotto. */ 10361 self._bkProvinciaByConn={}; 10362 if(allFetched.items&&allFetched.items.length){ 10363 allFetched.items.forEach(function(it){ 10364 var k=it.regione&&it.regione.connessione;if(!k)return; 10365 if(!self._bkProvinciaByConn[k])self._bkProvinciaByConn[k]=[]; 10366 self._bkProvinciaByConn[k].push(it.provincia); 10367 }); 10368 } 10369 } 10370 /* Regioni pronte: liberiamo la caption statica "Riprendiâ¦" così le 10371 * caption PER-STEP dei _bkPickXxx ("Cerco i serviziâ¦", "Cerco le 10372 * sediâ¦", "Cerco gli orariâ¦") diventano visibili durante la cascata, 10373 * invece di uno spinner congelato (#6). */
10374 self._bkResumeLoading=null; 10375 /* Step "selectRegione": le regioni sono ora popolate sopra (o abbiamo 10376 * thrown). Setta solo lo step e renderizza. */ 10377 if(rs.step==="selectRegione"){self.bkStep="selectRegione";self._bkRender();return;} 10378 if(rs.step==="selectProvincia"){ 10379 if(rs.provincia){self.bkSelProv=rs.provincia;} 10380 /* Senza regione non possiamo caricare province â torna a selectRegione */ 10381 if(!rs.regione){self.bkStep="selectRegione";self._bkRender();return;} 10382 /* Tentativo veloce: usa la cache _bkProvinciaByConn appena popolata. 10383 * Se hit, settiamo bkProvince direttamente senza un'altra chiamata 10384 * API. Se miss, deleghiamo a _bkPickRegione che gestisce fetch + 10385 * gestione errori + auto-pick + step transition. */ 10386 var connKey=rs.regione&&rs.regione.connessione; 10387 var cachedProv=connKey&&self._bkProvinciaByConn&&self._bkProvinciaByConn[connKey]; 10388 self.bkSelRegione=rs.regione; 10389 if(cachedProv&&cachedProv.length){ 10390 self.bkProvince=cachedProv; 10391 self.bkStep="selectProvincia";self._bkRender();return; 10392 } 10393 /* Cache miss: lascia che _bkPickRegione fetchi e gestisca lo step 10394 * internamente. NON sovrascriviamo bkStep dopo l'await: se la 10395 * fetch fallisce _bkPickRegione setta bkStep="error" via 10396 * _bkSetError, ed è giusto mostrare l'errore â non una lista vuota. */ 10397 self._bkBusy=false; 10398 await self._bkPickRegione(rs.regione); 10399 /* Se _bkPickRegione ha fallito (no province / network / regione 10400 * non più valida), il bkStep è "error". Lancia per far scattare 10401 * clearResumeState() nel catch outer: senza questo, al prossimo 10402 * refresh il banner "Riprendi" rifa il loop sullo stesso errore. */ 10403 if(self.bkStep==="error"){ 10404 throw new Error("resume: _bkPickRegione fallita su rs.regione (state stale o API down)"); 10405 } 10406 return; 10407 } 10408 /* Step più avanzati: ri-cascata via _bkPickXxx che caricano i dati */ 10409 if(!rs.provincia){throw new Error("missing provincia in resume state");} 10410 /* Reset busy: _bkPickProv lo riprende internamente */ 10411 self._bkBusy=false; 10412 await self._bkPickProv(rs.provincia); 10413 if(rs.step==="selectServizio")return; /* _bkPickProv ha lasciato bkStep=selectServizio */ 10414 if(!rs.servizio){throw new Error("missing servizio in resume state");} 10415 var fullSrv=(self.bkServizi||[]).filter(function(s){return s.codice===rs.servizio.codice;})[0]||rs.servizio; 10416 self._bkBusy=false; 10417 await self._bkPickServ(fullSrv); 10418 if(rs.step==="selectSede")return; 10419 if(!rs.sede){throw new Error("missing sede in resume state");} 10420 var fullSede=(self.bkSedi||[]).filter(function(s){return s.codice===rs.sede.codice;})[0]||rs.sede; 10421 self._bkBusy=false; 10422 await self._bkPickSede(fullSede); 10423 if(rs.step==="selectSlot")return; 10424 /* Step >= collectData: ricostruisci lo slot SOLO se esiste ancora tra 10425 * gli spazi appena ricaricati. Lo slot salvato a sessione precedente 10426 * può essere scaduto o già preso: NON ripristinarlo "as-is" (il vecchio 10427 * fallback `||rs.slot` lo faceva â uno slot stantio/passato finiva nel 10428 * payload prenota senza ri-validazione). Se non c'è più, riportiamo 10429 * l'utente alla scelta dello slot invece di prenotare dati errati. */ 10430 var freshSlot=(rs.slot&&self.bkSpazi&&self.bkSpazi.length) 10431 ? (self.bkSpazi.filter(function(s){return s.id_spazio===rs.slot.id_spazio;})[0]||null) 10432 : null; 10433 if(freshSlot){ 10434 self.bkSelSlot=freshSlot; 10435 /* #PII â se i dati cliente sono solo "candidati" (resume), non saltare 10436 * a "confirm" con dati altrui: atterra su collectData dove il gate 10437 * "Sei tu?" chiede conferma esplicita prima di prenotare. */ 10438 self.bkStep=(rs.step==="confirm"&&self._bkStoredCandidate&&!(self.bkClient.codfis||self.bkClient.nome))?"collectData":rs.step; 10439 }else{ 10440 self.bkSelSlot=null; 10441 self.bkStep="selectSlot"; /* slot non più disponibile â re-pick */ 10442 } 10443 self._bkRender(); 10444 }catch(err){ 10445 /* In caso di errore, mostra error boundary con messaggio utile */ 10446 try{ 10447 self._bkSetError({ 10448 error:bkT("err_s_default"), 10449 code:(err&&err.code)||"unknown", 10450 plannerMessage:(err&&err.message)||String(err) 10451 },null); 10452 }catch(_e){ 10453 self.bkStep="selectRegione";self._bkRender(); 10454 } 10455 } 10456 })(); 10457 }); 10458 b.querySelector("[data-act=discard]").addEventListener("click",function(){ 10459 clearResumeState(); 10460 self._bkLogEvent&&self._bkLogEvent("resume_discarded",{step:rs.step}); 10461 done(false); 10462 }); 10463 }); 10464 }; 10465 10466 /* Naviga direttamente a uno step precedente preservando lo stato a valle. 10467 * Usato dall'edit inline del summary (#36) e dal breadcrumb cliccabile (#47). */ 10468 CafChat.prototype._bkGoToStep = function(targetStep){ 10469 /* whitelist degli step navigabili a ritroso: */ 10470 var seq=["selectRegione","selectProvincia","selectServizio","selectSede","selectSlot","collectData","confirm"]; 10471 var ti=seq.indexOf(targetStep);if(ti<0)return; 10472 /* invalida ogni _bkPick* in volo prima di ripulire le selezioni a valle */
10473 this._bkInvalidateInflight(); 10474 /* Pulisci selezioni a valle dello step target */ 10475 if(ti<seq.indexOf("selectSlot")){this.bkSelSlot=null;} 10476 if(ti<seq.indexOf("selectSede")){this.bkSelSede=null;this.bkSpazi=[];} 10477 if(ti<seq.indexOf("selectServizio")){this.bkSelServ=null;this.bkServizi=[];} 10478 if(ti<seq.indexOf("selectProvincia")){this.bkSelProv=null;this.bkProvince=[];} 10479 if(ti<seq.indexOf("selectRegione")){this.bkSelRegione=null;this.bkRegioni=[];} 10480 this.bkStep=targetStep;this._bkDir="back";this._bkVoiceAnnounced=null; 10481 /* #30 â View Transitions: morph elegante tra step quando supportato */ 10482 this._bkRenderWithTransition(); 10483 bkHaptic&&bkHaptic(8); 10484 this._bkLogEvent&&this._bkLogEvent("step_jump",{to:targetStep}); 10485 }; 10486 10487 CafChat.prototype._bkBack = function(){ 10488 /* invalida ogni _bkPick* in volo: la navigazione indietro scarta lo stato a 10489 * valle, quindi una fetch stantia non deve poterlo riscrivere dopo il reset */ 10490 this._bkInvalidateInflight(); 10491 this.bkFormErr=null;this._bkVoiceAnnounced=null;this._bkDir="back"; 10492 switch(this.bkStep){ 10493 case "selectRegione": 10494 /* go back to asking start (idle) */ 10495 this._bkReset(); 10496 return; 10497 case "selectProvincia": 10498 /* If multiple regioni, allow re-pick. Otherwise reset. */ 10499 if(this.bkRegioni&&this.bkRegioni.length>1){ 10500 this.bkStep="selectRegione";this.bkSelRegione=null;this.bkProvince=[];this.bkSelProv=null;break; 10501 } 10502 this._bkReset(); 10503 return; 10504 case "selectServizio": 10505 /* if province was auto-selected (only 1 or from location match), go back to location */ 10506 if(this.bkProvince.length<=1){this._bkReset();return;} 10507 this.bkStep="selectProvincia";this.bkSelProv=null;break; 10508 case "selectSede":this.bkStep="selectServizio";this.bkSelServ=null; 10509 /* restore full servizi list (in case it was filtered by hint) */ 10510 this.bkServizi=[];break; 10511 case "selectSlot":this.bkStep="selectSede";this.bkSelSede=null;this.bkSpazi=[];break; 10512 case "collectData":this.bkStep="selectSlot";this.bkSelSlot=null;break; 10513 case "confirm":this.bkStep="collectData";break; 10514 case "error": 10515 var ret=this.bkErrReturn; 10516 this.bkErrCode=null;this.bkErrMsg="";this.bkErrReturn=null; 10517 if(ret){this.bkStep=ret;} 10518 else{this._bkReset();return;} 10519 break; 10520 default:this._bkReset();return; 10521 } 10522 /* if going back to selectServizio and servizi are empty, re-fetch */ 10523 if(this.bkStep==="selectServizio"&&(!this.bkServizi||!this.bkServizi.length)&&this.bkSelProv){ 10524 var self=this; 10525 self._bkBusy=true;self.bkStep="loading";self._bkRender(); 10526 var connBack=self.bkSelRegione&&self.bkSelRegione.connessione; 10527 self._bkFetch("servizi",{codiceProvincia:self.bkSelProv.codice,connessione:connBack}).then(function(all){ 10528 var srv=(all||[]).filter(function(s){return s.online==="1";}); 10529 self.bkServizi=srv.length?srv:[]; 10530 self.bkStep=srv.length?"selectServizio":"error"; 10531 if(!srv.length){self.bkErrMsg=bkT("err_s_no_services");self.bkErrCode="no_services";self.bkErrReturn="selectProvincia";} 10532 self._bkBusy=false;self._bkRender(); 10533 }).catch(function(e){self._bkBusy=false;self._bkSetError(e,"selectProvincia");}); 10534 return; 10535 } 10536 /* slot_taken: il rientro su DATA e ORA trova bkSpazi svuotato di proposito 10537 * (il submit ha azzerato gli spazi) â ri-passa da _bkPickSede per ri-fetchare 10538 * gli orari FRESCHI (gestisce anche il drain Piemonte e il caso "nessuno 10539 * spazio rimasto" â errore no_spazi con ritorno a selectSede). */ 10540 if(this.bkStep==="selectSlot"&&(!this.bkSpazi||!this.bkSpazi.length)&&this.bkSelSede&&this.bkSelProv&&this.bkSelServ){ 10541 this._bkPickSede(this.bkSelSede,{internalRefetch:true}); 10542 return; 10543 } 10544 this._bkRender(); 10545 }; 10546 10547 /* ââ booking: render UI into _bkEl âââââââââââââââââââââââââââ */ 10548 /* A11y (WCAG 2.4.3): il renderer inline azzera il container e distrugge 10549 * l'elemento con il focus. Il wrapper lo ri-posiziona dentro il wizard 10550 * inline (titolo o primo controllo) quando il focus era li' ed e' caduto 10551 * su body; non tocca nulla se l'utente stava scrivendo altrove. */ 10552 CafChat.prototype._bkRender = function(){ 10553 var hadFocus=false; 10554 try{ 10555 var _a=(this.shadow&&this.shadow.activeElement)||document.activeElement; 10556 hadFocus=!!(_a&&this._bkEl&&this._bkEl.contains(_a)); 10557 }catch(_e){} 10558 this._bkRenderInlineImpl(); 10559 if(this._bkFsMode||!hadFocus)return; 10560 try{ 10561 var el=this._bkEl;if(!el)return; 10562 var _b=(this.shadow&&this.shadow.activeElement)||document.activeElement; 10563 if(_b&&_b!==document.body&&el.contains(_b))return; 10564 var t=el.querySelector(".caf-bk-title"); 10565 if(t&&!t.hasAttribute("tabindex"))t.setAttribute("tabindex","-1"); 10566 if(!t)t=el.querySelector('button:not([disabled]),a[href],input:not([disabled]),[tabindex]:not
10566([tabindex="-1"])'); 10567 if(t)t.focus(); 10568 }catch(_e){} 10569 }; 10570 CafChat.prototype._bkRenderInlineImpl = function(){ 10571 /* clear resume loading hint quando usciamo dal loading state */ 10572 if(this.bkStep!=="loading"&&this._bkResumeLoading){this._bkResumeLoading=null;} 10573 /* update the prominent expand banner outside the conversation */ 10574 if(this._updateBkExpandBanner)this._updateBkExpandBanner(); 10575 /* #60 â persisti stato per resume su refresh accidentale: salviamo solo 10576 * step "interattivi", non loading/error/done (quelli sono transitori). */ 10577 try{ 10578 var persistableSteps={selectRegione:1,selectProvincia:1,selectServizio:1,selectSede:1,selectSlot:1,collectData:1,confirm:1}; 10579 if(persistableSteps[this.bkStep]){ 10580 saveResumeState({ 10581 step:this.bkStep, 10582 regione:this.bkSelRegione||null, 10583 provincia:this.bkSelProv||null, 10584 servizio:this.bkSelServ?{codice:this.bkSelServ.codice,descrizione:this.bkSelServ.descrizione,desonline:this.bkSelServ.desonline,tempo:this.bkSelServ.tempo}:null, 10585 sede:this.bkSelSede?{codice:this.bkSelSede.codice,descrizione:this.bkSelSede.descrizione,indirizzo:this.bkSelSede.indirizzo,citta:this.bkSelSede.citta}:null, 10586 slot:this.bkSelSlot||null, 10587 client:this.bkClient||null, 10588 bookingFor:this.bkBookingFor||"self" 10589 }); 10590 } 10591 }catch(_e){} 10592 /* when fullscreen mode active, route to premium renderer */ 10593 if(this._bkFsMode){this._bkRenderFs();return;} 10594 if(!this._bkEl)return;var el=this._bkEl,self=this,c=this.opts.color;el.innerHTML=""; 10595 this._updateSendBtn(); 10596 if(this.opts.demopromo){el.appendChild(this._bkDemoRibbon());} 10597 var TOTAL=5; 10598 var slideDir=self._bkDir||"fwd";self._bkDir="fwd"; 10599 10600 var stepEmojis=["ð","ð","ð¢","ð ","âï¸"]; 10601 var stepLabels=[bkT("step_zone"),bkT("step_service"),bkT("step_office"),bkT("step_time"),bkT("step_data")]; 10602 function mkStep(cur,label){ 10603 var w=document.createElement("div");w.className="caf-bk-steps-wrap"; 10604 /* step dots with checkmarks */ 10605 var row=document.createElement("div");row.className="caf-bk-steps"; 10606 for(var i=0;i<TOTAL;i++){ 10607 if(i>0){var ln=document.createElement("div");ln.className="caf-bk-step-line";ln.style.background=i<=cur?c+"60":"#e2e8f0";row.appendChild(ln);} 10608 var dot=document.createElement("div");dot.className="caf-bk-step-dot "+(i<cur?"done":i===cur?"active":"todo"); 10609 if(i<cur){dot.style.background=c;dot.innerHTML='<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round"><path d="M20 6L9 17l-5-5"/></svg>';} 10610 else{dot.textContent=i+1;} 10611 row.appendChild(dot); 10612 } 10613 w.appendChild(row); 10614 /* step labels */ 10615 var lblRow=document.createElement("div");lblRow.className="caf-bk-step-labels"; 10616 for(var j=0;j<TOTAL;j++){ 10617 if(j>0){var sp=document.createElement("div");sp.style.flex="1";lblRow.appendChild(sp);} 10618 var sl=document.createElement("div");sl.className="caf-bk-step-lbl"+(j===cur?" active":j<cur?" done":"");sl.textContent=stepLabels[j];lblRow.appendChild(sl); 10619 } 10620 w.appendChild(lblRow); 10621 /* progress bar */ 10622 var prog=document.createElement("div");prog.className="caf-bk-progress"; 10623 var progBar=document.createElement("div");progBar.className="caf-bk-progress-bar";progBar.style.width=Math.round(((cur+1)/TOTAL)*100)+"%";prog.appendChild(progBar); 10624 w.appendChild(prog); 10625 /* step counter */ 10626 var cnt=document.createElement("div");cnt.className="caf-bk-step-counter";cnt.textContent=bkT("step_of",(cur+1),TOTAL);w.appendChild(cnt); 10627 /* title */ 10628 var t=document.createElement("div");t.className="caf-bk-title";t.style.marginTop="8px";t.innerHTML=stepEmojis[cur]+" "+escapeHtml(label);w.appendChild(t); 10629 return w; 10630 } 10631 /* ripple effect helper */ 10632 function addRipple(btn){ 10633 btn.addEventListener("click",function(e){ 10634 var rect=btn.getBoundingClientRect();var rip=document.createElement("span");rip.className="caf-ripple"; 10635 var sz=Math.max(rect.width,rect.height);rip.style.width=rip.style.height=sz+"px"; 10636 rip.style.left=(e.clientX-rect.left-sz/2)+"px";rip.style.top=(e.clientY-rect.top-sz/2)+"px"; 10637 btn.appendChild(rip);setTimeout(function(){if(rip.parentNode)rip.parentNode.removeChild(rip);},600); 10638 }); 10639 } 10640 function mkBot(back){ 10641 var b=document.createElement("div");
10641b.className="caf-bk-bot"; 10642 if(back!==false){var bk=document.createElement("button");bk.className="caf-bk-back";bk.textContent="\u2190 "+bkT("btn_back");bk.addEventListener("click",function(){self._bkBack();});b.appendChild(bk);} 10643 else b.appendChild(document.createElement("span")); 10644 var cn=document.createElement("button");cn.className="caf-bk-cancel";cn.textContent="\u2715 "+bkT("btn_cancel");cn.addEventListener("click",function(){self._bkReset();});b.appendChild(cn);return b; 10645 } 10646 function mkSpin(lbl,icon){var w=document.createElement("div");w.className="caf-bk-spin"; 10647 var sw=document.createElement("div");sw.className="caf-bk-spin-wrap"; 10648 var r=document.createElement("div");r.className="caf-bk-ring";sw.appendChild(r); 10649 var ic=document.createElement("div");ic.className="caf-bk-spin-icon";ic.textContent=icon||"\uD83D\uDCC5";sw.appendChild(ic); 10650 w.appendChild(sw); 10651 var l=document.createElement("div");l.className="caf-bk-spin-lbl";l.textContent=lbl;w.appendChild(l); 10652 var bar=document.createElement("div");bar.className="caf-bk-spin-bar";var bi=document.createElement("div");bi.className="caf-bk-spin-bar-inner";bar.appendChild(bi);w.appendChild(bar); 10653 return w;} 10654 function mkCrumb(t,svgIcon){var s=document.createElement("div");s.className="caf-bk-crumb"; 10655 if(svgIcon)s.innerHTML=svgIcon; 10656 var sp=document.createElement("span");sp.textContent=t;s.appendChild(sp);return s;} 10657 function mkHint(txt){var d=document.createElement("div");d.style.cssText="font-size:12.5px;color:#5b6573;margin-bottom:10px;line-height:1.5;animation:caf-pop-in .3s ease-out";d.textContent=txt;return d;} 10658 10659 switch(this.bkStep){ 10660 case "loading":{ 10661 var loadMsg=(self._bkAutoProvStatus&&self._bkAutoProvStatus.type==="loading") 10662 ? self._bkAutoProvStatus.msg 10663 : (self.bkHint 10664 ? bkT("label_loading_hint",self.bkHint) 10665 : bkT("label_loading_options")); 10666 var loadIcon=(self._bkAutoProvStatus&&self._bkAutoProvStatus.type==="loading")?"\uD83D\uDCCD":"\uD83D\uDD0D"; 10667 el.appendChild(mkSpin(loadMsg,loadIcon)); 10668 /* "Annulla": sblocca l'attesa (fino a ~47s con i retry) e torna allo step 10669 * precedente. Solo se c'\u00E8 uno step di ritorno (non sul primissimo load). */ 10670 if(self._bkLoadingReturnStep){ 10671 var cbL=document.createElement("button");cbL.type="button";cbL.textContent=bkT("btn_cancel"); 10672 cbL.style.cssText="margin:14px auto 0;display:block;background:none;border:none;color:#5b6573;font-size:13px;text-decoration:underline;cursor:pointer;padding:6px 10px"; 10673 cbL.addEventListener("click",function(){self._bkCancelLoading();}); 10674 el.appendChild(cbL); 10675 } 10676 break; 10677 } 10678 case "submitting": el.appendChild(mkSpin(bkT("label_submitting"),"\u2709\uFE0F")); break; 10679 10680 case "selectRegione":{ 10681 var rw=document.createElement("div");rw.className=slideDir==="back"?"caf-bk-slide-back":"caf-bk-slide-fwd"; 10682 rw.appendChild(mkStep(0,bkT("t_region"))); 10683 rw.appendChild(mkHint(bkT("h_region"))); 10684 /* Banner stato geolocalizzazione: visibile in TUTTI gli stati. */ 10685 if(self._bkAutoProvStatus){ 10686 var stTypeR=self._bkAutoProvStatus.type; 10687 var spR=document.createElement("div");spR.className="caf-bk-locate-status "+(stTypeR==="ok"?"ok":(stTypeR==="err"?"err":"loading")); 10688 var spIcR; 10689 if(stTypeR==="loading")spIcR='<span class="spin" style="display:inline-block;width:14px;height:14px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:caf-spin .8s linear infinite;vertical-align:middle"></span>'; 10690 else if(stTypeR==="ok")spIcR='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg>'; 10691 else spIcR='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/></svg>'; 10692 spR.innerHTML=spIcR+'<span>'+escapeHtml(self._bkAutoProvStatus.msg)+'</span>'; 10693 rw.appendChild(spR); 10694 }
10695 var rg=document.createElement("div");rg.className="caf-bk-grid"; 10696 self.bkRegioni.forEach(function(r,i){ 10697 var b=document.createElement("button");b.className="caf-bk-card";b.style.animation="caf-fade-up .35s ease-out "+(i*40)+"ms backwards";b.style.paddingLeft="12px"; 10698 var ic=document.createElement("span");ic.style.cssText="margin-right:4px;font-size:13px";ic.textContent="\uD83C\uDDEE\uD83C\uDDF9";b.appendChild(ic); 10699 b.appendChild(document.createTextNode(r.descrizione)); 10700 addRipple(b);b.addEventListener("click",function(){self._bkPickRegione(r);});rg.appendChild(b); 10701 }); 10702 rw.appendChild(rg);rw.appendChild(mkBot(false));el.appendChild(rw);break; 10703 } 10704 case "selectProvincia":{ 10705 var pw=document.createElement("div");pw.className=slideDir==="back"?"caf-bk-slide-back":"caf-bk-slide-fwd"; 10706 pw.appendChild(mkStep(0,bkT("t_province"))); 10707 pw.appendChild(mkHint(bkT("h_province"))); 10708 /* PROMINENT "use my location" button for auto-detecting province */ 10709 var locProv=document.createElement("button");locProv.type="button";locProv.className="caf-bk-locate"; 10710 var lpLoading=self._bkAutoProvStatus&&self._bkAutoProvStatus.type==="loading"; 10711 if(lpLoading){locProv.disabled=true; 10712 locProv.innerHTML='<span class="caf-bk-locate-ic"><span class="spin"></span></span>'+ 10713 '<div class="caf-bk-locate-txt"><div class="caf-bk-locate-t">'+escapeHtml(self._bkAutoProvStatus.msg)+'</div></div>'; 10714 }else{ 10715 locProv.innerHTML='<span class="caf-bk-locate-ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/></svg></span>'+ 10716 '<div class="caf-bk-locate-txt"><div class="caf-bk-locate-t">'+escapeHtml(bkT("btn_use_location"))+'</div><div class="caf-bk-locate-s">'+escapeHtml(bkT("btn_use_location_sub"))+'</div></div>'+ 10717 '<span class="caf-bk-locate-arrow">âº</span>'; 10718 } 10719 locProv.addEventListener("click",function(){self._bkAutoProvStatus=null;self._bkAutoDetectProvince(function(){self._bkRender();});}); 10720 pw.appendChild(locProv); 10721 /* status row */ 10722 if(self._bkAutoProvStatus&&self._bkAutoProvStatus.type!=="loading"){ 10723 var sp=document.createElement("div");sp.className="caf-bk-locate-status "+(self._bkAutoProvStatus.type==="ok"?"ok":"err"); 10724 var spIc=self._bkAutoProvStatus.type==="ok" 10725 ?'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg>' 10726 :'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/></svg>'; 10727 sp.innerHTML=spIc+'<span>'+escapeHtml(self._bkAutoProvStatus.msg)+'</span>'; 10728 pw.appendChild(sp); 10729 } 10730 /* divider label */ 10731 var divL=document.createElement("div");divL.style.cssText="display:flex;align-items:center;gap:8px;margin:4px 0 8px;font-size:10px;color:#5b6573;font-weight:600;text-transform:uppercase;letter-spacing:.5px"; 10732 divL.innerHTML='<span style="flex:1;height:1px;background:#e2e8f0"></span><span>'+escapeHtml(bkT("label_or_choose"))+'</span><span style="flex:1;height:1px;background:#e2e8f0"></span>'; 10733 pw.appendChild(divL); 10734 10735 var g=document.createElement("div");g.className="caf-bk-grid"; 10736 self.bkProvince.forEach(function(p,i){ 10737 var b=document.createElement("button");b.className="caf-bk-card";b.style.animation="caf-fade-up .35s ease-out "+(i*50)+"ms backwards";b.style.paddingLeft="12px"; 10738 var ic=document.createElement("span");ic.style.cssText="margin-right:4px;font-size:13px";ic.textContent="\uD83D\uDCCD";b.appendChild(ic); 10739 b.appendChild(document.createTextNode(p.descrizione)); 10740 addRipple(b);b.addEventListener("click",function(){self._bkPickProv(p);});g.appendChild(b);}); 10741 pw.appendChild(g);pw.appendChild(mkBot(false));el.appendChild(pw);break; 10742 } 10743 case "selectServizio":{ 10744 var sw=document.createElement("div");sw.className=slideDir==="back"?"caf-bk-slide-back":"caf-bk-slide-fwd"; 10745 sw.appendChild(mkStep(1,bkT("h_service"))); 10746 sw.appendChild(mkCrumb(self.bkSelProv?self.bkSelProv.descrizione:"",'<svg width="10" height="10" viewB
10746ox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>')); 10747 var srvHintMsg=self.bkHint 10748 ? bkT("label_srv_hint_found",self.bkHint) 10749 : bkT("label_srv_hint"); 10750 sw.appendChild(mkHint(srvHintMsg)); 10751 /* search filter when many services */ 10752 var srvListEl=document.createElement("div");srvListEl.className="caf-bk-list"; 10753 var allServizi=self.bkServizi; 10754 function renderSrvList(filter){ 10755 srvListEl.innerHTML=""; 10756 var filtered=filter?allServizi.filter(function(s){return(s.desonline||s.descrizione).toLowerCase().indexOf(filter.toLowerCase())!==-1;}):allServizi; 10757 /* WCAG 4.1.3 (#C65): annuncia l'esito della ricerca (inline) */ 10758 if(filter){self._bkFsAnnounce(filtered.length?bkT("sr_results_count",filtered.length):bkT("sr_no_results"));} 10759 if(!filtered.length){var nr=document.createElement("div");nr.className="caf-bk-no-results";nr.textContent=bkT("ph_no_results_one",filter);srvListEl.appendChild(nr);return;} 10760 filtered.forEach(function(s,i){var b=document.createElement("button");b.className="caf-bk-card";b.style.animation="caf-fade-up .3s ease-out "+(i*35)+"ms backwards"; 10761 var ic=document.createElement("div");ic.className="caf-bk-card-icon";ic.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg>';b.appendChild(ic); 10762 var nm=document.createElement("span");nm.textContent=s.desonline||s.descrizione;b.appendChild(nm); 10763 if(+s.tempo>0){var sub=document.createElement("span");sub.className="caf-bk-sub";sub.innerHTML='<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> '+s.tempo+" min";b.appendChild(sub);} 10764 addRipple(b);b.addEventListener("click",function(){self._bkPickServ(s);});srvListEl.appendChild(b);}); 10765 } 10766 if(allServizi.length>5){ 10767 var srch=document.createElement("div");srch.className="caf-bk-search"; 10768 var srchIc=document.createElement("div");srchIc.className="caf-bk-search-icon";srchIc.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>';srch.appendChild(srchIc); 10769 var srchInp=document.createElement("input");srchInp.type="text";srchInp.placeholder=bkT("ph_search_service");srchInp.setAttribute("aria-label",bkT("ph_search_service"));
10769srchInp.addEventListener("input",function(){renderSrvList(srchInp.value);});srch.appendChild(srchInp);sw.appendChild(srch); 10770 } 10771 renderSrvList("");sw.appendChild(srvListEl);sw.appendChild(mkBot(true));el.appendChild(sw);break; 10772 } 10773 case "selectSede":{ 10774 var sdw=document.createElement("div");sdw.className=slideDir==="back"?"caf-bk-slide-back":"caf-bk-slide-fwd"; 10775 sdw.appendChild(mkStep(2,bkT("h_office"))); 10776 sdw.appendChild(mkCrumb(self.bkSelServ?(self.bkSelServ.desonline||self.bkSelServ.descrizione):"",'<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg>')); 10777 sdw.appendChild(mkHint(bkT("label_sede_hint"))); 10778 /* PROMINENT "find nearest" button (filled gradient + pulse) */ 10779 var nb=document.createElement("button");nb.type="button";nb.className="caf-bk-locate"; 10780 var nbLoading=self._bkNearestStatus&&self._bkNearestStatus.type==="loading"; 10781 if(nbLoading){nb.disabled=true; 10782 nb.innerHTML='<span class="caf-bk-locate-ic"><span class="spin"></span></span>'+ 10783 '<div class="caf-bk-locate-txt"><div class="caf-bk-locate-t">'+escapeHtml(self._bkNearestStatus.msg)+'</div></div>'; 10784 }else{ 10785 nb.innerHTML='<span class="caf-bk-locate-ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/></svg></span>'+ 10786 '<div class="caf-bk-locate-txt"><div class="caf-bk-locate-t">'+escapeHtml(self._bkNearestCode?bkT("btn_find_nearest_again"):bkT("btn_find_nearest"))+'</div><div class="caf-bk-locate-s">'+escapeHtml(self._bkNearestCode?bkT("btn_find_nearest_again_sub"):bkT("btn_find_nearest_sub"))+'</div></div>'+ 10787 '<span class="caf-bk-locate-arrow">âº</span>'; 10788 } 10789 nb.addEventListener("click",function(){self.bkSedi.forEach(function(s){delete s._distKm;});self._bkNearestCode=null;self._bkFindNearestSede(function(){self._bkRender();});}); 10790 sdw.appendChild(nb); 10791 if(self._bkNearestStatus&&self._bkNearestStatus.type!=="loading"){ 10792 var st=document.createElement("div");st.className="caf-bk-locate-status "+(self._bkNearestStatus.type==="ok"?"ok":"err"); 10793 var stIc=self._bkNearestStatus.type==="ok" 10794 ?'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg>' 10795 :'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/></svg>'; 10796 st.innerHTML=stIc+'<span>'+escapeHtml(self._bkNearestStatus.msg)+'</span>'; 10797 sdw.appendChild(st); 10798 } 10799 var sl=document.createElement("div");sl.className="caf-bk-list"; 10800 self.bkSedi.forEach(function(s,i){var b=document.createElement("button");b.className="caf-bk-card";b.style.animation="caf-fade-up .35s ease-out "+(i*50)+"ms backwards"; 10801 var isNearest=self._bkNearestCode&&(s.codice===self._bkNearestCode||s.descrizione===self._bkNearestCode); 10802 if(isNearest){b.style.borderColor=c;b.style.boxShadow="0 6px 20px "+c+"28";} 10803 var ic=document.createElement("div");ic.className="caf-bk-card-icon";ic.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>';b.appendChild(ic); 10804 var nm=document.createElement("span");nm.textContent=s.descrizione;b.appendChild(nm); 10805 if(s._distKm!==undefined){ 10806 var d=document.createElement("span");d.style.cssText="display:inline-block;margin-left:6px;font-size:10px;font-weight:700;color:"+c+";background:"+c+"15;border:1px solid "+c+"30;padding:1px 7px;border-radius:99px;vertical-align:middle"; 10807 var kmTxt=s._distKm<1?(Math.round(s._distKm*1000)+" m"):(s._distKm.toFixed(1)+" km"); 10808 d.textContent=(isNearest?"â ":"")+kmTxt; 10809 b.appendChild(d); 10810 } 10811 var ad=document.createElement("span");
10811ad.className="caf-bk-sub";ad.innerHTML='<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg> '+escapeHtml(s.indirizzo+", "+s.citta);b.appendChild(ad); 10812 if(s.telefono){ 10813 var tel=document.createElement("a");tel.className="caf-bk-sub"; 10814 tel.href="tel:"+encodeURIComponent(String(s.telefono).replace(/\s+/g,"")); 10815 tel.style.cssText="text-decoration:none;color:inherit;cursor:pointer"; 10816 tel.addEventListener("click",function(ev){ev.stopPropagation();self._bkLogEvent&&self._bkLogEvent("sede_phone_clicked",{sede_codice:s.codice});}); 10817 tel.innerHTML='<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3"/></svg> '+escapeHtml(s.telefono); 10818 b.appendChild(tel); 10819 } 10820 addRipple(b);b.addEventListener("click",function(){self._bkPickSede(s);});sl.appendChild(b);}); 10821 sdw.appendChild(sl);sdw.appendChild(mkBot(true));el.appendChild(sdw);break; 10822 } 10823 case "selectSlot":{ 10824 var slw=document.createElement("div");slw.className=slideDir==="back"?"caf-bk-slide-back":"caf-bk-slide-fwd"; 10825 slw.appendChild(mkStep(3,bkT("h_slot"))); 10826 slw.appendChild(mkCrumb(self.bkSelSede?self.bkSelSede.descrizione:"",'<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>')); 10827 slw.appendChild(mkHint(bkT("label_slots_hint"))); 10828 /* Banner pattern preferenza orario in base alle prenotazioni passate */ 10829 var prefTime=preferredTimeOfDay(); 10830 if(prefTime){ 10831 var ptB=document.createElement("div");ptB.style.cssText="display:flex;align-items:center;gap:8px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:10px;padding:7px 10px;margin-bottom:10px;font-size:11.5px;color:#1e40af"; 10832 ptB.innerHTML='<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> '+bkT("label_pref_time",'<strong>'+escapeHtml(prefTime==="morning"?bkT("label_morning"):bkT("label_afternoon"))+'</strong>'); 10833 slw.appendChild(ptB); 10834 } 10835 if(!self.bkSpazi.length){var emp=document.createElement("div");emp.style.cssText="border-radius:12px;border:1px solid #fde68a;background:#fffbeb;padding:14px;text-align:center;font-size:12px;color:#92400e;animation:caf-fade-up .3s ease-out";emp.innerHTML='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#92400e" stroke-width="2" style="margin-bottom:4px"><circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/></svg><br>'+escapeHtml(bkT("label_no_slots_h"));slw.appendChild(emp);} 10836 else{ 10837 /* group slots by date */ 10838 var dayGroups={},dayOrder=[]; 10839 self.bkSpazi.forEach(function(s){ 10840 var dk=s.data; 10841 if(!dayGroups[dk]){dayGroups[dk]=[];dayOrder.push(dk);} 10842 dayGroups[dk].push(s); 10843 }); 10844 var gIdx=0; 10845 dayOrder.forEach(function(dk){ 10846 var grp=document.createElement("div");grp.className="caf-bk-day-group";grp.style.animation="caf-fade-up .35s ease-out "+(gIdx*80)+"ms backwards"; 10847 /* day header */ 10848 var hdr=document.createElement("div");hdr.className="caf-bk-day-hdr"; 10849 var dp=fmtDate(dk).split(" "); 10850 var dayN=document.createElement("span");dayN.className="caf-bk-day-name";dayN.textContent=dp.slice(0,2).join(" ");hdr.appendChild(dayN); 10851 var dayD=document.createElement("span");dayD.className="caf-bk-day-date";dayD.textContent=dp.slice(2).join(" ");hdr.appendChild(dayD); 10852 var dayL=document.createElement("div");dayL.className="caf-bk-day-line";hdr.appendChild(dayL); 10853 grp.appendChild(hdr); 10854 /* time chips */ 10855 var chips=document.createElement("div");
10855chips.className="caf-bk-day-slots"; 10856 dayGroups[dk].forEach(function(s,ci){ 10857 var pm=+(s.ora_inizio.slice(0,2))>=13; 10858 var chip=document.createElement("button");chip.className="caf-bk-slot-chip";chip.style.animation="caf-fade-up .3s ease-out "+((gIdx*80)+(ci*30))+"ms backwards"; 10859 var tm=document.createElement("span");tm.className="caf-bk-slot-time";tm.textContent=fmtTime(s.ora_inizio);chip.appendChild(tm); 10860 var period=document.createElement("span");period.className="caf-bk-slot-period "+(pm?"pom":"matt");period.textContent=pm?bkT("label_afternoon"):bkT("label_morning");chip.appendChild(period); 10861 addRipple(chip);chip.addEventListener("click",function(){self._bkPickSlot(s);});chips.appendChild(chip); 10862 }); 10863 grp.appendChild(chips);slw.appendChild(grp);gIdx++; 10864 }); 10865 if(self.bkNextG){var mr=document.createElement("button");mr.className="caf-bk-more"; 10866 if(self._bkLoadingMore){mr.disabled=true;mr.style.opacity="0.6";mr.innerHTML='<span class="spin"></span> '+escapeHtml(bkT("label_loading_short"));} 10867 else{mr.innerHTML='<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14m-7-7h14"/></svg> '+escapeHtml(bkT("btn_load_more"));mr.addEventListener("click",function(){self._bkLoadMore();});} 10868 slw.appendChild(mr);} 10869 } 10870 slw.appendChild(mkBot(true));el.appendChild(slw);break; 10871 } 10872 case "collectData":{ 10873 var cdw=document.createElement("div");cdw.className=slideDir==="back"?"caf-bk-slide-back":"caf-bk-slide-fwd"; 10874 cdw.appendChild(mkStep(4,bkT("t_data"))); 10875 cdw.appendChild(mkCrumb(bkT("label_date_at",fmtDate(self.bkSelSlot?self.bkSelSlot.data:""),fmtTime(self.bkSelSlot?self.bkSelSlot.ora_inizio:"")),'<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>')); 10876 cdw.appendChild(mkHint(bkT("label_data_hint"))); 10877 /* Slot reservation timer (UI-only 5 min) */ 10878 self._bkRenderSlotTimer(cdw); 10879 /* Toggle "Per chi prenoti?" â se famiglia, swap storage key + clear form */ 10880 var forRow=document.createElement("div");forRow.style.cssText="display:flex;gap:6px;background:#f1f5f9;border-radius:10px;padding:3px;margin-bottom:10px"; 10881 [["self",bkT("label_form_for_self")],["family",bkT("label_form_for_family")]].forEach(function(pair){ 10882 var b=document.createElement("button");b.type="button";b.style.cssText="flex:1;border:none;padding:7px 10px;font-size:11.5px;font-weight:700;border-radius:8px;cursor:pointer;transition:all .2s;font-family:inherit;"+(self.bkBookingFor===pair[0]?"background:#fff;color:"+c+";box-shadow:0 1px 3px rgba(0,0,0,.06)":"background:transparent;color:#5b6573"); 10883 b.textContent=pair[1]; 10884 /* WCAG 4.1.2 (#C35): stato del toggle esposto alle AT */ 10885 b.setAttribute("aria-pressed",self.bkBookingFor===pair[0]?"true":"false"); 10886 b.addEventListener("click",function(){ 10887 if(self.bkBookingFor===pair[0])return; 10888 self.bkBookingFor=pair[0]; 10889 /* #PII â cambia intestatario: ricarica il candidato (non pre-fill cieco) 10890 * e svuota il form; il gate "Sei tu?" chiederà conferma. */ 10891 self._bkStoredCandidate=loadStoredClient(pair[0]); 10892 self.bkClient={nome:"",cognome:"",codfis:"",email:"",cellulare:"",sesso:"M"}; 10893 self._bkRender(); 10894 self._bkLogEvent&&self._bkLogEvent("booking_for_changed",{for:pair[0]}); 10895 }); 10896 forRow.appendChild(b); 10897 }); 10898 cdw.appendChild(forRow); 10899 /* TRUST SIGNALS strip â riduce ansia GDPR/dati personali nel momento di max attrito */ 10900 var ts=document.createElement("div");ts.style.cssText="display:flex;gap:8px;align-items:center;justify-content:center;flex-wrap:wrap;font-size:11px;color:#475569;background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:8px 10px;margin-bottom:12px"; 10901 ts.innerHTML='<span style="display:inline-flex;align-items:center;gap:4px">
10901<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2.4" stroke-linecap="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> '+escapeHtml(bkT("label_data_safe"))+'</span>'+ 10902 '<span style="color:#5b6573">·</span>'+ 10903 '<span style="display:inline-flex;align-items:center;gap:4px"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#0ea5e9" stroke-width="2.4" stroke-linecap="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.86 19.86 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.86 19.86 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.37 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.33 1.85.57 2.81.7A2 2 0 0 1 22 16.92Z"/></svg> '+escapeHtml(bkT("label_data_caf"))+'</span>'+ 10904 '<span style="color:#5b6573">·</span>'+ 10905 '<span style="display:inline-flex;align-items:center;gap:4px"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2.4" stroke-linecap="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> '+escapeHtml(bkT("label_data_real"))+'</span>'; 10906 cdw.appendChild(ts); 10907 var fg=document.createElement("div");fg.className="caf-bk-fg"; 10908 var ficons={ 10909 nome:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>', 10910 cognome:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>', 10911 codfis:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>', 10912 email:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2Z"/><path d="m22 6-10 7L2 6"/></svg>', 10913 cellulare:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>' 10914 }; 10915 /* inline validation rules */ 10916 var fvalidate={ 10917 nome:function(v){return v.trim().length>=2;}, 10918 cognome:function(v){return v.trim().length>=2;}, 10919 codfis:function(v){return validateCodiceFiscale(v).ok;}, 10920 email:function(v){return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);}, 10921 cellulare:function(v){return v.replace(/\D/g,"").length>=8;} 10922 }; 10923 var flabels={nome:bkT("label_form_nome"),cognome:bkT("label_form_cognome"),codfis:bkT("label_form_codfis"),email:bkT("label_form_email"),cellulare:bkT("label_form_cellulare")}; 10924 var checkSvg='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M20 6L9 17l-5-5"/></svg>'; 10925 /* inputmode + autocomplete mapping for mobile keyboards */ 10926 var fInputMode={nome:"text",cognome:"text",codfis:"text",email:"email",cellulare:"tel"}; 10927 var fAutoComp={nome:"given-name",cognome:"family-name",codfis:"off",email:"email",cellulare:"tel"}; 10928 var fhelp={ 10929 codfis:bkT("help_codfis"), 10930 email:bkT("help_email"), 10931 cellulare:bkT("help_cellulare") 10932 }; 10933 /* CF MUST come first: until it's algorithmically valid (struttura + checksum) 10934 * the other fields are disabled e il bottone "Prosegui" non funziona. */ 10935 var _wrapsByKey={}; 10936 var _inputsByKey={}; 10937 var _cfReasonBox=null; 10938 function _isCfOk(){return fvalidate.codfis(self.bkClient.codfis||"");} 10939 function _applyCfGate(){ 10940 var ok=_isCfOk(); 10941 ["nome","cognome","email","cellulare"].forEach(function(k){ 10942 var inp=_inputsByKey[k];var wrap=_wrapsByKey[k];if(!inp||!wrap)return; 10943 inp.disabled=!ok; 10944 if(!ok){ 10945 inp.setAttribute("aria-disabled","true"); 10946 inp.setAttribute("title",bkT("err_cf_first")); 10947 wrap.style.opacity="0.45";wrap.style.pointerEvents="none"; 10948 }else{ 10949 inp.removeAttribute("aria-disabled"); 10950 inp.removeAttribute("title"); 10951 wrap.style.opacity="";wrap.style.pointerEvents=""; 10952 } 10953 }); 10954 if(_proceedBtn){ 10955 /* CF valido E consenso privacy spuntato per proseguire al riepilogo */
10956 var pOk=ok&&!!self.bkPrivacyOk; 10957 _proceedBtn.disabled=!pOk; 10958 _proceedBtn.style.opacity=pOk?"":"0.45"; 10959 _proceedBtn.style.cursor=pOk?"":"not-allowed"; 10960 _proceedBtn.setAttribute("title",pOk?"":(!ok?bkT("err_cf_first"):bkT("consent_required"))); 10961 } 10962 } 10963 var _proceedBtn=null; 10964 [{k:"codfis",p:flabels.codfis+" *",t:"text",mx:16,up:1},{k:"nome",p:flabels.nome+" *",t:"text"},{k:"cognome",p:flabels.cognome+" *",t:"text"},{k:"email",p:flabels.email+" *",t:"email"},{k:"cellulare",p:flabels.cellulare+" *",t:"tel"}].forEach(function(f,i){ 10965 /* label con eventuale tooltip "?" inline */ 10966 var lblRow=document.createElement("div");lblRow.style.cssText="display:flex;align-items:center;gap:4px"; 10967 var lbl=document.createElement("div");lbl.className="caf-bk-inp-label";lbl.style.marginBottom="0";lbl.textContent=flabels[f.k];lblRow.appendChild(lbl); 10968 if(fhelp[f.k]){ 10969 var hb=document.createElement("button");hb.type="button";hb.setAttribute("aria-label",bkT("aria_more_info")); 10970 hb.style.cssText="background:none;border:none;color:#5b6573;cursor:pointer;padding:2px;display:inline-flex;border-radius:50%";
10971 hb.innerHTML='<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>'; 10972 var tip=null; 10973 hb.addEventListener("click",function(){ 10974 if(tip){tip.remove();tip=null;return;} 10975 tip=document.createElement("div");tip.style.cssText="font-size:11px;color:#475569;background:#f1f5f9;border-radius:6px;padding:6px 8px;margin-top:4px";tip.textContent=fhelp[f.k]; 10976 wrap.parentNode.insertBefore(tip,wrap); 10977 self._bkLogEvent&&self._bkLogEvent("faq_tooltip_clicked",{field:f.k}); 10978 }); 10979 lblRow.appendChild(hb); 10980 } 10981 fg.appendChild(lblRow); 10982 var wrap=document.createElement("div");wrap.className="caf-bk-inp-wrap";wrap.style.animation="caf-fade-up .3s ease-out "+(i*60)+"ms backwards"; 10983 var isValid=fvalidate[f.k]&&fvalidate[f.k](self.bkClient[f.k]); 10984 if(isValid&&self.bkClient[f.k])wrap.className+=" caf-bk-inp-valid"; 10985 var icon=document.createElement("div");icon.className="caf-bk-inp-icon";icon.innerHTML=ficons[f.k];wrap.appendChild(icon); 10986 var inp=document.createElement("input");inp.className="caf-bk-inp";inp.type=f.t;inp.placeholder=f.p;inp.value=self.bkClient[f.k]; 10987 inp.setAttribute("aria-label",flabels[f.k]);inp.setAttribute("aria-required","true"); 10988 inp.setAttribute("autocomplete",fAutoComp[f.k]||"off"); 10989 inp.setAttribute("inputmode",fInputMode[f.k]||"text"); 10990 inp.setAttribute("enterkeyhint",f.k==="cellulare"?"done":"next"); 10991 if(f.mx)inp.maxLength=f.mx;if(f.up)inp.style.textTransform="uppercase"; 10992 /* check mark */ 10993 var chk=document.createElement("div");chk.className="caf-bk-inp-check";chk.innerHTML=checkSvg;wrap.appendChild(chk); 10994 /* scroll field into view on mobile when keyboard opens */ 10995 inp.addEventListener("focus",function(){ 10996 setTimeout(function(){if(inp.scrollIntoViewIfNeeded)inp.scrollIntoViewIfNeeded(true);else inp.scrollIntoView({block:"center",behavior:bkScrollBehavior()});},300); 10997 }); 10998 inp.addEventListener("input",function(){ 10999 var val=f.up?inp.value.toUpperCase():inp.value; 11000 /* Phone real-time formatting: 333 123 4567 / 02 1234 5678 */ 11001 if(f.k==="cellulare"){ 11002 var formatted=formatPhoneIT(val); 11003 if(formatted!==val){inp.value=formatted;val=formatted; 11004 /* cursor at end (input lost cursor pos on programmatic value change) */ 11005 try{inp.setSelectionRange(formatted.length,formatted.length);}catch(_e){}} 11006 } 11007 self.bkClient[f.k]=val; 11008 if(fvalidate[f.k]&&fvalidate[f.k](val)){wrap.classList.add("caf-bk-inp-valid");inp.setAttribute("aria-invalid","false");} 11009 else{wrap.classList.remove("caf-bk-inp-valid");inp.setAttribute("aria-invalid",val?"true":"false");} 11010 /* clear form error on edit */ 11011 if(self.bkFormErr){self.bkFormErr=null;var errEl=cdw.querySelector(".caf-bk-ferr");if(errEl)errEl.remove();} 11012 /* clear email typo suggestion if user is editing email */ 11013 if(f.k==="email"){var oldSugg=wrap.parentNode&&wrap.parentNode.querySelector(".caf-bk-email-sugg");if(oldSugg)oldSugg.remove();} 11014 }); 11015 /* Email typo detection on blur */ 11016 if(f.k==="email"){ 11017 inp.addEventListener("blur",function(){ 11018 var v=inp.value.trim();if(!v||v.indexOf("@")<0)return; 11019 var sugg=suggestEmailDomain(v);if(!sugg)return; 11020 var existing=wrap.parentNode&&wrap.parentNode.querySelector(".caf-bk-email-sugg");if(existing)existing.remove(); 11021 var s=document.createElement("div");s.className="caf-bk-email-sugg"; 11022 s.style.cssText="font-size:12px;color:#0369a1;background:#f0f9ff;border:1px solid #bae6fd;border-radius:8px;padding:6px 10px;margin-top:4px;cursor:pointer;display:flex;align-items:center;gap:6px"; 11023 s.innerHTML='<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg> '+escapeHtml(bkT("label_did_you_mean"))+' <strong>'+escapeHtml(sugg)+'</strong> <span style="margin-left:auto;text-decoration:underline">'+escapeHtml(bkT("btn_use"))+'</span>'; 11024 s.addEventListener("click",function(){inp.value=sugg;self.bkClient.email=sugg;wrap.classList.add("caf-bk-inp-valid");s.remove();self._bkLogEvent&&self._bkLogEvent("email_typo_corrected");}); 11025 wrap.parentNode.insertBefore(s,wrap.nextSibling); 11026 self._bkLogEvent&&self._bkLogEvent("email_typo_suggested"); 11027 }); 11028 } 11029 /* move to next field on Enter (mobile keyboard "next" button) */ 11030 inp.addEventListener("keydown",function(e){ 11031 if(e.key==="Enter"){e.preventDefault();var allInps=fg.querySelectorAll(".caf-bk-inp");var idx=-1;for(var ai=0;ai<allInps.length;ai++){if(allInps[ai]===inp){idx=ai;break;}}if(idx>=0&&idx<allInps.length-1){allInps[idx+1].focus();}else{self._bkConfirm();}} 11032 }); 11033 wrap.appendChild(inp);fg.appendChild(wrap); 11034 _wrapsByKey[f.k]=wrap;_inputsByKey[f.k]=inp; 11035 /* Per il CF: mostro inline il motivo specifico di invali
11035dità (struttura, 11036 * mese, giorno, checksum, ecc.) sotto al campo, e aggancio il gate. */ 11037 if(f.k==="codfis"){ 11038 _cfReasonBox=document.createElement("div"); 11039 _cfReasonBox.className="caf-bk-cf-reason"; 11040 var _cfReasonId=quickId();_cfReasonBox.id=_cfReasonId;_cfReasonBox.setAttribute("role","alert");inp.setAttribute("aria-describedby",_cfReasonId); 11041 _cfReasonBox.style.cssText="font-size:11px;line-height:1.35;margin:4px 2px 0;min-height:14px"; 11042 fg.appendChild(_cfReasonBox); 11043 function _refreshCfReason(){ 11044 var v=self.bkClient.codfis||""; 11045 if(!v){_cfReasonBox.textContent="";_cfReasonBox.style.color="";return;} 11046 var r=validateCodiceFiscale(v); 11047 if(r.ok){_cfReasonBox.textContent="â "+bkT("cf_valid_ok");_cfReasonBox.style.color="#047857";} 11048 else{_cfReasonBox.textContent=r.reason;_cfReasonBox.style.color="#be123c";} 11049 } 11050 inp.addEventListener("input",function(){_refreshCfReason();_applyCfGate();}); 11051 inp.addEventListener("blur",function(){_refreshCfReason();}); 11052 _refreshCfReason(); 11053 } 11054 }); 11055 _applyCfGate(); 11056 cdw.appendChild(fg); 11057 if(self.bkFormErr){var fe=document.createElement("div");fe.className="caf-bk-ferr";fe.setAttribute("role","alert");fe.textContent=self.bkFormErr;cdw.appendChild(fe);} 11058 /* PRIVACY â consenso OBBLIGATORIO (checkbox) allo step Dati: opt-in 11059 * esplicito che gating "Prosegui al riepilogo" insieme al CF valido. 11060 * Sostituisce la vecchia microcopy passiva (GDPR Art. 6/7). */ 11061 cdw.appendChild(self._bkBuildConsentBox(function(){_applyCfGate();})); 11062 var cb=document.createElement("button");cb.className="caf-bk-pri caf-bk-cta-sticky";cb.style.cssText="background:linear-gradient(135deg,"+c+","+c+"cc);box-shadow:0 6px 20px "+c+"33";cb.textContent=bkT("btn_next")+" \u203A";cb.addEventListener("click",function(){if(!_isCfOk()){self.bkFormErr=bkT("err_cf_proceed");self._bkRender();return;}if(!self.bkPrivacyOk){self._bkShowToast&&self._bkShowToast({type:"error",msg:bkT("consent_required")});return;}self._bkConfirm();});cdw.appendChild(cb);_proceedBtn=cb;_applyCfGate(); 11063 cdw.appendChild(mkBot(true));el.appendChild(cdw);break; 11064 } 11065 case "confirm":{ 11066 var cfw=document.createElement("div");cfw.className="caf-bk-slide-fwd"; 11067 var tw=document.createElement("div");tw.style.cssText="display:flex;align-items:center;gap:10px;margin-bottom:12px"; 11068 tw.innerHTML='<div style="width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,'+c+'20,'+c+'10);display:flex;align-items:center;justify-content:center"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="'+c+'" stroke-width="2.5"><path d="M9 12l2 2 4-4"/><circle cx="12" cy="12" r="10"/></svg></div>'; 11069 var ct=document.createElement("span");ct.className="caf-bk-title";ct.style.cssText="margin-bottom:0;font-size:15px";ct.textContent=bkT("btn_confirm");tw.appendChild(ct);cfw.appendChild(tw); 11070 /* Slot reservation timer */ 11071 self._bkRenderSlotTimer(cfw); 11072 var sum=document.createElement("div");sum.className="caf-bk-sum";sum.style.animation="caf-fade-up .35s ease-out"; 11073 var sp=self.bkSelSlot||{},sd=self.bkSelSede||{},sv=self.bkSelServ||{},cd=self.bkClient; 11074 sum.innerHTML= 11075 '<div style="font-size:9px;text-transform:uppercase;letter-spacing:1px;color:#5b6573;font-weight:600;margin-bottom:6px">'+escapeHtml(bkT("label_appointment"))+'</div>'+ 11076 '<div class="caf-bk-sr"><div class="caf-bk-si"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></div><span class="caf-bk-sv">'+escapeHtml(fmtDate(sp.data||""))+" "+escapeHtml(fmtTime(sp.ora_inizio||""))+" - "+escapeHtml(fmtTime(sp.ora_fine||""))+"</span></div>"+ 11077 '<div class="caf-bk-sr"><div class="caf-bk-si"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></div><div><span class="caf-bk-sv">'+escapeHtml(sd.descrizione||"")+'</span><span class="caf-bk-ss">'+escapeHtml((sd.indirizzo||"")+", "+(sd.citta||""))+"</span></div></div>"+ 11078 '<div class="caf-bk-sr"><div class="caf-bk-si"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg></div><span class="caf-bk-sv">'+escapeHtml(sv.desonline||sv.descrizione||"")+"</span></div>"+ 11079 "<hr>"+ 11080 '<div style="font-size:9px;text-transform:uppercase;letter-spacing:1px;color:#5b6573;font-weight:600;margin-bottom:6px">'+escapeHtml(bkT("t_data"))+'</div>'+ 11081 '<div class="caf-bk-sr"><div class="caf-bk-si"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></div><div><span class="caf-bk-sv">'+escapeHtml(cd.nome+" "+cd.cognome)+'</span><span class="caf-bk-ss">'+escapeHtml(bkT("label_form_codfis"))+': '+escapeHtml(cd.codfis)+"</span></div></div>"+ 11082 '<div class="caf-bk-sr"><div class="caf-bk-si"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2Z"/><path d="m22 6-10 7L2 6"/></svg></div><span class="caf-bk-sv">'+escapeHtml(cd.email)+"</span></div>"+ 11083 '<div class="caf-bk-sr"><div class="caf-bk-si"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="2"/>
11083<line x1="12" y1="18" x2="12.01" y2="18"/></svg></div><span class="caf-bk-sv">'+escapeHtml(cd.cellulare)+"</span></div>"; 11084 cfw.appendChild(sum); 11085 var br=document.createElement("div");br.style.cssText="display:flex;gap:8px;margin-top:12px"; 11086 var sb=document.createElement("button");sb.className="caf-bk-pri caf-bk-cta-sticky";sb.style.cssText="flex:1;margin-top:0;background:linear-gradient(135deg,"+c+","+c+"cc);box-shadow:0 6px 24px "+c+"33";sb.innerHTML='<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" style="flex-shrink:0"><path d="M20 6L9 17l-5-5"/></svg> '+escapeHtml(bkT("btn_confirm"));sb.addEventListener("click",function(){if(!self.bkPrivacyOk){self._bkShowToast&&self._bkShowToast({type:"error",msg:bkT("consent_required")});return;}self._bkSubmit();});br.appendChild(sb); 11087 var eb=document.createElement("button");eb.className="caf-bk-act2";eb.style.padding="13px 16px";eb.innerHTML='<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> '+escapeHtml(bkT("btn_edit"));eb.addEventListener("click",function(){self._bkBack();});br.appendChild(eb); 11088 /* PRIVACY â checkbox di consenso obbligatoria: gate del bottone Conferma */ 11089 var applyConsentGate=function(ok){sb.disabled=!ok;sb.style.opacity=ok?"":"0.5";sb.style.cursor=ok?"":"not-allowed";sb.style.filter=ok?"":"grayscale(0.35)";sb.title=ok?"":bkT("consent_required");}; 11090 if(!self.bkPrivacyOk){cfw.appendChild(self._bkBuildConsentBox(applyConsentGate));} 11091 applyConsentGate(!!self.bkPrivacyOk); 11092 cfw.appendChild(br); 11093 cfw.appendChild(mkBot(false));el.appendChild(cfw);break; 11094 } 11095 case "done":{ 11096 var di=document.createElement("div");di.className="caf-bk-done-ic";di.style.cssText="background:linear-gradient(135deg,"+c+","+c+"b3);box-shadow:0 8px 24px "+c+"44"; 11097 di.innerHTML='<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round"><path d="M20 6L9 17l-5-5"/></svg>'; 11098 /* confetti particles */ 11099 var confColors=[c,"#f59e0b","#8b5cf6","#ef4444","#06b6d4","#10b981"]; 11100 for(var ci=0;ci<12;ci++){var cf=document.createElement("div");cf.className="caf-bk-confetti";cf.style.cssText="background:"+confColors[ci%confColors.length]+";left:"+(20+Math.random()*60)+"%;"+"top:"+(20+Math.random()*60)+"%;"+"animation-delay:"+(ci*80)+"ms;"+"animation-duration:"+(800+Math.random()*400)+"ms;"+"width:"+(4+Math.random()*4)+"px;height:"+(4+Math.random()*4)+"px;"+"border-radius:"+(Math.random()>.5?"50%":"2px");di.appendChild(cf);} 11101 el.appendChild(di); 11102 var dt=document.createElement("div");dt.className="caf-bk-done-t";dt.textContent="\uD83C\uDF89 "+bkT("label_done_h");el.appendChild(dt); 11103 if(self.bkId){var bid=document.createElement("div");bid.className="caf-bk-done-id";bid.style.cssText="background:"+c+"15;color:"+c;bid.textContent=bkT("label_done_id")+self.bkId;el.appendChild(bid);} 11104 var ds=document.createElement("div");ds.className="caf-bk-sum";ds.style.marginTop="10px";var dsp=self.bkSelSlot||{}; 11105 ds.innerHTML='<div class="caf-bk-sr"><div class="caf-bk-si">
11105<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></div><span class="caf-bk-sv">'+escapeHtml(fmtDate(dsp.data||""))+" | "+escapeHtml(fmtTime(dsp.ora_inizio||""))+" - "+escapeHtml(fmtTime(dsp.ora_fine||""))+"</span></div>"+ 11106 '<div class="caf-bk-sr"><div class="caf-bk-si"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></div><span>'+escapeHtml((self.bkSelSede||{}).descrizione||"")+"</span></div>"+ 11107 '<div class="caf-bk-sr"><div class="caf-bk-si"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg></div><span>'+escapeHtml((self.bkSelServ||{}).desonline||(self.bkSelServ||{}).descrizione||"")+"</span></div>"; 11108 el.appendChild(ds); 11109 var en=document.createElement("div");en.className="caf-bk-email";en.innerHTML='<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0"><path d="M9 12l2 2 4-4"/><circle cx="12" cy="12" r="10"/></svg> '+bkT("label_done_email","<strong>"+escapeHtml(self.bkClient.email)+"</strong>");el.appendChild(en); 11110 /* Azioni post-conferma: ICS + WhatsApp share = riduce no-show */ 11111 var actGrid=document.createElement("div");actGrid.style.cssText="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:12px"; 11112 var icsBtn=document.createElement("button");icsBtn.className="caf-bk-act2";icsBtn.style.cssText="justify-content:center"; 11113 icsBtn.innerHTML='<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M12 14v4M10 16h4"/></svg> '+escapeHtml(bkT("btn_calendar")); 11114 icsBtn.addEventListener("click",function(){ 11115 var ics=buildIcsForBooking({bookingId:self.bkId,sede:self.bkSelSede,servizio:self.bkSelServ,slot:self.bkSelSlot}); 11116 if(ics)downloadIcs(ics,"caf-cisl-"+(self.bkId||"appuntamento")+".ics"); 11117 self._bkLogEvent&&self._bkLogEvent("ics_downloaded"); 11118 }); 11119 actGrid.appendChild(icsBtn); 11120 var waBtn=document.createElement("button");waBtn.className="caf-bk-act2";waBtn.style.cssText="justify-content:center"; 11121 waBtn.innerHTML='<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg> WhatsApp'; 11122 waBtn.addEventListener("click",function(){ 11123 var sp=self.bkSelSlot||{};var sd=self.bkSelSede||{};var sv=self.bkSelServ||{}; 11124 var msg="Ho prenotato un appuntamento al CAF CISL: "+(sv.descrizione||"")+" il "+fmtDate(sp.data||"")+" alle "+fmtTime(sp.ora_inizio||"")+" presso "+(sd.descrizione||"")+(sd.indirizzo?" ("+sd.indirizzo+(sd.citta?", "+sd.citta:"")+")":"")+(self.bkId?" â ID: "+self.bkId:""); 11125 window.open(whatsappShareUrl(msg),"_blank","noopener"); 11126 self._bkLogEvent&&self._bkLogEvent("whatsapp_shared"); 11127 }); 11128 actGrid.appendChild(waBtn); 11129 el.appendChild(actGrid); 11130 /* Mailto reminder secondario: utile a chi non usa WhatsApp/calendario */ 11131 var mlBtn=document.createElement("a");mlBtn.className="caf-bk-act2"; 11132 mlBtn.style.cssText="margin-top:8px;width:100%;justify-content:center;display:flex;align-items:center;gap:6px;text-decoration:none;text-align:center;cursor:pointer"; 11133 mlBtn.href=mailtoReminder({email:self.bkClient.email,bookingId:self.bkId,sede:self.bkSelSede,servizio:self.bkSelServ,slot:self.bkSelSlot}); 11134 mlBtn.innerHTML='<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2Z"/><path d="m22 6-10 7L2 6"/></svg> '+escapeHtml(bkT("btn_email_reminder")); 11135 mlBtn.addEventListener("click",function(){self._bkLogEvent&&self._bkLogEvent("mailto_clicked");}); 11136 el.appendChild(mlBtn); 11137 /* Mini-survey emoji: misura NPS post-prenotazione senza form */ 11138 var srvWrap=document.createElement("div");srvWrap.style.cssText="margin-top:14px;padding:10px 12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;text-align:center"; 11139 var srvLbl=document.createElement("div");srvLbl.style.cssText="font-size:11.5px;color:#5b6573;margin-bottom:6px";srvLbl.textContent=bkT("label_done_survey");srvWrap.appendChild(srvLbl); 11140 var srvRow=document.createElement("div");srvRow.style.cssText="display:flex;justify-content:center;gap:14px"; 11141 [["happy","\uD83D\uDE0A",bkT("label_done_good")],["neutral","\uD83D\uDE10",bkT("label_done_meh")],["sad","\uD83D\uDE1E",bkT("label_done_bad")]].forEac
11141h(function(item){ 11142 var b=document.createElement("button");b.type="button";b.style.cssText="background:none;border:none;font-size:22px;cursor:pointer;padding:4px 6px;border-radius:8px;transition:background .2s;line-height:1"; 11143 b.title=item[2];b.textContent=item[1]; 11144 b.addEventListener("click",function(){ 11145 self._bkLogEvent&&self._bkLogEvent("post_booking_survey",{rating:item[0]}); 11146 srvRow.innerHTML='<div style="font-size:11.5px;color:#047857;font-weight:600;padding:6px 0">'+escapeHtml(bkT("label_done_thanks"))+'</div>'; 11147 }); 11148 b.addEventListener("mouseenter",function(){b.style.background="#e2e8f0";}); 11149 b.addEventListener("mouseleave",function(){b.style.background="";}); 11150 srvRow.appendChild(b); 11151 }); 11152 srvWrap.appendChild(srvRow); 11153 el.appendChild(srvWrap); 11154 var dc=document.createElement("button");dc.className="caf-bk-act2";dc.style.cssText="margin-top:8px;width:100%;justify-content:center";dc.textContent=bkT("btn_close");dc.addEventListener("click",function(){self._bkReset();});el.appendChild(dc); 11155 break; 11156 } 11157 case "error":{ 11158 var isRec=self.bkErrReturn!==null,isAB=self.bkErrCode==="already_booked",isST=self.bkErrCode==="slot_taken",isNR=["no_services","no_sedi","no_spazi"].indexOf(self.bkErrCode)!==-1,isCE=["network","timeout"].indexOf(self.bkErrCode)!==-1; 11159 var eCol=isNR?"#f59e0b":"#f43f5e",eBg=isNR?"#fef3c7":"#ffe4e6"; 11160 var ew=document.createElement("div");
11160ew.className="caf-bk-ew"; 11161 var ei=document.createElement("div");ei.className="caf-bk-ei";ei.style.background=eBg;ei.innerHTML='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="'+eCol+'" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/></svg>';ew.appendChild(ei); 11162 var etd=document.createElement("div");var ett=document.createElement("div");ett.className="caf-bk-et";ett.style.color=eCol;ett.textContent=isAB?bkT("err_h_already_booked"):isST?bkT("err_h_slot_taken"):isNR?bkT("sr_no_results"):isCE?bkT("err_h_network"):bkT("err_h_default");etd.appendChild(ett); 11163 var emm=document.createElement("div");emm.className="caf-bk-em";emm.textContent=self.bkErrMsg;etd.appendChild(emm);ew.appendChild(etd);el.appendChild(ew); 11164 var acts=document.createElement("div");acts.className="caf-bk-acts"; 11165 if(isRec){var bb=document.createElement("button");bb.className="caf-bk-act";bb.textContent="\u2190 "+(isAB?bkT("btn_choose_other"):isST?bkT("btn_choose_other_slot"):isNR?bkT("btn_back"):bkT("btn_retry"));bb.addEventListener("click",function(){self._bkBack();});acts.appendChild(bb);} 11166 if(isCE||(!isRec&&!isCE)){var rb=document.createElement("button");rb.className="caf-bk-act";rb.textContent="\u21BB "+(isCE?bkT("btn_retry"):bkT("btn_restart"));rb.addEventListener("click",function(){self._bkStart();});acts.appendChild(rb);} 11167 var cb2=document.createElement("button");cb2.className="caf-bk-act2";cb2.textContent=bkT("btn_close");cb2.addEventListener("click",function(){self._bkReset();});acts.appendChild(cb2);el.appendChild(acts); 11168 /* WCAG 4.1.3 (#C3): errore annunciato anche nel wizard inline */ 11169 self._bkFsAnnounce(bkT("label_error")+": "+ett.textContent+(self.bkErrMsg?". "+self.bkErrMsg:"")); 11170 break; 11171 }} 11172 this._scrollToBottom(); 11173 /* Voice mode: announce booking step via TTS so user can interact by voice */ 11174 if (this._voiceMode) { 11175 var _voiceSteps = ["selectProvincia","selectServizio","selectSede","selectSlot","collectData","confirm","done","error"]; 11176 if (_voiceSteps.indexOf(this.bkStep) !== -1) { 11177 this._bkVoiceAnnounce(); 11178 } 11179 } 11180 }; 11181 11182 /* ââ prominent "Espandi prenotazione" banner (under header, outside conversation) ââ */ 11183 CafChat.prototype._updateBkExpandBanner = function () { 11184 if (!this._bkExpandBannerEl) return; 11185 var show = this.isOpen 11186 && this.bkStep && this.bkStep !== "idle" 11187 && this.bkStep !== "done" 11188 && this.bkStep !== "error" 11189 && !this._bkFsMode 11190 && !this._voiceMode; 11191 if (show) this._bkExpandBannerEl.classList.add("active"); 11192 else this._bkExpandBannerEl.classList.remove("active"); 11193 }; 11194 11195 /* ââ persistent booking CTA âââââââââââââââââââââââââââââââ */ 11196 11197 /* parse user messages + service keywords to craft dynamic CTA text */ 11198 CafChat.prototype._getBkCtaContext = function () { 11199 /* SOLO servizi gestiti dal CAF. NASPI, Assegno Unico (INPS), pensioni 11200 * e altri servizi di patronato NON appaiono qui: il CTA banner / le 11201 * ghost suggestions non devono mai mostrare "Prenota NASPI" ecc. 11202 * Pattern più specifici PRIMA dei più generici (primo match vince). */ 11203 var services = [ 11204 {k:/\b730\b/i, name:"730"}, 11205 {k:/\bise[ue]\b/i, name:"ISEE"}, 11206 {k:/\bdsu\b/i, name:"DSU"}, 11207 {k:/\bcud\b/i, name:"CUD"}, 11208 {k:/\bsuccessi[oò]n/i, name:"Successione"}, 11209 {k:/\b(colf|badant\w*)\b/i, name:"Colf e Badanti"}, 11210 {k:/\b(imu|tasi|tari)\b/i, name:"IMU/TASI/TARI"}, 11211 {k:/\bmodello\s+redditi\b|\bunico\b/i, name:"Modello REDDITI"}, 11212 {k:/\bmodello\s+red\b|\bred\b/i, name:"Modello RED"}, 11213 {k:/\binvciv\b/i, name:"INVCIV"}, 11214 {k:/\baccas\/?ps\b|\bassegno\s+sociale\b/i, name:"ACCAS/PS"}, 11215 {k:/\bcedolare\s+secca\b|\blocazion[ei]\b/i, name:"Locazione"}, 11216 {k:/\bdelega\s+unica\b/i, name:"Delega Unica"}, 11217 {k:/\bvisur[ae]\s+catastal[ei]\b|\bvisura\b/i, name:"Visura Catastale"}, 11218 {k:/\bpartita\s+iva\b|\bsapi\b/i, name:"Partita IVA"}, 11219 {k:/\bdichiarazion/i, name:"dichiarazione"}, 11220 {k:/\bbonus\b/i, name:"bonus"} 11221 ]; 11222 /* scan last few user messages */ 11223 var recent = this.messages.slice(-6).filter(function(m){return m.role==="user";}); 11224 var foundSvc = null, foundLoc = null; 11225 var provinceNames = ["firenze","prato","pistoia","lucca","pisa","livorno","siena","arezzo","grosseto","massa","carrara","roma","milano","bologna","torino","napoli","bari","palermo","catania","venezia","verona","padova","genova"]; 11226 for (var i = recent.length - 1; i >= 0; i--) { 11227 var t = recent[i].content || ""; 11228 if (!foundSvc) { 11229 for (var j = 0; j < services.length; j++) { 11230 if (services[j].k.test(t)) { foundSvc = services[j].name; break; } 11231 } 11232 } 11233 if (!foundLoc) { 11234 var tl = t.toLowerCase(); 11235 for (var k2 = 0; k2 < provinceNames.length; k2++) { 11236 if (tl.indexOf(provinceNames[k2]) !== -1) { foundLoc = provinceNames[k2].charAt(0).toUpperCase()+provinceNames[k2].slice(1); break; } 11237 } 11238 } 11239 if (foundSvc && foundLoc) break; 11240 } 11241 /* count non-booking assistant replies to decide proactive state */ 11242 var botReplies = this.messages.filter(function(m){return m.role==="assistant"&&m.id!=="welcome"&&m.content&&m.content!=="@@BOOKING@@"&&!(typeof m.content==="string"&&m.content.indexOf("@@BOOKING_LAUNCH@@")===0);}).length;
11243 return { service: foundSvc, location: foundLoc, botReplies: botReplies }; 11244 }; 11245 11246 CafChat.prototype._renderBkCtaContent = function () { 11247 if (!this._bkCtaEl) return; 11248 var self = this; 11249 var ctx = this._getBkCtaContext(); 11250 var proactive = ctx.botReplies >= 3; 11251 var title = "Prenota il tuo appuntamento"; 11252 var sub = "â± ~2 minuti · Veloce, online, in pochi click"; 11253 var btnLbl = "Prenota ora"; 11254 if (ctx.service && ctx.location) { 11255 title = "Prenota " + ctx.service + " a " + ctx.location; 11256 sub = "Ho trovato il servizio per te, ti porto alla scelta dell'orario"; 11257 btnLbl = "Prenota"; 11258 } else if (ctx.service) { 11259 title = "Prenota il servizio " + ctx.service; 11260 sub = "Scegli sede e orario in pochi secondi"; 11261 btnLbl = "Prenota " + ctx.service; 11262 } else if (ctx.location) { 11263 title = "Prenota a " + ctx.location; 11264 sub = "Vedi gli slot disponibili nella tua zona"; 11265 btnLbl = "Prenota ora"; 11266 } else if (proactive) { 11267 title = "Hai bisogno di un appuntamento?"; 11268 sub = "Posso aiutarti a prenotare il servizio CAF che ti serve"; 11269 btnLbl = "Prenota adesso"; 11270 } 11271 this._bkCtaEl.classList.toggle("proactive", proactive); 11272 var badge = '<span class="caf-bk-cta-badge"><span class="d"></span>Disponibili</span>'; 11273 this._bkCtaEl.innerHTML = 11274 '<button type="button" class="caf-bk-cta-dismiss" aria-label="' + escapeHtml(bkT("aria_hide")) + '">' + 11275 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>' + 11276 '</button>' + 11277 '<div class="caf-bk-cta-ic">' + 11278 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">' + 11279 '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M9 16l2 2 4-4"/>' + 11280 '</svg>' + 11281 '</div>' + 11282 '<div class="caf-bk-cta-txt">' + 11283 '<div class="caf-bk-cta-t">' + escapeHtml(title) + badge + '</div>' + 11284 '<div class="caf-bk-cta-s">' + escapeHtml(sub) + '</div>' + 11285 '</div>' + 11286 '<button type="button" class="caf-bk-cta-btn">' + escapeHtml(btnLbl) + 11287 ' <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>' + 11288 '</button>'; 11289 /* wire events (innerHTML wipes listeners) */ 11290 var activate = function () { 11291 if (self._bkFsMode) return; 11292 if (self.bkStep !== "idle") return; /* CTA is hidden in this case anyway */ 11293 self._startBookingFromCta(); 11294 }; 11295 this._bkCtaEl.onclick = activate; 11296 var primaryBtn = this._bkCtaEl.querySelector(".caf-bk-cta-btn"); 11297 if (primaryBtn) { 11298 primaryBtn.addEventListener("click", function (e) { 11299 e.stopPropagation(); 11300 activate(); 11301 }); 11302 } 11303 var dismissBtn = this._bkCtaEl.querySelector(".caf-bk-cta-dismiss"); 11304 if (dismissBtn) { 11305 dismissBtn.addEventListener("click", function (e) { 11306 e.stopPropagation(); 11307 self._bkCtaDismissed = true; 11308 self._updateBkCta(); 11309 }); 11310 } 11311 }; 11312 11313 CafChat.prototype._updateBkCta = function () { 11314 if (!this._bkCtaEl) return; 11315 var baseOK = this._bkEnabled === true 11316 && this.bkStep === "idle" 11317 && !this._bkFsMode 11318 && !this._voiceMode 11319 && this.isOpen 11320 && !this._needsEmail() 11321 && !this._needsConsent(); 11322 var showFull = baseOK && !this._bkCtaDismissed; 11323 var showMini = baseOK && this._bkCtaDismissed; 11324 if (showFull) this._renderBkCtaContent(); 11325 this._bkCtaEl.style.display = showFull ? "flex" : "none"; 11326 if (this._bkCtaMiniEl) this._bkCtaMiniEl.style.display = showMini ? "inline-flex" : "none"; 11327 /* update ghost suggestions visibility */ 11328 this._updateGhostSug(); 11329 }; 11330 11331 /* ââ ghost suggestion chips (suggest a booking after idle) âââââââ */ 11332 CafChat.prototype._updateGhostSug = function () { 11333 if (!this._ghostSugEl) return; 11334 var show = this._bkEnabled === true 11335 && this.bkStep === "idle" 11336 && !this._bkFsMode 11337 && !this._voiceMode 11338 && this.isOpen 11339 && !this._needsEmail() 11340 && !this._needsConsent() 11341 && !!this._ghostSugShouldShow; 11342 if (!show) { this._ghostSugEl.style.display = "none"; return; } 11343 var self = this; 11344 var ctx = this._getBkCtaContext(); 11345 var suggestions; 11346 if (ctx.service) { 11347 suggestions = [ 11348 "Vorrei prenotare il " + ctx.service, 11349 "Quali documenti servono per " + ctx.service + "?", 11350 "Ho bisogno di un appuntamento" 11351 ]; 11352 } else { 11353 suggestions = [ 11354 "Vorrei prenotare un appuntamento", 11355 "Devo fare il 730", 11356 "Ho bisogno dell'ISEE" 11357 ]; 11358 } 11359 this._ghostSugEl.innerHTML = ""; 11360 suggestions.forEach(function (s) { 11361 var chip = document.createElement("button"); 11362 chip.type = "button"; 11363 chip.className = "caf-ghost-sug-chip"; 11364 chip.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>
11364' + escapeHtml(s); 11365 chip.addEventListener("click", function () { 11366 self.inputEl.value = s; 11367 self._ghostSugShouldShow = false; 11368 self._updateGhostSug(); 11369 self._send(s); 11370 }); 11371 self._ghostSugEl.appendChild(chip); 11372 }); 11373 this._ghostSugEl.style.display = "flex"; 11374 }; 11375 11376 /* schedule ghost suggestions after input idle */ 11377 CafChat.prototype._scheduleGhostSug = function () { 11378 var self = this; 11379 if (this._ghostSugTimer) { clearTimeout(this._ghostSugTimer); this._ghostSugTimer = null; } 11380 this._ghostSugShouldShow = false; 11381 this._updateGhostSug(); 11382 this._ghostSugTimer = setTimeout(function () { 11383 if (!self.inputEl || self.inputEl.value.trim().length > 0) return; 11384 if (!self.isOpen || self._voiceMode || self._bkFsMode) return; 11385 if (self.bkStep !== "idle") return; 11386 self._ghostSugShouldShow = true; 11387 self._updateGhostSug(); 11388 }, 15000); 11389 }; 11390 11391 CafChat.prototype._startBookingFromCta = function () { 11392 var self = this; 11393 /* gate consenso privacy: nessuna prenotazione (e nessun messaggio 11394 persistito) prima dell'approvazione. open() mostra il gate. */ 11395 if (self._needsConsent()) { self.open(); return; } 11396 if (self.bkStep !== "idle") return; 11397 /* inject placeholder booking message (same as intent-triggered flow) */ 11398 var um = {id: quickId(), role: "user", content: "Vorrei prenotare un appuntamento", timestamp: Date.now()}; 11399 self.messages.push(um); 11400 var bk = {id: quickId(), role: "assistant", content: "@@BOOKING@@", timestamp: Date.now()}; 11401 self.messages.push(bk); 11402 self._renderMessages(); 11403 self._saveMessages(); 11404 /* start booking flow inline (user can click Espandi to go fullscreen manually) */ 11405 self._bkStart(); 11406 self._updateBkCta(); 11407 }; 11408 11409 /* ââ Public entry: apri il pannello e porta dritto al wizard di 11410 * prenotazione. Se una prenotazione è già in corso (bkStep != "idle") 11411 * NON la resetta: riapre il pannello e rientra in fullscreen dove l'utente 11412 * era rimasto. Altrimenti avvia il flusso standard via _startBookingFromCta. */ 11413 CafChat.prototype.openBooking = function () { 11414 this.open(); 11415 /* gate consenso privacy pendente: open() ha appena mostrato il gate, 11416 il wizard NON deve partire (ne' visibile ne' nascosto sotto 11417 l'overlay). L'utente approva e poi riprova. */ 11418 if (this._needsConsent()) return; 11419 if (this.bkStep && this.bkStep !== "idle") { 11420 if (!this._bkFsMode) { try { this._bkEnterFs(); } catch (_e) {} } 11421 } else { 11422 this._startBookingFromCta(); 11423 } 11424 }; 11425 11426 /* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 11427 ââ FULLSCREEN BOOKING â premium expanded layout 11428 ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 11429 11430 /* A11y (WCAG 4.1.3): annuncia txt nella live region DENTRO l'overlay 11431 * fullscreen (con aria-modal le AT possono sopprimere gli annunci da nodi 11432 * fuori dal dialog). Fallback: srAnnouncer del pannello chat quando 11433 * l'overlay non esiste (wizard inline). Il piccolo delay lascia alle AT il 11434 * tempo di registrare la region appena ri-appesa dal re-render prima che 11435 * il testo cambi (altrimenti l'annuncio andrebbe perso). */ 11436 CafChat.prototype._bkFsAnnounce = function(txt){ 11437 var self=this; 11438 setTimeout(function(){
11439 try{ 11440 var t=(self._bkFsMode&&self._bkFsAnnouncer)?self._bkFsAnnouncer:self.srAnnouncer; 11441 if(t)t.textContent=txt; 11442 }catch(_e){} 11443 },80); 11444 }; 11445 11446 CafChat.prototype._bkEnterFs = function(){ 11447 if(this._bkFsMode)return; 11448 this._bkFsMode=true; 11449 if(this._updateBkExpandBanner)this._updateBkExpandBanner(); 11450 /* auto enter desktop fullscreen for best experience */ 11451 this._bkFsWasFs=this.isFullscreen; 11452 if(!this.isFullscreen&&window.innerWidth>=640&&typeof this._enterFullscreen==="function"){ 11453 try{this._enterFullscreen();}catch(e){} 11454 } 11455 /* create overlay element inside panel */ 11456 var ov=document.createElement("div");ov.className="caf-bk-fs";ov.setAttribute("role","dialog");ov.setAttribute("aria-modal","true");ov.setAttribute("aria-label",bkT("aria_booking")); 11457 this._bkFsEl=ov; 11458 this.panelEl.appendChild(ov); 11459 /* A11y (WCAG 4.1.3): live region dentro il dialog aria-modal per gli 11460 * annunci del wizard (errori, conferma, esiti ricerca). */ 11461 var fsAnn=document.createElement("div");fsAnn.className="caf-sr-only"; 11462 fsAnn.setAttribute("aria-live","polite");fsAnn.setAttribute("aria-atomic","true"); 11463 this._bkFsAnnouncer=fsAnn;ov.appendChild(fsAnn); 11464 /* ESC to close */ 11465 var self=this; 11466 this._bkFsEscHandler=function(e){if(e.key==="Escape"){ 11467 /* Durante l'invio della prenotazione (non idempotente, no auto-retry) NON 11468 * permettiamo l'uscita con Esc: interromperebbe lo stato mentre la POST 11469 * "prenota" è in volo â prenotazione creata e persa lato client + storico 11470 * incoerente. (Nello step submitting non c'è nemmeno il bottone Annulla.) */ 11471 if(self.bkStep==="submitting")return; 11472 /* 1.4.13: se c'e' un popover transitorio aperto (docs/prefisso tel), 11473 * Escape chiude prima quello (lo fa il handler del pannello): non 11474 * uscire dal wizard nello stesso keypress. */ 11475 var _fsPops=self.shadow&&self.shadow.querySelectorAll(".caf-lang-picker,.caf-bk-docs-pop,.caf-bk-tel-cc-pop"); 11476 if(_fsPops&&_fsPops.length)return; 11477 e.preventDefault();self._bkExitFs();}}; 11478 document.addEventListener("keydown",this._bkFsEscHandler); 11479 /* lock body scroll */ 11480 this._bkFsPrevOverflow=document.body.style.overflow; 11481 document.body.style.overflow="hidden"; 11482 this._bkFsPrevFocus=(this.shadow&&this.shadow.activeElement)||document.activeElement; 11483 this._bkRenderFs(); 11484 try{var _fsClose=ov.querySelector(".caf-bk-fs-hclose");if(_fsClose)_fsClose.focus();}catch(_e){} 11485 this._trapTab(ov); 11486 }; 11487 11488 CafChat.prototype._bkExitFs = function(silent){ 11489 if(!this._bkFsMode)return; 11490 this._bkFsMode=false; 11491 if(this._updateBkExpandBanner)this._updateBkExpandBanner(); 11492 if(this._bkFsEl&&this._bkFsEl.parentNode){this._bkFsEl.parentNode.removeChild(this._bkFsEl);} 11493 this._bkFsEl=null;this._bkFsAnnouncer=null; 11494 if(this._bkFsEscHandler){document.removeEventListener("keydown",this._bkFsEscHandler);this._bkFsEscHandler=null;} 11495 if(this._bkFsPrevOverflow!==undefined){document.body.style.overflow=this._bkFsPrevOverflow;this._bkFsPrevOverflow=undefined;} 11496 try{if(this._bkFsPrevFocus&&this._bkFsPrevFocus.focus){this._bkFsPrevFocus.focus();}else if(this.inputEl){this.inputEl.focus();}}catch(_e){} 11497 this._bkFsPrevFocus=null; 11498 /* if we auto-entered panel fullscreen, auto-exit as well */ 11499 if(!this._bkFsWasFs&&this.isFullscreen&&typeof this._exitFullscreen==="function"){ 11500 try{this._exitFullscreen();}catch(e){} 11501 } 11502 this._bkFsWasFs=false; 11503 /* La prenotazione è SEMPRE fullscreen: se l'utente esce a metà flusso 11504 * (non da done/error e non programmaticamente), resetta la prenotazione 11505 * così la chat resta pulita e si può ripartire da zero. */ 11506 /* BUGFIX (input chat morto): "error" era escluso dal reset â uscendo da 11507 * un wizard in errore bkStep restava "error", _updateSendBtn continuava a 11508 * bloccare l'input e non si poteva piu' scrivere. Uscire da un errore = 11509 * abbandonare (nessuna POST in volo): si resetta come per gli altri step. */ 11510 if(!silent&&this.bkStep&&this.bkStep!=="idle"&&this.bkStep!=="done"){
11511 try{this._bkLogEvent&&this._bkLogEvent("fullscreen_exit_aborted",{step:this.bkStep});}catch(_e){} 11512 this._bkReset(true); 11513 } 11514 /* Riallinea SEMPRE input/placeholder/bottoni allo stato post-uscita: 11515 * senza questa chiamata l'input restava congelato (disabled) sullo 11516 * stato del wizard anche dopo il reset. */ 11517 try{this._updateSendBtn();}catch(_e){} 11518 }; 11519 11520 /* render premium fullscreen booking layout. 11521 * Wrappa in View Transitions API SOLO sui cambi di step (non su intra-step 11522 * re-render come cambio gender/email-typo) per evitare overhead inutile e 11523 * conflitti con le animazioni `caf-bk-fs-stage` esistenti. Browser senza 11524 * supporto: fallback diretto. */ 11525 /* A11y (WCAG 2.4.3): ogni render fullscreen fa innerHTML="" e distrugge 11526 * l'elemento con il focus â il browser lo spostava su body, FUORI dal 11527 * dialog aria-modal, a OGNI passaggio di step (verificato in CDP). Dopo il 11528 * render, se il focus era dentro l'overlay ed e' andato perso: su cambio 11529 * step va al titolo del nuovo step (tabindex=-1, annuncia il contesto allo 11530 * screen reader); su re-render intra-step prova a rifocalizzare l'elemento 11531 * equivalente (stesso tag+classe, es. l'input di ricerca sedi), poi ripiega 11532 * sul titolo. Se il focus NON era nell'overlay (render asincroni in 11533 * background) non tocca nulla. */ 11534 CafChat.prototype._bkFsRefocus = function(prevRef,stepChanged){ 11535 var ov=this._bkFsEl;if(!ov)return; 11536 if(!prevRef)return; 11537 try{ 11538 var act=(this.shadow&&this.shadow.activeElement)||document.activeElement; 11539 if(act&&act!==document.body&&ov.contains(act))return; 11540 var t=null; 11541 if(!stepChanged&&prevRef.tag&&prevRef.cls){ 11542 t=ov.querySelector(prevRef.tag.toLowerCase()+"."+prevRef.cls); 11543 if(t&&t.disabled)t=null; 11544 } 11545 if(!t){ 11546 t=ov.querySelector(".caf-bk-fs-step-h1"); 11547 if(t&&!t.hasAttribute("tabindex"))t.setAttribute("tabindex","-1"); 11548 } 11549 if(!t){ 11550 var f=this._trapFocusables(ov); 11551 t=f.length?f[0]:null; 11552 } 11553 if(t)t.focus(); 11554 }catch(_e){} 11555 }; 11556 CafChat.prototype._bkRenderFs = function(){ 11557 var prevStep=this._bkPrevFsStep; 11558 var curStep=this.bkStep; 11559 var stepChanged=prevStep!==curStep&&prevStep!==undefined; 11560 this._bkPrevFsStep=curStep; 11561 var self=this; 11562 var prevRef=null; 11563 try{ 11564 var _a=(this.shadow&&this.shadow.activeElement)||document.activeElement; 11565 if(_a&&this._bkFsEl&&this._bkFsEl.contains(_a)){ 11566 var _c=(typeof _a.className==="string")?_a.className.split(/\s+/)[0]:""; 11567 prevRef={tag:_a.tagName||"",cls:_c}; 11568 } 11569 }catch(_e){} 11570 if(stepChanged){ 11571 bkViewTransition(function(){self._bkRenderFsImpl();self._bkFsRefocus(prevRef,true);}); 11572 }else{ 11573 this._bkRenderFsImpl(); 11574 this._bkFsRefocus(prevRef,false); 11575 } 11576 }; 11577 /* DEMOPROMO: fascia fissa che dichiara la natura di esempio della 11578 * prenotazione. Stile inline (nessuna dipendenza dal CSS string del widget), 11579 * non chiudibile, ricostruita ad ogni render â presente su tutti gli step. */ 11580 CafChat.prototype._bkDemoRibbon = function(){ 11581 var d=document.createElement("div"); 11582 d.className="caf-bk-demo-ribbon"; 11583 d.setAttribute("role","note"); 11584 d.style.cssText="display:flex;align-items:center;gap:8px;justify-content:center;" 11585 +"background:linear-gradient(90deg,#fef3c7,#fde68a);color:#92400e;" 11586 +"font-size:12.5px;font-weight:600;line-height:1.35;padding:8px 14px;" 11587 +"border-bottom:1px solid #f59e0b;text-align:center;letter-spacing:.1px;"; 11588 d.innerHTML='<span aria-hidden="true">ð¬</span><span>'+escapeHtml(bkT("demo_ribbon"))+'</span>'; 11589 return d; 11590 }; 11591 11592 CafChat.prototype._bkRenderFsImpl = function(){ 11593 if(!this._bkFsEl)return; 11594 var el=this._bkFsEl,self=this,c=this.opts.color,o=this.opts; 11595 el.innerHTML=""; 11596 /* A11y: innerHTML="" ha appena staccato la live region (_bkFsAnnouncer). 11597 * Ri-attacchiamo LO STESSO nodo (ri-appenderlo la ri-registra presso le 11598 * AT); _bkFsAnnounce scrive con un piccolo delay proprio per coprire 11599 * questa finestra di re-render. */ 11600 if(this._bkFsAnnouncer)el.appendChild(this._bkFsAnnouncer); 11601 this._updateSendBtn(); 11602 var TOTAL=5; 11603 var stepLabels=[bkT("step_zone"),bkT("step_service"),bkT("step_office"),bkT("step_time"),bkT("step_data")]; 11604 var stepIndex={selectRegione:0,selectProvincia:0,selectServizio:1,selectSede:2,selectSlot:3,collectData:4,confirm:4,submitting:4,done:4,loading:-1,error:-1}; 11605 var cur=stepIndex[self.bkStep];if(cur===undefined)cur=0; 11606 11607 /* ââ HERO ââ */ 11608 var hero=document.createElement("div");
11608hero.className="caf-bk-fs-hero"; 11609 var hrow=document.createElement("div");hrow.className="caf-bk-fs-hrow"; 11610 var hlogo=document.createElement("div");hlogo.className="caf-bk-fs-hlogo"; 11611 hlogo.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M8 14h.01M12 14h.01M16 14h.01M8 18h.01M12 18h.01M16 18h.01"/></svg>'; 11612 hrow.appendChild(hlogo); 11613 var htxt=document.createElement("div");htxt.className="caf-bk-fs-htxt"; 11614 var ht=document.createElement("div");ht.className="caf-bk-fs-htitle";ht.textContent=bkT("hero_title");htxt.appendChild(ht); 11615 var hs=document.createElement("div");hs.className="caf-bk-fs-hsub";hs.textContent=bkT("hero_subtitle")+(o.name||"CAF CISL");htxt.appendChild(hs); 11616 hrow.appendChild(htxt); 11617 var hcl=document.createElement("button");hcl.className="caf-bk-fs-hclose";hcl.type="button";hcl.setAttribute("aria-label",bkT("aria_exit_fullscreen")); 11618 hcl.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'; 11619 hcl.addEventListener("click",function(){self._bkExitFs();}); 11620 hrow.appendChild(hcl); 11621 hero.appendChild(hrow); 11622 11623 /* step rail (#47: dot cliccabili per step già completati) */ 11624 if(cur>=0&&self.bkStep!=="done"){ 11625 var rail=document.createElement("div");rail.className="caf-bk-fs-rail"; 11626 var track=document.createElement("div");track.className="caf-bk-fs-rail-track"; 11627 var stepKeyByIndex=["selectProvincia","selectServizio","selectSede","selectSlot","collectData"]; 11628 for(var i=0;i<TOTAL;i++){ 11629 if(i>0){var ln=document.createElement("div");ln.className="caf-bk-fs-rail-line"+(i<=cur?" done":"");track.appendChild(ln);} 11630 var dot=document.createElement("div");dot.className="caf-bk-fs-rail-dot "+(i<cur?"done":i===cur?"active":"todo"); 11631 if(i<cur)dot.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" style="width:12px;height:12px"><path d="M20 6L9 17l-5-5"/></svg>'; 11632 else dot.textContent=i+1; 11633 if(i<cur){ 11634 (function(idx){ 11635 dot.style.cursor="pointer"; 11636 dot.setAttribute("role","button"); 11637 dot.setAttribute("tabindex","0"); 11638 dot.setAttribute("aria-label",bkT("aria_back_to_step",(idx+1),stepLabels[idx])); 11639 var go=function(){var k=stepKeyByIndex[idx];if(k)self._bkGoToStep(k);}; 11640 dot.addEventListener("click",go); 11641 dot.addEventListener("keydown",function(ev){if(ev.key==="Enter"||ev.key===" "){ev.preventDefault();go();}}); 11642 })(i); 11643 } 11644 track.appendChild(dot); 11645 } 11646 rail.appendChild(track); 11647 var lbls=document.createElement("div");lbls.className="caf-bk-fs-rail-labels";
11648 stepLabels.forEach(function(l,j){ 11649 var sp=document.createElement("div");sp.className="caf-bk-fs-rail-lbl"+(j===cur?" active":"")+(j<cur?" clickable":"");sp.textContent=l; 11650 if(j<cur){sp.style.cursor="pointer";sp.addEventListener("click",function(){var k=stepKeyByIndex[j];if(k)self._bkGoToStep(k);});} 11651 lbls.appendChild(sp); 11652 }); 11653 rail.appendChild(lbls); 11654 /* Apple-style continuous progress bar */ 11655 var progWrap=document.createElement("div");progWrap.className="caf-bk-fs-progress-wrap"; 11656 var progBar=document.createElement("div");progBar.className="caf-bk-fs-progress-bar"; 11657 var progPct=Math.round(((cur+1)/TOTAL)*100); 11658 progBar.style.width=progPct+"%"; 11659 progWrap.appendChild(progBar);rail.appendChild(progWrap); 11660 hero.appendChild(rail); 11661 } 11662 el.appendChild(hero); 11663 if(o.demopromo){el.appendChild(self._bkDemoRibbon());} 11664 11665 /* #50/#51 â banner di stato connessione: offline (bloccante info) o 11666 * "slow-2g/2g" (avviso non bloccante). Aggiornato in tempo reale via 11667 * listener registrati in _init (vedi _bkAttachNetworkListeners). */ 11668 if(!bkIsOnline()){ 11669 var nb=document.createElement("div");nb.className="caf-bk-net-banner offline"; 11670 nb.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l22 22"/><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/>
11670<path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/><path d="M10.71 5.05A16 16 0 0 1 22.58 9"/><path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>' 11671 +'<span><b>'+escapeHtml(bkT("label_offline_b"))+'</b>'+escapeHtml(bkT("label_offline_msg"))+'</span>'; 11672 el.appendChild(nb); 11673 }else if(bkIsSlowNetwork()){ 11674 var nb2=document.createElement("div");nb2.className="caf-bk-net-banner slow"; 11675 nb2.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>' 11676 +'<span>'+escapeHtml(bkT("label_slow"))+'</span>'; 11677 el.appendChild(nb2); 11678 } 11679 11680 /* ââ BODY ââ */ 11681 var body=document.createElement("div");body.className="caf-bk-fs-body"; 11682 11683 /* sidebar (summary) */ 11684 var side=document.createElement("div");side.className="caf-bk-fs-side"; 11685 var sideTitle=document.createElement("div");sideTitle.className="caf-bk-fs-side-title"; 11686 sideTitle.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M9 12l2 2 4-4"/><circle cx="12" cy="12" r="10"/></svg> Riepilogo'; 11687 side.appendChild(sideTitle); 11688 var sideCard=document.createElement("div");sideCard.className="caf-bk-fs-side-card"; 11689 /* track which sidebar items just became filled so we can animate them once */ 11690 if(!self._bkSideFilled)self._bkSideFilled={}; 11691 function mkSideItem(lbl,val,svg,pending,key,jumpStep){ 11692 var it=document.createElement("div");it.className="caf-bk-fs-side-item"+(pending?" pending":""); 11693 /* Cliccando un item della sidebar, l'utente torna allo step 11694 * corrispondente (RegioneâselectRegione, ProvinciaâselectProvincia, â¦). 11695 * Questo annulla anche l'auto-jump del GPS in corso così la scelta 11696 * manuale non viene sovrascritta. */ 11697 if(jumpStep){ 11698 it.style.cursor="pointer"; 11699 it.setAttribute("role","button"); 11700 it.setAttribute("tabindex","0"); 11701 it.title="Modifica "+lbl.toLowerCase(); 11702 it.addEventListener("click",function(){self._bkJumpToStep(jumpStep);}); 11703 it.addEventListener("keydown",function(e){ 11704 if(e.key==="Enter"||e.key===" "){e.preventDefault();self._bkJumpToStep(jumpStep);} 11705 }); 11706 } 11707 var ic=document.createElement("div");ic.className="caf-bk-fs-side-ic"+(pending?" pending":"");ic.innerHTML=svg; 11708 /* just-completed: animate item + add checkmark overlay */ 11709 if(!pending&&key&&!self._bkSideFilled[key]){ 11710 it.classList.add("just-completed"); 11711 self._bkSideFilled[key]=true; 11712 var chk=document.createElement("div");chk.className="caf-bk-fs-side-check"; 11713 chk.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'; 11714 ic.appendChild(chk); 11715 }else if(!pending){ 11716 /* persistent checkmark (smaller/static) for already-filled items */ 11717 var chk2=document.createElement("div");chk2.className="caf-bk-fs-side-check";chk2.style.animation="none"; 11718 chk2.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" style="animation:none;stroke-dasharray:none"><polyline points="20 6 9 17 4 12"/></svg>'; 11719 ic.appendChild(chk2); 11720 } 11721 it.appendChild(ic); 11722 var tx=document.createElement("div");tx.className="caf-bk-fs-side-txt"; 11723 var l=document.createElement("div");l.className="caf-bk-fs-side-lbl";l.textContent=lbl;tx.appendChild(l); 11724 var v=document.createElement("div");v.className="caf-bk-fs-side-val"+(pending?" muted":"");v.textContent=val;tx.appendChild(v); 11725 it.appendChild(tx);return it; 11726 } 11727 var provSvg='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>'; 11728 var servSvg='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg>'; 11729 var sedeSvg='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>'; 11730 var dateSvg='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>'; 11731 var userSvg='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'; 11732 11733 var regSvg='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>'; 11734 /* Tutti gli item del riepilogo sono SEMPRE cliccabili. Se mancano 11735 * prerequisiti (es. click "Sede" senza Servizio scelto), _bkJumpToStep 11736 * fa fallback automatico allo step precedente più vicino disponibile. */ 11737 sideCard.appendChild(mkSideItem("Regione",self.bkSelRegione?self.bkSelRegione.descrizione:"â",regSvg,!self.bkSelRegione,"reg","selectRegione")); 11738 sideCard.appendChild(mkSideItem("Provincia",self.bkSelProv?self.bkSelProv.descrizione:"â",provSvg,!self.bkSelProv,"prov","selectProvincia")); 11739 sideCard.appendChild(mkSideItem("Servizio",self.bkSelServ?(self.bkSelServ.desonline||self.bkSelServ.descrizione):"â",servSvg,!self.bkSelServ,"serv","selectServizio")); 11740 sideCard.appendChild(mkSideItem("Sede",self.bkSelSede?self.bkSelSede.descrizione:"â",sedeSvg,!self.bkSelSede,"sede","selectSede")); 11741 var slotTxt=self.bkSelSlot?(fmtDate(self.bkSelSlot.data)+" · "+fmtTime(self.bkSelSlot.ora_inizio)):"â"; 11742 sideCard.appendChild(mkSideItem("Orario",slotTxt,dateSvg,!self.bkSelSlot,"slot","selectSlot")); 11743 var cd=self.bkClient; 11744 var hasClient=cd&&cd.nome&&cd.cognome; 11745 sideCard.appendChild(mkSideItem("Intestatario",hasClient?(cd.nome+" "+cd.cognome):"â",userSvg,!hasClient,"client","collectData")); 11746 side.appendChild(sideCard); 11747 11748 var help=document.createElement("div");
11748help.className="caf-bk-fs-side-help"; 11749 help.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg><span>Puoi tornare indietro in qualsiasi momento. Premi <b>ESC</b> per chiudere lo schermo intero.</span>'; 11750 side.appendChild(help); 11751 body.appendChild(side); 11752 11753 /* main step area â wrap with stage class for smooth transitions on step change */ 11754 var main=document.createElement("div");main.className="caf-bk-fs-main caf-bk-fs-stage"; 11755 var stepChanged=self._lastFsBkStep!==self.bkStep; 11756 if(stepChanged){ 11757 /* when transitioning to/from loading state, use a gentle fade (avoids double-slide 11758 when flow is selectX â loading â selectY which would feel jarring) */ 11759 if(self._lastFsBkStep==="loading"||self.bkStep==="loading"||self.bkStep==="submitting"||self._lastFsBkStep==="submitting"){ 11760 main.classList.add("fade"); 11761 }else{ 11762 main.classList.add(self._bkDir==="back"?"back":"fwd"); 11763 } 11764 } 11765 self._lastFsBkStep=self.bkStep; 11766 self._bkDir="fwd"; 11767 11768 function mkHead(num,title,sub){ 11769 var h=document.createElement("div");h.className="caf-bk-fs-step-head"; 11770 var n=document.createElement("div");n.className="caf-bk-fs-step-num";n.textContent=bkT("step_of",num,TOTAL);h.appendChild(n); 11771 var t=document.createElement("div");t.className="caf-bk-fs-step-h1";t.textContent=title;h.appendChild(t); 11772 if(sub){var s=document.createElement("div");s.className="caf-bk-fs-step-h2";s.textContent=sub;h.appendChild(s);} 11773 return h; 11774 } 11775 function mkLoader(txt){ 11776 var w=document.createElement("div");w.className="caf-bk-fs-center"; 11777 var ld=document.createElement("div");ld.className="caf-bk-fs-loader";w.appendChild(ld); 11778 var t=document.createElement("div");t.className="caf-bk-fs-loader-txt";t.textContent=txt||bkT("label_loading_short");w.appendChild(t); 11779 return w; 11780 } 11781 11782 /* skeleton loader builder â mirrors tile grid shape so user sees what's coming */ 11783 function mkSkeletonGrid(caption){ 11784 var wrap=document.createElement("div"); 11785 var head=document.createElement("div");head.className="caf-bk-fs-step-head"; 11786 var hSkel=document.createElement("div");hSkel.className="caf-bk-fs-skel caf-bk-fs-skel-row lg";hSkel.style.maxWidth="320px";head.appendChild(hSkel); 11787 var hSkel2=document.createElement("div");hSkel2.className="caf-bk-fs-skel caf-bk-fs-skel-row md";hSkel2.style.maxWidth="440px";hSkel2.style.marginTop="10px";head.appendChild(hSkel2); 11788 wrap.appendChild(head); 11789 var grid=document.createElement("div");grid.className="caf-bk-fs-grid";grid.style.marginTop="18px"; 11790 for(var k=0;k<6;k++){ 11791 var sk=document.createElement("div");sk.className="caf-bk-fs-skel caf-bk-fs-skel-tile";sk.style.setProperty("--d",(k*120)+"ms"); 11792 var ping=document.createElement("span");ping.className="caf-bk-fs-skel-ping";sk.appendChild(ping); 11793 grid.appendChild(sk); 11794 } 11795 wrap.appendChild(grid); 11796 if(caption){ 11797 var cap=document.createElement("div");cap.className="caf-bk-fs-skel-caption"; 11798 cap.innerHTML='<span class="sp"></span>'+escapeHtml(caption); 11799 wrap.appendChild(cap); 11800 } 11801 return wrap; 11802 } 11803 11804 switch(self.bkStep){ 11805 case "loading":{ 11806 var lMsg=(self._bkResumeLoading&&self._bkResumeLoading.msg) 11807 ? self._bkResumeLoading.msg 11808 : (self._bkAutoProvStatus&&self._bkAutoProvStatus.type==="loading") 11809 ? self._bkAutoProvStatus.msg 11810 : (self.bkHint?bkT("label_loading_hint",self.bkHint):bkT("label_loading_options")); 11811 main.appendChild(mkSkeletonGrid(lMsg)); 11812 /* "Annulla": evita lo spinner cieco fino a ~47s (retry Ã3); torna allo 11813 * step precedente abortendo il fetch in corso. */ 11814 if(self._bkLoadingReturnStep){ 11815 var cancW=document.createElement("div");cancW.style.cssText="text-align:center;margin-top:16px"; 11816 var cancB=document.createElement("button");cancB.type="button";cancB.textContent=bkT("btn_cancel"); 11817 cancB.style.cssText="background:none;border:none;color:#5b6573;font-size:13.5px;text-decoration:underline;cursor:pointer;padding:8px 14px"; 11818 cancB.addEventListener("click",function(){self._bkCancelLoading();}); 11819 cancW.appendChild(cancB);main.appendChild(cancW); 11820 } 11821 break; 11822 } 11823 case "submitting":{ 11824 /* keep the summary visible while submitting, show compact pulsing loader */ 11825 var submitWrap=document.createElement("div");submitWrap.style.cssText="display:flex;flex-direction:column;align-items:center;justify-content:center;flex:1;gap:14px;padding:40px 20px"; 11826 var sbIc=document.createElement("div");sbIc.style.cssText="width:72px;height:72px;border-radius:50%;background:linear-gradient(135deg,"+c+","+c+"cc);display:flex;align-items:center;justify-content:center;box-shadow:0 12px 32px "+c+"55;position:relative;animation:caf-submit-pulse 1.2s ease-in-out infinite"; 11827 sbIc.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="width:30px;height:30px"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/></svg>'; 11828 submitWrap.appendChild(sbIc); 11829 var sbT=document.createElement("div");sbT.style.cssText="font-size:18px;font-weight:800;color:#0f172a;text-align:center";sbT.textContent=bkT("label_submitting");submitWrap.appendChild(sbT); 11830 var sbS=document.createElement("div");sbS.style.cssText="font-size:13px;color:#5b6573;text-align:center;max-width:420px;line-height:1.5";sbS.textContent=bkT("label_submitting_sub");submitWrap.appendChild(sbS); 11831 main.appendChild(submitWrap);break; 11832 } 11833 11834 case "selectRegione":{ 11835 main.appendChild(mkHead(1,bkT("h_region"),bkT("s_region"))); 11836 /* #21 â Last booking shortcut chip: ripeti l'ultima prenotazione fatta */
11837 try{ 11838 var _lastH=loadBookingHistory(); 11839 var _lastB=_lastH&&_lastH[0]; 11840 if(_lastB&&_lastB.regione&&_lastB.provincia&&_lastB.servizio&&_lastB.sede&&_lastB.servizio.codice&&_lastB.sede.codice){ 11841 var lbWrap=document.createElement("button");lbWrap.type="button"; 11842 lbWrap.style.cssText="display:flex;align-items:center;gap:12px;width:100%;max-width:520px;padding:12px 14px;margin:0 0 14px;border:1.5px solid "+c+"55;background:linear-gradient(135deg,"+c+"12,"+c+"04);border-radius:14px;cursor:pointer;font-family:inherit;text-align:left;transition:all .2s;-webkit-tap-highlight-color:transparent;animation:caf-fade-up .35s ease-out"; 11843 lbWrap.innerHTML= 11844 '<span style="width:36px;height:36px;border-radius:10px;background:var(--caf-c);color:var(--caf-on-c);display:flex;align-items:center;justify-content:center;flex-shrink:0">'+ 11845 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" style="width:17px;height:17px"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/></svg>'+ 11846 '</span>'+ 11847 '<span style="flex:1;min-width:0">'+ 11848 '<span style="display:block;font-size:12.5px;font-weight:800;color:#0f172a">Ripeti ultima prenotazione</span>'+ 11849 '<span style="display:block;font-size:11px;color:#5b6573;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+escapeHtml((_lastB.servizio.desonline||_lastB.servizio.descrizione||"")+" · "+(_lastB.sede.descrizione||""))+'</span>'+ 11850 '</span>'+ 11851 '<svg viewBox="0 0 24 24" fill="none" stroke="'+c+'" stroke-width="2.5" stroke-linecap="round" style="width:14px;height:14px;flex-shrink:0"><polyline points="9 18 15 12 9 6"/></svg>'; 11852 lbWrap.addEventListener("mouseenter",function(){lbWrap.style.transform="translateY(-2px)";lbWrap.style.boxShadow="0 8px 22px "+c+"33";}); 11853 lbWrap.addEventListener("mouseleave",function(){lbWrap.style.transform="";lbWrap.style.boxShadow="";}); 11854 lbWrap.addEventListener("click",function(){ 11855 bkHaptic(8); 11856 self._bkLogEvent&&self._bkLogEvent("last_booking_repeated"); 11857 /* Pre-popola TUTTO il path (regione/prov/serv/sede) prima di chiamare 11858 * _bkPickSede, che richiede bkSelProv e bkSelServ già impostati. */ 11859 self.bkSelRegione=_lastB.regione; 11860 self.bkSelProv=_lastB.provincia; 11861 self.bkSelServ=_lastB.servizio; 11862 self.bkSelSede=_lastB.sede; 11863 self.bkSedi=[_lastB.sede]; 11864 self.bkStep="loading";self._bkRenderFs(); 11865 /* Carica gli slot per la sede memorizzata. _bkPickSede gestisce 11866 * fallimenti (_bkSetError) se la sede non è più disponibile. */ 11867 self._bkPickSede(_lastB.sede); 11868 }); 11869 main.appendChild(lbWrap); 11870 } 11871 }catch(_e){} 11872 /* #22 â Smart default: ordina le regioni in base a timezone (Italia) e history */ 11873 try{ 11874 var _tzPref=bkGuessRegionByTimezone(); 11875 var _histRegs={};_lastH=_lastH||loadBookingHistory(); 11876 _lastH.forEach(function(b){if(b&&b.sede&&b.sede.regione){_histRegs[b.sede.regione]=(_histRegs[b.sede.regione]||0)+1;}}); 11877 if(_tzPref&&self.bkRegioni&&self.bkRegioni.length>1){ 11878 self.bkRegioni.sort(function(a,b){ 11879 var ai=_tzPref.indexOf(a.descrizione),bi=_tzPref.indexOf(b.descrizione); 11880 var ah=_histRegs[a.descrizione]||0,bh=_histRegs[b.descrizione]||0; 11881 if(ah!==bh)return bh-ah; 11882 if(ai<0)ai=999;if(bi<0)bi=999;return ai-bi; 11883 }); 11884 } 11885 }catch(_e){} 11886 /* Banner stato geolocalizzazione: visibile in TUTTI gli stati 11887 * (loading/ok/err) per dare feedback durante il GPS in background. */ 11888 if(self._bkAutoProvStatus){ 11889 var spFR=document.createElement("div"); 11890 var stType=self._bkAutoProvStatus.type; 11891 spFR.className="caf-bk-locate-status "+(stType==="ok"?"ok":(stType==="err"?"err":"loading")); 11892 var spIcFR; 11893 if(stType==="loading")spIcFR='<span class="spin" style="display:inline-block;width:14px;
11893height:14px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:caf-spin .8s linear infinite;vertical-align:middle"></span>'; 11894 else if(stType==="ok")spIcFR='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg>'; 11895 else spIcFR='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/></svg>'; 11896 spFR.innerHTML=spIcFR+'<span>'+escapeHtml(self._bkAutoProvStatus.msg)+'</span>'; 11897 main.appendChild(spFR); 11898 } 11899 var regGrid=document.createElement("div");regGrid.className="caf-bk-fs-grid"; 11900 self.bkRegioni.forEach(function(r,i){ 11901 var t=document.createElement("button");t.type="button";t.className="caf-bk-fs-tile";t.style.animation="caf-fade-up .35s ease-out "+(i*30)+"ms backwards"; 11902 var ic=document.createElement("div");ic.className="caf-bk-fs-tile-ic";ic.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/><circle cx="12" cy="12" r="10"/></svg>';t.appendChild(ic); 11903 var nm=document.createElement("div");nm.className="caf-bk-fs-tile-name";nm.textContent=r.descrizione;t.appendChild(nm); 11904 var ar=document.createElement("div");ar.className="caf-bk-fs-tile-arrow";ar.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>';t.appendChild(ar); 11905 t.addEventListener("click",function(){ 11906 var all=regGrid.querySelectorAll(".caf-bk-fs-tile"); 11907 all.forEach(function(other){if(other!==t)other.classList.add("caf-bk-fs-tile-dim");}); 11908 t.classList.add("caf-bk-fs-tile-focus"); 11909 setTimeout(function(){self._bkPickRegione(r);},200); 11910 }); 11911 regGrid.appendChild(t); 11912 }); 11913 main.appendChild(regGrid);break; 11914 } 11915 11916 case "selectProvincia":{ 11917 main.appendChild(mkHead(1,bkT("h_province"),bkT("s_province"))); 11918 /* PROMINENT "use my location" to auto-detect province */ 11919 var locProvBox=document.createElement("div");locProvBox.className="caf-bk-nearby"; 11920 var locProvBtn=document.createElement("button");locProvBtn.type="button";locProvBtn.className="caf-bk-locate"; 11921 var lpLoad=self._bkAutoProvStatus&&self._bkAutoProvStatus.type==="loading"; 11922 if(lpLoad){locProvBtn.disabled=true; 11923 locProvBtn.innerHTML='<span class="caf-bk-locate-ic"><span class="spin"></span></span><div class="caf-bk-locate-txt"><div class="caf-bk-locate-t">'+escapeHtml(self._bkAutoProvStatus.msg)+'</div></div>'; 11924 }else{ 11925 locProvBtn.innerHTML='<span class="caf-bk-locate-ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/></svg></span><div class="caf-bk-locate-txt"><div class="caf-bk-locate-t">'+escapeHtml(bkT("btn_use_location"))+'</div><div class="caf-bk-locate-s">'+escapeHtml(bkT("btn_use_location_sub"))+'</div></div><span class="caf-bk-locate-arrow">âº</span>'; 11926 } 11927 locProvBtn.addEventListener("click",function(){self._bkAutoProvStatus=null;self._bkAutoDetectProvince(function(){self._bkRenderFs();});}); 11928 locProvBox.appendChild(locProvBtn); 11929 if(self._bkAutoProvStatus&&self._bkAutoProvStatus.type!=="loading"){ 11930 var spF=document.createElement("div");spF.className="caf-bk-locate-status "+(self._bkAutoProvStatus.type==="ok"?"ok":"err"); 11931 var spIcF=self._bkAutoProvStatus.type==="ok" 11932 ?'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg>' 11933 :'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/></svg>'; 11934 spF.innerHTML=spIcF+'<span>'+escapeHtml(self._bkAutoProvStatus.msg)+'</span>'; 11935 locProvBox.appendChild(spF); 11936 } 11937 main.appendChild(locProvBox); 11938 var divLbl=document.createElement("div");divLbl.style.cssText="display:flex;align-items:center;gap:10px;margin:2px 0 8px;font-size:10.5px;color:#5b6573;font-weight:700;text-transform:uppercase;letter-spacing:.5px"; 11939 divLbl.innerHTML='<span style="flex:1;height:1px;background:#e2e8f0"></span><span>'+escapeHtml(bkT("label_or_choose"))+'</span><span style="flex:1;height:1px;background:#e2e8f0"></span>'; 11940 main.appendChild(divLbl); 11941 11942 /* #2 â sort province per frequenza d'uso (più usate in cima). Stable sort. */ 11943 var sortedProvince=bkFreqSort("prov",self.bkProvince||[],function(p){return p.codice;}); 11944 11945 /* #3 â search input nazionale: cerca tra tutte le province. Se l'utente 11946 * digita una città /provincia che NON è in self.bkProvince (cioè non è
11947 * della regione corrente), suggeriamo lo switch alla regione corretta. */ 11948 if(self.bkProvince&&self.bkProvince.length>5){ 11949 var sb=document.createElement("div");sb.className="caf-bk-fs-search";sb.style.marginBottom="12px"; 11950 sb.innerHTML='<div class="caf-bk-fs-search-ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg></div>'; 11951 var sinp=document.createElement("input");sinp.type="text";sinp.placeholder=bkT("ph_search_province");sinp.setAttribute("aria-label",bkT("ph_search_province")); 11952 sb.appendChild(sinp); 11953 main.appendChild(sb); 11954 var _provDeb=null; 11955 sinp.addEventListener("input",function(){ 11956 if(_provDeb)clearTimeout(_provDeb); 11957 _provDeb=setTimeout(function(){ 11958 var q=bkNormalizeStr(sinp.value); 11959 grid.querySelectorAll(".caf-bk-fs-tile").forEach(function(t){ 11960 var name=bkNormalizeStr(t.dataset.name||""); 11961 t.style.display=(!q||name.indexOf(q)!==-1)?"":"none"; 11962 }); 11963 },120); 11964 }); 11965 } 11966 11967 var grid=document.createElement("div");grid.className="caf-bk-fs-grid"; 11968 sortedProvince.forEach(function(p,i){ 11969 var t=document.createElement("button");t.type="button";t.className="caf-bk-fs-tile";t.dataset.name=p.descrizione;t.style.animation="caf-fade-up .35s ease-out "+Math.min(i*30,400)+"ms backwards"; 11970 var ic=document.createElement("div");ic.className="caf-bk-fs-tile-ic";ic.innerHTML=provSvg;t.appendChild(ic); 11971 var nm=document.createElement("div");nm.className="caf-bk-fs-tile-name";nm.textContent=p.descrizione;t.appendChild(nm); 11972 /* #2 â badge "frequente" se l'utente l'ha già usata almeno 2 volte */ 11973 if(bkFreqGet("prov",p.codice)>=2){ 11974 var fr=document.createElement("span");fr.className="caf-bk-fs-tile-recent";fr.textContent=bkT("label_frequent");t.appendChild(fr); 11975 } 11976 var ar=document.createElement("div");ar.className="caf-bk-fs-tile-arrow";ar.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>';t.appendChild(ar); 11977 t.addEventListener("click",function(){ 11978 var all=grid.querySelectorAll(".caf-bk-fs-tile"); 11979 all.forEach(function(other){if(other!==t)other.classList.add("caf-bk-fs-tile-dim");}); 11980 t.classList.add("caf-bk-fs-tile-focus"); 11981 bkFreqInc("prov",p.codice); 11982 setTimeout(function(){self._bkPickProv(p);},200); 11983 }); 11984 grid.appendChild(t); 11985 }); 11986 main.appendChild(grid);break; 11987 } 11988 case "selectServizio":{ 11989 main.appendChild(mkHead(2,self.bkHint?bkT("h_service_hint"):bkT("h_service"),self.bkHint?bkT("s_service_hint",self.bkHint):bkT("s_service"))); 11990 var allSrv=self.bkServizi; 11991 var gridWrap=document.createElement("div");gridWrap.style.display="flex";gridWrap.style.flexDirection="column";gridWrap.style.gap="14px"; 11992 11993 /* #6 â categorizza tutti i servizi per categoria UI (icona + label). 11994 * I servizi senza match vanno in "Altri". */ 11995 var catsBuckets={};var catsOrder=[]; 11996 allSrv.forEach(function(s){ 11997 var cat=bkServizioCategoria(s);var bucket=catsBuckets[cat.id]; 11998 if(!bucket){bucket={cat:cat,items:[]};catsBuckets[cat.id]=bucket;catsOrder.push(cat.id);} 11999 bucket.items.push(s); 12000 }); 12001 /* #7 â flag popolari/recenti via frequency tracker */ 12002 var popularIds={}; 12003 var freq=allSrv.map(function(s){return{s:s,f:bkFreqGet("srv",s.codice)};}).filter(function(x){return x.f>0;}); 12004 freq.sort(function(a,b){return b.f-a.f;}); 12005 freq.slice(0,2).forEach(function(x){popularIds[x.s.codice]="popular";}); 12006 /* recente = primo della history se non già popolare */ 12007 try{var _hist=loadBookingHistory();if(_hist&&_hist.length){var lastSrvCode=_hist[0]&&_hist[0].servizio&&_hist[0].servizio.codice;if(lastSrvCode&&!popularIds[lastSrvCode])popularIds[lastSrvCode]="recent";}}catch(_e){} 12008 12009 var srvGrid=document.createElement("div");
12009srvGrid.className="caf-bk-fs-grid"; 12010 12011 /* Categoria attualmente attiva (null = tutte). */ 12012 var activeCat=null; 12013 /* Tab categorie (#6): mostrate solo se ci sono almeno 2 categorie diverse e abbastanza servizi. */ 12014 var catsBar=null; 12015 if(catsOrder.length>=2&&allSrv.length>=6){ 12016 catsBar=document.createElement("div");catsBar.className="caf-bk-srv-cats"; 12017 var allBtn=document.createElement("button");allBtn.type="button";allBtn.className="caf-bk-srv-cat active"; 12018 allBtn.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/></svg> Tutti <span class="caf-bk-srv-cat-count">'+allSrv.length+'</span>'; 12019 allBtn.addEventListener("click",function(){activeCat=null;updateCatsUi();renderSrv(searchInputRef.value||"");}); 12020 catsBar.appendChild(allBtn); 12021 catsOrder.forEach(function(catId){ 12022 var b=catsBuckets[catId]; 12023 var btn=document.createElement("button");btn.type="button";btn.className="caf-bk-srv-cat";btn.dataset.cat=catId; 12024 btn.innerHTML=b.cat.icon+' '+escapeHtml(b.cat.label)+' <span class="caf-bk-srv-cat-count">'+b.items.length+'</span>'; 12025 btn.addEventListener("click",function(){activeCat=catId;updateCatsUi();renderSrv(searchInputRef.value||"");}); 12026 catsBar.appendChild(btn); 12027 }); 12028 gridWrap.appendChild(catsBar); 12029 } 12030 function updateCatsUi(){ 12031 if(!catsBar)return; 12032 catsBar.querySelectorAll(".caf-bk-srv-cat").forEach(function(b){b.classList.remove("active");}); 12033 if(!activeCat){catsBar.querySelector(".caf-bk-srv-cat").classList.add("active");} 12034 else{var sel=catsBar.querySelector('.caf-bk-srv-cat[data-cat="'+activeCat+'"]');if(sel)sel.classList.add("active");} 12035 } 12036 12037 /* Search input (#3-#10-#11): include sinonimi + Levenshtein "did you mean" */ 12038 var searchInputRef={value:""};
12039 function renderSrv(filter){ 12040 srvGrid.innerHTML=""; 12041 var rawFilter=filter||""; 12042 var f=bkNormalizeStr(rawFilter); 12043 /* base list = tutti o filtrati per categoria */ 12044 var base=activeCat?(catsBuckets[activeCat]?catsBuckets[activeCat].items:[]):allSrv; 12045 var flt; 12046 if(!f){flt=base;} 12047 else{ 12048 /* tokens da cercare = filter normalizzato + sinonimi */ 12049 var tokens=[f]; 12050 if(BK_SINONIMI[f])BK_SINONIMI[f].forEach(function(t){tokens.push(bkNormalizeStr(t));}); 12051 /* match: ogni servizio passa se almeno un token è substring del nome */ 12052 flt=base.filter(function(s){ 12053 var hay=bkNormalizeStr((s.desonline||"")+" "+(s.descrizione||"")+" "+(s.descriptionTipologia||"")); 12054 for(var ti=0;ti<tokens.length;ti++){if(hay.indexOf(tokens[ti])!==-1)return true;} 12055 return false; 12056 }); 12057 } 12058 /* Sort: popolari primi (se non c'è filtro), poi recenti, poi resto */ 12059 if(!f){ 12060 flt=flt.slice().sort(function(a,b){ 12061 var ap=popularIds[a.codice]==="popular"?2:popularIds[a.codice]==="recent"?1:0; 12062 var bp=popularIds[b.codice]==="popular"?2:popularIds[b.codice]==="recent"?1:0; 12063 if(ap!==bp)return bp-ap; 12064 return 0; 12065 }); 12066 } 12067 /* WCAG 4.1.3 (#C65): annuncia l'esito della ricerca servizi */ 12068 if(f){self._bkFsAnnounce(flt.length?bkT("sr_results_count",flt.length):bkT("sr_no_results"));} 12069 if(!flt.length){ 12070 /* #11 â spelling tolerance: cerca servizi con Levenshtein ⤠3 */ 12071 var suggestions=[]; 12072 if(f){ 12073 allSrv.forEach(function(s){ 12074 var name=bkNormalizeStr(s.desonline||s.descrizione||""); 12075 if(!name)return; 12076 /* split nome in parole e calcola distanza minima */ 12077 var words=name.split(/\s+/);var minD=99; 12078 words.forEach(function(w){var d=bkLevenshtein(f,w,3);if(d<minD)minD=d;}); 12079 if(minD<=2&&minD<=Math.max(2,Math.floor(f.length/3))){suggestions.push({s:s,d:minD});} 12080 }); 12081 suggestions.sort(function(a,b){return a.d-b.d;});suggestions=suggestions.slice(0,3); 12082 } 12083 if(suggestions.length){ 12084 var sg=document.createElement("div");sg.className="caf-bk-suggest";sg.style.gridColumn="1/-1"; 12085 sg.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>' 12086 +'<div><div style="font-weight:700;margin-bottom:4px">'+escapeHtml(bkT("ph_no_results_generic",rawFilter))+'</div>' 12087 +'<div>'+escapeHtml(bkT("label_did_you_mean"))+'</div><div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:6px"></div></div>'; 12088 var sgRow=sg.querySelector("div > div:last-child"); 12089 suggestions.forEach(function(sg2){ 12090 var b=document.createElement("button");b.type="button";b.textContent=sg2.s.desonline||sg2.s.descrizione; 12091 b.addEventListener("click",function(){searchInputRef.value=sg2.s.desonline||sg2.s.descrizione;searchInputRef._inputEl&&(searchInputRef._inputEl.value=searchInputRef.value);renderSrv(searchInputRef.value);}); 12092 sgRow.appendChild(b); 12093 }); 12094 srvGrid.appendChild(sg); 12095 }else{ 12096 var nr=document.createElement("div");nr.style.cssText="grid-column:1/-1;padding:30px;text-align:center;color:#5b6573;font-size:13.5px";nr.textContent=bkT("ph_no_results_one",rawFilter);srvGrid.appendChild(nr); 12097 } 12098 return; 12099 } 12100 flt.forEach(function(s,i){ 12101 var t=document.createElement("button");t.type="button";t.className="caf-bk-fs-tile has-cat";t.style.animation="caf-fade-up .3s ease-out "+Math.min(i*25,500)+"ms backwards";t.style.position="relative"; 12102 var cat=bkServizioCategoria(s); 12103 /* Badge categoria sx */ 12104 var badge=document.createElement("div");badge.className="caf-bk-fs-tile-cat-badge";badge.style.background=cat.color;badge.innerHTML=cat.icon;t.appendChild(badge); 12105 /* Popular/Recent badge */ 12106 if(popularIds[s.codice]==="popular"){ 12107 var pb=document.createElement("span");
12107pb.className="caf-bk-fs-tile-popular";pb.textContent=bkT("label_popular");t.appendChild(pb); 12108 }else if(popularIds[s.codice]==="recent"){ 12109 var rb=document.createElement("span");rb.className="caf-bk-fs-tile-recent";rb.textContent=bkT("label_recent");t.appendChild(rb); 12110 } 12111 var nm=document.createElement("div");nm.className="caf-bk-fs-tile-name";nm.textContent=s.desonline||s.descrizione;t.appendChild(nm); 12112 if(+s.tempo>0){var su=document.createElement("div");su.className="caf-bk-fs-tile-sub";su.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> '+escapeHtml(bkT("label_form_duration_min",s.tempo));t.appendChild(su);} 12113 var ar=document.createElement("div");ar.className="caf-bk-fs-tile-arrow";ar.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>';t.appendChild(ar); 12114 t.addEventListener("click",function(ev){ 12115 var all=srvGrid.querySelectorAll(".caf-bk-fs-tile"); 12116 all.forEach(function(other){if(other!==t)other.classList.add("caf-bk-fs-tile-dim");}); 12117 t.classList.add("caf-bk-fs-tile-focus"); 12118 bkFreqInc("srv",s.codice); 12119 setTimeout(function(){self._bkPickServ(s);},200); 12120 }); 12121 srvGrid.appendChild(t); 12122 }); 12123 } 12124 if(allSrv.length>5){ 12125 var sb=document.createElement("div");sb.className="caf-bk-fs-search"; 12126 var sic=document.createElement("div");sic.className="caf-bk-fs-search-ic";sic.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>';sb.appendChild(sic); 12127 var sinp=document.createElement("input");sinp.type="text";sinp.placeholder=bkT("ph_search_service");sinp.setAttribute("aria-label",bkT("ph_search_service")); 12128 searchInputRef._inputEl=sinp; 12129 /* debounce 150ms per il fuzzy match */ 12130 var _srvDeb=null; 12131 sinp.addEventListener("input",function(){ 12132 searchInputRef.value=sinp.value; 12133 if(_srvDeb)clearTimeout(_srvDeb);
12134 _srvDeb=setTimeout(function(){renderSrv(sinp.value);},150); 12135 }); 12136 sb.appendChild(sinp); 12137 gridWrap.appendChild(sb); 12138 } 12139 renderSrv("");gridWrap.appendChild(srvGrid);main.appendChild(gridWrap);break; 12140 } 12141 case "selectSede":{ 12142 main.appendChild(mkHead(3,bkT("h_office"),bkT("s_office"))); 12143 /* "find nearest" block */ 12144 var nearby=document.createElement("div");nearby.className="caf-bk-nearby"; 12145 var nb=document.createElement("button");nb.type="button";nb.className="caf-bk-nearby-btn"; 12146 var nbLoading=self._bkNearestStatus&&self._bkNearestStatus.type==="loading"; 12147 if(nbLoading){nb.disabled=true;nb.innerHTML='<span class="spin"></span> '+escapeHtml(self._bkNearestStatus.msg);} 12148 else{nb.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/></svg> '+escapeHtml(self._bkNearestCode?bkT("btn_find_nearest_again"):bkT("btn_find_nearest"));} 12149 nb.addEventListener("click",function(){ 12150 /* reset ranking before new search */ 12151 self.bkSedi.forEach(function(s){delete s._distKm;}); 12152 self._bkNearestCode=null; 12153 self._bkFindNearestSede(function(){self._bkRenderFs();}); 12154 }); 12155 nearby.appendChild(nb); 12156 /* status */ 12157 if(self._bkNearestStatus&&self._bkNearestStatus.type!=="loading"){ 12158 var st=document.createElement("div");st.className="caf-bk-nearby-status "+(self._bkNearestStatus.type==="ok"?"ok":"err"); 12159 var stIc=self._bkNearestStatus.type==="ok" 12160 ?'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg>' 12161 :'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/></svg>'; 12162 st.innerHTML=stIc+'<span>'+escapeHtml(self._bkNearestStatus.msg)+'</span>'; 12163 nearby.appendChild(st); 12164 } 12165 main.appendChild(nearby); 12166 12167 /* SEARCH BAR â filtro live per nome sede / indirizzo / città / telefono. 12168 * Mostrato solo se ci sono > 5 sedi (sotto soglia non serve). */ 12169 var allSedi=self.bkSedi; 12170 var sgridWrap=document.createElement("div");sgridWrap.style.display="flex";sgridWrap.style.flexDirection="column";sgridWrap.style.gap="14px"; 12171 var sgrid=document.createElement("div");sgrid.className="caf-bk-fs-grid"; 12172
12173 function renderSediTiles(filter){ 12174 sgrid.innerHTML=""; 12175 var f=(filter||"").trim().toLowerCase(); 12176 var list=allSedi; 12177 if(f){ 12178 list=allSedi.filter(function(s){ 12179 var hay=((s.descrizione||"")+" "+(s.indirizzo||"")+" "+(s.citta||"")+" "+(s.telefono||"")).toLowerCase(); 12180 /* Match a-token: ogni parola del filtro deve essere presente */ 12181 var tokens=f.split(/\s+/).filter(Boolean); 12182 for(var ti=0;ti<tokens.length;ti++){if(hay.indexOf(tokens[ti])===-1)return false;} 12183 return true; 12184 }); 12185 } 12186 /* WCAG 4.1.3 (#C65): annuncia l'esito del filtro (debounce già sull'input) */ 12187 if(f){self._bkFsAnnounce(list.length?bkT("sr_results_count",list.length):bkT("sr_no_results"));} 12188 if(!list.length){ 12189 var nr=document.createElement("div"); 12190 nr.style.cssText="grid-column:1/-1;padding:30px;text-align:center;color:#5b6573;font-size:13.5px"; 12191 nr.textContent=bkT("ph_no_office_found",filter); 12192 sgrid.appendChild(nr);return; 12193 } 12194 list.forEach(function(s,i){ 12195 /* WCAG 4.1.2 (#C30/#C34): il tile contiene link tel/mappa â niente 12196 * role=button col rischio nested-interactive. Il click del mouse resta 12197 * sul contenitore; da tastiera si usa il bottone-freccia dedicato. */ 12198 var t=document.createElement("div");t.className="caf-bk-fs-tile";t.style.animation="caf-fade-up .35s ease-out "+Math.min(i*40,800)+"ms backwards"; 12199 var isNearest=self._bkNearestCode&&(s.codice===self._bkNearestCode||s.descrizione===self._bkNearestCode); 12200 if(isNearest){ 12201 t.classList.add("nearest"); 12202 if(self._bkNearestJustFound){ 12203 t.classList.add("nearest-reveal"); 12204 t.style.animation="none"; 12205 } 12206 var ribbon=document.createElement("div");ribbon.className="caf-bk-fs-tile-nearest-ribbon";ribbon.textContent=bkT("label_nearest");t.appendChild(ribbon); 12207 } 12208 if(s._distKm!==undefined){ 12209 var dst=document.createElement("span");dst.className="caf-bk-fs-tile-dist"; 12210 var kmTxt=s._distKm<1?(Math.round(s._distKm*1000)+" m"):(s._distKm.toFixed(1)+" km"); 12211 dst.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><circle cx="12" cy="10" r="3"/><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/></svg>'+kmTxt; 12212 t.appendChild(dst); 12213 } 12214 var ic=document.createElement("div");ic.className="caf-bk-fs-tile-ic";ic.innerHTML=sedeSvg;t.appendChild(ic); 12215 var nm=document.createElement("div");nm.className="caf-bk-fs-tile-name";nm.textContent=s.descrizione;t.appendChild(nm); 12216 var sub=document.createElement("div");sub.className="caf-bk-fs-tile-sub";sub.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg> '+escapeHtml(s.indirizzo+", "+s.citta);t.appendChild(sub); 12217 if(s.telefono){ 12218 var tel=document.createElement("a");tel.className="caf-bk-fs-tile-sub"; 12219 tel.href="tel:"+encodeURIComponent(String(s.telefono).replace(/\s+/g,"")); 12220 tel.style.cssText="text-decoration:none;color:inherit;cursor:pointer"; 12221 tel.addEventListener("click",function(ev){ev.stopPropagation();self._bkLogEvent&&self._bkLogEvent("sede_phone_clicked",{sede_codice:s.codice});}); 12222 tel.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3"/></svg> '+escapeHtml(s.telefono); 12223 t.appendChild(tel); 12224 } 12225 var mapLink=document.createElement("a");mapLink.className="caf-bk-fs-tile-sub";mapLink.href="https://www.google.com/maps/search/?api=1&query="+encodeURIComponent((s.descrizione||"")+" "+(s.indirizzo||"")+" "+(s.citta||""));mapLink.target="_blank";mapLink.rel="noopener";mapLink.setAttribute("aria-label",bkT("aria_view_on_map",(s.descrizione||"")));mapLink.style.cursor="pointer";mapLink.style.color="var(--caf-c-ink)";mapLink.style.fontWeight="700";mapLink.style.textDecoration="none"; 12226 mapLink.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/><line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/></svg> '+escapeHtml(bkT("label_view_map")); 12227 mapLink.addEventListener("click",function(ev){ev.stopPropagation();}); 12228 t.appendChild(mapLink); 12229 var pickSede=function(){ 12230 var allTiles=sgrid.querySelectorAll(".caf-bk-fs-tile");
12231 allTiles.forEach(function(other){if(other!==t)other.classList.add("caf-bk-fs-tile-dim");}); 12232 t.classList.add("caf-bk-fs-tile-focus"); 12233 setTimeout(function(){self._bkPickSede(s);},200); 12234 }; 12235 /* WCAG 4.1.2 (#C30): la freccia è un VERO <button> â unico stop da 12236 * tastiera del tile â con nome "Seleziona {sede}"; stile inline per 12237 * non cambiare l'aspetto. stopPropagation evita il doppio trigger 12238 * (il click sul contenitore chiama già pickSede). */ 12239 var ar=document.createElement("button");ar.type="button";ar.className="caf-bk-fs-tile-arrow"; 12240 ar.setAttribute("aria-label",bkT("aria_select_office",(s.descrizione||"")+(s.citta?(" - "+s.citta):""))); 12241 ar.style.cssText="background:none;border:0;padding:0;color:inherit;cursor:pointer"; 12242 ar.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>'; 12243 ar.addEventListener("click",function(ev){ev.stopPropagation();pickSede();}); 12244 t.appendChild(ar); 12245 t.addEventListener("click",pickSede); 12246 sgrid.appendChild(t); 12247 }); 12248 } 12249 12250 if(allSedi.length>5){ 12251 var sb=document.createElement("div");sb.className="caf-bk-fs-search"; 12252 var sic=document.createElement("div");sic.className="caf-bk-fs-search-ic"; 12253 sic.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>'; 12254 sb.appendChild(sic); 12255 var sinp=document.createElement("input"); 12256 sinp.type="text"; 12257 sinp.placeholder=bkT("ph_search_office"); 12258 sinp.setAttribute("aria-label",bkT("aria_search_office")); 12259 /* Persiste il filtro tra re-render (cambio status nearest, ecc.) 12260 * altrimenti l'utente lo perderebbe ad ogni evento. */ 12261 if(self._bkSedeSearch)sinp.value=self._bkSedeSearch; 12262 var _sedeDeb=null; 12263 sinp.addEventListener("input",function(){ 12264 if(_sedeDeb)clearTimeout(_sedeDeb); 12265 _sedeDeb=setTimeout(function(){ 12266 self._bkSedeSearch=sinp.value; 12267 renderSediTiles(sinp.value); 12268 },120); 12269 }); 12270 sb.appendChild(sinp); 12271 sgridWrap.appendChild(sb); 12272 } 12273 sgridWrap.appendChild(sgrid); 12274 main.appendChild(sgridWrap); 12275 renderSediTiles(self._bkSedeSearch||""); 12276 break; 12277 } 12278 case "selectSlot":{ 12279 /* Costruisco l'header MA non lo appendo subito: vogliamo che il 12280 * quickpick "Prima disponibilità " venga PRIMA del titolo, così su 12281 * mobile è la prima cosa visibile senza scroll. Per gli stati di 12282 * errore/empty (no slots) appendiamo l'header normalmente in cima. */ 12283 var slotHeadEl=mkHead(4,bkT("h_slot"),bkT("s_slot")); 12284 if(!self.bkSpazi.length){ 12285 main.appendChild(slotHeadEl); 12286 var em=document.createElement("div");em.className="caf-bk-fs-center"; 12287 var emIc=document.createElement("div");emIc.className="caf-bk-fs-empty-ic";emIc.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';em.appendChild(emIc); 12288 var emT=document.createElement("div");emT.className="caf-bk-fs-empty-t";emT.textContent=bkT("label_no_slots_h");em.appendChild(emT); 12289 var emS=document.createElement("div");emS.className="caf-bk-fs-empty-s";emS.textContent=bkT("label_no_slots_s");em.appendChild(emS); 12290 main.appendChild(em);break; 12291 } 12292 /* convert various API date formats to ISO yyyy-mm-dd */ 12293 function toIso(raw){ 12294 if(!raw)return ""; 12295 var r=String(raw); 12296 /* YYYYMMDD (8 digits, CAF API format) */ 12297 if(/^\d{8}$/.test(r))return r.slice(0,4)+"-"+r.slice(4,6)+"-"+r.slice(6,8); 12298 /* YYYY-MM-DD or YYYY-MM-DDT... */ 12299 if(/^\d{4}-\d{2}-\d{2}/.test(r))return r.substring(0,10); 12300 /* DD/MM/YYYY */ 12301 if(/^\d{2}\/\d{2}\/\d{4}/.test(r)){var p=r.split("/");return p[2]+"-"+p[1]+"-"+p[0];} 12302 return ""; 12303 } 12304 /* bucket slots by ISO yyyy-mm-dd */ 12305 var dayGroups={},dayKeys=[];
12306 self.bkSpazi.forEach(function(s){ 12307 var iso=toIso(s.data); 12308 if(!iso)return; 12309 if(!dayGroups[iso]){dayGroups[iso]=[];dayKeys.push(iso);} 12310 dayGroups[iso].push(s); 12311 }); 12312 if(!dayKeys.length){ 12313 main.appendChild(slotHeadEl); 12314 var em2=document.createElement("div");em2.className="caf-bk-fs-center"; 12315 var em2Ic=document.createElement("div");em2Ic.className="caf-bk-fs-empty-ic";em2Ic.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';em2.appendChild(em2Ic); 12316 var em2T=document.createElement("div");em2T.className="caf-bk-fs-empty-t";em2T.textContent=bkT("label_no_dates_h");em2.appendChild(em2T); 12317 var em2S=document.createElement("div");em2S.className="caf-bk-fs-empty-s";em2S.textContent=bkT("label_no_dates_s");em2.appendChild(em2S); 12318 main.appendChild(em2);break; 12319 } 12320 /* sort chronologically */ 12321 dayKeys.sort(); 12322 var firstDay=dayKeys[0]||""; 12323 function pad2(n){n=String(n);return n.length<2?"0"+n:n;} 12324 /* determine current month view (first available if not set) */ 12325 if(!self._bkCalMonth||!/^\d{4}-\d{2}$/.test(self._bkCalMonth)){ 12326 if(firstDay){self._bkCalMonth=firstDay.slice(0,7);} 12327 else{var tn=new Date();self._bkCalMonth=tn.getFullYear()+"-"+pad2(tn.getMonth()+1);} 12328 } 12329 if((!self._bkCalSelected||!dayGroups[self._bkCalSelected])&&firstDay){self._bkCalSelected=firstDay;} 12330 12331 /* #23 â density map globale: ore con più slot = "hot". */ 12332 var slotDensityGlobal=bkSlotDensityMap(self.bkSpazi); 12333 12334 /* #21 â Quick-pick "Prima disponibilità ": card prominente che, con un 12335 * solo click, seleziona il primo slot disponibile in assoluto. */ 12336 if(firstDay&&dayGroups[firstDay]&&dayGroups[firstDay].length){ 12337 var firstSlot=dayGroups[firstDay].slice().sort(function(a,b){return String(a.ora_inizio||"").localeCompare(String(b.ora_inizio||""));})[0]; 12338 if(firstSlot){ 12339 var qp=document.createElement("div");qp.className="caf-bk-quickpick";qp.setAttribute("role","button");qp.setAttribute("tabindex","0"); 12340 var fdObj=new Date(firstDay+"T00:00:00"); 12341 var dayNamesQ=bkT("cal_days_full").split("|"); 12342 var monthNamesQ=bkT("cal_months_short").split("|"); 12343 var whenStr=dayNamesQ[fdObj.getDay()]+" "+fdObj.getDate()+" "+monthNamesQ[fdObj.getMonth()]+" · "+fmtTime(firstSlot.ora_inizio); 12344 var sedeStr=(self.bkSelSede&&(self.bkSelSede.descrizione+(self.bkSelSede.citta?" â "+self.bkSelSede.citta:"")))||""; 12345 qp.innerHTML= 12346 '<div class="caf-bk-quickpick-ic">â¡</div>' 12347 +'<div class="caf-bk-quickpick-body">' 12348 +'<div class="caf-bk-quickpick-lbl">'+escapeHtml(bkT("label_first_avail"))+'</div>' 12349 +'<div class="caf-bk-quickpick-when">'+escapeHtml(whenStr)+'</div>' 12350 +(sedeStr?'<div class="caf-bk-quickpick-where">'+escapeHtml(sedeStr)+'</div>':'') 12351 +'</div>' 12352 +'<div class="caf-bk-quickpick-arrow">â</div>'; 12353 var pickFirst=function(){bkHaptic(10);self._bkLogEvent&&self._bkLogEvent("quickpick_first_slot",{slot:firstSlot});self._bkPickSlot(firstSlot);}; 12354 qp.addEventListener("click",pickFirst); 12355 qp.addEventListener("keydown",function(ev){if(ev.key==="Enter"||ev.key===" "){ev.preventDefault();pickFirst();}}); 12356 main.appendChild(qp); 12357 } 12358 } 12359 /* Header del passo DOPO il quickpick: l'utente vede prima la card verde 12360 * "Prima disponibilità " (CTA primaria), poi il titolo del passo, poi il 12361 * calendario. Critico su mobile dove lo spazio sopra-fold è limitato. */ 12362 main.appendChild(slotHeadEl); 12363 12364 var layout=document.createElement("div");layout.className="caf-bk-fs-slotlayout"; 12365 12366 /* LEFT: month calendar */ 12367 var cal=document.createElement("div");cal.className="caf-bk-fs-monthcal"; 12368 function renderMonth(){ 12369 cal.innerHTML=""; 12370 var parts=self._bkCalMonth.split("-");
12371 var yr=+parts[0],mo=+parts[1]-1; 12372 var first=new Date(yr,mo,1); 12373 var daysInMonth=new Date(yr,mo+1,0).getDate(); 12374 var startDow=(first.getDay()+6)%7; /* monday=0 */ 12375 var monthNames=bkT("cal_months").split("|"); 12376 var hdr=document.createElement("div");hdr.className="caf-bk-fs-monthcal-hdr"; 12377 var title=document.createElement("div");title.className="caf-bk-fs-monthcal-title";title.textContent=monthNames[mo]+" "+yr;hdr.appendChild(title); 12378 var nav=document.createElement("div");nav.className="caf-bk-fs-monthcal-nav"; 12379 /* determine if prev/next has available dates */ 12380 var hasPrev=dayKeys.some(function(dk){var d=new Date(dk+"T00:00:00");return(d.getFullYear()<yr)||(d.getFullYear()===yr&&d.getMonth()<mo);}); 12381 var hasNext=dayKeys.some(function(dk){var d=new Date(dk+"T00:00:00");return(d.getFullYear()>yr)||(d.getFullYear()===yr&&d.getMonth()>mo);}); 12382 /* #20 â FLIP-style slide su cambio mese: il grid esce, il nuovo entra. */ 12383 function _calNavigate(deltaMo){ 12384 var nm=mo+deltaMo,ny=yr;if(nm<0){nm=11;ny--;}if(nm>11){nm=0;ny++;} 12385 self._bkCalMonth=ny+"-"+String(nm+1).padStart(2,"0"); 12386 var oldGrid=cal.querySelector(".caf-bk-fs-monthcal-grid"); 12387 if(oldGrid){ 12388 oldGrid.style.transition="transform .22s ease, opacity .22s ease"; 12389 oldGrid.style.transform="translateX("+(deltaMo>0?-24:24)+"px)"; 12390 oldGrid.style.opacity="0"; 12391 } 12392 setTimeout(function(){ 12393 renderMonth(); 12394 var newGrid=cal.querySelector(".caf-bk-fs-monthcal-grid"); 12395 if(newGrid){ 12396 newGrid.style.transition="none";newGrid.style.transform="translateX("+(deltaMo>0?24:-24)+"px)";newGrid.style.opacity="0"; 12397 requestAnimationFrame(function(){ 12398 newGrid.style.transition="transform .28s cubic-bezier(.34,1.56,.64,1), opacity .28s ease"; 12399 newGrid.style.transform="translateX(0)";newGrid.style.opacity="1"; 12400 }); 12401 } 12402 renderSlots(); 12403 /* refocus della freccia equivalente (o dell'altra se disabilitata): 12404 * il rebuild l'ha distrutta e il focus era caduto su body. */ 12405 try{ 12406 var _aeN=(self.shadow&&self.shadow.activeElement)||document.activeElement; 12407 if(!_aeN||_aeN===document.body){ 12408 var _navB=cal.querySelectorAll(".caf-bk-fs-monthcal-nav button"); 12409 var _same=_navB&&_navB[deltaMo>0?1:0]; 12410 var _other=_navB&&_navB[deltaMo>0?0:1]; 12411 if(_same&&!_same.disabled)_same.focus(); 12412 else if(_other&&!_other.disabled)_other.focus(); 12413 } 12414 }catch(_e){} 12415 },150); 12416 } 12417 var prevB=document.createElement("button");prevB.type="button";prevB.disabled=!hasPrev;prevB.setAttribute("aria-label",bkT("aria_prev_month"));prevB.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><polyline points="15 18 9 12 15 6"/></svg>'; 12418 prevB.addEventListener("click",function(){_calNavigate(-1);}); 12419 nav.appendChild(prevB); 12420 var nextB=document.createElement("button");nextB.type="button";nextB.disabled=!hasNext;nextB.setAttribute("aria-label",bkT("aria_next_month"));nextB.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>'; 12421 nextB.addEventListener("click",function(){_calNavigate(1);}); 12422 nav.appendChild(nextB); 12423 hdr.appendChild(nav);cal.appendChild(hdr); 12424 /* weekdays */ 12425 var wk=document.createElement("div");wk.className="caf-bk-fs-monthcal-week"; 12426 bkT("cal_days_short").split("|").forEach(function(d){var el=document.createElement("div");el.className="caf-bk-fs-monthcal-wd";el.textContent=d;wk.appendChild(el);}); 12427 cal.appendChild(wk); 12428 /* grid */ 12429 var grid=document.createElement("div");grid.className="caf-bk-fs-monthcal-grid"; 12430 for(var e=0;e<startDow;e++){var emC=document.createElement("div");
12430emC.className="caf-bk-fs-monthcal-day empty";grid.appendChild(emC);} 12431 var todayStr=new Date().toISOString().substring(0,10); 12432 for(var d=1;d<=daysInMonth;d++){ 12433 var iso=yr+"-"+String(mo+1).padStart(2,"0")+"-"+String(d).padStart(2,"0"); 12434 var b=document.createElement("button");b.type="button";b.className="caf-bk-fs-monthcal-day";b.textContent=d; 12435 /* WCAG 4.1.2 (#C33/#C62): nome accessibile = data completa localizzata */ 12436 b.setAttribute("aria-label",d+" "+monthNames[mo]+" "+yr); 12437 var hasSlot=!!dayGroups[iso]; 12438 var isPast=iso<todayStr; 12439 if(hasSlot){ 12440 b.classList.add("available"); 12441 var dot=document.createElement("span");dot.className="dot";b.appendChild(dot); 12442 if(iso===self._bkCalSelected)b.classList.add("selected"); 12443 b.setAttribute("aria-pressed",iso===self._bkCalSelected?"true":"false"); 12444 (function(isoI){b.addEventListener("click",function(){ 12445 self._bkCalSelected=isoI;renderMonth();renderSlots(); 12446 /* refocus: il rebuild ha distrutto il bottone-giorno attivato 12447 * (WCAG 2.4.3); rifocalizza il giorno selezionato ricreato. */ 12448 try{ 12449 var _aeD=(self.shadow&&self.shadow.activeElement)||document.activeElement; 12450 if(!_aeD||_aeD===document.body){ 12451 var _selD=cal.querySelector(".caf-bk-fs-monthcal-day.selected"); 12452 if(_selD)_selD.focus(); 12453 } 12454 }catch(_e){} 12455 bkHaptic&&bkHaptic(6); 12456 /* Mobile: scroll automatico agli orari. Trovo il container 12457 * scrollabile reale (.caf-bk-fs-main) e ci scrollo dentro 12458 * con offset preciso, invece di scrollIntoView che a volte 12459 * sposta la pagina intera invece del pannello. */ 12460 if(window.matchMedia&&window.matchMedia("(max-width:719px)").matches){ 12461 requestAnimationFrame(function(){ 12462 /* doppio rAF per essere sicuri che il layout post-render sia stabile */ 12463 requestAnimationFrame(function(){ 12464 var scroller=slotPanel.closest(".caf-bk-fs-main")||slotPanel.parentElement; 12465 if(!scroller)return; 12466 var sRect=scroller.getBoundingClientRect(); 12467 var pRect=slotPanel.getBoundingClientRect(); 12468 var offset=(pRect.top-sRect.top)+scroller.scrollTop-8; /* -8: piccolo respiro sopra */ 12469 try{scroller.scrollTo({top:offset,behavior:bkScrollBehavior()});}catch(_e){scroller.scrollTop=offset;} 12470 }); 12471 }); 12472 } 12473 });})(iso); 12474 }else if(isPast){ 12475 b.classList.add("past");b.disabled=true; 12476 }else{ 12477 b.disabled=true;b.style.opacity=".4";b.style.cursor="default"; 12478 } 12479 if(iso===todayStr)b.classList.add("today"); 12480 grid.appendChild(b); 12481 } 12482 cal.appendChild(grid); 12483 /* legend */ 12484 var lg=document.createElement("div");lg.className="caf-bk-fs-monthcal-legend"; 12485 lg.innerHTML='<span class="lg"><span class="sq"></span> '+escapeHtml(bkT("label_available"))+'</span><span class="lg"><span class="sq sel"></span> '+escapeHtml(bkT("label_selected"))+'</span>'; 12486 cal.appendChild(lg); 12487 } 12488 layout.appendChild(cal); 12489 12490 /* RIGHT: slot panel for selected day */ 12491 var slotPanel=document.createElement("div");slotPanel.className="caf-bk-fs-slotpanel";
12492 function renderSlots(){ 12493 slotPanel.innerHTML=""; 12494 var hdr=document.createElement("div");hdr.className="caf-bk-fs-slotpanel-hdr"; 12495 hdr.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> '; 12496 var sel=self._bkCalSelected; 12497 if(sel&&dayGroups[sel]){ 12498 var dObj=new Date(sel+"T00:00:00"); 12499 var dayNames=bkT("cal_days_full").split("|"); 12500 var monthNames=bkT("cal_months").split("|"); 12501 var lbl=document.createElement("span");lbl.textContent=dayNames[dObj.getDay()]+" "+dObj.getDate()+" "+monthNames[dObj.getMonth()];hdr.appendChild(lbl); 12502 /* #11 â pill Oggi/Domani/Settimana + count degli orari disponibili */ 12503 (function(){ 12504 var today=new Date();today.setHours(0,0,0,0); 12505 var diffMs=dObj.getTime()-today.getTime();var diffD=Math.round(diffMs/86400000); 12506 var lbl2="";var bg=c+"15",col=c; 12507 if(diffD===0){lbl2=bkT("label_today");bg="#fef3c7";col="#d97706";} 12508 else if(diffD===1){lbl2=bkT("label_tomorrow");bg=c+"15";col=c;} 12509 else if(diffD>1&&diffD<=7){lbl2=bkT("label_this_week");bg="#e0f2fe";col="#0369a1";} 12510 /* count orari del giorno: utile soprattutto su mobile, dove il 12511 * banner è prominente e l'info "X orari disponibili" rassicura 12512 * subito l'utente. */ 12513 var nSlots=(dayGroups[sel]||[]).length; 12514 var countPill=document.createElement("span"); 12515 countPill.style.cssText="display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:700;padding:3px 9px;border-radius:99px;background:rgba(255,255,255,.22);color:#fff;margin-left:auto;backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)"; 12516 countPill.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" style="width:11px;height:11px"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> '+escapeHtml(bkT(nSlots===1?"label_count_slots_one":"label_count_slots_other",nSlots)); 12517 hdr.appendChild(countPill); 12518 if(lbl2){ 12519 var pill=document.createElement("span"); 12520 pill.style.cssText="display:inline-flex;align-items:center;gap:4px;font-size:9.5px;font-weight:800;letter-spacing:.6px;padding:3px 8px;border-radius:99px;background:"+bg+";color:"+col; 12521 pill.textContent=lbl2;hdr.appendChild(pill); 12522 } 12523 })(); 12524 slotPanel.appendChild(hdr); 12525 /* split am/pm */ 12526 var slots=dayGroups[sel]; 12527 var amS=[],pmS=[]; 12528 slots.forEach(function(s){var h=+(s.ora_inizio||"").slice(0,2);(h>=13?pmS:amS).push(s);}); 12529 function grpSlots(label,list,period){ 12530 if(!list.length)return; 12531 var g=document.createElement("div");g.className="caf-bk-fs-slotgroup"; 12532 var gh=document.createElement("div");gh.className="caf-bk-fs-slotgroup-hdr"; 12533 gh.innerHTML=period==="am"?'<svg viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2.2" stroke-linecap="round" style="width:12px;height:12px"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>':'<svg viewBox="0 0 24 24" fill="none" stroke="#fb923c" stroke-width="2.2" stroke-linecap="round" style="width:12px;height:12px"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>'; 12534 var t=document.createElement("span");t.textContent=label;gh.appendChild(t);g.appendChild(gh); 12535 var cs=document.createElement("div");cs.className="caf-bk-fs-cal-slots"; 12536 list.forEach(function(s,si){ 12537 var ch=document.createElement("button");ch.type="button";ch.className="caf-bk-fs-cal-chip"; 12538 ch.style.animation="caf-fade-up .25s ease-out "+(si*22)+"ms backwards"; 12539 /* #23 â density attribute basato su quanti slot esistono in quella fascia oraria */ 12540 var hh=parseInt(String(s.ora_inizio||"").slice(0,2),10); 12541 var dens=slotDensityGlobal[hh]; 12542 if(dens!=null){ch.setAttribute("data-density",dens>=0.7?"hot":dens>=0.4?"warm":"cool");} 12543 var dotEl=document.createElement("span");dotEl.className="d "+period;ch.appendChild(dotEl); 12544 var tn=document.createElement("span");tn.textContent=fmtTime(s.ora_inizio);ch.appendChild(tn); 12545 /* #10 â popularity dot: mostrato SOLO quando l'API fornisce 12546 * dati reali su disponibilità (s.disponibili + s.posti). Senza 12547 * dati veri, evitiamo segnali fuorvianti per ogni slot. */ 12548 if(s.disponibili!==undefined&&s.posti!==undefined&&+s.posti>0){ 12549 var avail=+s.disponibili/+s.posti; 12550 var pCol="#10b981",pTip="Molti posti disponibili"; 12551 if(avail<0.25){pCol="#ef4444";pTip="Ultimi posti";} 12552 else if(avail<0.6){pCol="#f59e0b";pTip="Pochi posti";} 12553 var pop=document.createElement("span"); 12554 pop.style.cssText="position:absolute;top:3px;right:3px;width:6px;height:6px;border-radius:50%;background:"+pCol+";box-shadow:0 0 0 1.5px #fff"; 12555 pop.title=pTip;ch.style.position="relative";
12555ch.appendChild(pop); 12556 } 12557 ch.addEventListener("click",function(){bkHaptic(8);self._bkPickSlot(s);}); 12558 cs.appendChild(ch); 12559 }); 12560 g.appendChild(cs);slotPanel.appendChild(g); 12561 } 12562 grpSlots(bkT("label_morning"),amS,"am"); 12563 grpSlots(bkT("label_afternoon"),pmS,"pm"); 12564 }else{ 12565 var noTxt=document.createElement("span");noTxt.textContent=bkT("label_pick_day");hdr.appendChild(noTxt); 12566 slotPanel.appendChild(hdr); 12567 var empty=document.createElement("div");empty.className="caf-bk-fs-slotpanel-empty";empty.textContent=bkT("label_no_slots_day");slotPanel.appendChild(empty); 12568 } 12569 } 12570 renderMonth();renderSlots(); 12571 layout.appendChild(slotPanel); 12572 main.appendChild(layout); 12573 if(self.bkNextG){ 12574 var mr=document.createElement("button");mr.type="button";mr.className="caf-bk-fs-btn caf-bk-fs-btn-ghost";mr.style.alignSelf="flex-start";mr.style.marginTop="12px"; 12575 if(self._bkLoadingMore){mr.disabled=true;mr.style.opacity="0.6";mr.innerHTML='<span class="spin"></span> '+escapeHtml(bkT("btn_load_more"));} 12576 else{ 12577 mr.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14m-7-7h14"/></svg> '+escapeHtml(bkT("btn_load_more")); 12578 mr.addEventListener("click",function(){self._bkLoadMore();}); 12579 } 12580 main.appendChild(mr); 12581 } 12582 break; 12583 } 12584 case "collectData":{ 12585 main.appendChild(mkHead(5,bkT("h_data"),bkT("s_data"))); 12586 /* Slot reservation timer (fullscreen) */ 12587 var slotTmrFs=document.createElement("div");slotTmrFs.style.maxWidth="680px";main.appendChild(slotTmrFs); 12588 self._bkRenderSlotTimer(slotTmrFs); 12589 /* Toggle Per me / Per un familiare */ 12590 var forRowFs=document.createElement("div");forRowFs.style.cssText="display:flex;gap:8px;background:#f1f5f9;border-radius:12px;padding:4px;margin:0 0 14px;max-width:420px"; 12591 [["self",bkT("label_form_for_self")],["family",bkT("label_form_for_family")]].forEach(function(pair){ 12592 var b=document.createElement("button");b.type="button";b.style.cssText="flex:1;border:none;padding:9px 12px;font-size:13px;font-weight:700;border-radius:10px;cursor:pointer;transition:all .2s;font-family:inherit;"+(self.bkBookingFor===pair[0]?"background:#fff;color:"+c+";box-shadow:0 1px 4px rgba(0,0,0,.08)":"background:transparent;color:#5b6573"); 12593 b.textContent=pair[1]; 12594 /* WCAG 4.1.2 (#C35): stato del toggle esposto alle AT */ 12595 b.setAttribute("aria-pressed",self.bkBookingFor===pair[0]?"true":"false"); 12596 b.addEventListener("click",function(){ 12597 if(self.bkBookingFor===pair[0])return; 12598 self.bkBookingFor=pair[0]; 12599 /* #PII â cambia intestatario: ricarica il candidato (non pre-fill cieco) 12600 * e svuota il form; il gate "Sei tu?" chiederà conferma. */ 12601 self._bkStoredCandidate=loadStoredClient(pair[0]); 12602 self.bkClient={nome:"",cognome:"",codfis:"",email:"",cellulare:"",sesso:"M"}; 12603 self._bkRenderFs(); 12604 self._bkLogEvent&&self._bkLogEvent("booking_for_changed",{for:pair[0]}); 12605 }); 12606 forRowFs.appendChild(b); 12607 }); 12608 main.appendChild(forRowFs); 12609 /* Trust signals strip (fullscreen) */ 12610 var tsFs=document.createElement("div");tsFs.style.cssText="display:flex;gap:14px;align-items:center;flex-wrap:wrap;font-size:12.5px;color:#475569;background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;padding:10px 14px;max-width:680px;margin:0 0 14px"; 12611 tsFs.innerHTML='<span style="display:inline-flex;align-items:center;gap:5px"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2.4" stroke-linecap="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> '+escapeHtml(bkT("label_data_safe"))+'</span>'+ 12612 '<span style="color:#5b6573">·</span>'+ 12613 '<span style="display:inline-flex;align-items:center;gap:5px"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#0ea5e9" stroke-width="2.4" stroke-linecap="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.86 19.86 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.86 19.86 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.37 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.9
126131.33 1.85.57 2.81.7A2 2 0 0 1 22 16.92Z"/></svg> '+escapeHtml(bkT("label_data_caf"))+'</span>'+ 12614 '<span style="color:#5b6573">·</span>'+ 12615 '<span style="display:inline-flex;align-items:center;gap:5px"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2.4" stroke-linecap="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> '+escapeHtml(bkT("label_data_real"))+'</span>'; 12616 main.appendChild(tsFs); 12617 /* #PII â gate "Sei tu?": se ci sono dati salvati (candidato) e il form è 12618 * ancora vuoto, chiediamo conferma esplicita prima di adottarli. Su un PC 12619 * condiviso (sportello) questo impedisce alla persona successiva di inviare 12620 * per sbaglio i dati di quella precedente. */ 12621 var _cand=self._bkStoredCandidate; 12622 var _formEmpty=!(self.bkClient.codfis||self.bkClient.nome||self.bkClient.email); 12623 if(_cand&&(_cand.codfis||_cand.nome)&&_formEmpty){ 12624 var who=((_cand.nome||"")+" "+(_cand.cognome||"")).trim(); 12625 var cfTail=(_cand.codfis||"").toUpperCase().slice(-4); 12626 var reuse=document.createElement("div"); 12627 reuse.style.cssText="display:flex;flex-wrap:wrap;align-items:center;gap:10px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:12px;padding:12px 14px;max-width:680px;margin:0 0 14px;font-size:13.5px;color:#1e3a5f"; 12628 var rtxt=document.createElement("div");rtxt.style.cssText="flex:1;
12628min-width:200px;line-height:1.4"; 12629 rtxt.innerHTML='<strong>'+escapeHtml(bkT("label_reuse_data_h"))+'</strong><br><span style="color:#475569">'+escapeHtml(who||bkT("label_saved_data"))+(cfTail?' · CF â¦'+escapeHtml(cfTail):'')+'</span>'; 12630 reuse.appendChild(rtxt); 12631 var ryes=document.createElement("button");ryes.type="button";ryes.textContent=bkT("btn_its_me"); 12632 ryes.setAttribute("aria-pressed","false"); 12633 ryes.style.cssText="border:none;background:var(--caf-c);color:var(--caf-on-c);font-weight:700;font-size:13px;padding:8px 14px;border-radius:9px;cursor:pointer;font-family:inherit"; 12634 ryes.addEventListener("click",function(){ 12635 self.bkClient={nome:_cand.nome||"",cognome:_cand.cognome||"",codfis:_cand.codfis||"",email:_cand.email||"",cellulare:_cand.cellulare||"",sesso:self.bkClient.sesso||"M"}; 12636 self._bkLogEvent&&self._bkLogEvent("prefill_reused",{for:self.bkBookingFor}); 12637 self._bkRenderFs(); 12638 }); 12639 var rno=document.createElement("button");rno.type="button";rno.textContent=bkT("btn_not_me"); 12640 rno.setAttribute("aria-pressed","false"); 12641 rno.style.cssText="border:1px solid #cbd5e1;background:#fff;color:#475569;font-weight:600;font-size:13px;padding:8px 14px;border-radius:9px;cursor:pointer;font-family:inherit"; 12642 rno.addEventListener("click",function(){ 12643 self._bkStoredCandidate=null; 12644 try{localStorage.removeItem(clientStorageKey(self.bkBookingFor));}catch(_e){} 12645 self._bkLogEvent&&self._bkLogEvent("prefill_rejected",{for:self.bkBookingFor}); 12646 self._bkRenderFs(); 12647 }); 12648 reuse.appendChild(ryes);reuse.appendChild(rno); 12649 main.appendChild(reuse); 12650 } 12651 var fg=document.createElement("div");fg.className="caf-bk-fs-form"; 12652 var ficons={ 12653 nome:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>', 12654 cognome:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>', 12655 codfis:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>', 12656 email:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2Z"/><path d="m22 6-10 7L2 6"/></svg>', 12657 cellulare:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>' 12658 }; 12659 var fvalidate={nome:function(v){return v.trim().length>=2;},cognome:function(v){return v.trim().length>=2;},codfis:function(v){return validateCodiceFiscale(v).ok;},email:function(v){return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);},cellulare:function(v){return v.replace(/\D/g,"").length>=8;}}; 12660 var flabels={nome:bkT("label_form_nome"),cognome:bkT("label_form_cognome"),codfis:bkT("label_form_codfis"),email:bkT("label_form_email"),cellulare:bkT("label_form_cellulare")}; 12661 var checkSvg='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M20 6L9 17l-5-5"/></svg>'; 12662 /* Codice Fiscale come primo campo: con l'auto-fill #1 ne deriviamo 12663 * sesso/data nascita/comune e l'utente verifica subito di aver 12664 * digitato il proprio CF prima di procedere col resto. */ 12665 var fFields=[{k:"codfis",p:"RSSMRA80A01H501U",t:"text",mx:16,up:1,full:true},{k:"nome",p:"Mario",t:"text",full:false},{k:"cognome",p:"Rossi",t:"text",full:false},{k:"email",p:"[email protected]",t:"email",full:false},{k:"cellulare",p:"333 1234567",t:"tel",full:false}]; 12666 /* CF gate (fullscreen): finché CF non è algoritmicamente valido, 12667 * blocchiamo gli altri input e il pulsante "Prosegui" nel footer. */ 12668 var _fsWrapsByKey={};var _fsInpsByKey={};var _fsCfReasonBox=null; 12669 self._bkCfFsApplyGate=function(){ 12670 var ok=fvalidate.codfis(self.bkClient.codfis||""); 12671 ["nome","cognome","email","cellulare"].forEach(function(k){ 12672 var inp=_fsInpsByKey[k];var w=_fsWrapsByKey[k];if(!inp||!w)return; 12673 inp.disabled=!ok;
12674 if(!ok){inp.setAttribute("aria-disabled","true");inp.setAttribute("title",bkT("err_cf_first"));w.style.opacity="0.45";w.style.pointerEvents="none";} 12675 else{inp.removeAttribute("aria-disabled");inp.removeAttribute("title");w.style.opacity="";w.style.pointerEvents="";} 12676 }); 12677 if(self._bkCfFsNextBtn){ 12678 /* "Prosegui al riepilogo": richiede CF valido E consenso privacy 12679 * spuntato. Senza spunta non si procede con la prenotazione. */ 12680 var nextOk=ok&&!!self.bkPrivacyOk; 12681 self._bkCfFsNextBtn.disabled=!nextOk; 12682 self._bkCfFsNextBtn.style.opacity=nextOk?"":"0.45"; 12683 self._bkCfFsNextBtn.style.cursor=nextOk?"":"not-allowed"; 12684 self._bkCfFsNextBtn.setAttribute("title",nextOk?"":(!ok?bkT("err_cf_first"):bkT("consent_required"))); 12685 } 12686 }; 12687 fFields.forEach(function(f,i){ 12688 var col=document.createElement("div");col.className="caf-bk-inp-wrap";if(f.full)col.classList.add("caf-bk-fs-form-full"); 12689 col.style.animation="caf-fade-up .35s ease-out "+(i*50)+"ms backwards"; 12690 /* label */ 12691 var lbl=document.createElement("div");lbl.className="caf-bk-inp-label";lbl.textContent=flabels[f.k];col.appendChild(lbl); 12692 var wrap=document.createElement("div");wrap.className="caf-bk-inp-wrap"; 12693 var isValid=fvalidate[f.k]&&fvalidate[f.k](self.bkClient[f.k]); 12694 if(isValid&&self.bkClient[f.k])wrap.classList.add("caf-bk-inp-valid"); 12695 var icon=document.createElement("div");icon.className="caf-bk-inp-icon";icon.innerHTML=ficons[f.k];wrap.appendChild(icon); 12696 var inp=document.createElement("input");inp.className="caf-bk-inp";inp.type=f.t;inp.placeholder=f.p;inp.value=self.bkClient[f.k]; 12697 inp.setAttribute("aria-label",flabels[f.k]);inp.setAttribute("aria-required","true"); 12698 /* attributi mobile-friendly per tastiera nativa corretta */ 12699 var fAutoFs={nome:"given-name",cognome:"family-name",codfis:"off",email:"email",cellulare:"tel"}; 12700 var fImFs={nome:"text",cognome:"text",codfis:"text",email:"email",cellulare:"tel"}; 12701 inp.setAttribute("autocomplete",fAutoFs[f.k]||"off"); 12702 inp.setAttribute("inputmode",fImFs[f.k]||"text"); 12703 inp.setAttribute("enterkeyhint",f.k==="cellulare"?"done":"next"); 12704 if(f.up)inp.setAttribute("autocapitalize","characters"); 12705 if(f.mx)inp.maxLength=f.mx;if(f.up)inp.style.textTransform="uppercase"; 12706 var chk=document.createElement("div");chk.className="caf-bk-inp-check";chk.innerHTML=checkSvg;wrap.appendChild(chk); 12707 inp.addEventListener("input",function(){ 12708 var val=f.up?inp.value.toUpperCase():inp.value; 12709 if(f.k==="cellulare"){var formatted=formatPhoneIT(val);if(formatted!==val){inp.value=formatted;val=formatted;try{inp.setSelectionRange(formatted.length,formatted.length);}catch(_e){}}} 12710 self.bkClient[f.k]=val; 12711 if(fvalidate[f.k]&&fvalidate[f.k](val)){wrap.classList.add("caf-bk-inp-valid");inp.setAttribute("aria-invalid","false");}else{wrap.classList.remove("caf-bk-inp-valid");inp.setAttribute("aria-invalid",val?"true":"false");} 12712 if(self.bkFormErr){self.bkFormErr=null;var errEl=main.querySelector(".caf-bk-ferr");if(errEl)errEl.remove();} 12713 if(f.k==="email"){var oldSugg2=col.querySelector(".caf-bk-email-sugg");if(oldSugg2)oldSugg2.remove();} 12714 /* #1 â Auto-fill da Codice Fiscale: quando l'utente raggiunge 16 12715 * caratteri, decodifichiamo data nascita + sesso + comune codice 12716 * catastale. Mostriamo un chip di feedback. Nome/cognome NON sono 12717 * deterministicamente derivabili dal CF (sono solo consonanti) â 12718 * quindi non li auto-compiliamo, ma usiamo i dati estratti per 12719 * popolare lo state e validare coerenza. 12720 * Il chip è informativo: aiuta l'utente a verificare di aver 12721 * digitato il proprio CF e non quello di un'altra persona. */ 12722 if(f.k==="codfis"){ 12723 var oldChip=col.querySelector(".caf-bk-cf-chip");if(oldChip)oldChip.remove(); 12724 if(val.length===16){ 12725 var parsed=bkParseCodiceFiscale(val); 12726 if(parsed){ 12727 /* Salva nello state per uso nel payload (sesso era hardcoded "M") */ 12728 self.bkClient.sesso=parsed.sesso;
12729 self.bkClient._cfBirthDate=parsed.giorno+"/"+parsed.mese+"/"+parsed.anno; 12730 self.bkClient._cfComune=parsed.codiceCatastale; 12731 var chip=document.createElement("div");chip.className="caf-bk-cf-chip"; 12732 var sessoLbl=parsed.sesso==="F"?bkT("cf_female"):bkT("cf_male"); 12733 var dateStr=parsed.giorno+"/"+parsed.mese+"/"+parsed.anno; 12734 chip.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg>' 12735 +'<span><b>'+escapeHtml(bkT("cf_valid_ok"))+'</b> '+escapeHtml(bkT("cf_valid_recognized",sessoLbl,dateStr,parsed.codiceCatastale))+'</span>'; 12736 col.appendChild(chip); 12737 }else{ 12738 var chipW=document.createElement("div");chipW.className="caf-bk-cf-chip warn"; 12739 chipW.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg>' 12740 +'<span>'+escapeHtml(bkT("cf_invalid"))+'</span>'; 12741 col.appendChild(chipW); 12742 } 12743 } 12744 } 12745 }); 12746 if(f.k==="email"){ 12747 inp.addEventListener("blur",function(){ 12748 var v=inp.value.trim();if(!v||v.indexOf("@")<0)return; 12749 var sugg=suggestEmailDomain(v);if(!sugg)return; 12750 var existing=col.querySelector(".caf-bk-email-sugg");if(existing)existing.remove(); 12751 var s=document.createElement("div");s.className="caf-bk-email-sugg"; 12752 s.style.cssText="font-size:13px;color:#0369a1;background:#f0f9ff;border:1px solid #bae6fd;border-radius:8px;padding:8px 12px;margin-top:6px;cursor:pointer;display:flex;align-items:center;gap:8px"; 12753 s.innerHTML='<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg> '+escapeHtml(bkT("label_did_you_mean"))+' <strong>'+escapeHtml(sugg)+'</strong> <span style="margin-left:auto;text-decoration:underline">'+escapeHtml(bkT("btn_use"))+'</span>'; 12754 s.addEventListener("click",function(){inp.value=sugg;self.bkClient.email=sugg;wrap.classList.add("caf-bk-inp-valid");s.remove();self._bkLogEvent&&self._bkLogEvent("email_typo_corrected");}); 12755 col.appendChild(s); 12756 self._bkLogEvent&&self._bkLogEvent("email_typo_suggested"); 12757 }); 12758 } 12759 inp.addEventListener("keydown",function(e){ 12760 if(e.key==="Enter"){e.preventDefault();var all=main.querySelectorAll(".caf-bk-inp");var idx=-1;for(var ai=0;ai<all.length;ai++){if(all[ai]===inp){idx=ai;break;}}if(idx>=0&&idx<all.length-1)all[idx+1].focus();else self._bkConfirm();} 12761 }); 12762 wrap.appendChild(inp);col.appendChild(wrap);fg.appendChild(col); 12763 _fsWrapsByKey[f.k]=wrap;_fsInpsByKey[f.k]=inp; 12764 if(f.k==="codfis"){ 12765 _fsCfReasonBox=document.createElement("div"); 12766 _fsCfReasonBox.className="caf-bk-cf-reason"; 12767 var _fsCfReasonId=quickId();_fsCfReasonBox.id=_fsCfReasonId;_fsCfReasonBox.setAttribute("role","alert");inp.setAttribute("aria-describedby",_fsCfReasonId); 12768 _fsCfReasonBox.style.cssText="font-size:12px;line-height:1.35;margin:4px 2px 0;min-height:14px"; 12769 col.appendChild(_fsCfReasonBox); 12770 var _fsRefreshCfReason=function(){ 12771 var v=self.bkClient.codfis||""; 12772 if(!v){_fsCfReasonBox.textContent="";_fsCfReasonBox.style.color="";return;} 12773 var r=validateCodiceFiscale(v); 12774 if(r.ok){_fsCfReasonBox.textContent="â "+bkT("cf_valid_ok");_fsCfReasonBox.style.color="#047857";} 12775 else{_fsCfReasonBox.textContent=r.reason;_fsCfReasonBox.style.color="#be123c";} 12776 }; 12777 inp.addEventListener("input",function(){_fsRefreshCfReason();self._bkCfFsApplyGate();}); 12778 inp.addEventListener("blur",function(){_fsRefreshCfReason();}); 12779 _fsRefreshCfReason(); 12780 } 12781 }); 12782 self._bkCfFsApplyGate(); 12783 /* gender row rimosso dalla UI per scelta UX. Il default sesso:"M"
12784 * viene mantenuto nello state (bkClient) per compatibilità col payload 12785 * API del backend Planner che richiede comunque il campo. */ 12786 main.appendChild(fg); 12787 if(self.bkFormErr){var fe=document.createElement("div");fe.className="caf-bk-ferr";fe.setAttribute("role","alert");fe.style.maxWidth="680px";fe.textContent=self.bkFormErr;main.appendChild(fe);} 12788 /* PRIVACY â consenso OBBLIGATORIO allo step Dati: la checkbox gating il 12789 * pulsante "Prosegui al riepilogo" (insieme al CF valido). Senza spunta 12790 * non si procede con la prenotazione. onToggle ri-applica il gate. */ 12791 main.appendChild(self._bkBuildConsentBox(function(){if(typeof self._bkCfFsApplyGate==="function")self._bkCfFsApplyGate();})); 12792 self._bkCfFsApplyGate(); 12793 /* #14 â attiva scroll-on-focus per gli input del form (mobile only) */ 12794 setTimeout(function(){self._bkAttachKeyboardScroll(self._bkFsEl);},0); 12795 break; 12796 } 12797 case "confirm":{ 12798 main.appendChild(mkHead(5,bkT("h_confirm"),bkT("s_confirm"))); 12799 var sp=self.bkSelSlot||{},sd=self.bkSelSede||{},sv=self.bkSelServ||{},cdx=self.bkClient; 12800 /* #35 â premium summary card a 3 colonne con edit inline (#36) */ 12801 var card=document.createElement("div");card.className="caf-bk-sum-premium"; 12802 /* head */ 12803 var head=document.createElement("div");head.className="caf-bk-sum-premium-head"; 12804 head.innerHTML='<div class="caf-bk-sum-premium-check"><svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg></div>' 12805 +'<div><h3 class="caf-bk-sum-premium-h">'+escapeHtml(bkT("label_summary_premium_h"))+'</h3>' 12806 +'<div class="caf-bk-sum-premium-s">'+escapeHtml(bkT("label_summary_premium_s"))+'</div></div>'; 12807 card.appendChild(head); 12808 /* cols: Quando / Dove / Cosa */ 12809 var cols=document.createElement("div");cols.className="caf-bk-sum-premium-cols"; 12810 function mkCol(iconSvg,h,line,sub,goStep){ 12811 var col=document.createElement("div");col.className="caf-bk-sum-premium-col"; 12812 var hd=document.createElement("div");hd.className="caf-bk-sum-premium-col-h";hd.innerHTML=iconSvg+' '+escapeHtml(h);col.appendChild(hd); 12813 var ln=document.createElement("div");ln.className="caf-bk-sum-premium-line";ln.textContent=line;col.appendChild(ln); 12814 if(sub){var sb=document.createElement("div");sb.className="caf-bk-sum-premium-sub";sb.textContent=sub;col.appendChild(sb);} 12815 if(goStep){ 12816 var ed=document.createElement("button");ed.type="button";ed.className="caf-bk-sum-premium-edit"; 12817 ed.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg> '+escapeHtml(bkT("btn_edit")); 12818 ed.addEventListener("click",function(){self._bkGoToStep(goStep);}); 12819 col.appendChild(ed); 12820 } 12821 return col; 12822 } 12823 cols.appendChild(mkCol( 12824 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>', 12825 bkT("label_when"), 12826 fmtDate(sp.data||"")+" · "+fmtTime(sp.ora_inizio||""), 12827 fmtTime(sp.ora_inizio||"")+" â "+fmtTime(sp.ora_fine||""), 12828 "selectSlot" 12829 )); 12830 cols.appendChild(mkCol( 12831 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/></svg>', 12832 bkT("label_where"), 12833 sd.descrizione||"", 12834 (sd.indirizzo||"")+(sd.citta?", "+sd.citta:""), 12835 "selectSede" 12836 )); 12837 cols.appendChild(mkCol( 12838 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>', 12839 bkT("label_what"), 12840 sv.desonline||sv.descrizione||"", 12841 (+sv.tempo>0?bkT("label_form_duration_min",sv.tempo):""), 12842 "selectServizio" 12843 )); 12844 card.appendChild(cols); 12845 /* intestatario row (ulteriore) */ 12846 var ownerCols=document.createElement("div");
12846ownerCols.className="caf-bk-sum-premium-cols"; 12847 /* a11y #C27 (WCAG 1.4.10): niente "1fr 1fr" inline (non collassava mai): 12848 * auto-fit -> 2 colonne solo quando lo spazio basta, 1 colonna a 320px. */ 12849 ownerCols.style.gridTemplateColumns="repeat(auto-fit,minmax(min(100%,200px),1fr))"; 12850 ownerCols.appendChild(mkCol( 12851 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="8" r="4"/><path d="M4 21v-2a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v2"/></svg>', 12852 bkT("label_owner"), 12853 ((cdx&&cdx.nome)||"")+" "+((cdx&&cdx.cognome)||""), 12854 bkT("label_form_codfis")+": "+((cdx&&cdx.codfis)||"â"), 12855 "collectData" 12856 )); 12857 ownerCols.appendChild(mkCol( 12858 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2Z"/><path d="m22 6-10 7L2 6"/></svg>', 12859 bkT("label_contacts"), 12860 (cdx&&cdx.email)||"â", 12861 (cdx&&cdx.cellulare)||"", 12862 "collectData" 12863 )); 12864 card.appendChild(ownerCols); 12865 /* #39 â disclaimer cancellazione */ 12866 var disc=document.createElement("div");disc.className="caf-bk-sum-disclaimer"; 12867 disc.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg> '+escapeHtml(bkT("label_summary_disclaimer")); 12868 card.appendChild(disc); 12869 main.appendChild(card); 12870 /* #37 â pulsante conferma "mega" prominente */ 12871 var confirmWrap=document.createElement("div");confirmWrap.style.cssText="margin:18px 0 6px;display:flex;justify-content:center"; 12872 var bigConfirm=document.createElement("button");bigConfirm.type="button";bigConfirm.className="caf-bk-confirm-mega"; 12873 bigConfirm.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M20 6L9 17l-5-5"/></svg> '+escapeHtml(bkT("btn_confirm")); 12874 bigConfirm.addEventListener("click",function(){ 12875 if(bigConfirm.disabled)return; 12876 if(!self.bkPrivacyOk){self._bkShowToast&&self._bkShowToast({type:"error",msg:bkT("consent_required")});return;} 12877 bigConfirm.disabled=true; 12878 bigConfirm.innerHTML='<span class="caf-bk-confirm-spinner"></span> '+escapeHtml(bkT("btn_confirming")); 12879 bkHaptic(15); 12880 self._bkSubmit(); 12881 }); 12882 confirmWrap.appendChild(bigConfirm); 12883 /* PRIVACY â checkbox di consenso obbligatoria sopra il bottone Conferma. 12884 * Il gate disabilita SIA il bottone "mega" nel corpo SIA il bottone 12885 * Conferma nello sticky footer (self._bkFsFootConfirmBtn, creato dopo, nel 12886 * footer), così tutti i CTA di conferma del layout fullscreen riflettono 12887 * in modo coerente lo stato del consenso. Azzero la ref del footer: verrà 12888 * ri-agganciata dal footer di QUESTO render. */ 12889 self._bkFsFootConfirmBtn=null; 12890 var applyFsConsentGate=function(ok){ 12891 var _btns=[bigConfirm,self._bkFsFootConfirmBtn]; 12892 for(var _i=0;_i<_btns.length;_i++){var _b=_btns[_i];if(!_b)continue; 12893 _b.disabled=!ok;_b.style.opacity=ok?"":"0.5";_b.style.cursor=ok?"":"not-allowed";_b.style.filter=ok?"":"grayscale(0.35)";_b.title=ok?"":bkT("consent_required");} 12894 }; 12895 self._bkFsConsentApplyGate=applyFsConsentGate; 12896 /* Il consenso viene raccolto allo step Dati. Qui lo ri-mostriamo SOLO se 12897 * non ancora dato (es. ripresa diretta sul riepilogo), così l'utente può 12898 * comunque acconsentire; il gate sui bottoni resta sempre attivo. */ 12899 if(!self.bkPrivacyOk){main.appendChild(self._bkBuildConsentBox(applyFsConsentGate));} 12900 applyFsConsentGate(!!self.bkPrivacyOk); 12901 main.appendChild(confirmWrap); 12902 break; 12903 } 12904 case "done":{ 12905 body.style.gridTemplateColumns="1fr";side.style.display="none"; 12906 var dn=document.createElement("div");dn.className="caf-bk-fs-done"; 12907 var dIc=document.createElement("div");
12907dIc.className="caf-bk-fs-done-ic"; 12908 dIc.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>'; 12909 dn.appendChild(dIc); 12910 /* #31 â confetti SVG ricco (stelle/cerchi/cuori/diamanti) sull'intero 12911 * pannello "done", non solo sull'icona, per un effetto più festoso. */ 12912 self._bkSpawnConfetti(dn,{count:36}); 12913 var dt=document.createElement("div");dt.className="caf-bk-fs-done-t";dt.textContent=bkT("label_done_h");dn.appendChild(dt); 12914 var ds=document.createElement("div");ds.className="caf-bk-fs-done-s";ds.textContent=bkT("label_done_s");dn.appendChild(ds); 12915 if(self.bkId){var bid=document.createElement("div");bid.className="caf-bk-fs-done-id";bid.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" style="width:12px;height:12px"><path d="M20 12V8l-8-5-8 5v8a4 4 0 0 0 4 4h4"/><path d="M9 12h6"/><path d="M9 16h3"/></svg> '+escapeHtml(bkT("label_done_id"))+escapeHtml(self.bkId);dn.appendChild(bid);} 12916 /* summary mini */ 12917 var dss=document.createElement("div");dss.className="caf-bk-fs-done-sum caf-bk-fs-summary"; 12918 var dspSlot=self.bkSelSlot||{}; 12919 var dsRows=[ 12920 [dateSvg,"Data",fmtDate(dspSlot.data||"")+" · "+fmtTime(dspSlot.ora_inizio||"")], 12921 [sedeSvg,"Sede",(self.bkSelSede||{}).descrizione||""], 12922 [servSvg,"Servizio",(self.bkSelServ||{}).desonline||(self.bkSelServ||{}).descrizione||""], 12923 ]; 12924 dsRows.forEach(function(r){ 12925 var row=document.createElement("div");row.className="caf-bk-fs-summary-row"; 12926 var ic=document.createElement("div");ic.className="caf-bk-fs-summary-ic";ic.innerHTML=r[0];row.appendChild(ic); 12927 var bd=document.createElement("div");bd.className="caf-bk-fs-summary-body"; 12928 var l=document.createElement("div");l.className="caf-bk-fs-summary-lbl";l.textContent=r[1];bd.appendChild(l); 12929 var v=document.createElement("div");v.className="caf-bk-fs-summary-val";v.textContent=r[2];bd.appendChild(v); 12930 row.appendChild(bd);dss.appendChild(row); 12931 }); 12932 dn.appendChild(dss); 12933 var dActs=document.createElement("div");dActs.className="caf-bk-fs-done-actions"; 12934 /* ICS calendar download */ 12935 var btIcs=document.createElement("button");btIcs.type="button";btIcs.className="caf-bk-fs-btn caf-bk-fs-btn-ghost"; 12936 btIcs.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;margin-right:6px;vertical-align:-2px"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M12 14v4M10 16h4"/></svg> '+escapeHtml(bkT("label_add_calendar")); 12937 btIcs.addEventListener("click",function(){ 12938 var ics=buildIcsForBooking({bookingId:self.bkId,sede:self.bkSelSede,servizio:self.bkSelServ,slot:self.bkSelSlot}); 12939 if(ics)downloadIcs(ics,"caf-cisl-"+(self.bkId||"appuntamento")+".ics"); 12940 self._bkLogEvent&&self._bkLogEvent("ics_downloaded"); 12941 }); 12942 dActs.appendChild(btIcs); 12943 /* WhatsApp share */ 12944 var btWa=document.createElement("button");btWa.type="button";btWa.className="caf-bk-fs-btn caf-bk-fs-btn-ghost"; 12945 btWa.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;margin-right:6px;vertical-align:-2px"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg> '+escapeHtml(bkT("label_share_whatsapp")); 12946 btWa.addEventListener("click",function(){ 12947 var sp=self.bkSelSlot||{};var sd=self.bkSelSede||{};var sv=self.bkSelServ||{}; 12948 var msg="Ho prenotato un appuntamento al CAF CISL: "+(sv.descrizione||"")+" il "+fmtDate(sp.data||"")+" alle "+fmtTime(sp.ora_inizio||"")+" presso "+(sd.descrizione||"")+(sd.indirizzo?" ("+sd.indirizzo+(sd.citta?", "+sd.citta:"")+")":"")+(self.bkId?" â ID: "+self.bkId:""); 12949 window.open(whatsappShareUrl(msg),"_blank","noopener"); 12950 self._bkLogEvent&&self._bkLogEvent("whatsapp_shared"); 12951 }); 12952 dActs.appendChild(btWa); 12953 /* Mailto reminder */ 12954 var btMl=document.createElement("a");
12954btMl.className="caf-bk-fs-btn caf-bk-fs-btn-ghost"; 12955 btMl.href=mailtoReminder({email:self.bkClient.email,bookingId:self.bkId,sede:self.bkSelSede,servizio:self.bkSelServ,slot:self.bkSelSlot}); 12956 btMl.style.cssText="text-decoration:none;display:inline-flex;align-items:center;gap:6px;cursor:pointer"; 12957 btMl.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2Z"/><path d="m22 6-10 7L2 6"/></svg> '+escapeHtml(bkT("label_email_reminder")); 12958 btMl.addEventListener("click",function(){self._bkLogEvent&&self._bkLogEvent("mailto_clicked");}); 12959 dActs.appendChild(btMl); 12960 var btClose=document.createElement("button");btClose.type="button";btClose.className="caf-bk-fs-btn caf-bk-fs-btn-pri";btClose.textContent=bkT("btn_close"); 12961 btClose.addEventListener("click",function(){self._bkReset();}); 12962 dActs.appendChild(btClose); 12963 dn.appendChild(dActs); 12964 /* Mini-survey post-booking */ 12965 var srvFs=document.createElement("div");srvFs.style.cssText="margin-top:18px;padding:14px 18px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;text-align:center;max-width:420px"; 12966 var srvFsLbl=document.createElement("div");srvFsLbl.style.cssText="font-size:13px;color:#475569;margin-bottom:8px;font-weight:600";srvFsLbl.textContent=bkT("label_done_survey");srvFs.appendChild(srvFsLbl); 12967 var srvFsRow=document.createElement("div");srvFsRow.style.cssText="display:flex;justify-content:center;gap:18px"; 12968 [["happy","\uD83D\uDE0A",bkT("label_done_good")],["neutral","\uD83D\uDE10",bkT("label_done_meh")],["sad","\uD83D\uDE1E",bkT("label_done_bad")]].forEach(function(item){ 12969 var b=document.createElement("button");b.type="button";b.style.cssText="background:none;border:none;font-size:30px;cursor:pointer;padding:6px 8px;border-radius:10px;transition:transform .2s,background .2s;line-height:1"; 12970 b.title=item[2];b.textContent=item[1]; 12971 b.addEventListener("click",function(){ 12972 self._bkLogEvent&&self._bkLogEvent("post_booking_survey",{rating:item[0]}); 12973 srvFsRow.innerHTML='<div style="font-size:13px;color:#047857;font-weight:600;padding:8px 0">'+escapeHtml(bkT("label_done_thanks"))+'</div>'; 12974 }); 12975 b.addEventListener("mouseenter",function(){b.style.transform="scale(1.2)";b.style.background="#e2e8f0";}); 12976 b.addEventListener("mouseleave",function(){b.style.transform="";b.style.background="";}); 12977 srvFsRow.appendChild(b); 12978 }); 12979 srvFs.appendChild(srvFsRow);dn.appendChild(srvFs); 12980 main.appendChild(dn); 12981 break; 12982 } 12983 case "error":{ 12984 body.style.gridTemplateColumns="1fr";side.style.display="none"; 12985 var isRec=self.bkErrReturn!==null; 12986 var fr=self._bkFriendlyError(self.bkErrCode,self.bkErrMsg); 12987 var helpline=bkHelplineNumber(self.opts); 12988 var er=document.createElement("div");er.className="caf-bk-fs-center"; 12989 var box=document.createElement("div");box.className="caf-bk-errbox"; 12990 box.innerHTML= 12991 '<div class="caf-bk-errbox-ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/></svg></div>' 12992 +'<h3 class="caf-bk-errbox-h">'+escapeHtml(fr.title)+'</h3>' 12993 +'<p class="caf-bk-errbox-msg">'+escapeHtml(fr.msg)+'</p>'; 12994 var actsEl=document.createElement("div");actsEl.className="caf-bk-errbox-actions"; 12995 /* Riprova: ricarica lo step relativo se possibile, altrimenti restart */ 12996 var retryBtn=document.createElement("button");retryBtn.type="button";retryBtn.className="caf-bk-errbox-btn primary"; 12997 retryBtn.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M3 12a9 9 0 0 1 14.5-7.1L21 8M21 3v5h-5M21 12a9 9 0 0 1-14.5 7.1L3 16M3 21v-5h5"/></svg> '+escapeHtml(bkT("btn_retry")); 12998 retryBtn.addEventListener("click",function(){ 12999 if(isRec)self._bkBack(); else self._bkStart(); 13000 }); 13001 actsEl.appendChild(retryBtn); 13002 /* Indietro */ 13003 if(isRec){ 13004 var bb=document.createElement("button");bb.type="button";
13004bb.className="caf-bk-errbox-btn ghost"; 13005 bb.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><polyline points="15 18 9 12 15 6"/></svg> '+escapeHtml(bkT("btn_change")); 13006 bb.addEventListener("click",function(){self._bkBack();}); 13007 actsEl.appendChild(bb); 13008 } 13009 /* Numero verde fallback (#59) */ 13010 if(helpline){ 13011 var hl=document.createElement("a");hl.className="caf-bk-errbox-btn helpline"; 13012 hl.href="tel:"+helpline.replace(/[^0-9+]/g,""); 13013 hl.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg> '+escapeHtml(bkT("btn_call"))+' '+escapeHtml(helpline); 13014 hl.style.textDecoration="none"; 13015 hl.addEventListener("click",function(){self._bkLogEvent&&self._bkLogEvent("helpline_clicked",{from:"error",code:self.bkErrCode});}); 13016 actsEl.appendChild(hl); 13017 } 13018 /* Ricomincia da capo */ 13019 var rs=document.createElement("button");rs.type="button";rs.className="caf-bk-errbox-btn ghost"; 13020 rs.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> '+escapeHtml(bkT("btn_restart")); 13021 rs.addEventListener("click",function(){self._bkReset();self._bkStart();}); 13022 actsEl.appendChild(rs); 13023 box.appendChild(actsEl); 13024 if(self.bkErrCode){var codeEl=document.createElement("div");codeEl.className="caf-bk-errbox-code";codeEl.textContent=bkT("label_error_code",self.bkErrCode);box.appendChild(codeEl);} 13025 er.appendChild(box);main.appendChild(er); 13026 /* WCAG 4.1.3 (#C3): errore (incluso esito incerto) annunciato alle AT */ 13027 self._bkFsAnnounce(bkT("label_error")+": "+fr.title+(fr.msg?". "+fr.msg:"")); 13028 break; 13029 }} 13030 13031 body.appendChild(main); 13032 el.appendChild(body); 13033 13034 /* ââ MOBILE SUMMARY (bottom sheet, <820px) ââ */ 13035 if(self.bkStep!=="done"&&self.bkStep!=="error"&&self.bkStep!=="submitting"){ 13036 var selectionsCount=0; 13037 if(self.bkSelProv)selectionsCount++; 13038 if(self.bkSelServ)selectionsCount++; 13039 if(self.bkSelSede)selectionsCount++; 13040 if(self.bkSelSlot)selectionsCount++; 13041 if(selectionsCount>0){ 13042 var mbsum=document.createElement("div");mbsum.className="caf-bk-fs-mbsum"; 13043 /* #13 â grab handle visivo + drag-to-toggle */ 13044 var grab=document.createElement("div");grab.className="caf-bk-fs-mbsum-grab";grab.setAttribute("aria-hidden","true"); 13045 mbsum.appendChild(grab); 13046 var summary=[]; 13047 if(self.bkSelProv)summary.push("<b>"+escapeHtml(self.bkSelProv.descrizione)+"</b>"); 13048 if(self.bkSelServ)summary.push("<b>"+escapeHtml(self.bkSelServ.desonline||self.bkSelServ.descrizione)+"</b>"); 13049 if(self.bkSelSede)summary.push(escapeHtml(self.bkSelSede.descrizione)); 13050 if(self.bkSelSlot)summary.push(escapeHtml(fmtDate(self.bkSelSlot.data)+" "+fmtTime(self.bkSelSlot.ora_inizio))); 13051 var row=document.createElement("div");row.className="caf-bk-fs-mbsum-row"; 13052 var cnt=document.createElement("div");cnt.className="caf-bk-fs-mbsum-count";cnt.textContent=selectionsCount;row.appendChild(cnt); 13053 var txt=document.createElement("div");txt.className="caf-bk-fs-mbsum-txt";txt.innerHTML=summary.join(" · ");row.appendChild(txt); 13054 var tog=document.createElement("button");tog.type="button";tog.className="caf-bk-fs-mbsum-toggle"; 13055 tog.innerHTML='Vedi <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" style="width:12px;height:12px"><polyline points="6 9 12 15 18 9"/></svg>'; 13056 tog.addEventListener("click",function(){ 13057 mbsum.classList.toggle("expanded"); 13058 tog.innerHTML=mbsum.classList.contains("expanded") 13059 ?'Chiudi <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" style="width:12px;height:12px"><polyline points="18 15 12 9 6 15"/></svg>' 13060 :'Vedi <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" style="width:12px;height:12px"><polyline points="6 9 12 15 18 9"/></svg>'; 13061 }); 13062 row.appendChild(tog);mbsum.appendChild(row); 13063 /* expanded items */ 13064 var exp=document.createElement("div");
13064exp.className="caf-bk-fs-mbsum-exp"; 13065 function addItem(lbl,val){if(!val)return;var it=document.createElement("div");it.className="caf-bk-fs-mbsum-item";var l=document.createElement("span");l.className="lbl";l.textContent=lbl;it.appendChild(l);var v=document.createElement("span");v.className="val";v.textContent=val;it.appendChild(v);exp.appendChild(it);} 13066 if(self.bkSelProv)addItem("Provincia",self.bkSelProv.descrizione); 13067 if(self.bkSelServ)addItem("Servizio",self.bkSelServ.desonline||self.bkSelServ.descrizione); 13068 if(self.bkSelSede)addItem("Sede",self.bkSelSede.descrizione+" â "+(self.bkSelSede.indirizzo||"")+", "+(self.bkSelSede.citta||"")); 13069 if(self.bkSelSlot)addItem("Orario",fmtDate(self.bkSelSlot.data)+" · "+fmtTime(self.bkSelSlot.ora_inizio)); 13070 mbsum.appendChild(exp); 13071 13072 /* #13 â drag-to-expand: pointer/touch sul handle. Threshold 30px. */ 13073 (function(){ 13074 var startY=null,curY=null,wasExpanded=false; 13075 var onStart=function(ev){ 13076 startY=(ev.touches?ev.touches[0].clientY:ev.clientY); 13077 wasExpanded=mbsum.classList.contains("expanded"); 13078 mbsum.classList.add("dragging"); 13079 }; 13080 var onMove=function(ev){ 13081 if(startY===null)return; 13082 curY=(ev.touches?ev.touches[0].clientY:ev.clientY); 13083 var dy=curY-startY; 13084 /* swipe UP (dy<0) e non espanso â preview espansione; swipe DOWN 13085 * (dy>0) e espanso â preview chiusura. */ 13086 if(!wasExpanded&&dy<-10)mbsum.classList.add("expanded"); 13087 else if(wasExpanded&&dy>10)mbsum.classList.remove("expanded"); 13088 }; 13089 var onEnd=function(){ 13090 startY=null;curY=null;mbsum.classList.remove("dragging"); 13091 /* Sync icona toggle button */ 13092 var nowExp=mbsum.classList.contains("expanded"); 13093 tog.innerHTML=nowExp 13094 ?'Chiudi <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" style="width:12px;height:12px"><polyline points="18 15 12 9 6 15"/></svg>' 13095 :'Vedi <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" style="width:12px;height:12px"><polyline points="6 9 12 15 18 9"/></svg>'; 13096 }; 13097 grab.addEventListener("touchstart",onStart,{passive:true}); 13098 grab.addEventListener("touchmove",onMove,{passive:true}); 13099 grab.addEventListener("touchend",onEnd); 13100 /* BUGFIX: mousemove/mouseup su window venivano aggiunti a OGNI 13101 * render della summary e mai rimossi (accumulo). Ora vivono solo 13102 * per la durata del drag. */ 13103 var _dragUp=function(ev){ 13104 window.removeEventListener("mousemove",onMove); 13105 window.removeEventListener("mouseup",_dragUp); 13106 onEnd(ev); 13107 }; 13108 grab.addEventListener("mousedown",function(ev){ 13109 onStart(ev); 13110 window.addEventListener("mousemove",onMove); 13111 window.addEventListener("mouseup",_dragUp); 13112 }); 13113 /* tap sull'handle = toggle */ 13114 grab.addEventListener("click",function(){tog.click();}); 13115 })(); 13116 13117 el.appendChild(mbsum); 13118 } 13119 } 13120 13121 /* ââ FOOTER ââ (hidden on done/error) */ 13122 if(self.bkStep!=="done"&&self.bkStep!=="error"&&self.bkStep!=="submitting"){ 13123 var foot=document.createElement("div");foot.className="caf-bk-fs-foot"; 13124 var fl=document.createElement("div");fl.className="caf-bk-fs-foot-l"; 13125 if(self.bkStep!=="selectProvincia"&&self.bkStep!=="loading"){ 13126 var back=document.createElement("button");back.type="button";back.className="caf-bk-fs-btn caf-bk-fs-btn-ghost"; 13127 back.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><polyline points="15 18 9 12 15 6"/></svg> '+escapeHtml(bkT("btn_back")); 13128 back.addEventListener("click",function(){self._bkBack();}); 13129 fl.appendChild(back); 13130 } 13131 var cancel=document.createElement("button");cancel.type="button";
13131cancel.className="caf-bk-fs-btn caf-bk-fs-btn-danger";cancel.textContent=bkT("btn_cancel"); 13132 cancel.addEventListener("click",function(){self._bkReset();}); 13133 fl.appendChild(cancel); 13134 foot.appendChild(fl); 13135 13136 var fr=document.createElement("div");fr.className="caf-bk-fs-foot-r"; 13137 /* Pulisco la ref al bottone "next" tra render diversi per evitare di 13138 * mutare un nodo DOM staccato in step !== collectData. */ 13139 if(self.bkStep!=="collectData"){self._bkCfFsNextBtn=null;} 13140 if(self.bkStep!=="confirm"){self._bkFsFootConfirmBtn=null;} 13141 if(self.bkStep==="collectData"){ 13142 var next=document.createElement("button");next.type="button";next.className="caf-bk-fs-btn caf-bk-fs-btn-pri"; 13143 next.innerHTML=escapeHtml(bkT("btn_next"))+' <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>'; 13144 next.addEventListener("click",function(){ 13145 if(!validateCodiceFiscale(self.bkClient.codfis||"").ok){ 13146 self.bkFormErr=bkT("err_cf_proceed"); 13147 self._bkRender();return; 13148 } 13149 if(!self.bkPrivacyOk){self._bkShowToast&&self._bkShowToast({type:"error",msg:bkT("consent_required")});return;} 13150 self._bkConfirm(); 13151 }); 13152 fr.appendChild(next); 13153 /* aggancia al gate CF rendered dal collectData */ 13154 self._bkCfFsNextBtn=next; 13155 if(typeof self._bkCfFsApplyGate==="function")self._bkCfFsApplyGate(); 13156 }else if(self.bkStep==="confirm"){ 13157 var confirmBtn=document.createElement("button");confirmBtn.type="button";confirmBtn.className="caf-bk-fs-btn caf-bk-fs-btn-pri"; 13158 confirmBtn.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M20 6L9 17l-5-5"/></svg> '+escapeHtml(bkT("btn_confirm")); 13159 confirmBtn.addEventListener("click",function(){if(!self.bkPrivacyOk){self._bkShowToast&&self._bkShowToast({type:"error",msg:bkT("consent_required")});return;}self._bkSubmit();}); 13160 fr.appendChild(confirmBtn); 13161 /* PRIVACY â questo CTA del footer condivide il gate di consenso con il 13162 * bottone "mega" del corpo: lo registro e applico subito lo stato. */ 13163 self._bkFsFootConfirmBtn=confirmBtn; 13164 if(typeof self._bkFsConsentApplyGate==="function")self._bkFsConsentApplyGate(!!self.bkPrivacyOk); 13165 } 13166 foot.appendChild(fr); 13167 el.appendChild(foot); 13168 } 13169 13170 /* Voice mode: announce booking step via TTS */ 13171 if(this._voiceMode){ 13172 var _voiceSteps=["selectProvincia","selectServizio","selectSede","selectSlot","collectData","confirm","done","error"]; 13173 if(_voiceSteps.indexOf(this.bkStep)!==-1){this._bkVoiceAnnounce();} 13174 } 13175 }; 13176 13177 /* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 13178 ââ VOICE BOOKING â spoken interaction for each booking step 13179 ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 13180 13181 /** Announce the current booking step options via TTS */ 13182 CafChat.prototype._bkVoiceAnnounce = function () { 13183 var self = this; 13184 var announceId = self.bkStep + "_" + (self._bkVoiceField || ""); 13185 if (self._bkVoiceAnnounced === announceId) return; 13186 self._bkVoiceAnnounced = announceId; 13187 13188 switch (self.bkStep) { 13189 case "selectProvincia": { 13190 var names = self.bkProvince.map(function(p) { return p.descrizione; }).join(", "); 13191 self._queueTTSChunk("Seleziona la provincia. Le province disponibili sono: " + names + ". Quale preferisci?"); 13192 break; 13193 } 13194 case "selectServizio": { 13195 var srvList = self.bkServizi.slice(0, 6); 13196 var srvNames = srvList.map(function(s, i) { return (i+1) + ", " + (s.desonline || s.descrizione); }).join(". "); 13197 var extra = self.bkServizi.length > 6 ? ". E altri " + (self.bkServizi.length - 6) + " servizi" : ""; 13198 self._queueTTSChunk("Scegli il servizio. I servizi disponibili sono: " + srvNames + extra + ". Quale vuoi prenotare? Puoi dire il nome o il numero."); 13199 break; 13200 } 13201 case "selectSede": { 13202 var sedeNames = self.bkSedi.map(function(s, i) { return (i+1) + ", " + s.descrizione + " a " + s.citta; }).join(". "); 13203 self._queueTTSChunk("Scegli la sede. Le sedi disponibili sono: " + sedeNames + ". Quale preferisci?"); 13204 break; 13205 } 13206 case "selectSlot": { 13207 var slotDescs = self.bkSpazi.slice(0, 5).map(function(s, i) { 13208 return (i+1) + ", " + fmtDate(s.data) + " alle " + fmtTime(s.ora_inizio); 13209 }).join(". "); 13210 var slotExtra = self.bkSpazi.length > 5 ? ". Ci sono altri " + (self.bkSpazi.length - 5) + " orari disponibili." : ""; 13211 self._queueTTSChunk("Scegli data e ora. Gli appuntamenti disponibili sono: " + slotDescs + slotExtra + ". Quale preferisci? Puoi dire il numero, il giorno, o l'orario."); 13212 break; 13213 } 13214 case "collectData": { 13215 self._bkVoiceField = self._bkVoiceField || "nome"; 13216 if (self._bkVoiceField === "nome") { 13217 self._queueTTSChunk("Quasi fatto! Ho bisogno dei tuoi dati per completare la prenotazione. Dimmi il tuo nome."); 13218 } 13219 break; 13220 } 13221 case "confirm": { 13222 var sp = self.bkSelSlot || {}; 13223 var sd = self.bkSelSede || {}; 13224 var sv = self.bkSelServ || {}; 13225 var cd = self.bkClient; 13226 self._queueTTSChunk( 13227 "Ecco il riepilogo della tua prenotazione. " + 13228 "Servizio: " + (sv.desonline || sv.descrizione) + ". " + 13229 "Sede: " + sd.descrizione + " a " + sd.citta + ". " + 13230 "Data: " + fmtDate(sp.data) + " alle " + fmtTime(sp.ora_inizio) + ". " + 13231 "A nome di " + cd.nome + " " + cd.cognome + ", " + 13232 "email " + cd.email + ", " + 13233 "cellulare " + cd.cellulare.split("").join(" ") + ". " + 13234 "Vuoi confermare? Rispondi s\u00ec, no, oppure modifica se vuoi cambiare qualcosa." 13235 ); 13236 break; 13237 } 13238 case "done": { 13239 var doneMsg = "Prenotazione confermata con successo!"; 13240 if (self.bkId) doneMsg += " Il numero di prenotazione \u00e8 " + self.bkId + "."; 13241 self._queueTTSChunk(doneMsg); 13242 break; 13243 } 13244 case "error": { 13245 self._queueTTSChunk((self.bkErrMsg || "Si \u00e8 verificato un errore.") + " Vuoi riprovare? Dì s\u00ec o annulla."); 13246 break; 13247 } 13248 } 13249 }; 13250 13251 /** Handle spoken text during an active booking step */ 13252 CafChat.prototype._bkHandleVoiceInput = function (text) { 13253 var self = this; 13254 var t = text.toLowerCase().trim(); 13255 13256 /* If a fetch is in progress, inform user and ignore input */ 13257 if (self._bkBusy) { 13258 self._queueTTSChunk("Un momento, sto ancora caricando. Riprova tra poco."); 13259 return; 13260 } 13261 13262 /* Global voice commands: cancel / back / repeat */ 13263 if (/\b(annulla|cancella|esci dalla prenotazione|stop prenotazione)\b/i.test(t)) { 13264 self._bkReset(); 13265 self._queueTTSChunk("Va bene, ho annullato la prenotazione."); 13266 return; 13267 } 13268 if (/\b(indietro|torna indietro|passo precedente)\b/i.test(t)) { 13269 self._bkBack(); 13270 return; 13271 } 13272 /* "ripeti" / "non ho capito" â re-announce current step */ 13273 if (/\b(ripeti|ripetere|non ho capito|puoi ripetere|cosa hai detto|ripeterm[ie]|di nuovo)\b/i.test(t)) { 13274 self._bkVoiceAnnounced = null; 13275 self._bkVoiceAnnounce(); 13276 return; 13277 } 13278 13279 switch (self.bkStep) { 13280 case "selectProvincia": { 13281 var matched = matchProvince(t, self.bkProvince); 13282 if (matched) { 13283 self._queueTTSChunk("Provincia di " + matched.descrizione + ". Cerco i servizi disponibili."); 13284 self._bkPickProv(matched); 13285 } else { 13286 var num = parseVoiceNumber(t); 13287 if (num !== null && num >= 1 && num <= self.bkProvince.length) { 13288 var prov = self.bkProvince[num - 1]; 13289 self._queueTTSChunk("Provincia di " + prov.descrizione + ". Cerco i servizi disponibili."); 13290 self._bkPickProv(prov); 13291 } else { 13292 self._bkVoiceAnnounced = null; /* allow re-announce */ 13293 var pNames = self.bkProvince.map(function(p) { return p.descrizione; }).join(", "); 13294 self._queueTTSChunk("Non ho capito. Le province disponibili sono: " + pNames + ". Quale preferisci?"); 13295 } 13296 } 13297 break; 13298 } 13299 case "selectServizio": { 13300 var bestServ = null, bestScore = 0;
13301 self.bkServizi.forEach(function(s) { 13302 var score = matchService(s, t); 13303 if (score > bestScore) { bestScore = score; bestServ = s; } 13304 }); 13305 if (bestServ && bestScore >= 40) { 13306 self._queueTTSChunk("Servizio selezionato: " + (bestServ.desonline || bestServ.descrizione) + ". Cerco le sedi."); 13307 self._bkPickServ(bestServ); 13308 } else { 13309 var sNum = parseVoiceNumber(t); 13310 if (sNum !== null && sNum >= 1 && sNum <= self.bkServizi.length) { 13311 var srv = self.bkServizi[sNum - 1]; 13312 self._queueTTSChunk("Servizio selezionato: " + (srv.desonline || srv.descrizione) + ". Cerco le sedi."); 13313 self._bkPickServ(srv); 13314 } else { 13315 self._bkVoiceAnnounced = null; 13316 var sNames = self.bkServizi.slice(0, 5).map(function(s, i) { return (i+1) + ", " + (s.desonline || s.descrizione); }).join(". "); 13317 self._queueTTSChunk("Non ho trovato il servizio. I servizi sono: " + sNames + ". Puoi dire il nome o il numero."); 13318 } 13319 } 13320 break; 13321 } 13322 case "selectSede": { 13323 var bestSede = matchSedeByVoice(t, self.bkSedi); 13324 if (bestSede) { 13325 self._queueTTSChunk("Sede selezionata: " + bestSede.descrizione + ". Cerco gli appuntamenti."); 13326 self._bkPickSede(bestSede); 13327 } else { 13328 var sdNum = parseVoiceNumber(t); 13329 if (sdNum !== null && sdNum >= 1 && sdNum <= self.bkSedi.length) { 13330 var sede = self.bkSedi[sdNum - 1]; 13331 self._queueTTSChunk("Sede selezionata: " + sede.descrizione + ". Cerco gli appuntamenti."); 13332 self._bkPickSede(sede); 13333 } else { 13334 self._bkVoiceAnnounced = null; 13335 var sdNames = self.bkSedi.map(function(s, i) { return (i+1) + ", " + s.descrizione; }).join(". "); 13336 self._queueTTSChunk("Non ho individuato la sede. Le sedi sono: " + sdNames + ". Puoi dire il nome o il numero."); 13337 } 13338 } 13339 break; 13340 } 13341 case "selectSlot": { 13342 /* "altri orari" / "carica altri" / "vedi altri" â load more */ 13343 if (/\b(altri|altre|carica|vedi|mostra|prossim[ie])\s*(orar[ie]|disponibilit|appuntament|dat[ae])?\b/i.test(t) && self.bkNextG) { 13344 self._queueTTSChunk("Cerco altri orari disponibili."); 13345 self._bkLoadMore(); 13346 break; 13347 } 13348 /* try natural language match first (domani, lunedì, alle 10, etc.) */ 13349 var slotMatch = matchSlotByVoice(t, self.bkSpazi); 13350 if (slotMatch) { 13351 self._queueTTSChunk("Appuntamento selezionato: " + fmtDate(slotMatch.data) + " alle " + fmtTime(slotMatch.ora_inizio) + "."); 13352 self._bkPickSlot(slotMatch); 13353 } else { 13354 /* fallback: try ordinal/number index */ 13355 var slNum = parseVoiceNumber(t); 13356 if (slNum !== null && slNum >= 1 && slNum <= self.bkSpazi.length) { 13357 var slot = self.bkSpazi[slNum - 1]; 13358 self._queueTTSChunk("Appuntamento selezionato: " + fmtDate(slot.data) + " alle " + fmtTime(slot.ora_inizio) + "."); 13359 self._bkPickSlot(slot); 13360 } else { 13361 self._bkVoiceAnnounced = null; 13362 var slDescs = self.bkSpazi.slice(0, 4).map(function(s, i) { 13363 return (i+1) + ", " + fmtDate(s.data) + " alle " + fmtTime(s.ora_inizio); 13364 }).join(". "); 13365 var helpMsg = "Non ho capito. Gli appuntamenti sono: " + slDescs + ". Puoi dire il numero, il giorno come domani o luned\u00ec, oppure l'orario come alle dieci."; 13366 if (self.bkNextG) helpMsg += " Dì 'altri orari' per vederne altri."; 13367 self._queueTTSChunk(helpMsg); 13368 } 13369 } 13370 break; 13371 } 13372 case "collectData": { 13373 self._bkHandleVoiceCollectData(t); 13374 break; 13375 } 13376 case "confirm": { 13377 if (/\b(s[i\u00ec]|conferm[ao]?|ok|va bene|perfetto|certo|procedi|invia)\b/i.test(t)) { 13378 self._queueTTSChunk("Invio la prenotazione."); 13379 self._bkSubmit(); 13380 } else if (/\b(no|modific|cambi|correggi|sbagliato)\b/i.test(t)) { 13381 /* check if user wants to edit a specific field */ 13382 if (/\b(nome|cognome|codice|fiscale|email|cellulare|telefono|numero)\b/i.test(t)) { 13383 /* go back to collectData and set the right field */ 13384 self.bkStep = "collectData"; 13385 if (/\bnome\b/i.test(t) && !/\bcognome\b/i.test(t)) self._bkVoiceField = "nome"; 13386 else if (/\bcognome\b/i.test(t)) self._bkVoiceField = "cognome"; 13387 else if (/\b(codice|fiscale)\b/i.test(t)) self._bkVoiceField = "codfis"; 13388 else if (/\bemail\b/i.test(t)) self._bkVoiceField = "email"; 13389 else if (/\b(cellulare|telefono|numero)\b/i.test(t)) self._bkVoiceField = "cellulare"; 13390 else self._bkVoiceField = "nome"; 13391 self._bkVoiceAnnounced = null; 13392 self._bkRender(); 13393 self._queueTTSChunk("Ok, modifichiamo. Dimmi il nuovo valore."); 13394 } else { 13395 self._bkBack(); 13396 self._queueTTSChunk("Torno alla modifica dei dati."); 13397 } 13398 } else { 13399 self._bkVoiceAnnounced = null; 13400 self._queueTTSChunk("Vuoi confermare la prenotazione? Rispondi s\u00ec, no, oppure dì cosa vuoi modificare."); 13401 } 13402 break; 13403 } 13404 case "error": { 13405 if (/\b(s[i\u00ec]|riprov|ricominc|riprova|ok|certo|va bene)\b/i.test(t)) { 13406 self._queueTTSChunk("Va bene, riprovo."); 13407 if (self.bkErrReturn) self._bkBack(); 13408 else self._bkStart(); 13409 } else if (/\b(no|chiudi|basta|esci|annull)\b/i.test(t)) { 13410 self._bkReset(); 13411 self._queueTTSChunk("Ho chiuso la prenotazione."); 13412 } else { 13413 self._bkVoiceAnnounced = null; 13414 self._queueTTSChunk("Vuoi riprovare? Rispondi s\u00ec o no."); 13415 } 13416 break; 13417 } 13418 } 13419 }; 13420 13421 /** Handle voice input for collectData step â one field at a time */ 13422 CafChat.prototype._bkHandleVoiceCollectData = function (text) { 13423 var self = this; 13424 var field = self._bkVoiceField || "nome"; 13425 var t = text.trim(); 13426 13427 /* helper: clean name input â strip filler words STT may prepe
13427nd */ 13428 function cleanName(raw) { 13429 return capitalizeFirst( 13430 raw.replace(/^(mi chiamo|il mio nome è|sono|il mio cognome è|di cognome)\s+/i, "") 13431 .replace(/\s+/g, " ").trim() 13432 ); 13433 } 13434 13435 /* helper: confirm + move to next field */ 13436 function nextField(currentVal, nextF, nextAnnounce, ttsMsg) { 13437 self._bkVoiceField = nextF; 13438 self._bkVoiceAnnounced = nextAnnounce; 13439 self._queueTTSChunk(currentVal + ". " + ttsMsg); 13440 self._bkRender(); 13441 } 13442 13443 /* helper: ask user to correct current field */ 13444 function retryField(ttsMsg) { 13445 self._bkVoiceAnnounced = "collectData_" + field + "_retry"; 13446 self._queueTTSChunk(ttsMsg); 13447 } 13448 13449 /* "salta" / "non lo so" â skip optional-ish fields or ask again */ 13450 var wantSkip = /\b(salta|saltare|non (lo )?so|passo|dopo)\b/i.test(t); 13451 13452 switch (field) { 13453 case "nome": { 13454 var nome = cleanName(t); 13455 if (nome.length < 2) { retryField("Non ho capito il nome. Come ti chiami?"); break; } 13456 self.bkClient.nome = nome; 13457 nextField(nome, "cognome", "collectData_cognome", "Qual \u00e8 il tuo cognome?"); 13458 break; 13459 } 13460 case "cognome": { 13461 var cognome = cleanName(t); 13462 if (cognome.length < 2) { retryField("Non ho capito il cognome. Qual \u00e8 il tuo cognome?"); break; } 13463 self.bkClient.cognome = cognome; 13464 nextField(cognome, "codfis", "collectData_codfis", "Dimmi il tuo codice fiscale. Puoi dettarlo lettera per lettera."); 13465 break; 13466 } 13467 case "codfis": { 13468 var cf = t.replace(/\s+/g, "").toUpperCase(); 13469 if (cf.length === 16) { 13470 self.bkClient.codfis = cf; 13471 nextField(cf, "email", "collectData_email", "Qual \u00e8 il tuo indirizzo email? Di' chiocciola per la at e punto per il punto."); 13472 } else if (cf.length > 10 && cf.length < 16) { 13473 /* close but not quite â likely STT error */ 13474 retryField("Ho capito " + cf.length + " caratteri su 16. Il codice fiscale \u00e8 " + cf + ". Prova a ripeterlo pi\u00f9 lentamente, lettera per lettera."); 13475 } else { 13476 retryField("Il codice fiscale deve essere di 16 caratteri. Dettalo lettera per lettera, ad esempio: A, B, C, D..."); 13477 } 13478 break; 13479 } 13480 case "email": { 13481 var email = voiceToEmail(t); 13482 /* fix common STT artifacts in emails */ 13483 email = email.replace(/\.\./g, ".").replace(/@+/g, "@"); 13484 if (email.indexOf("@") !== -1 && email.indexOf(".") !== -1 && email.length >= 6) { 13485 self.bkClient.email = email; 13486 nextField(email, "cellulare", "collectData_cellulare", "Qual \u00e8 il tuo numero di cellulare?"); 13487 } else { 13488 retryField("Non ho capito l'email. Dilla lentamente, ad esempio: mario punto rossi chiocciola gmail punto com."); 13489 } 13490 break; 13491 } 13492 case "cellulare": { 13493 var phone = voiceToDigits(t); 13494 if (phone.length >= 9 && phone.length <= 15) { 13495 self.bkClient.cellulare = phone; 13496 /* read back the number for confirmation, then go straight to confirm */ 13497 var phoneSpaced = phone.split("").join(" "); 13498 self._bkVoiceField = null; 13499 self.bkFormErr = null; 13500 self.bkStep = "confirm"; 13501 self._bkVoiceAnnounced = null; 13502 self._queueTTSChunk(phoneSpaced + ". Perfetto, ho tutti i dati."); 13503 self._bkRender(); 13504 } else if (phone.length > 0 && phone.length < 9) { 13505 retryField("Ho capito solo " + phone.length + " cifre: " + phone.split("").join(" ") + ". Il numero completo deve avere almeno 9 cifre. Ripeti per favore."); 13506 } else { 13507 retryField("Non ho capito il numero. Dillo cifra per cifra, ad esempio: tre tre otto, uno due tre, quattro cinque sei sette."); 13508 } 13509 break; 13510 } 13511 } 13512 }; 13513 13514 /* ââ send message âââââââââââââââââââââââââââââââââââââââââââ */ 13515 CafChat.prototype._send = async function (text) { 13516 var trimmed = (text || "").trim(); 13517 if (!trimmed || this.isLoading) return; 13518 /* block chat while booking active (but allow input during askLocation) */
13519 if (this.bkStep !== "idle" && this.bkStep !== "done" && this.bkStep !== "askLocation") { 13520 /* In voice mode, route spoken text to voice booking handler */ 13521 if (this._voiceMode) { 13522 var vMsg = {id:quickId(),role:"user",content:trimmed,timestamp:Date.now()}; 13523 this.messages.push(vMsg); 13524 this._renderVoiceTranscript(); 13525 this._bkHandleVoiceInput(trimmed); 13526 } 13527 return; 13528 } 13529 if (this._needsEmail()) return; 13530 /* gate consenso privacy: nessun messaggio parte senza approvazione */ 13531 if (this._needsConsent()) return; 13532 13533 this.inputEl.value = ""; 13534 this._hideError(); 13535 13536 /* ââ booking: user answered location question ââ */ 13537 if (this.bkStep === "askLocation") { 13538 var self3 = this; 13539 self3.isLoading = true; 13540 self3._updateSendBtn(); 13541 var locMsg = {id:quickId(),role:"user",content:trimmed,timestamp:Date.now()}; 13542 self3.messages.push(locMsg); 13543 self3._renderMessages(); 13544 if (self3._voiceMode) self3._queueTTSChunk("Un momento, cerco la provincia."); 13545 13546 /* fetch province: TUTTE le regioni se national, ALTRIMENTI solo la regione 13547 * ZONA preselezionata (vedi _bkRevalidateAllProvince). Su deploy ZONA il 13548 * match/picker resta quindi coerente con la regione effettivamente servita. */ 13549 var allDataAL = {regioni:[],items:[]}; 13550 try { allDataAL = await self3._bkFetchAllProvince(); } 13551 catch(e){ self3._bkLastErr = e; } 13552 var allProvinceAL = allDataAL.items.map(function(x){return x.provincia;}); 13553 self3.isLoading = false; 13554 13555 if (!allProvinceAL.length) { 13556 var errM = {id:quickId(),role:"assistant",content: 13557 "Mi dispiace, il servizio di prenotazione non \u00e8 disponibile al momento. Riprova pi\u00f9 tardi.", 13558 timestamp:Date.now()}; 13559 self3.messages.push(errM); 13560 self3.bkStep = "idle"; self3.bkHint = ""; 13561 self3._renderMessages(); self3._updateSendBtn(); 13562 if (self3._voiceMode) self3._queueTTSChunk(errM.content); 13563 /* Popup diagnostico con codice + motivo reale del Planner */ 13564 self3._showPlannerDiagPopup(); 13565 return; 13566 } 13567 13568 var matchedProv = matchProvince(trimmed, allProvinceAL); 13569 /* lista compatta delle regioni come hint testuale */ 13570 var regNamesAL = allDataAL.regioni.map(function(r){return r.descrizione;}).join(", "); 13571 13572 if (matchedProv && self3._bkApplyMatchedProvincia(allDataAL, matchedProv)) { 13573 /* matched â proceed */ 13574 var okM = {id:quickId(),role:"assistant",content: 13575 "Perfetto, provincia di **" + matchedProv.descrizione + "** (" + self3.bkSelRegione.descrizione + ")! " 13576 + (self3.bkHint ? "Cerco disponibilit\u00e0 per \"" + self3.bkHint + "\"..." : "Cerco i servizi disponibili..."), 13577 timestamp:Date.now()}; 13578 self3.messages.push(okM); 13579 var bkM = {id:quickId(),role:"assistant",content:"@@BOOKING@@",timestamp:Date.now()}; 13580 self3.messages.push(bkM); 13581 self3._renderMessages(); 13582 if (self3._voiceMode) self3._queueTTSChunk("Perfetto, provincia di " + matchedProv.descrizione + "! Cerco i servizi disponibili."); 13583 self3.bkStep = "loading"; self3._bkBusy = false; 13584 self3._bkRender(); self3._updateSendBtn(); 13585 self3._bkPickProv(matchedProv); 13586 } else { 13587 /* Not matched: show regione picker (UI step) so the user can drill down. 13588 * Singolare/plurale in base a quante regioni sono effettivamente servite 13589 * (su deploy ZONA \u00e8 una sola \u2192 evita "nelle regioni: Veneto"). */ 13590 var oneRegAL = allDataAL.regioni.length === 1; 13591 var noM = {id:quickId(),role:"assistant",content: 13592 "Mi dispiace, non ho trovato **" + escapeHtml(trimmed) + "** tra le province disponibili.\n\n" 13593 + (oneRegAL 13594 ? "Il servizio \u00e8 attivo in **" + regNamesAL + "**.\n\nSeleziona la regione qui sotto." 13595 : "Il servizio \u00e8 attivo nelle regioni: **" + regNamesAL + "**.\n\nSeleziona la regione qui sotto."), 13596 timestamp:Date.now()}; 13597 self3.messages.push(noM); 13598 self3.bkRegioni = allDataAL.regioni; 13599 var bkM2 = {id:quickId(),role:"assistant",content:"@@BOOKING@@",timestamp:Date.now()}; 13600 self3.messages.push(bkM2); 13601 self3.bkStep = "selectRegione"; 13602 self3._renderMessages(); self3._bkRender(); self3._updateSendBtn(); 13603 if (self3._voiceMode) { 13604 var noMClean = noM.content.replace(/\*\*/g, "").replace(/\n+/g, " "); 13605 self3._queueTTSChunk(noMClean); 13606 } 13607 } 13608 return; 13609 } 13610 13611 /* RENDER OTTIMISTICO: mostra SUBITO il messaggio utente + i puntini "sta 13612 * scrivendo" PRIMA delle analisi intent (Claude booking/pwd + check-enabled), 13613 * che giravano in SERIE lasciando la chat ferma fino a ~9-13s sul 1° messaggio. 13614 * um + thinkingMsg sono riusati/rimpiazzati dai rami sottostanti. */ 13615 var um = { id: quickId(), role: "user", content: trimmed, timestamp: Date.now() }; 13616 var thinkingMsg = { id: quickId(), role: "assistant", content: "", timestamp: Date.now() }; 13617 this.messages.push(um, thinkingMsg); 13618 this.isLoading = true; 13619 if (this.messagesEl) this.messagesEl.setAttribute("aria-busy", "true"); 13620 if (this.srAnnouncer) this.srAnnouncer.textContent = bkT("assistant_typing"); 13621 if (this._voiceMode) { this._renderVoiceTranscript(); this._updateVoiceUI(); } 13622 else { this._renderMessages(); } 13623 this._updateSendBtn(); 13624 13625 /* ââ booking intent detection â STRICT trigger ââ 13626 * Il wizard di prenotazione viene avviato SOLO se l'utente scrive 13627 * letteralmente la frase "voglio prenotare" (eventualmente seguita 13628 * da uno dei servizi fiscali gestiti dal CAF: 730, ISEE/DSU, IMU, 13629 * Modello RED, INVCIV, ACCAS/PS, Successioni, Contratti di 13630 * locazione/cedolare secca, Delega Unica/deleghe fiscali, Bonus e 13631 * detrazioni fiscali, Visure catastali, Modello REDDITI/Unico, 13632 * Colf e badanti, qualsiasi altra pratica fiscale/tributaria/ 13633 * dichiarativa, apertura Partita IVA con SAPI). 13634 * Per qualsiasi altro intento o servizio risponde il chatbot. */ 13635 var VOGLIO_PRENOTARE_RE = /\bvoglio\s+prenotare\b/i; 13636 /* Servizi di competenza INCA / Patronato (NON CAF): se l'utente li 13637 * menziona insieme a "voglio prenotare", il wizard NON deve aprirsi â 13638 * risponde il chatbot. Lista: pensioni, NASpI/disoccupazione, assegni
13639 * familiari ANF, permessi di soggiorno, infortuni, malattia 13640 * professionale, maternità /congedi, riscatto laurea, ricongiunzioni 13641 * contributive, Assegno Unico Universale (INPS), vertenze sindacali, 13642 * contratti di lavoro/buste paga, licenziamenti/mobbing/dimissioni, 13643 * consulenza sindacale, domanda di riconoscimento invalidità civile. */ 13644 var NON_CAF_SERVICE_RE = /\b(pension[ei]\b|naspi\b|disoccupazion|assegn[io]\s+familiar|anf\b|assegno\s+unico|permess[oi]\s+di\s+soggiorno|infortun[io]|malatt[ia][ae]?\s+professional|maternit|congedo|congedi|riscatto\s+laurea|ricongiunzion|vertenz|consulenza\s+sindacal|contratt[oi]\s+di\s+lavoro|bust[ae]\s+paga|licenziament|mobbing|dimission|(domanda|riconoscimento)\s+(?:di\s+)?invalidit)/i; 13645 if (this.bkStep === "idle" && VOGLIO_PRENOTARE_RE.test(trimmed) && !NON_CAF_SERVICE_RE.test(trimmed)) { 13646 var self2 = this; 13647 /* Intent (Claude) e check-enabled sono indipendenti â in PARALLELO 13648 * (prima erano due await in serie, ~4s + ~5s). */ 13649 var _bkPar = await Promise.all([ 13650 analyzeBookingIntent(self2.opts.baseUrl, trimmed), 13651 self2._bkCheckEnabled() 13652 ]); 13653 var intent = _bkPar[0]; 13654 var en = _bkPar[1]; 13655 /* client-side regex extractor â always runs, used to fill in missing hints */ 13656 var clientIntent = clientExtractIntent(trimmed); 13657 var svcHint = (intent && intent.service) || clientIntent.service || ""; 13658 13659 if (en) { 13660 /* Pre-fill bkClient con dati anagrafici estratti da Claude (solo se vuoti). */ 13661 if (intent) { 13662 if (intent.nome && !self2.bkClient.nome) self2.bkClient.nome = intent.nome; 13663 if (intent.cognome && !self2.bkClient.cognome) self2.bkClient.cognome = intent.cognome; 13664 if (intent.email && !self2.bkClient.email) self2.bkClient.email = intent.email; 13665 if (intent.cellulare && !self2.bkClient.cellulare) self2.bkClient.cellulare = formatPhoneIT(intent.cellulare); 13666 if (intent.nome || intent.cognome || intent.email || intent.cellulare) { 13667 self2._bkLogEvent && self2._bkLogEvent("intent_personal_data_extracted", { 13668 hasNome: !!intent.nome, hasCognome: !!intent.cognome, hasEmail: !!intent.email, hasCellulare: !!intent.cellulare 13669 }); 13670 } 13671 } 13672 /* um già mostrato (render ottimistico): rimpiazza il placeholder "sta 13673 * scrivendo" col bottone "Apri il wizard". */ 13674 self2.messages = self2.messages.filter(function (m) { return m.id !== thinkingMsg.id; }); 13675 var launchM = { id: quickId(), role: "assistant", content: "@@BOOKING_LAUNCH@@" + svcHint, timestamp: Date.now() }; 13676 self2.messages.push(launchM); 13677 self2.isLoading = false; 13678 if (self2.messagesEl) self2.messagesEl.setAttribute("aria-busy", "false"); 13679 /* WCAG 4.1.3 (#C4): la comparsa del bottone "Apri il wizard" va 13680 * annunciata â l'azzeramento sopprimeva qualunque annuncio. */ 13681 if (self2.srAnnouncer) self2.srAnnouncer.textContent = bkT("sr_booking_launch"); 13682 self2._renderMessages(); 13683 self2._saveMessages(); 13684 self2._updateSendBtn(); 13685 if (self2._voiceMode) { 13686 self2._queueTTSChunk(svcHint 13687 ? "Perfetto! Clicca sul bottone per prenotare " + svcHint + "." 13688 : "Perfetto! Clicca sul bottone per aprire il wizard di prenotazione."); 13689 } 13690 return; 13691 } 13692 } 13693 13694 /* ââ password reset intent detection (Claude + regex fallback) ââ */ 13695 if (this.bkStep === "idle" && this.pwdStep === "idle" 13696 && !(VOGLIO_PRENOTARE_RE.test(trimmed) && !NON_CAF_SERVICE_RE.test(trimmed))) { 13697 var selfPwd = this; 13698 /* Pre-filtro CHEAP: chiamiamo Claude (pwd-intent, ~4s) SOLO se ci sono 13699 * parole-chiave password. Prima girava per OGNI messaggio idle (anche 13700 * "voglio prenotare" \u2192 fall-through, +4s) bloccando la chat; ora i 13701 * messaggi normali saltano l'analisi e vanno dritti alla chat. */ 13702 var maybePwd = PWD_RESET_KEYWORDS.test(trimmed); 13703 var pwdIntent = maybePwd ? await analyzePasswordResetIntent(selfPwd.opts.baseUrl, trimmed) : null; 13704 var isPwdIntent = maybePwd 13705 ? (pwdIntent ? !!pwdIntent.isPasswordReset : (PWD_RESET_KEYWORDS.test(trimmed) && PWD_RESET_CONTEXT.test(trimmed))) 13706 : false;
13707 if (isPwdIntent) { 13708 selfPwd.pwdEmail = (pwdIntent && pwdIntent.email) || extractEmail(trimmed) || selfPwd.userEmail || ""; 13709 selfPwd.pwdStep = "form"; selfPwd.pwdErr = ""; 13710 /* um gi\u00e0 mostrato (render ottimistico): rimuovi il placeholder "sta 13711 * scrivendo", aggiungi intro + form reset. */ 13712 selfPwd.messages = selfPwd.messages.filter(function (m) { return m.id !== thinkingMsg.id; }); 13713 var introPwd = { id: quickId(), role: "assistant", content: 13714 "Nessun problema! Reimpostare la password di **PersonalCAF** \u00e8 semplice. Inserisci la tua email qui sotto e ti invieremo subito il link per il reset.", 13715 timestamp: Date.now() }; 13716 selfPwd.messages.push(introPwd); 13717 var pwM = { id: quickId(), role: "assistant", content: "@@PWDRESET@@", timestamp: Date.now() }; 13718 selfPwd.messages.push(pwM); 13719 selfPwd.isLoading = false; 13720 if (selfPwd.messagesEl) selfPwd.messagesEl.setAttribute("aria-busy", "false"); 13721 if (selfPwd.srAnnouncer) { var _pd = document.createElement("div"); _pd.innerHTML = renderMarkdown(introPwd.content, selfPwd.opts.color); selfPwd.srAnnouncer.textContent = (selfPwd.opts.name || bkT("sr_assistant")) + ": " + (_pd.textContent || ""); } 13722 selfPwd._renderMessages(); selfPwd._saveMessages(); 13723 selfPwd._pwdRender(); 13724 selfPwd._updateSendBtn(); 13725 return; 13726 } 13727 } 13728 13729 var isVoiceRequest = !!this._lastMessageWasVoice; 13730 this._lastMessageWasVoice = false; 13731 13732 /* um + thinkingMsg già pushati e renderizzati (render ottimistico). Riusiamo 13733 * il placeholder come bolla assistant in streaming; um è escluso dalla 13734 * history perché viene già inviato come `message`. */ 13735 var history = this.messages 13736 .filter(function (m) { return m.id !== "welcome" && m.id !== um.id && m.content && m.content.trim().length > 0 && m.content !== "@@BOOKING@@" && m.content !== "@@PWDRESET@@" && m.content.indexOf("@@BOOKING_LAUNCH@@") !== 0; }) 13737 .map(function (m) { return { role: m.role, content: m.content }; }); 13738 13739 var assistantMsg = thinkingMsg; /* isLoading già true; bolla già in DOM */ 13740 this._updateSendBtn(); 13741 13742 var apiUrl = this.opts.baseUrl + "/api/chat"; 13743 var self = this; 13744 var chatController = new AbortController(); 13745 this._chatAbort = chatController; 13746 /* Watchdog di INATTIVITà sullo stream chat: niente timeout fisso (troncherebbe 13747 * risposte lunghe ma sane), ma se non arriva NULLA (header o delta) per 13748 * STREAM_IDLE_MS abortiamo. Riarmato a ogni chunk. Senza questo, uno stallo 13749 * upstream lasciava l'utente sui puntini fino al timeout SERVER (60-120s). */ 13750 var STREAM_IDLE_MS = 25000; 13751 self._chatTimedOut = false; 13752 var _chatIdleTid = null; 13753 var _armChatIdle = function(){ if(_chatIdleTid)clearTimeout(_chatIdleTid); _chatIdleTid=setTimeout(function(){ self._chatTimedOut=true; try{chatController.abort();}catch(_e){} }, STREAM_IDLE_MS); }; 13754 var _clearChatIdle = function(){ if(_chatIdleTid){clearTimeout(_chatIdleTid);_chatIdleTid=null;} }; 13755 _armChatIdle(); 13756 13757 try { 13758 var response = await fetch(apiUrl, { 13759 method: "POST", 13760 headers: { "Content-Type": "application/json" }, 13761 body: JSON.stringify({ message: trimmed, stream: true, history: history, conversationId: self.conversationId, voiceMode: isVoiceRequest }), 13762 signal: chatController.signal, 13763 }); 13764 13765 if (!response.ok) { 13766 var errBody = null; 13767 try { errBody = await response.json(); } catch (e) { /* ok */ } 13768 var errMsg = errBody && errBody.error 13769 ? errBody.error + " (" + response.status + ")" 13770 : "HTTP " + response.status; 13771 throw new Error(errMsg); 13772 } 13773 13774 var contentType = response.headers.get("content-type") || ""; 13775 var fullReply = ""; 13776 var rawBuffer = ""; /* buffer raw byte-stream, pulito poi via parseMantisSentinels */ 13777 var ttsSentUpTo = 0; 13778 13779 if (response.body && !contentType.includes("application/json")) { 13780 var reader = response.body.getReader(); 13781 var decoder = new TextDecoder(); 13782 var frameId = null; 13783 var lastRenderTime = 0; 13784 var minInterval = 80; 13785 13786 var scheduleUpdate = function () { 13787 if (frameId !== null) return; 13788 frameId = requestAnimationFrame(function () { 13789 frameId = null; 13790 var now = performance.now(); 13791 if (now - lastRenderTime < minInterval && fullReply.length > 500) { 13792 frameId = requestAnimationFrame(function () { 13793 frameId = null; 13794 lastRenderTime = performance.now(); 13795 assistantMsg.content = fullReply; 13796 if (self._voiceMode) self._updateVoiceStreamingBubble(fullReply); 13797 else self._updateStreamingBubble(fullReply); 13798 }); 13799 return; 13800 }
13801 lastRenderTime = now; 13802 assistantMsg.content = fullReply; 13803 if (self._voiceMode) self._updateVoiceStreamingBubble(fullReply); 13804 else self._updateStreamingBubble(fullReply); 13805 }); 13806 }; 13807 13808 while (true) { 13809 var result = await reader.read(); 13810 if (result.done) break; 13811 _armChatIdle(); /* attività : riarma il watchdog di inattività */ 13812 rawBuffer += decoder.decode(result.value, { stream: true }); 13813 /* Rimuovi sentinel <<<MANTIS_*>>> e [F1] inline references PRIMA 13814 * di mostrare/inviare a TTS. Robusto contro fragmentation TCP. */ 13815 fullReply = parseMantisSentinels(rawBuffer).clean; 13816 scheduleUpdate(); 13817 13818 /* TTS chunking: split at sentence boundaries */ 13819 if (isVoiceRequest && self._voiceMode) { 13820 var minChars = ttsSentUpTo === 0 ? 12 : 60; 13821 var unsent = fullReply.length - ttsSentUpTo; 13822 if (unsent >= minChars) { 13823 var region = fullReply.slice(ttsSentUpTo); 13824 var bestBreak = -1; 13825 for (var ci = minChars; ci < Math.min(region.length, minChars + 100); ci++) { 13826 var ch = region[ci]; 13827 if ((ch === "." || ch === "!" || ch === "?") && (ci + 1 >= region.length || region[ci + 1] === " " || region[ci + 1] === "\n")) { 13828 bestBreak = ci + 1; break; 13829 } 13830 } 13831 if (bestBreak === -1) { 13832 for (var cj = minChars; cj < Math.min(region.length, minChars + 60); cj++) { 13833 if (region[cj] === "," || region[cj] === "\n") { bestBreak = cj + 1; break; } 13834 } 13835 } 13836 if (bestBreak === -1 && unsent >= minChars + 60) { 13837 for (var ck = minChars; ck < Math.min(region.length, minChars + 60); ck++) { 13838 if (region[ck] === " ") { bestBreak = ck + 1; break; } 13839 } 13840 } 13841 if (bestBreak > 0) { 13842 var chunk = region.slice(0, bestBreak).trim(); 13843 ttsSentUpTo += bestBreak; 13844 if (chunk) self._queueTTSChunk(chunk); 13845 } 13846 } 13847 } 13848 } 13849 if (frameId !== null) cancelAnimationFrame(frameId); 13850 /* Final pass: cleanup definitivo (rimuove eventuali sentinel residui 13851 * e [F<n>] aperti a fine buffer). */ 13852 var finalParsed = parseMantisSentinels(rawBuffer); 13853 fullReply = finalParsed.clean; 13854 /* AI Act art. 50(2): traccia il modello che ha generato la risposta 13855 * (dal sentinel MANTIS_RETRIEVED) per la marcatura data-model e la 13856 * persistenza lato server. */ 13857 try { 13858 if (finalParsed.retrieved && (finalParsed.retrieved.model || finalParsed.retrieved.modelId)) { 13859 assistantMsg.model = String(finalParsed.retrieved.model || finalParsed.retrieved.modelId); 13860 } 13861 } catch (_e) {} 13862 } else { 13863 var data = await response.json(); 13864 if (!data || !data.reply || typeof data.reply !== "string") { 13865 throw new Error("Invalid response"); 13866 } 13867 /* Anche in modalità JSON non-stream, applichiamo il parser per 13868 * sicurezza (defense-in-depth: il server già fa stripReferencesBlock). */ 13869 var _npParsed = parseMantisSentinels(data.reply.trim()); 13870 fullReply = _npParsed.clean; 13871 /* AI Act art. 50(2): modello dal body JSON o dal sentinel. */ 13872 try { 13873 var _npModel = (data.model || data.modelId) || 13874 (_npParsed.retrieved && (_npParsed.retrieved.model || _npParsed.retrieved.modelId)); 13875 if (_npModel) assistantMsg.model = String(_npModel); 13876 } catch (_e) {} 13877 } 13878 13879 assistantMsg.content = fullReply; 13880 13881 if (isVoiceRequest && self._voiceMode && fullReply) { 13882 var remaining = fullReply.slice(ttsSentUpTo).trim(); 13883 if (remaining) self._queueTTSChunk(remaining); 13884 } 13885 } catch (err) { 13886 var isAbort = err instanceof DOMException && err.name === "AbortError"; 13887 if (isAbort) { 13888 if (fullReply && fullReply.trim()) { 13889 assistantMsg.content = fullReply; 13890 } else { 13891 this.messages = this.messages.filter(function (m) { return m.id !== assistantMsg.id; }); 13892 /* Abort da watchdog (stallo upstream) â stop volontario dell'utente: 13893 * mostriamo un errore esplicito invece di sparire in silenzio. */ 13894 if (self._chatTimedOut) this._showError("Il server ha impiegato troppo tempo a rispondere. Riprova tra poco."); 13895 } 13896 } else { 13897 this.messages = this.messages.filter(function (m) { return m.id !== assistantMsg.id; }); 13898 this._showError(err && err.message ? err.message : "Ops! Non riesco a rispondere adesso. Riprova tra poco."); 13899 } 13900 } finally { 13901 _clearChatIdle(); 13902 this.isLoading = false;
13903 if (this.messagesEl) this.messagesEl.setAttribute("aria-busy", "false"); 13904 this._chatAbort = null; 13905 if (!this.isOpen && assistantMsg.content) { 13906 this.unreadCount++; 13907 this._updateBadge(); 13908 } 13909 this._saveMessages(); 13910 this._syncConversation(); 13911 if (this._updateBkCta) this._updateBkCta(); 13912 if (this._voiceMode) { 13913 this._renderVoiceTranscript(); 13914 this._updateVoiceUI(); 13915 if (!this._isSpeaking && !this._isPlayingQueue && !this._isListening) { 13916 this._scheduleListening(200); 13917 } 13918 } else { 13919 this._renderMessages(); 13920 } 13921 if (self.srAnnouncer && assistantMsg && assistantMsg.content) { 13922 var _annDiv = document.createElement("div"); 13923 _annDiv.innerHTML = renderMarkdown(assistantMsg.content, self.opts.color); 13924 self.srAnnouncer.textContent = (self.opts.name || bkT("sr_assistant")) + ": " + (_annDiv.textContent || ""); 13925 } 13926 this._updateSendBtn(); 13927 if (!this._voiceMode) this.inputEl.focus(); 13928 } 13929 }; 13930 13931 /* ââ open / close âââââââââââââââââââââââââââââââââââââââââââ */ 13932 CafChat.prototype.open = function (autoOpened) { 13933 this.isOpen = true; 13934 this._bubbleAltDone = true; /* a11y #C58: stop definitivo rotazione bubble */ 13935 this.unreadCount = 0; 13936 this._updateBadge(); 13937 this.panelEl.classList.add("open"); 13938 this.toggleEl.classList.add("open"); 13939 this.toggleEl.setAttribute("aria-expanded", "true"); 13940 this.toggleEl.setAttribute("aria-label", bkT("aria_close_chat")); 13941 this.bubbleEl.style.display = "none"; 13942 13943 /* fullscreen on mobile */ 13944 if (window.innerWidth < 640) { 13945 this.panelEl.classList.add("caf-fullscreen"); 13946 this.toggleEl.classList.add("caf-fs-hide"); 13947 document.body.style.overflow = "hidden"; 13948 document.body.style.touchAction = "none"; 13949 } 13950 13951 var self = this; 13952 if (self._needsConsent() && self.consentOverlay) { 13953 self.consentOverlay.style.display = "flex"; 13954 if (self.emailOverlay) self.emailOverlay.style.display = "none"; 13955 self.messagesEl.style.display = "none"; 13956 self._scrollWrap.style.display = "none"; 13957 self._inputAreaEl.style.display = "none"; 13958 if (!autoOpened) setTimeout(function () { 13959 var ccb = self.consentOverlay.querySelector(".caf-consent-cb"); 13960 if (ccb) ccb.focus(); 13961 }, 200); 13962 } else if (self._needsEmail() && self.emailOverlay) { 13963 if (self.consentOverlay) self.consentOverlay.style.display = "none"; 13964 self.emailOverlay.style.display = "flex"; 13965 self.messagesEl.style.display = "none"; 13966 self._scrollWrap.style.display = "none"; 13967 self._inputAreaEl.style.display = "none"; 13968 if (!autoOpened) setTimeout(function () { 13969 var emailInput = self.emailOverlay.querySelector(".caf-email-input"); 13970 if (emailInput) emailInput.focus(); 13971 }, 200); 13972 } else { 13973 if (self.consentOverlay) self.consentOverlay.style.display = "none"; 13974 if (self.emailOverlay) self.emailOverlay.style.display = "none"; 13975 /* il consenso puo' essere stato concesso da un'ALTRA tab/istanza 13976 (_needsConsent legge il localStorage live): se il pannello era 13977 stato costruito col gate attivo, la UI chat va ripristinata. */ 13978 self.messagesEl.style.display = ""; 13979 self._scrollWrap.style.display = ""; 13980 self._inputAreaEl.style.display = ""; 13981 self._scrollToBottom(); 13982 /* a11y #15: se l'apertura e' AUTOMATICA (timer 18s) NON si sposta il 13983 * focus: l'utente resta dove stava sulla pagina host (WCAG 2.4.3). */ 13984 if (!autoOpened) setTimeout(function () { 13985 /* Non rubare il focus a un overlay modale (banner GDPR del wizard, 13986 * popup) apparso nel frattempo: il focus va al modale (WCAG 2.4.3; 13987 * era la causa del banner GDPR irraggiungibile con Tab in CDP). */ 13988 var _m = self._trapTop && self._trapTop(); 13989 if (_m && _m !== self.panelEl) { 13990 try { var _f = self._trapFocusables(_m); if (_f.length) { _f[0].focus(); return; } } catch (_e) {} 13991 } 13992 self.inputEl.focus(); 13993 }, 200); 13994 } 13995 /* check if booking is enabled, then show persistent CTA */ 13996 if (self._syncAiFooter) self._syncAiFooter(); 13997 self._bkCheckEnabled().then(function () { self._updateBkCta(); }); 13998 self._updateBkExpandBanner(); 13999 }; 14000 14001 /* BUGFIX (fascia bianca "sotto Assistente IA"): il footer di disclosure 14002 * sta in fondo al pannello DOPO input/messaggi; quando il gate consenso o 14003 * email li nasconde (display:none), il footer risale come primo elemento 14004 * visibile sotto l'header â doppione del sottotitolo + vuoto bianco del 14005 * gate subito sotto. Durante i gate il footer va nascosto (la disclosure 14006 * IA completa e' gia' NEL testo del gate) e ripristinato quando la chat 14007 * appare. Chiamare dopo OGNI toggle dei gate. */ 14008 CafChat.prototype._syncAiFooter = function () { 14009 if (!this._aiFooterEl) return; 14010 var gateOn = false;
14011 try { 14012 var c = this.consentOverlay, e = this.emailOverlay; 14013 gateOn = !!((c && c.style.display !== "none" && c.getClientRects().length) || 14014 (e && e.style.display !== "none" && e.getClientRects().length)); 14015 } catch (_e) {} 14016 this._aiFooterEl.style.display = gateOn ? "none" : ""; 14017 }; 14018 14019 CafChat.prototype.close = function () { 14020 if (this._bkCtaEl) this._bkCtaEl.style.display = "none"; 14021 if (this._bkExpandBannerEl) this._bkExpandBannerEl.classList.remove("active"); 14022 this.isOpen = false; 14023 if (this._bkFsMode) this._bkExitFs(true); 14024 if (this._voiceMode) this._exitVoiceMode(); 14025 else if (this._isListening) this._abortListening(); 14026 if (this.isFullscreen) this._exitFullscreen(); 14027 this.panelEl.classList.remove("open", "caf-fullscreen"); 14028 /* pulizia difensiva di eventuali quote inline (versioni col pin 14029 * tastiera): deve sempre comandare il CSS del pannello */ 14030 this.panelEl.style.transition = ""; 14031 this.panelEl.style.height = ""; 14032 this.panelEl.style.top = ""; 14033 this.panelEl.style.borderBottom = ""; 14034 this.toggleEl.classList.remove("open", "caf-fs-hide"); 14035 this.toggleEl.setAttribute("aria-expanded", "false"); 14036 this.toggleEl.setAttribute("aria-label", this._toggleLabelClosed || bkT("aria_open_chat")); 14037 if (this.toggleEl && !this._voiceMode) { try { this.toggleEl.focus(); } catch (_e) {} } 14038 this.bubbleEl.style.display = this.opts.showBubble ? "" : "none"; 14039 document.body.style.overflow = ""; 14040 document.body.style.touchAction = ""; 14041 /* Riavvia l'alternanza del bubble se la prenotazione è abilitata 14042 * (opts.showBubble true e _bkEnabled già valutato come true). */ 14043 if (this.opts.showBubble && this._bkEnabled === true) { 14044 this._startBubbleAlternation(); 14045 } 14046 }; 14047 14048 /* ââ desktop/tablet fullscreen toggle âââââââââââââââââââââââ */ 14049 CafChat.prototype._toggleFullscreen = function () { 14050 if (this.isFullscreen) this._exitFullscreen(); 14051 else this._enterFullscreen(); 14052 }; 14053 14054 CafChat.prototype._enterFullscreen = function () { 14055 this.isFullscreen = true; 14056 this.panelEl.classList.add("caf-desktop-fs"); 14057 this.toggleEl.classList.add("caf-fs-hide-desk"); 14058 this.bubbleEl.classList.add("caf-fs-hide-desk"); 14059 document.body.style.overflow = "hidden"; 14060 this._fsBtnEl.innerHTML = 14061 '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px">' + 14062 '<polyline points="1 6 1 1 6 1"/><polyline points="19 6 19 1 14 1"/><polyline points="1 14 1 19 6 19"/><polyline points="19 14 19 19 14 19"/>' + 14063 '</svg>'; 14064 this._fsBtnEl.setAttribute("aria-label", bkT("aria_exit_fullscreen")); 14065 var self = this; 14066 setTimeout(function () { self._scrollToBottom(); }, 100); 14067 }; 14068 14069 CafChat.prototype._exitFullscreen = function () { 14070 this.isFullscreen = false; 14071 this.panelEl.classList.remove("caf-desktop-fs"); 14072 this.toggleEl.classList.remove("caf-fs-hide-desk"); 14073 this.bubbleEl.classList.remove("caf-fs-hide-desk"); 14074 document.body.style.overflow = ""; 14075 this._fsBtnEl.innerHTML = 14076 '<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px">' + 14077 '<polyline points="6 1 1 1 1 6"/><polyline points="14 1 19 1 19 6"/>
14077<polyline points="6 19 1 19 1 14"/><polyline points="14 19 19 19 19 14"/>' + 14078 '</svg>'; 14079 this._fsBtnEl.setAttribute("aria-label", bkT("aria_fullscreen")); 14080 var self = this; 14081 setTimeout(function () { self._scrollToBottom(); }, 100); 14082 }; 14083 14084 /* ââ helpers âââââââââââââââââââââââââââââââââââââââââââââââââ */ 14085 CafChat.prototype._scrollToBottom = function () { 14086 var el = this.messagesEl; 14087 if (el) el.scrollTop = el.scrollHeight; 14088 this.isAtBottom = true; 14089 this._updateScrollBtn(); 14090 }; 14091 14092 CafChat.prototype._updateScrollBtn = function () { 14093 if (this.scrollBtn) { 14094 this.scrollBtn.style.display = this.isAtBottom ? "none" : "flex"; 14095 } 14096 }; 14097 14098 CafChat.prototype._updateMenu = function () { 14099 if (this.menuEl) { 14100 this.menuEl.classList.toggle("open", this.menuOpen); 14101 } 14102 var mt = this.panelEl && this.panelEl.querySelector(".caf-menu-trigger"); 14103 if (mt) mt.setAttribute("aria-expanded", this.menuOpen ? "true" : "false"); 14104 }; 14105 14106 CafChat.prototype._updateSendBtn = function () { 14107 var bkActive = this.bkStep !== "idle" && this.bkStep !== "done" && this.bkStep !== "askLocation"; 14108 /* Rete di sicurezza: se lo step di prenotazione e' attivo ma NON esiste 14109 * piu' alcuna UI per proseguirlo (niente overlay fullscreen, niente 14110 * widget inline nel flusso chat, niente banner "Espandi" per riprendere), 14111 * lo stato e' orfano: bloccare l'input renderebbe la chat inutilizzabile 14112 * senza via d'uscita. */ 14113 if (bkActive && !this._bkFsMode) { 14114 var _bkUiReachable = (this._bkEl && this._bkEl.isConnected) || 14115 (this._bkExpandBannerEl && this._bkExpandBannerEl.classList.contains("active")); 14116 if (!_bkUiReachable) bkActive = false; 14117 } 14118 /* In voice mode, never disable mic/input during booking â voice handles all steps */ 14119 var blockInput = bkActive && !this._voiceMode; 14120 this.sendBtn.disabled = this.isLoading || blockInput; 14121 this.inputEl.disabled = blockInput; 14122 /* WCAG 3.1.2 (#C48): placeholder dinamico localizzato (ph_write_message = 14123 * accessible name dell'input). */ 14124 this.inputEl.placeholder = this.bkStep === "askLocation" 14125 ? bkT("ph_your_city") 14126 : bkActive && !this._voiceMode ? bkT("ph_follow_steps") : bkT("ph_write_message"); 14127 if (this.micBtn) this.micBtn.disabled = this.isLoading || blockInput; 14128 }; 14129 14130 CafChat.prototype._translateMessage = async function (content, langCode) { 14131 if (this.isLoading) return; 14132 var langName = LANG_NAMES[langCode] || langCode; 14133 var self = this; 14134 var assistantMsg = { 14135 id: quickId(), 14136 role: "assistant", 14137 transLang: langCode, 14138 content: "", 14139 timestamp: Date.now() 14140 }; 14141 this.messages.push(assistantMsg); 14142 this.isLoading = true; 14143 if (this.messagesEl) this.messagesEl.setAttribute("aria-busy", "true"); 14144 if (this.srAnnouncer) this.srAnnouncer.textContent = bkT("assistant_typing"); 14145 this._renderMessages(); 14146 this._updateSendBtn(); 14147 14148 var apiUrl = this.opts.baseUrl + "/api/chat"; 14149 /* AbortController + watchdog di inattività (come il send normale): senza, 14150 * un /api/chat appeso bloccava input/send/mic finché il server non chiudeva 14151 * (fino a ~120s). Agganciato a _chatAbort così "stop"/"nuova conversazione" 14152 * può annullare anche la traduzione. */ 14153 var tCtrl = new AbortController(); 14154 this._chatAbort = tCtrl; 14155 var _tIdleTid = null; 14156 var _armTIdle = function(){ if(_tIdleTid)clearTimeout(_tIdleTid); _tIdleTid=setTimeout(function(){ try{tCtrl.abort();}catch(_e){} }, 25000); }; 14157 var _clearTIdle = function(){ if(_tIdleTid){clearTimeout(_tIdleTid);_tIdleTid=null;} }; 14158 _armTIdle(); 14159 try { 14160 var response = await fetch(apiUrl, { 14161 method: "POST", 14162 headers: { "Content-Type": "application/json" }, 14163 body: JSON.stringify({ 14164 message: "Traduci il seguente testo in " + langName + ". Rispondi solo con la traduzione.\n\n" + content, 14165 stream: true, 14166 conversationId: self.conversationId 14167 }), 14168 signal: tCtrl.signal, 14169 }); 14170 if (!response.ok) throw new Error("HTTP " + response.status); 14171 14172 var ct = response.headers.get("content-type") || ""; 14173 var fullReply = ""; 14174 var rawBufferT = ""; /* buffer raw, ripulito via parseMantisSentinels */ 14175 14176 if (response.body && !ct.includes("application/json")) { 14177 var reader = response.body.getReader(); 14178 var decoder = new TextDecoder(); 14179 var frameId = null; 14180 var scheduleUpdate = function () { 14181 if (frameId !== null) return; 14182 frameId = requestAnimationFrame(function () { 14183 frameId = null; 14184 assistantMsg.content = "Traduzione (" + langName + ") · " + bkT("translate_ai_note") + ":\n" + fullReply; 14185 self._updateStreamingBubble(assistantMsg.content); 14186 }); 14187 }; 14188 while (true) { 14189 var result = await reader.read(); 14190 if (result.done) break; 14191 _armTIdle(); 14192 rawBufferT += decoder.decode(result.value, { stream: true }); 14193 fullReply = parseMantisSentinels(rawBufferT).clean; 14194 scheduleUpdate(); 14195 } 14196 if (frameId !== null) cancelAnimationFrame(frameId); 14197 fullReply = parseMantisSentinels(rawBufferT).clean; 14198 } else { 14199 var data = await response.json(); 14200 if (data && data.reply) fullReply = parseMantisSentinels(data.reply.trim()).clean; 14201 } 14202 assistantMsg.content = "Traduzione (" + langName + ") · " + bkT("translate_ai_note") + ":\n" + fullReply; 14203 } catch (err) { 14204 this.messages = this.messages.filter(function (m) { return m.id !== assistantMsg.id; }); 14205 this._showError("Traduzione non riuscita. Riprova."); 14206 } finally { 14207 _clearTIdle(); 14208 if (this._chatAbort === tCtrl) this._chatAbort = null; 14209 this.isLoading = false;
14210 if (this.messagesEl) this.messagesEl.setAttribute("aria-busy", "false"); 14211 this._saveMessages(); 14212 this._renderMessages(); 14213 if (this.srAnnouncer && assistantMsg && assistantMsg.content) { 14214 var _tDiv = document.createElement("div"); 14215 _tDiv.innerHTML = renderMarkdown(assistantMsg.content, this.opts.color); 14216 this.srAnnouncer.textContent = (this.opts.name || bkT("sr_assistant")) + ": " + (_tDiv.textContent || ""); 14217 } 14218 this._updateSendBtn(); 14219 } 14220 }; 14221 14222 CafChat.prototype._updateRatingButtons = function () { 14223 var good = this.menuEl.querySelector(".caf-rate-good"); 14224 var bad = this.menuEl.querySelector(".caf-rate-bad"); 14225 if (!good || !bad) return; 14226 good.className = "caf-rate-btn caf-rate-good" + (this.rating === "good" ? " selected-good" : ""); 14227 bad.className = "caf-rate-btn caf-rate-bad" + (this.rating === "bad" ? " selected-bad" : ""); 14228 good.setAttribute("aria-pressed", this.rating === "good" ? "true" : "false"); 14229 bad.setAttribute("aria-pressed", this.rating === "bad" ? "true" : "false"); 14230 }; 14231 14232 /* Invia il rating al server-side endpoint, che lo scrive su Firestore. 14233 * Riusa /api/conversations passando metadata.rating. Fire-and-forget. */ 14234 CafChat.prototype._syncRating = function () { 14235 if (this.opts.privacy) return; 14236 /* Stesso gate consenso di _syncConversation: nessuna persistenza server 14237 * prima del consenso o dopo la revoca (GDPR artt. 6, 7(3)). */ 14238 if (this.opts.consenso && this._needsConsent && this._needsConsent()) return; 14239 if (!this.conversationId || !this.rating) return; 14240 14241 var self = this; 14242 var msgs = this.messages.filter(function (m) { 14243 if (!m || !m.content || typeof m.content !== "string") return false; 14244 if (m.content === "@@BOOKING@@") return false; 14245 if (m.content === "@@PWDRESET@@") return false; 14246 if (m.content.indexOf("@@BOOKING_LAUNCH@@") === 0) return false; 14247 return m.role === "user" || m.role === "assistant"; 14248 }).map(function (m) { 14249 return { id: m.id, role: m.role, content: m.content, timestamp: m.timestamp || Date.now() }; 14250 }); 14251 14252 var apiUrl = (this.opts.baseUrl || "") + "/api/conversations"; 14253 fetch(apiUrl, { 14254 method: "POST", 14255 headers: { "Content-Type": "application/json" }, 14256 body: JSON.stringify({ 14257 conversationId: self.conversationId, 14258 messages: msgs, 14259 metadata: { 14260 /* minimizzazione: solo origin+pathname, niente email (non serve al rating). */ 14261 pageUrl: (function(){ try { return (typeof location !== "undefined") ? (location.origin + (location.pathname || "")) : ""; } catch (_e) { return ""; } })(), 14262 source: "widget-inline", 14263 rating: self.rating 14264 } 14265 }), 14266 keepalive: true 14267 }).catch(function () { /* silent */ }); 14268 }; 14269 14270 CafChat.prototype._updateBadge = function () { 14271 if (!this.badgeEl) return; 14272 if (this.unreadCount > 0 && !this.isOpen) { 14273 this.badgeEl.textContent = this.unreadCount > 9 ? "9+" : String(this.unreadCount); 14274 this.badgeEl.style.display = "flex"; 14275 } else { 14276 this.badgeEl.style.display = "none"; 14277 } 14278 }; 14279 14280 /* ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 14281 VOICE MODE â full conversational voice (STT + TTS + VAD) 14282 ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 14283 14284 /* ââ beep âââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 14285 CafChat.prototype._playBeep = function (freq, durationMs, volume) { 14286 try { 14287 var ctx = new AudioContext(); 14288 var osc = ctx.createOscillator(); 14289 var gain = ctx.createGain(); 14290 osc.type = "sine"; 14291 osc.frequency.value = freq; 14292 gain.gain.setValueAtTime(volume || 0.12, ctx.currentTime); 14293 gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + durationMs / 1000); 14294 osc.connect(gain).connect(ctx.destination); 14295 osc.start(); 14296 osc.stop(ctx.currentTime + durationMs / 1000); 14297 osc.onended = function () { ctx.close(); }; 14298 } catch (e) { /* silent */ } 14299 }; 14300 14301 /* ââ TTS helpers ââââââââââââââââââââââââââââââââââââââââââââââ */ 14302 var TTS_DIGITS = ["zero","uno","due","tre","quattro","cinque","sei","sette","otto","nove"]; 14303 14304 var TTS_PRONUNCIATIONS = { 14305 "CAF": "caf", "CISL": "cisl", "CGIL": "cgil", "UIL": "uil", 14306 "INPS": "inps", "INAIL": "inail", "ISEE": "isee", "IRPEF": "irpef", 14307 "CUD": "cud", "CU": "cu", "IMU": "imu", "TARI": "tari", "TASI": "tasi", 14308 "SPID": "spid", "CIE": "cie", "PEC": "pec", "ASL": "asl", 14309 "NASPI": "naspi", "RED": "red", "DSU": "dsu", "RAL": "ral", 14310 "IVA": "iva", "IBAN": "iban", "PIN": "pin", "OTP": "otp", 14311 "PDF": "pdf", "FAQ": "faq", 14312 "730": "sette trenta", "740": "sette quaranta", "770": "sette settanta", 14313 "F24": "effe ventiquattro", "F23": "effe ventitré", 14314 }; 14315 var TTS_PRON_REGEX = new RegExp("\\b(" + Object.keys(TTS_PRONUNCIATIONS).join("|") + ")\\b", "g"); 14316 14317 CafChat.prototype._cleanForTTS = function (raw) { 14318 return raw 14319 .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") 14320 .replace(/https?:\/\/[^\s)]+/gi, "") 14321 .replace(/www\.[^\s)]+/gi, "") 14322 .replace(/\*\*([^*]*)\*\*/g, "$1") 14323 .replace(/[*_~`#]/g, "") 14324 .replace(/^\s*[-\u2022]\s+/gm, "") 14325 .replace(/^\s*(\d+)\.\s+(?=[A-Z\u00C0-\u00DC])/gm, "$1, ") 14326 .replace(/[\u{1F600}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{2600}-\u{27BF}]/gu, "") 14327 .replace(/\((?![+\d])([^)]{0,8})\)/g, "") 14328 .replace(TTS_PRON_REGEX, function (m) { return TTS_PRONUNCIATIONS[m] || m; }) 14329 .replace(/\b([A-Z]{2,6})\b/g, function (m) { return m.charAt(0) + m.slice(1).toLowerCase(); }) 14330 .replace(/\(?\+?\d[\d\s.()\-]{5,}\d\)?/g, function (m) { 14331 var d = m.replace(/[^\d]/g, ""); if (d.length < 7) return m; 14332 return m.match(/\d+/g).map(function (g) { 14333 return g.split("").map(function (c) { return TTS_DIGITS[parseInt(c)]; }).join(" "); 14334 }).join(" - "); 14335 }) 14336 .replace(/---+/g, "") 14337 .replace(/\.{3,}/g, "...") 14338 .replace(/\s*[,;]\s*[,;]/g, ",") 14339 .replace(/\n{2,}/g, ". ") 14340 .replace(/\n/g, ", ") 14341 .replace(/\s{2,}/g, " ") 14342 .trim(); 14343 }; 14344 14345 /** Speak via browser speechSynthesis (fallback when /api/tts is unavailable). 14346 * Returns a Promise that resolves when speech finishes. */ 14347 CafChat.prototype._speakBrowserTTS = function (text) { 14348 var self = this; 14349 return new Promise(function (resolve) { 14350 if (!window.speechSynthesis) { resolve(); return; } 14351 /* Chrome loads voices async â wait for them if empty */ 14352 function doSpeak() { 14353 /* workaround: Chrome sometimes stalls speechSynthesis â cancel any stale queue */ 14354 window.speechSynthesis.cancel(); 14355 var utter = new SpeechSynthesisUtterance(text); 14356 utter.lang = "it-IT"; 14357 utter.rate = 1.05; 14358 utter.pitch = 1.0; 14359 /* try to pick an Italian voice */ 14360 var voices = window.speechSynthesis.getVoices(); 14361 var itVoice = voices.filter(function(v){ return v.lang && v.lang.
14361indexOf("it") === 0; }); 14362 if (itVoice.length) { 14363 /* prefer more natural voices (non-compact, non-Google basic) */ 14364 var preferred = itVoice.filter(function(v){ return !v.name.match(/Google/i) && !v.localService; }); 14365 if (!preferred.length) preferred = itVoice.filter(function(v){ return !v.name.match(/compact/i); }); 14366 utter.voice = preferred.length ? preferred[0] : itVoice[0]; 14367 } 14368 var resolved = false; 14369 var done = function () { if (resolved) return; resolved = true; self._currentUtterance = null; resolve(); }; 14370 utter.onend = done; 14371 utter.onerror = done; 14372 self._currentUtterance = utter; 14373 window.speechSynthesis.speak(utter); 14374 /* safety timeout */ 14375 setTimeout(done, Math.max(text.length * 80, 8000)); 14376 } 14377 var voices = window.speechSynthesis.getVoices(); 14378 if (voices.length === 0 && window.speechSynthesis.onvoiceschanged !== undefined) { 14379 window.speechSynthesis.onvoiceschanged = function () { 14380 window.speechSynthesis.onvoiceschanged = null; 14381 doSpeak(); 14382 }; 14383 /* if voices still don't load after 500ms, speak anyway */ 14384 setTimeout(function () { window.speechSynthesis.onvoiceschanged = null; doSpeak(); }, 500); 14385 } else { 14386 doSpeak(); 14387 } 14388 }); 14389 }; 14390 14391 CafChat.prototype._queueTTSChunk = function (text) { 14392 var cleaned = this._cleanForTTS(text); 14393 if (!cleaned) return; 14394 var self = this; 14395 var controller = new AbortController(); 14396 this._ttsAbortControllers.push(controller); 14397 14398 /* a11y #C73: /api/tts puo' non esistere sul deployment. Al primo 14399 * 404/errore di rete memoizziamo il fallimento (flag condiviso): i chunk 14400 * successivi saltano la fetch e vanno diretti al fallback speechSynthesis. 14401 * Quando l'endpoint esiste il comportamento resta IDENTICO (il flag non 14402 * viene mai impostato su risposta ok; l'abort utente non conta). */ 14403 var promise = CafChat._ttsUnavailable 14404 ? Promise.resolve(null) 14405 : fetch(this.opts.baseUrl + "/api/tts", { 14406 method: "POST", 14407 headers: { "Content-Type": "application/json" }, 14408 body: JSON.stringify({ text: cleaned }), 14409 signal: controller.signal, 14410 }) 14411 .then(function (r) { if (!r.ok) { CafChat._ttsUnavailable = true; return null; } return r.blob(); }) 14412 .then(function (b) { return b ? URL.createObjectURL(b) : null; }) 14413 .catch(function (err) { if (!(err && err.name === "AbortError")) CafChat._ttsUnavailable = true; return null; }); 14414 14415 /* wrap: if server TTS fails, fall back to browser speechSynthesis */ 14416 var wrappedPromise = promise.then(function (url) { 14417 if (url) return { type: "audio", url: url }; 14418 /* fallback to browser TTS */ 14419 return { type: "browser", text: cleaned }; 14420 }); 14421 14422 this._audioQueue.push(wrappedPromise); 14423 14424 // Cancel any pending listen timer â mic must never open during TTS 14425 this._scheduleListening(Infinity); 14426 if (!this._isPlayingQueue) { 14427 this._isPlayingQueue = true; 14428 this._isSpeaking = true; 14429 if (this._isListening) this._stopListening(); 14430 this._updateVoiceUI(); 14431 this._drainAudioQueue(); 14432 } 14433 }; 14434 14435 CafChat.prototype._drainAudioQueue = async function () { 14436 var epoch = this._ttsEpoch; 14437 while (this._audioQueue.length > 0 && this._ttsEpoch === epoch) { 14438 var item = await this._audioQueue[0]; 14439 this._audioQueue.shift(); 14440 if (!item || this._ttsEpoch !== epoch) { 14441 if (item && item.type === "audio" && item.url) URL.revokeObjectURL(item.url); 14442 break; 14443 } 14444 var self = this; 14445 14446 if (item.type === "browser") { 14447 /* use browser speechSynthesis */ 14448 await self._speakBrowserTTS(item.text); 14449 } else { 14450 /* use server-generated audio */ 14451 var url = item.url; 14452 var nextProm = this._audioQueue.length > 0 ? this._audioQueue[0] : null; 14453 var preloaded = null; 14454 if (nextProm) { 14455 nextProm.then(function (nextItem) { 14456 if (nextItem && nextItem.type === "audio" && nextItem.url && self._ttsEpoch === epoch) { preloaded = new Audio(nextItem.url); preloaded.preload = "auto"; } 14457 }).catch(function () {}); 14458 } 14459 await new Promise(function (resolve) { 14460 var resolved = false;
14461 var done = function () { if (resolved) return; resolved = true; URL.revokeObjectURL(url); self._currentAudio = null; clearTimeout(safetyTimer); resolve(); }; 14462 var audio = new Audio(url); 14463 self._currentAudio = audio; 14464 audio.onended = done; 14465 audio.onerror = done; 14466 var safetyTimer = setTimeout(done, 30000); 14467 audio.play().catch(done); 14468 }); 14469 void preloaded; 14470 } 14471 } 14472 if (this._ttsEpoch === epoch) { 14473 this._isPlayingQueue = false; 14474 this._isSpeaking = false; 14475 this._updateVoiceUI(); 14476 if (this._voiceMode && !this._isListening) { 14477 this._scheduleListening(1000); 14478 } 14479 } 14480 }; 14481 14482 CafChat.prototype._stopSpeaking = function () { 14483 this._ttsEpoch++; 14484 this._audioQueue = []; 14485 this._isPlayingQueue = false; 14486 for (var i = 0; i < this._ttsAbortControllers.length; i++) { 14487 this._ttsAbortControllers[i].abort(); 14488 } 14489 this._ttsAbortControllers = []; 14490 if (this._currentAudio) { 14491 this._currentAudio.pause(); 14492 this._currentAudio.currentTime = 0; 14493 this._currentAudio = null; 14494 } 14495 /* cancel browser speechSynthesis too */ 14496 if (window.speechSynthesis) { 14497 window.speechSynthesis.cancel(); 14498 } 14499 this._currentUtterance = null; 14500 this._isSpeaking = false; 14501 }; 14502 14503 /* ââ Centralised listen scheduler ââââââââââââââââââââââââââââ */ 14504 /* Single point of control: cancels any pending listen timer */ 14505 /* and re-schedules. TTS start always calls this with Infinity */ 14506 /* to cancel, ensuring the mic never opens during playback. */ 14507 CafChat.prototype._scheduleListening = function (delay) { 14508 var self = this; 14509 if (self._listenTimer) { clearTimeout(self._listenTimer); self._listenTimer = null; } 14510 if (delay === Infinity) return; // just cancel, don't reschedule 14511 self._listenTimer = setTimeout(function () { 14512 self._listenTimer = null; 14513 if (self._voiceMode && !self._isListening && 14514 !self._isPlayingQueue && !self._isSpeaking && !self.isLoading) { 14515 self._startListening(); 14516 } 14517 }, delay); 14518 }; 14519 14520 CafChat.prototype._interruptResponse = function () { 14521 if (this._chatAbort) { 14522 this._chatAbort.abort(); 14523 this._chatAbort = null; 14524 } 14525 this._stopSpeaking(); 14526 this.isLoading = false; 14527 this._updateVoiceUI(); 14528 }; 14529 14530 /* ââ VAD (Voice Activity Detection) ââââââââââââââââââââââââââ */ 14531 CafChat.prototype._stopVAD = function () { 14532 if (this._vadInterval) { 14533 clearInterval(this._vadInterval); 14534 this._vadInterval = null; 14535 } 14536 if (this._vadStream) { 14537 this._vadStream.getTracks().forEach(function (t) { t.stop(); }); 14538 this._vadStream = null; 14539 } 14540 }; 14541 14542 CafChat.prototype._startVAD = async function () { 14543 this._stopVAD(); 14544 var self = this; 14545 try { 14546 var stream = await navigator.mediaDevices.getUserMedia({ audio: true }); 14547 self._vadStream = stream; 14548 var ctx = new AudioContext(); 14549 var source = ctx.createMediaStreamSource(stream); 14550 var analyser = ctx.createAnalyser(); 14551 analyser.fftSize = 256; 14552 source.connect(analyser); 14553 var data = new Uint8Array(analyser.frequencyBinCount); 14554 var calibrationSamples = 0; 14555 var calibrationSum = 0; 14556 var ambientLevel = 10; 14557 var consecutiveAbove = 0; 14558 14559 self._vadInterval = setInterval(function () { 14560 if (!self._voiceMode) { self._stopVAD(); ctx.close(); return; } 14561 analyser.getByteFrequencyData(data); 14562 var sum = 0; 14563 for (var i = 0; i < data.length; i++) sum += data[i]; 14564 var avg = sum / data.length; 14565 if (calibrationSamples < 8) { 14566 calibrationSum += avg; 14567 calibrationSamples++; 14568 if (calibrationSamples === 8) ambientLevel = calibrationSum / 8; 14569 return; 14570 } 14571 var threshold = Math.max(ambientLevel * 3, 18); 14572 if (avg > threshold) { consecutiveAbove++; } else { consecutiveAbove = 0; } 14573 14574 if (consecutiveAbove >= 4) { 14575 self._stopVAD(); 14576 ctx.close(); 14577 self._interruptResponse(); 14578 if (self._voiceMode && !self._isListening) { 14579 self._startListening(); 14580 } 14581 } 14582 }, 50); 14583 } catch (e) { /* mic unavailable */ } 14584 }; 14585 14586 /* ââ Speech Recognition (STT) â continuous mode âââââââââââââââ */ 14587 CafChat.prototype._startListening = function () { 14588 var self = this; 14589 if (this._isListening) return; 14590 if (this._isSpeaking || this._isPlayingQueue) return; 14591 var SR = window.SpeechRecognition || window.webkitSpeechRecognition; 14592 if (!SR) { 14593 this._showError("Il tuo browser non supporta il riconoscimento vocale. Prova con Google Chrome."); 14594 return; 14595 } 14596 14597 var recognition = new SR(); 14598 recognition.lang = "it-IT"; 14599 // continuous=false: browser auto-stops after user pauses, fires onend cleanly. 14600 // On Android Chrome, continuous=true causes each isFinal result to contain 14601 // the full cumulative phrase ("Ciao", "Ciao Sto", "Ciao Sto cercando"â¦), 14602 // and _finalBuffer+= would concatenate them â "CiaoCiao StoCiao Sto cercandoâ¦". 14603 recognition.continuous = false;
14604 recognition.interimResults = true; 14605 recognition.maxAlternatives = 1; 14606 this._recognition = recognition; 14607 this._isListening = true; 14608 this._finalBuffer = ""; 14609 if (this._sendDebounce) { clearTimeout(this._sendDebounce); this._sendDebounce = null; } 14610 this._updateVoiceUI(); 14611 14612 /* Track consecutive "not-allowed" errors to avoid infinite retry loops */ 14613 if (!this._micRetryCount) this._micRetryCount = 0; 14614 14615 recognition.onresult = function (event) { 14616 self._micRetryCount = 0; /* reset on success */ 14617 // Take the last (most complete) result from the event. 14618 // Use SET (not +=) to avoid accumulating cumulative phrases on Android. 14619 var last = event.results[event.results.length - 1]; 14620 var text = last[0].transcript; 14621 if (last.isFinal) { 14622 self._finalBuffer = text; 14623 } 14624 var display = (self._finalBuffer || text).trim(); 14625 if (self._voiceMode) { 14626 self._voiceInterimText = display; 14627 self._updateVoiceGhost(); 14628 } else { 14629 self.inputEl.value = display; 14630 } 14631 // No debounce: with continuous=false the browser fires onend automatically. 14632 }; 14633 14634 recognition.onerror = function (event) { 14635 if (event.error === "not-allowed") { 14636 self._isListening = false; 14637 self._recognition = null; 14638 self._micRetryCount = (self._micRetryCount || 0) + 1; 14639 self._updateVoiceUI(); 14640 /* On auto-restart (no user gesture), browsers may fire "not-allowed" 14641 even though the user granted mic permission. In voice mode, silently 14642 re-schedule instead of showing an alarming error. Max 3 retries. */ 14643 if (self._voiceMode && !self._isSpeaking && !self._isPlayingQueue && self._micRetryCount < 3) { 14644 self._scheduleListening(800); 14645 } else { 14646 self._micRetryCount = 0; 14647 self._showError("Permesso microfono negato. Consenti l'accesso al microfono nelle impostazioni del browser."); 14648 } 14649 } else if (event.error !== "no-speech" && event.error !== "aborted") { 14650 self._isListening = false; 14651 self._recognition = null; 14652 self._updateVoiceUI(); 14653 self._showError("Errore microfono. Riprova."); 14654 } 14655 }; 14656 14657 recognition.onend = function () { 14658 if (self._sendDebounce) { clearTimeout(self._sendDebounce); self._sendDebounce = null; } 14659 var pendingText = self._finalBuffer.trim(); 14660 self._isListening = false; 14661 self._recognition = null; 14662 self._finalBuffer = ""; 14663 self._voiceInterimText = ""; 14664 self._updateVoiceGhost(); 14665 self._updateVoiceUI(); 14666 14667 if (pendingText) { 14668 self._lastMessageWasVoice = true; 14669 self._send(pendingText); 14670 } else if (self._voiceMode && !self._isSpeaking && !self._isPlayingQueue) { 14671 self._scheduleListening(400); 14672 } 14673 }; 14674 14675 try { 14676 recognition.start(); 14677 } catch (e) { 14678 this._isListening = false; 14679 this._recognition = null; 14680 this._micRetryCount = (this._micRetryCount || 0) + 1; 14681 this._updateVoiceUI(); 14682 if (this._voiceMode && !this._isSpeaking && !this._isPlayingQueue && this._micRetryCount < 3) { 14683 this._scheduleListening(800); 14684 } else { 14685 this._micRetryCount = 0; 14686 this._showError("Impossibile avviare il microfono. Verifica i permessi del browser."); 14687 } 14688 } 14689 }; 14690 14691 CafChat.prototype._stopListening = function () { 14692 if (this._recognition) { 14693 try { this._recognition.stop(); } catch (e) { /* ok */ } 14694 } 14695 }; 14696 14697 CafChat.prototype._abortListening = function () { 14698 if (this._sendDebounce) { clearTimeout(this._sendDebounce); this._sendDebounce = null; } 14699 if (this._recognition) { 14700 try { this._recognition.abort(); } catch (e) { /* ok */ } 14701 } 14702 this._isListening = false; 14703 this._recognition = null; 14704 this._finalBuffer = ""; 14705 this._voiceInterimText = ""; 14706 this._updateVoiceGhost(); 14707 this._updateVoiceUI(); 14708 }; 14709 14710 /* ââ enter / exit voice mode âââââââââââââââââââââââââââââââââ */ 14711 CafChat.prototype._enterVoiceMode = function () { 14712 var SR = window.SpeechRecognition || window.webkitSpeechRecognition; 14713 if (!SR) { 14714 this._showError("Il tuo browser non supporta il riconoscimento vocale. Prova con Google Chrome."); 14715 return; 14716 } 14717 var self = this; 14718 this._stopSpeaking(); 14719 this._voiceMode = true; 14720 this._isSpeaking = false;
14721 this._audioQueue = []; 14722 this._ttsAbortControllers = []; 14723 this._ttsEpoch = (this._ttsEpoch || 0) + 1; 14724 this._lastMessageWasVoice = false; 14725 this._voiceInterimText = ""; 14726 14727 /* show voice panel, hide normal chat */ 14728 this.messagesEl.style.display = "none"; 14729 if (this._scrollWrap) this._scrollWrap.style.display = "none"; 14730 this.errorEl.style.display = "none"; 14731 this._inputAreaEl.style.display = "none"; 14732 this.voicePanelEl.classList.add("active"); 14733 14734 this._renderVoiceTranscript(); 14735 this._updateVoiceUI(); 14736 14737 /* a11y #C52 (WCAG 2.4.3): il bottone mic dell'input area appena attivato 14738 * viene nascosto -> senza questo focus() il focus cadrebbe sul body della 14739 * pagina host. Sposta il focus sul controllo principale della UI vocale 14740 * e memorizza l'origine per ripristinarla all'uscita. */ 14741 this._voiceReturnFocusEl = this.micBtn || null; 14742 try { if (this.voiceMicBtn) this.voiceMicBtn.focus(); } catch (_e) {} 14743 14744 var welcomeText = ""; 14745 if (this.messages.length > 0 && this.messages[0].role === "assistant" && this.messages[0].content) { 14746 welcomeText = this.messages[0].content; 14747 } 14748 14749 /* getUserMedia is just an optional pre-check to trigger the browser 14750 permission prompt. SpeechRecognition has its OWN mic access and does 14751 NOT need getUserMedia. So if getUserMedia fails, we still proceed. */ 14752 var proceed = function () { 14753 self._micRetryCount = 0; 14754 /* AI Act art. 50(1): dichiarazione di natura IA anche nel canale vocale. */ 14755 if (self.opts.aiDisclosure !== false && !self._voiceNoticeShown) { 14756 self._voiceNoticeShown = true; 14757 self._queueTTSChunk(bkT("ai_voice_notice")); 14758 } 14759 if (welcomeText) { 14760 self._queueTTSChunk(welcomeText); 14761 } else { 14762 self._startListening(); 14763 } 14764 }; 14765 if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { 14766 navigator.mediaDevices.getUserMedia({ audio: true }) 14767 .then(function (stream) { 14768 stream.getTracks().forEach(function (t) { t.stop(); }); 14769 proceed(); 14770 }) 14771 .catch(function () { 14772 /* getUserMedia failed but SpeechRecognition may still work */ 14773 proceed(); 14774 }); 14775 } else { 14776 proceed(); 14777 } 14778 }; 14779 14780 CafChat.prototype._exitVoiceMode = function () { 14781 this._scheduleListening(Infinity); // cancel any pending listen timer 14782 this._voiceMode = false; 14783 this._abortListening(); 14784 this._stopSpeaking(); 14785 this._stopVAD(); 14786 this._voiceInterimText = ""; 14787 14788 /* hide voice panel, show normal chat */ 14789 this.voicePanelEl.classList.remove("active"); 14790 this.messagesEl.style.display = ""; 14791 if (this._scrollWrap) this._scrollWrap.style.display = ""; 14792 this._inputAreaEl.style.display = ""; 14793 14794 this._renderMessages(); 14795 this._scrollToBottom(); 14796 /* a11y #C52: ripristina il focus al bottone che ha aperto la modalita' 14797 * vocale (fallback: input chat). */ 14798 var _rf = this._voiceReturnFocusEl; 14799 this._voiceReturnFocusEl = null; 14800 if (_rf && _rf.isConnected && _rf.offsetParent !== null) { 14801 try { _rf.focus(); } catch (_e) { this.inputEl.focus(); } 14802 } else { 14803 this.inputEl.focus(); 14804 } 14805 }; 14806 14807 /* ââ voice UI update âââââââââââââââââââââââââââââââââââââââââ */ 14808 CafChat.prototype._updateVoiceUI = function () { 14809 if (!this._voiceMode || !this.voiceMicBtn) return; 14810 var c = this.opts.color; 14811 /* WCAG 4.1.2/4.1.3 (#C17/#C36): stato del canale vocale esposto alle AT. 14812 * Annunciamo solo i passaggi ad ascolto/elaborazione (una volta per 14813 * transizione) per non sovrapporci al TTS della risposta. */ 14814 var _vs = this._isListening ? "listening" : this._isSpeaking ? "speaking" : this.isLoading ? "processing" : "idle"; 14815 if (_vs !== this._voiceAnnState) { 14816 this._voiceAnnState = _vs; 14817 if (this.srAnnouncer && (_vs === "listening" || _vs === "processing")) { 14818 this.srAnnouncer.textContent = _vs === "listening" ? bkT("sr_listening") : bkT("sr_processing"); 14819 } 14820 } 14821 14822 if (this._isListening) { 14823 this._setVoiceMicIcon("stop"); 14824 this.voiceMicBtn.setAttribute("aria-label", bkT("aria_stop_listening")); 14825 this.voiceMicBtn.setAttribute("aria-pressed", "true"); 14826 this.voiceMicBtn.style.background = c; 14827 this.voiceMicBtn.style.boxShadow = "0 0 40px 8px " + c + "4d"; 14828 this.voiceMicBtn.style.animation = "none"; 14829 this.voiceMicBtn.style.transform = "scale(1.05)"; 14830 this.voiceStatusEl.textContent = bkT("voice_speak_now"); 14831 this.voiceStatusEl.style.color = "var(--caf-c-ink)"; /* a11y #C54 */ 14832 this.voiceHintEl.textContent = bkT("voice_listening_hint"); 14833 this.voiceHintEl.style.color = "var(--caf-c-ink)"; 14834 this.voiceRingEl.style.width = "120px"; 14835 this.voiceRingEl.style.height = "120px"; 14836 this.voiceRingEl.style.borderColor = c + "40"; 14837 this.voiceRipplesEl.style.display = ""; 14838 this.voiceBarsEl.style.display = ""; 14839 this.voiceGlowEl.style.width = "200px"; 14840 this.voiceGlowEl.style.height = "200px"; 14841 this.voiceGlowEl.style.opacity = "0.18"; 14842 } else if (this._isSpeaking) { 14843 this._setVoiceMicIcon("speaker"); 14844 this.voiceMicBtn.setAttribute("aria-label", bkT("aria_speak")); 14845 this.voiceMicBtn.setAttribute("aria-pressed", "false"); 14846 this.voiceMicBtn.style.background = "#34d399";
14847 this.voiceMicBtn.style.boxShadow = "0 0 30px 4px rgba(16,185,129,.2)"; 14848 this.voiceMicBtn.style.animation = "caf-speaking-glow 2s ease-in-out infinite"; 14849 this.voiceMicBtn.style.transform = ""; 14850 this.voiceStatusEl.textContent = bkT("voice_responding"); 14851 this.voiceStatusEl.style.color = "#047857"; 14852 this.voiceHintEl.textContent = bkT("voice_tap_interrupt"); 14853 this.voiceHintEl.style.color = "#047857"; 14854 this.voiceRingEl.style.width = "110px"; 14855 this.voiceRingEl.style.height = "110px"; 14856 this.voiceRingEl.style.borderColor = "rgba(16,185,129,.2)"; 14857 this.voiceRipplesEl.style.display = "none"; 14858 this.voiceBarsEl.style.display = "none"; 14859 this.voiceGlowEl.style.width = "160px"; 14860 this.voiceGlowEl.style.height = "160px"; 14861 this.voiceGlowEl.style.opacity = "0.12"; 14862 } else if (this.isLoading) { 14863 this._setVoiceMicIcon("dots"); 14864 this.voiceMicBtn.setAttribute("aria-label", bkT("sr_processing")); 14865 this.voiceMicBtn.setAttribute("aria-pressed", "false"); 14866 this.voiceMicBtn.style.background = "#94a3b8"; 14867 this.voiceMicBtn.style.boxShadow = "0 0 20px 4px rgba(100,116,139,.15)"; 14868 this.voiceMicBtn.style.animation = "caf-mic-breathe 1.5s ease-in-out infinite"; 14869 this.voiceMicBtn.style.transform = ""; 14870 this.voiceStatusEl.textContent = bkT("voice_thinking"); 14871 this.voiceStatusEl.style.color = "#5b6573"; 14872 this.voiceHintEl.textContent = bkT("voice_thinking_hint"); 14873 this.voiceHintEl.style.color = "#5b6573"; 14874 this.voiceRingEl.style.width = "105px"; 14875 this.voiceRingEl.style.height = "105px"; 14876 this.voiceRingEl.style.borderColor = "rgba(100,116,139,.2)"; 14877 this.voiceRipplesEl.style.display = "none"; 14878 this.voiceBarsEl.style.display = "none"; 14879 this.voiceGlowEl.style.width = "120px"; 14880 this.voiceGlowEl.style.height = "120px"; 14881 this.voiceGlowEl.style.opacity = "0.08"; 14882 } else { 14883 this._setVoiceMicIcon("mic"); 14884 this.voiceMicBtn.setAttribute("aria-label", bkT("aria_speak")); 14885 this.voiceMicBtn.setAttribute("aria-pressed", "false"); 14886 this.voiceMicBtn.style.background = c; 14887 this.voiceMicBtn.style.boxShadow = "0 8px 30px -4px " + c + "80"; 14888 this.voiceMicBtn.style.animation = "caf-mic-breathe 3s ease-in-out infinite"; 14889 this.voiceMicBtn.style.transform = ""; 14890 this.voiceStatusEl.textContent = bkT("voice_tap_to_talk"); 14891 this.voiceStatusEl.style.color = "var(--caf-c-ink)"; /* a11y #C54 */ 14892 this.voiceHintEl.textContent = bkT("voice_press_to_start"); 14893 this.voiceHintEl.style.color = "#5b6573"; 14894 this.voiceRingEl.style.width = "105px"; 14895 this.voiceRingEl.style.height = "105px"; 14896 this.voiceRingEl.style.borderColor = c + "30"; 14897 this.voiceRipplesEl.style.display = "none"; 14898 this.voiceBarsEl.style.display = "none"; 14899 this.voiceGlowEl.style.width = "120px"; 14900 this.voiceGlowEl.style.height = "120px"; 14901 this.voiceGlowEl.style.opacity = "0.08"; 14902 } 14903 }; 14904 14905 /* ââ voice transcript rendering ââââââââââââââââââââââââââââââ */ 14906 14907 /* Full rebuild â only when messages array changes (new msg, streaming done) */ 14908 CafChat.prototype._renderVoiceTranscript = function () { 14909 if (!this.voiceTranscriptEl) return; 14910 var o = this.opts; 14911 var frag = document.createDocumentFragment(); 14912 14913 /* AI Act art. 50(1)+(5): l'annuncio vocale di natura IA ha anche una 14914 * controparte TESTUALE visibile e persistente nel transcript. */ 14915 if (o.aiDisclosure !== false) { 14916 var _vnRow = document.createElement("div"); 14917 _vnRow.className = "caf-vt-ai-notice"; 14918 _vnRow.setAttribute("role", "note"); 14919 _vnRow.style.cssText = "margin:2px 0 8px;padding:8px 12px;background:#f1f5f9;border-radius:10px;font-size:12px;line-height:1.45;color:#334155;text-align:center"; 14920 _vnRow.textContent = bkT("ai_voice_notice"); 14921 frag.appendChild(_vnRow); 14922 } 14923 14924 for (var i = 0; i < this.messages.length; i++) { 14925 var msg = this.messages[i]; 14926 /* Skip booking placeholder messages in voice transcript */ 14927 if (msg.content === "@@BOOKING@@") continue; 14928 if (typeof msg.content === "string" && msg.content.indexOf("@@BOOKING_LAUNCH@@") === 0) continue; 14929 var row = document.createElement("div"); 14930 row.className = "caf-vt-row" + (msg.role === "user" ? " user" : ""); 14931 if (msg.role === "assistant") { 14932 var av = document.createElement("div"); 14933 av.className = "caf-vt-avatar"; 14934 av.innerHTML = '<img src="' + escapeHtml(o.logo) + '" alt="">'; 14935 row.appendChild(av); 14936 } 14937 var bbl = document.createElement("div"); 14938 bbl.className = "caf-vt-bubble " + msg.role; 14939 /* AI Act art. 50(2): marcatura machine-readable anche sul transcript 14940 * del voice mode (stessa regola delle bolle chat). */ 14941 if (msg.role === "assistant") { 14942 bbl.setAttribute("data-ai-generated", "true"); 14943 if (msg.model) bbl.setAttribute("data-model", String(msg.model)); 14944 } 14945 if (msg.role === "user") bbl.style.background = "var(--caf-c)"; 14946 if (msg.content === "" && this.isLoading && i === this.messages.length - 1) { 14947 bbl.innerHTML = '<div class="caf-vt-dots"><span style="background:' + o.color + '66"></span><span style="background:' + o.color + '66"></span><span style="background:' + o.color + '66"></span></div>'; 14948 this._voiceStreamingBubble = bbl; 14949 } else { 14950 bbl.innerHTML = renderMarkdown(msg.content, o.color); 14951 var _ecVQ = ""; 14952 if (msg.role === "assistant") { 14953 for (var _vqi = i - 1; _vqi >= 0; _vqi--) { 14954 if (this.messages[_vqi].role === "user" && this.messages[_vqi].content) { 14955 _ecVQ = this.messages[_vqi].content; break; 14956 } 14957 } 14958 } 14959 this._renderEcommerceCards && this._renderEcommerceCards(bbl, _ecVQ); 14960 if (this.isLoading && i === this.messages.length - 1 && msg.role === "assistant") { 14961 this._voiceStreamingBubble = bbl; 14962 } 14963 } 14964 row.appendChild(bbl); 14965 frag.appendChild(row); 14966 } 14967 14968 this.voiceTranscriptEl.innerHTML = ""; 14969 this._voiceGhostEl = null; 14970 this.voiceTranscriptEl.appendChild(frag); 14971 this._updateVoiceGhost(); 14972 this.voiceTranscriptEl.scrollTop = this.voiceTranscriptEl.scrollHeight; 14973 }; 14974 14975 /* Update only the streaming assistant bubble (no DOM rebuild) */ 14976 CafChat.prototype._updateVoiceStreamingBubble = function (content) { 14977 // Recover reference if DOM was rebuilt (e.g. by _renderVoiceTranscript) 14978 if (!this._voiceStreamingBubble && this.voiceTranscriptEl) { 14979 var bubbles = this.voiceTranscriptEl.querySelectorAll(".caf-vt-bubble.assistant"); 14980 if (bubbles.length > 0) { 14981 this._voiceStreamingBubble = bubbles[bubbles.length - 1]; 14982 } 14983 } 14984 if (!this._voiceStreamingBubble) return; 14985 this._voiceStreamingBubble.innerHTML = renderMarkdown(content, this.opts.color); 14986 /* card NON renderizzate durante streaming (vedi nota in _updateStreamingBubble) */ 14987 var el = this.voiceTranscriptEl; 14988 if (el.scrollTop + el.clientHeight >= el.scrollHeight - 80) { 14989 el.scrollTop = el.scrollHeight; 14990 } 14991 }; 14992 14993 /* Update only the ghost bubble in-place (no DOM rebuild) */ 14994 CafChat.prototype._updateVoiceGhost = function () { 14995 if (!this.voiceTranscriptEl) return; 14996 var o = this.opts; 14997 14998 if (this._isListening && this._voiceInterimText) { 14999 if (!this._voiceGhostEl) { 15000 this._voiceGhostEl = document.createElement("div"); 15001 this._voiceGhostEl.className = "caf-vt-row user"; 15002 var gBbl = document.createElement("div"); 15003 gBbl.className = "caf-vt-ghost"; 15004 /* a11y #C54: sfondo a colore pieno + testo onC (niente alph
15004a) */ 15005 gBbl.style.background = "var(--caf-c)"; 15006 gBbl.style.color = "var(--caf-on-c)"; 15007 this._voiceGhostEl.appendChild(gBbl); 15008 this.voiceTranscriptEl.appendChild(this._voiceGhostEl); 15009 } 15010 this._voiceGhostEl.firstChild.textContent = this._voiceInterimText; 15011 this.voiceTranscriptEl.scrollTop = this.voiceTranscriptEl.scrollHeight; 15012 } else if (this._voiceGhostEl) { 15013 if (this._voiceGhostEl.parentNode) { 15014 this._voiceGhostEl.parentNode.removeChild(this._voiceGhostEl); 15015 } 15016 this._voiceGhostEl = null; 15017 } 15018 }; 15019 15020 /* a11y #C56 (WCAG 2.2.1): niente auto-hide â l'errore resta finche' 15021 * l'utente non lo chiude (X) o non invia un altro messaggio (_sendMessage 15022 * chiama _hideError). Nota: in caso di errore di invio viene rimossa solo 15023 * la bolla ASSISTENTE parziale; il messaggio utente resta in cronologia, 15024 * quindi il testo e' sempre recuperabile (nessun intervento necessario). */ 15025 CafChat.prototype._showError = function (msg) { 15026 var self = this; 15027 if (this._errorTimer) { clearTimeout(this._errorTimer); this._errorTimer = null; } 15028 this.errorEl.innerHTML = '<span aria-hidden="true">â ï¸ </span><span class="caf-sr-only">' + escapeHtml(bkT("label_error")) + ': </span>' + escapeHtml(String(msg)) + 15029 '<button type="button" class="caf-error-x" aria-label="' + escapeHtml(bkT("aria_close")) + '" style="background:transparent;border:none;color:inherit;font-size:15px;line-height:1;padding:2px 6px;margin-left:8px;cursor:pointer;vertical-align:middle;min-width:24px;min-height:24px">\u2715</button>'; 15030 var xb = this.errorEl.querySelector(".caf-error-x"); 15031 if (xb) xb.addEventListener("click", function () { 15032 self._hideError(); 15033 try { if (self.inputEl) self.inputEl.focus(); } catch (_e) {} 15034 }); 15035 this.errorEl.style.display = "block"; 15036 }; 15037 15038 CafChat.prototype._hideError = function () { 15039 this.errorEl.style.display = "none"; 15040 }; 15041 15042 /* ââ public API âââââââââââââââââââââââââââââââââââââââââââââ */ 15043 var instance = null; 15044 15045 /* Garantisce che il widget sia inizializzato. Ritorna l'istanza, oppure 15046 * null se la pagina è bloccata o manca data-base-url (no-op pulito). */ 15047 function ensureInstance() { 15048 if (!instance) { 15049 try { window.CafChatInline.init(); } catch (_e) {} 15050 } 15051 return instance; 15052 } 15053 15054 window.CafChatInline = { 15055 init: function (userOpts) { 15056 if (instance) return instance; 15057 /* Blocco esplicito: su queste pagine il widget non parte mai. */ 15058 if (isWidgetBlockedPage()) return null; 15059 var opts = resolveOpts(userOpts); 15060 if (!opts.baseUrl) { 15061 console.error("CafChatInline: data-base-url is required."); 15062 return null; 15063 } 15064 try { 15065 instance = new CafChat(opts); 15066 /* ââ record widget load stat (fire-and-forget) ââ */ 15067 if (!opts.privacy) { 15068 fetch(opts.baseUrl + "/api/stats", { method: "POST" }).catch(function () {}); 15069 } 15070 } catch(e) { 15071 console.error("CafChatInline init error:", e); 15072 } 15073 return instance; 15074 }, 15075 open: function () { 15076 var inst = ensureInstance(); 15077 if (inst) inst.open(); 15078 else console.warn("CafChatInline: widget non disponibile su questa pagina."); 15079 }, 15080 openBooking: function () { 15081 var inst = ensureInstance(); 15082 if (inst) inst.openBooking(); 15083 else console.warn("CafChatInline: widget non disponibile su questa pagina."); 15084 }, 15085 close: function () { 15086 if (instance) instance.close(); 15087 }, 15088 }; 15089 15090 /* auto-init when data-base-url is provided on the script tag */ 15091 var detectedBaseUrl = getAttr("base-url"); 15092 if (detectedBaseUrl) { 15093 if (document.readyState === "loading") { 15094 document.addEventListener("DOMContentLoaded", function () { 15095 window.CafChatInline.init(); 15096 }); 15097 } else { 15098 window.CafChatInline.init(); 15099 } 15100 } 15101 15102 /* ââ Host-page triggers: bottoni del sito con data-caf-action ââââââââââ 15103 * Event delegation su document (capture). Funziona anche per bottoni 15104 * iniettati dopo l'init (CMS / contenuti dinamici). Valori: 15105 * data-caf-action="open" â apre la chat (benvenuto) 15106 * data-caf-action="book" â apre il wizard di prenotazione 15107 * Il lazy-init dell'API gestisce il caso "click prima dell'init". */ 15108 function bindHostTriggers() { 15109 if (window.__CAF_TRIGGERS_BOUND__) return; 15110 window.__CAF_TRIGGERS_BOUND__ = true; 15111 document.addEventListener("click", function (e) { 15112 var t = e.target; 15113 if (!t || !t.closest) return; 15114 var el = t.closest("[data-caf-action]"); 15115 if (!el) return; 15116 var action = (el.getAttribute("data-caf-action") || "").trim().toLowerCase(); 15117 if (action !== "open" && action !== "book") return; 15118 /* Evita la navigazione se il click è su (o dentro) un <a>, anche quando 15119 * l'elemento con data-caf-action è un contenitore non-anchor 15120 * (es. <div data-caf-action="book"><a href=...>). */ 15121 if (el.tagName === "A" || t.closest("a")) e.preventDefault();
15122 try { 15123 if (action === "book") window.CafChatInline.openBooking(); 15124 else window.CafChatInline.open(); 15125 } catch (_e) {} 15126 }, true); 15127 } 15128 /* Non agganciare i trigger sulle pagine dove il widget è disabilitato 15129 * (isWidgetBlockedPage): lì init() torna comunque null. */ 15130 if (!isWidgetBlockedPage()) bindHostTriggers(); 15131})();
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.