PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.21
MxChat – AI Chatbot & Content Generation for WordPress v3.2.21
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.21, at js/chat-script.js

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