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

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