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

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