PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.22
MxChat – AI Chatbot & Content Generation for WordPress v3.2.22
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
mxchat-basic / js / chat-script.js

chat-script.js in MxChat – AI Chatbot & Content Generation for WordPress 3.2.22, at js/chat-script.js

4,972 lines 209.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 jQuery(document).ready(function($) {
2
3 // Nonce refresh — v2 (plan-6a68c9).
4 //
5 // The widget no longer relies on a nonce embedded in inline cached HTML.
6 // Before each chat-send / stream-send / upload, we call the REST endpoint
7 // GET /wp-json/mxchat/v1/nonce and use the freshly-issued value. The
8 // endpoint creates the nonce with action `mxchat_chat_send`; the server-side
9 // verifier ALSO still accepts the legacy `mxchat_chat_nonce` action for a
10 // 30-day backwards-compat window so cached pages still in users' browsers
11 // (which carry the legacy inline-localized nonce) keep working.
12 //
13 // Cache: a single module-scoped slot. TTL 12h conservatively (WP nonces are
14 // 24h but we refetch at half-life so a freshly-cached-page user never sees
15 // a borderline-stale nonce).
16 var cachedFreshNonce = null;
17 var cachedFreshNonceFetchedAt = 0;
18 var NONCE_TTL_MS = 12 * 60 * 60 * 1000;
19 var nonceRefreshState = 'idle'; // 'idle' | 'pending' | 'done'
20 var nonceRefreshCallbacks = [];
21
22 function getRestNonceUrl() {
23 if (typeof mxchatChat !== 'undefined' && mxchatChat.rest_url) {
24 return mxchatChat.rest_url.replace(/\/+$/, '') + '/nonce';
25 }
26 // Fallback: derive from current origin if mxchatChat.rest_url isn't set.
27 return window.location.origin + '/wp-json/mxchat/v1/nonce';
28 }
29
30 function fetchFreshNonceFromRest() {
31 return fetch(getRestNonceUrl(), {
32 credentials: 'same-origin',
33 headers: { 'Accept': 'application/json' }
34 }).then(function (resp) {
35 if (!resp.ok) {
36 throw new Error('REST nonce fetch failed: ' + resp.status);
37 }
38 return resp.json();
39 }).then(function (data) {
40 if (data && data.nonce) {
41 return data.nonce;
42 }
43 throw new Error('REST nonce response had no nonce field.');
44 });
45 }
46
47 /**
48 * withFreshNonce(cb) — invoke cb() after ensuring mxchatChat.nonce is fresh.
49 * Tries REST endpoint first (cache-bypass design); falls back to the legacy
50 * admin-ajax refresh path if REST is unavailable. Idempotent — concurrent
51 * calls share the same in-flight refresh.
52 */
53 function withFreshNonce(callback) {
54 if (typeof mxchatChat === 'undefined') {
55 if (callback) callback();
56 return;
57 }
58 var now = Date.now();
59 if (cachedFreshNonce && (now - cachedFreshNonceFetchedAt) < NONCE_TTL_MS) {
60 mxchatChat.nonce = cachedFreshNonce;
61 if (callback) callback();
62 return;
63 }
64 if (callback) nonceRefreshCallbacks.push(callback);
65 if (nonceRefreshState === 'pending') return;
66 nonceRefreshState = 'pending';
67
68 var resolved = function (nonce) {
69 if (nonce) {
70 cachedFreshNonce = nonce;
71 cachedFreshNonceFetchedAt = Date.now();
72 mxchatChat.nonce = nonce;
73 }
74 nonceRefreshState = 'done';
75 var pending = nonceRefreshCallbacks;
76 nonceRefreshCallbacks = [];
77 pending.forEach(function (cb) { try { cb(); } catch (e) {} });
78 };
79
80 fetchFreshNonceFromRest()
81 .then(resolved)
82 .catch(function () {
83 // Fallback to the legacy admin-ajax refresh path (issued with the
84 // old action `mxchat_chat_nonce`; the server still accepts both
85 // during the compat window).
86 if (mxchatChat.ajax_url) {
87 $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' })
88 .done(function (res) {
89 if (res && res.success && res.data && res.data.nonce) {
90 resolved(res.data.nonce);
91 return;
92 }
93 resolved(null);
94 })
95 .fail(function () { resolved(null); });
96 } else {
97 resolved(null);
98 }
99 });
100 }
101
102 // Backwards-compat alias — every existing caller in this file (and any
103 // out-of-tree consumer that hit this internal API) keeps working unchanged.
104 function refreshNonceIfNeeded(callback) {
105 return withFreshNonce(callback);
106 }
107
108 // Dynamic-settings refresh (plan-32db95).
109 //
110 // Every widget setting ships inline in cached page HTML, so behind a
111 // full-page cache the site owner can't purge (host cache, CDN, the
112 // browser itself) a toggled setting looks broken until the cache turns
113 // over. Same distrust-cached-HTML reasoning as the per-request nonce:
114 // on the FIRST widget open per page load we ask the nonce endpoint for
115 // the current behavior-gate settings (?with_settings=1), merge them over
116 // mxchatChat, and rebuild the header menu. Colors are NOT refreshed —
117 // they're server-inline-styled, so a runtime swap would visibly flash.
118 // On any failure we keep the inline values silently (nonce-fallback
119 // posture). At most one request per page load, only if a widget opens.
120 var dynamicSettingsState = 'idle'; // 'idle' | 'pending' | 'done'
121
122 function mxchatRefreshDynamicSettings() {
123 if (dynamicSettingsState !== 'idle') return;
124 if (typeof mxchatChat === 'undefined') return;
125 dynamicSettingsState = 'pending';
126
127 var applied = function (data) {
128 dynamicSettingsState = 'done';
129 if (!data) return; // endpoint unavailable — inline values stand.
130 if (data.nonce) {
131 // Seed the nonce cache too: saves the first send's REST
132 // round-trip and keeps us under the endpoint's rate limit.
133 cachedFreshNonce = data.nonce;
134 cachedFreshNonceFetchedAt = Date.now();
135 mxchatChat.nonce = data.nonce;
136 }
137 if (data.settings && typeof data.settings === 'object') {
138 $.extend(mxchatChat, data.settings);
139 mxchatRebuildHeaderMenus();
140 }
141 };
142
143 fetch(getRestNonceUrl() + '?with_settings=1', {
144 credentials: 'same-origin',
145 headers: { 'Accept': 'application/json' }
146 }).then(function (resp) {
147 if (!resp.ok) throw new Error('settings refresh failed: ' + resp.status);
148 return resp.json();
149 }).then(applied).catch(function () {
150 // Fallback: legacy admin-ajax refresh path, same as withFreshNonce.
151 if (mxchatChat.ajax_url) {
152 $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce', with_settings: 1 })
153 .done(function (res) {
154 applied(res && res.success && res.data ? res.data : null);
155 })
156 .fail(function () { applied(null); });
157 } else {
158 applied(null);
159 }
160 });
161 }
162
163 // ====================================
164 // MULTI-INSTANCE MANAGEMENT SYSTEM
165 // ====================================
166
167 // Instance registry - tracks all chatbot instances on the page
168 const MxChatInstances = {
169 instances: {},
170
171 // Lifecycle facts add-ons can read after the fact (plan 65c9b6). The
172 // events below fire once; a script that loads late checks these instead.
173 toolbarReady: false,
174 opened: {},
175 hasOpened: function(botId) {
176 return !!this.opened[botId || 'default'];
177 },
178
179 // Initialize an instance for a bot
180 init: function(botId) {
181 if (!this.instances[botId]) {
182 // When persistence is OFF, track when this session started
183 // so the AI only sees messages from this page load
184 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
185
186 this.instances[botId] = {
187 botId: botId,
188 sessionId: null,
189 lastSeenMessageId: '',
190 notificationCheckInterval: null,
191 pollingInterval: null,
192 processedMessageIds: new Set(),
193 activePdfFile: null,
194 activeWordFile: null,
195 chatHistoryLoaded: false,
196 isStreaming: false,
197 // Fresh context timestamp - only used when persistence is OFF
198 sessionStartTimestamp: chatPersistenceEnabled ? 0 : Date.now()
199 };
200 }
201 return this.instances[botId];
202 },
203
204 // Get instance by botId
205 get: function(botId) {
206 return this.instances[botId] || this.init(botId);
207 },
208
209 // Get all active bot IDs
210 getAllBotIds: function() {
211 return Object.keys(this.instances);
212 },
213
214 // Session management per bot
215 // Returns existing session ID from cookie or localStorage (with in-memory fallback),
216 // or null if none exists. Does NOT create a new session — use ensureSession() for that.
217 getChatSession: function(botId) {
218 var cookieName = 'mxchat_session_id_' + botId;
219 var storageKey = 'mxchat_session_id_' + botId;
220 var sessionId = getCookie(cookieName);
221
222 // Fallback to localStorage if cookie is missing (e.g. cleared by browser/consent)
223 if (!sessionId) {
224 try { sessionId = localStorage.getItem(storageKey); } catch (e) {}
225 }
226
227 // Fallback to in-memory instance when cookie AND localStorage are both blocked
228 // (Safari ITP, strict tracking prevention, cross-origin iframes with partitioned
229 // storage). Without this, ensureSession() can generate and store an ID that
230 // getChatSession() then can't read back, causing null session_ids on send.
231 if (!sessionId && this.instances[botId] && this.instances[botId].sessionId) {
232 sessionId = this.instances[botId].sessionId;
233 }
234
235 // Guard against stored sentinel values that indicate earlier broken writes.
236 if (sessionId === 'null' || sessionId === 'undefined') {
237 sessionId = null;
238 }
239
240 // Re-sync cookie from localStorage if cookie was lost
241 if (sessionId && !getCookie(cookieName)) {
242 document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax";
243 }
244
245 return sessionId || null;
246 },
247
248 // Lazy session initializer — called on first user interaction
249 ensureSession: function(botId) {
250 botId = botId || 'default';
251 var instance = this.instances[botId] || this.init(botId);
252
253 if (instance.sessionId) {
254 return instance.sessionId;
255 }
256
257 // Check for existing session from cookie or localStorage
258 var existingSession = this.getChatSession(botId);
259
260 if (existingSession) {
261 instance.sessionId = existingSession;
262 } else {
263 // Brand new session
264 var newId = generateSessionId();
265 this.setChatSession(botId, newId);
266 instance.sessionId = newId;
267 }
268
269 // Now that we have a session, do the deferred work
270 refreshNonceIfNeeded();
271 trackOriginatingPage();
272
273 // Note: loadChatHistory is handled by showChatContainerForBot with loader UI,
274 // so we do NOT call it here to avoid a race condition.
275
276 return instance.sessionId;
277 },
278
279 setChatSession: function(botId, sessionId) {
280 var cookieName = 'mxchat_session_id_' + botId;
281 var storageKey = 'mxchat_session_id_' + botId;
282 document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax";
283 try { localStorage.setItem(storageKey, sessionId); } catch (e) {}
284 if (this.instances[botId]) {
285 this.instances[botId].sessionId = sessionId;
286 }
287 },
288
289 resetChatSession: function(botId) {
290 // Clear old session from localStorage before setting new one
291 try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {}
292 var newSessionId = generateSessionId();
293 this.setChatSession(botId, newSessionId);
294 var $chatBox = getElement(botId, 'chat-box');
295 if ($chatBox.length) {
296 // Keep the greeting, drop everything else. Identify the greeting
297 // by its marker class, NOT by position (plan a1a79b): after a
298 // chat-persistence restore the first .bot-message is a real
299 // reply, so ":not(:first)" left a stale answer sitting at the
300 // top of an otherwise empty box. The positional fallback only
301 // runs when the marker is absent — a page served from HTML cache
302 // that predates this release — and behaves exactly as before.
303 if ($chatBox.find('.mxchat-intro-message').length) {
304 $chatBox.find('.user-message, .bot-message:not(.mxchat-intro-message), .agent-message').remove();
305 } else {
306 $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove();
307 }
308 }
309 if (this.instances[botId]) {
310 this.instances[botId].chatHistoryLoaded = false;
311 this.instances[botId].processedMessageIds = new Set();
312 }
313 },
314
315 // Silent reset — new session ID without clearing the chat UI
316 // Used when IP changes mid-conversation so the user doesn't see messages vanish
317 silentResetSession: function(botId) {
318 try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {}
319 var newSessionId = generateSessionId();
320 this.setChatSession(botId, newSessionId);
321 if (this.instances[botId]) {
322 this.instances[botId].sessionId = newSessionId;
323 }
324 return newSessionId;
325 }
326 };
327
328 // ====================================
329 // LIFECYCLE EVENTS FOR ADD-ONS (plan 65c9b6)
330 // ====================================
331 //
332 // Document-level CustomEvents an add-on listens for instead of polling the
333 // DOM or firing requests on page load:
334 // mxchat:toolbar-ready detail { enabled } once, right after the
335 // toolbar show/hide pass; items rendered through the
336 // mxchat_chat_toolbar_items filter can bind now.
337 // mxchat:opened detail { botId, embedded } once per bot per page
338 // load, the first time its window is shown — the
339 // launcher, the pre-chat bubble, an embedded widget
340 // (at load), or any script that reveals it.
341 // Same facts, readable late: MxChatInstances.toolbarReady / hasOpened(botId).
342 function mxchatDispatch(name, detail) {
343 try {
344 document.dispatchEvent(new CustomEvent(name, { detail: detail || {} }));
345 } catch (err) { /* CustomEvent unsupported — nothing to notify */ }
346 }
347
348 function mxchatFireOpened(botId) {
349 botId = botId || 'default';
350 if (MxChatInstances.opened[botId]) return;
351 MxChatInstances.opened[botId] = true;
352 mxchatDispatch('mxchat:opened', { botId: botId, embedded: isEmbeddedBot(botId) });
353 }
354
355 // Catch openers this file does not own (the Trigger add-on, theme buttons,
356 // custom scripts): a floating window gaining .visible counts as opened.
357 function mxchatWatchForOpen(botId) {
358 var el = getElementDOM(botId, 'floating-chatbot');
359 if (!el || typeof MutationObserver === 'undefined') return;
360 if (el.classList.contains('visible')) { mxchatFireOpened(botId); return; }
361 var obs = new MutationObserver(function() {
362 if (el.classList.contains('visible')) {
363 mxchatFireOpened(botId);
364 obs.disconnect();
365 }
366 });
367 obs.observe(el, { attributes: true, attributeFilter: ['class'] });
368 }
369
370 // ====================================
371 // ELEMENT SELECTOR HELPERS
372 // ====================================
373
374 // Check if a specific bot has an AI theme assigned (skip inline colors)
375 function shouldSkipInlineColors(botId) {
376 // If global AI theme is active, skip inline colors for all bots
377 if (mxchatChat.skip_inline_colors) {
378 return true;
379 }
380 // Check if this specific bot has a theme assignment
381 var botAssignments = mxchatChat.bot_theme_assignments || {};
382 return botAssignments.hasOwnProperty(botId);
383 }
384
385 // Get element by ID with bot suffix - returns jQuery object
386 function getElement(botId, elementName) {
387 return $('#' + elementName + '-' + botId);
388 }
389
390 // Get element by ID with bot suffix - returns DOM element
391 function getElementDOM(botId, elementName) {
392 return document.getElementById(elementName + '-' + botId);
393 }
394
395 // Get bot ID from any element within a chatbot instance
396 function getBotIdFromElement(element) {
397 var $wrapper = $(element).closest('.mxchat-chatbot-wrapper');
398 if ($wrapper.length) {
399 return $wrapper.data('bot-id') || 'default';
400 }
401 // Fallback: try to find from floating container
402 var $floating = $(element).closest('.floating-chatbot');
403 if ($floating.length) {
404 var id = $floating.attr('id') || '';
405 var match = id.match(/floating-chatbot-(.+)/);
406 if (match) return match[1];
407 }
408 // Fallback: the pre-chat teaser bubble (#pre-chat-message-{bot_id}) is a SIBLING
409 // outside .mxchat-chatbot-wrapper / .floating-chatbot, so its children — e.g. the
410 // .close-pre-chat-message button, which carries only a class and no id — miss both
411 // branches above. Walk to the nearest ancestor whose id is pre-chat-message-{bot_id}
412 // and read the suffix. (closest() includes the element itself, so a click directly on
413 // #pre-chat-message-{bot_id} resolves here too.)
414 var $preChat = $(element).closest('[id^="pre-chat-message-"]');
415 if ($preChat.length) {
416 var preId = $preChat.attr('id') || '';
417 var preMatch = preId.match(/^pre-chat-message-(.+)$/);
418 if (preMatch) return preMatch[1];
419 }
420 // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
421 var elementId = $(element).attr('id') || '';
422 if (elementId) {
423 // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
424 var idMatch = elementId.match(/^(?:floating-chatbot-button|pre-chat-message|chat-notification-badge)-(.+)$/);
425 if (idMatch) return idMatch[1];
426 }
427 return 'default';
428 }
429
430 // Get wrapper element for a bot
431 function getWrapper(botId) {
432 return getElement(botId, 'mxchat-chatbot-wrapper');
433 }
434
435 // ====================================
436 // GLOBAL VARIABLES & CONFIGURATION
437 // ====================================
438 const toolbarIconColor = mxchatChat.toolbar_icon_color || '#212121';
439
440 // Initialize color settings (these are global as they come from PHP)
441 var userMessageBgColor = mxchatChat.user_message_bg_color;
442 var userMessageFontColor = mxchatChat.user_message_font_color;
443 var botMessageBgColor = mxchatChat.bot_message_bg_color;
444 var botMessageFontColor = mxchatChat.bot_message_font_color;
445 var liveAgentMessageBgColor = mxchatChat.live_agent_message_bg_color;
446 var liveAgentMessageFontColor = mxchatChat.live_agent_message_font_color;
447
448 var linkTarget = mxchatChat.link_target_toggle === 'on' ? '_blank' : '_self';
449
450 // ====================================
451 // SESSION MANAGEMENT (Legacy compatibility)
452 // ====================================
453
454 function getCookie(name) {
455 let value = "; " + document.cookie;
456 let parts = value.split("; " + name + "=");
457 if (parts.length == 2) return parts.pop().split(";").shift();
458 }
459
460 function generateSessionId() {
461 // Session IDs function as the de-facto bearer token for an anonymous
462 // chat, so generate them with a CSPRNG when available. Math.random is a
463 // legacy fallback for ancient/sandboxed environments that lack
464 // window.crypto. The 'mxchat_chat_' prefix is preserved exactly (other
465 // code pattern-matches on it). (plan-0c17b5)
466 var rand;
467 try {
468 if (window.crypto && window.crypto.getRandomValues) {
469 var buf = new Uint8Array(16); // 128 bits
470 window.crypto.getRandomValues(buf);
471 rand = Array.prototype.map.call(buf, function (b) {
472 return ('0' + b.toString(16)).slice(-2);
473 }).join('');
474 }
475 } catch (e) {}
476 if (!rand) {
477 rand = Math.random().toString(36).substr(2, 9); // legacy fallback
478 }
479 return 'mxchat_chat_' + rand;
480 }
481
482 // Legacy function - now delegates to instance manager
483 function getChatSession(botId) {
484 botId = botId || 'default';
485 return MxChatInstances.getChatSession(botId);
486 }
487
488 function setChatSession(sessionId, botId) {
489 botId = botId || 'default';
490 MxChatInstances.setChatSession(botId, sessionId);
491 }
492
493 function resetChatSession(botId) {
494 botId = botId || 'default';
495 MxChatInstances.resetChatSession(botId);
496 }
497
498 // ====================================
499 // INITIALIZE ALL CHATBOT INSTANCES
500 // ====================================
501
502 function initializeAllInstances() {
503 // Find all chatbot wrappers on the page
504 $('.mxchat-chatbot-wrapper').each(function() {
505 var botId = $(this).data('bot-id') || 'default';
506 MxChatInstances.init(botId);
507 initializeBotInstance(botId);
508 });
509 }
510
511 function initializeBotInstance(botId) {
512 var instance = MxChatInstances.get(botId);
513
514 // Initialize quick questions state for this bot
515 checkQuickQuestionsState(botId);
516
517 // Note: Event handlers use event delegation with class selectors,
518 // so they work automatically for all instances without per-bot setup
519 }
520
521 // ====================================
522 // CONTEXTUAL AWARENESS FUNCTIONALITY
523 // ====================================
524
525 // Plan f3e515: the page context is computed once per page, not once per message.
526 // Keyed on URL + title + the content element's text length — cheap to check,
527 // and enough to notice a navigation or a re-rendered content area.
528 var pageContextCache = { key: null, value: null };
529
530 function getPageContext() {
531 // Check if contextual awareness is enabled
532 if (mxchatChat.contextual_awareness_toggle !== 'on') {
533 return null;
534 }
535
536 // Get page URL
537 const pageUrl = window.location.href;
538
539 // Get page title
540 const pageTitle = document.title || '';
541
542 // Get main content from the page
543 let pageContent = '';
544
545 // Try to get content from common content areas
546 const contentSelectors = [
547 'main',
548 '[role="main"]',
549 '.content',
550 '.main-content',
551 '.post-content',
552 '.entry-content',
553 '.page-content',
554 'article',
555 '#content',
556 '#main'
557 ];
558
559 let contentElement = null;
560 for (const selector of contentSelectors) {
561 contentElement = document.querySelector(selector);
562 if (contentElement) {
563 break;
564 }
565 }
566
567 // If no specific content area found, use body but exclude header, footer, nav, sidebar
568 if (!contentElement) {
569 contentElement = document.body;
570 }
571
572 if (contentElement) {
573 const cacheKey = pageUrl + '\u0000' + pageTitle + '\u0000' + (contentElement.textContent || '').length;
574 if (pageContextCache.key === cacheKey) {
575 return pageContextCache.value;
576 }
577
578 // Copy the element into a document that has no browsing context
579 // (plan f3e515). A live cloneNode(true) constructed real <video> /
580 // <audio> / <img> elements: media with a src runs resource selection
581 // on creation whether or not it is attached, and an autoplay attribute
582 // made the detached copy PLAY — audibly, once the visible player had
583 // been unmuted through the property. Every message spawned another
584 // one. Elements in an inert document never fetch or play, while
585 // querySelectorAll / textContent / dataset all still work on them.
586 const inertDocument = document.implementation.createHTMLDocument('');
587 const clone = inertDocument.importNode(contentElement, true);
588
589 // Remove unwanted elements
590 const unwantedSelectors = [
591 'header',
592 'footer',
593 'nav',
594 '.navigation',
595 '.sidebar',
596 '.widget',
597 '.menu',
598 'script',
599 'style',
600 '.comments',
601 '#comments',
602 '.breadcrumb',
603 '.breadcrumbs',
604 '#floating-chatbot',
605 '#floating-chatbot-button',
606 '.mxchat',
607 '[class*="chat"]',
608 '[id*="chat"]',
609 // Media contributes no text and (outside the inert document) fetches.
610 'video',
611 'audio',
612 'iframe',
613 'object',
614 'embed',
615 'picture',
616 'img',
617 'source'
618 ];
619
620 unwantedSelectors.forEach(selector => {
621 const elements = clone.querySelectorAll(selector);
622 elements.forEach(el => el.remove());
623 });
624
625 // Extract MxChat context data attributes before getting text content
626 const contextData = [];
627 clone.querySelectorAll('[data-mxchat-context]').forEach(el => {
628 const contextValue = el.dataset.mxchatContext;
629 if (contextValue && contextValue.trim()) {
630 contextData.push(contextValue);
631 }
632 });
633
634 // Get text content and clean it up
635 pageContent = clone.textContent || clone.innerText || '';
636
637 // Add context data to page content if any were found
638 if (contextData.length > 0) {
639 pageContent += '\n\nAdditional Context:\n' + contextData.join('\n');
640 }
641
642 // Clean up whitespace and limit length
643 pageContent = pageContent
644 .replace(/\s+/g, ' ')
645 .trim()
646 .substring(0, 3000); // Limit to 3000 characters to avoid token limits
647
648 // Only return context if we have meaningful content
649 const result = (!pageContent || pageContent.length < 50) ? null : {
650 url: pageUrl,
651 title: pageTitle,
652 content: pageContent
653 };
654 pageContextCache = { key: cacheKey, value: result };
655 return result;
656 }
657
658 return null;
659 }
660
661 // Track originating page when chat starts
662 function trackOriginatingPage() {
663 const sessionId = getChatSession();
664 const pageUrl = window.location.href;
665 const pageTitle = document.title || 'Untitled Page';
666
667 // Only track once per session
668 const trackingKey = 'mxchat_originating_tracked_' + sessionId;
669 if (sessionStorage.getItem(trackingKey)) {
670 return;
671 }
672
673 $.ajax({
674 url: mxchatChat.ajax_url,
675 type: 'POST',
676 data: {
677 action: 'mxchat_track_originating_page',
678 session_id: sessionId,
679 page_url: pageUrl,
680 page_title: pageTitle,
681 nonce: mxchatChat.nonce
682 },
683 success: function(response) {
684 if (response.success) {
685 sessionStorage.setItem(trackingKey, 'true');
686 }
687 }
688 });
689 }
690
691 // ====================================
692 // CORE CHAT FUNCTIONALITY
693 // ====================================
694
695 // Helper functions to disable/enable chat input while waiting for response
696 function disableChatInput(botId) {
697 botId = botId || 'default';
698 var chatInput = getElementDOM(botId, 'chat-input');
699 var sendButton = getElementDOM(botId, 'send-button');
700 if (chatInput) {
701 chatInput.disabled = true;
702 chatInput.style.opacity = '0.6';
703 }
704 if (sendButton) {
705 sendButton.disabled = true;
706 sendButton.style.opacity = '0.5';
707 sendButton.style.pointerEvents = 'none';
708 }
709 }
710
711 // Whether the input may grab focus after a completed reply (plan 03799f).
712 // On coarse-pointer devices focusing a text input summons the on-screen
713 // keyboard over the answer the visitor is trying to read, so 'auto' (the
714 // default) focuses only on fine-pointer devices. The site-wide
715 // mxchat_autofocus_after_reply PHP filter can force 'on'/'off'.
716 // NOT used on widget open (:~3424) — that focus is a deliberate act and is
717 // what makes the widget keyboard-accessible.
718 function mxchatShouldAutofocusAfterReply() {
719 var pref = (typeof mxchatChat !== 'undefined' && mxchatChat.autofocus_after_reply) || 'auto';
720 if (pref === 'on') return true;
721 if (pref === 'off') return false;
722 try {
723 return !window.matchMedia('(pointer: coarse)').matches;
724 } catch (err) {
725 return true;
726 }
727 }
728
729 function enableChatInput(botId) {
730 botId = botId || 'default';
731 var chatInput = getElementDOM(botId, 'chat-input');
732 var sendButton = getElementDOM(botId, 'send-button');
733 if (chatInput) {
734 chatInput.disabled = false;
735 chatInput.style.opacity = '1';
736 if (mxchatShouldAutofocusAfterReply()) {
737 try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
738 }
739 }
740 if (sendButton) {
741 sendButton.disabled = false;
742 sendButton.style.opacity = '1';
743 sendButton.style.pointerEvents = 'auto';
744 }
745 // Every completion path re-enables input, so this is the single restore
746 // point for the streaming Stop affordance (no-op when not in stop mode).
747 mxchatRestoreSendButton(botId);
748 }
749
750 // --- Streaming Stop control -------------------------------------------------
751 // One live stream handle per bot instance, so Stop on one widget never aborts
752 // another bot on the same page.
753 var mxchatActiveStreams = {};
754 // Original send-button markup, captured once per bot the first time the Stop
755 // state is shown (never captured while already in stop mode, so a rapid
756 // stop-then-resend can't save the stop glyph as the "original").
757 var mxchatSendMarkup = {};
758
759 function mxchatShowStopButton(botId) {
760 var btn = getElementDOM(botId, 'send-button');
761 if (!btn) return;
762 if (!btn.classList.contains('mxchat-stop-mode')) {
763 mxchatSendMarkup[botId] = {
764 html: btn.innerHTML,
765 label: btn.getAttribute('aria-label')
766 };
767 }
768
769 // Mirror the send icon's rendered size + color so the stop glyph looks
770 // native, including custom send images/colors and theme overrides.
771 var child = btn.querySelector('svg, img');
772 var size = 25;
773 var color = '';
774 if (child) {
775 var rect = child.getBoundingClientRect();
776 if (rect.width) {
777 size = Math.round(Math.min(rect.width, rect.height));
778 }
779 var cs = window.getComputedStyle(child);
780 color = (child.tagName.toLowerCase() === 'svg' ? cs.fill : cs.color) || '';
781 }
782 var stopLabel = (typeof mxchatChat !== 'undefined' && mxchatChat.stop_button_label) || 'Stop response';
783 btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" style="width:' + size + 'px;height:' + size + 'px;' + (color ? 'fill:' + color + ';' : '') + '"><rect x="5" y="5" width="14" height="14" rx="3"></rect></svg>';
784 // An add-on's DIRECT send-button handler (e.g. mxchat-vision's rebind) can
785 // start a stream synchronously while the originating click is still
786 // bubbling up to our delegated handler. Without this guard, that handler
787 // reads the just-added stop-mode class as a user Stop press and aborts the
788 // brand-new stream — the user's message renders but no reply ever fires
789 // (plan-4bba64 silent message loss). The flag only spans the current event
790 // dispatch: cleared on the next macrotask, long before a real Stop click.
791 btn.__mxchatStopJustShown = true;
792 setTimeout(function () { btn.__mxchatStopJustShown = false; }, 0);
793 btn.classList.add('mxchat-stop-mode');
794 btn.setAttribute('aria-label', stopLabel);
795 btn.setAttribute('title', stopLabel);
796 // disableChatInput() ran when the turn was sent; the Stop control itself
797 // must stay clickable while the textarea remains disabled.
798 btn.disabled = false;
799 btn.style.opacity = '1';
800 btn.style.pointerEvents = 'auto';
801 }
802
803 function mxchatRestoreSendButton(botId) {
804 var btn = getElementDOM(botId, 'send-button');
805 var saved = mxchatSendMarkup[botId];
806 if (!btn || !btn.classList.contains('mxchat-stop-mode') || !saved) return;
807 btn.innerHTML = saved.html;
808 btn.classList.remove('mxchat-stop-mode');
809 btn.removeAttribute('title');
810 if (saved.label) {
811 btn.setAttribute('aria-label', saved.label);
812 }
813 }
814
815 function mxchatStopStreaming(botId) {
816 var entry = mxchatActiveStreams[botId];
817 if (!entry || !entry.controller) return;
818 entry.aborted = true;
819 try { entry.controller.abort(); } catch (e) {}
820 }
821
822 // Returns true when a stream rejection came from an intentional Stop click:
823 // keep the partial text as the turn's answer — no error UI, no fallback resend.
824 function mxchatHandleStreamAbort(botId, accumulatedContent, callback) {
825 var entry = mxchatActiveStreams[botId];
826 if (!entry || !entry.aborted) return false;
827 delete mxchatActiveStreams[botId];
828 if (!accumulatedContent) {
829 // Stopped before the first chunk: drop the thinking bubble, no orphan message.
830 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
831 }
832 enableChatInput(botId); // also restores the send icon
833 if (callback) {
834 callback(accumulatedContent || '');
835 }
836 return true;
837 }
838
839 // Update your existing sendMessage function
840 function sendMessage(botId) {
841 botId = botId || 'default';
842 MxChatInstances.ensureSession(botId);
843 var $chatInput = getElement(botId, 'chat-input');
844 var message = $chatInput.val();
845
846 // ADD PROMPT HOOK HERE
847 if (typeof customMxChatFilter === 'function') {
848 message = customMxChatFilter(message, "prompt");
849 }
850
851 if (message) {
852 // Don't disable input in live agent mode - let users chat freely
853 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
854 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
855 if (!isAgentMode) {
856 disableChatInput(botId);
857 }
858
859 appendMessage("user", message, '', [], false, botId);
860 $chatInput.val('');
861 mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
862 $chatInput.css('height', 'auto');
863
864 if (hasQuickQuestions(botId)) {
865 collapseQuickQuestions(botId);
866 }
867 appendThinkingMessage(botId);
868 scrollToBottom(botId);
869
870 const currentModel = mxchatChat.model || 'gpt-5.6-sol';
871
872 // Check if streaming is enabled AND supported for this model
873 if (shouldUseStreaming(currentModel)) {
874 callMxChatStream(message, function(response) {
875 // Content is final: releasing aria-busy lets the live region
876 // announce the completed reply once (plan 67f126).
877 getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
878 }, botId);
879 } else {
880 callMxChat(message, function(response) {
881 replaceLastMessage("bot", response, '', [], botId);
882 }, botId);
883 }
884 }
885 }
886
887 // Update your existing sendMessageToChatbot function
888 function sendMessageToChatbot(message, botId) {
889 botId = botId || 'default';
890 MxChatInstances.ensureSession(botId);
891
892 // ADD PROMPT HOOK HERE
893 if (typeof customMxChatFilter === 'function') {
894 message = customMxChatFilter(message, "prompt");
895 }
896
897 // Don't disable input in live agent mode - let users chat freely
898 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
899 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
900 if (!isAgentMode) {
901 disableChatInput(botId);
902 }
903
904 var sessionId = getChatSession(botId);
905
906 if (hasQuickQuestions(botId)) {
907 collapseQuickQuestions(botId);
908 }
909 appendThinkingMessage(botId);
910 scrollToBottom(botId);
911
912 const currentModel = mxchatChat.model || 'gpt-5.6-sol';
913
914 // Check if streaming is enabled AND supported for this model
915 if (shouldUseStreaming(currentModel)) {
916 callMxChatStream(message, function(response) {
917 // Final content — release aria-busy so the reply announces once (67f126).
918 getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
919 }, botId);
920 } else {
921 callMxChat(message, function(response) {
922 getElement(botId, 'chat-box').find('.temporary-message').remove();
923 replaceLastMessage("bot", response, '', [], botId);
924 }, botId);
925 }
926 }
927
928 // Updated shouldUseStreaming function with debugging
929 function shouldUseStreaming(model) {
930 // Check if streaming is enabled in settings (using your toggle naming pattern)
931 const streamingEnabled = mxchatChat.enable_streaming_toggle === 'on';
932
933 // Check if model supports streaming
934 const streamingSupported = isStreamingSupported(model);
935
936
937 // Only use streaming if both enabled and supported
938 return streamingEnabled && streamingSupported;
939 }
940
941 // Helper function to handle chat mode updates
942 function handleChatModeUpdates(response, responseText) {
943 // Check for explicit chat mode in response (THIS IS THE KEY FIX)
944 if (response.chat_mode) {
945 updateChatModeIndicator(response.chat_mode);
946 return; // Return early since we found explicit mode
947 }
948 // Check for fallback response chat mode
949 else if (response.fallbackResponse && response.fallbackResponse.chat_mode) {
950 updateChatModeIndicator(response.fallbackResponse.chat_mode);
951 return; // Return early since we found explicit mode
952 }
953
954 // Only do text-based detection if no explicit mode was provided
955 // Check for specific AI chatbot response text
956 if (responseText === 'You are now chatting with the AI chatbot.' ||
957 responseText.includes('now chatting with the AI') ||
958 responseText.includes('switched to AI mode') ||
959 responseText.includes('AI chatbot is now')) {
960 updateChatModeIndicator('ai');
961 }
962 // Check for agent transfer messages
963 else if (responseText.includes('agent') &&
964 (responseText.includes('transfer') || responseText.includes('connected'))) {
965 updateChatModeIndicator('agent');
966 }
967 }
968
969 // Function to get bot ID from any element or wrapper
970 // If element is provided, finds the bot ID from its wrapper
971 // If no element, returns 'default' (for backward compatibility)
972 function getMxChatBotId(element) {
973 if (element) {
974 return getBotIdFromElement(element);
975 }
976 // Fallback: find first chatbot wrapper on page
977 const chatbotWrapper = document.querySelector('.mxchat-chatbot-wrapper');
978 return chatbotWrapper ? chatbotWrapper.getAttribute('data-bot-id') || 'default' : 'default';
979 }
980
981 function callMxChat(message, callback, botId) {
982 botId = botId || getMxChatBotId();
983
984 // Streaming fallbacks land here: drop any leftover stream handle and
985 // return the button to its send state (no-op for plain non-stream turns).
986 if (mxchatActiveStreams[botId]) {
987 delete mxchatActiveStreams[botId];
988 }
989 mxchatRestoreSendButton(botId);
990
991 // Store the message in case we need to retry after session reset
992 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
993
994 // Get page context if contextual awareness is enabled
995 const pageContext = getPageContext();
996
997 // Get instance for session start timestamp (used when persistence is OFF)
998 var instance = MxChatInstances.get(botId);
999
1000 // Guarantee a non-null session_id before the AJAX leaves. ensureSession() is idempotent
1001 // and returns the guaranteed-present session id from the in-memory instance even when
1002 // cookie/localStorage writes are silently blocked by the browser.
1003 var sessionId = MxChatInstances.ensureSession(botId);
1004 if (!sessionId || sessionId === 'null' || sessionId === 'undefined') {
1005 // Last-resort generation to ensure we never POST a null marker.
1006 sessionId = generateSessionId();
1007 MxChatInstances.setChatSession(botId, sessionId);
1008 }
1009
1010 // Wait for the page-cache nonce refresh to complete before firing the
1011 // chat-send AJAX. On cached pages the inline mxchatChat.nonce is stale
1012 // until refreshNonceIfNeeded() returns; constructing ajaxData inside the
1013 // callback guarantees we read the fresh value. See plan-c5457f.
1014 refreshNonceIfNeeded(function() {
1015 // Prepare AJAX data
1016 const ajaxData = {
1017 action: 'mxchat_handle_chat_request',
1018 message: message,
1019 session_id: sessionId,
1020 nonce: mxchatChat.nonce,
1021 current_page_url: window.location.href,
1022 current_page_title: document.title,
1023 bot_id: botId,
1024 // Pass session start timestamp so AI context matches what user sees
1025 session_start_timestamp: instance.sessionStartTimestamp || 0
1026 };
1027
1028 // Add page context if available
1029 if (pageContext) {
1030 ajaxData.page_context = JSON.stringify(pageContext);
1031 }
1032
1033 // CHECK FOR VISION FLAGS AND ADD THEM
1034 if (window.mxchatVisionProcessed) {
1035 ajaxData.vision_processed = true;
1036 ajaxData.original_user_message = window.mxchatOriginalMessage || message;
1037 ajaxData.vision_images_count = window.mxchatVisionImagesCount || 0;
1038 // Clear the flags after use
1039 window.mxchatVisionProcessed = false;
1040 window.mxchatOriginalMessage = null;
1041 window.mxchatVisionImagesCount = 0;
1042 }
1043
1044 $.ajax({
1045 url: mxchatChat.ajax_url,
1046 type: 'POST',
1047 dataType: 'json',
1048 data: ajaxData,
1049 success: function(response) {
1050 // IMMEDIATE CHAT MODE UPDATE - This should be FIRST
1051 if (response.chat_mode) {
1052 updateChatModeIndicator(response.chat_mode, botId);
1053 }
1054
1055 // Also check in data property if response is wrapped
1056 if (response.data && response.data.chat_mode) {
1057 updateChatModeIndicator(response.data.chat_mode, botId);
1058 }
1059
1060 // SECURITY FIX: Check for errors FIRST before checking for success
1061 // This ensures API errors (quota exceeded, invalid key, rate limit) are properly displayed
1062 if (response.success === false || (response.data && response.data.error_message)) {
1063 let errorMessage = "";
1064 let errorCode = "";
1065
1066 // Check various possible error locations in the response
1067 if (response.data && response.data.error_message) {
1068 errorMessage = response.data.error_message;
1069 errorCode = response.data.error_code || "";
1070 } else if (response.error_message) {
1071 errorMessage = response.error_message;
1072 errorCode = response.error_code || "";
1073 } else if (response.message) {
1074 errorMessage = response.message;
1075 } else if (typeof response.data === 'string') {
1076 errorMessage = response.data;
1077 } else {
1078 // Fallback for any other unexpected response format
1079 errorMessage = "An error occurred. Please try again or contact support.";
1080 }
1081
1082 // Handle session reset action (IP changed, session expired, etc.)
1083 // Silent reset — keep chat UI intact, just get a new session and retry
1084 if (response.data && response.data.action === 'reset_session') {
1085 MxChatInstances.silentResetSession(botId);
1086 // Re-send the original message with the new session (user message is already displayed)
1087 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1088 if (originalMessage) {
1089 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1090 var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1091 if (shouldUseStreaming(currentModel)) {
1092 callMxChatStream(originalMessage, function(response) {
1093 // Final content — release aria-busy so the reply announces once (67f126).
1094 getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
1095 }, botId);
1096 } else {
1097 callMxChat(originalMessage, function(response) {
1098 replaceLastMessage("bot", response, '', [], botId);
1099 }, botId);
1100 }
1101 }
1102 return;
1103 }
1104
1105 // Format user-friendly error message
1106 let displayMessage = errorMessage;
1107
1108 // Customize message for admin users
1109 if (mxchatChat.is_admin) {
1110 // For admin users, show more technical details including error code
1111 displayMessage = errorMessage + (errorCode ? " (Error code: " + errorCode + ")" : "");
1112 }
1113
1114 replaceLastMessage("bot", displayMessage, '', [], botId);
1115 return; // Exit early for errors
1116 }
1117
1118 // NOW check if this is a successful response by looking for text, html, or message fields
1119 // This preserves compatibility with your server response format
1120 if (response.text !== undefined || response.html !== undefined || response.message !== undefined ||
1121 (response.success === true && response.data && response.data.status === 'waiting_for_agent')) {
1122
1123 // Handle successful response - this is your original success handling code
1124
1125 // Handle other responses
1126 let responseText = response.text || '';
1127 let responseHtml = response.html || '';
1128 let responseMessage = response.message || '';
1129
1130 // Add PDF filename handling
1131 if (response.data && response.data.filename) {
1132 showActivePdf(response.data.filename, botId);
1133 var instance = MxChatInstances.get(botId);
1134 instance.activePdfFile = response.data.filename;
1135 }
1136
1137 // Add redirect check here
1138 if (response.redirect_url) {
1139 if (responseText) {
1140 replaceLastMessage("bot", responseText, '', [], botId);
1141 }
1142 setTimeout(() => {
1143 window.location.href = response.redirect_url;
1144 }, 1500);
1145 return;
1146 }
1147
1148 // Check for live agent response
1149 if (response.success && response.data && response.data.status === 'waiting_for_agent') {
1150 removeThinkingDots(botId);
1151 updateChatModeIndicator('agent', botId);
1152 enableChatInput(botId);
1153 return;
1154 }
1155
1156 // Handle the message and show notification if chat is hidden
1157 if (responseText || responseHtml || responseMessage) {
1158
1159 // ADD RESPONSE HOOKS HERE - BEFORE DISPLAYING
1160 if (responseText && typeof customMxChatFilter === 'function') {
1161 responseText = customMxChatFilter(responseText, "response");
1162 }
1163 if (responseMessage && typeof customMxChatFilter === 'function') {
1164 responseMessage = customMxChatFilter(responseMessage, "response");
1165 }
1166
1167 // Update the messages as before
1168 if (responseText && responseHtml) {
1169 replaceLastMessage("bot", responseText, responseHtml, [], botId);
1170 } else if (responseText) {
1171 replaceLastMessage("bot", responseText, '', [], botId);
1172 } else if (responseHtml) {
1173 replaceLastMessage("bot", "", responseHtml, [], botId);
1174 } else if (responseMessage) {
1175 replaceLastMessage("bot", responseMessage, '', [], botId);
1176 }
1177
1178 // Check if chat is hidden and show notification
1179 var $floatingChatbot = getElement(botId, 'floating-chatbot');
1180 if ($floatingChatbot.hasClass('hidden')) {
1181 var $badge = getElement(botId, 'chat-notification-badge');
1182 if ($badge.length) {
1183 $badge.show();
1184 }
1185 }
1186 } else {
1187 var emptyMsg = "I received an empty response. Please try again or contact support if this persists.";
1188 if (response.vectorstore_error) {
1189 emptyMsg = "I received an empty response. Debug info: " + response.vectorstore_error;
1190 }
1191 replaceLastMessage("bot", emptyMsg, '', [], botId);
1192 }
1193
1194 if (response.message_id) {
1195 var instance = MxChatInstances.get(botId);
1196 instance.lastSeenMessageId = response.message_id;
1197 }
1198
1199 return;
1200 }
1201
1202 // Fallback for truly unexpected response formats
1203 replaceLastMessage("bot", "Unexpected response format. Please try again or contact support.", '', [], botId);
1204 },
1205 error: function(xhr, status, error) {
1206 let errorMessage = "An unexpected error occurred.";
1207
1208 // Try to parse the response if it's JSON
1209 try {
1210 const responseJson = JSON.parse(xhr.responseText);
1211
1212 if (responseJson.data && responseJson.data.error_message) {
1213 errorMessage = responseJson.data.error_message;
1214 } else if (responseJson.message) {
1215 errorMessage = responseJson.message;
1216 }
1217 } catch (e) {
1218 // Not JSON or parsing failed, use HTTP status based messages
1219 if (xhr.status === 0) {
1220 errorMessage = "Network error: Please check your internet connection.";
1221 } else if (xhr.status === 403) {
1222 errorMessage = "Access denied: Your session may have expired. Please refresh the page.";
1223 } else if (xhr.status === 404) {
1224 errorMessage = "API endpoint not found. Please contact support.";
1225 } else if (xhr.status === 429) {
1226 errorMessage = "Too many requests. Please try again in a moment.";
1227 } else if (xhr.status >= 500) {
1228 errorMessage = "Server error: The server encountered an issue. Please try again later.";
1229 }
1230 }
1231
1232 replaceLastMessage("bot", errorMessage, '', [], botId);
1233 }
1234 });
1235 }); // refreshNonceIfNeeded
1236 }
1237
1238 function callMxChatStream(message, callback, botId) {
1239 botId = botId || getMxChatBotId();
1240
1241 // Store the message in case we need to retry after session reset
1242 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
1243
1244 const currentModel = mxchatChat.model || 'gpt-5.6-sol';
1245 if (!isStreamingSupported(currentModel)) {
1246 callMxChat(message, callback, botId);
1247 return;
1248 }
1249
1250 // Get page context if contextual awareness is enabled
1251 const pageContext = getPageContext();
1252
1253 // Get instance for session start timestamp (used when persistence is OFF)
1254 var instance = MxChatInstances.get(botId);
1255
1256 // Guarantee a non-null session_id before the fetch. FormData.append() stringifies any
1257 // non-string value via String(), so passing `null` would POST the literal string "null"
1258 // and land in the transcripts table as a ghost session. ensureSession() always returns
1259 // a real string even when cookies/localStorage are blocked.
1260 var streamSessionId = MxChatInstances.ensureSession(botId);
1261 if (!streamSessionId || streamSessionId === 'null' || streamSessionId === 'undefined') {
1262 streamSessionId = generateSessionId();
1263 MxChatInstances.setChatSession(botId, streamSessionId);
1264 }
1265
1266 // Wait for the page-cache nonce refresh before constructing formData (which
1267 // captures mxchatChat.nonce by value). Mirrors callMxChat's wrapping. See plan-c5457f.
1268 refreshNonceIfNeeded(function() {
1269 const formData = new FormData();
1270 formData.append('action', 'mxchat_stream_chat');
1271 formData.append('message', message);
1272 formData.append('session_id', streamSessionId);
1273 formData.append('nonce', mxchatChat.nonce);
1274 formData.append('current_page_url', window.location.href);
1275 formData.append('current_page_title', document.title);
1276 formData.append('bot_id', botId);
1277 // Pass session start timestamp so AI context matches what user sees
1278 formData.append('session_start_timestamp', instance.sessionStartTimestamp || 0);
1279
1280 // Add page context if available
1281 if (pageContext) {
1282 formData.append('page_context', JSON.stringify(pageContext));
1283 }
1284
1285 // CHECK FOR VISION FLAGS AND ADD THEM
1286 if (window.mxchatVisionProcessed) {
1287 formData.append('vision_processed', 'true');
1288 formData.append('original_user_message', window.mxchatOriginalMessage || message);
1289 formData.append('vision_images_count', window.mxchatVisionImagesCount || '0');
1290 // Clear the flags after use
1291 window.mxchatVisionProcessed = false;
1292 window.mxchatOriginalMessage = null;
1293 window.mxchatVisionImagesCount = 0;
1294 }
1295
1296 let accumulatedContent = '';
1297 let testingDataReceived = false;
1298 let streamingStarted = false;
1299 // Server-pushed html to append as its OWN bot bubble once the stream
1300 // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is
1301 // deferred to [DONE] so the embed always lands BELOW the streamed text.
1302 let pendingAppendHtml = '';
1303
1304 // Abortable stream: a fresh controller per turn, keyed by bot instance.
1305 // The Stop control (send button swapped in place) aborts both the read
1306 // loop and the underlying request.
1307 var streamControl = { controller: new AbortController(), aborted: false };
1308 mxchatActiveStreams[botId] = streamControl;
1309 mxchatShowStopButton(botId);
1310
1311 fetch(mxchatChat.ajax_url, {
1312 method: 'POST',
1313 body: formData,
1314 credentials: 'same-origin',
1315 signal: streamControl.controller.signal
1316 })
1317 .then(response => {
1318 // Store the response for potential fallback handling
1319 const responseClone = response.clone();
1320
1321 if (!response.ok) {
1322 // Try to get error details from response
1323 return responseClone.json().then(errorData => {
1324 throw { isServerError: true, data: errorData };
1325 }).catch(() => {
1326 throw new Error('Network response was not ok');
1327 });
1328 }
1329
1330 // Check if response is JSON instead of streaming
1331 const contentType = response.headers.get('content-type');
1332 if (contentType && contentType.includes('application/json')) {
1333 return responseClone.json().then(data => {
1334 // IMMEDIATE CHAT MODE UPDATE for JSON response
1335 if (data.chat_mode) {
1336 updateChatModeIndicator(data.chat_mode, botId);
1337 }
1338
1339 // Check for testing panel
1340 if (window.mxchatTestPanelInstance && data.testing_data) {
1341 window.mxchatTestPanelInstance.handleTestingData(data.testing_data);
1342 }
1343
1344 // Handle the JSON response directly
1345 handleNonStreamResponse(data, callback, botId);
1346 return Promise.resolve(); // Prevent further processing
1347 });
1348 }
1349
1350 // Continue with streaming processing
1351 const reader = response.body.getReader();
1352 const decoder = new TextDecoder();
1353 let buffer = '';
1354
1355 function processStream() {
1356 reader.read().then(({ done, value }) => {
1357 if (done) {
1358 // If streaming completed but no content was received, try to get response as fallback
1359 if (!streamingStarted || !accumulatedContent) {
1360 // Try to read the response as JSON
1361 responseClone.text().then(text => {
1362 try {
1363 const data = JSON.parse(text);
1364 if (data.text || data.message || data.html) {
1365 handleNonStreamResponse(data, callback, botId);
1366 } else {
1367 // No valid data, fall back to regular call
1368 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1369 callMxChat(message, callback, botId);
1370 }
1371 } catch (e) {
1372 // Could not parse, fall back to regular call
1373 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1374 callMxChat(message, callback, botId);
1375 }
1376 }).catch(() => {
1377 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1378 callMxChat(message, callback, botId);
1379 });
1380 return;
1381 }
1382
1383 // Re-enable chat input when stream ends with content
1384 enableChatInput(botId);
1385
1386 // Scroll the user's last message to the top now that the
1387 // bot's full reply has rendered (gives max reading room).
1388 var $chatBoxDone = getElement(botId, 'chat-box');
1389 var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
1390 if ($lastUserMsgDone.length) {
1391 scrollElementToTop($lastUserMsgDone, botId);
1392 }
1393
1394 if (callback) {
1395 callback(accumulatedContent);
1396 }
1397 return;
1398 }
1399
1400 buffer += decoder.decode(value, { stream: true });
1401 const lines = buffer.split('\n');
1402 buffer = lines.pop() || '';
1403
1404 for (const line of lines) {
1405 if (line.startsWith('data: ')) {
1406 const data = line.substring(6);
1407
1408 if (data === '[DONE]') {
1409 if (!accumulatedContent) {
1410 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1411 callMxChat(message, callback, botId);
1412 return;
1413 }
1414
1415 // Re-enable chat input after streaming completes
1416 enableChatInput(botId);
1417
1418 // Render any server-pushed appendix html (e.g. the
1419 // YouTube embed) as its own bot bubble below the
1420 // streamed text — mirrors how it is saved in the
1421 // transcript, so history replays identically.
1422 if (pendingAppendHtml) {
1423 appendMessage("bot", "", pendingAppendHtml, [], false, botId);
1424 pendingAppendHtml = '';
1425 }
1426
1427 // Scroll the user's last message to the top now
1428 // that the bot's full reply has rendered.
1429 var $chatBoxStreamDone = getElement(botId, 'chat-box');
1430 var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
1431 if ($lastUserMsgStreamDone.length) {
1432 scrollElementToTop($lastUserMsgStreamDone, botId);
1433 }
1434
1435 if (callback) {
1436 callback(accumulatedContent);
1437 }
1438 return;
1439 }
1440
1441 try {
1442 const json = JSON.parse(data);
1443
1444 // IMMEDIATE CHAT MODE UPDATE FOR STREAMING
1445 if (json.chat_mode) {
1446 updateChatModeIndicator(json.chat_mode, botId);
1447 }
1448
1449 // Handle testing data
1450 if (json.testing_data && !testingDataReceived) {
1451 if (window.mxchatTestPanelInstance) {
1452 window.mxchatTestPanelInstance.handleTestingData(json.testing_data);
1453 testingDataReceived = true;
1454 }
1455 }
1456 // Handle content streaming
1457 else if (json.content) {
1458 streamingStarted = true;
1459 accumulatedContent += json.content;
1460 updateStreamingMessage(accumulatedContent, botId);
1461 }
1462 // Stash appendix html (e.g. video embed) for [DONE]
1463 else if (json.append_html) {
1464 pendingAppendHtml = json.append_html;
1465 }
1466 // Server-side final pass changed the assembled text
1467 // (ffef6f: dead-link stripping) — swap the rendered
1468 // bubble for the validated version. Arrives at most
1469 // once, just before [DONE].
1470 else if (json.replace_content) {
1471 streamingStarted = true;
1472 accumulatedContent = json.replace_content;
1473 updateStreamingMessage(accumulatedContent, botId);
1474 }
1475 // Handle complete response in stream (fallback response)
1476 else if (json.text || json.message || json.html) {
1477 handleNonStreamResponse(json, callback, botId);
1478 return;
1479 }
1480 // Handle errors
1481 else if (json.error) {
1482
1483 // Get error message from various possible fields
1484 let errorMessage = json.error_message || json.message || json.text ||
1485 (typeof json.error === 'string' ? json.error : 'An error occurred. Please try again.');
1486
1487 // Re-enable chat input on error
1488 enableChatInput(botId);
1489
1490 // Display the error directly in the chat
1491 replaceLastMessage("bot", errorMessage, '', [], botId);
1492
1493 if (callback) {
1494 callback(errorMessage);
1495 }
1496 return;
1497 }
1498 } catch (e) {
1499 // SSE data parsing error - silently continue
1500 }
1501 }
1502 }
1503
1504 processStream();
1505 }).catch(streamError => {
1506 if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1507 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1508 callMxChat(message, callback, botId);
1509 });
1510 }
1511
1512 processStream();
1513 })
1514 .catch(error => {
1515 if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1516 // Check if we have server error data with chat mode
1517 if (error && error.isServerError && error.data) {
1518 // Check for chat mode in error data
1519 if (error.data.chat_mode) {
1520 updateChatModeIndicator(error.data.chat_mode, botId);
1521 }
1522
1523 handleNonStreamResponse(error.data, callback, botId);
1524 } else {
1525 // Only fall back to regular call if we don't have any response data
1526 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1527 callMxChat(message, callback, botId);
1528 }
1529 });
1530 }); // refreshNonceIfNeeded
1531 }
1532
1533 // Helper function to handle non-streaming responses
1534 function handleNonStreamResponse(data, callback, botId) {
1535 botId = botId || 'default';
1536
1537 // IMMEDIATE CHAT MODE UPDATE FOR NON-STREAMING RESPONSES
1538 if (data.chat_mode) {
1539 updateChatModeIndicator(data.chat_mode, botId);
1540 }
1541
1542 // Also check in data property if response is wrapped
1543 if (data.data && data.data.chat_mode) {
1544 updateChatModeIndicator(data.data.chat_mode, botId);
1545 }
1546
1547 // NOTE: Don't remove temporary message here - let replaceLastMessage handle it
1548 // This prevents a visual gap between thinking dots disappearing and content appearing
1549
1550 // SECURITY FIX: Check for errors FIRST
1551 if (data.success === false || (data.data && data.data.error_message)) {
1552 let errorMessage = "";
1553 let errorCode = "";
1554
1555 // Check various possible error locations
1556 if (data.data && data.data.error_message) {
1557 errorMessage = data.data.error_message;
1558 errorCode = data.data.error_code || "";
1559 } else if (data.error_message) {
1560 errorMessage = data.error_message;
1561 errorCode = data.error_code || "";
1562 } else if (data.message) {
1563 errorMessage = data.message;
1564 } else if (typeof data.data === 'string') {
1565 errorMessage = data.data;
1566 } else {
1567 errorMessage = "An error occurred. Please try again or contact support.";
1568 }
1569
1570 // Handle session reset action (IP changed, session expired, etc.)
1571 // Silent reset — keep chat UI intact, just get a new session and retry
1572 if (data.data && data.data.action === 'reset_session') {
1573 MxChatInstances.silentResetSession(botId);
1574 // Re-send the original message with the new session (user message is already displayed)
1575 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1576 if (originalMessage) {
1577 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1578 var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1579 if (shouldUseStreaming(currentModel)) {
1580 callMxChatStream(originalMessage, callback, botId);
1581 } else {
1582 callMxChat(originalMessage, callback, botId);
1583 }
1584 }
1585 return;
1586 }
1587
1588 // Format user-friendly error message
1589 let displayMessage = errorMessage;
1590 if (mxchatChat.is_admin) {
1591 displayMessage = errorMessage + (errorCode ? " (Error code: " + errorCode + ")" : "");
1592 }
1593
1594 replaceLastMessage("bot", displayMessage, '', [], botId);
1595
1596 if (callback) {
1597 callback('');
1598 }
1599 return; // Exit early for errors
1600 }
1601
1602 // Check for live agent response
1603 if (data.success && data.data && data.data.status === 'waiting_for_agent') {
1604 removeThinkingDots(botId);
1605 // Also remove any leftover bot-message that lost its temporary-message class
1606 var $chatBox = getElement(botId, 'chat-box');
1607 $chatBox.find('.bot-message .thinking-dots').closest('.bot-message').remove();
1608 updateChatModeIndicator('agent', botId);
1609 enableChatInput(botId);
1610 if (callback) {
1611 callback('');
1612 }
1613 return;
1614 }
1615
1616 // Handle different response formats
1617 if (data.text || data.html || data.message) {
1618
1619 // Apply response hooks
1620 if (data.text && typeof customMxChatFilter === 'function') {
1621 data.text = customMxChatFilter(data.text, "response");
1622 }
1623 if (data.message && typeof customMxChatFilter === 'function') {
1624 data.message = customMxChatFilter(data.message, "response");
1625 }
1626
1627 // Display the response
1628 if (data.text && data.html) {
1629 replaceLastMessage("bot", data.text, data.html, [], botId);
1630 } else if (data.text) {
1631 replaceLastMessage("bot", data.text, '', [], botId);
1632 } else if (data.html) {
1633 replaceLastMessage("bot", "", data.html, [], botId);
1634 } else if (data.message) {
1635 replaceLastMessage("bot", data.message, '', [], botId);
1636 }
1637 }
1638
1639 // Handle other response properties
1640 if (data.data && data.data.filename) {
1641 showActivePdf(data.data.filename, botId);
1642 var instance = MxChatInstances.get(botId);
1643 instance.activePdfFile = data.data.filename;
1644 }
1645
1646 if (data.redirect_url) {
1647 setTimeout(() => {
1648 window.location.href = data.redirect_url;
1649 }, 1500);
1650 }
1651
1652 // Ensure chat input is re-enabled (safety net for edge cases)
1653 enableChatInput(botId);
1654
1655 if (callback) {
1656 callback(data.text || data.message || '');
1657 }
1658 }
1659
1660 // Enhanced updateChatModeIndicator function for immediate DOM updates
1661 function updateChatModeIndicator(mode, botId) {
1662 botId = botId || 'default';
1663 const indicator = getElementDOM(botId, 'chat-mode-indicator');
1664 if (indicator) {
1665 const oldText = indicator.textContent;
1666
1667 if (mode === 'agent') {
1668 indicator.textContent = 'Live Agent';
1669 startPolling(botId);
1670 } else {
1671 // Everything else is AI mode
1672 const customAiText = indicator.getAttribute('data-ai-text') || 'AI Agent';
1673 indicator.textContent = customAiText;
1674 stopPolling(botId);
1675 }
1676
1677 // Force immediate DOM update and reflow
1678 if (oldText !== indicator.textContent) {
1679 // Force a reflow to ensure the change is visible immediately
1680 indicator.style.display = 'none';
1681 indicator.offsetHeight; // Trigger reflow
1682 indicator.style.display = '';
1683
1684 // Double-check after a brief moment to ensure the change stuck
1685 setTimeout(() => {
1686 if (mode === 'agent' && indicator.textContent !== 'Live Agent') {
1687 indicator.textContent = 'Live Agent';
1688 } else if (mode !== 'agent' && indicator.textContent === 'Live Agent') {
1689 const customAiText = indicator.getAttribute('data-ai-text') || 'AI Agent';
1690 indicator.textContent = customAiText;
1691 }
1692 }, 50);
1693 }
1694 }
1695 }
1696
1697 // Function to update message during streaming
1698 function updateStreamingMessage(content, botId) {
1699 botId = botId || 'default';
1700
1701 // ADD RESPONSE HOOK FOR REAL-TIME STREAMING
1702 if (typeof customMxChatFilter === 'function') {
1703 content = customMxChatFilter(content, "response");
1704 }
1705
1706 const formattedContent = linkify(content);
1707
1708 // Find the temporary message in this bot's chat box
1709 var $chatBox = getElement(botId, 'chat-box');
1710 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1711
1712 if (tempMessage.length) {
1713 // aria-busy=true for the whole stream: the bubble is rewritten on
1714 // every chunk, and without busy a polite live region announces those
1715 // rewrites continuously. Flipped false once the reply is final, so
1716 // assistive tech announces the completed message ONCE (plan 67f126).
1717 if (tempMessage.attr('aria-busy') !== 'true') {
1718 tempMessage.attr('aria-busy', 'true');
1719 }
1720 // Update existing message
1721 tempMessage.html(formattedContent);
1722 } else {
1723 // Create new temporary message if it doesn't exist
1724 appendMessage("bot", content, '', [], true, botId);
1725 }
1726 }
1727
1728 function isStreamingSupported(model) {
1729 if (!model) return false;
1730
1731 const modelPrefix = model.split('-')[0].toLowerCase();
1732
1733 // Support streaming for OpenAI, Claude, Grok, DeepSeek, and OpenRouter models
1734 const isSupported = modelPrefix === 'gpt' ||
1735 modelPrefix === 'o1' ||
1736 modelPrefix === 'claude' ||
1737 modelPrefix === 'grok' ||
1738 modelPrefix === 'deepseek' ||
1739 model === 'openrouter'; // Add this line - check full model name for OpenRouter
1740
1741 return isSupported;
1742 }
1743
1744 // Update the event handlers to use the correct function names (using event delegation)
1745 // Use class-based selectors for multi-instance support
1746 $(document).on('click', '.send-button', function() {
1747 var botId = getBotIdFromElement(this);
1748 // While a response is streaming the button is a Stop control.
1749 if (this.classList.contains('mxchat-stop-mode')) {
1750 // Same click that just started this stream (an add-on's direct handler
1751 // ran before this delegated one) — not a Stop press. See
1752 // mxchatShowStopButton for the full story (plan-4bba64).
1753 if (this.__mxchatStopJustShown) return;
1754 mxchatStopStreaming(botId);
1755 return;
1756 }
1757 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1758 if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1759 disableChatInput(botId);
1760 }
1761 sendMessage(botId);
1762 });
1763
1764 // Override enter key handler (using event delegation)
1765 $(document).on('keypress', '.chat-input', function(e) {
1766 if (e.which == 13 && !e.shiftKey) {
1767 e.preventDefault();
1768 var botId = getBotIdFromElement(this);
1769 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1770 if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1771 disableChatInput(botId);
1772 }
1773 sendMessage(botId);
1774 }
1775 });
1776
1777 // Chat input character counter + soft limit feedback (plan 7091a2).
1778 // Language-neutral: numbers + color only, no translatable strings. The counter
1779 // reveals near the cap and ramps neutral -> amber -> red; an over-limit keystroke
1780 // or trimmed paste produces a brief border-flash/shake so the maxlength cap (plan
1781 // a3fae2) is never a silent "input jumps back". Per-bot scoped via .input-container.
1782 function mxchatUpdateCharCounter(inputEl) {
1783 if (!inputEl || !inputEl.closest) return;
1784 var max = parseInt(inputEl.getAttribute('maxlength'), 10);
1785 var container = inputEl.closest('.input-container');
1786 if (!container || !max || max <= 0) return;
1787 var counter = container.querySelector('.mxchat-char-counter');
1788 if (!counter) return;
1789 var len = inputEl.value.length;
1790 var ratio = len / max;
1791 var nearThreshold = 0.8; // start surfacing the counter at 80% of the cap
1792 var cur = counter.querySelector('.mxchat-char-counter-current');
1793 if (cur) cur.textContent = len;
1794 var warn = ratio >= nearThreshold && len < max;
1795 var full = len >= max;
1796 counter.classList.toggle('is-visible', ratio >= nearThreshold);
1797 counter.classList.toggle('is-warn', warn);
1798 counter.classList.toggle('is-full', full);
1799 container.classList.toggle('mxchat-input-near-limit', warn);
1800 container.classList.toggle('mxchat-input-at-limit', full);
1801 }
1802
1803 function mxchatBumpInput(inputEl) {
1804 var container = inputEl && inputEl.closest ? inputEl.closest('.input-container') : null;
1805 if (!container) return;
1806 container.classList.remove('mxchat-input-bump');
1807 void container.offsetWidth; // reflow so a rapid second hit retriggers the animation
1808 container.classList.add('mxchat-input-bump');
1809 clearTimeout($(container).data('mxchatBumpTimeout'));
1810 var t = setTimeout(function() { container.classList.remove('mxchat-input-bump'); }, 220);
1811 $(container).data('mxchatBumpTimeout', t);
1812 }
1813
1814 // Live counter update on every input.
1815 $(document).on('input', '.chat-input', function() {
1816 mxchatUpdateCharCounter(this);
1817 });
1818
1819 // Visible "you've hit the edge" feedback when a printable keystroke is about to be
1820 // rejected at the cap (maxlength silently swallows it otherwise).
1821 $(document).on('keydown', '.chat-input', function(e) {
1822 var max = parseInt(this.getAttribute('maxlength'), 10);
1823 if (!max || max <= 0 || this.value.length < max) return;
1824 if (e.ctrlKey || e.metaKey || e.altKey) return;
1825 // A single printable char with no selection to overwrite WILL be rejected.
1826 if (e.key && e.key.length === 1 && this.selectionStart === this.selectionEnd) {
1827 mxchatBumpInput(this);
1828 }
1829 });
1830
1831 // A paste that gets trimmed to the cap also bumps, so truncation is never silent.
1832 $(document).on('paste', '.chat-input', function() {
1833 var el = this;
1834 var max = parseInt(el.getAttribute('maxlength'), 10);
1835 if (!max || max <= 0) return;
1836 setTimeout(function() {
1837 mxchatUpdateCharCounter(el);
1838 if (el.value.length >= max) mxchatBumpInput(el);
1839 }, 0);
1840 });
1841
1842 // Builds the list of overflow-menu items for a given bot.
1843 // Adding a future item is one push to this array — do NOT hardcode "only download."
1844 function mxchatGetHeaderMenuItems(botId) {
1845 var items = [];
1846 var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1847
1848 // The `print_button_*` keys still gate this item for back-compat with
1849 // existing user options. The action is now a transcript download, not print.
1850 if (settings.print_button_enabled === 'on') {
1851 items.push({
1852 id: 'download-transcript',
1853 label: settings.print_button_label || 'Download Transcript',
1854 icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>',
1855 action: function() {
1856 mxchatDownloadTranscript(botId);
1857 }
1858 });
1859 }
1860
1861 // "Start new chat" — surfaces the EXISTING per-conversation reset
1862 // (MxChatInstances.resetChatSession) so a visitor can start a fresh thread
1863 // without the site owner disabling chat persistence globally. Default OFF;
1864 // gated by the reset_chat_enabled option. plan ac2e81.
1865 if (settings.reset_chat_enabled === 'on') {
1866 items.push({
1867 id: 'reset-chat',
1868 label: settings.reset_chat_label || 'Start new chat',
1869 icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>',
1870 action: function() {
1871 var confirmMsg = settings.reset_chat_confirm || 'Start a new chat? This clears the current conversation.';
1872 if (window.confirm(confirmMsg)) {
1873 MxChatInstances.resetChatSession(botId);
1874 }
1875 }
1876 });
1877 }
1878
1879 return items;
1880 }
1881
1882 // Builds a clean markdown transcript of the current conversation and triggers
1883 // a file download. Used by the "Download Transcript" menu item.
1884 function mxchatDownloadTranscript(botId) {
1885 var $chatBox = getElement(botId, 'chat-box');
1886 if (!$chatBox || !$chatBox.length) return;
1887
1888 var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1889 var headerTitle = settings.print_header_title || 'Chat transcript';
1890 var now = new Date();
1891 var stamp = now.toLocaleString();
1892
1893 var lines = [];
1894 lines.push('# ' + headerTitle);
1895 lines.push('');
1896 lines.push('Exported: ' + stamp);
1897 lines.push('');
1898 lines.push('---');
1899 lines.push('');
1900
1901 $chatBox.find('.user-message, .bot-message, .agent-message').each(function() {
1902 var $msg = $(this);
1903 // Skip thinking placeholders and any in-flight temporary messages.
1904 if ($msg.find('.thinking-dots').length) return;
1905 if ($msg.hasClass('temporary-message')) return;
1906
1907 var sender;
1908 if ($msg.hasClass('user-message')) sender = 'User';
1909 else if ($msg.hasClass('agent-message')) sender = 'Live Agent';
1910 else sender = 'AI Agent';
1911
1912 // Strip interactive UI from the cloned message so we get the conversation text.
1913 var $clone = $msg.clone();
1914 $clone.find('.copy-button, .message-toolbar, .mxchat-copy, button, script, style').remove();
1915 var text = $clone.text().replace(/ /g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
1916 if (!text) return;
1917
1918 lines.push('**' + sender + '**');
1919 lines.push('');
1920 lines.push(text);
1921 lines.push('');
1922 });
1923
1924 var content = lines.join('\n');
1925 var iso = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
1926 var fname = 'mxchat-transcript-' + iso + '.md';
1927 var blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
1928 var url = URL.createObjectURL(blob);
1929 var a = document.createElement('a');
1930 a.href = url;
1931 a.download = fname;
1932 a.style.display = 'none';
1933 document.body.appendChild(a);
1934 a.click();
1935 setTimeout(function() {
1936 if (a.parentNode) a.parentNode.removeChild(a);
1937 URL.revokeObjectURL(url);
1938 }, 100);
1939 }
1940
1941 // Reads the bot bubble's actual computed bg+fg and writes them as CSS vars
1942 // on the menu wrap, so the dropdown matches whatever paints the bubble —
1943 // saved options, AI theme CSS, or the mxchat-theme add-on.
1944 function mxchatSyncMenuColors(botId, $wrap) {
1945 if (!$wrap || !$wrap.length) return;
1946 var $bot = $wrap.closest('.mxchat-chatbot-wrapper').find('.bot-message').not('.temporary-message').first();
1947 if (!$bot.length) return;
1948 var cs = window.getComputedStyle($bot[0]);
1949 if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') {
1950 $wrap[0].style.setProperty('--mxchat-menu-bg', cs.backgroundColor);
1951 }
1952 // Bot text color usually lives on a child div, not .bot-message itself.
1953 var $textChild = $bot.find('[style*="color"]').first();
1954 var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
1955 if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
1956 }
1957
1958 // Renders (or re-renders) the item list for one menu wrap. Split out of
1959 // mxchatInitHeaderMenu so the dynamic-settings merge (plan-32db95) can
1960 // rebuild items + trigger visibility WITHOUT re-binding the one-time
1961 // open/close/keyboard wiring. closeMenu is passed in by the init closure;
1962 // a rebuild before init (never happens, but harmless) just skips it.
1963 function mxchatRenderHeaderMenuItems(botId, $wrap, closeMenuFn) {
1964 var $trigger = $wrap.find('.mxchat-menu-trigger');
1965 var $menu = $wrap.find('.mxchat-header-menu');
1966 var items = mxchatGetHeaderMenuItems(botId);
1967
1968 $menu.empty();
1969
1970 if (!items.length) {
1971 $trigger.hide();
1972 $menu.hide();
1973 return;
1974 }
1975
1976 // Clear any inline display:none a previous zero-item render left behind —
1977 // open/close visibility is governed by the hidden prop + is-open class.
1978 $trigger.css('display', '');
1979 $menu.css('display', '');
1980
1981 items.forEach(function(item, idx) {
1982 var $btn = $('<button>', {
1983 type: 'button',
1984 'class': 'mxchat-menu-item',
1985 'role': 'menuitem',
1986 'tabindex': '-1',
1987 'data-menu-id': item.id,
1988 html: '<span class="mxchat-menu-item-icon">' + item.icon + '</span>' +
1989 '<span class="mxchat-menu-item-label"></span>'
1990 });
1991 $btn.find('.mxchat-menu-item-label').text(item.label);
1992 $btn.on('click', function(e) {
1993 e.preventDefault();
1994 e.stopPropagation();
1995 if (closeMenuFn) closeMenuFn();
1996 try { item.action(); } catch (err) { /* no-op */ }
1997 });
1998 $menu.append($btn);
1999 });
2000 }
2001
2002 // Re-render every menu on the page after a dynamic-settings merge
2003 // (multi-bot: each wrap re-reads its items). An OPEN menu is left alone —
2004 // swapping items under the user mid-interaction yanks focus — and the
2005 // rebuild runs when it closes instead (closeMenu checks the pending flag).
2006 function mxchatRebuildHeaderMenus() {
2007 $('.mxchat-header-menu-wrap').each(function() {
2008 var $wrap = $(this);
2009 var botId = $wrap.data('bot-id');
2010 if (!botId) return;
2011 if (!$wrap.data('mxchatMenuReady')) {
2012 mxchatInitHeaderMenu(botId);
2013 return;
2014 }
2015 if ($wrap.find('.mxchat-header-menu').hasClass('is-open')) {
2016 $wrap.data('mxchatMenuRebuildPending', true);
2017 return;
2018 }
2019 mxchatRenderHeaderMenuItems(botId, $wrap, $wrap.data('mxchatMenuClose'));
2020 });
2021 }
2022
2023 // One-time per-widget init: renders menu items, wires open/close,
2024 // outside-click, Escape, and arrow-key navigation. If no items, hides the
2025 // trigger. Wiring happens even when there are zero items at init, so a
2026 // later dynamic-settings rebuild that adds items has a working trigger.
2027 function mxchatInitHeaderMenu(botId) {
2028 var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
2029 if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
2030
2031 var $trigger = $wrap.find('.mxchat-menu-trigger');
2032 var $menu = $wrap.find('.mxchat-header-menu');
2033
2034 // Initial color sync — covers normal page load.
2035 mxchatSyncMenuColors(botId, $wrap);
2036
2037 function openMenu() {
2038 // Re-sync each open in case the active theme changed since init.
2039 mxchatSyncMenuColors(botId, $wrap);
2040 $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
2041 $trigger.attr('aria-expanded', 'true');
2042 // Focus the first item for keyboard users
2043 setTimeout(function() {
2044 $menu.find('.mxchat-menu-item').first().attr('tabindex', '0').trigger('focus');
2045 }, 0);
2046 }
2047 function closeMenu(returnFocus) {
2048 $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
2049 $trigger.attr('aria-expanded', 'false');
2050 $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
2051 if (returnFocus) $trigger.trigger('focus');
2052 // A dynamic-settings rebuild that arrived while the menu was open
2053 // was deferred (mxchatRebuildHeaderMenus) — run it now.
2054 if ($wrap.data('mxchatMenuRebuildPending')) {
2055 $wrap.removeData('mxchatMenuRebuildPending');
2056 mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
2057 }
2058 }
2059
2060 // Toggle on trigger click — stop propagation so the .chatbot-top-bar
2061 // click-to-collapse handler does not fire.
2062 $trigger.on('click', function(e) {
2063 e.preventDefault();
2064 e.stopPropagation();
2065 if ($menu.hasClass('is-open')) closeMenu();
2066 else openMenu();
2067 });
2068
2069 // Don't let clicks inside the menu bubble to the top-bar collapse handler.
2070 $menu.on('click', function(e) {
2071 e.stopPropagation();
2072 });
2073
2074 // Outside click closes the menu.
2075 $(document).on('click.mxchatMenu-' + botId, function(e) {
2076 if (!$menu.hasClass('is-open')) return;
2077 if ($wrap.has(e.target).length || $wrap.is(e.target)) return;
2078 closeMenu();
2079 });
2080
2081 // Keyboard: Escape closes and returns focus; arrow keys move focus; Enter activates.
2082 $menu.on('keydown', '.mxchat-menu-item', function(e) {
2083 var $items = $menu.find('.mxchat-menu-item');
2084 var idx = $items.index(this);
2085 if (e.key === 'Escape') {
2086 e.preventDefault();
2087 closeMenu(true);
2088 } else if (e.key === 'ArrowDown') {
2089 e.preventDefault();
2090 var $next = $items.eq((idx + 1) % $items.length);
2091 $items.attr('tabindex', '-1');
2092 $next.attr('tabindex', '0').trigger('focus');
2093 } else if (e.key === 'ArrowUp') {
2094 e.preventDefault();
2095 var $prev = $items.eq((idx - 1 + $items.length) % $items.length);
2096 $items.attr('tabindex', '-1');
2097 $prev.attr('tabindex', '0').trigger('focus');
2098 } else if (e.key === 'Enter' || e.key === ' ') {
2099 e.preventDefault();
2100 $(this).trigger('click');
2101 }
2102 });
2103 $trigger.on('keydown', function(e) {
2104 if (e.key === 'Escape' && $menu.hasClass('is-open')) {
2105 e.preventDefault();
2106 closeMenu(true);
2107 } else if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !$menu.hasClass('is-open')) {
2108 e.preventDefault();
2109 openMenu();
2110 }
2111 });
2112
2113 // Expose closeMenu for out-of-closure re-renders (mxchatRebuildHeaderMenus),
2114 // then do the initial item render.
2115 $wrap.data('mxchatMenuClose', closeMenu);
2116 mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
2117
2118 $wrap.data('mxchatMenuReady', true);
2119 }
2120
2121 // Initialize header menus for every rendered widget on DOM ready.
2122 $(function() {
2123 $('.mxchat-header-menu-wrap').each(function() {
2124 var botId = $(this).data('bot-id');
2125 if (botId) mxchatInitHeaderMenu(botId);
2126 });
2127
2128 // Embedded (non-floating) widgets are open from the moment the page
2129 // renders — refresh dynamic settings at init (plan-32db95). Floating
2130 // widgets refresh on first launcher open instead.
2131 var hasEmbeddedWidget = $('.mxchat-chatbot-wrapper').filter(function() {
2132 return !$(this).closest('.floating-chatbot').length;
2133 }).length > 0;
2134 if (hasEmbeddedWidget) {
2135 mxchatRefreshDynamicSettings();
2136 }
2137 });
2138
2139 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
2140 try {
2141 // Determine styles based on sender type
2142 let messageClass, bgColor, fontColor;
2143
2144 if (sender === "user") {
2145 messageClass = "user-message";
2146 bgColor = userMessageBgColor;
2147 fontColor = userMessageFontColor;
2148 // Only sanitize user input
2149 messageText = sanitizeUserInput(messageText);
2150 } else if (sender === "agent") {
2151 messageClass = "agent-message";
2152 bgColor = liveAgentMessageBgColor;
2153 fontColor = liveAgentMessageFontColor;
2154 } else {
2155 messageClass = "bot-message";
2156 bgColor = botMessageBgColor;
2157 fontColor = botMessageFontColor;
2158 }
2159
2160 const messageDiv = $('<div>')
2161 .addClass(messageClass)
2162 .attr('dir', 'auto');
2163
2164 // Only apply inline colors if AI theme is not active (let CSS handle it)
2165 var skipColors = shouldSkipInlineColors(botId);
2166 if (skipColors) {
2167 messageDiv.css({
2168 'margin-bottom': '1em'
2169 });
2170 } else {
2171 messageDiv.css({
2172 'background': bgColor,
2173 'color': fontColor,
2174 'margin-bottom': '1em'
2175 });
2176 }
2177
2178 // Process the message content - always run linkify to convert markdown
2179 // links and format text. linkify() handles existing HTML safely via
2180 // negative lookaheads that skip URLs already inside <a> tags.
2181 let fullMessage = linkify(messageText);
2182
2183 // Add images if provided
2184 if (images && images.length > 0) {
2185 fullMessage += '<div class="image-gallery" dir="auto">';
2186 images.forEach(img => {
2187 const safeTitle = sanitizeUserInput(img.title);
2188 const safeUrl = encodeURI(img.image_url);
2189 const safeThumbnail = encodeURI(img.thumbnail_url);
2190
2191 fullMessage += `
2192 <div style="margin-bottom: 10px;">
2193 <strong>${safeTitle}</strong><br>
2194 <a href="${safeUrl}" target="_blank">
2195 <img src="${safeThumbnail}" alt="${safeTitle}" style="max-width: 100px; height: auto; margin: 5px;" />
2196 </a>
2197 </div>`;
2198 });
2199 fullMessage += '</div>';
2200 }
2201
2202 // Append HTML content if provided
2203 if (messageHtml && sender !== "user") {
2204 // Only add line breaks if there's actual text content before the HTML
2205 if (fullMessage && fullMessage.trim()) {
2206 fullMessage += '<br><br>' + messageHtml;
2207 } else {
2208 fullMessage = messageHtml;
2209 }
2210 }
2211
2212 messageDiv.html(fullMessage);
2213
2214 if (isTemporary) {
2215 // In-flight bubble: hold aria-busy so the live region stays quiet
2216 // until the content is finalized (plan 67f126).
2217 messageDiv.addClass('temporary-message').attr('aria-busy', 'true');
2218 }
2219
2220 // Append to the correct chatbot instance's chat-box
2221 var $chatBox = getElement(botId, 'chat-box');
2222 messageDiv.hide().appendTo($chatBox).fadeIn(300, function() {
2223 // FIXED: Use event delegation for link tracking
2224 if (sender === "bot" || sender === "agent") {
2225 attachLinkTracking(messageDiv, messageText, botId);
2226 }
2227
2228 if (sender === "bot") {
2229 const lastUserMessage = $chatBox.find('.user-message').last();
2230 if (lastUserMessage.length) {
2231 scrollElementToTop(lastUserMessage, botId);
2232 }
2233 }
2234
2235 if ((sender === "bot" || sender === "agent") && !isTemporary) {
2236 if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2237 }
2238 });
2239
2240 if (messageText.id) {
2241 var instance = MxChatInstances.get(botId);
2242 instance.lastSeenMessageId = messageText.id;
2243 hideNotification(botId);
2244 }
2245 } catch (error) {
2246 // Error rendering message - silently continue
2247 }
2248 }
2249
2250 // Helper function to attach link tracking with proper event handling
2251 function attachLinkTracking(messageDiv, messageText, botId) {
2252 botId = botId || 'default';
2253 // Use a slight delay to ensure DOM is ready
2254 setTimeout(function() {
2255 const links = messageDiv.find('a[href]').not('[data-tracked]');
2256
2257 links.each(function() {
2258 const $link = $(this);
2259 const originalHref = $link.attr('href');
2260
2261 // Mark as tracked to avoid duplicate handlers
2262 $link.attr('data-tracked', 'true');
2263
2264 // Only track external URLs
2265 if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
2266 // Remove any existing click handlers first
2267 $link.off('click.tracking');
2268
2269 // Add new click handler with namespace
2270 $link.on('click.tracking', function(e) {
2271 e.preventDefault();
2272 e.stopPropagation();
2273
2274 const messageContext = typeof messageText === 'string'
2275 ? messageText.substring(0, 200)
2276 : '';
2277
2278 // Track the click
2279 $.ajax({
2280 url: mxchatChat.ajax_url,
2281 type: 'POST',
2282 data: {
2283 action: 'mxchat_track_url_click',
2284 session_id: getChatSession(botId),
2285 url: originalHref,
2286 message_context: messageContext,
2287 nonce: mxchatChat.nonce
2288 },
2289 complete: function() {
2290 // Always redirect, even if tracking fails
2291 if ($link.attr('target') === '_blank' || linkTarget === '_blank') {
2292 window.open(originalHref, '_blank');
2293 } else {
2294 window.location.href = originalHref;
2295 }
2296 }
2297 });
2298
2299 return false; // Extra insurance to prevent default
2300 });
2301 }
2302 });
2303 }, 100); // Small delay to ensure DOM is ready
2304 }
2305
2306 function replaceLastMessage(sender, responseText, responseHtml = '', images = [], botId = 'default') {
2307 var messageClass = sender === "user" ? "user-message" : sender === "agent" ? "agent-message" : "bot-message";
2308 var $chatBox = getElement(botId, 'chat-box');
2309 var lastMessageDiv = $chatBox.find('.bot-message.temporary-message, .agent-message.temporary-message').last();
2310
2311 // Determine styles
2312 let bgColor, fontColor;
2313 if (sender === "user") {
2314 bgColor = userMessageBgColor;
2315 fontColor = userMessageFontColor;
2316 } else if (sender === "agent") {
2317 bgColor = liveAgentMessageBgColor;
2318 fontColor = liveAgentMessageFontColor;
2319 } else {
2320 bgColor = botMessageBgColor;
2321 fontColor = botMessageFontColor;
2322 }
2323
2324 // Always run linkify to convert markdown links and format text.
2325 // linkify() already handles existing HTML (its URL patterns use negative lookaheads
2326 // to avoid double-processing URLs that are already inside <a> tags).
2327 var fullMessage = linkify(responseText);
2328
2329 if (responseHtml) {
2330 // Only add line breaks if there's actual text content before the HTML
2331 if (fullMessage && fullMessage.trim()) {
2332 fullMessage += '<br><br>' + responseHtml;
2333 } else {
2334 fullMessage = responseHtml;
2335 }
2336 }
2337
2338 if (images.length > 0) {
2339 fullMessage += '<div class="image-gallery" dir="auto">';
2340 images.forEach(img => {
2341 const safeTitle = sanitizeUserInput(img.title);
2342 const safeUrl = escapeAttr(img.image_url);
2343 const safeThumbnail = escapeAttr(img.thumbnail_url);
2344 fullMessage += `
2345 <div style="margin-bottom: 10px;">
2346 <strong>${safeTitle}</strong><br>
2347 <a href="${safeUrl}" target="_blank">
2348 <img src="${safeThumbnail}" alt="${safeTitle}" style="max-width: 100px; height: auto; margin: 5px;" />
2349 </a>
2350 </div>`;
2351 });
2352 fullMessage += '</div>';
2353 }
2354
2355 if (lastMessageDiv.length) {
2356 // Replace content immediately to prevent visual gap between thinking dots and response
2357 // aria-busy released AFTER the final content is set, so the live region
2358 // announces the finished message once (plan 67f126).
2359 lastMessageDiv
2360 .html(fullMessage)
2361 .removeClass('bot-message user-message temporary-message')
2362 .addClass(messageClass)
2363 .attr('dir', 'auto')
2364 .attr('aria-busy', 'false');
2365
2366 // Only apply inline colors if AI theme is not active (let CSS handle it)
2367 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
2368 if (!skipColors) {
2369 lastMessageDiv.css({
2370 'background-color': bgColor,
2371 'color': fontColor,
2372 });
2373 }
2374
2375 // Handle link tracking and scroll
2376 if (sender === "bot" || sender === "agent") {
2377 attachLinkTracking(lastMessageDiv, responseText, botId);
2378
2379 const lastUserMessage = $chatBox.find('.user-message').last();
2380 if (lastUserMessage.length) {
2381 scrollElementToTop(lastUserMessage, botId);
2382 }
2383 // Show notification if chat is hidden
2384 var $floatingChatbot = getElement(botId, 'floating-chatbot');
2385 if ($floatingChatbot.hasClass('hidden')) {
2386 showNotification(botId);
2387 }
2388 }
2389
2390 // Re-enable chat input after response is displayed
2391 enableChatInput(botId);
2392
2393 if (sender === "bot" || sender === "agent") {
2394 if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2395 }
2396 } else {
2397 appendMessage(sender, responseText, responseHtml, images, false, botId);
2398 // Re-enable chat input after response is displayed
2399 enableChatInput(botId);
2400 }
2401 }
2402
2403
2404 function appendThinkingMessage(botId) {
2405 botId = botId || 'default';
2406
2407 // Don't show thinking dots in live agent mode - message is just forwarded to a human
2408 var indicator = getElementDOM(botId, 'chat-mode-indicator');
2409 if (indicator && indicator.textContent === 'Live Agent') {
2410 return;
2411 }
2412
2413 var $chatBox = getElement(botId, 'chat-box');
2414
2415 // Remove any existing thinking dots in this bot's chat first
2416 $chatBox.find('.thinking-dots').remove();
2417
2418 // Check if we should skip inline colors (AI theme is active)
2419 var skipColors = shouldSkipInlineColors(botId);
2420
2421 // Retrieve the bot message font color and background color
2422 var botMessageFontColor = mxchatChat.bot_message_font_color;
2423 var botMessageBgColor = mxchatChat.bot_message_bg_color;
2424
2425 // Build thinking dots HTML - skip inline colors if AI theme is active
2426 // The dots are decorative; the sr-only span is what the live region
2427 // announces for the waiting state (plan 67f126). Server-localized
2428 // string — safe to inject (esc_html__ output, no user content).
2429 var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
2430 var srThinking = mxchatChat.thinking_announcement || 'Assistant is typing';
2431 var thinkingHtml = '<span class="sr-only">' + srThinking + '</span>' +
2432 '<div class="thinking-dots-container" aria-hidden="true">' +
2433 '<div class="thinking-dots">' +
2434 '<span class="dot"' + dotStyle + '></span>' +
2435 '<span class="dot"' + dotStyle + '></span>' +
2436 '<span class="dot"' + dotStyle + '></span>' +
2437 '</div>' +
2438 '</div>';
2439
2440 // Append the thinking dots to this bot's chat container - skip inline colors if AI theme is active
2441 var messageStyle = skipColors ? '' : ' style="background-color: ' + botMessageBgColor + '; color: ' + botMessageFontColor + ';"';
2442 $chatBox.append('<div class="bot-message temporary-message"' + messageStyle + '>' + thinkingHtml + '</div>');
2443 scrollToBottom(botId);
2444 }
2445
2446 function removeThinkingDots(botId) {
2447 botId = botId || 'default';
2448 var $chatBox = getElement(botId, 'chat-box');
2449 // Remove by temporary-message class first, then fall back to any bot-message containing thinking dots
2450 $chatBox.find('.thinking-dots').closest('.temporary-message').remove();
2451 $chatBox.find('.bot-message .thinking-dots').closest('.bot-message').remove();
2452 }
2453
2454 // ====================================
2455 // TEXT FORMATTING & PROCESSING
2456 // ====================================
2457
2458 function linkify(inputText) {
2459 if (!inputText) {
2460 return '';
2461 }
2462
2463 // Helper function to check if URL is already encoded
2464 function isUrlEncoded(url) {
2465 // Check for % followed by exactly 2 hex digits
2466 return /%[0-9a-fA-F]{2}/.test(url);
2467 }
2468
2469 // Helper function to safely encode URLs only if needed, then make the
2470 // result attribute-safe. "Don't double-encode an already-encoded URL"
2471 // used to mean "return it untouched" — so a literal quote riding next
2472 // to a %20 reached href="…" intact and closed the attribute
2473 // (plan 2db182). Every href this function feeds now goes through
2474 // escapeAttr(), whether or not the URL was already percent-encoded.
2475 function safeEncodeUrl(url) {
2476 const encoded = isUrlEncoded(url) ? url : encodeURI(url);
2477 return escapeAttr(encoded);
2478 }
2479
2480 // Take backslash escapes and code (fenced + inline) out of the text FIRST
2481 // (plan 9dc34f). Styling used to run before formatCodeBlocks(), so a
2482 // backtick could not protect an underscore and \_ was not an escape at
2483 // all; the header rule also ran over fenced code. Each escape becomes a
2484 // placeholder carrying its literal character, each code span a
2485 // placeholder carrying its rendered HTML; both are restored below, after
2486 // the styling rules have run, so nothing downstream changes.
2487 const literals = protectLiterals(inputText);
2488 let processedText = literals.text;
2489
2490 // Process markdown headers
2491 processedText = formatMarkdownHeaders(processedText);
2492
2493 // Process text styling (bold, italic, strikethrough)
2494 processedText = formatTextStyling(processedText);
2495
2496 // Legacy raw <?php ... ?> blocks (fenced + inline code were already
2497 // rendered inside protectLiterals) — still BEFORE processing links
2498 processedText = formatCodeBlocks(processedText);
2499
2500 // Put the rendered code and the escaped characters back
2501 processedText = literals.restore(processedText);
2502
2503 // Process markdown tables BEFORE converting newlines to paragraphs
2504 processedText = formatMarkdownTables(processedText);
2505
2506 // NOW convert to paragraphs
2507 processedText = convertNewlinesToBreaks(processedText);
2508
2509 // IMPORTANT: Handle citation-style brackets FIRST [URL]
2510 // This prevents them from being processed as markdown links
2511 // Match [URL] where URL is a complete URL in square brackets (common in AI citations)
2512 processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => {
2513 // Clean the URL of any trailing punctuation
2514 let cleanUrl = url.replace(/[.,;!?]+$/, '');
2515 const safeUrl = safeEncodeUrl(cleanUrl);
2516 // The bracket body is the only link text that can carry angle
2517 // brackets (the standalone matcher excludes them) — keep it text.
2518 const safeText = cleanUrl.replace(/</g, '&lt;').replace(/>/g, '&gt;');
2519 // Return as a proper link without the brackets
2520 return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
2521 });
2522
2523 // Process markdown links: [text](url) and [](url)
2524 // Uses balanced parenthesis matching to handle URLs containing parens
2525 // (e.g. PDF filenames with dates like (2025-08-28).pdf)
2526 processedText = (function(input) {
2527 var result = '';
2528 var i = 0;
2529 while (i < input.length) {
2530 // Look for [ at current position
2531 if (input[i] === '[') {
2532 // Find closing ]
2533 var closeBracket = input.indexOf(']', i + 1);
2534 if (closeBracket === -1 || closeBracket + 1 >= input.length || input[closeBracket + 1] !== '(') {
2535 result += input[i];
2536 i++;
2537 continue;
2538 }
2539 var linkText = input.substring(i + 1, closeBracket);
2540 // Check if URL starts with http
2541 var urlStart = closeBracket + 2;
2542 if (!input.substring(urlStart).match(/^https?:\/\//)) {
2543 result += input[i];
2544 i++;
2545 continue;
2546 }
2547 // Find balanced closing paren
2548 var depth = 1;
2549 var j = urlStart;
2550 while (j < input.length && depth > 0) {
2551 if (input[j] === '(') depth++;
2552 else if (input[j] === ')') depth--;
2553 if (depth > 0) j++;
2554 }
2555 if (depth !== 0) {
2556 result += input[i];
2557 i++;
2558 continue;
2559 }
2560 var url = input.substring(urlStart, j);
2561 var cleanUrl = url.replace(/[\].,;!?]+$/, '');
2562 var encodedUrl = safeEncodeUrl(cleanUrl);
2563 if (!linkText || !linkText.trim()) {
2564 result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + cleanUrl + '</a>';
2565 } else {
2566 var safeText = sanitizeUserInput(linkText);
2567 result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + safeText + '</a>';
2568 }
2569 i = j + 1; // Skip past the closing )
2570 } else {
2571 result += input[i];
2572 i++;
2573 }
2574 }
2575 return result;
2576 })(processedText);
2577
2578 // Process phone numbers: [text](tel:number)
2579 const phonePattern = /\[([^\]]+)\]\((tel:[\d+\-\s()]+)\)/g;
2580 processedText = processedText.replace(phonePattern, (match, text, phone) => {
2581 const safePhone = safeEncodeUrl(phone);
2582 const safeText = sanitizeUserInput(text);
2583 return `<a href="${safePhone}">${safeText}</a>`;
2584 });
2585
2586 // Process mailto links: [text](mailto:email)
2587 const mailtoPattern = /\[([^\]]+)\]\((mailto:[^\)]+)\)/g;
2588 processedText = processedText.replace(mailtoPattern, (match, text, mailto) => {
2589 const safeMailto = safeEncodeUrl(mailto);
2590 const safeText = sanitizeUserInput(text);
2591 return `<a href="${safeMailto}">${safeText}</a>`;
2592 });
2593
2594 // Process standalone URLs - but NOT if they're already in <a> tags or brackets
2595 // Updated pattern to be more careful about what it matches
2596 const urlPattern = /(^|[^">=\[\]])(https?:\/\/[^\s<"\[\]]+)(?![^<]*<\/a>)(?!\])/gim;
2597 processedText = processedText.replace(urlPattern, (match, prefix, url) => {
2598 // Extra check: make sure this isn't already linked
2599 if (match.includes('href=') || match.includes('</a>')) {
2600 return match;
2601 }
2602
2603 // Clean trailing punctuation
2604 let cleanUrl = url.replace(/[.,;!?)]+$/, '');
2605 const safeUrl = safeEncodeUrl(cleanUrl);
2606 return `${prefix}<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2607 });
2608
2609 // Process www. URLs - but NOT if they're already in <a> tags or brackets
2610 const wwwPattern = /(^|[^">/\[\]])(www\.[\S]+)(?![^<]*<\/a>)(?!\])/gim;
2611 processedText = processedText.replace(wwwPattern, (match, prefix, url) => {
2612 // Extra check: make sure this isn't already linked
2613 if (match.includes('href=') || match.includes('</a>')) {
2614 return match;
2615 }
2616
2617 // Clean trailing punctuation
2618 let cleanUrl = url.replace(/[.,;!?)]+$/, '');
2619 const safeUrl = safeEncodeUrl(`http://${cleanUrl}`);
2620 return `${prefix}<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2621 });
2622
2623 return processedText;
2624 }
2625
2626 // One left-to-right pass over backslash escapes and code (plan 9dc34f).
2627 // A single alternation keeps the two honest with each other: an escaped
2628 // backtick is consumed as an escape before it can open a code span, and a
2629 // code span is consumed whole so the escapes inside it stay verbatim.
2630 // Placeholders are private-use characters — no underscore, asterisk,
2631 // backtick, hash, pipe or bracket for a later rule to match.
2632 function protectLiterals(text) {
2633 const store = [];
2634 const out = String(text).replace(/\\([\\`*_~])|(```[\s\S]*?```)|(`[^`\n]+`)/g, function(match, escaped) {
2635 store.push(escaped !== undefined ? escaped : formatCodeBlocks(match));
2636 return '\uE000' + (store.length - 1) + '\uE001';
2637 });
2638 return {
2639 text: out,
2640 restore: function(t) {
2641 return String(t).replace(/\uE000(\d+)\uE001/g, function(m, i) {
2642 return store[Number(i)];
2643 });
2644 }
2645 };
2646 }
2647
2648 function formatMarkdownHeaders(text) {
2649 // Handle h1 to h6 headers
2650 return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) {
2651 const level = hashes.length;
2652 return `<h${level} class="chat-heading chat-heading-${level}">${content.trim()}</h${level}>`;
2653 });
2654 }
2655
2656 function formatTextStyling(text) {
2657 // IMPORTANT: Protect BOTH HTML href and Markdown URLs from formatting
2658 const protectedSegments = [];
2659 let protectedText = text;
2660
2661 // Step 1a: Protect HTML href="..." attributes
2662 protectedText = protectedText.replace(/href\s*=\s*["']([^"']+)["']/gi, function(match) {
2663 const placeholder = `__PROTECTED_${protectedSegments.length}__`;
2664 protectedSegments.push(match);
2665 return placeholder;
2666 });
2667
2668 // Step 1b: Protect Markdown links [text](url)
2669 // This is crucial - we need to protect the URLs in markdown format
2670 protectedText = protectedText.replace(/\[([^\]]*)\]\(([^)]+)\)/g, function(match) {
2671 const placeholder = `__PROTECTED_${protectedSegments.length}__`;
2672 protectedSegments.push(match);
2673 return placeholder;
2674 });
2675
2676 // Step 1c: Also protect bare URLs that might exist
2677 protectedText = protectedText.replace(/(https?:\/\/[^\s<>"]+)/gi, function(match) {
2678 const placeholder = `__PROTECTED_${protectedSegments.length}__`;
2679 protectedSegments.push(match);
2680 return placeholder;
2681 });
2682
2683 // Step 2: Now apply text styling to the protected text
2684 // Handle bold text (**text**)
2685 protectedText = protectedText.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
2686
2687 // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind)
2688 // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another *
2689 protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
2690
2691 // Handle underscores for italic - Safari-compatible (no lookbehind), and
2692 // word-bounded (plan 9dc34f): the opener must follow the start of the
2693 // text or a non-word character, the closer must precede the end or a
2694 // non-word character, and the content may not start or end with a
2695 // space. So my_var_name, MXCHAT_API_KEY and snake_case stay as typed
2696 // while "use _this_ word" still italicises. A token whose content is
2697 // entirely upper-case letters and digits (_VALUE_, _ID_) is a placeholder
2698 // convention, not prose emphasis — it is left verbatim too (ticket 440).
2699 // __PROTECTED_N__ placeholders cannot match: their underscores are all
2700 // adjacent to another underscore or a word character.
2701 protectedText = protectedText.replace(/(^|[^\p{L}\p{N}_])_((?!PROTECTED)[^\s_](?:[^_\n]*[^\s_])?)_(?![\p{L}\p{N}_])/gu, function(match, before, content) {
2702 if (/^[\p{Lu}\p{N}]+$/u.test(content)) {
2703 return match;
2704 }
2705 return before + '<em>' + content + '</em>';
2706 });
2707
2708 // Handle strikethrough (~~text~~)
2709 protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>');
2710
2711 // Step 3: Restore all protected segments
2712 protectedSegments.forEach((original, index) => {
2713 const placeholder = `__PROTECTED_${index}__`;
2714 protectedText = protectedText.replace(placeholder, original);
2715 });
2716
2717 return protectedText;
2718 }
2719 function formatBoldText(text) {
2720 // This function is kept for compatibility but now uses formatTextStyling
2721 return formatTextStyling(text);
2722 }
2723
2724 function convertNewlinesToBreaks(text) {
2725 // Split the text into paragraphs (marked by double newlines or multiple <br> tags)
2726 const paragraphs = text.split(/(?:\n\n|\<br\>\s*\<br\>)/g);
2727
2728 // Filter out empty paragraphs and wrap each paragraph in <p> tags
2729 return paragraphs
2730 .map(para => para.trim())
2731 .filter(para => para.length > 0) // Remove empty paragraphs
2732 .map(para => `<p>${para}</p>`)
2733 .join('');
2734 }
2735 function formatCodeBlocks(text) {
2736 // Handle fenced code blocks with language specification (```language)
2737 text = text.replace(/```(\w+)?\n?([\s\S]*?)```/g, (match, language, code) => {
2738 const lang = language || 'text';
2739 const escapedCode = escapeHtml(code.trim());
2740 return `<div class="mxchat-code-block-container">
2741 <div class="mxchat-code-header">
2742 <span class="mxchat-code-language">${lang}</span>
2743 <button class="mxchat-copy-button" aria-label="Copy to clipboard">Copy</button>
2744 </div>
2745 <pre class="mxchat-code-block"><code class="language-${lang}">${escapedCode}</code></pre>
2746 </div>`;
2747 });
2748
2749 // Handle inline code with single backticks
2750 text = text.replace(/`([^`\n]+)`/g, '<code class="mxchat-inline-code">$1</code>');
2751
2752 // Handle raw PHP tags (legacy support)
2753 text = text.replace(/(<\?php[\s\S]*?\?>)/g, (match) => {
2754 const escapedCode = escapeHtml(match);
2755 return `<div class="mxchat-code-block-container">
2756 <div class="mxchat-code-header">
2757 <span class="mxchat-code-language">php</span>
2758 <button class="mxchat-copy-button" aria-label="Copy to clipboard">Copy</button>
2759 </div>
2760 <pre class="mxchat-code-block"><code class="language-php">${escapedCode}</code></pre>
2761 </div>`;
2762 });
2763
2764 return text;
2765 }
2766
2767 function formatMarkdownTables(text) {
2768 var lines = text.split('\n');
2769 var result = [];
2770 var i = 0;
2771
2772 while (i < lines.length) {
2773 // Check for a table: current line has pipes AND next line is a separator row
2774 if (i + 1 < lines.length &&
2775 lines[i].indexOf('|') !== -1 &&
2776 /^\s*\|?[\s\-:]+(\|[\s\-:]+)+\|?\s*$/.test(lines[i + 1])) {
2777
2778 var tableLines = [];
2779 var headerLine = lines[i];
2780 var separatorLine = lines[i + 1];
2781 tableLines.push(headerLine);
2782 tableLines.push(separatorLine);
2783
2784 // Collect remaining table rows
2785 var j = i + 2;
2786 while (j < lines.length && lines[j].indexOf('|') !== -1 && lines[j].trim() !== '') {
2787 tableLines.push(lines[j]);
2788 j++;
2789 }
2790
2791 // Parse alignment from separator row
2792 var sepCells = separatorLine.split('|').filter(function(c) { return c.trim() !== ''; });
2793 var alignments = sepCells.map(function(cell) {
2794 var trimmed = cell.trim();
2795 if (trimmed.charAt(0) === ':' && trimmed.charAt(trimmed.length - 1) === ':') return 'center';
2796 if (trimmed.charAt(trimmed.length - 1) === ':') return 'right';
2797 return 'left';
2798 });
2799
2800 // Build HTML table
2801 var html = '<div class="mxchat-table-wrapper"><table class="mxchat-table">';
2802
2803 // Header row
2804 var headerCells = tableLines[0].split('|').filter(function(c) { return c.trim() !== ''; });
2805 html += '<thead><tr>';
2806 headerCells.forEach(function(cell, idx) {
2807 var align = alignments[idx] || 'left';
2808 html += '<th style="text-align:' + align + '">' + cell.trim() + '</th>';
2809 });
2810 html += '</tr></thead>';
2811
2812 // Body rows
2813 html += '<tbody>';
2814 for (var r = 2; r < tableLines.length; r++) {
2815 var rowCells = tableLines[r].split('|').filter(function(c) { return c.trim() !== ''; });
2816 html += '<tr>';
2817 rowCells.forEach(function(cell, idx) {
2818 var align = alignments[idx] || 'left';
2819 html += '<td style="text-align:' + align + '">' + cell.trim() + '</td>';
2820 });
2821 html += '</tr>';
2822 }
2823 html += '</tbody></table></div>';
2824
2825 result.push(html);
2826 i = j;
2827 } else {
2828 result.push(lines[i]);
2829 i++;
2830 }
2831 }
2832
2833 return result.join('\n');
2834 }
2835
2836 function sanitizeUserInput(text) {
2837 const div = document.createElement('div');
2838 div.textContent = text;
2839 return div.innerHTML;
2840 }
2841
2842 // Attribute-safe URL for href="…" / src="…" interpolation (plan 2db182).
2843 // Percent-encodes the characters that can end or extend an attribute.
2844 // Valid in every scheme the widget emits (http, https, tel, mailto) and
2845 // never changes where a legitimate link resolves — encodeURI() already
2846 // produces most of these, this just guarantees them for URLs that skip
2847 // encodeURI() because they were percent-encoded on arrival.
2848 function escapeAttr(url) {
2849 return String(url)
2850 .replace(/"/g, '%22')
2851 .replace(/'/g, '%27')
2852 .replace(/</g, '%3C')
2853 .replace(/>/g, '%3E')
2854 .replace(/`/g, '%60')
2855 .replace(/ /g, '%20');
2856 }
2857
2858 function escapeHtml(unsafe) {
2859 // Skip escaping if it's already escaped or contains HTML code block markup
2860 if (unsafe.includes('&lt;') || unsafe.includes('&gt;') ||
2861 unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) {
2862 return unsafe;
2863 }
2864
2865 return unsafe
2866 .replace(/&/g, "&amp;")
2867 .replace(/</g, "&lt;")
2868 .replace(/>/g, "&gt;")
2869 .replace(/"/g, "&quot;")
2870 .replace(/'/g, "&#039;");
2871 }
2872
2873 function decodeHTMLEntities(text) {
2874 var textArea = document.createElement('textarea');
2875 textArea.innerHTML = text;
2876 return textArea.value;
2877 }
2878
2879 // ====================================
2880 // UI & SCROLLING CONTROLS
2881 // ====================================
2882
2883 function scrollToBottom(botIdOrInstant, instant) {
2884 // Handle backward compatibility: scrollToBottom() or scrollToBottom(true/false)
2885 var botId = 'default';
2886 if (typeof botIdOrInstant === 'string') {
2887 botId = botIdOrInstant;
2888 instant = instant || false;
2889 } else if (typeof botIdOrInstant === 'boolean') {
2890 instant = botIdOrInstant;
2891 } else {
2892 instant = false;
2893 }
2894
2895 var chatBox = getElement(botId, 'chat-box');
2896 if (instant) {
2897 // Instantly set the scroll position to the bottom
2898 chatBox.scrollTop(chatBox.prop("scrollHeight"));
2899 } else {
2900 // Use requestAnimationFrame for smoother scrolling if needed
2901 let start = null;
2902 const scrollHeight = chatBox.prop("scrollHeight");
2903 const initialScroll = chatBox.scrollTop();
2904 const distance = scrollHeight - initialScroll;
2905 const duration = 500; // Duration in ms
2906
2907 function smoothScroll(timestamp) {
2908 if (!start) start = timestamp;
2909 const progress = timestamp - start;
2910 const currentScroll = initialScroll + (distance * (progress / duration));
2911 chatBox.scrollTop(currentScroll);
2912
2913 if (progress < duration) {
2914 requestAnimationFrame(smoothScroll);
2915 } else {
2916 chatBox.scrollTop(scrollHeight); // Ensure it's exactly at the bottom
2917 }
2918 }
2919
2920 requestAnimationFrame(smoothScroll);
2921 }
2922 }
2923
2924 function scrollElementToTop(element, botId, topOffset) {
2925 botId = botId || 'default';
2926 topOffset = (typeof topOffset === 'number') ? topOffset : 2;
2927 var chatBox = getElement(botId, 'chat-box');
2928 var elementTop = element.position().top + chatBox.scrollTop();
2929 chatBox.animate({ scrollTop: Math.max(0, elementTop - topOffset) }, 500);
2930 }
2931
2932 function showChatWidget(botId) {
2933 botId = botId || 'default';
2934 var $button = getElement(botId, 'floating-chatbot-button');
2935 // First ensure display is set
2936 $button.css('display', 'flex');
2937 // Then handle the fade
2938 $button.fadeTo(500, 1);
2939 // Force visibility
2940 $button.removeClass('hidden');
2941 }
2942
2943 function hideChatWidget(botId) {
2944 botId = botId || 'default';
2945 var $button = getElement(botId, 'floating-chatbot-button');
2946 $button.css('display', 'none');
2947 $button.addClass('hidden');
2948 }
2949
2950 function disableScroll() {
2951 if (isMobile()) {
2952 $('body').css('overflow', 'hidden');
2953 }
2954 }
2955
2956 function enableScroll() {
2957 if (isMobile()) {
2958 $('body').css('overflow', '');
2959 }
2960 }
2961
2962 function isMobile() {
2963 // This can be a simple check, or more sophisticated detection of mobile devices
2964 return window.innerWidth <= 768; // Example threshold for mobile devices
2965 }
2966
2967 function setFullHeight() {
2968 var vh = $(window).innerHeight() * 0.01;
2969 $(':root').css('--vh', vh + 'px');
2970 }
2971
2972
2973 // ====================================
2974 // NOTIFICATION SYSTEM
2975 // ====================================
2976
2977 function createNotificationBadge() {
2978 const chatButton = document.getElementById('floating-chatbot-button');
2979
2980 if (!chatButton) return;
2981
2982 // Remove any existing badge first
2983 const existingBadge = chatButton.querySelector('.chat-notification-badge');
2984 if (existingBadge) {
2985 existingBadge.remove();
2986 }
2987
2988 notificationBadge = document.createElement('div');
2989 notificationBadge.className = 'chat-notification-badge';
2990 notificationBadge.style.cssText = `
2991 display: none;
2992 position: absolute;
2993 top: -5px;
2994 right: -5px;
2995 background-color: red;
2996 color: white;
2997 border-radius: 50%;
2998 padding: 4px 8px;
2999 font-size: 12px;
3000 font-weight: bold;
3001 z-index: 10001;
3002 `;
3003 chatButton.style.position = 'relative';
3004 chatButton.appendChild(notificationBadge);
3005
3006 }
3007
3008 function showNotification(botId) {
3009 botId = botId || 'default';
3010 const badge = getElementDOM(botId, 'chat-notification-badge');
3011 var $floatingChatbot = getElement(botId, 'floating-chatbot');
3012 if (badge && $floatingChatbot.hasClass('hidden')) {
3013 badge.style.display = 'block';
3014 badge.textContent = '1';
3015 }
3016 }
3017
3018 function hideNotification(botId) {
3019 botId = botId || 'default';
3020 const badge = getElementDOM(botId, 'chat-notification-badge');
3021 if (badge) {
3022 badge.style.display = 'none';
3023 }
3024 }
3025
3026 function startNotificationChecking(botId) {
3027 botId = botId || 'default';
3028 const chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3029 if (!chatPersistenceEnabled) return;
3030
3031 createNotificationBadge(botId);
3032 var instance = MxChatInstances.get(botId);
3033 instance.notificationCheckInterval = setInterval(function() {
3034 checkForNewMessages(botId);
3035 }, 30000); // Check every 30 seconds
3036 }
3037
3038 function stopNotificationChecking(botId) {
3039 botId = botId || 'default';
3040 var instance = MxChatInstances.get(botId);
3041 if (instance.notificationCheckInterval) {
3042 clearInterval(instance.notificationCheckInterval);
3043 }
3044 }
3045
3046 function checkForNewMessages() {
3047 const sessionId = getChatSession();
3048 const chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3049
3050 if (!chatPersistenceEnabled) return;
3051
3052 $.ajax({
3053 url: mxchatChat.ajax_url,
3054 type: 'POST',
3055 data: {
3056 action: 'mxchat_check_new_messages',
3057 session_id: sessionId,
3058 last_seen_id: lastSeenMessageId,
3059 nonce: mxchatChat.nonce
3060 },
3061 success: function(response) {
3062 if (response.success && response.data.hasNewMessages) {
3063 showNotification();
3064 }
3065 }
3066 });
3067 }
3068
3069
3070 // ====================================
3071 // LIVE AGENT FUNCTIONALITY
3072 // ====================================
3073
3074 function startPolling(botId) {
3075 botId = botId || 'default';
3076 var instance = MxChatInstances.get(botId);
3077 // Clear any existing interval first
3078 stopPolling(botId);
3079 instance.pollingInterval = setInterval(function() {
3080 checkForAgentMessages(botId);
3081 }, 5000);
3082 }
3083
3084 function stopPolling(botId) {
3085 botId = botId || 'default';
3086 var instance = MxChatInstances.get(botId);
3087 if (instance.pollingInterval) {
3088 clearInterval(instance.pollingInterval);
3089 instance.pollingInterval = null;
3090 }
3091 }
3092
3093 function checkForAgentMessages(botId) {
3094 botId = botId || 'default';
3095 var instance = MxChatInstances.get(botId);
3096 const sessionId = getChatSession(botId);
3097 $.ajax({
3098 url: mxchatChat.ajax_url,
3099 type: 'POST',
3100 dataType: 'json',
3101 data: {
3102 action: 'mxchat_fetch_new_messages',
3103 session_id: sessionId,
3104 last_seen_id: instance.lastSeenMessageId,
3105 persistence_enabled: 'true',
3106 nonce: mxchatChat.nonce
3107 },
3108 success: function (response) {
3109 if (response.success && response.data?.new_messages) {
3110 let hasNewMessage = false;
3111
3112 response.data.new_messages.forEach(function (message) {
3113 if (message.role === "agent" && !instance.processedMessageIds.has(message.id)) {
3114 hasNewMessage = true;
3115 appendMessage("agent", message.content, '', [], false, botId);
3116 instance.lastSeenMessageId = message.id;
3117 instance.processedMessageIds.add(message.id);
3118 }
3119 });
3120
3121 if (hasNewMessage) {
3122 enableChatInput(botId);
3123 }
3124
3125 var $floatingChatbot = getElement(botId, 'floating-chatbot');
3126 if (hasNewMessage && $floatingChatbot.hasClass('hidden')) {
3127 showNotification(botId);
3128 }
3129
3130 scrollToBottom(botId, true);
3131 }
3132
3133 // Handle chat mode transitions (e.g. agent ended chat via !endchat)
3134 if (response.success && response.data?.chat_mode) {
3135 updateChatModeIndicator(response.data.chat_mode, botId);
3136 }
3137 },
3138 error: function (xhr, status, error) {
3139 // Polling error - silently continue
3140 }
3141 });
3142 }
3143
3144 // ====================================
3145 // CHAT HISTORY & PERSISTENCE
3146 // ====================================
3147
3148 function loadChatHistory(botId, onComplete) {
3149 botId = botId || 'default';
3150 var instance = MxChatInstances.get(botId);
3151
3152 // Prevent duplicate loading
3153 if (instance.chatHistoryLoaded) {
3154 if (onComplete) onComplete();
3155 return;
3156 }
3157
3158 // Use getChatSession which returns null if no session exists (does NOT create one)
3159 var sessionId = getChatSession(botId);
3160 var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3161
3162 // No session yet — nothing to load. History will load after first message via ensureSession.
3163 if (!sessionId) {
3164 instance.chatHistoryLoaded = true;
3165 if (onComplete) onComplete();
3166 return;
3167 }
3168
3169 if (chatPersistenceEnabled && sessionId) {
3170 $.ajax({
3171 url: mxchatChat.ajax_url,
3172 type: 'POST',
3173 dataType: 'json',
3174 data: {
3175 action: 'mxchat_fetch_conversation_history',
3176 session_id: sessionId
3177 },
3178 success: function(response) {
3179 // Handle session reset (IP changed while user was away)
3180 if (response.success === false && response.data && response.data.action === 'reset_session') {
3181 // Silent reset — new session but don't clear UI
3182 MxChatInstances.silentResetSession(botId);
3183 instance.chatHistoryLoaded = true; // Prevent retry loop
3184 if (onComplete) onComplete();
3185 return;
3186 }
3187
3188 // Check if the response indicates success
3189 if (response.success) {
3190 // Handle case where conversation data exists and is an array
3191 if (response.data && Array.isArray(response.data.conversation)) {
3192 var $chatBox = getElement(botId, 'chat-box');
3193 var $fragment = $(document.createDocumentFragment());
3194 let highestMessageId = instance.lastSeenMessageId;
3195
3196 // Update chat mode if provided
3197 if (response.data.chat_mode) {
3198 updateChatModeIndicator(response.data.chat_mode, botId);
3199 }
3200
3201 // Only process if there are actual messages
3202 if (response.data.conversation.length > 0) {
3203 // Restored history must be SILENT to screen readers
3204 // (plan 67f126): these are DOM additions inside the
3205 // live region and would otherwise announce as if
3206 // they just arrived. Lift aria-live for the batch
3207 // repopulate, restore it after the browser has
3208 // processed the mutations.
3209 var mxLiveRegionEl = $chatBox.get(0);
3210 var mxSavedAriaLive = mxLiveRegionEl ? mxLiveRegionEl.getAttribute('aria-live') : null;
3211 if (mxLiveRegionEl) {
3212 mxLiveRegionEl.setAttribute('aria-live', 'off');
3213 }
3214
3215 // IMPORTANT: Clear existing messages before loading history.
3216 // Detach the greeting first and put it back below —
3217 // it is server-rendered and never stored in the
3218 // transcript, so the old unconditional .empty()
3219 // deleted it for the rest of the page life (plan
3220 // a1a79b). Detach rather than rebuild: intro_message
3221 // is not localized to JS, and this node already
3222 // carries the per-bot inline colors and any
3223 // {visitor_name} substitution already applied to it.
3224 var $mxIntro = $chatBox.find('.mxchat-intro-message').first().detach();
3225 $chatBox.empty();
3226
3227 $.each(response.data.conversation, function(index, message) {
3228 // Skip agent messages if persistence is off
3229 if (!chatPersistenceEnabled && message.role === 'agent') {
3230 return;
3231 }
3232
3233 var messageClass, messageBgColor, messageFontColor;
3234
3235 switch (message.role) {
3236 case 'user':
3237 messageClass = 'user-message';
3238 messageBgColor = userMessageBgColor;
3239 messageFontColor = userMessageFontColor;
3240 break;
3241 case 'agent':
3242 messageClass = 'agent-message';
3243 messageBgColor = liveAgentMessageBgColor;
3244 messageFontColor = liveAgentMessageFontColor;
3245 break;
3246 default:
3247 messageClass = 'bot-message';
3248 messageBgColor = botMessageBgColor;
3249 messageFontColor = botMessageFontColor;
3250 break;
3251 }
3252
3253 var messageElement = $('<div>').addClass(messageClass)
3254 .css({
3255 'background': messageBgColor,
3256 'color': messageFontColor
3257 });
3258
3259 var content = message.content;
3260 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
3261 content = decodeHTMLEntities(content);
3262
3263 // A visitor's own rows get the same treatment as the
3264 // live turn (appendMessage escapes user text before
3265 // linkify). Without this a replayed user message came
3266 // back as live markup after a refresh (plan 2db182).
3267 // Bot/agent rows are trusted HTML and stay as they were.
3268 if (message.role === 'user') {
3269 content = sanitizeUserInput(content);
3270 }
3271
3272 // Skip linkify for messages containing structured HTML
3273 // (forms, product cards, galleries, etc.) to avoid
3274 // markdown formatting corrupting HTML attributes
3275 // (e.g. underscores in name="field_name" becoming <em> tags).
3276 // One family check instead of a per-card literal list: any
3277 // element carrying an mxchat- prefixed class is MxChat-generated
3278 // structured markup and replays raw. The old list drifted every
3279 // time an add-on minted a new card class — the filtered-search
3280 // card ("mxchat-filtered-product-card") missed it and replayed
3281 // through linkify as visible markup.
3282 if (/<[a-z][^>]*class\s*=\s*["'][^"']*\bmxchat-/i.test(content) ||
3283 content.includes("<form") ||
3284 content.includes("<input") ||
3285 content.includes("<select") ||
3286 content.includes("<textarea")) {
3287 messageElement.html(content);
3288 } else {
3289 var formattedContent = linkify(content);
3290 messageElement.html(formattedContent);
3291 }
3292
3293 $fragment.append(messageElement);
3294
3295 // Track message IDs
3296 if (message.id) {
3297 highestMessageId = Math.max(highestMessageId, message.id);
3298 instance.processedMessageIds.add(message.id);
3299 }
3300 });
3301
3302 // Only append messages and scroll if we have content.
3303 // Greeting goes back FIRST, above the restored
3304 // history: "Hello — [earlier conversation]" is the
3305 // natural reading and matches the order a fresh
3306 // visitor sees (plan a1a79b).
3307 if ($mxIntro && $mxIntro.length) {
3308 $chatBox.append($mxIntro);
3309 }
3310 $chatBox.append($fragment);
3311 scrollToBottom(botId, true);
3312
3313 // Re-attach live semantics AFTER the rehydration
3314 // mutations have been processed with the region off
3315 // (plan 67f126). Restoring later announces nothing
3316 // retroactively; new turns announce normally.
3317 if (mxLiveRegionEl) {
3318 setTimeout(function() {
3319 mxLiveRegionEl.setAttribute('aria-live', mxSavedAriaLive || 'polite');
3320 }, 200);
3321 }
3322
3323 // Collapse quick questions if we have conversation history
3324 // BUT skip auto-collapse for embedded bots (they should stay expanded)
3325 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
3326 collapseQuickQuestions(botId);
3327 }
3328
3329 // Update lastSeenMessageId after history loads
3330 instance.lastSeenMessageId = highestMessageId;
3331
3332 // Only update chat mode if persistence is enabled and we have messages
3333 if (chatPersistenceEnabled) {
3334 var lastMessage = response.data.conversation[response.data.conversation.length - 1];
3335 if (lastMessage.role === 'agent') {
3336 updateChatModeIndicator('agent', botId);
3337 }
3338 }
3339
3340 // Mark as loaded ONLY after successful load
3341 instance.chatHistoryLoaded = true;
3342 }
3343 }
3344 }
3345 if (onComplete) onComplete();
3346 },
3347 error: function(xhr, status, error) {
3348 // Error loading chat history - silently continue
3349 if (onComplete) onComplete();
3350 }
3351 });
3352 } else {
3353 if (onComplete) onComplete();
3354 }
3355 }
3356
3357
3358 // ====================================
3359 // FILE UPLOAD FUNCTIONALITY
3360 // ====================================
3361
3362 function addSafeEventListener(elementId, eventType, handler) {
3363 const element = document.getElementById(elementId);
3364 if (element) {
3365 element.addEventListener(eventType, handler);
3366 }
3367 }
3368
3369 function showActivePdf(filename, botId) {
3370 botId = botId || 'default';
3371 const container = getElementDOM(botId, 'active-pdf-container');
3372 const nameElement = getElementDOM(botId, 'active-pdf-name');
3373
3374 if (!container || !nameElement) {
3375 return;
3376 }
3377
3378 nameElement.textContent = filename;
3379 container.style.display = 'flex';
3380 }
3381
3382 function showActiveWord(filename, botId) {
3383 botId = botId || 'default';
3384 const container = getElementDOM(botId, 'active-word-container');
3385 const nameElement = getElementDOM(botId, 'active-word-name');
3386
3387 if (!container || !nameElement) {
3388 return;
3389 }
3390
3391 nameElement.textContent = filename;
3392 container.style.display = 'flex';
3393 }
3394
3395 function removeActivePdf(botId) {
3396 botId = botId || 'default';
3397 var instance = MxChatInstances.get(botId);
3398 const container = getElementDOM(botId, 'active-pdf-container');
3399 const nameElement = getElementDOM(botId, 'active-pdf-name');
3400
3401 if (!container || !nameElement || !instance.activePdfFile) return;
3402
3403 fetch(mxchatChat.ajax_url, {
3404 method: 'POST',
3405 headers: {
3406 'Content-Type': 'application/x-www-form-urlencoded',
3407 },
3408 body: new URLSearchParams({
3409 'action': 'mxchat_remove_pdf',
3410 'session_id': getChatSession(botId),
3411 'nonce': mxchatChat.nonce
3412 })
3413 })
3414 .then(response => response.json())
3415 .then(data => {
3416 if (data.success) {
3417 container.style.display = 'none';
3418 nameElement.textContent = '';
3419 instance.activePdfFile = null;
3420 appendMessage('bot', 'PDF removed.', '', [], false, botId);
3421 }
3422 })
3423 .catch(error => {
3424 // Error removing PDF - silently continue
3425 });
3426 }
3427
3428 function removeActiveWord(botId) {
3429 botId = botId || 'default';
3430 var instance = MxChatInstances.get(botId);
3431 const container = getElementDOM(botId, 'active-word-container');
3432 const nameElement = getElementDOM(botId, 'active-word-name');
3433
3434 if (!container || !nameElement || !instance.activeWordFile) return;
3435
3436 fetch(mxchatChat.ajax_url, {
3437 method: 'POST',
3438 headers: {
3439 'Content-Type': 'application/x-www-form-urlencoded',
3440 },
3441 body: new URLSearchParams({
3442 'action': 'mxchat_remove_word',
3443 'session_id': getChatSession(botId),
3444 'nonce': mxchatChat.nonce
3445 })
3446 })
3447 .then(response => response.json())
3448 .then(data => {
3449 if (data.success) {
3450 container.style.display = 'none';
3451 nameElement.textContent = '';
3452 instance.activeWordFile = null;
3453 appendMessage('bot', 'Word document removed.', '', [], false, botId);
3454 }
3455 })
3456 .catch(error => {
3457 // Error removing Word document - silently continue
3458 });
3459 }
3460
3461 // ====================================
3462 // CONSENT & COMPLIANCE (GDPR)
3463 // ====================================
3464
3465 function initializeChatVisibility(botId) {
3466 botId = botId || 'default';
3467 const complianzEnabled = mxchatChat.complianz_toggle === 'on' ||
3468 mxchatChat.complianz_toggle === '1' ||
3469 mxchatChat.complianz_toggle === 1;
3470
3471 if (complianzEnabled && typeof cmplz_has_consent === "function" && typeof complianz !== 'undefined') {
3472 // Initial check
3473 checkConsentAndShowChat(botId);
3474
3475 // Listen for consent changes
3476 $(document).on('cmplz_status_change', function(event) {
3477 checkConsentAndShowChat(botId);
3478 });
3479 } else {
3480 // If Complianz is not enabled, always show
3481 getElement(botId, 'floating-chatbot-button')
3482 .css('display', 'flex')
3483 .removeClass('hidden no-consent')
3484 .fadeTo(500, 1);
3485
3486 // Also check pre-chat message when Complianz is not enabled
3487 checkPreChatDismissal(botId);
3488 }
3489 }
3490
3491
3492 function checkConsentAndShowChat(botId) {
3493 botId = botId || 'default';
3494 var consentStatus = cmplz_has_consent('marketing');
3495 var consentType = complianz.consenttype;
3496
3497 let $widget = getElement(botId, 'floating-chatbot-button');
3498 let $chatbot = getElement(botId, 'floating-chatbot');
3499 let $preChat = getElement(botId, 'pre-chat-message');
3500
3501 if (consentStatus === true) {
3502 $widget
3503 .removeClass('no-consent')
3504 .css('display', 'flex')
3505 .removeClass('hidden')
3506 .fadeTo(500, 1);
3507 $chatbot.removeClass('no-consent');
3508
3509 // Show pre-chat message if not dismissed
3510 checkPreChatDismissal(botId);
3511 } else {
3512 $widget
3513 .addClass('no-consent')
3514 .fadeTo(500, 0, function() {
3515 $(this)
3516 .css('display', 'none')
3517 .addClass('hidden');
3518 });
3519 $chatbot.addClass('no-consent');
3520
3521 // Hide pre-chat message when no consent
3522 $preChat.hide();
3523 }
3524 }
3525
3526
3527 // ====================================
3528 // PRE-CHAT MESSAGE HANDLING
3529 // ====================================
3530
3531 function checkPreChatDismissal(botId) {
3532 botId = botId || 'default';
3533 try {
3534 var dismissedAt = localStorage.getItem('mxchat_pre_chat_dismissed_' + botId);
3535 if (dismissedAt) {
3536 // Re-show after 24 hours
3537 var elapsed = Date.now() - parseInt(dismissedAt, 10);
3538 if (elapsed < 86400000) {
3539 getElement(botId, 'pre-chat-message').hide();
3540 return;
3541 }
3542 // Expired — clear and show again
3543 localStorage.removeItem('mxchat_pre_chat_dismissed_' + botId);
3544 }
3545 getElement(botId, 'pre-chat-message').fadeIn(250);
3546 } catch (e) {
3547 // localStorage unavailable — show the message
3548 getElement(botId, 'pre-chat-message').fadeIn(250);
3549 }
3550 }
3551
3552 function handlePreChatDismissal(botId) {
3553 botId = botId || 'default';
3554 getElement(botId, 'pre-chat-message').fadeOut(200);
3555 try {
3556 localStorage.setItem('mxchat_pre_chat_dismissed_' + botId, String(Date.now()));
3557 } catch (e) {
3558 // localStorage unavailable — dismissal won't persist
3559 }
3560 }
3561
3562
3563 // ====================================
3564 // UTILITY FUNCTIONS
3565 // ====================================
3566
3567 function copyToClipboard(text) {
3568 var tempInput = $('<input>');
3569 $('body').append(tempInput);
3570 tempInput.val(text).select();
3571 document.execCommand('copy');
3572 tempInput.remove();
3573 }
3574
3575
3576 function isImageHtml(str) {
3577 return str.startsWith('<img') && str.endsWith('>');
3578 }
3579
3580
3581 // ====================================
3582 // EVENT HANDLERS & INITIALIZATION
3583 // ====================================
3584
3585 $(document).on('click', '.mxchat-popular-question', function () {
3586 var question = $(this).text();
3587 var botId = getBotIdFromElement(this);
3588
3589 // Append the question as if the user typed it
3590 appendMessage("user", question, '', [], false, botId);
3591
3592 // Only collapse if there are questions
3593 if (hasQuickQuestions(botId)) {
3594 collapseQuickQuestions(botId);
3595 }
3596
3597 // Send the question to the server
3598 sendMessageToChatbot(question, botId);
3599 });
3600
3601 $(document).on('click', '.questions-toggle-btn', function(e) {
3602 e.preventDefault();
3603 e.stopPropagation();
3604 var botId = getBotIdFromElement(this);
3605 expandQuickQuestions(botId);
3606 });
3607
3608 $(document).on('click', '.questions-collapse-btn', function(e) {
3609 e.preventDefault();
3610 e.stopPropagation();
3611 var botId = getBotIdFromElement(this);
3612 collapseQuickQuestions(botId);
3613 });
3614
3615 // Consent-safe YouTube embed (plan 03ba33): the server only ever ships a
3616 // thumbnail facade — no Google iframe exists until the visitor taps play.
3617 // Delegated so it also works for embeds restored from chat history.
3618 $(document).on('click', '.mxchat-youtube-embed .mxchat-youtube-facade', function(e) {
3619 e.preventDefault();
3620 var $wrap = $(this).closest('.mxchat-youtube-embed');
3621 var videoId = String($wrap.data('video-id') || '').replace(/[^A-Za-z0-9_-]/g, '');
3622 if (!videoId) {
3623 return;
3624 }
3625 var title = $wrap.find('.mxchat-youtube-title').text() || 'YouTube video';
3626 var $iframe = $('<iframe>', {
3627 src: 'https://www.youtube-nocookie.com/embed/' + videoId + '?autoplay=1&rel=0',
3628 title: title,
3629 allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
3630 allowfullscreen: true,
3631 frameborder: 0
3632 }).addClass('mxchat-youtube-iframe');
3633 $wrap.addClass('mxchat-youtube-playing');
3634 $(this).replaceWith($iframe);
3635 });
3636
3637 // Chatbot visibility toggle handlers - use class selector for multi-instance support
3638 // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3639 $(document).on('click keydown', '.floating-chatbot-button', function(e) {
3640 if (e.type === 'keydown') {
3641 if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
3642 e.preventDefault();
3643 }
3644 var botId = getBotIdFromElement(this);
3645 var $chatbot = getElement(botId, 'floating-chatbot');
3646 var $badge = getElement(botId, 'chat-notification-badge');
3647 var $preChat = getElement(botId, 'pre-chat-message');
3648
3649 if ($chatbot.hasClass('hidden')) {
3650 $chatbot.removeClass('hidden').addClass('visible')
3651 .attr('aria-modal', 'true').attr('role', 'dialog');
3652 $(this).addClass('hidden').attr('aria-expanded', 'true');
3653 $badge.hide(); // Hide notification when opening chat
3654 disableScroll();
3655 $preChat.fadeOut(250);
3656 mxchatFireOpened(botId);
3657
3658 // First open per page load: re-fetch behavior settings in case
3659 // this page's inline values came from a stale full-page cache
3660 // (plan-32db95). Idempotent — later opens are a no-op.
3661 mxchatRefreshDynamicSettings();
3662
3663 // Load chat history for returning visitors (persistence)
3664 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3665 if (chatPersistenceEnabled) {
3666 MxChatInstances.ensureSession(botId);
3667 }
3668
3669 // Deferred email check — only on first widget open
3670 var emailBlocker = getElementDOM(botId, 'email-blocker');
3671 var instance = MxChatInstances.get(botId);
3672 if (emailBlocker && !instance.emailCheckDone) {
3673 instance.emailCheckDone = true;
3674 resolveEmailState(botId);
3675 } else if (!emailBlocker) {
3676 // No email collection — still route through showChatContainerForBot
3677 // so the loader is shown while chat history loads
3678 showChatContainerForBot(botId);
3679 }
3680
3681 // Move keyboard focus into the message input after the open transition.
3682 setTimeout(function() {
3683 var chatInput = getElementDOM(botId, 'chat-input');
3684 if (chatInput && !chatInput.disabled) {
3685 try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
3686 }
3687 }, 300);
3688 } else {
3689 $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
3690 $(this).removeClass('hidden').attr('aria-expanded', 'false');
3691 enableScroll();
3692 checkPreChatDismissal(botId);
3693 }
3694 });
3695
3696 // Allow clicking anywhere on the title bar to close the chatbot.
3697 // Returns keyboard focus to the launcher so keyboard users don't get
3698 // stranded at <body> (WCAG SC 2.4.3 Focus Order). :focus-visible is
3699 // heuristic-based so mouse-triggered close won't show a focus ring.
3700 $(document).on('click', '.chatbot-top-bar', function() {
3701 var botId = getBotIdFromElement(this);
3702 getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3703 var $launcher = getElement(botId, 'floating-chatbot-button');
3704 $launcher.removeClass('hidden').attr('aria-expanded', 'false');
3705 enableScroll();
3706 try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3707 });
3708
3709 // Global Escape-key handler — closes any visible chat widget and
3710 // returns focus to its launcher. Standard modal-dismissal pattern;
3711 // pairs with aria-modal="true" set on the widget when it opens.
3712 $(document).on('keydown', function(e) {
3713 if (e.key !== 'Escape' && e.key !== 'Esc') return;
3714 var $visible = $('.floating-chatbot.visible');
3715 if (!$visible.length) return;
3716 e.preventDefault();
3717 $visible.each(function() {
3718 var botId = getBotIdFromElement(this);
3719 $(this).addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3720 var $launcher = getElement(botId, 'floating-chatbot-button');
3721 $launcher.removeClass('hidden').attr('aria-expanded', 'false');
3722 try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3723 });
3724 enableScroll();
3725 });
3726
3727 $(document).on('click', '.close-pre-chat-message', function(e) {
3728 e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
3729 var botId = getBotIdFromElement(this);
3730 handlePreChatDismissal(botId);
3731 });
3732
3733
3734 // PDF upload button handlers - use class selector
3735 $(document).on('click', '.pdf-upload-btn', function() {
3736 var botId = getBotIdFromElement(this);
3737 var pdfInput = getElementDOM(botId, 'pdf-upload');
3738 if (pdfInput) pdfInput.click();
3739 });
3740
3741 // Word upload button handlers - use class selector
3742 $(document).on('click', '.word-upload-btn', function() {
3743 var botId = getBotIdFromElement(this);
3744 var wordInput = getElementDOM(botId, 'word-upload');
3745 if (wordInput) wordInput.click();
3746 });
3747
3748 // PDF file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'pdf-upload')
3749 $(document).on('change', '.pdf-upload', async function(e) {
3750 var botId = getBotIdFromElement(this);
3751 var instance = MxChatInstances.get(botId);
3752 const file = this.files[0];
3753 const sessionId = MxChatInstances.ensureSession(botId);
3754
3755 if (!file || file.type !== 'application/pdf') {
3756 alert('Please select a valid PDF file.');
3757 return;
3758 }
3759
3760 if (!sessionId) {
3761 alert('Error: No session ID found');
3762 return;
3763 }
3764
3765 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3766 alert('Error: Ajax configuration missing');
3767 return;
3768 }
3769
3770 // Disable buttons and show loading state
3771 const uploadBtn = getElementDOM(botId, 'pdf-upload-btn');
3772 const sendBtn = getElementDOM(botId, 'send-button');
3773 if (!uploadBtn) return;
3774 const originalBtnContent = uploadBtn.innerHTML;
3775
3776 try {
3777 // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3778 await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3779 const formData = new FormData();
3780 formData.append('action', 'mxchat_upload_pdf');
3781 formData.append('pdf_file', file);
3782 formData.append('session_id', sessionId);
3783 formData.append('nonce', mxchatChat.nonce);
3784
3785 uploadBtn.disabled = true;
3786 if (sendBtn) sendBtn.disabled = true;
3787 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3788 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3789 </svg>`;
3790
3791 const response = await fetch(mxchatChat.ajax_url, {
3792 method: 'POST',
3793 body: formData
3794 });
3795
3796 const data = await response.json();
3797
3798 if (data.success) {
3799 // Hide popular questions if they exist
3800 if (hasQuickQuestions(botId)) {
3801 collapseQuickQuestions(botId);
3802 }
3803
3804 // Show the active PDF name
3805 showActivePdf(data.data.filename, botId);
3806
3807 appendMessage('bot', data.data.message, '', [], false, botId);
3808 scrollToBottom(botId);
3809 instance.activePdfFile = data.data.filename;
3810 } else {
3811 alert('Failed to upload PDF. Please try again.');
3812 }
3813 } catch (error) {
3814 alert('Error uploading file. Please try again.');
3815 } finally {
3816 uploadBtn.disabled = false;
3817 if (sendBtn) sendBtn.disabled = false;
3818 uploadBtn.innerHTML = originalBtnContent;
3819 this.value = ''; // Reset file input
3820 }
3821 });
3822
3823 // Word file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'word-upload')
3824 $(document).on('change', '.word-upload', async function(e) {
3825 var botId = getBotIdFromElement(this);
3826 var instance = MxChatInstances.get(botId);
3827 const file = this.files[0];
3828 const sessionId = MxChatInstances.ensureSession(botId);
3829
3830 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
3831 alert('Please select a valid Word document (.docx).');
3832 return;
3833 }
3834
3835 if (!sessionId) {
3836 alert('Error: No session ID found');
3837 return;
3838 }
3839
3840 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3841 alert('Error: Ajax configuration missing');
3842 return;
3843 }
3844
3845 // Disable buttons and show loading state
3846 const uploadBtn = getElementDOM(botId, 'word-upload-btn');
3847 const sendBtn = getElementDOM(botId, 'send-button');
3848 if (!uploadBtn) return;
3849 const originalBtnContent = uploadBtn.innerHTML;
3850
3851 try {
3852 // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3853 await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3854 const formData = new FormData();
3855 formData.append('action', 'mxchat_upload_word');
3856 formData.append('word_file', file);
3857 formData.append('session_id', sessionId);
3858 formData.append('nonce', mxchatChat.nonce);
3859
3860 uploadBtn.disabled = true;
3861 if (sendBtn) sendBtn.disabled = true;
3862 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3863 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3864 </svg>`;
3865
3866 const response = await fetch(mxchatChat.ajax_url, {
3867 method: 'POST',
3868 body: formData
3869 });
3870
3871 const data = await response.json();
3872
3873 if (data.success) {
3874 // Hide popular questions if they exist
3875 if (hasQuickQuestions(botId)) {
3876 collapseQuickQuestions(botId);
3877 }
3878
3879 // Show the active Word document name
3880 showActiveWord(data.data.filename, botId);
3881
3882 appendMessage('bot', data.data.message, '', [], false, botId);
3883 scrollToBottom(botId);
3884 instance.activeWordFile = data.data.filename;
3885 } else {
3886 alert('Failed to upload Word document. Please try again.');
3887 }
3888 } catch (error) {
3889 alert('Error uploading file. Please try again.');
3890 } finally {
3891 uploadBtn.disabled = false;
3892 if (sendBtn) sendBtn.disabled = false;
3893 uploadBtn.innerHTML = originalBtnContent;
3894 this.value = ''; // Reset file input
3895 }
3896 });
3897
3898 // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids)
3899 $(document).on('click', '.remove-pdf-btn', function(e) {
3900 e.preventDefault();
3901 e.stopPropagation();
3902 removeActivePdf(getBotIdFromElement(this));
3903 });
3904
3905 $(document).on('click', '.remove-word-btn', function(e) {
3906 e.preventDefault();
3907 e.stopPropagation();
3908 removeActiveWord(getBotIdFromElement(this));
3909 });
3910
3911 // Window resize handlers
3912 $(window).on('resize orientationchange', function() {
3913 setFullHeight();
3914 });
3915
3916
3917 // ====================================
3918 // TOOLBAR & STYLING SETUP
3919 // ====================================
3920
3921 // Apply toolbar settings
3922 if (mxchatChat.chat_toolbar_toggle === 'on') {
3923 $('.chat-toolbar').show();
3924 } else {
3925 $('.chat-toolbar').hide();
3926 }
3927 // Toolbar pass done — server-rendered add-on items can bind (plan 65c9b6).
3928 MxChatInstances.toolbarReady = true;
3929 mxchatDispatch('mxchat:toolbar-ready', { enabled: mxchatChat.chat_toolbar_toggle === 'on' });
3930
3931 // Apply toolbar icon colors
3932 const toolbarElements = [
3933 '#mxchat-chatbot .toolbar-btn svg',
3934 '#mxchat-chatbot .active-pdf-name',
3935 '#mxchat-chatbot .active-word-name',
3936 '#mxchat-chatbot .remove-pdf-btn svg',
3937 '#mxchat-chatbot .remove-word-btn svg',
3938 '#mxchat-chatbot .toolbar-perplexity svg'
3939 ];
3940
3941 toolbarElements.forEach(selector => {
3942 $(selector).css({
3943 'fill': toolbarIconColor,
3944 'stroke': toolbarIconColor,
3945 'color': toolbarIconColor
3946 });
3947 });
3948
3949
3950 // ====================================
3951 // INIT LOADER & CHAT CONTAINER HELPERS
3952 // ====================================
3953 // These must be outside the email collection block so they're always available
3954 // (used by persistence loading even when email collection is off)
3955
3956 function showInitLoader(botId) {
3957 var loader = getElementDOM(botId, 'mxchat-init-loader');
3958 if (loader) loader.style.display = 'flex';
3959 }
3960
3961 function hideInitLoader(botId) {
3962 var loader = getElementDOM(botId, 'mxchat-init-loader');
3963 if (loader) loader.style.display = 'none';
3964 }
3965
3966 function showEmailFormForBot(botId) {
3967 hideInitLoader(botId);
3968 var emailBlocker = getElementDOM(botId, 'email-blocker');
3969 var chatContainer = getElementDOM(botId, 'chat-container');
3970 if (emailBlocker) emailBlocker.style.display = 'flex';
3971 if (chatContainer) chatContainer.style.display = 'none';
3972 }
3973
3974 function showChatContainerForBot(botId) {
3975 var emailBlocker = getElementDOM(botId, 'email-blocker');
3976 var chatContainer = getElementDOM(botId, 'chat-container');
3977 if (emailBlocker) emailBlocker.style.display = 'none';
3978
3979 var instance = MxChatInstances.get(botId);
3980 var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3981
3982 // If persistence is on and history hasn't loaded yet, show loader
3983 // while history loads to prevent flash of empty chat
3984 if (chatPersistenceEnabled && !instance.chatHistoryLoaded) {
3985 if (chatContainer) chatContainer.style.display = 'none';
3986 showInitLoader(botId);
3987 loadChatHistory(botId, function() {
3988 hideInitLoader(botId);
3989 if (chatContainer) chatContainer.style.display = 'flex';
3990 scrollToBottom(botId, true);
3991 });
3992 } else {
3993 hideInitLoader(botId);
3994 if (chatContainer) chatContainer.style.display = 'flex';
3995 if (typeof loadChatHistory === 'function') {
3996 loadChatHistory(botId);
3997 }
3998 }
3999 }
4000
4001 // ====================================
4002 // EMAIL COLLECTION SETUP - MULTI-INSTANCE VERSION
4003 // ====================================
4004 // Only run email collection setup if it's enabled
4005 if (mxchatChat && mxchatChat.email_collection_enabled === 'on') {
4006
4007 // Track submitting state per bot
4008 const emailSubmittingState = {};
4009
4010 // Add CSS animations for email form (once globally)
4011 if (!document.getElementById('email-error-styles')) {
4012 const style = document.createElement('style');
4013 style.id = 'email-error-styles';
4014 style.textContent = `
4015 @keyframes fadeInError {
4016 from { opacity: 0; transform: translateY(-5px); }
4017 to { opacity: 1; transform: translateY(0); }
4018 }
4019 .email-input-shake {
4020 animation: shake 0.5s ease-in-out;
4021 }
4022 @keyframes shake {
4023 0%, 100% { transform: translateX(0); }
4024 25% { transform: translateX(-5px); }
4025 75% { transform: translateX(5px); }
4026 }
4027 @keyframes spin {
4028 from { transform: rotate(0deg); }
4029 to { transform: rotate(360deg); }
4030 }
4031 .email-spinner {
4032 display: inline-block;
4033 vertical-align: middle;
4034 }
4035 `;
4036 document.head.appendChild(style);
4037 }
4038
4039 function isValidEmailAddress(email) {
4040 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
4041 return emailRegex.test(email.trim()) && email.length <= 254;
4042 }
4043
4044 function isValidNameInput(name) {
4045 return name && name.trim().length >= 2 && name.trim().length <= 100;
4046 }
4047
4048 /**
4049 * Replace {visitor_name} placeholder in intro message with actual visitor name
4050 * @param {string} botId - The bot instance ID
4051 * @param {string} visitorName - The visitor's name to insert
4052 */
4053 function replaceVisitorNamePlaceholder(botId, visitorName) {
4054 var chatBox = getElementDOM(botId, 'chat-box');
4055 if (!chatBox) return;
4056
4057 // Find the greeting by its marker, not by position (plan a1a79b) —
4058 // after a persistence restore the first .bot-message is a restored
4059 // reply, and {visitor_name} was being substituted into that instead.
4060 // Positional fallback for HTML cached before this release only.
4061 var introMessage = chatBox.querySelector('.mxchat-intro-message') || chatBox.querySelector('.bot-message');
4062 if (!introMessage) return;
4063
4064 var messageContent = introMessage.querySelector('div[dir="auto"]');
4065 if (!messageContent) return;
4066
4067 var html = messageContent.innerHTML;
4068
4069 // Replace {visitor_name} placeholder (case-insensitive)
4070 if (visitorName && visitorName.trim()) {
4071 // Escape HTML to prevent XSS
4072 var safeName = $('<div>').text(visitorName.trim()).html();
4073 html = html.replace(/\{visitor_name\}/gi, safeName);
4074 } else {
4075 // Remove placeholder and clean up spacing if no name provided
4076 html = html.replace(/\{visitor_name\}/gi, '');
4077 // Clean up any double spaces that might result
4078 html = html.replace(/\s{2,}/g, ' ').trim();
4079 }
4080
4081 messageContent.innerHTML = html;
4082 }
4083
4084 function setEmailSubmissionState(botId, loading) {
4085 var submitButton = getElementDOM(botId, 'email-submit-button');
4086 var emailInput = getElementDOM(botId, 'user-email');
4087 var nameInput = getElementDOM(botId, 'user-name');
4088
4089 if (loading) {
4090 emailSubmittingState[botId] = true;
4091 if (submitButton) submitButton.disabled = true;
4092 if (emailInput) emailInput.disabled = true;
4093 if (nameInput) nameInput.disabled = true;
4094
4095 if (submitButton && !submitButton.getAttribute('data-original-html')) {
4096 submitButton.setAttribute('data-original-html', submitButton.innerHTML);
4097 const originalText = submitButton.textContent;
4098 submitButton.innerHTML = `
4099 <svg class="email-spinner" style="width: 16px; height: 16px; margin-right: 8px; animation: spin 1s linear infinite;" viewBox="0 0 24 24">
4100 <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" stroke-dasharray="31.416" stroke-dashoffset="31.416">
4101 <animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416" repeatCount="indefinite"/>
4102 <animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416" repeatCount="indefinite"/>
4103 </circle>
4104 </svg>
4105 ${originalText}
4106 `;
4107 submitButton.style.opacity = '0.8';
4108 }
4109 } else {
4110 emailSubmittingState[botId] = false;
4111 if (submitButton) submitButton.disabled = false;
4112 if (emailInput) emailInput.disabled = false;
4113 if (nameInput) nameInput.disabled = false;
4114
4115 if (submitButton) {
4116 const originalHtml = submitButton.getAttribute('data-original-html');
4117 if (originalHtml) {
4118 submitButton.innerHTML = originalHtml;
4119 }
4120 submitButton.style.opacity = '1';
4121 }
4122 }
4123 }
4124
4125 function showEmailError(botId, message) {
4126 clearEmailError(botId);
4127
4128 var emailForm = getElementDOM(botId, 'email-collection-form');
4129 if (!emailForm) return;
4130
4131 const errorDiv = document.createElement('div');
4132 errorDiv.className = 'email-error';
4133 errorDiv.style.cssText = `
4134 color: #e74c3c;
4135 font-size: 12px;
4136 margin-top: 8px;
4137 padding: 4px 0;
4138 animation: fadeInError 0.3s ease;
4139 `;
4140 errorDiv.textContent = message;
4141 emailForm.appendChild(errorDiv);
4142
4143 // Add shake animation to inputs
4144 var emailInput = getElementDOM(botId, 'user-email');
4145 var nameInput = getElementDOM(botId, 'user-name');
4146
4147 if (emailInput) {
4148 emailInput.classList.add('email-input-shake');
4149 setTimeout(() => emailInput.classList.remove('email-input-shake'), 500);
4150 }
4151 if (nameInput) {
4152 nameInput.classList.add('email-input-shake');
4153 setTimeout(() => nameInput.classList.remove('email-input-shake'), 500);
4154 }
4155 }
4156
4157 function clearEmailError(botId) {
4158 var emailForm = getElementDOM(botId, 'email-collection-form');
4159 if (emailForm) {
4160 const existingErrors = emailForm.querySelectorAll('.email-error');
4161 existingErrors.forEach(error => error.remove());
4162 }
4163 }
4164
4165 // Resolve email state using server-side data when available, AJAX fallback otherwise
4166 function resolveEmailState(botId) {
4167 if (mxchatChat.skip_email_check && mxchatChat.initial_email_state) {
4168 if (mxchatChat.initial_email_state.show_email_form) {
4169 showEmailFormForBot(botId);
4170 } else {
4171 showChatContainerForBot(botId);
4172 }
4173 } else {
4174 checkSessionAndEmailForBot(botId);
4175 }
4176 }
4177
4178 function checkSessionAndEmailForBot(botId) {
4179 const sessionId = MxChatInstances.ensureSession(botId);
4180
4181 // Hide both panels while we check — show loader instead
4182 var emailBlocker = getElementDOM(botId, 'email-blocker');
4183 var chatContainer = getElementDOM(botId, 'chat-container');
4184 if (emailBlocker) emailBlocker.style.display = 'none';
4185 if (chatContainer) chatContainer.style.display = 'none';
4186 showInitLoader(botId);
4187
4188 fetch(mxchatChat.ajax_url, {
4189 method: 'POST',
4190 headers: {
4191 'Content-Type': 'application/x-www-form-urlencoded',
4192 },
4193 body: new URLSearchParams({
4194 action: 'mxchat_check_email_provided',
4195 session_id: sessionId,
4196 nonce: mxchatChat.nonce,
4197 })
4198 })
4199 .then((response) => {
4200 if (!response.ok) {
4201 throw new Error(`HTTP error! status: ${response.status}`);
4202 }
4203 return response.json();
4204 })
4205 .then((data) => {
4206 if (data.success) {
4207 // A success response means the server has already decided the
4208 // required info is on file. With email optional (c0cfaf) that
4209 // info can be a name and no address, so keying on data.email
4210 // alone re-prompted a name-only visitor on every page load.
4211 if (data.data.logged_in || data.data.email || data.data.name) {
4212 showChatContainerForBot(botId);
4213 } else {
4214 showEmailFormForBot(botId);
4215 }
4216 } else {
4217 // Pre-fill for a signed-in visitor being shown the form
4218 // (c0cfaf). It arrives on this nocache'd response rather than
4219 // in the page HTML, which is cacheable. Never overwrite what
4220 // the visitor has already typed.
4221 if (data.data) {
4222 prefillEmailForm(botId, data.data.prefill_name, data.data.prefill_email);
4223 }
4224 showEmailFormForBot(botId);
4225 }
4226 })
4227 .catch((error) => {
4228 showEmailFormForBot(botId);
4229 });
4230 }
4231
4232 function prefillEmailForm(botId, name, email) {
4233 var nameInput = getElementDOM(botId, 'user-name');
4234 var emailInput = getElementDOM(botId, 'user-email');
4235 if (nameInput && name && !nameInput.value) {
4236 nameInput.value = name;
4237 }
4238 if (emailInput && email && !emailInput.value) {
4239 emailInput.value = email;
4240 }
4241 }
4242
4243 // Event delegation for email form submission
4244 $(document).on('submit', '.email-collection-form', function(e) {
4245 e.preventDefault();
4246 e.stopPropagation();
4247
4248 var botId = getBotIdFromElement(this);
4249
4250 // Prevent double submission
4251 if (emailSubmittingState[botId]) {
4252 return false;
4253 }
4254
4255 var emailInput = getElementDOM(botId, 'user-email');
4256 var nameInput = getElementDOM(botId, 'user-name');
4257 var consentInput = getElementDOM(botId, 'user-consent');
4258 var userEmail = emailInput ? emailInput.value.trim() : '';
4259 var userName = nameInput ? nameInput.value.trim() : '';
4260 var sessionId = MxChatInstances.ensureSession(botId);
4261
4262 // Validate email. The address is optional when the owner turned
4263 // "Require Email Address" off (c0cfaf) — but anything actually typed
4264 // still has to be a valid address. The server enforces both rules
4265 // independently.
4266 var emailRequired = (mxchatChat.lead_capture_require_email || 'on') !== 'off';
4267
4268 if (emailRequired && !userEmail) {
4269 showEmailError(botId, 'Please enter your email address.');
4270 return false;
4271 }
4272
4273 if (userEmail && !isValidEmailAddress(userEmail)) {
4274 showEmailError(botId, 'Please enter a valid email address.');
4275 return false;
4276 }
4277
4278 if (!emailRequired && !userEmail && !userName) {
4279 showEmailError(botId, 'Please enter your name or email address.');
4280 return false;
4281 }
4282
4283 // Validate name if field exists and has content
4284 if (nameInput && userName && !isValidNameInput(userName)) {
4285 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
4286 return false;
4287 }
4288
4289 // Consent checkbox (b062c4): backstop behind the native required
4290 // attribute; the server enforces this independently either way.
4291 if (consentInput && consentInput.required && !consentInput.checked) {
4292 showEmailError(botId, 'Please tick the consent box to continue.');
4293 return false;
4294 }
4295
4296 clearEmailError(botId);
4297 setEmailSubmissionState(botId, true);
4298
4299 // Prepare form data
4300 const formData = new URLSearchParams({
4301 action: 'mxchat_handle_save_email_and_response',
4302 email: userEmail,
4303 session_id: sessionId,
4304 nonce: mxchatChat.nonce,
4305 });
4306
4307 if (userName) {
4308 formData.append('name', userName);
4309 }
4310
4311 // Ticked/unticked both travel when the checkbox is rendered, so an
4312 // optional-consent "no" is recorded as a decision, not an absence.
4313 if (consentInput) {
4314 formData.append('consent', consentInput.checked ? '1' : '0');
4315 }
4316
4317 fetch(mxchatChat.ajax_url, {
4318 method: 'POST',
4319 headers: {
4320 'Content-Type': 'application/x-www-form-urlencoded',
4321 },
4322 body: formData
4323 })
4324 .then((response) => {
4325 if (!response.ok) {
4326 throw new Error(`HTTP error! status: ${response.status}`);
4327 }
4328 return response.json();
4329 })
4330 .then((data) => {
4331 setEmailSubmissionState(botId, false);
4332
4333 if (data.success) {
4334 showChatContainerForBot(botId);
4335
4336 // Replace {visitor_name} placeholder in intro message with actual name
4337 if (userName) {
4338 replaceVisitorNamePlaceholder(botId, userName);
4339 } else {
4340 // Remove placeholder if no name provided
4341 replaceVisitorNamePlaceholder(botId, '');
4342 }
4343
4344 if (data.message && typeof appendMessage === 'function') {
4345 setTimeout(() => {
4346 appendMessage('bot', data.message, '', [], false, botId);
4347 if (typeof scrollToBottom === 'function') {
4348 scrollToBottom(botId);
4349 }
4350 }, 100);
4351 }
4352 } else {
4353 showEmailError(botId, data.message || 'Failed to save email. Please try again.');
4354 }
4355 })
4356 .catch((error) => {
4357 setEmailSubmissionState(botId, false);
4358 showEmailError(botId, 'An error occurred. Please try again.');
4359 });
4360
4361 return false;
4362 });
4363
4364 // Real-time email validation using event delegation
4365 $(document).on('input', '.mxchat-email-input', function() {
4366 var botId = getBotIdFromElement(this);
4367 var $input = $(this);
4368
4369 // Clear previous timeout
4370 clearTimeout($input.data('validationTimeout'));
4371
4372 // Debounce validation
4373 var timeout = setTimeout(() => {
4374 var email = this.value.trim();
4375 clearEmailError(botId);
4376
4377 if (email && !isValidEmailAddress(email)) {
4378 showEmailError(botId, 'Please enter a valid email address.');
4379 }
4380 }, 500);
4381
4382 $input.data('validationTimeout', timeout);
4383 });
4384
4385 // Handle Enter key in email input
4386 $(document).on('keypress', '.mxchat-email-input', function(e) {
4387 if (e.key === 'Enter') {
4388 e.preventDefault();
4389 var botId = getBotIdFromElement(this);
4390 if (!emailSubmittingState[botId]) {
4391 $(this).closest('.email-collection-form').submit();
4392 }
4393 }
4394 });
4395
4396 // Handle Enter key in name input
4397 $(document).on('keypress', '.mxchat-name-input', function(e) {
4398 if (e.key === 'Enter') {
4399 e.preventDefault();
4400 var botId = getBotIdFromElement(this);
4401 if (!emailSubmittingState[botId]) {
4402 $(this).closest('.email-collection-form').submit();
4403 }
4404 }
4405 });
4406
4407 // Initialize email check for all bot instances
4408 // For floating bots: defer until widget is opened (zero passive AJAX)
4409 // For embedded bots: check immediately since the form is visible
4410 $('.mxchat-chatbot-wrapper').each(function() {
4411 var botId = $(this).data('bot-id') || 'default';
4412 var emailBlocker = getElementDOM(botId, 'email-blocker');
4413
4414 if (emailBlocker) {
4415 if (isEmbeddedBot(botId)) {
4416 // Embedded bots are always visible — check now
4417 resolveEmailState(botId);
4418 }
4419 // Floating bots: handled in the widget open handler
4420 } else if (isEmbeddedBot(botId)) {
4421 // Embedded bot, no email collection — load history with loader
4422 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4423 if (chatPersistenceEnabled) {
4424 MxChatInstances.ensureSession(botId);
4425 showChatContainerForBot(botId);
4426 }
4427 }
4428 });
4429 }
4430
4431 // Open chatbot when pre-chat message is clicked - use class selector for multi-instance
4432 $(document).on('click', '.pre-chat-message', function() {
4433 var botId = getBotIdFromElement(this);
4434 var $chatbot = getElement(botId, 'floating-chatbot');
4435 if ($chatbot.hasClass('hidden')) {
4436 $chatbot.removeClass('hidden').addClass('visible');
4437 getElement(botId, 'floating-chatbot-button').addClass('hidden');
4438 handlePreChatDismissal(botId);
4439 disableScroll(); // Disable scroll when chatbot opens
4440 mxchatFireOpened(botId);
4441
4442 // Load chat history for returning visitors (persistence)
4443 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4444 if (chatPersistenceEnabled) {
4445 MxChatInstances.ensureSession(botId);
4446 }
4447
4448 // Deferred email check — only on first widget open
4449 var emailBlocker = getElementDOM(botId, 'email-blocker');
4450 var instance = MxChatInstances.get(botId);
4451 if (emailBlocker && !instance.emailCheckDone) {
4452 instance.emailCheckDone = true;
4453 resolveEmailState(botId);
4454 } else if (!emailBlocker) {
4455 showChatContainerForBot(botId);
4456 }
4457 }
4458 });
4459
4460 // Legacy duplicate close handler removed — handled by single event delegation above
4461
4462
4463 function hasQuickQuestions(botId) {
4464 botId = botId || 'default';
4465 var questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
4466 if (!questionsContainer) return false;
4467 const questionButtons = questionsContainer.querySelectorAll('.mxchat-popular-question');
4468 return questionButtons.length > 0;
4469 }
4470
4471 /**
4472 * Check if a bot is embedded (not floating)
4473 * Embedded bots don't have a .floating-chatbot wrapper
4474 */
4475 function isEmbeddedBot(botId) {
4476 botId = botId || 'default';
4477 var floatingWrapper = document.getElementById('floating-chatbot-' + botId);
4478 return !floatingWrapper;
4479 }
4480
4481 function collapseQuickQuestions(botId) {
4482 botId = botId || 'default';
4483 const questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
4484 if (questionsContainer && hasQuickQuestions(botId)) {
4485 questionsContainer.classList.add('collapsed');
4486 questionsContainer.classList.add('has-been-collapsed');
4487 try {
4488 sessionStorage.setItem('mxchat_questions_collapsed_' + botId, 'true');
4489 sessionStorage.setItem('mxchat_questions_has_been_collapsed_' + botId, 'true');
4490 } catch (e) {
4491 // Ignore if sessionStorage is not available
4492 }
4493 }
4494 }
4495
4496 function expandQuickQuestions(botId) {
4497 botId = botId || 'default';
4498 const questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
4499 if (questionsContainer && hasQuickQuestions(botId)) {
4500 questionsContainer.classList.remove('collapsed');
4501 try {
4502 sessionStorage.setItem('mxchat_questions_collapsed_' + botId, 'false');
4503 } catch (e) {
4504 // Ignore if sessionStorage is not available
4505 }
4506 }
4507 }
4508
4509 function checkQuickQuestionsState(botId) {
4510 botId = botId || 'default';
4511 if (!hasQuickQuestions(botId)) {
4512 return; // Don't do anything if no questions exist
4513 }
4514
4515 // Skip restoring collapsed state for embedded bots - they should always start expanded
4516 if (isEmbeddedBot(botId)) {
4517 return;
4518 }
4519
4520 try {
4521 const isCollapsed = sessionStorage.getItem('mxchat_questions_collapsed_' + botId);
4522 const hasBeenCollapsed = sessionStorage.getItem('mxchat_questions_has_been_collapsed_' + botId);
4523
4524 const questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
4525 if (questionsContainer) {
4526 if (hasBeenCollapsed === 'true') {
4527 questionsContainer.classList.add('has-been-collapsed');
4528 }
4529 if (isCollapsed === 'true') {
4530 questionsContainer.classList.add('collapsed');
4531 }
4532 }
4533 } catch (e) {
4534 // Ignore if sessionStorage is not available
4535 }
4536 }
4537
4538 // Global delegation for dynamically added links as fallback
4539 // Use class selector for multi-instance support
4540 $(document).on('click', '.chat-box a[href]:not([data-tracked])', function(e) {
4541 const $link = $(this);
4542 const messageDiv = $link.closest('.bot-message, .agent-message');
4543
4544 // Only process bot/agent message links
4545 if (messageDiv.length > 0) {
4546 const originalHref = $link.attr('href');
4547
4548 if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
4549 e.preventDefault();
4550 e.stopPropagation();
4551
4552 // Mark as tracked
4553 $link.attr('data-tracked', 'true');
4554
4555 // Get bot ID from the chat box context
4556 var botId = getBotIdFromElement(this);
4557
4558 // Get message context from the message div
4559 const messageText = messageDiv.text().substring(0, 200);
4560
4561 $.ajax({
4562 url: mxchatChat.ajax_url,
4563 type: 'POST',
4564 data: {
4565 action: 'mxchat_track_url_click',
4566 session_id: getChatSession(botId),
4567 url: originalHref,
4568 message_context: messageText,
4569 nonce: mxchatChat.nonce
4570 },
4571 complete: function() {
4572 if ($link.attr('target') === '_blank' || linkTarget === '_blank') {
4573 window.open(originalHref, '_blank');
4574 } else {
4575 window.location.href = originalHref;
4576 }
4577 }
4578 });
4579
4580 return false;
4581 }
4582 }
4583 });
4584
4585 // ====================================
4586 // MAIN INITIALIZATION
4587 // ====================================
4588
4589 // Initialize all chatbot instances on the page
4590 initializeAllInstances();
4591
4592 // Legacy initialization for single bot compatibility
4593 $('.floating-chatbot.hidden').each(function() {
4594 var botId = getBotIdFromElement(this);
4595 getElement(botId, 'floating-chatbot-button').removeClass('hidden');
4596 });
4597
4598 // Initialize when document is ready
4599 setFullHeight();
4600
4601 // Note: trackOriginatingPage() and loadChatHistory() are now deferred
4602 // until the user's first interaction via MxChatInstances.ensureSession()
4603
4604 // Initialize chat visibility for all instances
4605 $('.mxchat-chatbot-wrapper').each(function() {
4606 var botId = $(this).data('bot-id') || 'default';
4607 initializeChatVisibility(botId);
4608 // An embedded widget is on screen from the first paint; a floating one
4609 // reports mxchat:opened when its window is shown (plan 65c9b6).
4610 if (isEmbeddedBot(botId)) {
4611 mxchatFireOpened(botId);
4612 } else {
4613 mxchatWatchForOpen(botId);
4614 }
4615 });
4616
4617 // Make functions globally available for add-ons
4618 window.hasQuickQuestions = hasQuickQuestions;
4619 window.collapseQuickQuestions = collapseQuickQuestions;
4620 window.appendMessage = appendMessage;
4621 window.appendThinkingMessage = appendThinkingMessage;
4622 window.scrollToBottom = scrollToBottom;
4623 window.scrollElementToTop = scrollElementToTop;
4624 window.replaceLastMessage = replaceLastMessage;
4625 window.callMxChat = callMxChat;
4626 window.callMxChatStream = callMxChatStream;
4627 window.shouldUseStreaming = shouldUseStreaming;
4628 window.getChatSession = getChatSession;
4629 window.getPageContext = getPageContext;
4630 window.updateStreamingMessage = updateStreamingMessage;
4631 window.MxChatInstances = MxChatInstances;
4632 window.getElement = getElement;
4633 window.getElementDOM = getElementDOM;
4634 window.getBotIdFromElement = getBotIdFromElement;
4635
4636 }); // End of jQuery ready
4637
4638
4639 // ====================================
4640 // GLOBAL EVENT LISTENERS (Outside jQuery)
4641 // ====================================
4642
4643 // Event listener for copy button (code blocks)
4644 document.addEventListener("click", (e) => {
4645 if (e.target.classList.contains("mxchat-copy-button")) {
4646 const copyButton = e.target;
4647 const codeBlock = copyButton
4648 .closest(".mxchat-code-block-container")
4649 .querySelector(".mxchat-code-block code");
4650
4651 if (codeBlock) {
4652 // Preserve formatting using innerText
4653 navigator.clipboard.writeText(codeBlock.innerText).then(() => {
4654 copyButton.textContent = "Copied!";
4655 copyButton.setAttribute("aria-label", "Copied to clipboard");
4656
4657 setTimeout(() => {
4658 copyButton.textContent = "Copy";
4659 copyButton.setAttribute("aria-label", "Copy to clipboard");
4660 }, 2000);
4661 });
4662 }
4663 }
4664 });
4665
4666 // ============================================================================
4667 // SATISFACTION RATING (v3.2.6)
4668 // ============================================================================
4669 // Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
4670 // inactivity following a bot reply. One prompt per session, deduped via
4671 // localStorage. Runs ONLY when the satisfaction_rating_enabled option is on —
4672 // the option (default off) is authoritative.
4673 jQuery(function($) {
4674 if (typeof mxchatChat === 'undefined') return;
4675 // wp_localize_script stringifies scalars: a PHP boolean false arrives as
4676 // '' and true as '1', so this must be an explicit-enable allowlist — the
4677 // old "disabled when exactly false/'off'" check let '' through and the
4678 // bubble rendered on sites with the option off/unset (plan-4bba64). PHP
4679 // now emits 'on'/'off' strings; true/'1'/1 keep cached pre-fix HTML
4680 // (boolean-true localizations) working.
4681 // NOTE (plan-32db95): this gate reads the INLINE value at DOM ready and is
4682 // deliberately NOT re-evaluated after the widget's dynamic-settings refresh
4683 // merges fresh values over mxchatChat (that merge fires on first widget
4684 // open, after this module has already decided). Re-evaluating would mean
4685 // restructuring the whole module to late-bind its listeners — not worth it
4686 // for a prompt that is at worst stale for one page load on a cached page.
4687 var sre = mxchatChat.satisfaction_rating_enabled;
4688 if (sre !== 'on' && sre !== true && sre !== '1' && sre !== 1) return;
4689
4690 // wp_localize_script stringifies ints, so accept both number and numeric string.
4691 var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
4692 var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);
4693 if (!isFinite(idleSeconds)) idleSeconds = 60;
4694 if (idleSeconds < 5) idleSeconds = 5;
4695 if (idleSeconds > 600) idleSeconds = 600;
4696 var IDLE_MS = idleSeconds * 1000;
4697 var MIN_BOT_REPLIES = 2;
4698 var ratingState = {};
4699
4700 function getState(botId) {
4701 if (!ratingState[botId]) {
4702 ratingState[botId] = { idleTimer: null, botReplies: 0, promptShown: false, dismissed: false };
4703 }
4704 return ratingState[botId];
4705 }
4706
4707 function getSessionId(botId) {
4708 if (typeof MxChatInstances !== 'undefined' && MxChatInstances.getChatSession) {
4709 return MxChatInstances.getChatSession(botId);
4710 }
4711 return null;
4712 }
4713
4714 function isAlreadyRated(sessionId) {
4715 if (!sessionId) return false;
4716 try { return localStorage.getItem('mxchat_rated:' + sessionId) === '1'; } catch (e) { return false; }
4717 }
4718
4719 function markRated(sessionId) {
4720 if (!sessionId) return;
4721 try { localStorage.setItem('mxchat_rated:' + sessionId, '1'); } catch (e) {}
4722 }
4723
4724 function esc(s) {
4725 return String(s == null ? '' : s)
4726 .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
4727 .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
4728 }
4729
4730 // Mirror shouldSkipInlineColors so rating bubbles defer to AI-theme CSS.
4731 function ratingSkipInlineColors(botId) {
4732 if (mxchatChat.skip_inline_colors) return true;
4733 var botAssignments = mxchatChat.bot_theme_assignments || {};
4734 return botAssignments.hasOwnProperty(botId);
4735 }
4736
4737 function botBubbleStyleAttr(botId) {
4738 if (ratingSkipInlineColors(botId)) return '';
4739 var bg = mxchatChat.bot_message_bg_color;
4740 var fg = mxchatChat.bot_message_font_color;
4741 if (!bg && !fg) return '';
4742 return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"';
4743 }
4744
4745 // Reads the rating bubble's actual computed fg+bg (whatever paints it —
4746 // the inline color pickers OR the mxchat-theme AI customizer's injected CSS)
4747 // and paints the filled "Send" pill so it fills with the bot font color and
4748 // labels in the bubble bg. Mirrors mxchatSyncMenuColors(~:1512) for the read.
4749 // We paint the submit button DIRECTLY (inline longhand) rather than relying
4750 // on the CSS rule's var()s: Chromium resolves an INHERITED custom property
4751 // unreliably inside a descendant's `background`, so a bubble-level var would
4752 // silently fall back to the literal (white-block bug all over again). Inline
4753 // longhand always wins. Same transparent-guard as the menu so we never paint
4754 // a see-through value — in that case the CSS literal fallbacks keep it legible.
4755 function syncRatingBubbleColors(botId) {
4756 var $chatBox = getChatBoxByBotId(botId);
4757 if (!$chatBox || !$chatBox.length) return;
4758 var bubbleEl = $chatBox.find('.mxchat-rating-bot-bubble').last()[0];
4759 if (!bubbleEl) return;
4760 var cs = window.getComputedStyle(bubbleEl);
4761 var fg = cs.color;
4762 var bg = cs.backgroundColor;
4763 var hasFg = fg && fg !== 'rgba(0, 0, 0, 0)' && fg !== 'transparent';
4764 var hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
4765 // Expose on the bubble too, for any inheriting styles / future use.
4766 if (hasFg) bubbleEl.style.setProperty('--mxchat-bot-fg', fg);
4767 if (hasBg) bubbleEl.style.setProperty('--mxchat-bot-bg', bg);
4768 // Paint the Send pill directly — the part that actually fixes the bug.
4769 var submitEl = bubbleEl.querySelector('.mxchat-rating-submit');
4770 if (submitEl) {
4771 if (hasFg) submitEl.style.backgroundColor = fg; // fill = bot font color
4772 if (hasBg) submitEl.style.color = bg; // label = bubble background
4773 }
4774 }
4775
4776 function copy(key) {
4777 var c = mxchatChat.satisfaction_rating_copy || {};
4778 var d = {
4779 question: 'Was this helpful?',
4780 helpful: 'Helpful',
4781 not_helpful: 'Not helpful',
4782 dismiss: 'Dismiss',
4783 thanks: 'Thanks! Anything we should improve? (optional)',
4784 placeholder: 'Tell us what could be better…',
4785 send: 'Send',
4786 skip: 'Skip',
4787 saved: 'Thanks for the feedback.'
4788 };
4789 return c[key] || d[key];
4790 }
4791
4792 function thumbUpSvg() {
4793 return '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M7.493 18.75c-.425 0-.82-.236-.975-.632A7.48 7.48 0 0 1 6 15.375c0-1.75.599-3.358 1.602-4.634.151-.192.373-.309.6-.397.473-.183.89-.514 1.212-.924a9.042 9.042 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V2.75A.75.75 0 0 1 15 2a2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H14.23c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23h-.777Z"/><path d="M2.331 10.977a11.969 11.969 0 0 0-.831 4.398 12 12 0 0 0 .52 3.507c.26.85 1.084 1.368 1.973 1.368H4.9c.445 0 .72-.498.523-.898a8.963 8.963 0 0 1-.924-3.977c0-1.708.476-3.305 1.302-4.666.245-.403-.028-.959-.5-.959H4.25c-.832 0-1.612.453-1.918 1.227Z"/></svg>';
4794 }
4795 function thumbDownSvg() {
4796 return '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M15.73 5.25h1.035A7.465 7.465 0 0 1 18 9.375a7.465 7.465 0 0 1-1.235 4.125h-.148c-.806 0-1.534.446-2.031 1.08a9.04 9.04 0 0 1-2.861 2.4c-.723.384-1.35.956-1.653 1.715a4.498 4.498 0 0 0-.322 1.672V21a.75.75 0 0 1-.75.75 2.25 2.25 0 0 1-2.25-2.25c0-1.152.26-2.243.723-3.218.266-.558-.107-1.282-.725-1.282H3.622c-1.026 0-1.945-.694-2.054-1.715A12.137 12.137 0 0 1 1.5 12c0-2.848.992-5.464 2.649-7.521C4.537 3.997 5.136 3.75 5.754 3.75h4.541c.483 0 .964.078 1.423.23l3.114 1.04c.46.152.94.23 1.423.23Z"/><path d="M21.669 13.023c.536-1.362.831-2.845.831-4.398 0-1.22-.182-2.398-.52-3.507-.26-.85-1.084-1.368-1.973-1.368H19.1c-.445 0-.72.498-.523.898.591 1.2.924 2.55.924 3.977a8.958 8.958 0 0 1-1.302 4.666c-.245.403.028.959.5.959h1.053c.832 0 1.612-.453 1.918-1.227Z"/></svg>';
4797 }
4798
4799 function buildPromptHtml(botId) {
4800 var styleAttr = botBubbleStyleAttr(botId);
4801 return ''
4802 + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4803 + '<div class="mxchat-rating-prompt" data-bot-id="' + esc(botId) + '" role="group" aria-label="' + esc(copy('question')) + '">'
4804 + '<div class="mxchat-rating-question">' + esc(copy('question')) + '</div>'
4805 + '<div class="mxchat-rating-actions">'
4806 + '<span class="mxchat-rating-buttons">'
4807 + '<button type="button" class="mxchat-rating-btn" data-rating="1" aria-label="' + esc(copy('helpful')) + '">' + thumbUpSvg() + '</button>'
4808 + '<button type="button" class="mxchat-rating-btn" data-rating="-1" aria-label="' + esc(copy('not_helpful')) + '">' + thumbDownSvg() + '</button>'
4809 + '</span>'
4810 + '<button type="button" class="mxchat-rating-dismiss" aria-label="' + esc(copy('dismiss')) + '">×</button>'
4811 + '</div>'
4812 + '</div>'
4813 + '</div>';
4814 }
4815
4816 function buildFeedbackHtml(botId, rating) {
4817 var styleAttr = botBubbleStyleAttr(botId);
4818 return ''
4819 + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4820 + '<div class="mxchat-rating-feedback" data-bot-id="' + esc(botId) + '" data-rating="' + esc(String(rating)) + '">'
4821 + '<div class="mxchat-rating-feedback-label">' + esc(copy('thanks')) + '</div>'
4822 + '<textarea class="mxchat-rating-feedback-input" maxlength="500" placeholder="' + esc(copy('placeholder')) + '" rows="2"></textarea>'
4823 + '<div class="mxchat-rating-feedback-actions">'
4824 + '<button type="button" class="mxchat-rating-skip">' + esc(copy('skip')) + '</button>'
4825 + '<button type="button" class="mxchat-rating-submit">' + esc(copy('send')) + '</button>'
4826 + '</div>'
4827 + '</div>'
4828 + '</div>';
4829 }
4830
4831 function buildSavedHtml(botId) {
4832 var styleAttr = botBubbleStyleAttr(botId);
4833 return ''
4834 + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4835 + '<div class="mxchat-rating-saved">' + esc(copy('saved')) + '</div>'
4836 + '</div>';
4837 }
4838
4839 function getChatBoxByBotId(botId) {
4840 var $byId = $('#chat-box-' + botId);
4841 if ($byId.length) return $byId.first();
4842 return $('.chat-box').first();
4843 }
4844
4845 function scrollChatBoxToBottom($chatBox) {
4846 if (!$chatBox || !$chatBox.length) return;
4847 $chatBox.scrollTop($chatBox[0].scrollHeight);
4848 }
4849
4850 function showPrompt(botId) {
4851 var s = getState(botId);
4852 if (s.promptShown || s.dismissed) return;
4853 var sessionId = getSessionId(botId);
4854 if (!sessionId) return;
4855 if (isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4856 var $chatBox = getChatBoxByBotId(botId);
4857 if (!$chatBox.length) return;
4858 if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; }
4859 $chatBox.append(buildPromptHtml(botId));
4860 syncRatingBubbleColors(botId);
4861 s.promptShown = true;
4862 scrollChatBoxToBottom($chatBox);
4863 }
4864
4865 function submitRating(botId, rating, feedback) {
4866 var sessionId = getSessionId(botId);
4867 if (!sessionId) return;
4868 $.post(mxchatChat.ajax_url, {
4869 action: 'mxchat_save_rating',
4870 session_id: sessionId,
4871 bot_id: botId,
4872 rating: rating,
4873 feedback: feedback || ''
4874 });
4875 markRated(sessionId);
4876 }
4877
4878 function onBotReply(botId) {
4879 var s = getState(botId);
4880 s.botReplies += 1;
4881 if (s.promptShown || s.dismissed) return;
4882 var sessionId = getSessionId(botId);
4883 if (sessionId && isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4884 if (s.botReplies < MIN_BOT_REPLIES) return;
4885 if (s.idleTimer) clearTimeout(s.idleTimer);
4886 s.idleTimer = setTimeout(function() { showPrompt(botId); }, IDLE_MS);
4887 }
4888
4889 function onUserMessage(botId) {
4890 var s = getState(botId);
4891 if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = null; }
4892 }
4893
4894 function botIdFromChatBox(el) {
4895 var id = el && el.id ? el.id : '';
4896 return id.indexOf('chat-box-') === 0 ? id.substring('chat-box-'.length) : 'default';
4897 }
4898
4899 function setupObserver(chatBox) {
4900 var botId = botIdFromChatBox(chatBox);
4901 try {
4902 var observer = new MutationObserver(function(mutations) {
4903 mutations.forEach(function(m) {
4904 for (var i = 0; i < m.addedNodes.length; i++) {
4905 var node = m.addedNodes[i];
4906 if (!node || node.nodeType !== 1) continue;
4907 var $n = $(node);
4908 if ($n.hasClass('mxchat-rating-bot-bubble') || $n.hasClass('mxchat-rating-prompt') || $n.hasClass('mxchat-rating-feedback') || $n.hasClass('mxchat-rating-saved')) continue;
4909 if ($n.hasClass('bot-message')) onBotReply(botId); // count at insert time — streaming providers append with .temporary-message first, then remove later (childList observer can't see attr changes)
4910 else if ($n.hasClass('user-message')) onUserMessage(botId);
4911 }
4912 });
4913 });
4914 observer.observe(chatBox, { childList: true });
4915 } catch (e) { /* noop */ }
4916 }
4917
4918 $('.chat-box').each(function() { setupObserver(this); });
4919
4920 $(document).on('click', '.mxchat-rating-btn', function(e) {
4921 e.preventDefault();
4922 var $btn = $(this);
4923 var $prompt = $btn.closest('.mxchat-rating-prompt');
4924 var $wrap = $btn.closest('.mxchat-rating-bot-bubble');
4925 var botId = $prompt.data('bot-id') || 'default';
4926 var rating = parseInt($btn.attr('data-rating'), 10);
4927 if (rating !== 1 && rating !== -1) return;
4928 submitRating(botId, rating, '');
4929 ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating));
4930 syncRatingBubbleColors(botId);
4931 scrollChatBoxToBottom(getChatBoxByBotId(botId));
4932 });
4933
4934 $(document).on('click', '.mxchat-rating-dismiss', function(e) {
4935 e.preventDefault();
4936 var $prompt = $(this).closest('.mxchat-rating-prompt');
4937 var $wrap = $(this).closest('.mxchat-rating-bot-bubble');
4938 var botId = $prompt.data('bot-id') || 'default';
4939 var s = getState(botId);
4940 s.dismissed = true;
4941 markRated(getSessionId(botId));
4942 ($wrap.length ? $wrap : $prompt).remove();
4943 });
4944
4945 function closeFeedback($fb) {
4946 var botId = $fb.data('bot-id') || 'default';
4947 var $wrap = $fb.closest('.mxchat-rating-bot-bubble');
4948 ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId));
4949 syncRatingBubbleColors(botId);
4950 scrollChatBoxToBottom(getChatBoxByBotId(botId));
4951 }
4952
4953 $(document).on('click', '.mxchat-rating-skip', function(e) {
4954 e.preventDefault();
4955 closeFeedback($(this).closest('.mxchat-rating-feedback'));
4956 });
4957
4958 $(document).on('click', '.mxchat-rating-submit', function(e) {
4959 e.preventDefault();
4960 var $fb = $(this).closest('.mxchat-rating-feedback');
4961 var botId = $fb.data('bot-id') || 'default';
4962 var rating = parseInt($fb.attr('data-rating'), 10);
4963 if (rating !== 1 && rating !== -1) { closeFeedback($fb); return; }
4964 var text = String($fb.find('.mxchat-rating-feedback-input').val() || '').trim();
4965 if (text !== '') {
4966 submitRating(botId, rating, text);
4967 }
4968 closeFeedback($fb);
4969 });
4970 });
4971
4972