PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.20
MxChat – AI Chatbot & Content Generation for WordPress v3.2.20
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 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
mxchat-basic / js / chat-script.js

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

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