PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.1.7
MxChat – AI Chatbot & Content Generation for WordPress v3.1.7
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
← All changes | js/chat-script.js +132 -1144 3.2.93.1.7 View file →
@@ -1,163 +1,20 @@
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);
37 - }
38 - return resp.json();
39 - }).then(function (data) {
40 - if (data && data.nonce) {
41 - return data.nonce;
42 - }
43 - throw new Error('REST nonce response had no nonce field.');
44 - });
45 - }
46 -
47 - /**
48 - * withFreshNonce(cb) — invoke cb() after ensuring mxchatChat.nonce is fresh.
49 - * Tries REST endpoint first (cache-bypass design); falls back to the legacy
50 - * admin-ajax refresh path if REST is unavailable. Idempotent — concurrent
51 - * calls share the same in-flight refresh.
52 - */
53 - function withFreshNonce(callback) {
54 - if (typeof mxchatChat === 'undefined') {
3 + // Nonce refresh is deferred until first user interaction (ensureSession)
4 + // to avoid admin-ajax calls on passive page loads.
5 + var nonceRefreshed = false;
6 + function refreshNonceIfNeeded(callback) {
7 + if (nonceRefreshed || typeof mxchatChat === 'undefined' || !mxchatChat.ajax_url) {
55 8 if (callback) callback();
56 9 return;
57 10 }
58 - var now = Date.now();
59 - if (cachedFreshNonce && (now - cachedFreshNonceFetchedAt) < NONCE_TTL_MS) {
60 - mxchatChat.nonce = cachedFreshNonce;
11 + nonceRefreshed = true;
12 + $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' }, function(res) {
13 + if (res && res.success && res.data && res.data.nonce) {
14 + mxchatChat.nonce = res.data.nonce;
15 + }
61 16 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 17 });
161 18 }
162 19
163 20 // ====================================
@@ -203,10 +60,10 @@
203 60 return Object.keys(this.instances);
204 61 },
205 62
206 63 // 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.
64 + // Returns existing session ID from cookie or localStorage, or null if none exists.
65 + // Does NOT create a new session — use ensureSession() for that.
209 66 getChatSession: function(botId) {
210 67 var cookieName = 'mxchat_session_id_' + botId;
211 68 var storageKey = 'mxchat_session_id_' + botId;
212 69 var sessionId = getCookie(cookieName);
@@ -215,21 +72,8 @@
215 72 if (!sessionId) {
216 73 try { sessionId = localStorage.getItem(storageKey); } catch (e) {}
217 74 }
218 75
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 76 // Re-sync cookie from localStorage if cookie was lost
233 77 if (sessionId && !getCookie(cookieName)) {
234 78 document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax";
235 79 }
@@ -261,10 +105,12 @@
261 105 // Now that we have a session, do the deferred work
262 106 refreshNonceIfNeeded();
263 107 trackOriginatingPage();
264 108
265 - // Note: loadChatHistory is handled by showChatContainerForBot with loader UI,
266 - // so we do NOT call it here to avoid a race condition.
109 + var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
110 + if (chatPersistenceEnabled && mxchatChat.email_collection_enabled !== 'on') {
111 + loadChatHistory(botId);
112 + }
267 113
268 114 return instance.sessionId;
269 115 },
270 116
@@ -603,102 +449,10 @@
603 449 sendButton.disabled = false;
604 450 sendButton.style.opacity = '1';
605 451 sendButton.style.pointerEvents = 'auto';
606 452 }
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 453 }
611 454
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 455 // Update your existing sendMessage function
702 456 function sendMessage(botId) {
703 457 botId = botId || 'default';
704 458 MxChatInstances.ensureSession(botId);
@@ -838,15 +592,8 @@
838 592
839 593 function callMxChat(message, callback, botId) {
840 594 botId = botId || getMxChatBotId();
841 595
842 - // Streaming fallbacks land here: drop any leftover stream handle and
843 - // return the button to its send state (no-op for plain non-stream turns).
844 - if (mxchatActiveStreams[botId]) {
845 - delete mxchatActiveStreams[botId];
846 - }
847 - mxchatRestoreSendButton(botId);
848 -
849 596 // Store the message in case we need to retry after session reset
850 597 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
851 598
852 599 // Get page context if contextual awareness is enabled
@@ -854,28 +601,13 @@
854 601
855 602 // Get instance for session start timestamp (used when persistence is OFF)
856 603 var instance = MxChatInstances.get(botId);
857 604
858 - // Guarantee a non-null session_id before the AJAX leaves. ensureSession() is idempotent
859 - // and returns the guaranteed-present session id from the in-memory instance even when
860 - // cookie/localStorage writes are silently blocked by the browser.
861 - var sessionId = MxChatInstances.ensureSession(botId);
862 - if (!sessionId || sessionId === 'null' || sessionId === 'undefined') {
863 - // Last-resort generation to ensure we never POST a null marker.
864 - sessionId = generateSessionId();
865 - MxChatInstances.setChatSession(botId, sessionId);
866 - }
867 -
868 - // Wait for the page-cache nonce refresh to complete before firing the
869 - // chat-send AJAX. On cached pages the inline mxchatChat.nonce is stale
870 - // until refreshNonceIfNeeded() returns; constructing ajaxData inside the
871 - // callback guarantees we read the fresh value. See plan-c5457f.
872 - refreshNonceIfNeeded(function() {
873 605 // Prepare AJAX data
874 606 const ajaxData = {
875 607 action: 'mxchat_handle_chat_request',
876 608 message: message,
877 - session_id: sessionId,
609 + session_id: getChatSession(botId),
878 610 nonce: mxchatChat.nonce,
879 611 current_page_url: window.location.href,
880 612 current_page_title: document.title,
881 613 bot_id: botId,
@@ -881,14 +613,14 @@
881 613 bot_id: botId,
882 614 // Pass session start timestamp so AI context matches what user sees
883 615 session_start_timestamp: instance.sessionStartTimestamp || 0
884 616 };
885 -
617 +
886 618 // Add page context if available
887 619 if (pageContext) {
888 620 ajaxData.page_context = JSON.stringify(pageContext);
889 621 }
890 -
622 +
891 623 // CHECK FOR VISION FLAGS AND ADD THEM
892 624 if (window.mxchatVisionProcessed) {
893 625 ajaxData.vision_processed = true;
894 626 ajaxData.original_user_message = window.mxchatOriginalMessage || message;
@@ -897,9 +629,9 @@
897 629 window.mxchatVisionProcessed = false;
898 630 window.mxchatOriginalMessage = null;
899 631 window.mxchatVisionImagesCount = 0;
900 632 }
901 -
633 +
902 634 $.ajax({
903 635 url: mxchatChat.ajax_url,
904 636 type: 'POST',
905 637 dataType: 'json',
@@ -1088,9 +820,8 @@
1088 820
1089 821 replaceLastMessage("bot", errorMessage, '', [], botId);
1090 822 }
1091 823 });
1092 - }); // refreshNonceIfNeeded
1093 824 }
1094 825
1095 826 function callMxChatStream(message, callback, botId) {
1096 827 botId = botId || getMxChatBotId();
@@ -1109,25 +840,12 @@
1109 840
1110 841 // Get instance for session start timestamp (used when persistence is OFF)
1111 842 var instance = MxChatInstances.get(botId);
1112 843
1113 - // Guarantee a non-null session_id before the fetch. FormData.append() stringifies any
1114 - // non-string value via String(), so passing `null` would POST the literal string "null"
1115 - // and land in the transcripts table as a ghost session. ensureSession() always returns
1116 - // a real string even when cookies/localStorage are blocked.
1117 - var streamSessionId = MxChatInstances.ensureSession(botId);
1118 - if (!streamSessionId || streamSessionId === 'null' || streamSessionId === 'undefined') {
1119 - streamSessionId = generateSessionId();
1120 - MxChatInstances.setChatSession(botId, streamSessionId);
1121 - }
1122 -
1123 - // Wait for the page-cache nonce refresh before constructing formData (which
1124 - // captures mxchatChat.nonce by value). Mirrors callMxChat's wrapping. See plan-c5457f.
1125 - refreshNonceIfNeeded(function() {
1126 844 const formData = new FormData();
1127 845 formData.append('action', 'mxchat_stream_chat');
1128 846 formData.append('message', message);
1129 - formData.append('session_id', streamSessionId);
847 + formData.append('session_id', getChatSession(botId));
1130 848 formData.append('nonce', mxchatChat.nonce);
1131 849 formData.append('current_page_url', window.location.href);
1132 850 formData.append('current_page_title', document.title);
1133 851 formData.append('bot_id', botId);
@@ -1153,20 +871,12 @@
1153 871 let accumulatedContent = '';
1154 872 let testingDataReceived = false;
1155 873 let streamingStarted = false;
1156 874
1157 - // Abortable stream: a fresh controller per turn, keyed by bot instance.
1158 - // The Stop control (send button swapped in place) aborts both the read
1159 - // loop and the underlying request.
1160 - var streamControl = { controller: new AbortController(), aborted: false };
1161 - mxchatActiveStreams[botId] = streamControl;
1162 - mxchatShowStopButton(botId);
1163 -
1164 875 fetch(mxchatChat.ajax_url, {
1165 876 method: 'POST',
1166 877 body: formData,
1167 - credentials: 'same-origin',
1168 - signal: streamControl.controller.signal
878 + credentials: 'same-origin'
1169 879 })
1170 880 .then(response => {
1171 881 // Store the response for potential fallback handling
1172 882 const responseClone = response.clone();
@@ -1235,16 +945,8 @@
1235 945
1236 946 // Re-enable chat input when stream ends with content
1237 947 enableChatInput(botId);
1238 948
1239 - // Scroll the user's last message to the top now that the
1240 - // bot's full reply has rendered (gives max reading room).
1241 - var $chatBoxDone = getElement(botId, 'chat-box');
1242 - var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
1243 - if ($lastUserMsgDone.length) {
1244 - scrollElementToTop($lastUserMsgDone, botId);
1245 - }
1246 -
1247 949 if (callback) {
1248 950 callback(accumulatedContent);
1249 951 }
1250 952 return;
@@ -1267,16 +969,8 @@
1267 969
1268 970 // Re-enable chat input after streaming completes
1269 971 enableChatInput(botId);
1270 972
1271 - // Scroll the user's last message to the top now
1272 - // that the bot's full reply has rendered.
1273 - var $chatBoxStreamDone = getElement(botId, 'chat-box');
1274 - var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
1275 - if ($lastUserMsgStreamDone.length) {
1276 - scrollElementToTop($lastUserMsgStreamDone, botId);
1277 - }
1278 -
1279 973 if (callback) {
1280 974 callback(accumulatedContent);
1281 975 }
1282 976 return;
@@ -1333,9 +1027,8 @@
1333 1027 }
1334 1028
1335 1029 processStream();
1336 1030 }).catch(streamError => {
1337 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1338 1031 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1339 1032 callMxChat(message, callback, botId);
1340 1033 });
1341 1034 }
@@ -1342,9 +1035,8 @@
1342 1035
1343 1036 processStream();
1344 1037 })
1345 1038 .catch(error => {
1346 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1347 1039 // Check if we have server error data with chat mode
1348 1040 if (error && error.isServerError && error.data) {
1349 1041 // Check for chat mode in error data
1350 1042 if (error.data.chat_mode) {
@@ -1357,9 +1049,8 @@
1357 1049 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1358 1050 callMxChat(message, callback, botId);
1359 1051 }
1360 1052 });
1361 - }); // refreshNonceIfNeeded
1362 1053 }
1363 1054
1364 1055 // Helper function to handle non-streaming responses
1365 1056 function handleNonStreamResponse(data, callback, botId) {
@@ -1568,17 +1259,8 @@
1568 1259 // Update the event handlers to use the correct function names (using event delegation)
1569 1260 // Use class-based selectors for multi-instance support
1570 1261 $(document).on('click', '.send-button', function() {
1571 1262 var botId = getBotIdFromElement(this);
1572 - // While a response is streaming the button is a Stop control.
1573 - if (this.classList.contains('mxchat-stop-mode')) {
1574 - // Same click that just started this stream (an add-on's direct handler
1575 - // ran before this delegated one) — not a Stop press. See
1576 - // mxchatShowStopButton for the full story (plan-4bba64).
1577 - if (this.__mxchatStopJustShown) return;
1578 - mxchatStopStreaming(botId);
1579 - return;
1580 - }
1581 1263 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1582 1264 if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1583 1265 disableChatInput(botId);
1584 1266 }
@@ -1597,287 +1279,9 @@
1597 1279 sendMessage(botId);
1598 1280 }
1599 1281 });
1600 1282
1601 -// Builds the list of overflow-menu items for a given bot.
1602 -// Adding a future item is one push to this array — do NOT hardcode "only download."
1603 -function mxchatGetHeaderMenuItems(botId) {
1604 - var items = [];
1605 - var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1606 -
1607 - // The `print_button_*` keys still gate this item for back-compat with
1608 - // existing user options. The action is now a transcript download, not print.
1609 - if (settings.print_button_enabled === 'on') {
1610 - items.push({
1611 - id: 'download-transcript',
1612 - label: settings.print_button_label || 'Download Transcript',
1613 - icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
1614 - action: function() {
1615 - mxchatDownloadTranscript(botId);
1616 - }
1617 - });
1618 - }
1619 -
1620 - return items;
1621 -}
1622 -
1623 -// Builds a clean markdown transcript of the current conversation and triggers
1624 -// a file download. Used by the "Download Transcript" menu item.
1625 -function mxchatDownloadTranscript(botId) {
1626 - var $chatBox = getElement(botId, 'chat-box');
1627 - if (!$chatBox || !$chatBox.length) return;
1628 -
1629 - var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1630 - var headerTitle = settings.print_header_title || 'Chat transcript';
1631 - var now = new Date();
1632 - var stamp = now.toLocaleString();
1633 -
1634 - var lines = [];
1635 - lines.push('# ' + headerTitle);
1636 - lines.push('');
1637 - lines.push('Exported: ' + stamp);
1638 - lines.push('');
1639 - lines.push('---');
1640 - lines.push('');
1641 -
1642 - $chatBox.find('.user-message, .bot-message, .agent-message').each(function() {
1643 - var $msg = $(this);
1644 - // Skip thinking placeholders and any in-flight temporary messages.
1645 - if ($msg.find('.thinking-dots').length) return;
1646 - if ($msg.hasClass('temporary-message')) return;
1647 -
1648 - var sender;
1649 - if ($msg.hasClass('user-message')) sender = 'User';
1650 - else if ($msg.hasClass('agent-message')) sender = 'Live Agent';
1651 - else sender = 'AI Agent';
1652 -
1653 - // Strip interactive UI from the cloned message so we get the conversation text.
1654 - var $clone = $msg.clone();
1655 - $clone.find('.copy-button, .message-toolbar, .mxchat-copy, button, script, style').remove();
1656 - var text = $clone.text().replace(/ /g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
1657 - if (!text) return;
1658 -
1659 - lines.push('**' + sender + '**');
1660 - lines.push('');
1661 - lines.push(text);
1662 - lines.push('');
1663 - });
1664 -
1665 - var content = lines.join('\n');
1666 - var iso = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
1667 - var fname = 'mxchat-transcript-' + iso + '.md';
1668 - var blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
1669 - var url = URL.createObjectURL(blob);
1670 - var a = document.createElement('a');
1671 - a.href = url;
1672 - a.download = fname;
1673 - a.style.display = 'none';
1674 - document.body.appendChild(a);
1675 - a.click();
1676 - setTimeout(function() {
1677 - if (a.parentNode) a.parentNode.removeChild(a);
1678 - URL.revokeObjectURL(url);
1679 - }, 100);
1680 -}
1681 -
1682 -// Reads the bot bubble's actual computed bg+fg and writes them as CSS vars
1683 -// on the menu wrap, so the dropdown matches whatever paints the bubble —
1684 -// saved options, AI theme CSS, or the mxchat-theme add-on.
1685 -function mxchatSyncMenuColors(botId, $wrap) {
1686 - if (!$wrap || !$wrap.length) return;
1687 - var $bot = $wrap.closest('.mxchat-chatbot-wrapper').find('.bot-message').not('.temporary-message').first();
1688 - if (!$bot.length) return;
1689 - var cs = window.getComputedStyle($bot[0]);
1690 - if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') {
1691 - $wrap[0].style.setProperty('--mxchat-menu-bg', cs.backgroundColor);
1692 - }
1693 - // Bot text color usually lives on a child div, not .bot-message itself.
1694 - var $textChild = $bot.find('[style*="color"]').first();
1695 - var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
1696 - if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
1697 -}
1698 -
1699 -// Renders (or re-renders) the item list for one menu wrap. Split out of
1700 -// mxchatInitHeaderMenu so the dynamic-settings merge (plan-32db95) can
1701 -// rebuild items + trigger visibility WITHOUT re-binding the one-time
1702 -// open/close/keyboard wiring. closeMenu is passed in by the init closure;
1703 -// a rebuild before init (never happens, but harmless) just skips it.
1704 -function mxchatRenderHeaderMenuItems(botId, $wrap, closeMenuFn) {
1705 - var $trigger = $wrap.find('.mxchat-menu-trigger');
1706 - var $menu = $wrap.find('.mxchat-header-menu');
1707 - var items = mxchatGetHeaderMenuItems(botId);
1708 -
1709 - $menu.empty();
1710 -
1711 - if (!items.length) {
1712 - $trigger.hide();
1713 - $menu.hide();
1714 - return;
1715 - }
1716 -
1717 - // Clear any inline display:none a previous zero-item render left behind —
1718 - // open/close visibility is governed by the hidden prop + is-open class.
1719 - $trigger.css('display', '');
1720 - $menu.css('display', '');
1721 -
1722 - items.forEach(function(item, idx) {
1723 - var $btn = $('<button>', {
1724 - type: 'button',
1725 - 'class': 'mxchat-menu-item',
1726 - 'role': 'menuitem',
1727 - 'tabindex': '-1',
1728 - 'data-menu-id': item.id,
1729 - html: '<span class="mxchat-menu-item-icon">' + item.icon + '</span>' +
1730 - '<span class="mxchat-menu-item-label"></span>'
1731 - });
1732 - $btn.find('.mxchat-menu-item-label').text(item.label);
1733 - $btn.on('click', function(e) {
1734 - e.preventDefault();
1735 - e.stopPropagation();
1736 - if (closeMenuFn) closeMenuFn();
1737 - try { item.action(); } catch (err) { /* no-op */ }
1738 - });
1739 - $menu.append($btn);
1740 - });
1741 -}
1742 -
1743 -// Re-render every menu on the page after a dynamic-settings merge
1744 -// (multi-bot: each wrap re-reads its items). An OPEN menu is left alone —
1745 -// swapping items under the user mid-interaction yanks focus — and the
1746 -// rebuild runs when it closes instead (closeMenu checks the pending flag).
1747 -function mxchatRebuildHeaderMenus() {
1748 - $('.mxchat-header-menu-wrap').each(function() {
1749 - var $wrap = $(this);
1750 - var botId = $wrap.data('bot-id');
1751 - if (!botId) return;
1752 - if (!$wrap.data('mxchatMenuReady')) {
1753 - mxchatInitHeaderMenu(botId);
1754 - return;
1755 - }
1756 - if ($wrap.find('.mxchat-header-menu').hasClass('is-open')) {
1757 - $wrap.data('mxchatMenuRebuildPending', true);
1758 - return;
1759 - }
1760 - mxchatRenderHeaderMenuItems(botId, $wrap, $wrap.data('mxchatMenuClose'));
1761 - });
1762 -}
1763 -
1764 -// One-time per-widget init: renders menu items, wires open/close,
1765 -// outside-click, Escape, and arrow-key navigation. If no items, hides the
1766 -// trigger. Wiring happens even when there are zero items at init, so a
1767 -// later dynamic-settings rebuild that adds items has a working trigger.
1768 -function mxchatInitHeaderMenu(botId) {
1769 - var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
1770 - if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
1771 -
1772 - var $trigger = $wrap.find('.mxchat-menu-trigger');
1773 - var $menu = $wrap.find('.mxchat-header-menu');
1774 -
1775 - // Initial color sync — covers normal page load.
1776 - mxchatSyncMenuColors(botId, $wrap);
1777 -
1778 - function openMenu() {
1779 - // Re-sync each open in case the active theme changed since init.
1780 - mxchatSyncMenuColors(botId, $wrap);
1781 - $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
1782 - $trigger.attr('aria-expanded', 'true');
1783 - // Focus the first item for keyboard users
1784 - setTimeout(function() {
1785 - $menu.find('.mxchat-menu-item').first().attr('tabindex', '0').trigger('focus');
1786 - }, 0);
1787 - }
1788 - function closeMenu(returnFocus) {
1789 - $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
1790 - $trigger.attr('aria-expanded', 'false');
1791 - $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
1792 - if (returnFocus) $trigger.trigger('focus');
1793 - // A dynamic-settings rebuild that arrived while the menu was open
1794 - // was deferred (mxchatRebuildHeaderMenus) — run it now.
1795 - if ($wrap.data('mxchatMenuRebuildPending')) {
1796 - $wrap.removeData('mxchatMenuRebuildPending');
1797 - mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
1798 - }
1799 - }
1800 -
1801 - // Toggle on trigger click — stop propagation so the .chatbot-top-bar
1802 - // click-to-collapse handler does not fire.
1803 - $trigger.on('click', function(e) {
1804 - e.preventDefault();
1805 - e.stopPropagation();
1806 - if ($menu.hasClass('is-open')) closeMenu();
1807 - else openMenu();
1808 - });
1809 -
1810 - // Don't let clicks inside the menu bubble to the top-bar collapse handler.
1811 - $menu.on('click', function(e) {
1812 - e.stopPropagation();
1813 - });
1814 -
1815 - // Outside click closes the menu.
1816 - $(document).on('click.mxchatMenu-' + botId, function(e) {
1817 - if (!$menu.hasClass('is-open')) return;
1818 - if ($wrap.has(e.target).length || $wrap.is(e.target)) return;
1819 - closeMenu();
1820 - });
1821 -
1822 - // Keyboard: Escape closes and returns focus; arrow keys move focus; Enter activates.
1823 - $menu.on('keydown', '.mxchat-menu-item', function(e) {
1824 - var $items = $menu.find('.mxchat-menu-item');
1825 - var idx = $items.index(this);
1826 - if (e.key === 'Escape') {
1827 - e.preventDefault();
1828 - closeMenu(true);
1829 - } else if (e.key === 'ArrowDown') {
1830 - e.preventDefault();
1831 - var $next = $items.eq((idx + 1) % $items.length);
1832 - $items.attr('tabindex', '-1');
1833 - $next.attr('tabindex', '0').trigger('focus');
1834 - } else if (e.key === 'ArrowUp') {
1835 - e.preventDefault();
1836 - var $prev = $items.eq((idx - 1 + $items.length) % $items.length);
1837 - $items.attr('tabindex', '-1');
1838 - $prev.attr('tabindex', '0').trigger('focus');
1839 - } else if (e.key === 'Enter' || e.key === ' ') {
1840 - e.preventDefault();
1841 - $(this).trigger('click');
1842 - }
1843 - });
1844 - $trigger.on('keydown', function(e) {
1845 - if (e.key === 'Escape' && $menu.hasClass('is-open')) {
1846 - e.preventDefault();
1847 - closeMenu(true);
1848 - } else if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !$menu.hasClass('is-open')) {
1849 - e.preventDefault();
1850 - openMenu();
1851 - }
1852 - });
1853 -
1854 - // Expose closeMenu for out-of-closure re-renders (mxchatRebuildHeaderMenus),
1855 - // then do the initial item render.
1856 - $wrap.data('mxchatMenuClose', closeMenu);
1857 - mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
1858 -
1859 - $wrap.data('mxchatMenuReady', true);
1860 -}
1861 -
1862 -// Initialize header menus for every rendered widget on DOM ready.
1863 -$(function() {
1864 - $('.mxchat-header-menu-wrap').each(function() {
1865 - var botId = $(this).data('bot-id');
1866 - if (botId) mxchatInitHeaderMenu(botId);
1867 - });
1868 -
1869 - // Embedded (non-floating) widgets are open from the moment the page
1870 - // renders — refresh dynamic settings at init (plan-32db95). Floating
1871 - // widgets refresh on first launcher open instead.
1872 - var hasEmbeddedWidget = $('.mxchat-chatbot-wrapper').filter(function() {
1873 - return !$(this).closest('.floating-chatbot').length;
1874 - }).length > 0;
1875 - if (hasEmbeddedWidget) {
1876 - mxchatRefreshDynamicSettings();
1877 - }
1878 -});
1879 -
1283 +
1880 1284 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
1881 1285 try {
1882 1286 // Determine styles based on sender type
1883 1287 let messageClass, bgColor, fontColor;
@@ -1969,12 +1373,8 @@
1969 1373 if (lastUserMessage.length) {
1970 1374 scrollElementToTop(lastUserMessage, botId);
1971 1375 }
1972 1376 }
1973 -
1974 - if ((sender === "bot" || sender === "agent") && !isTemporary) {
1975 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
1976 - }
1977 1377 });
1978 1378
1979 1379 if (messageText.id) {
1980 1380 var instance = MxChatInstances.get(botId);
@@ -2121,12 +1521,8 @@
2121 1521 }
2122 1522
2123 1523 // Re-enable chat input after response is displayed
2124 1524 enableChatInput(botId);
2125 -
2126 - if (sender === "bot" || sender === "agent") {
2127 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2128 - }
2129 1525 } else {
2130 1526 appendMessage(sender, responseText, responseHtml, images, false, botId);
2131 1527 // Re-enable chat input after response is displayed
2132 1528 enableChatInput(botId);
@@ -2579,14 +1975,13 @@
2579 1975 requestAnimationFrame(smoothScroll);
2580 1976 }
2581 1977 }
2582 1978
2583 - function scrollElementToTop(element, botId, topOffset) {
1979 + function scrollElementToTop(element, botId) {
2584 1980 botId = botId || 'default';
2585 - topOffset = (typeof topOffset === 'number') ? topOffset : 2;
2586 1981 var chatBox = getElement(botId, 'chat-box');
2587 1982 var elementTop = element.position().top + chatBox.scrollTop();
2588 - chatBox.animate({ scrollTop: Math.max(0, elementTop - topOffset) }, 500);
1983 + chatBox.animate({ scrollTop: elementTop }, 500);
2589 1984 }
2590 1985
2591 1986 function showChatWidget(botId) {
2592 1987 botId = botId || 'default';
@@ -2897,19 +2292,9 @@
2897 2292 var content = message.content;
2898 2293 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
2899 2294 content = decodeHTMLEntities(content);
2900 2295
2901 - // Skip linkify for messages containing structured HTML
2902 - // (forms, product cards, galleries, etc.) to avoid
2903 - // markdown formatting corrupting HTML attributes
2904 - // (e.g. underscores in name="field_name" becoming <em> tags)
2905 - if (content.includes("mxchat-product-card") ||
2906 - content.includes("mxchat-image-gallery") ||
2907 - content.includes("mxchat-featured-products") ||
2908 - content.includes("<form") ||
2909 - content.includes("<input") ||
2910 - content.includes("<select") ||
2911 - content.includes("<textarea")) {
2296 + if (content.includes("mxchat-product-card") || content.includes("mxchat-image-gallery")) {
2912 2297 messageElement.html(content);
2913 2298 } else {
2914 2299 var formattedContent = linkify(content);
2915 2300 messageElement.html(formattedContent);
@@ -3023,10 +2408,10 @@
3023 2408 .then(data => {
3024 2409 if (data.success) {
3025 2410 container.style.display = 'none';
3026 2411 nameElement.textContent = '';
3027 - instance.activePdfFile = null;
3028 - appendMessage('bot', 'PDF removed.', '', [], false, botId);
2412 + activePdfFile = null;
2413 + appendMessage('bot', 'PDF removed.');
3029 2414 }
3030 2415 })
3031 2416 .catch(error => {
3032 2417 // Error removing PDF - silently continue
@@ -3032,16 +2417,14 @@
3032 2417 // Error removing PDF - silently continue
3033 2418 });
3034 2419 }
3035 2420
3036 - function removeActiveWord(botId) {
3037 - botId = botId || 'default';
3038 - var instance = MxChatInstances.get(botId);
3039 - const container = getElementDOM(botId, 'active-word-container');
3040 - const nameElement = getElementDOM(botId, 'active-word-name');
3041 -
3042 - if (!container || !nameElement || !instance.activeWordFile) return;
3043 -
2421 + function removeActiveWord() {
2422 + const container = document.getElementById('active-word-container');
2423 + const nameElement = document.getElementById('active-word-name');
2424 +
2425 + if (!container || !nameElement || !activeWordFile) return;
2426 +
3044 2427 fetch(mxchatChat.ajax_url, {
3045 2428 method: 'POST',
3046 2429 headers: {
3047 2430 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3047,9 +2430,9 @@
3047 2430 'Content-Type': 'application/x-www-form-urlencoded',
3048 2431 },
3049 2432 body: new URLSearchParams({
3050 2433 'action': 'mxchat_remove_word',
3051 - 'session_id': getChatSession(botId),
2434 + 'session_id': sessionId,
3052 2435 'nonce': mxchatChat.nonce
3053 2436 })
3054 2437 })
3055 2438 .then(response => response.json())
@@ -3056,10 +2439,10 @@
3056 2439 .then(data => {
3057 2440 if (data.success) {
3058 2441 container.style.display = 'none';
3059 2442 nameElement.textContent = '';
3060 - instance.activeWordFile = null;
3061 - appendMessage('bot', 'Word document removed.', '', [], false, botId);
2443 + activeWordFile = null;
2444 + appendMessage('bot', 'Word document removed.');
3062 2445 }
3063 2446 })
3064 2447 .catch(error => {
3065 2448 // Error removing Word document - silently continue
@@ -3220,14 +2603,9 @@
3220 2603 collapseQuickQuestions(botId);
3221 2604 });
3222 2605
3223 2606 // Chatbot visibility toggle handlers - use class selector for multi-instance support
3224 - // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3225 - $(document).on('click keydown', '.floating-chatbot-button', function(e) {
3226 - if (e.type === 'keydown') {
3227 - if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
3228 - e.preventDefault();
3229 - }
2607 + $(document).on('click', '.floating-chatbot-button', function() {
3230 2608 var botId = getBotIdFromElement(this);
3231 2609 var $chatbot = getElement(botId, 'floating-chatbot');
3232 2610 var $badge = getElement(botId, 'chat-notification-badge');
3233 2611 var $preChat = getElement(botId, 'pre-chat-message');
@@ -3232,20 +2610,14 @@
3232 2610 var $badge = getElement(botId, 'chat-notification-badge');
3233 2611 var $preChat = getElement(botId, 'pre-chat-message');
3234 2612
3235 2613 if ($chatbot.hasClass('hidden')) {
3236 - $chatbot.removeClass('hidden').addClass('visible')
3237 - .attr('aria-modal', 'true').attr('role', 'dialog');
3238 - $(this).addClass('hidden').attr('aria-expanded', 'true');
2614 + $chatbot.removeClass('hidden').addClass('visible');
2615 + $(this).addClass('hidden');
3239 2616 $badge.hide(); // Hide notification when opening chat
3240 2617 disableScroll();
3241 2618 $preChat.fadeOut(250);
3242 2619
3243 - // First open per page load: re-fetch behavior settings in case
3244 - // this page's inline values came from a stale full-page cache
3245 - // (plan-32db95). Idempotent — later opens are a no-op.
3246 - mxchatRefreshDynamicSettings();
3247 -
3248 2620 // Load chat history for returning visitors (persistence)
3249 2621 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3250 2622 if (chatPersistenceEnabled) {
3251 2623 MxChatInstances.ensureSession(botId);
@@ -3256,60 +2628,25 @@
3256 2628 var instance = MxChatInstances.get(botId);
3257 2629 if (emailBlocker && !instance.emailCheckDone) {
3258 2630 instance.emailCheckDone = true;
3259 2631 resolveEmailState(botId);
3260 - } else if (!emailBlocker) {
3261 - // No email collection — still route through showChatContainerForBot
3262 - // so the loader is shown while chat history loads
3263 - showChatContainerForBot(botId);
3264 2632 }
3265 -
3266 - // Move keyboard focus into the message input after the open transition.
3267 - setTimeout(function() {
3268 - var chatInput = getElementDOM(botId, 'chat-input');
3269 - if (chatInput && !chatInput.disabled) {
3270 - try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
3271 - }
3272 - }, 300);
3273 2633 } else {
3274 - $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
3275 - $(this).removeClass('hidden').attr('aria-expanded', 'false');
2634 + $chatbot.removeClass('visible').addClass('hidden');
2635 + $(this).removeClass('hidden');
3276 2636 enableScroll();
3277 2637 checkPreChatDismissal(botId);
3278 2638 }
3279 2639 });
3280 2640
3281 - // Allow clicking anywhere on the title bar to close the chatbot.
3282 - // Returns keyboard focus to the launcher so keyboard users don't get
3283 - // stranded at <body> (WCAG SC 2.4.3 Focus Order). :focus-visible is
3284 - // heuristic-based so mouse-triggered close won't show a focus ring.
2641 + // Allow clicking anywhere on the title bar to close the chatbot
3285 2642 $(document).on('click', '.chatbot-top-bar', function() {
3286 2643 var botId = getBotIdFromElement(this);
3287 - getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3288 - var $launcher = getElement(botId, 'floating-chatbot-button');
3289 - $launcher.removeClass('hidden').attr('aria-expanded', 'false');
2644 + getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible');
2645 + getElement(botId, 'floating-chatbot-button').removeClass('hidden');
3290 2646 enableScroll();
3291 - try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3292 2647 });
3293 2648
3294 - // Global Escape-key handler — closes any visible chat widget and
3295 - // returns focus to its launcher. Standard modal-dismissal pattern;
3296 - // pairs with aria-modal="true" set on the widget when it opens.
3297 - $(document).on('keydown', function(e) {
3298 - if (e.key !== 'Escape' && e.key !== 'Esc') return;
3299 - var $visible = $('.floating-chatbot.visible');
3300 - if (!$visible.length) return;
3301 - e.preventDefault();
3302 - $visible.each(function() {
3303 - var botId = getBotIdFromElement(this);
3304 - $(this).addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3305 - var $launcher = getElement(botId, 'floating-chatbot-button');
3306 - $launcher.removeClass('hidden').attr('aria-expanded', 'false');
3307 - try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3308 - });
3309 - enableScroll();
3310 - });
3311 -
3312 2649 $(document).on('click', '.close-pre-chat-message', function(e) {
3313 2650 e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
3314 2651 var botId = getBotIdFromElement(this);
3315 2652 handlePreChatDismissal(botId);
@@ -3329,20 +2666,17 @@
3329 2666 var wordInput = getElementDOM(botId, 'word-upload');
3330 2667 if (wordInput) wordInput.click();
3331 2668 });
3332 2669
3333 - // PDF file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'pdf-upload')
3334 - $(document).on('change', '.pdf-upload', async function(e) {
3335 - var botId = getBotIdFromElement(this);
3336 - var instance = MxChatInstances.get(botId);
3337 - const file = this.files[0];
3338 - const sessionId = MxChatInstances.ensureSession(botId);
3339 -
2670 + // PDF file input change handler
2671 + addSafeEventListener('pdf-upload', 'change', async function(e) {
2672 + const file = e.target.files[0];
2673 +
3340 2674 if (!file || file.type !== 'application/pdf') {
3341 2675 alert('Please select a valid PDF file.');
3342 2676 return;
3343 2677 }
3344 -
2678 +
3345 2679 if (!sessionId) {
3346 2680 alert('Error: No session ID found');
3347 2681 return;
3348 2682 }
@@ -3350,49 +2684,47 @@
3350 2684 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3351 2685 alert('Error: Ajax configuration missing');
3352 2686 return;
3353 2687 }
3354 -
2688 +
3355 2689 // Disable buttons and show loading state
3356 - const uploadBtn = getElementDOM(botId, 'pdf-upload-btn');
3357 - const sendBtn = getElementDOM(botId, 'send-button');
3358 - if (!uploadBtn) return;
2690 + const uploadBtn = document.getElementById('pdf-upload-btn');
2691 + const sendBtn = document.getElementById('send-button');
3359 2692 const originalBtnContent = uploadBtn.innerHTML;
3360 -
2693 +
3361 2694 try {
3362 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3363 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3364 2695 const formData = new FormData();
3365 2696 formData.append('action', 'mxchat_upload_pdf');
3366 2697 formData.append('pdf_file', file);
3367 2698 formData.append('session_id', sessionId);
3368 2699 formData.append('nonce', mxchatChat.nonce);
3369 -
2700 +
3370 2701 uploadBtn.disabled = true;
3371 - if (sendBtn) sendBtn.disabled = true;
2702 + sendBtn.disabled = true;
3372 2703 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3373 2704 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3374 2705 </svg>`;
3375 -
2706 +
3376 2707 const response = await fetch(mxchatChat.ajax_url, {
3377 2708 method: 'POST',
3378 2709 body: formData
3379 2710 });
3380 -
2711 +
3381 2712 const data = await response.json();
3382 -
2713 +
3383 2714 if (data.success) {
3384 2715 // Hide popular questions if they exist
3385 - if (hasQuickQuestions(botId)) {
3386 - collapseQuickQuestions(botId);
2716 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2717 + if (hasQuickQuestions()) {
2718 + collapseQuickQuestions();
3387 2719 }
3388 -
2720 +
3389 2721 // Show the active PDF name
3390 - showActivePdf(data.data.filename, botId);
3391 -
3392 - appendMessage('bot', data.data.message, '', [], false, botId);
3393 - scrollToBottom(botId);
3394 - instance.activePdfFile = data.data.filename;
2722 + showActivePdf(data.data.filename);
2723 +
2724 + appendMessage('bot', data.data.message);
2725 + scrollToBottom();
2726 + activePdfFile = data.data.filename;
3395 2727 } else {
3396 2728 alert('Failed to upload PDF. Please try again.');
3397 2729 }
3398 2730 } catch (error) {
@@ -3398,76 +2730,66 @@
3398 2730 } catch (error) {
3399 2731 alert('Error uploading file. Please try again.');
3400 2732 } finally {
3401 2733 uploadBtn.disabled = false;
3402 - if (sendBtn) sendBtn.disabled = false;
2734 + sendBtn.disabled = false;
3403 2735 uploadBtn.innerHTML = originalBtnContent;
3404 2736 this.value = ''; // Reset file input
3405 2737 }
3406 2738 });
3407 2739
3408 - // Word file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'word-upload')
3409 - $(document).on('change', '.word-upload', async function(e) {
3410 - var botId = getBotIdFromElement(this);
3411 - var instance = MxChatInstances.get(botId);
3412 - const file = this.files[0];
3413 - const sessionId = MxChatInstances.ensureSession(botId);
3414 -
2740 + // Word file input change handler
2741 + addSafeEventListener('word-upload', 'change', async function(e) {
2742 + const file = e.target.files[0];
2743 +
3415 2744 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
3416 2745 alert('Please select a valid Word document (.docx).');
3417 2746 return;
3418 2747 }
3419 -
2748 +
3420 2749 if (!sessionId) {
3421 2750 alert('Error: No session ID found');
3422 2751 return;
3423 2752 }
3424 2753
3425 - if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3426 - alert('Error: Ajax configuration missing');
3427 - return;
3428 - }
3429 -
3430 2754 // Disable buttons and show loading state
3431 - const uploadBtn = getElementDOM(botId, 'word-upload-btn');
3432 - const sendBtn = getElementDOM(botId, 'send-button');
3433 - if (!uploadBtn) return;
2755 + const uploadBtn = document.getElementById('word-upload-btn');
2756 + const sendBtn = document.getElementById('send-button');
3434 2757 const originalBtnContent = uploadBtn.innerHTML;
3435 -
2758 +
3436 2759 try {
3437 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3438 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3439 2760 const formData = new FormData();
3440 2761 formData.append('action', 'mxchat_upload_word');
3441 2762 formData.append('word_file', file);
3442 2763 formData.append('session_id', sessionId);
3443 2764 formData.append('nonce', mxchatChat.nonce);
3444 -
2765 +
3445 2766 uploadBtn.disabled = true;
3446 - if (sendBtn) sendBtn.disabled = true;
2767 + sendBtn.disabled = true;
3447 2768 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3448 2769 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3449 2770 </svg>`;
3450 -
2771 +
3451 2772 const response = await fetch(mxchatChat.ajax_url, {
3452 2773 method: 'POST',
3453 2774 body: formData
3454 2775 });
3455 -
2776 +
3456 2777 const data = await response.json();
3457 -
2778 +
3458 2779 if (data.success) {
3459 2780 // Hide popular questions if they exist
3460 - if (hasQuickQuestions(botId)) {
3461 - collapseQuickQuestions(botId);
2781 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2782 + if (hasQuickQuestions()) {
2783 + collapseQuickQuestions();
3462 2784 }
3463 -
2785 +
3464 2786 // Show the active Word document name
3465 - showActiveWord(data.data.filename, botId);
3466 -
3467 - appendMessage('bot', data.data.message, '', [], false, botId);
3468 - scrollToBottom(botId);
3469 - instance.activeWordFile = data.data.filename;
2787 + showActiveWord(data.data.filename);
2788 +
2789 + appendMessage('bot', data.data.message);
2790 + scrollToBottom();
2791 + activeWordFile = data.data.filename;
3470 2792 } else {
3471 2793 alert('Failed to upload Word document. Please try again.');
3472 2794 }
3473 2795 } catch (error) {
@@ -3473,25 +2795,25 @@
3473 2795 } catch (error) {
3474 2796 alert('Error uploading file. Please try again.');
3475 2797 } finally {
3476 2798 uploadBtn.disabled = false;
3477 - if (sendBtn) sendBtn.disabled = false;
2799 + sendBtn.disabled = false;
3478 2800 uploadBtn.innerHTML = originalBtnContent;
3479 2801 this.value = ''; // Reset file input
3480 2802 }
3481 2803 });
3482 2804
3483 - // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids)
3484 - $(document).on('click', '.remove-pdf-btn', function(e) {
2805 + // Remove button click handlers
2806 + document.getElementById('remove-pdf-btn')?.addEventListener('click', function(e) {
3485 2807 e.preventDefault();
3486 2808 e.stopPropagation();
3487 - removeActivePdf(getBotIdFromElement(this));
2809 + removeActivePdf();
3488 2810 });
3489 -
3490 - $(document).on('click', '.remove-word-btn', function(e) {
2811 +
2812 + document.getElementById('remove-word-btn')?.addEventListener('click', function(e) {
3491 2813 e.preventDefault();
3492 2814 e.stopPropagation();
3493 - removeActiveWord(getBotIdFromElement(this));
2815 + removeActiveWord();
3494 2816 });
3495 2817
3496 2818 // Window resize handlers
3497 2819 $(window).on('resize orientationchange', function() {
@@ -3529,59 +2851,8 @@
3529 2851 });
3530 2852
3531 2853
3532 2854 // ====================================
3533 -// INIT LOADER & CHAT CONTAINER HELPERS
3534 -// ====================================
3535 -// These must be outside the email collection block so they're always available
3536 -// (used by persistence loading even when email collection is off)
3537 -
3538 -function showInitLoader(botId) {
3539 - var loader = getElementDOM(botId, 'mxchat-init-loader');
3540 - if (loader) loader.style.display = 'flex';
3541 -}
3542 -
3543 -function hideInitLoader(botId) {
3544 - var loader = getElementDOM(botId, 'mxchat-init-loader');
3545 - if (loader) loader.style.display = 'none';
3546 -}
3547 -
3548 -function showEmailFormForBot(botId) {
3549 - hideInitLoader(botId);
3550 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3551 - var chatContainer = getElementDOM(botId, 'chat-container');
3552 - if (emailBlocker) emailBlocker.style.display = 'flex';
3553 - if (chatContainer) chatContainer.style.display = 'none';
3554 -}
3555 -
3556 -function showChatContainerForBot(botId) {
3557 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3558 - var chatContainer = getElementDOM(botId, 'chat-container');
3559 - if (emailBlocker) emailBlocker.style.display = 'none';
3560 -
3561 - var instance = MxChatInstances.get(botId);
3562 - var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3563 -
3564 - // If persistence is on and history hasn't loaded yet, show loader
3565 - // while history loads to prevent flash of empty chat
3566 - if (chatPersistenceEnabled && !instance.chatHistoryLoaded) {
3567 - if (chatContainer) chatContainer.style.display = 'none';
3568 - showInitLoader(botId);
3569 - loadChatHistory(botId, function() {
3570 - hideInitLoader(botId);
3571 - if (chatContainer) chatContainer.style.display = 'flex';
3572 - scrollToBottom(botId, true);
3573 - });
3574 - } else {
3575 - hideInitLoader(botId);
3576 - if (chatContainer) chatContainer.style.display = 'flex';
3577 - if (typeof loadChatHistory === 'function') {
3578 - loadChatHistory(botId);
3579 - }
3580 - }
3581 -}
3582 -
3583 -// ====================================
3584 2855 // EMAIL COLLECTION SETUP - MULTI-INSTANCE VERSION
3585 2856 // ====================================
3586 2857 // Only run email collection setup if it's enabled
3587 2858 if (mxchatChat && mxchatChat.email_collection_enabled === 'on') {
@@ -3617,8 +2888,40 @@
3617 2888 `;
3618 2889 document.head.appendChild(style);
3619 2890 }
3620 2891
2892 + // Helper functions for email collection (multi-instance aware)
2893 + function showEmailFormForBot(botId) {
2894 + var emailBlocker = getElementDOM(botId, 'email-blocker');
2895 + var chatContainer = getElementDOM(botId, 'chat-container');
2896 + if (emailBlocker) emailBlocker.style.display = 'flex';
2897 + if (chatContainer) chatContainer.style.display = 'none';
2898 + }
2899 +
2900 + function showChatContainerForBot(botId) {
2901 + var emailBlocker = getElementDOM(botId, 'email-blocker');
2902 + var chatContainer = getElementDOM(botId, 'chat-container');
2903 + if (emailBlocker) emailBlocker.style.display = 'none';
2904 +
2905 + var instance = MxChatInstances.get(botId);
2906 + var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
2907 +
2908 + // If persistence is on and history hasn't loaded yet, keep container
2909 + // hidden until history loads to prevent flash of empty chat
2910 + if (chatPersistenceEnabled && !instance.chatHistoryLoaded) {
2911 + if (chatContainer) chatContainer.style.display = 'none';
2912 + loadChatHistory(botId, function() {
2913 + if (chatContainer) chatContainer.style.display = 'flex';
2914 + scrollToBottom(botId, true);
2915 + });
2916 + } else {
2917 + if (chatContainer) chatContainer.style.display = 'flex';
2918 + if (typeof loadChatHistory === 'function') {
2919 + loadChatHistory(botId);
2920 + }
2921 + }
2922 + }
2923 +
3621 2924 function isValidEmailAddress(email) {
3622 2925 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3623 2926 return emailRegex.test(email.trim()) && email.length <= 254;
3624 2927 }
@@ -3756,14 +3059,13 @@
3756 3059
3757 3060 function checkSessionAndEmailForBot(botId) {
3758 3061 const sessionId = MxChatInstances.ensureSession(botId);
3759 3062
3760 - // Hide both panels while we check — show loader instead
3063 + // Hide both panels while we check — prevents flash of wrong state
3761 3064 var emailBlocker = getElementDOM(botId, 'email-blocker');
3762 3065 var chatContainer = getElementDOM(botId, 'chat-container');
3763 3066 if (emailBlocker) emailBlocker.style.display = 'none';
3764 3067 if (chatContainer) chatContainer.style.display = 'none';
3765 - showInitLoader(botId);
3766 3068
3767 3069 fetch(mxchatChat.ajax_url, {
3768 3070 method: 'POST',
3769 3071 headers: {
@@ -3943,8 +3245,9 @@
3943 3245 $('.mxchat-chatbot-wrapper').each(function() {
3944 3246 var botId = $(this).data('bot-id') || 'default';
3945 3247 var emailBlocker = getElementDOM(botId, 'email-blocker');
3946 3248
3249 + // Only check if email blocker exists for this bot
3947 3250 if (emailBlocker) {
3948 3251 if (isEmbeddedBot(botId)) {
3949 3252 // Embedded bots are always visible — check now
3950 3253 resolveEmailState(botId);
@@ -3949,15 +3252,8 @@
3949 3252 // Embedded bots are always visible — check now
3950 3253 resolveEmailState(botId);
3951 3254 }
3952 3255 // Floating bots: handled in the widget open handler
3953 - } else if (isEmbeddedBot(botId)) {
3954 - // Embedded bot, no email collection — load history with loader
3955 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3956 - if (chatPersistenceEnabled) {
3957 - MxChatInstances.ensureSession(botId);
3958 - showChatContainerForBot(botId);
3959 - }
3960 3256 }
3961 3257 });
3962 3258 }
3963 3259
@@ -3982,10 +3278,8 @@
3982 3278 var instance = MxChatInstances.get(botId);
3983 3279 if (emailBlocker && !instance.emailCheckDone) {
3984 3280 instance.emailCheckDone = true;
3985 3281 resolveEmailState(botId);
3986 - } else if (!emailBlocker) {
3987 - showChatContainerForBot(botId);
3988 3282 }
3989 3283 }
3990 3284 });
3991 3285
@@ -4185,312 +3479,6 @@
4185 3479 }, 2000);
4186 3480 });
4187 3481 }
4188 3482 }
4189 -});
4190 -
4191 -// ============================================================================
4192 -// SATISFACTION RATING (v3.2.6)
4193 -// ============================================================================
4194 -// Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
4195 -// inactivity following a bot reply. One prompt per session, deduped via
4196 -// localStorage. Runs ONLY when the satisfaction_rating_enabled option is on —
4197 -// the option (default off) is authoritative.
4198 -jQuery(function($) {
4199 - if (typeof mxchatChat === 'undefined') return;
4200 - // wp_localize_script stringifies scalars: a PHP boolean false arrives as
4201 - // '' and true as '1', so this must be an explicit-enable allowlist — the
4202 - // old "disabled when exactly false/'off'" check let '' through and the
4203 - // bubble rendered on sites with the option off/unset (plan-4bba64). PHP
4204 - // now emits 'on'/'off' strings; true/'1'/1 keep cached pre-fix HTML
4205 - // (boolean-true localizations) working.
4206 - // NOTE (plan-32db95): this gate reads the INLINE value at DOM ready and is
4207 - // deliberately NOT re-evaluated after the widget's dynamic-settings refresh
4208 - // merges fresh values over mxchatChat (that merge fires on first widget
4209 - // open, after this module has already decided). Re-evaluating would mean
4210 - // restructuring the whole module to late-bind its listeners — not worth it
4211 - // for a prompt that is at worst stale for one page load on a cached page.
4212 - var sre = mxchatChat.satisfaction_rating_enabled;
4213 - if (sre !== 'on' && sre !== true && sre !== '1' && sre !== 1) return;
4214 -
4215 - // wp_localize_script stringifies ints, so accept both number and numeric string.
4216 - var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
4217 - var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);
4218 - if (!isFinite(idleSeconds)) idleSeconds = 60;
4219 - if (idleSeconds < 5) idleSeconds = 5;
4220 - if (idleSeconds > 600) idleSeconds = 600;
4221 - var IDLE_MS = idleSeconds * 1000;
4222 - var MIN_BOT_REPLIES = 2;
4223 - var ratingState = {};
4224 -
4225 - function getState(botId) {
4226 - if (!ratingState[botId]) {
4227 - ratingState[botId] = { idleTimer: null, botReplies: 0, promptShown: false, dismissed: false };
4228 - }
4229 - return ratingState[botId];
4230 - }
4231 -
4232 - function getSessionId(botId) {
4233 - if (typeof MxChatInstances !== 'undefined' && MxChatInstances.getChatSession) {
4234 - return MxChatInstances.getChatSession(botId);
4235 - }
4236 - return null;
4237 - }
4238 -
4239 - function isAlreadyRated(sessionId) {
4240 - if (!sessionId) return false;
4241 - try { return localStorage.getItem('mxchat_rated:' + sessionId) === '1'; } catch (e) { return false; }
4242 - }
4243 -
4244 - function markRated(sessionId) {
4245 - if (!sessionId) return;
4246 - try { localStorage.setItem('mxchat_rated:' + sessionId, '1'); } catch (e) {}
4247 - }
4248 -
4249 - function esc(s) {
4250 - return String(s == null ? '' : s)
4251 - .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
4252 - .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
4253 - }
4254 -
4255 - // Mirror shouldSkipInlineColors so rating bubbles defer to AI-theme CSS.
4256 - function ratingSkipInlineColors(botId) {
4257 - if (mxchatChat.skip_inline_colors) return true;
4258 - var botAssignments = mxchatChat.bot_theme_assignments || {};
4259 - return botAssignments.hasOwnProperty(botId);
4260 - }
4261 -
4262 - function botBubbleStyleAttr(botId) {
4263 - if (ratingSkipInlineColors(botId)) return '';
4264 - var bg = mxchatChat.bot_message_bg_color;
4265 - var fg = mxchatChat.bot_message_font_color;
4266 - if (!bg && !fg) return '';
4267 - return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"';
4268 - }
4269 -
4270 - // Reads the rating bubble's actual computed fg+bg (whatever paints it —
4271 - // the inline color pickers OR the mxchat-theme AI customizer's injected CSS)
4272 - // and paints the filled "Send" pill so it fills with the bot font color and
4273 - // labels in the bubble bg. Mirrors mxchatSyncMenuColors(~:1512) for the read.
4274 - // We paint the submit button DIRECTLY (inline longhand) rather than relying
4275 - // on the CSS rule's var()s: Chromium resolves an INHERITED custom property
4276 - // unreliably inside a descendant's `background`, so a bubble-level var would
4277 - // silently fall back to the literal (white-block bug all over again). Inline
4278 - // longhand always wins. Same transparent-guard as the menu so we never paint
4279 - // a see-through value — in that case the CSS literal fallbacks keep it legible.
4280 - function syncRatingBubbleColors(botId) {
4281 - var $chatBox = getChatBoxByBotId(botId);
4282 - if (!$chatBox || !$chatBox.length) return;
4283 - var bubbleEl = $chatBox.find('.mxchat-rating-bot-bubble').last()[0];
4284 - if (!bubbleEl) return;
4285 - var cs = window.getComputedStyle(bubbleEl);
4286 - var fg = cs.color;
4287 - var bg = cs.backgroundColor;
4288 - var hasFg = fg && fg !== 'rgba(0, 0, 0, 0)' && fg !== 'transparent';
4289 - var hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
4290 - // Expose on the bubble too, for any inheriting styles / future use.
4291 - if (hasFg) bubbleEl.style.setProperty('--mxchat-bot-fg', fg);
4292 - if (hasBg) bubbleEl.style.setProperty('--mxchat-bot-bg', bg);
4293 - // Paint the Send pill directly — the part that actually fixes the bug.
4294 - var submitEl = bubbleEl.querySelector('.mxchat-rating-submit');
4295 - if (submitEl) {
4296 - if (hasFg) submitEl.style.backgroundColor = fg; // fill = bot font color
4297 - if (hasBg) submitEl.style.color = bg; // label = bubble background
4298 - }
4299 - }
4300 -
4301 - function copy(key) {
4302 - var c = mxchatChat.satisfaction_rating_copy || {};
4303 - var d = {
4304 - question: 'Was this helpful?',
4305 - helpful: 'Helpful',
4306 - not_helpful: 'Not helpful',
4307 - dismiss: 'Dismiss',
4308 - thanks: 'Thanks! Anything we should improve? (optional)',
4309 - placeholder: 'Tell us what could be better…',
4310 - send: 'Send',
4311 - skip: 'Skip',
4312 - saved: 'Thanks for the feedback.'
4313 - };
4314 - return c[key] || d[key];
4315 - }
4316 -
4317 - function thumbUpSvg() {
4318 - 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>';
4319 - }
4320 - function thumbDownSvg() {
4321 - 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>';
4322 - }
4323 -
4324 - function buildPromptHtml(botId) {
4325 - var styleAttr = botBubbleStyleAttr(botId);
4326 - return ''
4327 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4328 - + '<div class="mxchat-rating-prompt" data-bot-id="' + esc(botId) + '" role="group" aria-label="' + esc(copy('question')) + '">'
4329 - + '<div class="mxchat-rating-question">' + esc(copy('question')) + '</div>'
4330 - + '<div class="mxchat-rating-actions">'
4331 - + '<span class="mxchat-rating-buttons">'
4332 - + '<button type="button" class="mxchat-rating-btn" data-rating="1" aria-label="' + esc(copy('helpful')) + '">' + thumbUpSvg() + '</button>'
4333 - + '<button type="button" class="mxchat-rating-btn" data-rating="-1" aria-label="' + esc(copy('not_helpful')) + '">' + thumbDownSvg() + '</button>'
4334 - + '</span>'
4335 - + '<button type="button" class="mxchat-rating-dismiss" aria-label="' + esc(copy('dismiss')) + '">×</button>'
4336 - + '</div>'
4337 - + '</div>'
4338 - + '</div>';
4339 - }
4340 -
4341 - function buildFeedbackHtml(botId, rating) {
4342 - var styleAttr = botBubbleStyleAttr(botId);
4343 - return ''
4344 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4345 - + '<div class="mxchat-rating-feedback" data-bot-id="' + esc(botId) + '" data-rating="' + esc(String(rating)) + '">'
4346 - + '<div class="mxchat-rating-feedback-label">' + esc(copy('thanks')) + '</div>'
4347 - + '<textarea class="mxchat-rating-feedback-input" maxlength="500" placeholder="' + esc(copy('placeholder')) + '" rows="2"></textarea>'
4348 - + '<div class="mxchat-rating-feedback-actions">'
4349 - + '<button type="button" class="mxchat-rating-skip">' + esc(copy('skip')) + '</button>'
4350 - + '<button type="button" class="mxchat-rating-submit">' + esc(copy('send')) + '</button>'
4351 - + '</div>'
4352 - + '</div>'
4353 - + '</div>';
4354 - }
4355 -
4356 - function buildSavedHtml(botId) {
4357 - var styleAttr = botBubbleStyleAttr(botId);
4358 - return ''
4359 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4360 - + '<div class="mxchat-rating-saved">' + esc(copy('saved')) + '</div>'
4361 - + '</div>';
4362 - }
4363 -
4364 - function getChatBoxByBotId(botId) {
4365 - var $byId = $('#chat-box-' + botId);
4366 - if ($byId.length) return $byId.first();
4367 - return $('.chat-box').first();
4368 - }
4369 -
4370 - function scrollChatBoxToBottom($chatBox) {
4371 - if (!$chatBox || !$chatBox.length) return;
4372 - $chatBox.scrollTop($chatBox[0].scrollHeight);
4373 - }
4374 -
4375 - function showPrompt(botId) {
4376 - var s = getState(botId);
4377 - if (s.promptShown || s.dismissed) return;
4378 - var sessionId = getSessionId(botId);
4379 - if (!sessionId) return;
4380 - if (isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4381 - var $chatBox = getChatBoxByBotId(botId);
4382 - if (!$chatBox.length) return;
4383 - if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; }
4384 - $chatBox.append(buildPromptHtml(botId));
4385 - syncRatingBubbleColors(botId);
4386 - s.promptShown = true;
4387 - scrollChatBoxToBottom($chatBox);
4388 - }
4389 -
4390 - function submitRating(botId, rating, feedback) {
4391 - var sessionId = getSessionId(botId);
4392 - if (!sessionId) return;
4393 - $.post(mxchatChat.ajax_url, {
4394 - action: 'mxchat_save_rating',
4395 - session_id: sessionId,
4396 - bot_id: botId,
4397 - rating: rating,
4398 - feedback: feedback || ''
4399 - });
4400 - markRated(sessionId);
4401 - }
4402 -
4403 - function onBotReply(botId) {
4404 - var s = getState(botId);
4405 - s.botReplies += 1;
4406 - if (s.promptShown || s.dismissed) return;
4407 - var sessionId = getSessionId(botId);
4408 - if (sessionId && isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4409 - if (s.botReplies < MIN_BOT_REPLIES) return;
4410 - if (s.idleTimer) clearTimeout(s.idleTimer);
4411 - s.idleTimer = setTimeout(function() { showPrompt(botId); }, IDLE_MS);
4412 - }
4413 -
4414 - function onUserMessage(botId) {
4415 - var s = getState(botId);
4416 - if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = null; }
4417 - }
4418 -
4419 - function botIdFromChatBox(el) {
4420 - var id = el && el.id ? el.id : '';
4421 - return id.indexOf('chat-box-') === 0 ? id.substring('chat-box-'.length) : 'default';
4422 - }
4423 -
4424 - function setupObserver(chatBox) {
4425 - var botId = botIdFromChatBox(chatBox);
4426 - try {
4427 - var observer = new MutationObserver(function(mutations) {
4428 - mutations.forEach(function(m) {
4429 - for (var i = 0; i < m.addedNodes.length; i++) {
4430 - var node = m.addedNodes[i];
4431 - if (!node || node.nodeType !== 1) continue;
4432 - var $n = $(node);
4433 - if ($n.hasClass('mxchat-rating-bot-bubble') || $n.hasClass('mxchat-rating-prompt') || $n.hasClass('mxchat-rating-feedback') || $n.hasClass('mxchat-rating-saved')) continue;
4434 - 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)
4435 - else if ($n.hasClass('user-message')) onUserMessage(botId);
4436 - }
4437 - });
4438 - });
4439 - observer.observe(chatBox, { childList: true });
4440 - } catch (e) { /* noop */ }
4441 - }
4442 -
4443 - $('.chat-box').each(function() { setupObserver(this); });
4444 -
4445 - $(document).on('click', '.mxchat-rating-btn', function(e) {
4446 - e.preventDefault();
4447 - var $btn = $(this);
4448 - var $prompt = $btn.closest('.mxchat-rating-prompt');
4449 - var $wrap = $btn.closest('.mxchat-rating-bot-bubble');
4450 - var botId = $prompt.data('bot-id') || 'default';
4451 - var rating = parseInt($btn.attr('data-rating'), 10);
4452 - if (rating !== 1 && rating !== -1) return;
4453 - submitRating(botId, rating, '');
4454 - ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating));
4455 - syncRatingBubbleColors(botId);
4456 - scrollChatBoxToBottom(getChatBoxByBotId(botId));
4457 - });
4458 -
4459 - $(document).on('click', '.mxchat-rating-dismiss', function(e) {
4460 - e.preventDefault();
4461 - var $prompt = $(this).closest('.mxchat-rating-prompt');
4462 - var $wrap = $(this).closest('.mxchat-rating-bot-bubble');
4463 - var botId = $prompt.data('bot-id') || 'default';
4464 - var s = getState(botId);
4465 - s.dismissed = true;
4466 - markRated(getSessionId(botId));
4467 - ($wrap.length ? $wrap : $prompt).remove();
4468 - });
4469 -
4470 - function closeFeedback($fb) {
4471 - var botId = $fb.data('bot-id') || 'default';
4472 - var $wrap = $fb.closest('.mxchat-rating-bot-bubble');
4473 - ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId));
4474 - syncRatingBubbleColors(botId);
4475 - scrollChatBoxToBottom(getChatBoxByBotId(botId));
4476 - }
4477 -
4478 - $(document).on('click', '.mxchat-rating-skip', function(e) {
4479 - e.preventDefault();
4480 - closeFeedback($(this).closest('.mxchat-rating-feedback'));
4481 - });
4482 -
4483 - $(document).on('click', '.mxchat-rating-submit', function(e) {
4484 - e.preventDefault();
4485 - var $fb = $(this).closest('.mxchat-rating-feedback');
4486 - var botId = $fb.data('bot-id') || 'default';
4487 - var rating = parseInt($fb.attr('data-rating'), 10);
4488 - if (rating !== 1 && rating !== -1) { closeFeedback($fb); return; }
4489 - var text = String($fb.find('.mxchat-rating-feedback-input').val() || '').trim();
4490 - if (text !== '') {
4491 - submitRating(botId, rating, text);
4492 - }
4493 - closeFeedback($fb);
4494 - });
4495 3483 });
4496 3484