PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.1.4
MxChat – AI Chatbot & Content Generation for WordPress v3.1.4
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 +248 -1375 3.2.133.1.4 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,39 +60,18 @@
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.
209 64 getChatSession: function(botId) {
210 65 var cookieName = 'mxchat_session_id_' + botId;
211 - var storageKey = 'mxchat_session_id_' + botId;
212 66 var sessionId = getCookie(cookieName);
213 67
214 - // Fallback to localStorage if cookie is missing (e.g. cleared by browser/consent)
215 68 if (!sessionId) {
216 - try { sessionId = localStorage.getItem(storageKey); } catch (e) {}
69 + sessionId = generateSessionId();
70 + this.setChatSession(botId, sessionId);
217 71 }
218 72
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;
73 + return sessionId;
238 74 },
239 75
240 76 // Lazy session initializer — called on first user interaction
241 77 ensureSession: function(botId) {
@@ -245,10 +81,11 @@
245 81 if (instance.sessionId) {
246 82 return instance.sessionId;
247 83 }
248 84
249 - // Check for existing session from cookie or localStorage
250 - var existingSession = this.getChatSession(botId);
85 + // Check if a cookie already exists from a prior visit
86 + var cookieName = 'mxchat_session_id_' + botId;
87 + var existingSession = getCookie(cookieName);
251 88
252 89 if (existingSession) {
253 90 instance.sessionId = existingSession;
254 91 } else {
@@ -261,10 +98,12 @@
261 98 // Now that we have a session, do the deferred work
262 99 refreshNonceIfNeeded();
263 100 trackOriginatingPage();
264 101
265 - // Note: loadChatHistory is handled by showChatContainerForBot with loader UI,
266 - // so we do NOT call it here to avoid a race condition.
102 + var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
103 + if (chatPersistenceEnabled && mxchatChat.email_collection_enabled !== 'on') {
104 + loadChatHistory(botId);
105 + }
267 106
268 107 return instance.sessionId;
269 108 },
270 109
@@ -269,11 +108,9 @@
269 108 },
270 109
271 110 setChatSession: function(botId, sessionId) {
272 111 var cookieName = 'mxchat_session_id_' + botId;
273 - var storageKey = 'mxchat_session_id_' + botId;
274 112 document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax";
275 - try { localStorage.setItem(storageKey, sessionId); } catch (e) {}
276 113 if (this.instances[botId]) {
277 114 this.instances[botId].sessionId = sessionId;
278 115 }
279 116 },
@@ -278,10 +115,8 @@
278 115 }
279 116 },
280 117
281 118 resetChatSession: function(botId) {
282 - // Clear old session from localStorage before setting new one
283 - try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {}
284 119 var newSessionId = generateSessionId();
285 120 this.setChatSession(botId, newSessionId);
286 121 var $chatBox = getElement(botId, 'chat-box');
287 122 if ($chatBox.length) {
@@ -290,20 +125,8 @@
290 125 if (this.instances[botId]) {
291 126 this.instances[botId].chatHistoryLoaded = false;
292 127 this.instances[botId].processedMessageIds = new Set();
293 128 }
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 129 }
307 130 };
308 131
309 132 // ====================================
@@ -343,20 +166,8 @@
343 166 var id = $floating.attr('id') || '';
344 167 var match = id.match(/floating-chatbot-(.+)/);
345 168 if (match) return match[1];
346 169 }
347 - // Fallback: the pre-chat teaser bubble (#pre-chat-message-{bot_id}) is a SIBLING
348 - // outside .mxchat-chatbot-wrapper / .floating-chatbot, so its children — e.g. the
349 - // .close-pre-chat-message button, which carries only a class and no id — miss both
350 - // branches above. Walk to the nearest ancestor whose id is pre-chat-message-{bot_id}
351 - // and read the suffix. (closest() includes the element itself, so a click directly on
352 - // #pre-chat-message-{bot_id} resolves here too.)
353 - var $preChat = $(element).closest('[id^="pre-chat-message-"]');
354 - if ($preChat.length) {
355 - var preId = $preChat.attr('id') || '';
356 - var preMatch = preId.match(/^pre-chat-message-(.+)$/);
357 - if (preMatch) return preMatch[1];
358 - }
359 170 // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
360 171 var elementId = $(element).attr('id') || '';
361 172 if (elementId) {
362 173 // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
@@ -615,102 +426,10 @@
615 426 sendButton.disabled = false;
616 427 sendButton.style.opacity = '1';
617 428 sendButton.style.pointerEvents = 'auto';
618 429 }
619 - // Every completion path re-enables input, so this is the single restore
620 - // point for the streaming Stop affordance (no-op when not in stop mode).
621 - mxchatRestoreSendButton(botId);
622 430 }
623 431
624 -// --- Streaming Stop control -------------------------------------------------
625 -// One live stream handle per bot instance, so Stop on one widget never aborts
626 -// another bot on the same page.
627 -var mxchatActiveStreams = {};
628 -// Original send-button markup, captured once per bot the first time the Stop
629 -// state is shown (never captured while already in stop mode, so a rapid
630 -// stop-then-resend can't save the stop glyph as the "original").
631 -var mxchatSendMarkup = {};
632 -
633 -function mxchatShowStopButton(botId) {
634 - var btn = getElementDOM(botId, 'send-button');
635 - if (!btn) return;
636 - if (!btn.classList.contains('mxchat-stop-mode')) {
637 - mxchatSendMarkup[botId] = {
638 - html: btn.innerHTML,
639 - label: btn.getAttribute('aria-label')
640 - };
641 - }
642 -
643 - // Mirror the send icon's rendered size + color so the stop glyph looks
644 - // native, including custom send images/colors and theme overrides.
645 - var child = btn.querySelector('svg, img');
646 - var size = 25;
647 - var color = '';
648 - if (child) {
649 - var rect = child.getBoundingClientRect();
650 - if (rect.width) {
651 - size = Math.round(Math.min(rect.width, rect.height));
652 - }
653 - var cs = window.getComputedStyle(child);
654 - color = (child.tagName.toLowerCase() === 'svg' ? cs.fill : cs.color) || '';
655 - }
656 - var stopLabel = (typeof mxchatChat !== 'undefined' && mxchatChat.stop_button_label) || 'Stop response';
657 - btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" style="width:' + size + 'px;height:' + size + 'px;' + (color ? 'fill:' + color + ';' : '') + '"><rect x="5" y="5" width="14" height="14" rx="3"></rect></svg>';
658 - // An add-on's DIRECT send-button handler (e.g. mxchat-vision's rebind) can
659 - // start a stream synchronously while the originating click is still
660 - // bubbling up to our delegated handler. Without this guard, that handler
661 - // reads the just-added stop-mode class as a user Stop press and aborts the
662 - // brand-new stream — the user's message renders but no reply ever fires
663 - // (plan-4bba64 silent message loss). The flag only spans the current event
664 - // dispatch: cleared on the next macrotask, long before a real Stop click.
665 - btn.__mxchatStopJustShown = true;
666 - setTimeout(function () { btn.__mxchatStopJustShown = false; }, 0);
667 - btn.classList.add('mxchat-stop-mode');
668 - btn.setAttribute('aria-label', stopLabel);
669 - btn.setAttribute('title', stopLabel);
670 - // disableChatInput() ran when the turn was sent; the Stop control itself
671 - // must stay clickable while the textarea remains disabled.
672 - btn.disabled = false;
673 - btn.style.opacity = '1';
674 - btn.style.pointerEvents = 'auto';
675 -}
676 -
677 -function mxchatRestoreSendButton(botId) {
678 - var btn = getElementDOM(botId, 'send-button');
679 - var saved = mxchatSendMarkup[botId];
680 - if (!btn || !btn.classList.contains('mxchat-stop-mode') || !saved) return;
681 - btn.innerHTML = saved.html;
682 - btn.classList.remove('mxchat-stop-mode');
683 - btn.removeAttribute('title');
684 - if (saved.label) {
685 - btn.setAttribute('aria-label', saved.label);
686 - }
687 -}
688 -
689 -function mxchatStopStreaming(botId) {
690 - var entry = mxchatActiveStreams[botId];
691 - if (!entry || !entry.controller) return;
692 - entry.aborted = true;
693 - try { entry.controller.abort(); } catch (e) {}
694 -}
695 -
696 -// Returns true when a stream rejection came from an intentional Stop click:
697 -// keep the partial text as the turn's answer — no error UI, no fallback resend.
698 -function mxchatHandleStreamAbort(botId, accumulatedContent, callback) {
699 - var entry = mxchatActiveStreams[botId];
700 - if (!entry || !entry.aborted) return false;
701 - delete mxchatActiveStreams[botId];
702 - if (!accumulatedContent) {
703 - // Stopped before the first chunk: drop the thinking bubble, no orphan message.
704 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
705 - }
706 - enableChatInput(botId); // also restores the send icon
707 - if (callback) {
708 - callback(accumulatedContent || '');
709 - }
710 - return true;
711 -}
712 -
713 432 // Update your existing sendMessage function
714 433 function sendMessage(botId) {
715 434 botId = botId || 'default';
716 435 MxChatInstances.ensureSession(botId);
@@ -731,9 +450,8 @@
731 450 }
732 451
733 452 appendMessage("user", message, '', [], false, botId);
734 453 $chatInput.val('');
735 - mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
736 454 $chatInput.css('height', 'auto');
737 455
738 456 if (hasQuickQuestions(botId)) {
739 457 collapseQuickQuestions(botId);
@@ -851,15 +569,8 @@
851 569
852 570 function callMxChat(message, callback, botId) {
853 571 botId = botId || getMxChatBotId();
854 572
855 - // Streaming fallbacks land here: drop any leftover stream handle and
856 - // return the button to its send state (no-op for plain non-stream turns).
857 - if (mxchatActiveStreams[botId]) {
858 - delete mxchatActiveStreams[botId];
859 - }
860 - mxchatRestoreSendButton(botId);
861 -
862 573 // Store the message in case we need to retry after session reset
863 574 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
864 575
865 576 // Get page context if contextual awareness is enabled
@@ -867,28 +578,13 @@
867 578
868 579 // Get instance for session start timestamp (used when persistence is OFF)
869 580 var instance = MxChatInstances.get(botId);
870 581
871 - // Guarantee a non-null session_id before the AJAX leaves. ensureSession() is idempotent
872 - // and returns the guaranteed-present session id from the in-memory instance even when
873 - // cookie/localStorage writes are silently blocked by the browser.
874 - var sessionId = MxChatInstances.ensureSession(botId);
875 - if (!sessionId || sessionId === 'null' || sessionId === 'undefined') {
876 - // Last-resort generation to ensure we never POST a null marker.
877 - sessionId = generateSessionId();
878 - MxChatInstances.setChatSession(botId, sessionId);
879 - }
880 -
881 - // Wait for the page-cache nonce refresh to complete before firing the
882 - // chat-send AJAX. On cached pages the inline mxchatChat.nonce is stale
883 - // until refreshNonceIfNeeded() returns; constructing ajaxData inside the
884 - // callback guarantees we read the fresh value. See plan-c5457f.
885 - refreshNonceIfNeeded(function() {
886 582 // Prepare AJAX data
887 583 const ajaxData = {
888 584 action: 'mxchat_handle_chat_request',
889 585 message: message,
890 - session_id: sessionId,
586 + session_id: getChatSession(botId),
891 587 nonce: mxchatChat.nonce,
892 588 current_page_url: window.location.href,
893 589 current_page_title: document.title,
894 590 bot_id: botId,
@@ -894,14 +590,14 @@
894 590 bot_id: botId,
895 591 // Pass session start timestamp so AI context matches what user sees
896 592 session_start_timestamp: instance.sessionStartTimestamp || 0
897 593 };
898 -
594 +
899 595 // Add page context if available
900 596 if (pageContext) {
901 597 ajaxData.page_context = JSON.stringify(pageContext);
902 598 }
903 -
599 +
904 600 // CHECK FOR VISION FLAGS AND ADD THEM
905 601 if (window.mxchatVisionProcessed) {
906 602 ajaxData.vision_processed = true;
907 603 ajaxData.original_user_message = window.mxchatOriginalMessage || message;
@@ -910,9 +606,9 @@
910 606 window.mxchatVisionProcessed = false;
911 607 window.mxchatOriginalMessage = null;
912 608 window.mxchatVisionImagesCount = 0;
913 609 }
914 -
610 +
915 611 $.ajax({
916 612 url: mxchatChat.ajax_url,
917 613 type: 'POST',
918 614 dataType: 'json',
@@ -950,16 +646,23 @@
950 646 errorMessage = "An error occurred. Please try again or contact support.";
951 647 }
952 648
953 649 // Handle session reset action (IP changed, session expired, etc.)
954 - // Silent reset — keep chat UI intact, just get a new session and retry
955 650 if (response.data && response.data.action === 'reset_session') {
956 - MxChatInstances.silentResetSession(botId);
957 - // Re-send the original message with the new session (user message is already displayed)
651 + // Clear the old session and generate a new one
652 + resetChatSession(botId);
653 + // Remove the temporary loading message
654 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
655 + // Re-send the original message with the new session
958 656 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
959 657 if (originalMessage) {
960 658 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
961 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
659 + // Re-add the user message and thinking indicator
660 + appendMessage("user", originalMessage, '', [], false, botId);
661 + appendThinkingMessage(botId);
662 + scrollToBottom(botId);
663 + // Determine whether to use streaming
664 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
962 665 if (shouldUseStreaming(currentModel)) {
963 666 callMxChatStream(originalMessage, function(response) {
964 667 getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
965 668 }, botId);
@@ -1101,9 +804,8 @@
1101 804
1102 805 replaceLastMessage("bot", errorMessage, '', [], botId);
1103 806 }
1104 807 });
1105 - }); // refreshNonceIfNeeded
1106 808 }
1107 809
1108 810 function callMxChatStream(message, callback, botId) {
1109 811 botId = botId || getMxChatBotId();
@@ -1122,25 +824,12 @@
1122 824
1123 825 // Get instance for session start timestamp (used when persistence is OFF)
1124 826 var instance = MxChatInstances.get(botId);
1125 827
1126 - // Guarantee a non-null session_id before the fetch. FormData.append() stringifies any
1127 - // non-string value via String(), so passing `null` would POST the literal string "null"
1128 - // and land in the transcripts table as a ghost session. ensureSession() always returns
1129 - // a real string even when cookies/localStorage are blocked.
1130 - var streamSessionId = MxChatInstances.ensureSession(botId);
1131 - if (!streamSessionId || streamSessionId === 'null' || streamSessionId === 'undefined') {
1132 - streamSessionId = generateSessionId();
1133 - MxChatInstances.setChatSession(botId, streamSessionId);
1134 - }
1135 -
1136 - // Wait for the page-cache nonce refresh before constructing formData (which
1137 - // captures mxchatChat.nonce by value). Mirrors callMxChat's wrapping. See plan-c5457f.
1138 - refreshNonceIfNeeded(function() {
1139 828 const formData = new FormData();
1140 829 formData.append('action', 'mxchat_stream_chat');
1141 830 formData.append('message', message);
1142 - formData.append('session_id', streamSessionId);
831 + formData.append('session_id', getChatSession(botId));
1143 832 formData.append('nonce', mxchatChat.nonce);
1144 833 formData.append('current_page_url', window.location.href);
1145 834 formData.append('current_page_title', document.title);
1146 835 formData.append('bot_id', botId);
@@ -1166,20 +855,12 @@
1166 855 let accumulatedContent = '';
1167 856 let testingDataReceived = false;
1168 857 let streamingStarted = false;
1169 858
1170 - // Abortable stream: a fresh controller per turn, keyed by bot instance.
1171 - // The Stop control (send button swapped in place) aborts both the read
1172 - // loop and the underlying request.
1173 - var streamControl = { controller: new AbortController(), aborted: false };
1174 - mxchatActiveStreams[botId] = streamControl;
1175 - mxchatShowStopButton(botId);
1176 -
1177 859 fetch(mxchatChat.ajax_url, {
1178 860 method: 'POST',
1179 861 body: formData,
1180 - credentials: 'same-origin',
1181 - signal: streamControl.controller.signal
862 + credentials: 'same-origin'
1182 863 })
1183 864 .then(response => {
1184 865 // Store the response for potential fallback handling
1185 866 const responseClone = response.clone();
@@ -1248,16 +929,8 @@
1248 929
1249 930 // Re-enable chat input when stream ends with content
1250 931 enableChatInput(botId);
1251 932
1252 - // Scroll the user's last message to the top now that the
1253 - // bot's full reply has rendered (gives max reading room).
1254 - var $chatBoxDone = getElement(botId, 'chat-box');
1255 - var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
1256 - if ($lastUserMsgDone.length) {
1257 - scrollElementToTop($lastUserMsgDone, botId);
1258 - }
1259 -
1260 933 if (callback) {
1261 934 callback(accumulatedContent);
1262 935 }
1263 936 return;
@@ -1280,16 +953,8 @@
1280 953
1281 954 // Re-enable chat input after streaming completes
1282 955 enableChatInput(botId);
1283 956
1284 - // Scroll the user's last message to the top now
1285 - // that the bot's full reply has rendered.
1286 - var $chatBoxStreamDone = getElement(botId, 'chat-box');
1287 - var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
1288 - if ($lastUserMsgStreamDone.length) {
1289 - scrollElementToTop($lastUserMsgStreamDone, botId);
1290 - }
1291 -
1292 957 if (callback) {
1293 958 callback(accumulatedContent);
1294 959 }
1295 960 return;
@@ -1346,9 +1011,8 @@
1346 1011 }
1347 1012
1348 1013 processStream();
1349 1014 }).catch(streamError => {
1350 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1351 1015 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1352 1016 callMxChat(message, callback, botId);
1353 1017 });
1354 1018 }
@@ -1355,9 +1019,8 @@
1355 1019
1356 1020 processStream();
1357 1021 })
1358 1022 .catch(error => {
1359 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1360 1023 // Check if we have server error data with chat mode
1361 1024 if (error && error.isServerError && error.data) {
1362 1025 // Check for chat mode in error data
1363 1026 if (error.data.chat_mode) {
@@ -1370,9 +1033,8 @@
1370 1033 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1371 1034 callMxChat(message, callback, botId);
1372 1035 }
1373 1036 });
1374 - }); // refreshNonceIfNeeded
1375 1037 }
1376 1038
1377 1039 // Helper function to handle non-streaming responses
1378 1040 function handleNonStreamResponse(data, callback, botId) {
@@ -1411,16 +1073,21 @@
1411 1073 errorMessage = "An error occurred. Please try again or contact support.";
1412 1074 }
1413 1075
1414 1076 // Handle session reset action (IP changed, session expired, etc.)
1415 - // Silent reset — keep chat UI intact, just get a new session and retry
1416 1077 if (data.data && data.data.action === 'reset_session') {
1417 - MxChatInstances.silentResetSession(botId);
1418 - // Re-send the original message with the new session (user message is already displayed)
1078 + // Clear the old session and generate a new one
1079 + resetChatSession(botId);
1080 + // Re-send the original message with the new session
1419 1081 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1420 1082 if (originalMessage) {
1421 1083 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1422 - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1084 + // Re-add the user message and thinking indicator
1085 + appendMessage("user", originalMessage, '', [], false, botId);
1086 + appendThinkingMessage(botId);
1087 + scrollToBottom(botId);
1088 + // Determine whether to use streaming
1089 + const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest';
1423 1090 if (shouldUseStreaming(currentModel)) {
1424 1091 callMxChatStream(originalMessage, callback, botId);
1425 1092 } else {
1426 1093 callMxChat(originalMessage, callback, botId);
@@ -1581,17 +1248,8 @@
1581 1248 // Update the event handlers to use the correct function names (using event delegation)
1582 1249 // Use class-based selectors for multi-instance support
1583 1250 $(document).on('click', '.send-button', function() {
1584 1251 var botId = getBotIdFromElement(this);
1585 - // While a response is streaming the button is a Stop control.
1586 - if (this.classList.contains('mxchat-stop-mode')) {
1587 - // Same click that just started this stream (an add-on's direct handler
1588 - // ran before this delegated one) — not a Stop press. See
1589 - // mxchatShowStopButton for the full story (plan-4bba64).
1590 - if (this.__mxchatStopJustShown) return;
1591 - mxchatStopStreaming(botId);
1592 - return;
1593 - }
1594 1252 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1595 1253 if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1596 1254 disableChatInput(botId);
1597 1255 }
@@ -1610,370 +1268,9 @@
1610 1268 sendMessage(botId);
1611 1269 }
1612 1270 });
1613 1271
1614 -// Chat input character counter + soft limit feedback (plan 7091a2).
1615 -// Language-neutral: numbers + color only, no translatable strings. The counter
1616 -// reveals near the cap and ramps neutral -> amber -> red; an over-limit keystroke
1617 -// or trimmed paste produces a brief border-flash/shake so the maxlength cap (plan
1618 -// a3fae2) is never a silent "input jumps back". Per-bot scoped via .input-container.
1619 -function mxchatUpdateCharCounter(inputEl) {
1620 - if (!inputEl || !inputEl.closest) return;
1621 - var max = parseInt(inputEl.getAttribute('maxlength'), 10);
1622 - var container = inputEl.closest('.input-container');
1623 - if (!container || !max || max <= 0) return;
1624 - var counter = container.querySelector('.mxchat-char-counter');
1625 - if (!counter) return;
1626 - var len = inputEl.value.length;
1627 - var ratio = len / max;
1628 - var nearThreshold = 0.8; // start surfacing the counter at 80% of the cap
1629 - var cur = counter.querySelector('.mxchat-char-counter-current');
1630 - if (cur) cur.textContent = len;
1631 - var warn = ratio >= nearThreshold && len < max;
1632 - var full = len >= max;
1633 - counter.classList.toggle('is-visible', ratio >= nearThreshold);
1634 - counter.classList.toggle('is-warn', warn);
1635 - counter.classList.toggle('is-full', full);
1636 - container.classList.toggle('mxchat-input-near-limit', warn);
1637 - container.classList.toggle('mxchat-input-at-limit', full);
1638 -}
1639 -
1640 -function mxchatBumpInput(inputEl) {
1641 - var container = inputEl && inputEl.closest ? inputEl.closest('.input-container') : null;
1642 - if (!container) return;
1643 - container.classList.remove('mxchat-input-bump');
1644 - void container.offsetWidth; // reflow so a rapid second hit retriggers the animation
1645 - container.classList.add('mxchat-input-bump');
1646 - clearTimeout($(container).data('mxchatBumpTimeout'));
1647 - var t = setTimeout(function() { container.classList.remove('mxchat-input-bump'); }, 220);
1648 - $(container).data('mxchatBumpTimeout', t);
1649 -}
1650 -
1651 -// Live counter update on every input.
1652 -$(document).on('input', '.chat-input', function() {
1653 - mxchatUpdateCharCounter(this);
1654 -});
1655 -
1656 -// Visible "you've hit the edge" feedback when a printable keystroke is about to be
1657 -// rejected at the cap (maxlength silently swallows it otherwise).
1658 -$(document).on('keydown', '.chat-input', function(e) {
1659 - var max = parseInt(this.getAttribute('maxlength'), 10);
1660 - if (!max || max <= 0 || this.value.length < max) return;
1661 - if (e.ctrlKey || e.metaKey || e.altKey) return;
1662 - // A single printable char with no selection to overwrite WILL be rejected.
1663 - if (e.key && e.key.length === 1 && this.selectionStart === this.selectionEnd) {
1664 - mxchatBumpInput(this);
1665 - }
1666 -});
1667 -
1668 -// A paste that gets trimmed to the cap also bumps, so truncation is never silent.
1669 -$(document).on('paste', '.chat-input', function() {
1670 - var el = this;
1671 - var max = parseInt(el.getAttribute('maxlength'), 10);
1672 - if (!max || max <= 0) return;
1673 - setTimeout(function() {
1674 - mxchatUpdateCharCounter(el);
1675 - if (el.value.length >= max) mxchatBumpInput(el);
1676 - }, 0);
1677 -});
1678 -
1679 -// Builds the list of overflow-menu items for a given bot.
1680 -// Adding a future item is one push to this array — do NOT hardcode "only download."
1681 -function mxchatGetHeaderMenuItems(botId) {
1682 - var items = [];
1683 - var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1684 -
1685 - // The `print_button_*` keys still gate this item for back-compat with
1686 - // existing user options. The action is now a transcript download, not print.
1687 - if (settings.print_button_enabled === 'on') {
1688 - items.push({
1689 - id: 'download-transcript',
1690 - label: settings.print_button_label || 'Download Transcript',
1691 - icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
1692 - action: function() {
1693 - mxchatDownloadTranscript(botId);
1694 - }
1695 - });
1696 - }
1697 -
1698 - // "Start new chat" — surfaces the EXISTING per-conversation reset
1699 - // (MxChatInstances.resetChatSession) so a visitor can start a fresh thread
1700 - // without the site owner disabling chat persistence globally. Default OFF;
1701 - // gated by the reset_chat_enabled option. plan ac2e81.
1702 - if (settings.reset_chat_enabled === 'on') {
1703 - items.push({
1704 - id: 'reset-chat',
1705 - label: settings.reset_chat_label || 'Start new chat',
1706 - icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>',
1707 - action: function() {
1708 - var confirmMsg = settings.reset_chat_confirm || 'Start a new chat? This clears the current conversation.';
1709 - if (window.confirm(confirmMsg)) {
1710 - MxChatInstances.resetChatSession(botId);
1711 - }
1712 - }
1713 - });
1714 - }
1715 -
1716 - return items;
1717 -}
1718 -
1719 -// Builds a clean markdown transcript of the current conversation and triggers
1720 -// a file download. Used by the "Download Transcript" menu item.
1721 -function mxchatDownloadTranscript(botId) {
1722 - var $chatBox = getElement(botId, 'chat-box');
1723 - if (!$chatBox || !$chatBox.length) return;
1724 -
1725 - var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1726 - var headerTitle = settings.print_header_title || 'Chat transcript';
1727 - var now = new Date();
1728 - var stamp = now.toLocaleString();
1729 -
1730 - var lines = [];
1731 - lines.push('# ' + headerTitle);
1732 - lines.push('');
1733 - lines.push('Exported: ' + stamp);
1734 - lines.push('');
1735 - lines.push('---');
1736 - lines.push('');
1737 -
1738 - $chatBox.find('.user-message, .bot-message, .agent-message').each(function() {
1739 - var $msg = $(this);
1740 - // Skip thinking placeholders and any in-flight temporary messages.
1741 - if ($msg.find('.thinking-dots').length) return;
1742 - if ($msg.hasClass('temporary-message')) return;
1743 -
1744 - var sender;
1745 - if ($msg.hasClass('user-message')) sender = 'User';
1746 - else if ($msg.hasClass('agent-message')) sender = 'Live Agent';
1747 - else sender = 'AI Agent';
1748 -
1749 - // Strip interactive UI from the cloned message so we get the conversation text.
1750 - var $clone = $msg.clone();
1751 - $clone.find('.copy-button, .message-toolbar, .mxchat-copy, button, script, style').remove();
1752 - var text = $clone.text().replace(/ /g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
1753 - if (!text) return;
1754 -
1755 - lines.push('**' + sender + '**');
1756 - lines.push('');
1757 - lines.push(text);
1758 - lines.push('');
1759 - });
1760 -
1761 - var content = lines.join('\n');
1762 - var iso = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
1763 - var fname = 'mxchat-transcript-' + iso + '.md';
1764 - var blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
1765 - var url = URL.createObjectURL(blob);
1766 - var a = document.createElement('a');
1767 - a.href = url;
1768 - a.download = fname;
1769 - a.style.display = 'none';
1770 - document.body.appendChild(a);
1771 - a.click();
1772 - setTimeout(function() {
1773 - if (a.parentNode) a.parentNode.removeChild(a);
1774 - URL.revokeObjectURL(url);
1775 - }, 100);
1776 -}
1777 -
1778 -// Reads the bot bubble's actual computed bg+fg and writes them as CSS vars
1779 -// on the menu wrap, so the dropdown matches whatever paints the bubble —
1780 -// saved options, AI theme CSS, or the mxchat-theme add-on.
1781 -function mxchatSyncMenuColors(botId, $wrap) {
1782 - if (!$wrap || !$wrap.length) return;
1783 - var $bot = $wrap.closest('.mxchat-chatbot-wrapper').find('.bot-message').not('.temporary-message').first();
1784 - if (!$bot.length) return;
1785 - var cs = window.getComputedStyle($bot[0]);
1786 - if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') {
1787 - $wrap[0].style.setProperty('--mxchat-menu-bg', cs.backgroundColor);
1788 - }
1789 - // Bot text color usually lives on a child div, not .bot-message itself.
1790 - var $textChild = $bot.find('[style*="color"]').first();
1791 - var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
1792 - if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
1793 -}
1794 -
1795 -// Renders (or re-renders) the item list for one menu wrap. Split out of
1796 -// mxchatInitHeaderMenu so the dynamic-settings merge (plan-32db95) can
1797 -// rebuild items + trigger visibility WITHOUT re-binding the one-time
1798 -// open/close/keyboard wiring. closeMenu is passed in by the init closure;
1799 -// a rebuild before init (never happens, but harmless) just skips it.
1800 -function mxchatRenderHeaderMenuItems(botId, $wrap, closeMenuFn) {
1801 - var $trigger = $wrap.find('.mxchat-menu-trigger');
1802 - var $menu = $wrap.find('.mxchat-header-menu');
1803 - var items = mxchatGetHeaderMenuItems(botId);
1804 -
1805 - $menu.empty();
1806 -
1807 - if (!items.length) {
1808 - $trigger.hide();
1809 - $menu.hide();
1810 - return;
1811 - }
1812 -
1813 - // Clear any inline display:none a previous zero-item render left behind —
1814 - // open/close visibility is governed by the hidden prop + is-open class.
1815 - $trigger.css('display', '');
1816 - $menu.css('display', '');
1817 -
1818 - items.forEach(function(item, idx) {
1819 - var $btn = $('<button>', {
1820 - type: 'button',
1821 - 'class': 'mxchat-menu-item',
1822 - 'role': 'menuitem',
1823 - 'tabindex': '-1',
1824 - 'data-menu-id': item.id,
1825 - html: '<span class="mxchat-menu-item-icon">' + item.icon + '</span>' +
1826 - '<span class="mxchat-menu-item-label"></span>'
1827 - });
1828 - $btn.find('.mxchat-menu-item-label').text(item.label);
1829 - $btn.on('click', function(e) {
1830 - e.preventDefault();
1831 - e.stopPropagation();
1832 - if (closeMenuFn) closeMenuFn();
1833 - try { item.action(); } catch (err) { /* no-op */ }
1834 - });
1835 - $menu.append($btn);
1836 - });
1837 -}
1838 -
1839 -// Re-render every menu on the page after a dynamic-settings merge
1840 -// (multi-bot: each wrap re-reads its items). An OPEN menu is left alone —
1841 -// swapping items under the user mid-interaction yanks focus — and the
1842 -// rebuild runs when it closes instead (closeMenu checks the pending flag).
1843 -function mxchatRebuildHeaderMenus() {
1844 - $('.mxchat-header-menu-wrap').each(function() {
1845 - var $wrap = $(this);
1846 - var botId = $wrap.data('bot-id');
1847 - if (!botId) return;
1848 - if (!$wrap.data('mxchatMenuReady')) {
1849 - mxchatInitHeaderMenu(botId);
1850 - return;
1851 - }
1852 - if ($wrap.find('.mxchat-header-menu').hasClass('is-open')) {
1853 - $wrap.data('mxchatMenuRebuildPending', true);
1854 - return;
1855 - }
1856 - mxchatRenderHeaderMenuItems(botId, $wrap, $wrap.data('mxchatMenuClose'));
1857 - });
1858 -}
1859 -
1860 -// One-time per-widget init: renders menu items, wires open/close,
1861 -// outside-click, Escape, and arrow-key navigation. If no items, hides the
1862 -// trigger. Wiring happens even when there are zero items at init, so a
1863 -// later dynamic-settings rebuild that adds items has a working trigger.
1864 -function mxchatInitHeaderMenu(botId) {
1865 - var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
1866 - if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
1867 -
1868 - var $trigger = $wrap.find('.mxchat-menu-trigger');
1869 - var $menu = $wrap.find('.mxchat-header-menu');
1870 -
1871 - // Initial color sync — covers normal page load.
1872 - mxchatSyncMenuColors(botId, $wrap);
1873 -
1874 - function openMenu() {
1875 - // Re-sync each open in case the active theme changed since init.
1876 - mxchatSyncMenuColors(botId, $wrap);
1877 - $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
1878 - $trigger.attr('aria-expanded', 'true');
1879 - // Focus the first item for keyboard users
1880 - setTimeout(function() {
1881 - $menu.find('.mxchat-menu-item').first().attr('tabindex', '0').trigger('focus');
1882 - }, 0);
1883 - }
1884 - function closeMenu(returnFocus) {
1885 - $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
1886 - $trigger.attr('aria-expanded', 'false');
1887 - $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
1888 - if (returnFocus) $trigger.trigger('focus');
1889 - // A dynamic-settings rebuild that arrived while the menu was open
1890 - // was deferred (mxchatRebuildHeaderMenus) — run it now.
1891 - if ($wrap.data('mxchatMenuRebuildPending')) {
1892 - $wrap.removeData('mxchatMenuRebuildPending');
1893 - mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
1894 - }
1895 - }
1896 -
1897 - // Toggle on trigger click — stop propagation so the .chatbot-top-bar
1898 - // click-to-collapse handler does not fire.
1899 - $trigger.on('click', function(e) {
1900 - e.preventDefault();
1901 - e.stopPropagation();
1902 - if ($menu.hasClass('is-open')) closeMenu();
1903 - else openMenu();
1904 - });
1905 -
1906 - // Don't let clicks inside the menu bubble to the top-bar collapse handler.
1907 - $menu.on('click', function(e) {
1908 - e.stopPropagation();
1909 - });
1910 -
1911 - // Outside click closes the menu.
1912 - $(document).on('click.mxchatMenu-' + botId, function(e) {
1913 - if (!$menu.hasClass('is-open')) return;
1914 - if ($wrap.has(e.target).length || $wrap.is(e.target)) return;
1915 - closeMenu();
1916 - });
1917 -
1918 - // Keyboard: Escape closes and returns focus; arrow keys move focus; Enter activates.
1919 - $menu.on('keydown', '.mxchat-menu-item', function(e) {
1920 - var $items = $menu.find('.mxchat-menu-item');
1921 - var idx = $items.index(this);
1922 - if (e.key === 'Escape') {
1923 - e.preventDefault();
1924 - closeMenu(true);
1925 - } else if (e.key === 'ArrowDown') {
1926 - e.preventDefault();
1927 - var $next = $items.eq((idx + 1) % $items.length);
1928 - $items.attr('tabindex', '-1');
1929 - $next.attr('tabindex', '0').trigger('focus');
1930 - } else if (e.key === 'ArrowUp') {
1931 - e.preventDefault();
1932 - var $prev = $items.eq((idx - 1 + $items.length) % $items.length);
1933 - $items.attr('tabindex', '-1');
1934 - $prev.attr('tabindex', '0').trigger('focus');
1935 - } else if (e.key === 'Enter' || e.key === ' ') {
1936 - e.preventDefault();
1937 - $(this).trigger('click');
1938 - }
1939 - });
1940 - $trigger.on('keydown', function(e) {
1941 - if (e.key === 'Escape' && $menu.hasClass('is-open')) {
1942 - e.preventDefault();
1943 - closeMenu(true);
1944 - } else if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !$menu.hasClass('is-open')) {
1945 - e.preventDefault();
1946 - openMenu();
1947 - }
1948 - });
1949 -
1950 - // Expose closeMenu for out-of-closure re-renders (mxchatRebuildHeaderMenus),
1951 - // then do the initial item render.
1952 - $wrap.data('mxchatMenuClose', closeMenu);
1953 - mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
1954 -
1955 - $wrap.data('mxchatMenuReady', true);
1956 -}
1957 -
1958 -// Initialize header menus for every rendered widget on DOM ready.
1959 -$(function() {
1960 - $('.mxchat-header-menu-wrap').each(function() {
1961 - var botId = $(this).data('bot-id');
1962 - if (botId) mxchatInitHeaderMenu(botId);
1963 - });
1964 -
1965 - // Embedded (non-floating) widgets are open from the moment the page
1966 - // renders — refresh dynamic settings at init (plan-32db95). Floating
1967 - // widgets refresh on first launcher open instead.
1968 - var hasEmbeddedWidget = $('.mxchat-chatbot-wrapper').filter(function() {
1969 - return !$(this).closest('.floating-chatbot').length;
1970 - }).length > 0;
1971 - if (hasEmbeddedWidget) {
1972 - mxchatRefreshDynamicSettings();
1973 - }
1974 -});
1975 -
1272 +
1976 1273 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
1977 1274 try {
1978 1275 // Determine styles based on sender type
1979 1276 let messageClass, bgColor, fontColor;
@@ -2011,12 +1308,17 @@
2011 1308 'margin-bottom': '1em'
2012 1309 });
2013 1310 }
2014 1311
2015 - // Process the message content - always run linkify to convert markdown
2016 - // links and format text. linkify() handles existing HTML safely via
2017 - // negative lookaheads that skip URLs already inside <a> tags.
2018 - let fullMessage = linkify(messageText);
1312 + // Process the message content based on sender
1313 + let fullMessage;
1314 + if (sender === "user") {
1315 + // For user messages, apply linkify after sanitization
1316 + fullMessage = linkify(messageText);
1317 + } else {
1318 + // For bot/agent messages, preserve HTML
1319 + fullMessage = messageText;
1320 + }
2019 1321
2020 1322 // Add images if provided
2021 1323 if (images && images.length > 0) {
2022 1324 fullMessage += '<div class="image-gallery" dir="auto">';
@@ -2065,12 +1367,8 @@
2065 1367 if (lastUserMessage.length) {
2066 1368 scrollElementToTop(lastUserMessage, botId);
2067 1369 }
2068 1370 }
2069 -
2070 - if ((sender === "bot" || sender === "agent") && !isTemporary) {
2071 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2072 - }
2073 1371 });
2074 1372
2075 1373 if (messageText.id) {
2076 1374 var instance = MxChatInstances.get(botId);
@@ -2155,12 +1453,26 @@
2155 1453 bgColor = botMessageBgColor;
2156 1454 fontColor = botMessageFontColor;
2157 1455 }
2158 1456
2159 - // Always run linkify to convert markdown links and format text.
2160 - // linkify() already handles existing HTML (its URL patterns use negative lookaheads
2161 - // to avoid double-processing URLs that are already inside <a> tags).
2162 - var fullMessage = linkify(responseText);
1457 + // FIXED: Only linkify if response doesn't already contain HTML links or tags
1458 + // This prevents double-processing of URLs that are already formatted as HTML
1459 + var fullMessage;
1460 + if (sender === "user") {
1461 + // Always linkify user messages (they're plain text)
1462 + fullMessage = linkify(responseText);
1463 + } else {
1464 + // For bot/agent messages, check if HTML already exists
1465 + if (responseText.includes('<a href=') || responseText.includes('</a>') ||
1466 + responseText.includes('<img') || responseText.includes('<div') ||
1467 + responseText.includes('<p>') || responseText.includes('<br>')) {
1468 + // Response already has HTML, don't process it
1469 + fullMessage = responseText;
1470 + } else {
1471 + // Plain text response, apply linkify
1472 + fullMessage = linkify(responseText);
1473 + }
1474 + }
2163 1475
2164 1476 if (responseHtml) {
2165 1477 // Only add line breaks if there's actual text content before the HTML
2166 1478 if (fullMessage && fullMessage.trim()) {
@@ -2217,12 +1529,8 @@
2217 1529 }
2218 1530
2219 1531 // Re-enable chat input after response is displayed
2220 1532 enableChatInput(botId);
2221 -
2222 - if (sender === "bot" || sender === "agent") {
2223 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2224 - }
2225 1533 } else {
2226 1534 appendMessage(sender, responseText, responseHtml, images, false, botId);
2227 1535 // Re-enable chat input after response is displayed
2228 1536 enableChatInput(botId);
@@ -2325,63 +1633,37 @@
2325 1633 // Return as a proper link without the brackets
2326 1634 return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2327 1635 });
2328 1636
2329 - // Process markdown links: [text](url) and [](url)
2330 - // Uses balanced parenthesis matching to handle URLs containing parens
2331 - // (e.g. PDF filenames with dates like (2025-08-28).pdf)
2332 - processedText = (function(input) {
2333 - var result = '';
2334 - var i = 0;
2335 - while (i < input.length) {
2336 - // Look for [ at current position
2337 - if (input[i] === '[') {
2338 - // Find closing ]
2339 - var closeBracket = input.indexOf(']', i + 1);
2340 - if (closeBracket === -1 || closeBracket + 1 >= input.length || input[closeBracket + 1] !== '(') {
2341 - result += input[i];
2342 - i++;
2343 - continue;
2344 - }
2345 - var linkText = input.substring(i + 1, closeBracket);
2346 - // Check if URL starts with http
2347 - var urlStart = closeBracket + 2;
2348 - if (!input.substring(urlStart).match(/^https?:\/\//)) {
2349 - result += input[i];
2350 - i++;
2351 - continue;
2352 - }
2353 - // Find balanced closing paren
2354 - var depth = 1;
2355 - var j = urlStart;
2356 - while (j < input.length && depth > 0) {
2357 - if (input[j] === '(') depth++;
2358 - else if (input[j] === ')') depth--;
2359 - if (depth > 0) j++;
2360 - }
2361 - if (depth !== 0) {
2362 - result += input[i];
2363 - i++;
2364 - continue;
2365 - }
2366 - var url = input.substring(urlStart, j);
2367 - var cleanUrl = url.replace(/[\].,;!?]+$/, '');
2368 - var encodedUrl = safeEncodeUrl(cleanUrl);
2369 - if (!linkText || !linkText.trim()) {
2370 - result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + cleanUrl + '</a>';
2371 - } else {
2372 - var safeText = sanitizeUserInput(linkText);
2373 - result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + safeText + '</a>';
2374 - }
2375 - i = j + 1; // Skip past the closing )
2376 - } else {
2377 - result += input[i];
2378 - i++;
2379 - }
1637 + // Process proper markdown links with text: [text](url)
1638 + // This MUST have non-empty text in the first brackets
1639 + const markdownLinkPattern = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
1640 + processedText = processedText.replace(markdownLinkPattern, (match, text, url) => {
1641 + // Make sure we have actual text (not just whitespace)
1642 + if (!text || !text.trim()) {
1643 + // If no text, treat the URL as the text
1644 + let cleanUrl = url.replace(/[.,;!?]+$/, '');
1645 + const safeUrl = safeEncodeUrl(cleanUrl);
1646 + return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2380 1647 }
2381 - return result;
2382 - })(processedText);
1648 +
1649 + // Clean the URL
1650 + let cleanUrl = url.replace(/[\].,;!?]+$/, '');
1651 + const safeUrl = safeEncodeUrl(cleanUrl);
1652 + const safeText = sanitizeUserInput(text);
1653 + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
1654 + });
2383 1655
1656 + // Handle empty markdown links: [](url)
1657 + // This is a specific case where there's no text
1658 + const emptyMarkdownPattern = /\[\]\((https?:\/\/[^\s)]+)\)/g;
1659 + processedText = processedText.replace(emptyMarkdownPattern, (match, url) => {
1660 + let cleanUrl = url.replace(/[.,;!?]+$/, '');
1661 + const safeUrl = safeEncodeUrl(cleanUrl);
1662 + // Use the URL itself as the link text
1663 + return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
1664 + });
1665 +
2384 1666 // Process phone numbers: [text](tel:number)
2385 1667 const phonePattern = /\[([^\]]+)\]\((tel:[\d+\-\s()]+)\)/g;
2386 1668 processedText = processedText.replace(phonePattern, (match, text, phone) => {
2387 1669 const safePhone = safeEncodeUrl(phone);
@@ -2675,14 +1957,13 @@
2675 1957 requestAnimationFrame(smoothScroll);
2676 1958 }
2677 1959 }
2678 1960
2679 - function scrollElementToTop(element, botId, topOffset) {
1961 + function scrollElementToTop(element, botId) {
2680 1962 botId = botId || 'default';
2681 - topOffset = (typeof topOffset === 'number') ? topOffset : 2;
2682 1963 var chatBox = getElement(botId, 'chat-box');
2683 1964 var elementTop = element.position().top + chatBox.scrollTop();
2684 - chatBox.animate({ scrollTop: Math.max(0, elementTop - topOffset) }, 500);
1965 + chatBox.animate({ scrollTop: elementTop }, 500);
2685 1966 }
2686 1967
2687 1968 function showChatWidget(botId) {
2688 1969 botId = botId || 'default';
@@ -2909,19 +2190,11 @@
2909 2190 if (onComplete) onComplete();
2910 2191 return;
2911 2192 }
2912 2193
2913 - // Use getChatSession which returns null if no session exists (does NOT create one)
2914 2194 var sessionId = getChatSession(botId);
2915 2195 var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
2916 2196
2917 - // No session yet — nothing to load. History will load after first message via ensureSession.
2918 - if (!sessionId) {
2919 - instance.chatHistoryLoaded = true;
2920 - if (onComplete) onComplete();
2921 - return;
2922 - }
2923 -
2924 2197 if (chatPersistenceEnabled && sessionId) {
2925 2198 $.ajax({
2926 2199 url: mxchatChat.ajax_url,
2927 2200 type: 'POST',
@@ -2932,10 +2205,10 @@
2932 2205 },
2933 2206 success: function(response) {
2934 2207 // Handle session reset (IP changed while user was away)
2935 2208 if (response.success === false && response.data && response.data.action === 'reset_session') {
2936 - // Silent reset — new session but don't clear UI
2937 - MxChatInstances.silentResetSession(botId);
2209 + // Silently reset session - user will start fresh
2210 + resetChatSession(botId);
2938 2211 instance.chatHistoryLoaded = true; // Prevent retry loop
2939 2212 if (onComplete) onComplete();
2940 2213 return;
2941 2214 }
@@ -2993,19 +2266,9 @@
2993 2266 var content = message.content;
2994 2267 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
2995 2268 content = decodeHTMLEntities(content);
2996 2269
2997 - // Skip linkify for messages containing structured HTML
2998 - // (forms, product cards, galleries, etc.) to avoid
2999 - // markdown formatting corrupting HTML attributes
3000 - // (e.g. underscores in name="field_name" becoming <em> tags)
3001 - if (content.includes("mxchat-product-card") ||
3002 - content.includes("mxchat-image-gallery") ||
3003 - content.includes("mxchat-featured-products") ||
3004 - content.includes("<form") ||
3005 - content.includes("<input") ||
3006 - content.includes("<select") ||
3007 - content.includes("<textarea")) {
2270 + if (content.includes("mxchat-product-card") || content.includes("mxchat-image-gallery")) {
3008 2271 messageElement.html(content);
3009 2272 } else {
3010 2273 var formattedContent = linkify(content);
3011 2274 messageElement.html(formattedContent);
@@ -3119,10 +2382,10 @@
3119 2382 .then(data => {
3120 2383 if (data.success) {
3121 2384 container.style.display = 'none';
3122 2385 nameElement.textContent = '';
3123 - instance.activePdfFile = null;
3124 - appendMessage('bot', 'PDF removed.', '', [], false, botId);
2386 + activePdfFile = null;
2387 + appendMessage('bot', 'PDF removed.');
3125 2388 }
3126 2389 })
3127 2390 .catch(error => {
3128 2391 // Error removing PDF - silently continue
@@ -3128,16 +2391,14 @@
3128 2391 // Error removing PDF - silently continue
3129 2392 });
3130 2393 }
3131 2394
3132 - function removeActiveWord(botId) {
3133 - botId = botId || 'default';
3134 - var instance = MxChatInstances.get(botId);
3135 - const container = getElementDOM(botId, 'active-word-container');
3136 - const nameElement = getElementDOM(botId, 'active-word-name');
3137 -
3138 - if (!container || !nameElement || !instance.activeWordFile) return;
3139 -
2395 + function removeActiveWord() {
2396 + const container = document.getElementById('active-word-container');
2397 + const nameElement = document.getElementById('active-word-name');
2398 +
2399 + if (!container || !nameElement || !activeWordFile) return;
2400 +
3140 2401 fetch(mxchatChat.ajax_url, {
3141 2402 method: 'POST',
3142 2403 headers: {
3143 2404 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3143,9 +2404,9 @@
3143 2404 'Content-Type': 'application/x-www-form-urlencoded',
3144 2405 },
3145 2406 body: new URLSearchParams({
3146 2407 'action': 'mxchat_remove_word',
3147 - 'session_id': getChatSession(botId),
2408 + 'session_id': sessionId,
3148 2409 'nonce': mxchatChat.nonce
3149 2410 })
3150 2411 })
3151 2412 .then(response => response.json())
@@ -3152,10 +2413,10 @@
3152 2413 .then(data => {
3153 2414 if (data.success) {
3154 2415 container.style.display = 'none';
3155 2416 nameElement.textContent = '';
3156 - instance.activeWordFile = null;
3157 - appendMessage('bot', 'Word document removed.', '', [], false, botId);
2417 + activeWordFile = null;
2418 + appendMessage('bot', 'Word document removed.');
3158 2419 }
3159 2420 })
3160 2421 .catch(error => {
3161 2422 // Error removing Word document - silently continue
@@ -3234,20 +2495,14 @@
3234 2495
3235 2496 function checkPreChatDismissal(botId) {
3236 2497 botId = botId || 'default';
3237 2498 try {
3238 - var dismissedAt = localStorage.getItem('mxchat_pre_chat_dismissed_' + botId);
3239 - if (dismissedAt) {
3240 - // Re-show after 24 hours
3241 - var elapsed = Date.now() - parseInt(dismissedAt, 10);
3242 - if (elapsed < 86400000) {
3243 - getElement(botId, 'pre-chat-message').hide();
3244 - return;
3245 - }
3246 - // Expired — clear and show again
3247 - localStorage.removeItem('mxchat_pre_chat_dismissed_' + botId);
2499 + var dismissed = localStorage.getItem('mxchat_pre_chat_dismissed_' + botId);
2500 + if (!dismissed) {
2501 + getElement(botId, 'pre-chat-message').fadeIn(250);
2502 + } else {
2503 + getElement(botId, 'pre-chat-message').hide();
3248 2504 }
3249 - getElement(botId, 'pre-chat-message').fadeIn(250);
3250 2505 } catch (e) {
3251 2506 // localStorage unavailable — show the message
3252 2507 getElement(botId, 'pre-chat-message').fadeIn(250);
3253 2508 }
@@ -3256,9 +2511,9 @@
3256 2511 function handlePreChatDismissal(botId) {
3257 2512 botId = botId || 'default';
3258 2513 getElement(botId, 'pre-chat-message').fadeOut(200);
3259 2514 try {
3260 - localStorage.setItem('mxchat_pre_chat_dismissed_' + botId, String(Date.now()));
2515 + localStorage.setItem('mxchat_pre_chat_dismissed_' + botId, '1');
3261 2516 } catch (e) {
3262 2517 // localStorage unavailable — dismissal won't persist
3263 2518 }
3264 2519 }
@@ -3316,14 +2571,9 @@
3316 2571 collapseQuickQuestions(botId);
3317 2572 });
3318 2573
3319 2574 // Chatbot visibility toggle handlers - use class selector for multi-instance support
3320 - // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3321 - $(document).on('click keydown', '.floating-chatbot-button', function(e) {
3322 - if (e.type === 'keydown') {
3323 - if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
3324 - e.preventDefault();
3325 - }
2575 + $(document).on('click', '.floating-chatbot-button', function() {
3326 2576 var botId = getBotIdFromElement(this);
3327 2577 var $chatbot = getElement(botId, 'floating-chatbot');
3328 2578 var $badge = getElement(botId, 'chat-notification-badge');
3329 2579 var $preChat = getElement(botId, 'pre-chat-message');
@@ -3328,26 +2578,14 @@
3328 2578 var $badge = getElement(botId, 'chat-notification-badge');
3329 2579 var $preChat = getElement(botId, 'pre-chat-message');
3330 2580
3331 2581 if ($chatbot.hasClass('hidden')) {
3332 - $chatbot.removeClass('hidden').addClass('visible')
3333 - .attr('aria-modal', 'true').attr('role', 'dialog');
3334 - $(this).addClass('hidden').attr('aria-expanded', 'true');
2582 + $chatbot.removeClass('hidden').addClass('visible');
2583 + $(this).addClass('hidden');
3335 2584 $badge.hide(); // Hide notification when opening chat
3336 2585 disableScroll();
3337 2586 $preChat.fadeOut(250);
3338 2587
3339 - // First open per page load: re-fetch behavior settings in case
3340 - // this page's inline values came from a stale full-page cache
3341 - // (plan-32db95). Idempotent — later opens are a no-op.
3342 - mxchatRefreshDynamicSettings();
3343 -
3344 - // Load chat history for returning visitors (persistence)
3345 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3346 - if (chatPersistenceEnabled) {
3347 - MxChatInstances.ensureSession(botId);
3348 - }
3349 -
3350 2588 // Deferred email check — only on first widget open
3351 2589 var emailBlocker = getElementDOM(botId, 'email-blocker');
3352 2590 var instance = MxChatInstances.get(botId);
3353 2591 if (emailBlocker && !instance.emailCheckDone) {
@@ -3352,64 +2590,31 @@
3352 2590 var instance = MxChatInstances.get(botId);
3353 2591 if (emailBlocker && !instance.emailCheckDone) {
3354 2592 instance.emailCheckDone = true;
3355 2593 resolveEmailState(botId);
3356 - } else if (!emailBlocker) {
3357 - // No email collection — still route through showChatContainerForBot
3358 - // so the loader is shown while chat history loads
3359 - showChatContainerForBot(botId);
3360 2594 }
3361 -
3362 - // Move keyboard focus into the message input after the open transition.
3363 - setTimeout(function() {
3364 - var chatInput = getElementDOM(botId, 'chat-input');
3365 - if (chatInput && !chatInput.disabled) {
3366 - try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
3367 - }
3368 - }, 300);
3369 2595 } else {
3370 - $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
3371 - $(this).removeClass('hidden').attr('aria-expanded', 'false');
2596 + $chatbot.removeClass('visible').addClass('hidden');
2597 + $(this).removeClass('hidden');
3372 2598 enableScroll();
3373 2599 checkPreChatDismissal(botId);
3374 2600 }
3375 2601 });
3376 2602
3377 - // Allow clicking anywhere on the title bar to close the chatbot.
3378 - // Returns keyboard focus to the launcher so keyboard users don't get
3379 - // stranded at <body> (WCAG SC 2.4.3 Focus Order). :focus-visible is
3380 - // heuristic-based so mouse-triggered close won't show a focus ring.
2603 + // Allow clicking anywhere on the title bar to close the chatbot
3381 2604 $(document).on('click', '.chatbot-top-bar', function() {
3382 2605 var botId = getBotIdFromElement(this);
3383 - getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3384 - var $launcher = getElement(botId, 'floating-chatbot-button');
3385 - $launcher.removeClass('hidden').attr('aria-expanded', 'false');
2606 + getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible');
2607 + getElement(botId, 'floating-chatbot-button').removeClass('hidden');
3386 2608 enableScroll();
3387 - try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3388 2609 });
3389 2610
3390 - // Global Escape-key handler — closes any visible chat widget and
3391 - // returns focus to its launcher. Standard modal-dismissal pattern;
3392 - // pairs with aria-modal="true" set on the widget when it opens.
3393 - $(document).on('keydown', function(e) {
3394 - if (e.key !== 'Escape' && e.key !== 'Esc') return;
3395 - var $visible = $('.floating-chatbot.visible');
3396 - if (!$visible.length) return;
3397 - e.preventDefault();
3398 - $visible.each(function() {
3399 - var botId = getBotIdFromElement(this);
3400 - $(this).addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3401 - var $launcher = getElement(botId, 'floating-chatbot-button');
3402 - $launcher.removeClass('hidden').attr('aria-expanded', 'false');
3403 - try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3404 - });
3405 - enableScroll();
3406 - });
3407 -
3408 2611 $(document).on('click', '.close-pre-chat-message', function(e) {
3409 2612 e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
3410 2613 var botId = getBotIdFromElement(this);
3411 - handlePreChatDismissal(botId);
2614 + getElement(botId, 'pre-chat-message').fadeOut(200, function() {
2615 + $(this).remove();
2616 + });
3412 2617 });
3413 2618
3414 2619
3415 2620 // PDF upload button handlers - use class selector
@@ -3425,20 +2630,17 @@
3425 2630 var wordInput = getElementDOM(botId, 'word-upload');
3426 2631 if (wordInput) wordInput.click();
3427 2632 });
3428 2633
3429 - // PDF file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'pdf-upload')
3430 - $(document).on('change', '.pdf-upload', async function(e) {
3431 - var botId = getBotIdFromElement(this);
3432 - var instance = MxChatInstances.get(botId);
3433 - const file = this.files[0];
3434 - const sessionId = MxChatInstances.ensureSession(botId);
3435 -
2634 + // PDF file input change handler
2635 + addSafeEventListener('pdf-upload', 'change', async function(e) {
2636 + const file = e.target.files[0];
2637 +
3436 2638 if (!file || file.type !== 'application/pdf') {
3437 2639 alert('Please select a valid PDF file.');
3438 2640 return;
3439 2641 }
3440 -
2642 +
3441 2643 if (!sessionId) {
3442 2644 alert('Error: No session ID found');
3443 2645 return;
3444 2646 }
@@ -3446,49 +2648,47 @@
3446 2648 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3447 2649 alert('Error: Ajax configuration missing');
3448 2650 return;
3449 2651 }
3450 -
2652 +
3451 2653 // Disable buttons and show loading state
3452 - const uploadBtn = getElementDOM(botId, 'pdf-upload-btn');
3453 - const sendBtn = getElementDOM(botId, 'send-button');
3454 - if (!uploadBtn) return;
2654 + const uploadBtn = document.getElementById('pdf-upload-btn');
2655 + const sendBtn = document.getElementById('send-button');
3455 2656 const originalBtnContent = uploadBtn.innerHTML;
3456 -
2657 +
3457 2658 try {
3458 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3459 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3460 2659 const formData = new FormData();
3461 2660 formData.append('action', 'mxchat_upload_pdf');
3462 2661 formData.append('pdf_file', file);
3463 2662 formData.append('session_id', sessionId);
3464 2663 formData.append('nonce', mxchatChat.nonce);
3465 -
2664 +
3466 2665 uploadBtn.disabled = true;
3467 - if (sendBtn) sendBtn.disabled = true;
2666 + sendBtn.disabled = true;
3468 2667 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3469 2668 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3470 2669 </svg>`;
3471 -
2670 +
3472 2671 const response = await fetch(mxchatChat.ajax_url, {
3473 2672 method: 'POST',
3474 2673 body: formData
3475 2674 });
3476 -
2675 +
3477 2676 const data = await response.json();
3478 -
2677 +
3479 2678 if (data.success) {
3480 2679 // Hide popular questions if they exist
3481 - if (hasQuickQuestions(botId)) {
3482 - collapseQuickQuestions(botId);
2680 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2681 + if (hasQuickQuestions()) {
2682 + collapseQuickQuestions();
3483 2683 }
3484 -
2684 +
3485 2685 // Show the active PDF name
3486 - showActivePdf(data.data.filename, botId);
3487 -
3488 - appendMessage('bot', data.data.message, '', [], false, botId);
3489 - scrollToBottom(botId);
3490 - instance.activePdfFile = data.data.filename;
2686 + showActivePdf(data.data.filename);
2687 +
2688 + appendMessage('bot', data.data.message);
2689 + scrollToBottom();
2690 + activePdfFile = data.data.filename;
3491 2691 } else {
3492 2692 alert('Failed to upload PDF. Please try again.');
3493 2693 }
3494 2694 } catch (error) {
@@ -3494,76 +2694,66 @@
3494 2694 } catch (error) {
3495 2695 alert('Error uploading file. Please try again.');
3496 2696 } finally {
3497 2697 uploadBtn.disabled = false;
3498 - if (sendBtn) sendBtn.disabled = false;
2698 + sendBtn.disabled = false;
3499 2699 uploadBtn.innerHTML = originalBtnContent;
3500 2700 this.value = ''; // Reset file input
3501 2701 }
3502 2702 });
3503 2703
3504 - // Word file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'word-upload')
3505 - $(document).on('change', '.word-upload', async function(e) {
3506 - var botId = getBotIdFromElement(this);
3507 - var instance = MxChatInstances.get(botId);
3508 - const file = this.files[0];
3509 - const sessionId = MxChatInstances.ensureSession(botId);
3510 -
2704 + // Word file input change handler
2705 + addSafeEventListener('word-upload', 'change', async function(e) {
2706 + const file = e.target.files[0];
2707 +
3511 2708 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
3512 2709 alert('Please select a valid Word document (.docx).');
3513 2710 return;
3514 2711 }
3515 -
2712 +
3516 2713 if (!sessionId) {
3517 2714 alert('Error: No session ID found');
3518 2715 return;
3519 2716 }
3520 2717
3521 - if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3522 - alert('Error: Ajax configuration missing');
3523 - return;
3524 - }
3525 -
3526 2718 // Disable buttons and show loading state
3527 - const uploadBtn = getElementDOM(botId, 'word-upload-btn');
3528 - const sendBtn = getElementDOM(botId, 'send-button');
3529 - if (!uploadBtn) return;
2719 + const uploadBtn = document.getElementById('word-upload-btn');
2720 + const sendBtn = document.getElementById('send-button');
3530 2721 const originalBtnContent = uploadBtn.innerHTML;
3531 -
2722 +
3532 2723 try {
3533 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3534 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3535 2724 const formData = new FormData();
3536 2725 formData.append('action', 'mxchat_upload_word');
3537 2726 formData.append('word_file', file);
3538 2727 formData.append('session_id', sessionId);
3539 2728 formData.append('nonce', mxchatChat.nonce);
3540 -
2729 +
3541 2730 uploadBtn.disabled = true;
3542 - if (sendBtn) sendBtn.disabled = true;
2731 + sendBtn.disabled = true;
3543 2732 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3544 2733 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3545 2734 </svg>`;
3546 -
2735 +
3547 2736 const response = await fetch(mxchatChat.ajax_url, {
3548 2737 method: 'POST',
3549 2738 body: formData
3550 2739 });
3551 -
2740 +
3552 2741 const data = await response.json();
3553 -
2742 +
3554 2743 if (data.success) {
3555 2744 // Hide popular questions if they exist
3556 - if (hasQuickQuestions(botId)) {
3557 - collapseQuickQuestions(botId);
2745 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2746 + if (hasQuickQuestions()) {
2747 + collapseQuickQuestions();
3558 2748 }
3559 -
2749 +
3560 2750 // Show the active Word document name
3561 - showActiveWord(data.data.filename, botId);
3562 -
3563 - appendMessage('bot', data.data.message, '', [], false, botId);
3564 - scrollToBottom(botId);
3565 - instance.activeWordFile = data.data.filename;
2751 + showActiveWord(data.data.filename);
2752 +
2753 + appendMessage('bot', data.data.message);
2754 + scrollToBottom();
2755 + activeWordFile = data.data.filename;
3566 2756 } else {
3567 2757 alert('Failed to upload Word document. Please try again.');
3568 2758 }
3569 2759 } catch (error) {
@@ -3569,25 +2759,25 @@
3569 2759 } catch (error) {
3570 2760 alert('Error uploading file. Please try again.');
3571 2761 } finally {
3572 2762 uploadBtn.disabled = false;
3573 - if (sendBtn) sendBtn.disabled = false;
2763 + sendBtn.disabled = false;
3574 2764 uploadBtn.innerHTML = originalBtnContent;
3575 2765 this.value = ''; // Reset file input
3576 2766 }
3577 2767 });
3578 2768
3579 - // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids)
3580 - $(document).on('click', '.remove-pdf-btn', function(e) {
2769 + // Remove button click handlers
2770 + document.getElementById('remove-pdf-btn')?.addEventListener('click', function(e) {
3581 2771 e.preventDefault();
3582 2772 e.stopPropagation();
3583 - removeActivePdf(getBotIdFromElement(this));
2773 + removeActivePdf();
3584 2774 });
3585 -
3586 - $(document).on('click', '.remove-word-btn', function(e) {
2775 +
2776 + document.getElementById('remove-word-btn')?.addEventListener('click', function(e) {
3587 2777 e.preventDefault();
3588 2778 e.stopPropagation();
3589 - removeActiveWord(getBotIdFromElement(this));
2779 + removeActiveWord();
3590 2780 });
3591 2781
3592 2782 // Window resize handlers
3593 2783 $(window).on('resize orientationchange', function() {
@@ -3625,59 +2815,8 @@
3625 2815 });
3626 2816
3627 2817
3628 2818 // ====================================
3629 -// INIT LOADER & CHAT CONTAINER HELPERS
3630 -// ====================================
3631 -// These must be outside the email collection block so they're always available
3632 -// (used by persistence loading even when email collection is off)
3633 -
3634 -function showInitLoader(botId) {
3635 - var loader = getElementDOM(botId, 'mxchat-init-loader');
3636 - if (loader) loader.style.display = 'flex';
3637 -}
3638 -
3639 -function hideInitLoader(botId) {
3640 - var loader = getElementDOM(botId, 'mxchat-init-loader');
3641 - if (loader) loader.style.display = 'none';
3642 -}
3643 -
3644 -function showEmailFormForBot(botId) {
3645 - hideInitLoader(botId);
3646 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3647 - var chatContainer = getElementDOM(botId, 'chat-container');
3648 - if (emailBlocker) emailBlocker.style.display = 'flex';
3649 - if (chatContainer) chatContainer.style.display = 'none';
3650 -}
3651 -
3652 -function showChatContainerForBot(botId) {
3653 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3654 - var chatContainer = getElementDOM(botId, 'chat-container');
3655 - if (emailBlocker) emailBlocker.style.display = 'none';
3656 -
3657 - var instance = MxChatInstances.get(botId);
3658 - var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3659 -
3660 - // If persistence is on and history hasn't loaded yet, show loader
3661 - // while history loads to prevent flash of empty chat
3662 - if (chatPersistenceEnabled && !instance.chatHistoryLoaded) {
3663 - if (chatContainer) chatContainer.style.display = 'none';
3664 - showInitLoader(botId);
3665 - loadChatHistory(botId, function() {
3666 - hideInitLoader(botId);
3667 - if (chatContainer) chatContainer.style.display = 'flex';
3668 - scrollToBottom(botId, true);
3669 - });
3670 - } else {
3671 - hideInitLoader(botId);
3672 - if (chatContainer) chatContainer.style.display = 'flex';
3673 - if (typeof loadChatHistory === 'function') {
3674 - loadChatHistory(botId);
3675 - }
3676 - }
3677 -}
3678 -
3679 -// ====================================
3680 2819 // EMAIL COLLECTION SETUP - MULTI-INSTANCE VERSION
3681 2820 // ====================================
3682 2821 // Only run email collection setup if it's enabled
3683 2822 if (mxchatChat && mxchatChat.email_collection_enabled === 'on') {
@@ -3713,8 +2852,40 @@
3713 2852 `;
3714 2853 document.head.appendChild(style);
3715 2854 }
3716 2855
2856 + // Helper functions for email collection (multi-instance aware)
2857 + function showEmailFormForBot(botId) {
2858 + var emailBlocker = getElementDOM(botId, 'email-blocker');
2859 + var chatContainer = getElementDOM(botId, 'chat-container');
2860 + if (emailBlocker) emailBlocker.style.display = 'flex';
2861 + if (chatContainer) chatContainer.style.display = 'none';
2862 + }
2863 +
2864 + function showChatContainerForBot(botId) {
2865 + var emailBlocker = getElementDOM(botId, 'email-blocker');
2866 + var chatContainer = getElementDOM(botId, 'chat-container');
2867 + if (emailBlocker) emailBlocker.style.display = 'none';
2868 +
2869 + var instance = MxChatInstances.get(botId);
2870 + var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
2871 +
2872 + // If persistence is on and history hasn't loaded yet, keep container
2873 + // hidden until history loads to prevent flash of empty chat
2874 + if (chatPersistenceEnabled && !instance.chatHistoryLoaded) {
2875 + if (chatContainer) chatContainer.style.display = 'none';
2876 + loadChatHistory(botId, function() {
2877 + if (chatContainer) chatContainer.style.display = 'flex';
2878 + scrollToBottom(botId, true);
2879 + });
2880 + } else {
2881 + if (chatContainer) chatContainer.style.display = 'flex';
2882 + if (typeof loadChatHistory === 'function') {
2883 + loadChatHistory(botId);
2884 + }
2885 + }
2886 + }
2887 +
3717 2888 function isValidEmailAddress(email) {
3718 2889 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3719 2890 return emailRegex.test(email.trim()) && email.length <= 254;
3720 2891 }
@@ -3850,16 +3021,15 @@
3850 3021 }
3851 3022 }
3852 3023
3853 3024 function checkSessionAndEmailForBot(botId) {
3854 - const sessionId = MxChatInstances.ensureSession(botId);
3025 + const sessionId = getChatSession(botId);
3855 3026
3856 - // Hide both panels while we check — show loader instead
3027 + // Hide both panels while we check — prevents flash of wrong state
3857 3028 var emailBlocker = getElementDOM(botId, 'email-blocker');
3858 3029 var chatContainer = getElementDOM(botId, 'chat-container');
3859 3030 if (emailBlocker) emailBlocker.style.display = 'none';
3860 3031 if (chatContainer) chatContainer.style.display = 'none';
3861 - showInitLoader(botId);
3862 3032
3863 3033 fetch(mxchatChat.ajax_url, {
3864 3034 method: 'POST',
3865 3035 headers: {
@@ -3908,9 +3078,9 @@
3908 3078 var emailInput = getElementDOM(botId, 'user-email');
3909 3079 var nameInput = getElementDOM(botId, 'user-name');
3910 3080 var userEmail = emailInput ? emailInput.value.trim() : '';
3911 3081 var userName = nameInput ? nameInput.value.trim() : '';
3912 - var sessionId = MxChatInstances.ensureSession(botId);
3082 + var sessionId = getChatSession(botId);
3913 3083
3914 3084 // Validate email
3915 3085 if (!userEmail) {
3916 3086 showEmailError(botId, 'Please enter your email address.');
@@ -4039,8 +3209,9 @@
4039 3209 $('.mxchat-chatbot-wrapper').each(function() {
4040 3210 var botId = $(this).data('bot-id') || 'default';
4041 3211 var emailBlocker = getElementDOM(botId, 'email-blocker');
4042 3212
3213 + // Only check if email blocker exists for this bot
4043 3214 if (emailBlocker) {
4044 3215 if (isEmbeddedBot(botId)) {
4045 3216 // Embedded bots are always visible — check now
4046 3217 resolveEmailState(botId);
@@ -4045,15 +3216,8 @@
4045 3216 // Embedded bots are always visible — check now
4046 3217 resolveEmailState(botId);
4047 3218 }
4048 3219 // Floating bots: handled in the widget open handler
4049 - } else if (isEmbeddedBot(botId)) {
4050 - // Embedded bot, no email collection — load history with loader
4051 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4052 - if (chatPersistenceEnabled) {
4053 - MxChatInstances.ensureSession(botId);
4054 - showChatContainerForBot(botId);
4055 - }
4056 3220 }
4057 3221 });
4058 3222 }
4059 3223
@@ -4063,17 +3227,11 @@
4063 3227 var $chatbot = getElement(botId, 'floating-chatbot');
4064 3228 if ($chatbot.hasClass('hidden')) {
4065 3229 $chatbot.removeClass('hidden').addClass('visible');
4066 3230 getElement(botId, 'floating-chatbot-button').addClass('hidden');
4067 - handlePreChatDismissal(botId);
3231 + $(this).fadeOut(250); // Hide pre-chat message
4068 3232 disableScroll(); // Disable scroll when chatbot opens
4069 3233
4070 - // Load chat history for returning visitors (persistence)
4071 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4072 - if (chatPersistenceEnabled) {
4073 - MxChatInstances.ensureSession(botId);
4074 - }
4075 -
4076 3234 // Deferred email check — only on first widget open
4077 3235 var emailBlocker = getElementDOM(botId, 'email-blocker');
4078 3236 var instance = MxChatInstances.get(botId);
4079 3237 if (emailBlocker && !instance.emailCheckDone) {
@@ -4078,17 +3236,38 @@
4078 3236 var instance = MxChatInstances.get(botId);
4079 3237 if (emailBlocker && !instance.emailCheckDone) {
4080 3238 instance.emailCheckDone = true;
4081 3239 resolveEmailState(botId);
4082 - } else if (!emailBlocker) {
4083 - showChatContainerForBot(botId);
4084 3240 }
4085 3241 }
4086 3242 });
4087 3243
4088 - // Legacy duplicate close handler removed — handled by single event delegation above
3244 + // Dismiss pre-chat message via close button - handled by event delegation above at line ~2376
3245 + // This is a fallback for legacy support
3246 + $(document).on('click', '.close-pre-chat-message', function() {
3247 + var botId = getBotIdFromElement(this);
3248 + var $preChat = getElement(botId, 'pre-chat-message');
3249 + $preChat.fadeOut(200); // Hide the message
4089 3250
3251 + // Send an AJAX request to set the transient flag for 24 hours
3252 + $.ajax({
3253 + url: mxchatChat.ajax_url,
3254 + type: 'POST',
3255 + data: {
3256 + action: 'mxchat_dismiss_pre_chat_message',
3257 + _ajax_nonce: mxchatChat.nonce
3258 + },
3259 + success: function() {
3260 + // Ensure the message is hidden after dismissal
3261 + $preChat.hide();
3262 + },
3263 + error: function() {
3264 + // Error dismissing pre-chat message - silently continue
3265 + }
3266 + });
3267 + });
4090 3268
3269 +
4091 3270 function hasQuickQuestions(botId) {
4092 3271 botId = botId || 'default';
4093 3272 var questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
4094 3273 if (!questionsContainer) return false;
@@ -4281,312 +3460,6 @@
4281 3460 }, 2000);
4282 3461 });
4283 3462 }
4284 3463 }
4285 -});
4286 -
4287 -// ============================================================================
4288 -// SATISFACTION RATING (v3.2.6)
4289 -// ============================================================================
4290 -// Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
4291 -// inactivity following a bot reply. One prompt per session, deduped via
4292 -// localStorage. Runs ONLY when the satisfaction_rating_enabled option is on —
4293 -// the option (default off) is authoritative.
4294 -jQuery(function($) {
4295 - if (typeof mxchatChat === 'undefined') return;
4296 - // wp_localize_script stringifies scalars: a PHP boolean false arrives as
4297 - // '' and true as '1', so this must be an explicit-enable allowlist — the
4298 - // old "disabled when exactly false/'off'" check let '' through and the
4299 - // bubble rendered on sites with the option off/unset (plan-4bba64). PHP
4300 - // now emits 'on'/'off' strings; true/'1'/1 keep cached pre-fix HTML
4301 - // (boolean-true localizations) working.
4302 - // NOTE (plan-32db95): this gate reads the INLINE value at DOM ready and is
4303 - // deliberately NOT re-evaluated after the widget's dynamic-settings refresh
4304 - // merges fresh values over mxchatChat (that merge fires on first widget
4305 - // open, after this module has already decided). Re-evaluating would mean
4306 - // restructuring the whole module to late-bind its listeners — not worth it
4307 - // for a prompt that is at worst stale for one page load on a cached page.
4308 - var sre = mxchatChat.satisfaction_rating_enabled;
4309 - if (sre !== 'on' && sre !== true && sre !== '1' && sre !== 1) return;
4310 -
4311 - // wp_localize_script stringifies ints, so accept both number and numeric string.
4312 - var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
4313 - var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);
4314 - if (!isFinite(idleSeconds)) idleSeconds = 60;
4315 - if (idleSeconds < 5) idleSeconds = 5;
4316 - if (idleSeconds > 600) idleSeconds = 600;
4317 - var IDLE_MS = idleSeconds * 1000;
4318 - var MIN_BOT_REPLIES = 2;
4319 - var ratingState = {};
4320 -
4321 - function getState(botId) {
4322 - if (!ratingState[botId]) {
4323 - ratingState[botId] = { idleTimer: null, botReplies: 0, promptShown: false, dismissed: false };
4324 - }
4325 - return ratingState[botId];
4326 - }
4327 -
4328 - function getSessionId(botId) {
4329 - if (typeof MxChatInstances !== 'undefined' && MxChatInstances.getChatSession) {
4330 - return MxChatInstances.getChatSession(botId);
4331 - }
4332 - return null;
4333 - }
4334 -
4335 - function isAlreadyRated(sessionId) {
4336 - if (!sessionId) return false;
4337 - try { return localStorage.getItem('mxchat_rated:' + sessionId) === '1'; } catch (e) { return false; }
4338 - }
4339 -
4340 - function markRated(sessionId) {
4341 - if (!sessionId) return;
4342 - try { localStorage.setItem('mxchat_rated:' + sessionId, '1'); } catch (e) {}
4343 - }
4344 -
4345 - function esc(s) {
4346 - return String(s == null ? '' : s)
4347 - .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
4348 - .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
4349 - }
4350 -
4351 - // Mirror shouldSkipInlineColors so rating bubbles defer to AI-theme CSS.
4352 - function ratingSkipInlineColors(botId) {
4353 - if (mxchatChat.skip_inline_colors) return true;
4354 - var botAssignments = mxchatChat.bot_theme_assignments || {};
4355 - return botAssignments.hasOwnProperty(botId);
4356 - }
4357 -
4358 - function botBubbleStyleAttr(botId) {
4359 - if (ratingSkipInlineColors(botId)) return '';
4360 - var bg = mxchatChat.bot_message_bg_color;
4361 - var fg = mxchatChat.bot_message_font_color;
4362 - if (!bg && !fg) return '';
4363 - return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"';
4364 - }
4365 -
4366 - // Reads the rating bubble's actual computed fg+bg (whatever paints it —
4367 - // the inline color pickers OR the mxchat-theme AI customizer's injected CSS)
4368 - // and paints the filled "Send" pill so it fills with the bot font color and
4369 - // labels in the bubble bg. Mirrors mxchatSyncMenuColors(~:1512) for the read.
4370 - // We paint the submit button DIRECTLY (inline longhand) rather than relying
4371 - // on the CSS rule's var()s: Chromium resolves an INHERITED custom property
4372 - // unreliably inside a descendant's `background`, so a bubble-level var would
4373 - // silently fall back to the literal (white-block bug all over again). Inline
4374 - // longhand always wins. Same transparent-guard as the menu so we never paint
4375 - // a see-through value — in that case the CSS literal fallbacks keep it legible.
4376 - function syncRatingBubbleColors(botId) {
4377 - var $chatBox = getChatBoxByBotId(botId);
4378 - if (!$chatBox || !$chatBox.length) return;
4379 - var bubbleEl = $chatBox.find('.mxchat-rating-bot-bubble').last()[0];
4380 - if (!bubbleEl) return;
4381 - var cs = window.getComputedStyle(bubbleEl);
4382 - var fg = cs.color;
4383 - var bg = cs.backgroundColor;
4384 - var hasFg = fg && fg !== 'rgba(0, 0, 0, 0)' && fg !== 'transparent';
4385 - var hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
4386 - // Expose on the bubble too, for any inheriting styles / future use.
4387 - if (hasFg) bubbleEl.style.setProperty('--mxchat-bot-fg', fg);
4388 - if (hasBg) bubbleEl.style.setProperty('--mxchat-bot-bg', bg);
4389 - // Paint the Send pill directly — the part that actually fixes the bug.
4390 - var submitEl = bubbleEl.querySelector('.mxchat-rating-submit');
4391 - if (submitEl) {
4392 - if (hasFg) submitEl.style.backgroundColor = fg; // fill = bot font color
4393 - if (hasBg) submitEl.style.color = bg; // label = bubble background
4394 - }
4395 - }
4396 -
4397 - function copy(key) {
4398 - var c = mxchatChat.satisfaction_rating_copy || {};
4399 - var d = {
4400 - question: 'Was this helpful?',
4401 - helpful: 'Helpful',
4402 - not_helpful: 'Not helpful',
4403 - dismiss: 'Dismiss',
4404 - thanks: 'Thanks! Anything we should improve? (optional)',
4405 - placeholder: 'Tell us what could be better…',
4406 - send: 'Send',
4407 - skip: 'Skip',
4408 - saved: 'Thanks for the feedback.'
4409 - };
4410 - return c[key] || d[key];
4411 - }
4412 -
4413 - function thumbUpSvg() {
4414 - return '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M7.493 18.75c-.425 0-.82-.236-.975-.632A7.48 7.48 0 0 1 6 15.375c0-1.75.599-3.358 1.602-4.634.151-.192.373-.309.6-.397.473-.183.89-.514 1.212-.924a9.042 9.042 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V2.75A.75.75 0 0 1 15 2a2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H14.23c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23h-.777Z"/><path d="M2.331 10.977a11.969 11.969 0 0 0-.831 4.398 12 12 0 0 0 .52 3.507c.26.85 1.084 1.368 1.973 1.368H4.9c.445 0 .72-.498.523-.898a8.963 8.963 0 0 1-.924-3.977c0-1.708.476-3.305 1.302-4.666.245-.403-.028-.959-.5-.959H4.25c-.832 0-1.612.453-1.918 1.227Z"/></svg>';
4415 - }
4416 - function thumbDownSvg() {
4417 - return '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M15.73 5.25h1.035A7.465 7.465 0 0 1 18 9.375a7.465 7.465 0 0 1-1.235 4.125h-.148c-.806 0-1.534.446-2.031 1.08a9.04 9.04 0 0 1-2.861 2.4c-.723.384-1.35.956-1.653 1.715a4.498 4.498 0 0 0-.322 1.672V21a.75.75 0 0 1-.75.75 2.25 2.25 0 0 1-2.25-2.25c0-1.152.26-2.243.723-3.218.266-.558-.107-1.282-.725-1.282H3.622c-1.026 0-1.945-.694-2.054-1.715A12.137 12.137 0 0 1 1.5 12c0-2.848.992-5.464 2.649-7.521C4.537 3.997 5.136 3.75 5.754 3.75h4.541c.483 0 .964.078 1.423.23l3.114 1.04c.46.152.94.23 1.423.23Z"/><path d="M21.669 13.023c.536-1.362.831-2.845.831-4.398 0-1.22-.182-2.398-.52-3.507-.26-.85-1.084-1.368-1.973-1.368H19.1c-.445 0-.72.498-.523.898.591 1.2.924 2.55.924 3.977a8.958 8.958 0 0 1-1.302 4.666c-.245.403.028.959.5.959h1.053c.832 0 1.612-.453 1.918-1.227Z"/></svg>';
4418 - }
4419 -
4420 - function buildPromptHtml(botId) {
4421 - var styleAttr = botBubbleStyleAttr(botId);
4422 - return ''
4423 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4424 - + '<div class="mxchat-rating-prompt" data-bot-id="' + esc(botId) + '" role="group" aria-label="' + esc(copy('question')) + '">'
4425 - + '<div class="mxchat-rating-question">' + esc(copy('question')) + '</div>'
4426 - + '<div class="mxchat-rating-actions">'
4427 - + '<span class="mxchat-rating-buttons">'
4428 - + '<button type="button" class="mxchat-rating-btn" data-rating="1" aria-label="' + esc(copy('helpful')) + '">' + thumbUpSvg() + '</button>'
4429 - + '<button type="button" class="mxchat-rating-btn" data-rating="-1" aria-label="' + esc(copy('not_helpful')) + '">' + thumbDownSvg() + '</button>'
4430 - + '</span>'
4431 - + '<button type="button" class="mxchat-rating-dismiss" aria-label="' + esc(copy('dismiss')) + '">×</button>'
4432 - + '</div>'
4433 - + '</div>'
4434 - + '</div>';
4435 - }
4436 -
4437 - function buildFeedbackHtml(botId, rating) {
4438 - var styleAttr = botBubbleStyleAttr(botId);
4439 - return ''
4440 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4441 - + '<div class="mxchat-rating-feedback" data-bot-id="' + esc(botId) + '" data-rating="' + esc(String(rating)) + '">'
4442 - + '<div class="mxchat-rating-feedback-label">' + esc(copy('thanks')) + '</div>'
4443 - + '<textarea class="mxchat-rating-feedback-input" maxlength="500" placeholder="' + esc(copy('placeholder')) + '" rows="2"></textarea>'
4444 - + '<div class="mxchat-rating-feedback-actions">'
4445 - + '<button type="button" class="mxchat-rating-skip">' + esc(copy('skip')) + '</button>'
4446 - + '<button type="button" class="mxchat-rating-submit">' + esc(copy('send')) + '</button>'
4447 - + '</div>'
4448 - + '</div>'
4449 - + '</div>';
4450 - }
4451 -
4452 - function buildSavedHtml(botId) {
4453 - var styleAttr = botBubbleStyleAttr(botId);
4454 - return ''
4455 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4456 - + '<div class="mxchat-rating-saved">' + esc(copy('saved')) + '</div>'
4457 - + '</div>';
4458 - }
4459 -
4460 - function getChatBoxByBotId(botId) {
4461 - var $byId = $('#chat-box-' + botId);
4462 - if ($byId.length) return $byId.first();
4463 - return $('.chat-box').first();
4464 - }
4465 -
4466 - function scrollChatBoxToBottom($chatBox) {
4467 - if (!$chatBox || !$chatBox.length) return;
4468 - $chatBox.scrollTop($chatBox[0].scrollHeight);
4469 - }
4470 -
4471 - function showPrompt(botId) {
4472 - var s = getState(botId);
4473 - if (s.promptShown || s.dismissed) return;
4474 - var sessionId = getSessionId(botId);
4475 - if (!sessionId) return;
4476 - if (isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4477 - var $chatBox = getChatBoxByBotId(botId);
4478 - if (!$chatBox.length) return;
4479 - if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; }
4480 - $chatBox.append(buildPromptHtml(botId));
4481 - syncRatingBubbleColors(botId);
4482 - s.promptShown = true;
4483 - scrollChatBoxToBottom($chatBox);
4484 - }
4485 -
4486 - function submitRating(botId, rating, feedback) {
4487 - var sessionId = getSessionId(botId);
4488 - if (!sessionId) return;
4489 - $.post(mxchatChat.ajax_url, {
4490 - action: 'mxchat_save_rating',
4491 - session_id: sessionId,
4492 - bot_id: botId,
4493 - rating: rating,
4494 - feedback: feedback || ''
4495 - });
4496 - markRated(sessionId);
4497 - }
4498 -
4499 - function onBotReply(botId) {
4500 - var s = getState(botId);
4501 - s.botReplies += 1;
4502 - if (s.promptShown || s.dismissed) return;
4503 - var sessionId = getSessionId(botId);
4504 - if (sessionId && isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4505 - if (s.botReplies < MIN_BOT_REPLIES) return;
4506 - if (s.idleTimer) clearTimeout(s.idleTimer);
4507 - s.idleTimer = setTimeout(function() { showPrompt(botId); }, IDLE_MS);
4508 - }
4509 -
4510 - function onUserMessage(botId) {
4511 - var s = getState(botId);
4512 - if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = null; }
4513 - }
4514 -
4515 - function botIdFromChatBox(el) {
4516 - var id = el && el.id ? el.id : '';
4517 - return id.indexOf('chat-box-') === 0 ? id.substring('chat-box-'.length) : 'default';
4518 - }
4519 -
4520 - function setupObserver(chatBox) {
4521 - var botId = botIdFromChatBox(chatBox);
4522 - try {
4523 - var observer = new MutationObserver(function(mutations) {
4524 - mutations.forEach(function(m) {
4525 - for (var i = 0; i < m.addedNodes.length; i++) {
4526 - var node = m.addedNodes[i];
4527 - if (!node || node.nodeType !== 1) continue;
4528 - var $n = $(node);
4529 - if ($n.hasClass('mxchat-rating-bot-bubble') || $n.hasClass('mxchat-rating-prompt') || $n.hasClass('mxchat-rating-feedback') || $n.hasClass('mxchat-rating-saved')) continue;
4530 - if ($n.hasClass('bot-message')) onBotReply(botId); // count at insert time — streaming providers append with .temporary-message first, then remove later (childList observer can't see attr changes)
4531 - else if ($n.hasClass('user-message')) onUserMessage(botId);
4532 - }
4533 - });
4534 - });
4535 - observer.observe(chatBox, { childList: true });
4536 - } catch (e) { /* noop */ }
4537 - }
4538 -
4539 - $('.chat-box').each(function() { setupObserver(this); });
4540 -
4541 - $(document).on('click', '.mxchat-rating-btn', function(e) {
4542 - e.preventDefault();
4543 - var $btn = $(this);
4544 - var $prompt = $btn.closest('.mxchat-rating-prompt');
4545 - var $wrap = $btn.closest('.mxchat-rating-bot-bubble');
4546 - var botId = $prompt.data('bot-id') || 'default';
4547 - var rating = parseInt($btn.attr('data-rating'), 10);
4548 - if (rating !== 1 && rating !== -1) return;
4549 - submitRating(botId, rating, '');
4550 - ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating));
4551 - syncRatingBubbleColors(botId);
4552 - scrollChatBoxToBottom(getChatBoxByBotId(botId));
4553 - });
4554 -
4555 - $(document).on('click', '.mxchat-rating-dismiss', function(e) {
4556 - e.preventDefault();
4557 - var $prompt = $(this).closest('.mxchat-rating-prompt');
4558 - var $wrap = $(this).closest('.mxchat-rating-bot-bubble');
4559 - var botId = $prompt.data('bot-id') || 'default';
4560 - var s = getState(botId);
4561 - s.dismissed = true;
4562 - markRated(getSessionId(botId));
4563 - ($wrap.length ? $wrap : $prompt).remove();
4564 - });
4565 -
4566 - function closeFeedback($fb) {
4567 - var botId = $fb.data('bot-id') || 'default';
4568 - var $wrap = $fb.closest('.mxchat-rating-bot-bubble');
4569 - ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId));
4570 - syncRatingBubbleColors(botId);
4571 - scrollChatBoxToBottom(getChatBoxByBotId(botId));
4572 - }
4573 -
4574 - $(document).on('click', '.mxchat-rating-skip', function(e) {
4575 - e.preventDefault();
4576 - closeFeedback($(this).closest('.mxchat-rating-feedback'));
4577 - });
4578 -
4579 - $(document).on('click', '.mxchat-rating-submit', function(e) {
4580 - e.preventDefault();
4581 - var $fb = $(this).closest('.mxchat-rating-feedback');
4582 - var botId = $fb.data('bot-id') || 'default';
4583 - var rating = parseInt($fb.attr('data-rating'), 10);
4584 - if (rating !== 1 && rating !== -1) { closeFeedback($fb); return; }
4585 - var text = String($fb.find('.mxchat-rating-feedback-input').val() || '').trim();
4586 - if (text !== '') {
4587 - submitRating(botId, rating, text);
4588 - }
4589 - closeFeedback($fb);
4590 - });
4591 3464 });
4592 3465