1/* BBF Chat â Paula v2.0 â tool calling */ 2(function () { 3 'use strict'; 4 5 const cfg = window.WAT_CHAT || {}; 6 let strings = cfg.strings || {}; 7 const t = key => strings[key] || ''; 8 const ajaxUrl = new URL(cfg.ajax_url, window.location.href); 9 if (cfg.lang) ajaxUrl.searchParams.set('lang', cfg.lang); 10 11 const root = document.getElementById('bbf-chat-root'); 12 if (!root) return; 13 let loggedIn = false, userName = '', identity = '', greeting = ''; 14 let loginReady = null; 15 async function refreshIdentity() { 16 const r = await fetch((() => { const url = new URL(ajaxUrl); url.searchParams.set('action', 'wat_chat_bootstrap'); url.searchParams.set('_', Date.now()); return url; })(), {credentials: 'same-origin', cache: 'no-store'}); 17 const d = await r.json(); 18 if (!d.success || !d.data.nonce) throw new Error('Chat unavailable'); 19 const next = d.data; 20 if (identity && identity !== next.identity) resetConversation(); 21 identity = next.identity; 22 loggedIn = !!next.is_logged_in; 23 userName = next.user_name || ''; 24 greeting = next.greeting || ''; 25 cfg.nonce = next.nonce; 26 cfg.userEmail = next.user_email || ''; 27 cfg.supportEmail = next.support_email || '[email protected]'; 28 strings = next.strings || strings; 29 } 30 31 const trigger = document.getElementById('bbf-chat-trigger'); 32 const window_ = document.getElementById('bbf-chat-window'); 33 const messages = document.getElementById('bbf-chat-messages'); 34 const chatBody = document.getElementById('bbf-chat-body') || messages; 35 const qrWrap = document.getElementById('bbf-quick-replies'); 36 const input = document.getElementById('bbf-chat-input'); 37 const sendBtn = document.getElementById('bbf-chat-send'); 38 const closeBtn = document.getElementById('bbf-chat-close'); 39 const iconClosed = trigger.querySelector('.bbf-icon-closed'); 40 const iconOpen = trigger.querySelector('.bbf-icon-open'); 41 42 let returnFocus = null, serviceMode = false; 43 const servicePanel = document.createElement('div'); 44 servicePanel.className = 'bbf-service-panel'; 45 servicePanel.hidden = true; 46 chatBody.appendChild(servicePanel); 47 let isOpen = false, isTyping = false, history = [], greeted = false; 48 49 const storageKey = 'wat_chat_session_v1'; 50 const language = cfg.lang || document.documentElement.lang || 'en'; 51 let transcript = [], restoring = false, pending = ''; 52 let initialized = false; 53 function resetConversation() { 54 history = []; transcript = []; pending = ''; greeted = false; 55 servicePanel.replaceChildren(); servicePanel.hidden = true; serviceMode = false; 56 messages.textContent = ''; clearQR(); input.value = ''; hideTyping(); 57 try { sessionStorage.removeItem(storageKey); } catch (e) { /* Storage may be disabled. */ } 58 } 59 function saveConversation() { 60 if (!identity || restoring || !initialized) return; 61 try { 62 sessionStorage.setItem(storageKey, JSON.stringify({ 63 version: 1, language, identity, updated: Date.now(), open: isOpen, 64 greeted, transcript: transcript.slice(-100), history: history.slice(-16), 65 draft: input.value.slice(0, 600), pending 66 })); 67 } catch (e) { /* Chat still works when browser storage is unavailable. */ } 68 } 69 function remember(entry) { 70 if (restoring) return; 71 transcript.push(entry); transcript = transcript.slice(-100); 72 saveConversation(); 73 } 74 async function initialize() { 75 await refreshIdentity(); 76 let saved = null; 77 try { 78 const raw = sessionStorage.getItem(storageKey); 79 if (raw && raw.length < 500000) saved = JSON.parse(raw); 80 } catch (e) { /* Ignore invalid or unavailable storage. */ } 81 initialized = true; 82 if (!saved || saved.version !== 1 || saved.language !== language || saved.identity !== identity || 83 !Number.isFinite(saved.updated) || Date.now() - saved.updated > 4 * 60 * 60 * 1000 || 84 !Array.isArray(saved.transcript) || !Array.isArray(saved.history)) { 85 resetConversation(); return; 86 } 87 restoring = true;
88 history = saved.history.filter(x => x && ['user', 'assistant'].includes(x.role) && typeof x.content === 'string') 89 .slice(-16).map(x => ({role: x.role, content: x.content.slice(0, 4000)})); 90 transcript = saved.transcript.filter(x => x && ['user', 'bot', 'products'].includes(x.type) && typeof x.text === 'string' && ![t('language_hint'), 'PS: Ich spreche auch andere Sprachen. Schreib einfach in deiner bevorzugten Sprache. ð', 'PS: I also speak other languages. Just write in your preferred language. ð'].includes(x.text)).slice(-100); 91 for (const entry of transcript) { 92 if (entry.type === 'user') appendUser(entry.text); 93 else if (entry.type === 'products') appendProducts(entry.text, Array.isArray(entry.products) ? entry.products : []); 94 else appendBot(entry.text); 95 } 96 greeted = !!saved.greeted && transcript.length > 0; 97 input.value = typeof saved.pending === 'string' && saved.pending ? saved.pending.slice(0, 600) : (typeof saved.draft === 'string' ? saved.draft.slice(0, 600) : ''); 98 restoring = false; 99 renderQuickActions(); 100 if (saved.open && !isOpen) openChat(false); 101 saveConversation(); 102 } 103 function ensureReady() { 104 if (!loginReady) loginReady = initialize().catch(error => { loginReady = null; throw error; }); 105 return loginReady; 106 } 107 108 // ââ Open / Close ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 109 function openChat(focus = true) { 110 if (isOpen) return; 111 returnFocus = document.activeElement; 112 isOpen = true; 113 document.documentElement.classList.add('wat-chat-open'); 114 window_.hidden = false; 115 trigger.setAttribute('aria-expanded', 'true'); 116 iconClosed.style.display = 'none'; 117 iconOpen.style.display = 'flex'; 118 window.dispatchEvent(new CustomEvent('wat:chat:state', {detail: {open: true}})); 119 ensureReady().then(() => { 120 if (!isOpen) return; 121 if (focus) input.focus(); 122 if (!greeted) { greeted = true; showGreeting(); } 123 saveConversation(); 124 }).catch(() => { if (isOpen) appendBot(t('unavailable')); }); 125 } 126 127 function closeChat() { 128 isOpen = false; 129 document.documentElement.classList.remove('wat-chat-open'); 130 window_.hidden = true; 131 trigger.setAttribute('aria-expanded', 'false'); 132 iconClosed.style.display = 'flex'; 133 iconOpen.style.display = 'none'; 134 const focusTarget = returnFocus && returnFocus.isConnected && returnFocus.getClientRects().length ? returnFocus : trigger; 135 if (focusTarget.getClientRects().length) focusTarget.focus(); 136 window.dispatchEvent(new CustomEvent('wat:chat:state', {detail: {open: false}})); 137 saveConversation(); 138 } 139 140 trigger.addEventListener('click', () => isOpen ? closeChat() : openChat()); 141 closeBtn.addEventListener('click', closeChat); 142 document.addEventListener('keydown', e => { if (e.key === 'Escape' && isOpen) closeChat(); }); 143 144 // ââ Greeting ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 145 function showGreeting() { 146 appendBot(greeting.replace(/\{name\}/g, userName)); 147 renderQuickActions(); 148 } 149 150 // ââ Messages ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 151 const PAULA_SVG = `<svg width="22" height="22" viewBox="0 0 28 28" fill="none"> 152 <circle cx="14" cy="14" r="14" fill="#eeeef8"/> 153 <ellipse cx="14" cy="9" rx="7" ry="5.5" fill="#787BAF" opacity=".9"/> 154 <ellipse cx="8" cy="11" rx="2.5" ry="3.5" fill="#787BAF" opacity=".9"/> 155 <ellipse cx="20" cy="11" rx="2.5" ry="3.5" fill="#787BAF" opacity=".9"/> 156 <circle cx="14" cy="15" r="6" fill="#FDDBB4"/> 157 <circle cx="11.8" cy="14" r="1.1" fill="#5a4a8a"/> 158 <circle cx="16.2" cy="14" r="1.1" fill="#5a4a8a"/> 159 <circle cx="12.2" cy="13.6" r=".4" fill="white"/> 160 <circle cx="16.6" cy="13.6" r=".4" fill="white"/> 161 <ellipse cx="9.5" cy="16" rx="1.8" ry="1" fill="#ffb0b0" opacity=".4"/> 162 <ellipse cx="18.5" cy="16" rx="1.8" ry="1" fill="#ffb0b0" opacity=".4"/> 163 <path d="M11.5 17.5c.7 1.2 4.3 1.2 5 0" stroke="#c07858" stroke-width="1" stroke-linecap="round"/> 164 </svg>`; 165 166 function appendBot(text) { 167 remember({type: 'bot', text: String(text)}); 168 const wrap = document.createElement('div'); 169 wrap.className = 'bbf-msg bbf-msg-bot'; 170 const av = document.createElement('div'); 171 av.className = 'bbf-msg-avatar'; 172 av.innerHTML = PAULA_SVG; 173 const bubble = document.createElement('div'); 174 bubble.className = 'bbf-bubble'; 175 bubble.innerHTML = renderText(text).replace(/\n/g, '<br>'); 176 wrap.append(av, bubble); 177 messages.appendChild(wrap); 178 scrollBottom(); 179 } 180 181 function appendUser(text) { 182 remember({type: 'user', text: String(text)}); 183 const wrap = document.createElement('div'); 184 wrap.className = 'bbf-msg bbf-msg-user'; 185 const bubble = document.createElement('div'); 186 bubble.className = 'bbf-bubble'; 187 bubble.textContent = text; 188 wrap.appendChild(bubble); 189 messages.appendChild(wrap); 190 scrollBottom(); 191 } 192 193 // ââ Typing indicator ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 194 let typingEl = null; 195 function showTyping() { 196 if (typingEl) return; 197 const wrap = document.createElement('div'); 198 wrap.className = 'bbf-msg bbf-msg-bot bbf-typing'; 199 const av = document.createElement('div'); 200 av.className = 'bbf-msg-avatar'; 201 av.innerHTML = PAULA_SVG; 202 const bubble = document.createElement('div'); 203 bubble.className = 'bbf-bubble'; 204 bubble.innerHTML = '<div class="bbf-typing-dots"><span></span><span></span><span></span></div>'; 205 wrap.append(av, bubble); 206 messages.appendChild(wrap); 207 typingEl = wrap; 208 scrollBottom(); 209 } 210 function hideTyping() { if (typingEl) { typingEl.remove(); typingEl = null; } } 211 212 // ââ Product cards âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 213 function appendProducts(intro, products) { 214 products = products.filter(p => p && typeof p === 'object').slice(0, 6).map(p => ({ 215 name: String(p.name || '').slice(0, 500), price: p.currency === (language.startsWith('de') ? 'EUR' : 'USD') ? String(p.price || '').slice(0, 200) : t('price_on_page'), 216 currency: p.currency || '', id: Number(p.id) || 0, restock: p.restock === true, 217 url: safeUrl(p.url), image: safeUrl(p.image) 218 })); 219 remember({type: 'products', text: String(intro), products}); 220 const wrap = document.createElement('div'); 221 wrap.className = 'bbf-products-wrap'; 222 const introEl = document.createElement('div'); 223 introEl.className = 'bbf-products-intro'; 224 introEl.innerHTML = renderText(intro); 225 const grid = document.createElement('div'); 226 grid.className = 'bbf-products-grid';
227 products.forEach(p => { 228 const a = document.createElement('a'); 229 a.className = 'bbf-product-card'; 230 a.href = safeUrl(p.url); a.rel = 'noopener'; 231 const img = document.createElement('img'); 232 img.src = safeUrl(p.image); img.alt = p.name; 233 img.className = 'bbf-product-img'; img.loading = 'lazy'; 234 const info = document.createElement('div'); 235 info.className = 'bbf-product-info'; 236 const name = document.createElement('div'); name.className = 'bbf-product-name'; name.textContent = decodeEntities(p.name); 237 const price = document.createElement('div'); price.className = 'bbf-product-price'; price.textContent = decodeEntities(p.price); 238 info.append(name, price); 239 a.append(img, info); 240 const item = document.createElement('div'); 241 item.className = 'bbf-product-item'; 242 item.appendChild(a); 243 if (p.restock && p.id) { 244 const notify = document.createElement('button'); 245 notify.type = 'button'; notify.className = 'bbf-restock-btn'; notify.textContent = t('restock_label'); 246 notify.addEventListener('click', () => openRestock(p)); 247 item.appendChild(notify); 248 } 249 grid.appendChild(item); 250 }); 251 wrap.append(introEl, grid); 252 messages.appendChild(wrap); 253 scrollBottom(); 254 } 255 256 // ââ Quick replies âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 257 function renderQuickActions() { 258 const keys = loggedIn ? ['orders', 'formula', 'shipping', 'returns'] : ['status', 'formula', 'goat', 'shipping']; 259 showQR(keys.map(key => ({label: t(key + '_label'), msg: t(key + '_message')}))); 260 const support = document.createElement('button'); 261 support.type = 'button'; support.className = 'bbf-quick-btn'; support.textContent = t('support_label'); 262 support.disabled = isTyping || serviceMode; support.addEventListener('click', openSupport); qrWrap.appendChild(support); 263 } 264 function showQR(list) { 265 clearQR(); 266 list.forEach(r => { 267 const btn = document.createElement('button'); 268 btn.type = 'button'; 269 btn.disabled = isTyping || serviceMode; 270 btn.className = 'bbf-quick-btn'; 271 btn.textContent = r.label; 272 btn.addEventListener('click', () => send(r.msg)); 273 qrWrap.appendChild(btn); 274 }); 275 } 276 function clearQR() { qrWrap.innerHTML = ''; } 277 278 // ââ Send ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 279 function send(text) { 280 text = (text || input.value).trim(); 281 if (!text || isTyping || serviceMode) return; 282 input.value = ''; 283 input.style.height = 'auto'; 284 callAPI(text); 285 } 286 287 async function callAPI(text) { 288 isTyping = true; 289 sendBtn.disabled = true; 290 qrWrap.querySelectorAll('button').forEach(button => { button.disabled = true; }); 291 showTyping(); 292 try { 293 // Refresh on every send: login/logout in another tab and expired cached nonces. 294 await ensureReady(); 295 await refreshIdentity(); 296 renderQuickActions(); 297 pending = text; 298 appendUser(text); 299 showTyping(); 300 const fd = new FormData(); 301 fd.append('action', 'wat_chat_message'); 302 fd.append('nonce', cfg.nonce); 303 if (cfg.lang) fd.append('lang', cfg.lang); 304 fd.append('message', text); 305 fd.append('history', JSON.stringify(history.slice(-16))); 306 const data = await postWithChallenge(fd); 307 if (!data.success) { appendBot(data.data?.message || t('unavailable')); return; } 308 const res = data.data;
309 if (res.type === 'products') appendProducts(res.message, res.products || []); 310 else appendBot(res.message); 311 history.push({role: 'user', content: text}, {role: 'assistant', content: res.message}); 312 history = history.slice(-16); 313 } catch (error) { 314 loginReady = null; 315 if (error.message === 'check_cancelled') input.value = text; 316 appendBot(t(error.message === 'check_cancelled' ? 'check_cancelled' : 'connection')); 317 } finally { 318 pending = ''; 319 saveConversation(); 320 hideTyping(); isTyping = false; sendBtn.disabled = false; 321 renderQuickActions(); 322 scrollBottom(); 323 } 324 } 325 326 327 // Chat links preserve the transcript, but the destination must start with the dialog closed. 328 function closeForLink(event) { 329 const link = event.target.closest('a[href]'); 330 if (link && safeUrl(link.href)) closeChat(); 331 } 332 messages.addEventListener('click', closeForLink); 333 messages.addEventListener('auxclick', closeForLink); 334 335 function formField(form, label, type, value = '') { 336 const wrapper = document.createElement('label'); 337 wrapper.textContent = label; 338 const field = document.createElement(type === 'textarea' ? 'textarea' : 'input'); 339 if (type !== 'textarea') field.type = type; 340 field.value = value; 341 wrapper.appendChild(field); form.appendChild(wrapper); 342 return field; 343 } 344 function formButton(form, label, handler, type = 'button') { 345 const button = document.createElement('button'); 346 button.type = type; button.textContent = label; 347 if (handler) button.addEventListener('click', handler); 348 form.appendChild(button); return button; 349 } 350 async function postWithChallenge(fd) { 351 while (true) { 352 const response = await fetch(ajaxUrl, {method: 'POST', body: fd, credentials: 'same-origin'}); 353 const result = await response.json(); 354 if (result.success || result.data?.code !== 'human_check') return result; 355 const answer = await humanCheck(result.data); 356 fd.set('challenge_id', result.data.challenge.id); 357 fd.set('challenge_answer', answer); 358 } 359 } 360 function humanCheck(data) { 361 return new Promise((resolve, reject) => { 362 const previous = Array.from(servicePanel.childNodes); 363 const wasHidden = servicePanel.hidden; 364 const form = document.createElement('form'); 365 const message = document.createElement('p'); message.textContent = data.message; 366 form.appendChild(message); 367 const answer = formField(form, data.challenge.question, 'text'); 368 answer.inputMode = 'numeric'; answer.pattern = '[0-9]+'; answer.required = true; 369 answer.setAttribute('aria-label', t('human_answer') + ': ' + data.challenge.question); 370 const cleanup = () => { servicePanel.replaceChildren(...previous); servicePanel.hidden = wasHidden; }; 371 formButton(form, t('continue'), null, 'submit'); 372 formButton(form, t('cancel'), () => { cleanup(); reject(new Error('check_cancelled')); }); 373 form.addEventListener('submit', event => { event.preventDefault(); cleanup(); resolve(answer.value.trim()); }); 374 servicePanel.replaceChildren(form); servicePanel.hidden = false; scrollBottom(); answer.focus(); 375 }); 376 } 377 async function openRestock(product) { 378 if (isTyping || serviceMode) return; 379 serviceMode = true; sendBtn.disabled = true; renderQuickActions(); 380 const end = () => { servicePanel.hidden = true; servicePanel.replaceChildren(); serviceMode = false; sendBtn.disabled = isTyping; renderQuickActions(); }; 381 const form = document.createElement('form'); 382 const heading = document.createElement('strong'); heading.textContent = product.name; form.appendChild(heading); 383 const status = document.createElement('p'); status.setAttribute('role', 'status'); form.appendChild(status); 384 servicePanel.replaceChildren(form); servicePanel.hidden = false; scrollBottom(); 385 try { 386 const previousIdentity = identity; 387 await refreshIdentity(); 388 if (identity !== previousIdentity) { end(); appendBot(t('security')); return; } 389 const request = new FormData(); 390 request.set('action', 'wat_chat_restock'); request.set('mode', 'options'); 391 request.set('product_id', String(product.id)); request.set('nonce', cfg.nonce); request.set('lang', language); 392 const result = await postWithChallenge(request); 393 if (!result.success) throw new Error(result.data?.message || t('restock_failed')); 394 if (result.data.login_required) throw new Error(t('restock_login')); 395 const label = document.createElement('label'); label.textContent = t('restock_variant'); 396 const select = document.createElement('select'); select.required = true; select.setAttribute('aria-label', t('restock_variant')); 397 result.data.options.forEach(option => { 398 const el = document.createElement('option');
398 el.value = String(option.id); el.textContent = option.name; select.appendChild(el); 399 }); 400 label.appendChild(select); form.appendChild(label); 401 const email = formField(form, t('support_email_label'), 'email', cfg.userEmail); 402 email.required = true; email.readOnly = loggedIn; 403 const consent = formField(form, t('restock_consent'), 'checkbox'); consent.required = true; 404 let accountConsent = null; 405 if (result.data.create_account) { accountConsent = formField(form, t('restock_account'), 'checkbox'); accountConsent.required = true; } 406 const submit = formButton(form, t('restock_label'), null, 'submit'); 407 const cancel = formButton(form, t('cancel'), end); 408 let sending = false; 409 form.addEventListener('submit', async event => { 410 event.preventDefault(); if (sending) return; 411 sending = true; submit.disabled = true; cancel.disabled = true; 412 try { 413 await refreshIdentity(); 414 if (identity !== previousIdentity) { end(); appendBot(t('security')); return; } 415 const fd = new FormData(); 416 fd.set('action', 'wat_chat_restock'); fd.set('mode', 'subscribe'); fd.set('nonce', cfg.nonce); fd.set('lang', language); 417 fd.set('product_id', String(product.id)); fd.set('variation_id', select.value); 418 fd.set('email', email.value); fd.set('consent', consent.checked ? '1' : '0'); 419 fd.set('account_consent', accountConsent?.checked ? '1' : '0'); 420 const response = await postWithChallenge(fd); 421 if (!response.success) { status.textContent = response.data?.message || t('restock_failed'); return; } 422 const chosen = select.selectedOptions[0].textContent; 423 end(); appendBot(chosen + '\n' + response.data.message); saveConversation(); 424 } catch (error) { status.textContent = t('restock_failed'); } 425 finally { sending = false; submit.disabled = false; cancel.disabled = false; } 426 }); 427 scrollBottom(); email.focus(); 428 } catch (error) { 429 status.textContent = error.message === 'check_cancelled' ? t('human_check') : error.message; 430 const link = document.createElement('a'); link.href = product.url; link.textContent = product.name; 431 link.addEventListener('click', () => { end(); closeChat(); }); form.appendChild(link); 432 formButton(form, t('cancel'), end); scrollBottom(); 433 } 434 } 435 436 function openSupport() { 437 if (isTyping || serviceMode) return; 438 serviceMode = true; sendBtn.disabled = true; renderQuickActions(); 439 const form = document.createElement('form'); 440 const title = document.createElement('strong'); title.textContent = t('support_title'); form.appendChild(title); 441 const name = formField(form, t('support_name'), 'text', userName); 442 name.maxLength = 150; name.readOnly = loggedIn; 443 const email = formField(form, t('support_email_label'), 'email', cfg.userEmail); 444 email.required = true; email.readOnly = loggedIn; 445 const lastUser = transcript.filter(entry => entry.type === 'user').slice(-1)[0]; 446 const question = formField(form, t('support_question'), 'textarea', input.value || lastUser?.text || ''); 447 question.required = true; question.maxLength = 4000; question.rows = 4; 448 const include = formField(form, t('support_history'), 'checkbox'); include.checked = false; 449 const consent = formField(form, t('support_consent') + ' ' + cfg.supportEmail, 'checkbox'); consent.required = true; 450 const status = document.createElement('p'); status.setAttribute('role', 'status'); form.appendChild(status); 451 const submit = formButton(form, t('support_submit'), null, 'submit'); 452 const end = () => { servicePanel.hidden = true; servicePanel.replaceChildren(); serviceMode = false; sendBtn.disabled = isTyping; renderQuickActions(); }; 453 const cancel = formButton(form, t('cancel'), end); 454 const bytes = crypto.getRandomValues(new Uint8Array(16)); 455 const requestId = Array.from(bytes, n => n.toString(16).padStart(2, '0')).join(''); 456 let sending = false;
457 form.addEventListener('submit', async event => { 458 event.preventDefault(); 459 if (sending) return; 460 sending = true; submit.disabled = true; cancel.disabled = true; 461 try { 462 const previousIdentity = identity; 463 await refreshIdentity(); 464 if (identity !== previousIdentity) { end(); appendBot(t('security')); return; } 465 const fd = new FormData(); 466 fd.set('action', 'wat_chat_support'); fd.set('nonce', cfg.nonce); 467 fd.set('lang', language); fd.set('name', name.value); fd.set('email', email.value); 468 fd.set('question', question.value); fd.set('consent', consent.checked ? '1' : '0'); 469 fd.set('include_history', include.checked ? '1' : '0'); fd.set('request_id', requestId); 470 fd.set('transcript', include.checked ? JSON.stringify(transcript.slice(-100).map(entry => ({type: entry.type, text: entry.text}))) : '[]'); 471 const result = await postWithChallenge(fd); 472 if (!result.success) { status.textContent = result.data?.message || t('support_failed'); return; } 473 end(); appendBot(result.data.message || t('support_sent')); saveConversation(); 474 } catch (error) { 475 status.textContent = error.message === 'check_cancelled' ? t('human_check') : t('support_failed'); 476 } finally { 477 sending = false; submit.disabled = false; cancel.disabled = false; 478 } 479 }); 480 servicePanel.replaceChildren(form); servicePanel.hidden = false; scrollBottom(); question.focus(); 481 } 482 483 // ââ Input âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 484 sendBtn.addEventListener('click', () => send()); 485 input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }); 486 input.addEventListener('input', () => { input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 100) + 'px'; saveConversation(); }); 487 488 function safeUrl(value) { 489 try { const u = new URL(String(value)); return ['https:', 'http:'].includes(u.protocol) ? u.href : ''; } 490 catch (e) { return ''; } 491 } 492 function decodeEntities(value) { 493 const el = document.createElement('textarea'); 494 el.innerHTML = escapeHtml(String(value || '')).replace(/&/g, '&'); 495 return el.value; 496 } 497 function renderText(s) { 498 // Build links as DOM nodes; never interpolate URLs or model text into HTML. 499 const container = document.createElement('span'); 500 const text = String(s || '').replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1 ($2)'); 501 const pattern = /https?:\/\/[^\s<>"']+/g; 502 let start = 0; 503 for (const match of text.matchAll(pattern)) { 504 container.appendChild(document.createTextNode(text.slice(start, match.index))); 505 const link = document.createElement('a'); 506 link.href = safeUrl(match[0]); link.textContent = match[0]; link.target = '_blank'; link.rel = 'noopener noreferrer'; 507 container.appendChild(link); start = match.index + match[0].length; 508 } 509 container.appendChild(document.createTextNode(text.slice(start))); 510 return container.innerHTML; 511 } 512 function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } 513 function scrollBottom() { requestAnimationFrame(() => { chatBody.scrollTop = chatBody.scrollHeight; }); } 514 515 window.addEventListener('pagehide', () => { 516 saveConversation(); 517 // Back/forward cache must not flash a previously authenticated conversation. 518 window_.hidden = true; 519 document.documentElement.classList.remove('wat-chat-open'); 520 }); 521 window.addEventListener('pageshow', event => { 522 if (!event.persisted) return; 523 initialized = false; loginReady = null; isOpen = false;
524 servicePanel.replaceChildren(); servicePanel.hidden = true; serviceMode = false; 525 history = []; transcript = []; greeted = false; pending = ''; 526 messages.textContent = ''; clearQR(); hideTyping(); 527 isTyping = false; sendBtn.disabled = false; 528 trigger.setAttribute('aria-expanded', 'false'); 529 iconClosed.style.display = 'flex'; iconOpen.style.display = 'none'; 530 ensureReady().catch(() => {}); 531 }); 532 // Public integration API for native app navigation. Opening twice never toggles closed. 533 window.WATChat = Object.freeze({ 534 open: () => openChat(), 535 close: () => closeChat(), 536 isOpen: () => isOpen 537 }); 538 window.dispatchEvent(new CustomEvent('wat:chat:ready')); 539 540 // Validate identity before displaying any restored customer data. 541 try { if (sessionStorage.getItem(storageKey)) ensureReady().catch(() => {}); } catch (e) { /* Optional storage. */ } 542})();
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.