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

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