PageSourceSearch

https://babysbestfood.com/wp-content/plugins/woo-ai-toolkit/includes/modules/chatbot/assets/chat.js?ver=0.4.9

js babysbestfood.com collected 2026-09-30 12:23:31 UTC 30,727 bytes, 542 lines download raw bytes

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(/&amp;/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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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.