PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.1.3
MxChat – AI Chatbot & Content Generation for WordPress v3.1.3
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
← All changes | js/chat-script.js +264 -1425 3.2.123.1.3 View file →
@@ -1,166 +1,15 @@
1 1 jQuery(document).ready(function($) {
2 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);
3 + // Refresh nonce on load — fixes stale nonces from page-cache plugins
4 + if (typeof mxchatChat !== 'undefined' && mxchatChat.ajax_url) {
5 + $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' }, function(res) {
6 + if (res && res.success && res.data && res.data.nonce) {
7 + mxchatChat.nonce = res.data.nonce;
37 8 }
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 9 });
45 10 }
46 11
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 12 // ====================================
164 13 // MULTI-INSTANCE MANAGEMENT SYSTEM
165 14 // ====================================
166 15
@@ -203,39 +52,18 @@
203 52 return Object.keys(this.instances);
204 53 },
205 54
206 55 // 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 56 getChatSession: function(botId) {
210 57 var cookieName = 'mxchat_session_id_' + botId;
211 - var storageKey = 'mxchat_session_id_' + botId;
212 58 var sessionId = getCookie(cookieName);
213 59
214 - // Fallback to localStorage if cookie is missing (e.g. cleared by browser/consent)
215 60 if (!sessionId) {
216 - try { sessionId = localStorage.getItem(storageKey); } catch (e) {}
61 + sessionId = generateSessionId();
62 + this.setChatSession(botId, sessionId);
217 63 }
218 64
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;
65 + return sessionId;
238 66 },
239 67
240 68 // Lazy session initializer — called on first user interaction
241 69 ensureSession: function(botId) {
@@ -245,10 +73,11 @@
245 73 if (instance.sessionId) {
246 74 return instance.sessionId;
247 75 }
248 76
249 - // Check for existing session from cookie or localStorage
250 - var existingSession = this.getChatSession(botId);
77 + // Check if a cookie already exists from a prior visit
78 + var cookieName = 'mxchat_session_id_' + botId;
79 + var existingSession = getCookie(cookieName);
251 80
252 81 if (existingSession) {
253 82 instance.sessionId = existingSession;
254 83 } else {
@@ -258,13 +87,14 @@
258 87 instance.sessionId = newId;
259 88 }
260 89
261 90 // Now that we have a session, do the deferred work
262 - refreshNonceIfNeeded();
263 91 trackOriginatingPage();
264 92
265 - // Note: loadChatHistory is handled by showChatContainerForBot with loader UI,
266 - // so we do NOT call it here to avoid a race condition.
93 + var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
94 + if (chatPersistenceEnabled && mxchatChat.email_collection_enabled !== 'on') {
95 + loadChatHistory(botId);
96 + }
267 97
268 98 return instance.sessionId;
269 99 },
270 100
@@ -269,11 +99,9 @@
269 99 },
270 100
271 101 setChatSession: function(botId, sessionId) {
272 102 var cookieName = 'mxchat_session_id_' + botId;
273 - var storageKey = 'mxchat_session_id_' + botId;
274 103 document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax";
275 - try { localStorage.setItem(storageKey, sessionId); } catch (e) {}
276 104 if (this.instances[botId]) {
277 105 this.instances[botId].sessionId = sessionId;
278 106 }
279 107 },
@@ -278,10 +106,8 @@
278 106 }
279 107 },
280 108
281 109 resetChatSession: function(botId) {
282 - // Clear old session from localStorage before setting new one
283 - try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {}
284 110 var newSessionId = generateSessionId();
285 111 this.setChatSession(botId, newSessionId);
286 112 var $chatBox = getElement(botId, 'chat-box');
287 113 if ($chatBox.length) {
@@ -290,20 +116,8 @@
290 116 if (this.instances[botId]) {
291 117 this.instances[botId].chatHistoryLoaded = false;
292 118 this.instances[botId].processedMessageIds = new Set();
293 119 }
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 120 }
307 121 };
308 122
309 123 // ====================================
@@ -603,102 +417,10 @@
603 417 sendButton.disabled = false;
604 418 sendButton.style.opacity = '1';
605 419 sendButton.style.pointerEvents = 'auto';
606 420 }
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 421 }
611 422
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 423 // Update your existing sendMessage function
702 424 function sendMessage(botId) {
703 425 botId = botId || 'default';
704 426 MxChatInstances.ensureSession(botId);
@@ -719,9 +441,8 @@
719 441 }
720 442
721 443 appendMessage("user", message, '', [], false, botId);
722 444 $chatInput.val('');
723 - mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
724 445 $chatInput.css('height', 'auto');
725 446
726 447 if (hasQuickQuestions(botId)) {
727 448 collapseQuickQuestions(botId);
@@ -839,15 +560,8 @@
839 560
840 561 function callMxChat(message, callback, botId) {
841 562 botId = botId || getMxChatBotId();
842 563
843 - // Streaming fallbacks land here: drop any leftover stream handle and
844 - // return the button to its send state (no-op for plain non-stream turns).
845 - if (mxchatActiveStreams[botId]) {
846 - delete mxchatActiveStreams[botId];
847 - }
848 - mxchatRestoreSendButton(botId);
849 -
850 564 // Store the message in case we need to retry after session reset
851 565 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
852 566
853 567 // Get page context if contextual awareness is enabled
@@ -855,28 +569,13 @@
855 569
856 570 // Get instance for session start timestamp (used when persistence is OFF)
857 571 var instance = MxChatInstances.get(botId);
858 572
859 - // Guarantee a non-null session_id before the AJAX leaves. ensureSession() is idempotent
860 - // and returns the guaranteed-present session id from the in-memory instance even when
861 - // cookie/localStorage writes are silently blocked by the browser.
862 - var sessionId = MxChatInstances.ensureSession(botId);
863 - if (!sessionId || sessionId === 'null' || sessionId === 'undefined') {
864 - // Last-resort generation to ensure we never POST a null marker.
865 - sessionId = generateSessionId();
866 - MxChatInstances.setChatSession(botId, sessionId);
867 - }
868 -
869 - // Wait for the page-cache nonce refresh to complete before firing the
870 - // chat-send AJAX. On cached pages the inline mxchatChat.nonce is stale
871 - // until refreshNonceIfNeeded() returns; constructing ajaxData inside the
872 - // callback guarantees we read the fresh value. See plan-c5457f.
873 - refreshNonceIfNeeded(function() {
874 573 // Prepare AJAX data
875 574 const ajaxData = {
876 575 action: 'mxchat_handle_chat_request',
877 576 message: message,
878 - session_id: sessionId,
577 + session_id: getChatSession(botId),
879 578 nonce: mxchatChat.nonce,
880 579 current_page_url: window.location.href,
881 580 current_page_title: document.title,
882 581 bot_id: botId,
@@ -882,14 +581,14 @@
882 581 bot_id: botId,
883 582 // Pass session start timestamp so AI context matches what user sees
884 583 session_start_timestamp: instance.sessionStartTimestamp || 0
885 584 };
886 -
585 +
887 586 // Add page context if available
888 587 if (pageContext) {
889 588 ajaxData.page_context = JSON.stringify(pageContext);
890 589 }
891 -
590 +
892 591 // CHECK FOR VISION FLAGS AND ADD THEM
893 592 if (window.mxchatVisionProcessed) {
894 593 ajaxData.vision_processed = true;
895 594 ajaxData.original_user_message = window.mxchatOriginalMessage || message;
@@ -898,9 +597,9 @@
898 597 window.mxchatVisionProcessed = false;
899 598 window.mxchatOriginalMessage = null;
900 599 window.mxchatVisionImagesCount = 0;
901 600 }
902 -
601 +
903 602 $.ajax({
904 603 url: mxchatChat.ajax_url,
905 604 type: 'POST',
906 605 dataType: 'json',
@@ -938,16 +637,23 @@
938 637 errorMessage = "An error occurred. Please try again or contact support.";
939 638 }
940 639
941 640 // Handle session reset action (IP changed, session expired, etc.)
942 - // Silent reset — keep chat UI intact, just get a new session and retry
943 641 if (response.data && response.data.action === 'reset_session') {
944 - MxChatInstances.silentResetSession(botId);
945 - // Re-send the original message with the new session (user message is already displayed)
642 + // Clear the old session and generate a new one
643 + resetChatSession(botId);
644 + // Remove the temporary loading message
645 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
646 + // Re-send the original message with the new session
946 647 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
947 648 if (originalMessage) {
948 649 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
949 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
650 + // Re-add the user message and thinking indicator
651 + appendMessage("user", originalMessage, '', [], false, botId);
652 + appendThinkingMessage(botId);
653 + scrollToBottom(botId);
654 + // Determine whether to use streaming
655 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
950 656 if (shouldUseStreaming(currentModel)) {
951 657 callMxChatStream(originalMessage, function(response) {
952 658 getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
953 659 }, botId);
@@ -1041,13 +747,9 @@
1041 747 $badge.show();
1042 748 }
1043 749 }
1044 750 } else {
1045 - var emptyMsg = "I received an empty response. Please try again or contact support if this persists.";
1046 - if (response.vectorstore_error) {
1047 - emptyMsg = "I received an empty response. Debug info: " + response.vectorstore_error;
1048 - }
1049 - replaceLastMessage("bot", emptyMsg, '', [], botId);
751 + replaceLastMessage("bot", "I received an empty response. Please try again or contact support if this persists.", '', [], botId);
1050 752 }
1051 753
1052 754 if (response.message_id) {
1053 755 var instance = MxChatInstances.get(botId);
@@ -1089,9 +791,8 @@
1089 791
1090 792 replaceLastMessage("bot", errorMessage, '', [], botId);
1091 793 }
1092 794 });
1093 - }); // refreshNonceIfNeeded
1094 795 }
1095 796
1096 797 function callMxChatStream(message, callback, botId) {
1097 798 botId = botId || getMxChatBotId();
@@ -1110,25 +811,12 @@
1110 811
1111 812 // Get instance for session start timestamp (used when persistence is OFF)
1112 813 var instance = MxChatInstances.get(botId);
1113 814
1114 - // Guarantee a non-null session_id before the fetch. FormData.append() stringifies any
1115 - // non-string value via String(), so passing `null` would POST the literal string "null"
1116 - // and land in the transcripts table as a ghost session. ensureSession() always returns
1117 - // a real string even when cookies/localStorage are blocked.
1118 - var streamSessionId = MxChatInstances.ensureSession(botId);
1119 - if (!streamSessionId || streamSessionId === 'null' || streamSessionId === 'undefined') {
1120 - streamSessionId = generateSessionId();
1121 - MxChatInstances.setChatSession(botId, streamSessionId);
1122 - }
1123 -
1124 - // Wait for the page-cache nonce refresh before constructing formData (which
1125 - // captures mxchatChat.nonce by value). Mirrors callMxChat's wrapping. See plan-c5457f.
1126 - refreshNonceIfNeeded(function() {
1127 815 const formData = new FormData();
1128 816 formData.append('action', 'mxchat_stream_chat');
1129 817 formData.append('message', message);
1130 - formData.append('session_id', streamSessionId);
818 + formData.append('session_id', getChatSession(botId));
1131 819 formData.append('nonce', mxchatChat.nonce);
1132 820 formData.append('current_page_url', window.location.href);
1133 821 formData.append('current_page_title', document.title);
1134 822 formData.append('bot_id', botId);
@@ -1154,20 +842,12 @@
1154 842 let accumulatedContent = '';
1155 843 let testingDataReceived = false;
1156 844 let streamingStarted = false;
1157 845
1158 - // Abortable stream: a fresh controller per turn, keyed by bot instance.
1159 - // The Stop control (send button swapped in place) aborts both the read
1160 - // loop and the underlying request.
1161 - var streamControl = { controller: new AbortController(), aborted: false };
1162 - mxchatActiveStreams[botId] = streamControl;
1163 - mxchatShowStopButton(botId);
1164 -
1165 846 fetch(mxchatChat.ajax_url, {
1166 847 method: 'POST',
1167 848 body: formData,
1168 - credentials: 'same-origin',
1169 - signal: streamControl.controller.signal
849 + credentials: 'same-origin'
1170 850 })
1171 851 .then(response => {
1172 852 // Store the response for potential fallback handling
1173 853 const responseClone = response.clone();
@@ -1236,16 +916,8 @@
1236 916
1237 917 // Re-enable chat input when stream ends with content
1238 918 enableChatInput(botId);
1239 919
1240 - // Scroll the user's last message to the top now that the
1241 - // bot's full reply has rendered (gives max reading room).
1242 - var $chatBoxDone = getElement(botId, 'chat-box');
1243 - var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
1244 - if ($lastUserMsgDone.length) {
1245 - scrollElementToTop($lastUserMsgDone, botId);
1246 - }
1247 -
1248 920 if (callback) {
1249 921 callback(accumulatedContent);
1250 922 }
1251 923 return;
@@ -1268,16 +940,8 @@
1268 940
1269 941 // Re-enable chat input after streaming completes
1270 942 enableChatInput(botId);
1271 943
1272 - // Scroll the user's last message to the top now
1273 - // that the bot's full reply has rendered.
1274 - var $chatBoxStreamDone = getElement(botId, 'chat-box');
1275 - var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
1276 - if ($lastUserMsgStreamDone.length) {
1277 - scrollElementToTop($lastUserMsgStreamDone, botId);
1278 - }
1279 -
1280 944 if (callback) {
1281 945 callback(accumulatedContent);
1282 946 }
1283 947 return;
@@ -1334,9 +998,8 @@
1334 998 }
1335 999
1336 1000 processStream();
1337 1001 }).catch(streamError => {
1338 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1339 1002 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1340 1003 callMxChat(message, callback, botId);
1341 1004 });
1342 1005 }
@@ -1343,9 +1006,8 @@
1343 1006
1344 1007 processStream();
1345 1008 })
1346 1009 .catch(error => {
1347 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1348 1010 // Check if we have server error data with chat mode
1349 1011 if (error && error.isServerError && error.data) {
1350 1012 // Check for chat mode in error data
1351 1013 if (error.data.chat_mode) {
@@ -1358,9 +1020,8 @@
1358 1020 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1359 1021 callMxChat(message, callback, botId);
1360 1022 }
1361 1023 });
1362 - }); // refreshNonceIfNeeded
1363 1024 }
1364 1025
1365 1026 // Helper function to handle non-streaming responses
1366 1027 function handleNonStreamResponse(data, callback, botId) {
@@ -1399,16 +1060,21 @@
1399 1060 errorMessage = "An error occurred. Please try again or contact support.";
1400 1061 }
1401 1062
1402 1063 // Handle session reset action (IP changed, session expired, etc.)
1403 - // Silent reset — keep chat UI intact, just get a new session and retry
1404 1064 if (data.data && data.data.action === 'reset_session') {
1405 - MxChatInstances.silentResetSession(botId);
1406 - // Re-send the original message with the new session (user message is already displayed)
1065 + // Clear the old session and generate a new one
1066 + resetChatSession(botId);
1067 + // Re-send the original message with the new session
1407 1068 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1408 1069 if (originalMessage) {
1409 1070 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1410 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1071 + // Re-add the user message and thinking indicator
1072 + appendMessage("user", originalMessage, '', [], false, botId);
1073 + appendThinkingMessage(botId);
1074 + scrollToBottom(botId);
1075 + // Determine whether to use streaming
1076 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1411 1077 if (shouldUseStreaming(currentModel)) {
1412 1078 callMxChatStream(originalMessage, callback, botId);
1413 1079 } else {
1414 1080 callMxChat(originalMessage, callback, botId);
@@ -1569,17 +1235,8 @@
1569 1235 // Update the event handlers to use the correct function names (using event delegation)
1570 1236 // Use class-based selectors for multi-instance support
1571 1237 $(document).on('click', '.send-button', function() {
1572 1238 var botId = getBotIdFromElement(this);
1573 - // While a response is streaming the button is a Stop control.
1574 - if (this.classList.contains('mxchat-stop-mode')) {
1575 - // Same click that just started this stream (an add-on's direct handler
1576 - // ran before this delegated one) — not a Stop press. See
1577 - // mxchatShowStopButton for the full story (plan-4bba64).
1578 - if (this.__mxchatStopJustShown) return;
1579 - mxchatStopStreaming(botId);
1580 - return;
1581 - }
1582 1239 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1583 1240 if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1584 1241 disableChatInput(botId);
1585 1242 }
@@ -1598,370 +1255,9 @@
1598 1255 sendMessage(botId);
1599 1256 }
1600 1257 });
1601 1258
1602 -// Chat input character counter + soft limit feedback (plan 7091a2).
1603 -// Language-neutral: numbers + color only, no translatable strings. The counter
1604 -// reveals near the cap and ramps neutral -> amber -> red; an over-limit keystroke
1605 -// or trimmed paste produces a brief border-flash/shake so the maxlength cap (plan
1606 -// a3fae2) is never a silent "input jumps back". Per-bot scoped via .input-container.
1607 -function mxchatUpdateCharCounter(inputEl) {
1608 - if (!inputEl || !inputEl.closest) return;
1609 - var max = parseInt(inputEl.getAttribute('maxlength'), 10);
1610 - var container = inputEl.closest('.input-container');
1611 - if (!container || !max || max <= 0) return;
1612 - var counter = container.querySelector('.mxchat-char-counter');
1613 - if (!counter) return;
1614 - var len = inputEl.value.length;
1615 - var ratio = len / max;
1616 - var nearThreshold = 0.8; // start surfacing the counter at 80% of the cap
1617 - var cur = counter.querySelector('.mxchat-char-counter-current');
1618 - if (cur) cur.textContent = len;
1619 - var warn = ratio >= nearThreshold && len < max;
1620 - var full = len >= max;
1621 - counter.classList.toggle('is-visible', ratio >= nearThreshold);
1622 - counter.classList.toggle('is-warn', warn);
1623 - counter.classList.toggle('is-full', full);
1624 - container.classList.toggle('mxchat-input-near-limit', warn);
1625 - container.classList.toggle('mxchat-input-at-limit', full);
1626 -}
1627 -
1628 -function mxchatBumpInput(inputEl) {
1629 - var container = inputEl && inputEl.closest ? inputEl.closest('.input-container') : null;
1630 - if (!container) return;
1631 - container.classList.remove('mxchat-input-bump');
1632 - void container.offsetWidth; // reflow so a rapid second hit retriggers the animation
1633 - container.classList.add('mxchat-input-bump');
1634 - clearTimeout($(container).data('mxchatBumpTimeout'));
1635 - var t = setTimeout(function() { container.classList.remove('mxchat-input-bump'); }, 220);
1636 - $(container).data('mxchatBumpTimeout', t);
1637 -}
1638 -
1639 -// Live counter update on every input.
1640 -$(document).on('input', '.chat-input', function() {
1641 - mxchatUpdateCharCounter(this);
1642 -});
1643 -
1644 -// Visible "you've hit the edge" feedback when a printable keystroke is about to be
1645 -// rejected at the cap (maxlength silently swallows it otherwise).
1646 -$(document).on('keydown', '.chat-input', function(e) {
1647 - var max = parseInt(this.getAttribute('maxlength'), 10);
1648 - if (!max || max <= 0 || this.value.length < max) return;
1649 - if (e.ctrlKey || e.metaKey || e.altKey) return;
1650 - // A single printable char with no selection to overwrite WILL be rejected.
1651 - if (e.key && e.key.length === 1 && this.selectionStart === this.selectionEnd) {
1652 - mxchatBumpInput(this);
1653 - }
1654 -});
1655 -
1656 -// A paste that gets trimmed to the cap also bumps, so truncation is never silent.
1657 -$(document).on('paste', '.chat-input', function() {
1658 - var el = this;
1659 - var max = parseInt(el.getAttribute('maxlength'), 10);
1660 - if (!max || max <= 0) return;
1661 - setTimeout(function() {
1662 - mxchatUpdateCharCounter(el);
1663 - if (el.value.length >= max) mxchatBumpInput(el);
1664 - }, 0);
1665 -});
1666 -
1667 -// Builds the list of overflow-menu items for a given bot.
1668 -// Adding a future item is one push to this array — do NOT hardcode "only download."
1669 -function mxchatGetHeaderMenuItems(botId) {
1670 - var items = [];
1671 - var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1672 -
1673 - // The `print_button_*` keys still gate this item for back-compat with
1674 - // existing user options. The action is now a transcript download, not print.
1675 - if (settings.print_button_enabled === 'on') {
1676 - items.push({
1677 - id: 'download-transcript',
1678 - label: settings.print_button_label || 'Download Transcript',
1679 - 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>',
1680 - action: function() {
1681 - mxchatDownloadTranscript(botId);
1682 - }
1683 - });
1684 - }
1685 -
1686 - // "Start new chat" — surfaces the EXISTING per-conversation reset
1687 - // (MxChatInstances.resetChatSession) so a visitor can start a fresh thread
1688 - // without the site owner disabling chat persistence globally. Default OFF;
1689 - // gated by the reset_chat_enabled option. plan ac2e81.
1690 - if (settings.reset_chat_enabled === 'on') {
1691 - items.push({
1692 - id: 'reset-chat',
1693 - label: settings.reset_chat_label || 'Start new chat',
1694 - 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>',
1695 - action: function() {
1696 - var confirmMsg = settings.reset_chat_confirm || 'Start a new chat? This clears the current conversation.';
1697 - if (window.confirm(confirmMsg)) {
1698 - MxChatInstances.resetChatSession(botId);
1699 - }
1700 - }
1701 - });
1702 - }
1703 -
1704 - return items;
1705 -}
1706 -
1707 -// Builds a clean markdown transcript of the current conversation and triggers
1708 -// a file download. Used by the "Download Transcript" menu item.
1709 -function mxchatDownloadTranscript(botId) {
1710 - var $chatBox = getElement(botId, 'chat-box');
1711 - if (!$chatBox || !$chatBox.length) return;
1712 -
1713 - var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1714 - var headerTitle = settings.print_header_title || 'Chat transcript';
1715 - var now = new Date();
1716 - var stamp = now.toLocaleString();
1717 -
1718 - var lines = [];
1719 - lines.push('# ' + headerTitle);
1720 - lines.push('');
1721 - lines.push('Exported: ' + stamp);
1722 - lines.push('');
1723 - lines.push('---');
1724 - lines.push('');
1725 -
1726 - $chatBox.find('.user-message, .bot-message, .agent-message').each(function() {
1727 - var $msg = $(this);
1728 - // Skip thinking placeholders and any in-flight temporary messages.
1729 - if ($msg.find('.thinking-dots').length) return;
1730 - if ($msg.hasClass('temporary-message')) return;
1731 -
1732 - var sender;
1733 - if ($msg.hasClass('user-message')) sender = 'User';
1734 - else if ($msg.hasClass('agent-message')) sender = 'Live Agent';
1735 - else sender = 'AI Agent';
1736 -
1737 - // Strip interactive UI from the cloned message so we get the conversation text.
1738 - var $clone = $msg.clone();
1739 - $clone.find('.copy-button, .message-toolbar, .mxchat-copy, button, script, style').remove();
1740 - var text = $clone.text().replace(/ /g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
1741 - if (!text) return;
1742 -
1743 - lines.push('**' + sender + '**');
1744 - lines.push('');
1745 - lines.push(text);
1746 - lines.push('');
1747 - });
1748 -
1749 - var content = lines.join('\n');
1750 - var iso = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
1751 - var fname = 'mxchat-transcript-' + iso + '.md';
1752 - var blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
1753 - var url = URL.createObjectURL(blob);
1754 - var a = document.createElement('a');
1755 - a.href = url;
1756 - a.download = fname;
1757 - a.style.display = 'none';
1758 - document.body.appendChild(a);
1759 - a.click();
1760 - setTimeout(function() {
1761 - if (a.parentNode) a.parentNode.removeChild(a);
1762 - URL.revokeObjectURL(url);
1763 - }, 100);
1764 -}
1765 -
1766 -// Reads the bot bubble's actual computed bg+fg and writes them as CSS vars
1767 -// on the menu wrap, so the dropdown matches whatever paints the bubble —
1768 -// saved options, AI theme CSS, or the mxchat-theme add-on.
1769 -function mxchatSyncMenuColors(botId, $wrap) {
1770 - if (!$wrap || !$wrap.length) return;
1771 - var $bot = $wrap.closest('.mxchat-chatbot-wrapper').find('.bot-message').not('.temporary-message').first();
1772 - if (!$bot.length) return;
1773 - var cs = window.getComputedStyle($bot[0]);
1774 - if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') {
1775 - $wrap[0].style.setProperty('--mxchat-menu-bg', cs.backgroundColor);
1776 - }
1777 - // Bot text color usually lives on a child div, not .bot-message itself.
1778 - var $textChild = $bot.find('[style*="color"]').first();
1779 - var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
1780 - if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
1781 -}
1782 -
1783 -// Renders (or re-renders) the item list for one menu wrap. Split out of
1784 -// mxchatInitHeaderMenu so the dynamic-settings merge (plan-32db95) can
1785 -// rebuild items + trigger visibility WITHOUT re-binding the one-time
1786 -// open/close/keyboard wiring. closeMenu is passed in by the init closure;
1787 -// a rebuild before init (never happens, but harmless) just skips it.
1788 -function mxchatRenderHeaderMenuItems(botId, $wrap, closeMenuFn) {
1789 - var $trigger = $wrap.find('.mxchat-menu-trigger');
1790 - var $menu = $wrap.find('.mxchat-header-menu');
1791 - var items = mxchatGetHeaderMenuItems(botId);
1792 -
1793 - $menu.empty();
1794 -
1795 - if (!items.length) {
1796 - $trigger.hide();
1797 - $menu.hide();
1798 - return;
1799 - }
1800 -
1801 - // Clear any inline display:none a previous zero-item render left behind —
1802 - // open/close visibility is governed by the hidden prop + is-open class.
1803 - $trigger.css('display', '');
1804 - $menu.css('display', '');
1805 -
1806 - items.forEach(function(item, idx) {
1807 - var $btn = $('<button>', {
1808 - type: 'button',
1809 - 'class': 'mxchat-menu-item',
1810 - 'role': 'menuitem',
1811 - 'tabindex': '-1',
1812 - 'data-menu-id': item.id,
1813 - html: '<span class="mxchat-menu-item-icon">' + item.icon + '</span>' +
1814 - '<span class="mxchat-menu-item-label"></span>'
1815 - });
1816 - $btn.find('.mxchat-menu-item-label').text(item.label);
1817 - $btn.on('click', function(e) {
1818 - e.preventDefault();
1819 - e.stopPropagation();
1820 - if (closeMenuFn) closeMenuFn();
1821 - try { item.action(); } catch (err) { /* no-op */ }
1822 - });
1823 - $menu.append($btn);
1824 - });
1825 -}
1826 -
1827 -// Re-render every menu on the page after a dynamic-settings merge
1828 -// (multi-bot: each wrap re-reads its items). An OPEN menu is left alone —
1829 -// swapping items under the user mid-interaction yanks focus — and the
1830 -// rebuild runs when it closes instead (closeMenu checks the pending flag).
1831 -function mxchatRebuildHeaderMenus() {
1832 - $('.mxchat-header-menu-wrap').each(function() {
1833 - var $wrap = $(this);
1834 - var botId = $wrap.data('bot-id');
1835 - if (!botId) return;
1836 - if (!$wrap.data('mxchatMenuReady')) {
1837 - mxchatInitHeaderMenu(botId);
1838 - return;
1839 - }
1840 - if ($wrap.find('.mxchat-header-menu').hasClass('is-open')) {
1841 - $wrap.data('mxchatMenuRebuildPending', true);
1842 - return;
1843 - }
1844 - mxchatRenderHeaderMenuItems(botId, $wrap, $wrap.data('mxchatMenuClose'));
1845 - });
1846 -}
1847 -
1848 -// One-time per-widget init: renders menu items, wires open/close,
1849 -// outside-click, Escape, and arrow-key navigation. If no items, hides the
1850 -// trigger. Wiring happens even when there are zero items at init, so a
1851 -// later dynamic-settings rebuild that adds items has a working trigger.
1852 -function mxchatInitHeaderMenu(botId) {
1853 - var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
1854 - if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
1855 -
1856 - var $trigger = $wrap.find('.mxchat-menu-trigger');
1857 - var $menu = $wrap.find('.mxchat-header-menu');
1858 -
1859 - // Initial color sync — covers normal page load.
1860 - mxchatSyncMenuColors(botId, $wrap);
1861 -
1862 - function openMenu() {
1863 - // Re-sync each open in case the active theme changed since init.
1864 - mxchatSyncMenuColors(botId, $wrap);
1865 - $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
1866 - $trigger.attr('aria-expanded', 'true');
1867 - // Focus the first item for keyboard users
1868 - setTimeout(function() {
1869 - $menu.find('.mxchat-menu-item').first().attr('tabindex', '0').trigger('focus');
1870 - }, 0);
1871 - }
1872 - function closeMenu(returnFocus) {
1873 - $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
1874 - $trigger.attr('aria-expanded', 'false');
1875 - $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
1876 - if (returnFocus) $trigger.trigger('focus');
1877 - // A dynamic-settings rebuild that arrived while the menu was open
1878 - // was deferred (mxchatRebuildHeaderMenus) — run it now.
1879 - if ($wrap.data('mxchatMenuRebuildPending')) {
1880 - $wrap.removeData('mxchatMenuRebuildPending');
1881 - mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
1882 - }
1883 - }
1884 -
1885 - // Toggle on trigger click — stop propagation so the .chatbot-top-bar
1886 - // click-to-collapse handler does not fire.
1887 - $trigger.on('click', function(e) {
1888 - e.preventDefault();
1889 - e.stopPropagation();
1890 - if ($menu.hasClass('is-open')) closeMenu();
1891 - else openMenu();
1892 - });
1893 -
1894 - // Don't let clicks inside the menu bubble to the top-bar collapse handler.
1895 - $menu.on('click', function(e) {
1896 - e.stopPropagation();
1897 - });
1898 -
1899 - // Outside click closes the menu.
1900 - $(document).on('click.mxchatMenu-' + botId, function(e) {
1901 - if (!$menu.hasClass('is-open')) return;
1902 - if ($wrap.has(e.target).length || $wrap.is(e.target)) return;
1903 - closeMenu();
1904 - });
1905 -
1906 - // Keyboard: Escape closes and returns focus; arrow keys move focus; Enter activates.
1907 - $menu.on('keydown', '.mxchat-menu-item', function(e) {
1908 - var $items = $menu.find('.mxchat-menu-item');
1909 - var idx = $items.index(this);
1910 - if (e.key === 'Escape') {
1911 - e.preventDefault();
1912 - closeMenu(true);
1913 - } else if (e.key === 'ArrowDown') {
1914 - e.preventDefault();
1915 - var $next = $items.eq((idx + 1) % $items.length);
1916 - $items.attr('tabindex', '-1');
1917 - $next.attr('tabindex', '0').trigger('focus');
1918 - } else if (e.key === 'ArrowUp') {
1919 - e.preventDefault();
1920 - var $prev = $items.eq((idx - 1 + $items.length) % $items.length);
1921 - $items.attr('tabindex', '-1');
1922 - $prev.attr('tabindex', '0').trigger('focus');
1923 - } else if (e.key === 'Enter' || e.key === ' ') {
1924 - e.preventDefault();
1925 - $(this).trigger('click');
1926 - }
1927 - });
1928 - $trigger.on('keydown', function(e) {
1929 - if (e.key === 'Escape' && $menu.hasClass('is-open')) {
1930 - e.preventDefault();
1931 - closeMenu(true);
1932 - } else if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !$menu.hasClass('is-open')) {
1933 - e.preventDefault();
1934 - openMenu();
1935 - }
1936 - });
1937 -
1938 - // Expose closeMenu for out-of-closure re-renders (mxchatRebuildHeaderMenus),
1939 - // then do the initial item render.
1940 - $wrap.data('mxchatMenuClose', closeMenu);
1941 - mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
1942 -
1943 - $wrap.data('mxchatMenuReady', true);
1944 -}
1945 -
1946 -// Initialize header menus for every rendered widget on DOM ready.
1947 -$(function() {
1948 - $('.mxchat-header-menu-wrap').each(function() {
1949 - var botId = $(this).data('bot-id');
1950 - if (botId) mxchatInitHeaderMenu(botId);
1951 - });
1952 -
1953 - // Embedded (non-floating) widgets are open from the moment the page
1954 - // renders — refresh dynamic settings at init (plan-32db95). Floating
1955 - // widgets refresh on first launcher open instead.
1956 - var hasEmbeddedWidget = $('.mxchat-chatbot-wrapper').filter(function() {
1957 - return !$(this).closest('.floating-chatbot').length;
1958 - }).length > 0;
1959 - if (hasEmbeddedWidget) {
1960 - mxchatRefreshDynamicSettings();
1961 - }
1962 -});
1963 -
1259 +
1964 1260 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
1965 1261 try {
1966 1262 // Determine styles based on sender type
1967 1263 let messageClass, bgColor, fontColor;
@@ -1999,12 +1295,17 @@
1999 1295 'margin-bottom': '1em'
2000 1296 });
2001 1297 }
2002 1298
2003 - // Process the message content - always run linkify to convert markdown
2004 - // links and format text. linkify() handles existing HTML safely via
2005 - // negative lookaheads that skip URLs already inside <a> tags.
2006 - let fullMessage = linkify(messageText);
1299 + // Process the message content based on sender
1300 + let fullMessage;
1301 + if (sender === "user") {
1302 + // For user messages, apply linkify after sanitization
1303 + fullMessage = linkify(messageText);
1304 + } else {
1305 + // For bot/agent messages, preserve HTML
1306 + fullMessage = messageText;
1307 + }
2007 1308
2008 1309 // Add images if provided
2009 1310 if (images && images.length > 0) {
2010 1311 fullMessage += '<div class="image-gallery" dir="auto">';
@@ -2053,12 +1354,8 @@
2053 1354 if (lastUserMessage.length) {
2054 1355 scrollElementToTop(lastUserMessage, botId);
2055 1356 }
2056 1357 }
2057 -
2058 - if ((sender === "bot" || sender === "agent") && !isTemporary) {
2059 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2060 - }
2061 1358 });
2062 1359
2063 1360 if (messageText.id) {
2064 1361 var instance = MxChatInstances.get(botId);
@@ -2143,12 +1440,26 @@
2143 1440 bgColor = botMessageBgColor;
2144 1441 fontColor = botMessageFontColor;
2145 1442 }
2146 1443
2147 - // Always run linkify to convert markdown links and format text.
2148 - // linkify() already handles existing HTML (its URL patterns use negative lookaheads
2149 - // to avoid double-processing URLs that are already inside <a> tags).
2150 - var fullMessage = linkify(responseText);
1444 + // FIXED: Only linkify if response doesn't already contain HTML links or tags
1445 + // This prevents double-processing of URLs that are already formatted as HTML
1446 + var fullMessage;
1447 + if (sender === "user") {
1448 + // Always linkify user messages (they're plain text)
1449 + fullMessage = linkify(responseText);
1450 + } else {
1451 + // For bot/agent messages, check if HTML already exists
1452 + if (responseText.includes('<a href=') || responseText.includes('</a>') ||
1453 + responseText.includes('<img') || responseText.includes('<div') ||
1454 + responseText.includes('<p>') || responseText.includes('<br>')) {
1455 + // Response already has HTML, don't process it
1456 + fullMessage = responseText;
1457 + } else {
1458 + // Plain text response, apply linkify
1459 + fullMessage = linkify(responseText);
1460 + }
1461 + }
2151 1462
2152 1463 if (responseHtml) {
2153 1464 // Only add line breaks if there's actual text content before the HTML
2154 1465 if (fullMessage && fullMessage.trim()) {
@@ -2205,12 +1516,8 @@
2205 1516 }
2206 1517
2207 1518 // Re-enable chat input after response is displayed
2208 1519 enableChatInput(botId);
2209 -
2210 - if (sender === "bot" || sender === "agent") {
2211 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2212 - }
2213 1520 } else {
2214 1521 appendMessage(sender, responseText, responseHtml, images, false, botId);
2215 1522 // Re-enable chat input after response is displayed
2216 1523 enableChatInput(botId);
@@ -2313,63 +1620,37 @@
2313 1620 // Return as a proper link without the brackets
2314 1621 return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2315 1622 });
2316 1623
2317 - // Process markdown links: [text](url) and [](url)
2318 - // Uses balanced parenthesis matching to handle URLs containing parens
2319 - // (e.g. PDF filenames with dates like (2025-08-28).pdf)
2320 - processedText = (function(input) {
2321 - var result = '';
2322 - var i = 0;
2323 - while (i < input.length) {
2324 - // Look for [ at current position
2325 - if (input[i] === '[') {
2326 - // Find closing ]
2327 - var closeBracket = input.indexOf(']', i + 1);
2328 - if (closeBracket === -1 || closeBracket + 1 >= input.length || input[closeBracket + 1] !== '(') {
2329 - result += input[i];
2330 - i++;
2331 - continue;
2332 - }
2333 - var linkText = input.substring(i + 1, closeBracket);
2334 - // Check if URL starts with http
2335 - var urlStart = closeBracket + 2;
2336 - if (!input.substring(urlStart).match(/^https?:\/\//)) {
2337 - result += input[i];
2338 - i++;
2339 - continue;
2340 - }
2341 - // Find balanced closing paren
2342 - var depth = 1;
2343 - var j = urlStart;
2344 - while (j < input.length && depth > 0) {
2345 - if (input[j] === '(') depth++;
2346 - else if (input[j] === ')') depth--;
2347 - if (depth > 0) j++;
2348 - }
2349 - if (depth !== 0) {
2350 - result += input[i];
2351 - i++;
2352 - continue;
2353 - }
2354 - var url = input.substring(urlStart, j);
2355 - var cleanUrl = url.replace(/[\].,;!?]+$/, '');
2356 - var encodedUrl = safeEncodeUrl(cleanUrl);
2357 - if (!linkText || !linkText.trim()) {
2358 - result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + cleanUrl + '</a>';
2359 - } else {
2360 - var safeText = sanitizeUserInput(linkText);
2361 - result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + safeText + '</a>';
2362 - }
2363 - i = j + 1; // Skip past the closing )
2364 - } else {
2365 - result += input[i];
2366 - i++;
2367 - }
1624 + // Process proper markdown links with text: [text](url)
1625 + // This MUST have non-empty text in the first brackets
1626 + const markdownLinkPattern = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
1627 + processedText = processedText.replace(markdownLinkPattern, (match, text, url) => {
1628 + // Make sure we have actual text (not just whitespace)
1629 + if (!text || !text.trim()) {
1630 + // If no text, treat the URL as the text
1631 + let cleanUrl = url.replace(/[.,;!?]+$/, '');
1632 + const safeUrl = safeEncodeUrl(cleanUrl);
1633 + return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2368 1634 }
2369 - return result;
2370 - })(processedText);
1635 +
1636 + // Clean the URL
1637 + let cleanUrl = url.replace(/[\].,;!?]+$/, '');
1638 + const safeUrl = safeEncodeUrl(cleanUrl);
1639 + const safeText = sanitizeUserInput(text);
1640 + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
1641 + });
2371 1642
1643 + // Handle empty markdown links: [](url)
1644 + // This is a specific case where there's no text
1645 + const emptyMarkdownPattern = /\[\]\((https?:\/\/[^\s)]+)\)/g;
1646 + processedText = processedText.replace(emptyMarkdownPattern, (match, url) => {
1647 + let cleanUrl = url.replace(/[.,;!?]+$/, '');
1648 + const safeUrl = safeEncodeUrl(cleanUrl);
1649 + // Use the URL itself as the link text
1650 + return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
1651 + });
1652 +
2372 1653 // Process phone numbers: [text](tel:number)
2373 1654 const phonePattern = /\[([^\]]+)\]\((tel:[\d+\-\s()]+)\)/g;
2374 1655 processedText = processedText.replace(phonePattern, (match, text, phone) => {
2375 1656 const safePhone = safeEncodeUrl(phone);
@@ -2663,14 +1944,13 @@
2663 1944 requestAnimationFrame(smoothScroll);
2664 1945 }
2665 1946 }
2666 1947
2667 - function scrollElementToTop(element, botId, topOffset) {
1948 + function scrollElementToTop(element, botId) {
2668 1949 botId = botId || 'default';
2669 - topOffset = (typeof topOffset === 'number') ? topOffset : 2;
2670 1950 var chatBox = getElement(botId, 'chat-box');
2671 1951 var elementTop = element.position().top + chatBox.scrollTop();
2672 - chatBox.animate({ scrollTop: Math.max(0, elementTop - topOffset) }, 500);
1952 + chatBox.animate({ scrollTop: elementTop }, 500);
2673 1953 }
2674 1954
2675 1955 function showChatWidget(botId) {
2676 1956 botId = botId || 'default';
@@ -2887,29 +2167,20 @@
2887 2167 // ====================================
2888 2168 // CHAT HISTORY & PERSISTENCE
2889 2169 // ====================================
2890 2170
2891 -function loadChatHistory(botId, onComplete) {
2171 +function loadChatHistory(botId) {
2892 2172 botId = botId || 'default';
2893 2173 var instance = MxChatInstances.get(botId);
2894 2174
2895 2175 // Prevent duplicate loading
2896 2176 if (instance.chatHistoryLoaded) {
2897 - if (onComplete) onComplete();
2898 2177 return;
2899 2178 }
2900 2179
2901 - // Use getChatSession which returns null if no session exists (does NOT create one)
2902 2180 var sessionId = getChatSession(botId);
2903 2181 var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
2904 2182
2905 - // No session yet — nothing to load. History will load after first message via ensureSession.
2906 - if (!sessionId) {
2907 - instance.chatHistoryLoaded = true;
2908 - if (onComplete) onComplete();
2909 - return;
2910 - }
2911 -
2912 2183 if (chatPersistenceEnabled && sessionId) {
2913 2184 $.ajax({
2914 2185 url: mxchatChat.ajax_url,
2915 2186 type: 'POST',
@@ -2920,12 +2191,11 @@
2920 2191 },
2921 2192 success: function(response) {
2922 2193 // Handle session reset (IP changed while user was away)
2923 2194 if (response.success === false && response.data && response.data.action === 'reset_session') {
2924 - // Silent reset — new session but don't clear UI
2925 - MxChatInstances.silentResetSession(botId);
2195 + // Silently reset session - user will start fresh
2196 + resetChatSession(botId);
2926 2197 instance.chatHistoryLoaded = true; // Prevent retry loop
2927 - if (onComplete) onComplete();
2928 2198 return;
2929 2199 }
2930 2200
2931 2201 // Check if the response indicates success
@@ -2981,19 +2251,9 @@
2981 2251 var content = message.content;
2982 2252 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
2983 2253 content = decodeHTMLEntities(content);
2984 2254
2985 - // Skip linkify for messages containing structured HTML
2986 - // (forms, product cards, galleries, etc.) to avoid
2987 - // markdown formatting corrupting HTML attributes
2988 - // (e.g. underscores in name="field_name" becoming <em> tags)
2989 - if (content.includes("mxchat-product-card") ||
2990 - content.includes("mxchat-image-gallery") ||
2991 - content.includes("mxchat-featured-products") ||
2992 - content.includes("<form") ||
2993 - content.includes("<input") ||
2994 - content.includes("<select") ||
2995 - content.includes("<textarea")) {
2255 + if (content.includes("mxchat-product-card") || content.includes("mxchat-image-gallery")) {
2996 2256 messageElement.html(content);
2997 2257 } else {
2998 2258 var formattedContent = linkify(content);
2999 2259 messageElement.html(formattedContent);
@@ -3033,17 +2293,13 @@
3033 2293 instance.chatHistoryLoaded = true;
3034 2294 }
3035 2295 }
3036 2296 }
3037 - if (onComplete) onComplete();
3038 2297 },
3039 2298 error: function(xhr, status, error) {
3040 2299 // Error loading chat history - silently continue
3041 - if (onComplete) onComplete();
3042 2300 }
3043 2301 });
3044 - } else {
3045 - if (onComplete) onComplete();
3046 2302 }
3047 2303 }
3048 2304
3049 2305
@@ -3107,10 +2363,10 @@
3107 2363 .then(data => {
3108 2364 if (data.success) {
3109 2365 container.style.display = 'none';
3110 2366 nameElement.textContent = '';
3111 - instance.activePdfFile = null;
3112 - appendMessage('bot', 'PDF removed.', '', [], false, botId);
2367 + activePdfFile = null;
2368 + appendMessage('bot', 'PDF removed.');
3113 2369 }
3114 2370 })
3115 2371 .catch(error => {
3116 2372 // Error removing PDF - silently continue
@@ -3116,16 +2372,14 @@
3116 2372 // Error removing PDF - silently continue
3117 2373 });
3118 2374 }
3119 2375
3120 - function removeActiveWord(botId) {
3121 - botId = botId || 'default';
3122 - var instance = MxChatInstances.get(botId);
3123 - const container = getElementDOM(botId, 'active-word-container');
3124 - const nameElement = getElementDOM(botId, 'active-word-name');
3125 -
3126 - if (!container || !nameElement || !instance.activeWordFile) return;
3127 -
2376 + function removeActiveWord() {
2377 + const container = document.getElementById('active-word-container');
2378 + const nameElement = document.getElementById('active-word-name');
2379 +
2380 + if (!container || !nameElement || !activeWordFile) return;
2381 +
3128 2382 fetch(mxchatChat.ajax_url, {
3129 2383 method: 'POST',
3130 2384 headers: {
3131 2385 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3131,9 +2385,9 @@
3131 2385 'Content-Type': 'application/x-www-form-urlencoded',
3132 2386 },
3133 2387 body: new URLSearchParams({
3134 2388 'action': 'mxchat_remove_word',
3135 - 'session_id': getChatSession(botId),
2389 + 'session_id': sessionId,
3136 2390 'nonce': mxchatChat.nonce
3137 2391 })
3138 2392 })
3139 2393 .then(response => response.json())
@@ -3140,10 +2394,10 @@
3140 2394 .then(data => {
3141 2395 if (data.success) {
3142 2396 container.style.display = 'none';
3143 2397 nameElement.textContent = '';
3144 - instance.activeWordFile = null;
3145 - appendMessage('bot', 'Word document removed.', '', [], false, botId);
2398 + activeWordFile = null;
2399 + appendMessage('bot', 'Word document removed.');
3146 2400 }
3147 2401 })
3148 2402 .catch(error => {
3149 2403 // Error removing Word document - silently continue
@@ -3221,35 +2475,45 @@
3221 2475 // ====================================
3222 2476
3223 2477 function checkPreChatDismissal(botId) {
3224 2478 botId = botId || 'default';
3225 - try {
3226 - var dismissedAt = localStorage.getItem('mxchat_pre_chat_dismissed_' + botId);
3227 - if (dismissedAt) {
3228 - // Re-show after 24 hours
3229 - var elapsed = Date.now() - parseInt(dismissedAt, 10);
3230 - if (elapsed < 86400000) {
2479 + $.ajax({
2480 + url: mxchatChat.ajax_url,
2481 + type: 'POST',
2482 + data: {
2483 + action: 'mxchat_check_pre_chat_message_status',
2484 + _ajax_nonce: mxchatChat.nonce
2485 + },
2486 + success: function(response) {
2487 + if (response.success && !response.data.dismissed) {
2488 + getElement(botId, 'pre-chat-message').fadeIn(250);
2489 + } else {
3231 2490 getElement(botId, 'pre-chat-message').hide();
3232 - return;
3233 2491 }
3234 - // Expired — clear and show again
3235 - localStorage.removeItem('mxchat_pre_chat_dismissed_' + botId);
2492 + },
2493 + error: function() {
2494 + // Error checking pre-chat dismissal - silently continue
3236 2495 }
3237 - getElement(botId, 'pre-chat-message').fadeIn(250);
3238 - } catch (e) {
3239 - // localStorage unavailable — show the message
3240 - getElement(botId, 'pre-chat-message').fadeIn(250);
3241 - }
2496 + });
3242 2497 }
3243 2498
3244 2499 function handlePreChatDismissal(botId) {
3245 2500 botId = botId || 'default';
3246 2501 getElement(botId, 'pre-chat-message').fadeOut(200);
3247 - try {
3248 - localStorage.setItem('mxchat_pre_chat_dismissed_' + botId, String(Date.now()));
3249 - } catch (e) {
3250 - // localStorage unavailable — dismissal won't persist
3251 - }
2502 + $.ajax({
2503 + url: mxchatChat.ajax_url,
2504 + type: 'POST',
2505 + data: {
2506 + action: 'mxchat_dismiss_pre_chat_message',
2507 + _ajax_nonce: mxchatChat.nonce
2508 + },
2509 + success: function() {
2510 + $('#pre-chat-message').hide();
2511 + },
2512 + error: function() {
2513 + // Error dismissing pre-chat message - silently continue
2514 + }
2515 + });
3252 2516 }
3253 2517
3254 2518
3255 2519 // ====================================
@@ -3304,14 +2568,9 @@
3304 2568 collapseQuickQuestions(botId);
3305 2569 });
3306 2570
3307 2571 // Chatbot visibility toggle handlers - use class selector for multi-instance support
3308 - // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3309 - $(document).on('click keydown', '.floating-chatbot-button', function(e) {
3310 - if (e.type === 'keydown') {
3311 - if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
3312 - e.preventDefault();
3313 - }
2572 + $(document).on('click', '.floating-chatbot-button', function() {
3314 2573 var botId = getBotIdFromElement(this);
3315 2574 var $chatbot = getElement(botId, 'floating-chatbot');
3316 2575 var $badge = getElement(botId, 'chat-notification-badge');
3317 2576 var $preChat = getElement(botId, 'pre-chat-message');
@@ -3316,88 +2575,35 @@
3316 2575 var $badge = getElement(botId, 'chat-notification-badge');
3317 2576 var $preChat = getElement(botId, 'pre-chat-message');
3318 2577
3319 2578 if ($chatbot.hasClass('hidden')) {
3320 - $chatbot.removeClass('hidden').addClass('visible')
3321 - .attr('aria-modal', 'true').attr('role', 'dialog');
3322 - $(this).addClass('hidden').attr('aria-expanded', 'true');
2579 + $chatbot.removeClass('hidden').addClass('visible');
2580 + $(this).addClass('hidden');
3323 2581 $badge.hide(); // Hide notification when opening chat
3324 2582 disableScroll();
3325 2583 $preChat.fadeOut(250);
3326 -
3327 - // First open per page load: re-fetch behavior settings in case
3328 - // this page's inline values came from a stale full-page cache
3329 - // (plan-32db95). Idempotent — later opens are a no-op.
3330 - mxchatRefreshDynamicSettings();
3331 -
3332 - // Load chat history for returning visitors (persistence)
3333 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3334 - if (chatPersistenceEnabled) {
3335 - MxChatInstances.ensureSession(botId);
3336 - }
3337 -
3338 - // Deferred email check — only on first widget open
3339 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3340 - var instance = MxChatInstances.get(botId);
3341 - if (emailBlocker && !instance.emailCheckDone) {
3342 - instance.emailCheckDone = true;
3343 - resolveEmailState(botId);
3344 - } else if (!emailBlocker) {
3345 - // No email collection — still route through showChatContainerForBot
3346 - // so the loader is shown while chat history loads
3347 - showChatContainerForBot(botId);
3348 - }
3349 -
3350 - // Move keyboard focus into the message input after the open transition.
3351 - setTimeout(function() {
3352 - var chatInput = getElementDOM(botId, 'chat-input');
3353 - if (chatInput && !chatInput.disabled) {
3354 - try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
3355 - }
3356 - }, 300);
3357 2584 } else {
3358 - $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
3359 - $(this).removeClass('hidden').attr('aria-expanded', 'false');
2585 + $chatbot.removeClass('visible').addClass('hidden');
2586 + $(this).removeClass('hidden');
3360 2587 enableScroll();
3361 2588 checkPreChatDismissal(botId);
3362 2589 }
3363 2590 });
3364 2591
3365 - // Allow clicking anywhere on the title bar to close the chatbot.
3366 - // Returns keyboard focus to the launcher so keyboard users don't get
3367 - // stranded at <body> (WCAG SC 2.4.3 Focus Order). :focus-visible is
3368 - // heuristic-based so mouse-triggered close won't show a focus ring.
2592 + // Allow clicking anywhere on the title bar to close the chatbot
3369 2593 $(document).on('click', '.chatbot-top-bar', function() {
3370 2594 var botId = getBotIdFromElement(this);
3371 - getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3372 - var $launcher = getElement(botId, 'floating-chatbot-button');
3373 - $launcher.removeClass('hidden').attr('aria-expanded', 'false');
2595 + getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible');
2596 + getElement(botId, 'floating-chatbot-button').removeClass('hidden');
3374 2597 enableScroll();
3375 - try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3376 2598 });
3377 2599
3378 - // Global Escape-key handler — closes any visible chat widget and
3379 - // returns focus to its launcher. Standard modal-dismissal pattern;
3380 - // pairs with aria-modal="true" set on the widget when it opens.
3381 - $(document).on('keydown', function(e) {
3382 - if (e.key !== 'Escape' && e.key !== 'Esc') return;
3383 - var $visible = $('.floating-chatbot.visible');
3384 - if (!$visible.length) return;
3385 - e.preventDefault();
3386 - $visible.each(function() {
3387 - var botId = getBotIdFromElement(this);
3388 - $(this).addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3389 - var $launcher = getElement(botId, 'floating-chatbot-button');
3390 - $launcher.removeClass('hidden').attr('aria-expanded', 'false');
3391 - try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3392 - });
3393 - enableScroll();
3394 - });
3395 -
3396 2600 $(document).on('click', '.close-pre-chat-message', function(e) {
3397 2601 e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
3398 2602 var botId = getBotIdFromElement(this);
3399 - handlePreChatDismissal(botId);
2603 + getElement(botId, 'pre-chat-message').fadeOut(200, function() {
2604 + $(this).remove();
2605 + });
3400 2606 });
3401 2607
3402 2608
3403 2609 // PDF upload button handlers - use class selector
@@ -3413,20 +2619,17 @@
3413 2619 var wordInput = getElementDOM(botId, 'word-upload');
3414 2620 if (wordInput) wordInput.click();
3415 2621 });
3416 2622
3417 - // PDF file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'pdf-upload')
3418 - $(document).on('change', '.pdf-upload', async function(e) {
3419 - var botId = getBotIdFromElement(this);
3420 - var instance = MxChatInstances.get(botId);
3421 - const file = this.files[0];
3422 - const sessionId = MxChatInstances.ensureSession(botId);
3423 -
2623 + // PDF file input change handler
2624 + addSafeEventListener('pdf-upload', 'change', async function(e) {
2625 + const file = e.target.files[0];
2626 +
3424 2627 if (!file || file.type !== 'application/pdf') {
3425 2628 alert('Please select a valid PDF file.');
3426 2629 return;
3427 2630 }
3428 -
2631 +
3429 2632 if (!sessionId) {
3430 2633 alert('Error: No session ID found');
3431 2634 return;
3432 2635 }
@@ -3434,49 +2637,47 @@
3434 2637 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3435 2638 alert('Error: Ajax configuration missing');
3436 2639 return;
3437 2640 }
3438 -
2641 +
3439 2642 // Disable buttons and show loading state
3440 - const uploadBtn = getElementDOM(botId, 'pdf-upload-btn');
3441 - const sendBtn = getElementDOM(botId, 'send-button');
3442 - if (!uploadBtn) return;
2643 + const uploadBtn = document.getElementById('pdf-upload-btn');
2644 + const sendBtn = document.getElementById('send-button');
3443 2645 const originalBtnContent = uploadBtn.innerHTML;
3444 -
2646 +
3445 2647 try {
3446 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3447 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3448 2648 const formData = new FormData();
3449 2649 formData.append('action', 'mxchat_upload_pdf');
3450 2650 formData.append('pdf_file', file);
3451 2651 formData.append('session_id', sessionId);
3452 2652 formData.append('nonce', mxchatChat.nonce);
3453 -
2653 +
3454 2654 uploadBtn.disabled = true;
3455 - if (sendBtn) sendBtn.disabled = true;
2655 + sendBtn.disabled = true;
3456 2656 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3457 2657 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3458 2658 </svg>`;
3459 -
2659 +
3460 2660 const response = await fetch(mxchatChat.ajax_url, {
3461 2661 method: 'POST',
3462 2662 body: formData
3463 2663 });
3464 -
2664 +
3465 2665 const data = await response.json();
3466 -
2666 +
3467 2667 if (data.success) {
3468 2668 // Hide popular questions if they exist
3469 - if (hasQuickQuestions(botId)) {
3470 - collapseQuickQuestions(botId);
2669 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2670 + if (hasQuickQuestions()) {
2671 + collapseQuickQuestions();
3471 2672 }
3472 -
2673 +
3473 2674 // Show the active PDF name
3474 - showActivePdf(data.data.filename, botId);
3475 -
3476 - appendMessage('bot', data.data.message, '', [], false, botId);
3477 - scrollToBottom(botId);
3478 - instance.activePdfFile = data.data.filename;
2675 + showActivePdf(data.data.filename);
2676 +
2677 + appendMessage('bot', data.data.message);
2678 + scrollToBottom();
2679 + activePdfFile = data.data.filename;
3479 2680 } else {
3480 2681 alert('Failed to upload PDF. Please try again.');
3481 2682 }
3482 2683 } catch (error) {
@@ -3482,76 +2683,66 @@
3482 2683 } catch (error) {
3483 2684 alert('Error uploading file. Please try again.');
3484 2685 } finally {
3485 2686 uploadBtn.disabled = false;
3486 - if (sendBtn) sendBtn.disabled = false;
2687 + sendBtn.disabled = false;
3487 2688 uploadBtn.innerHTML = originalBtnContent;
3488 2689 this.value = ''; // Reset file input
3489 2690 }
3490 2691 });
3491 2692
3492 - // Word file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'word-upload')
3493 - $(document).on('change', '.word-upload', async function(e) {
3494 - var botId = getBotIdFromElement(this);
3495 - var instance = MxChatInstances.get(botId);
3496 - const file = this.files[0];
3497 - const sessionId = MxChatInstances.ensureSession(botId);
3498 -
2693 + // Word file input change handler
2694 + addSafeEventListener('word-upload', 'change', async function(e) {
2695 + const file = e.target.files[0];
2696 +
3499 2697 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
3500 2698 alert('Please select a valid Word document (.docx).');
3501 2699 return;
3502 2700 }
3503 -
2701 +
3504 2702 if (!sessionId) {
3505 2703 alert('Error: No session ID found');
3506 2704 return;
3507 2705 }
3508 2706
3509 - if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3510 - alert('Error: Ajax configuration missing');
3511 - return;
3512 - }
3513 -
3514 2707 // Disable buttons and show loading state
3515 - const uploadBtn = getElementDOM(botId, 'word-upload-btn');
3516 - const sendBtn = getElementDOM(botId, 'send-button');
3517 - if (!uploadBtn) return;
2708 + const uploadBtn = document.getElementById('word-upload-btn');
2709 + const sendBtn = document.getElementById('send-button');
3518 2710 const originalBtnContent = uploadBtn.innerHTML;
3519 -
2711 +
3520 2712 try {
3521 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3522 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3523 2713 const formData = new FormData();
3524 2714 formData.append('action', 'mxchat_upload_word');
3525 2715 formData.append('word_file', file);
3526 2716 formData.append('session_id', sessionId);
3527 2717 formData.append('nonce', mxchatChat.nonce);
3528 -
2718 +
3529 2719 uploadBtn.disabled = true;
3530 - if (sendBtn) sendBtn.disabled = true;
2720 + sendBtn.disabled = true;
3531 2721 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3532 2722 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3533 2723 </svg>`;
3534 -
2724 +
3535 2725 const response = await fetch(mxchatChat.ajax_url, {
3536 2726 method: 'POST',
3537 2727 body: formData
3538 2728 });
3539 -
2729 +
3540 2730 const data = await response.json();
3541 -
2731 +
3542 2732 if (data.success) {
3543 2733 // Hide popular questions if they exist
3544 - if (hasQuickQuestions(botId)) {
3545 - collapseQuickQuestions(botId);
2734 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2735 + if (hasQuickQuestions()) {
2736 + collapseQuickQuestions();
3546 2737 }
3547 -
2738 +
3548 2739 // Show the active Word document name
3549 - showActiveWord(data.data.filename, botId);
3550 -
3551 - appendMessage('bot', data.data.message, '', [], false, botId);
3552 - scrollToBottom(botId);
3553 - instance.activeWordFile = data.data.filename;
2740 + showActiveWord(data.data.filename);
2741 +
2742 + appendMessage('bot', data.data.message);
2743 + scrollToBottom();
2744 + activeWordFile = data.data.filename;
3554 2745 } else {
3555 2746 alert('Failed to upload Word document. Please try again.');
3556 2747 }
3557 2748 } catch (error) {
@@ -3557,25 +2748,25 @@
3557 2748 } catch (error) {
3558 2749 alert('Error uploading file. Please try again.');
3559 2750 } finally {
3560 2751 uploadBtn.disabled = false;
3561 - if (sendBtn) sendBtn.disabled = false;
2752 + sendBtn.disabled = false;
3562 2753 uploadBtn.innerHTML = originalBtnContent;
3563 2754 this.value = ''; // Reset file input
3564 2755 }
3565 2756 });
3566 2757
3567 - // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids)
3568 - $(document).on('click', '.remove-pdf-btn', function(e) {
2758 + // Remove button click handlers
2759 + document.getElementById('remove-pdf-btn')?.addEventListener('click', function(e) {
3569 2760 e.preventDefault();
3570 2761 e.stopPropagation();
3571 - removeActivePdf(getBotIdFromElement(this));
2762 + removeActivePdf();
3572 2763 });
3573 -
3574 - $(document).on('click', '.remove-word-btn', function(e) {
2764 +
2765 + document.getElementById('remove-word-btn')?.addEventListener('click', function(e) {
3575 2766 e.preventDefault();
3576 2767 e.stopPropagation();
3577 - removeActiveWord(getBotIdFromElement(this));
2768 + removeActiveWord();
3578 2769 });
3579 2770
3580 2771 // Window resize handlers
3581 2772 $(window).on('resize orientationchange', function() {
@@ -3613,59 +2804,8 @@
3613 2804 });
3614 2805
3615 2806
3616 2807 // ====================================
3617 -// INIT LOADER & CHAT CONTAINER HELPERS
3618 -// ====================================
3619 -// These must be outside the email collection block so they're always available
3620 -// (used by persistence loading even when email collection is off)
3621 -
3622 -function showInitLoader(botId) {
3623 - var loader = getElementDOM(botId, 'mxchat-init-loader');
3624 - if (loader) loader.style.display = 'flex';
3625 -}
3626 -
3627 -function hideInitLoader(botId) {
3628 - var loader = getElementDOM(botId, 'mxchat-init-loader');
3629 - if (loader) loader.style.display = 'none';
3630 -}
3631 -
3632 -function showEmailFormForBot(botId) {
3633 - hideInitLoader(botId);
3634 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3635 - var chatContainer = getElementDOM(botId, 'chat-container');
3636 - if (emailBlocker) emailBlocker.style.display = 'flex';
3637 - if (chatContainer) chatContainer.style.display = 'none';
3638 -}
3639 -
3640 -function showChatContainerForBot(botId) {
3641 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3642 - var chatContainer = getElementDOM(botId, 'chat-container');
3643 - if (emailBlocker) emailBlocker.style.display = 'none';
3644 -
3645 - var instance = MxChatInstances.get(botId);
3646 - var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3647 -
3648 - // If persistence is on and history hasn't loaded yet, show loader
3649 - // while history loads to prevent flash of empty chat
3650 - if (chatPersistenceEnabled && !instance.chatHistoryLoaded) {
3651 - if (chatContainer) chatContainer.style.display = 'none';
3652 - showInitLoader(botId);
3653 - loadChatHistory(botId, function() {
3654 - hideInitLoader(botId);
3655 - if (chatContainer) chatContainer.style.display = 'flex';
3656 - scrollToBottom(botId, true);
3657 - });
3658 - } else {
3659 - hideInitLoader(botId);
3660 - if (chatContainer) chatContainer.style.display = 'flex';
3661 - if (typeof loadChatHistory === 'function') {
3662 - loadChatHistory(botId);
3663 - }
3664 - }
3665 -}
3666 -
3667 -// ====================================
3668 2808 // EMAIL COLLECTION SETUP - MULTI-INSTANCE VERSION
3669 2809 // ====================================
3670 2810 // Only run email collection setup if it's enabled
3671 2811 if (mxchatChat && mxchatChat.email_collection_enabled === 'on') {
@@ -3701,8 +2841,28 @@
3701 2841 `;
3702 2842 document.head.appendChild(style);
3703 2843 }
3704 2844
2845 + // Helper functions for email collection (multi-instance aware)
2846 + function showEmailFormForBot(botId) {
2847 + var emailBlocker = getElementDOM(botId, 'email-blocker');
2848 + var chatContainer = getElementDOM(botId, 'chat-container');
2849 + if (emailBlocker) emailBlocker.style.display = 'flex';
2850 + if (chatContainer) chatContainer.style.display = 'none';
2851 + }
2852 +
2853 + function showChatContainerForBot(botId) {
2854 + var emailBlocker = getElementDOM(botId, 'email-blocker');
2855 + var chatContainer = getElementDOM(botId, 'chat-container');
2856 + if (emailBlocker) emailBlocker.style.display = 'none';
2857 + if (chatContainer) chatContainer.style.display = 'flex';
2858 +
2859 + // Load chat history for this bot
2860 + if (typeof loadChatHistory === 'function') {
2861 + loadChatHistory(botId);
2862 + }
2863 + }
2864 +
3705 2865 function isValidEmailAddress(email) {
3706 2866 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3707 2867 return emailRegex.test(email.trim()) && email.length <= 254;
3708 2868 }
@@ -3824,31 +2984,11 @@
3824 2984 existingErrors.forEach(error => error.remove());
3825 2985 }
3826 2986 }
3827 2987
3828 - // Resolve email state using server-side data when available, AJAX fallback otherwise
3829 - function resolveEmailState(botId) {
3830 - if (mxchatChat.skip_email_check && mxchatChat.initial_email_state) {
3831 - if (mxchatChat.initial_email_state.show_email_form) {
3832 - showEmailFormForBot(botId);
3833 - } else {
3834 - showChatContainerForBot(botId);
3835 - }
3836 - } else {
3837 - checkSessionAndEmailForBot(botId);
3838 - }
3839 - }
3840 -
3841 2988 function checkSessionAndEmailForBot(botId) {
3842 - const sessionId = MxChatInstances.ensureSession(botId);
2989 + const sessionId = getChatSession(botId);
3843 2990
3844 - // Hide both panels while we check — show loader instead
3845 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3846 - var chatContainer = getElementDOM(botId, 'chat-container');
3847 - if (emailBlocker) emailBlocker.style.display = 'none';
3848 - if (chatContainer) chatContainer.style.display = 'none';
3849 - showInitLoader(botId);
3850 -
3851 2991 fetch(mxchatChat.ajax_url, {
3852 2992 method: 'POST',
3853 2993 headers: {
3854 2994 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3896,9 +3036,9 @@
3896 3036 var emailInput = getElementDOM(botId, 'user-email');
3897 3037 var nameInput = getElementDOM(botId, 'user-name');
3898 3038 var userEmail = emailInput ? emailInput.value.trim() : '';
3899 3039 var userName = nameInput ? nameInput.value.trim() : '';
3900 - var sessionId = MxChatInstances.ensureSession(botId);
3040 + var sessionId = getChatSession(botId);
3901 3041
3902 3042 // Validate email
3903 3043 if (!userEmail) {
3904 3044 showEmailError(botId, 'Please enter your email address.');
@@ -4021,27 +3161,25 @@
4021 3161 }
4022 3162 });
4023 3163
4024 3164 // Initialize email check for all bot instances
4025 - // For floating bots: defer until widget is opened (zero passive AJAX)
4026 - // For embedded bots: check immediately since the form is visible
4027 3165 $('.mxchat-chatbot-wrapper').each(function() {
4028 3166 var botId = $(this).data('bot-id') || 'default';
4029 3167 var emailBlocker = getElementDOM(botId, 'email-blocker');
4030 3168
3169 + // Only check if email blocker exists for this bot
4031 3170 if (emailBlocker) {
4032 - if (isEmbeddedBot(botId)) {
4033 - // Embedded bots are always visible — check now
4034 - resolveEmailState(botId);
3171 + if (mxchatChat.skip_email_check && mxchatChat.initial_email_state) {
3172 + if (mxchatChat.initial_email_state.show_email_form) {
3173 + showEmailFormForBot(botId);
3174 + } else {
3175 + showChatContainerForBot(botId);
3176 + }
3177 + } else {
3178 + setTimeout(function() {
3179 + checkSessionAndEmailForBot(botId);
3180 + }, 100);
4035 3181 }
4036 - // Floating bots: handled in the widget open handler
4037 - } else if (isEmbeddedBot(botId)) {
4038 - // Embedded bot, no email collection — load history with loader
4039 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4040 - if (chatPersistenceEnabled) {
4041 - MxChatInstances.ensureSession(botId);
4042 - showChatContainerForBot(botId);
4043 - }
4044 3182 }
4045 3183 });
4046 3184 }
4047 3185
@@ -4051,32 +3189,39 @@
4051 3189 var $chatbot = getElement(botId, 'floating-chatbot');
4052 3190 if ($chatbot.hasClass('hidden')) {
4053 3191 $chatbot.removeClass('hidden').addClass('visible');
4054 3192 getElement(botId, 'floating-chatbot-button').addClass('hidden');
4055 - handlePreChatDismissal(botId);
3193 + $(this).fadeOut(250); // Hide pre-chat message
4056 3194 disableScroll(); // Disable scroll when chatbot opens
3195 + }
3196 + });
4057 3197
4058 - // Load chat history for returning visitors (persistence)
4059 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4060 - if (chatPersistenceEnabled) {
4061 - MxChatInstances.ensureSession(botId);
4062 - }
3198 + // Dismiss pre-chat message via close button - handled by event delegation above at line ~2376
3199 + // This is a fallback for legacy support
3200 + $(document).on('click', '.close-pre-chat-message', function() {
3201 + var botId = getBotIdFromElement(this);
3202 + var $preChat = getElement(botId, 'pre-chat-message');
3203 + $preChat.fadeOut(200); // Hide the message
4063 3204
4064 - // Deferred email check — only on first widget open
4065 - var emailBlocker = getElementDOM(botId, 'email-blocker');
4066 - var instance = MxChatInstances.get(botId);
4067 - if (emailBlocker && !instance.emailCheckDone) {
4068 - instance.emailCheckDone = true;
4069 - resolveEmailState(botId);
4070 - } else if (!emailBlocker) {
4071 - showChatContainerForBot(botId);
3205 + // Send an AJAX request to set the transient flag for 24 hours
3206 + $.ajax({
3207 + url: mxchatChat.ajax_url,
3208 + type: 'POST',
3209 + data: {
3210 + action: 'mxchat_dismiss_pre_chat_message',
3211 + _ajax_nonce: mxchatChat.nonce
3212 + },
3213 + success: function() {
3214 + // Ensure the message is hidden after dismissal
3215 + $preChat.hide();
3216 + },
3217 + error: function() {
3218 + // Error dismissing pre-chat message - silently continue
4072 3219 }
4073 - }
3220 + });
4074 3221 });
4075 3222
4076 - // Legacy duplicate close handler removed — handled by single event delegation above
4077 3223
4078 -
4079 3224 function hasQuickQuestions(botId) {
4080 3225 botId = botId || 'default';
4081 3226 var questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
4082 3227 if (!questionsContainer) return false;
@@ -4269,312 +3414,6 @@
4269 3414 }, 2000);
4270 3415 });
4271 3416 }
4272 3417 }
4273 -});
4274 -
4275 -// ============================================================================
4276 -// SATISFACTION RATING (v3.2.6)
4277 -// ============================================================================
4278 -// Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
4279 -// inactivity following a bot reply. One prompt per session, deduped via
4280 -// localStorage. Runs ONLY when the satisfaction_rating_enabled option is on —
4281 -// the option (default off) is authoritative.
4282 -jQuery(function($) {
4283 - if (typeof mxchatChat === 'undefined') return;
4284 - // wp_localize_script stringifies scalars: a PHP boolean false arrives as
4285 - // '' and true as '1', so this must be an explicit-enable allowlist — the
4286 - // old "disabled when exactly false/'off'" check let '' through and the
4287 - // bubble rendered on sites with the option off/unset (plan-4bba64). PHP
4288 - // now emits 'on'/'off' strings; true/'1'/1 keep cached pre-fix HTML
4289 - // (boolean-true localizations) working.
4290 - // NOTE (plan-32db95): this gate reads the INLINE value at DOM ready and is
4291 - // deliberately NOT re-evaluated after the widget's dynamic-settings refresh
4292 - // merges fresh values over mxchatChat (that merge fires on first widget
4293 - // open, after this module has already decided). Re-evaluating would mean
4294 - // restructuring the whole module to late-bind its listeners — not worth it
4295 - // for a prompt that is at worst stale for one page load on a cached page.
4296 - var sre = mxchatChat.satisfaction_rating_enabled;
4297 - if (sre !== 'on' && sre !== true && sre !== '1' && sre !== 1) return;
4298 -
4299 - // wp_localize_script stringifies ints, so accept both number and numeric string.
4300 - var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
4301 - var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);
4302 - if (!isFinite(idleSeconds)) idleSeconds = 60;
4303 - if (idleSeconds < 5) idleSeconds = 5;
4304 - if (idleSeconds > 600) idleSeconds = 600;
4305 - var IDLE_MS = idleSeconds * 1000;
4306 - var MIN_BOT_REPLIES = 2;
4307 - var ratingState = {};
4308 -
4309 - function getState(botId) {
4310 - if (!ratingState[botId]) {
4311 - ratingState[botId] = { idleTimer: null, botReplies: 0, promptShown: false, dismissed: false };
4312 - }
4313 - return ratingState[botId];
4314 - }
4315 -
4316 - function getSessionId(botId) {
4317 - if (typeof MxChatInstances !== 'undefined' && MxChatInstances.getChatSession) {
4318 - return MxChatInstances.getChatSession(botId);
4319 - }
4320 - return null;
4321 - }
4322 -
4323 - function isAlreadyRated(sessionId) {
4324 - if (!sessionId) return false;
4325 - try { return localStorage.getItem('mxchat_rated:' + sessionId) === '1'; } catch (e) { return false; }
4326 - }
4327 -
4328 - function markRated(sessionId) {
4329 - if (!sessionId) return;
4330 - try { localStorage.setItem('mxchat_rated:' + sessionId, '1'); } catch (e) {}
4331 - }
4332 -
4333 - function esc(s) {
4334 - return String(s == null ? '' : s)
4335 - .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
4336 - .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
4337 - }
4338 -
4339 - // Mirror shouldSkipInlineColors so rating bubbles defer to AI-theme CSS.
4340 - function ratingSkipInlineColors(botId) {
4341 - if (mxchatChat.skip_inline_colors) return true;
4342 - var botAssignments = mxchatChat.bot_theme_assignments || {};
4343 - return botAssignments.hasOwnProperty(botId);
4344 - }
4345 -
4346 - function botBubbleStyleAttr(botId) {
4347 - if (ratingSkipInlineColors(botId)) return '';
4348 - var bg = mxchatChat.bot_message_bg_color;
4349 - var fg = mxchatChat.bot_message_font_color;
4350 - if (!bg && !fg) return '';
4351 - return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"';
4352 - }
4353 -
4354 - // Reads the rating bubble's actual computed fg+bg (whatever paints it —
4355 - // the inline color pickers OR the mxchat-theme AI customizer's injected CSS)
4356 - // and paints the filled "Send" pill so it fills with the bot font color and
4357 - // labels in the bubble bg. Mirrors mxchatSyncMenuColors(~:1512) for the read.
4358 - // We paint the submit button DIRECTLY (inline longhand) rather than relying
4359 - // on the CSS rule's var()s: Chromium resolves an INHERITED custom property
4360 - // unreliably inside a descendant's `background`, so a bubble-level var would
4361 - // silently fall back to the literal (white-block bug all over again). Inline
4362 - // longhand always wins. Same transparent-guard as the menu so we never paint
4363 - // a see-through value — in that case the CSS literal fallbacks keep it legible.
4364 - function syncRatingBubbleColors(botId) {
4365 - var $chatBox = getChatBoxByBotId(botId);
4366 - if (!$chatBox || !$chatBox.length) return;
4367 - var bubbleEl = $chatBox.find('.mxchat-rating-bot-bubble').last()[0];
4368 - if (!bubbleEl) return;
4369 - var cs = window.getComputedStyle(bubbleEl);
4370 - var fg = cs.color;
4371 - var bg = cs.backgroundColor;
4372 - var hasFg = fg && fg !== 'rgba(0, 0, 0, 0)' && fg !== 'transparent';
4373 - var hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
4374 - // Expose on the bubble too, for any inheriting styles / future use.
4375 - if (hasFg) bubbleEl.style.setProperty('--mxchat-bot-fg', fg);
4376 - if (hasBg) bubbleEl.style.setProperty('--mxchat-bot-bg', bg);
4377 - // Paint the Send pill directly — the part that actually fixes the bug.
4378 - var submitEl = bubbleEl.querySelector('.mxchat-rating-submit');
4379 - if (submitEl) {
4380 - if (hasFg) submitEl.style.backgroundColor = fg; // fill = bot font color
4381 - if (hasBg) submitEl.style.color = bg; // label = bubble background
4382 - }
4383 - }
4384 -
4385 - function copy(key) {
4386 - var c = mxchatChat.satisfaction_rating_copy || {};
4387 - var d = {
4388 - question: 'Was this helpful?',
4389 - helpful: 'Helpful',
4390 - not_helpful: 'Not helpful',
4391 - dismiss: 'Dismiss',
4392 - thanks: 'Thanks! Anything we should improve? (optional)',
4393 - placeholder: 'Tell us what could be better…',
4394 - send: 'Send',
4395 - skip: 'Skip',
4396 - saved: 'Thanks for the feedback.'
4397 - };
4398 - return c[key] || d[key];
4399 - }
4400 -
4401 - function thumbUpSvg() {
4402 - 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>';
4403 - }
4404 - function thumbDownSvg() {
4405 - 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>';
4406 - }
4407 -
4408 - function buildPromptHtml(botId) {
4409 - var styleAttr = botBubbleStyleAttr(botId);
4410 - return ''
4411 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4412 - + '<div class="mxchat-rating-prompt" data-bot-id="' + esc(botId) + '" role="group" aria-label="' + esc(copy('question')) + '">'
4413 - + '<div class="mxchat-rating-question">' + esc(copy('question')) + '</div>'
4414 - + '<div class="mxchat-rating-actions">'
4415 - + '<span class="mxchat-rating-buttons">'
4416 - + '<button type="button" class="mxchat-rating-btn" data-rating="1" aria-label="' + esc(copy('helpful')) + '">' + thumbUpSvg() + '</button>'
4417 - + '<button type="button" class="mxchat-rating-btn" data-rating="-1" aria-label="' + esc(copy('not_helpful')) + '">' + thumbDownSvg() + '</button>'
4418 - + '</span>'
4419 - + '<button type="button" class="mxchat-rating-dismiss" aria-label="' + esc(copy('dismiss')) + '">×</button>'
4420 - + '</div>'
4421 - + '</div>'
4422 - + '</div>';
4423 - }
4424 -
4425 - function buildFeedbackHtml(botId, rating) {
4426 - var styleAttr = botBubbleStyleAttr(botId);
4427 - return ''
4428 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4429 - + '<div class="mxchat-rating-feedback" data-bot-id="' + esc(botId) + '" data-rating="' + esc(String(rating)) + '">'
4430 - + '<div class="mxchat-rating-feedback-label">' + esc(copy('thanks')) + '</div>'
4431 - + '<textarea class="mxchat-rating-feedback-input" maxlength="500" placeholder="' + esc(copy('placeholder')) + '" rows="2"></textarea>'
4432 - + '<div class="mxchat-rating-feedback-actions">'
4433 - + '<button type="button" class="mxchat-rating-skip">' + esc(copy('skip')) + '</button>'
4434 - + '<button type="button" class="mxchat-rating-submit">' + esc(copy('send')) + '</button>'
4435 - + '</div>'
4436 - + '</div>'
4437 - + '</div>';
4438 - }
4439 -
4440 - function buildSavedHtml(botId) {
4441 - var styleAttr = botBubbleStyleAttr(botId);
4442 - return ''
4443 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4444 - + '<div class="mxchat-rating-saved">' + esc(copy('saved')) + '</div>'
4445 - + '</div>';
4446 - }
4447 -
4448 - function getChatBoxByBotId(botId) {
4449 - var $byId = $('#chat-box-' + botId);
4450 - if ($byId.length) return $byId.first();
4451 - return $('.chat-box').first();
4452 - }
4453 -
4454 - function scrollChatBoxToBottom($chatBox) {
4455 - if (!$chatBox || !$chatBox.length) return;
4456 - $chatBox.scrollTop($chatBox[0].scrollHeight);
4457 - }
4458 -
4459 - function showPrompt(botId) {
4460 - var s = getState(botId);
4461 - if (s.promptShown || s.dismissed) return;
4462 - var sessionId = getSessionId(botId);
4463 - if (!sessionId) return;
4464 - if (isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4465 - var $chatBox = getChatBoxByBotId(botId);
4466 - if (!$chatBox.length) return;
4467 - if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; }
4468 - $chatBox.append(buildPromptHtml(botId));
4469 - syncRatingBubbleColors(botId);
4470 - s.promptShown = true;
4471 - scrollChatBoxToBottom($chatBox);
4472 - }
4473 -
4474 - function submitRating(botId, rating, feedback) {
4475 - var sessionId = getSessionId(botId);
4476 - if (!sessionId) return;
4477 - $.post(mxchatChat.ajax_url, {
4478 - action: 'mxchat_save_rating',
4479 - session_id: sessionId,
4480 - bot_id: botId,
4481 - rating: rating,
4482 - feedback: feedback || ''
4483 - });
4484 - markRated(sessionId);
4485 - }
4486 -
4487 - function onBotReply(botId) {
4488 - var s = getState(botId);
4489 - s.botReplies += 1;
4490 - if (s.promptShown || s.dismissed) return;
4491 - var sessionId = getSessionId(botId);
4492 - if (sessionId && isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4493 - if (s.botReplies < MIN_BOT_REPLIES) return;
4494 - if (s.idleTimer) clearTimeout(s.idleTimer);
4495 - s.idleTimer = setTimeout(function() { showPrompt(botId); }, IDLE_MS);
4496 - }
4497 -
4498 - function onUserMessage(botId) {
4499 - var s = getState(botId);
4500 - if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = null; }
4501 - }
4502 -
4503 - function botIdFromChatBox(el) {
4504 - var id = el && el.id ? el.id : '';
4505 - return id.indexOf('chat-box-') === 0 ? id.substring('chat-box-'.length) : 'default';
4506 - }
4507 -
4508 - function setupObserver(chatBox) {
4509 - var botId = botIdFromChatBox(chatBox);
4510 - try {
4511 - var observer = new MutationObserver(function(mutations) {
4512 - mutations.forEach(function(m) {
4513 - for (var i = 0; i < m.addedNodes.length; i++) {
4514 - var node = m.addedNodes[i];
4515 - if (!node || node.nodeType !== 1) continue;
4516 - var $n = $(node);
4517 - if ($n.hasClass('mxchat-rating-bot-bubble') || $n.hasClass('mxchat-rating-prompt') || $n.hasClass('mxchat-rating-feedback') || $n.hasClass('mxchat-rating-saved')) continue;
4518 - 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)
4519 - else if ($n.hasClass('user-message')) onUserMessage(botId);
4520 - }
4521 - });
4522 - });
4523 - observer.observe(chatBox, { childList: true });
4524 - } catch (e) { /* noop */ }
4525 - }
4526 -
4527 - $('.chat-box').each(function() { setupObserver(this); });
4528 -
4529 - $(document).on('click', '.mxchat-rating-btn', function(e) {
4530 - e.preventDefault();
4531 - var $btn = $(this);
4532 - var $prompt = $btn.closest('.mxchat-rating-prompt');
4533 - var $wrap = $btn.closest('.mxchat-rating-bot-bubble');
4534 - var botId = $prompt.data('bot-id') || 'default';
4535 - var rating = parseInt($btn.attr('data-rating'), 10);
4536 - if (rating !== 1 && rating !== -1) return;
4537 - submitRating(botId, rating, '');
4538 - ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating));
4539 - syncRatingBubbleColors(botId);
4540 - scrollChatBoxToBottom(getChatBoxByBotId(botId));
4541 - });
4542 -
4543 - $(document).on('click', '.mxchat-rating-dismiss', function(e) {
4544 - e.preventDefault();
4545 - var $prompt = $(this).closest('.mxchat-rating-prompt');
4546 - var $wrap = $(this).closest('.mxchat-rating-bot-bubble');
4547 - var botId = $prompt.data('bot-id') || 'default';
4548 - var s = getState(botId);
4549 - s.dismissed = true;
4550 - markRated(getSessionId(botId));
4551 - ($wrap.length ? $wrap : $prompt).remove();
4552 - });
4553 -
4554 - function closeFeedback($fb) {
4555 - var botId = $fb.data('bot-id') || 'default';
4556 - var $wrap = $fb.closest('.mxchat-rating-bot-bubble');
4557 - ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId));
4558 - syncRatingBubbleColors(botId);
4559 - scrollChatBoxToBottom(getChatBoxByBotId(botId));
4560 - }
4561 -
4562 - $(document).on('click', '.mxchat-rating-skip', function(e) {
4563 - e.preventDefault();
4564 - closeFeedback($(this).closest('.mxchat-rating-feedback'));
4565 - });
4566 -
4567 - $(document).on('click', '.mxchat-rating-submit', function(e) {
4568 - e.preventDefault();
4569 - var $fb = $(this).closest('.mxchat-rating-feedback');
4570 - var botId = $fb.data('bot-id') || 'default';
4571 - var rating = parseInt($fb.attr('data-rating'), 10);
4572 - if (rating !== 1 && rating !== -1) { closeFeedback($fb); return; }
4573 - var text = String($fb.find('.mxchat-rating-feedback-input').val() || '').trim();
4574 - if (text !== '') {
4575 - submitRating(botId, rating, text);
4576 - }
4577 - closeFeedback($fb);
4578 - });
4579 3418 });
4580 3419