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

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