PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.0.2
MxChat – AI Chatbot & Content Generation for WordPress v3.0.2
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 +580 -1981 3.2.163.0.2 View file →
@@ -1,166 +1,6 @@
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') {
55 - if (callback) callback();
56 - return;
57 - }
58 - var now = Date.now();
59 - if (cachedFreshNonce && (now - cachedFreshNonceFetchedAt) < NONCE_TTL_MS) {
60 - mxchatChat.nonce = cachedFreshNonce;
61 - if (callback) callback();
62 - return;
63 - }
64 - if (callback) nonceRefreshCallbacks.push(callback);
65 - if (nonceRefreshState === 'pending') return;
66 - nonceRefreshState = 'pending';
67 -
68 - var resolved = function (nonce) {
69 - if (nonce) {
70 - cachedFreshNonce = nonce;
71 - cachedFreshNonceFetchedAt = Date.now();
72 - mxchatChat.nonce = nonce;
73 - }
74 - nonceRefreshState = 'done';
75 - var pending = nonceRefreshCallbacks;
76 - nonceRefreshCallbacks = [];
77 - pending.forEach(function (cb) { try { cb(); } catch (e) {} });
78 - };
79 -
80 - fetchFreshNonceFromRest()
81 - .then(resolved)
82 - .catch(function () {
83 - // Fallback to the legacy admin-ajax refresh path (issued with the
84 - // old action `mxchat_chat_nonce`; the server still accepts both
85 - // during the compat window).
86 - if (mxchatChat.ajax_url) {
87 - $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce' })
88 - .done(function (res) {
89 - if (res && res.success && res.data && res.data.nonce) {
90 - resolved(res.data.nonce);
91 - return;
92 - }
93 - resolved(null);
94 - })
95 - .fail(function () { resolved(null); });
96 - } else {
97 - resolved(null);
98 - }
99 - });
100 - }
101 -
102 - // Backwards-compat alias — every existing caller in this file (and any
103 - // out-of-tree consumer that hit this internal API) keeps working unchanged.
104 - function refreshNonceIfNeeded(callback) {
105 - return withFreshNonce(callback);
106 - }
107 -
108 - // Dynamic-settings refresh (plan-32db95).
109 - //
110 - // Every widget setting ships inline in cached page HTML, so behind a
111 - // full-page cache the site owner can't purge (host cache, CDN, the
112 - // browser itself) a toggled setting looks broken until the cache turns
113 - // over. Same distrust-cached-HTML reasoning as the per-request nonce:
114 - // on the FIRST widget open per page load we ask the nonce endpoint for
115 - // the current behavior-gate settings (?with_settings=1), merge them over
116 - // mxchatChat, and rebuild the header menu. Colors are NOT refreshed —
117 - // they're server-inline-styled, so a runtime swap would visibly flash.
118 - // On any failure we keep the inline values silently (nonce-fallback
119 - // posture). At most one request per page load, only if a widget opens.
120 - var dynamicSettingsState = 'idle'; // 'idle' | 'pending' | 'done'
121 -
122 - function mxchatRefreshDynamicSettings() {
123 - if (dynamicSettingsState !== 'idle') return;
124 - if (typeof mxchatChat === 'undefined') return;
125 - dynamicSettingsState = 'pending';
126 -
127 - var applied = function (data) {
128 - dynamicSettingsState = 'done';
129 - if (!data) return; // endpoint unavailable — inline values stand.
130 - if (data.nonce) {
131 - // Seed the nonce cache too: saves the first send's REST
132 - // round-trip and keeps us under the endpoint's rate limit.
133 - cachedFreshNonce = data.nonce;
134 - cachedFreshNonceFetchedAt = Date.now();
135 - mxchatChat.nonce = data.nonce;
136 - }
137 - if (data.settings && typeof data.settings === 'object') {
138 - $.extend(mxchatChat, data.settings);
139 - mxchatRebuildHeaderMenus();
140 - }
141 - };
142 -
143 - fetch(getRestNonceUrl() + '?with_settings=1', {
144 - credentials: 'same-origin',
145 - headers: { 'Accept': 'application/json' }
146 - }).then(function (resp) {
147 - if (!resp.ok) throw new Error('settings refresh failed: ' + resp.status);
148 - return resp.json();
149 - }).then(applied).catch(function () {
150 - // Fallback: legacy admin-ajax refresh path, same as withFreshNonce.
151 - if (mxchatChat.ajax_url) {
152 - $.post(mxchatChat.ajax_url, { action: 'mxchat_refresh_nonce', with_settings: 1 })
153 - .done(function (res) {
154 - applied(res && res.success && res.data ? res.data : null);
155 - })
156 - .fail(function () { applied(null); });
157 - } else {
158 - applied(null);
159 - }
160 - });
161 - }
162 -
163 3 // ====================================
164 4 // MULTI-INSTANCE MANAGEMENT SYSTEM
165 5 // ====================================
166 6
@@ -170,15 +10,11 @@
170 10
171 11 // Initialize an instance for a bot
172 12 init: function(botId) {
173 13 if (!this.instances[botId]) {
174 - // When persistence is OFF, track when this session started
175 - // so the AI only sees messages from this page load
176 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
177 -
178 14 this.instances[botId] = {
179 15 botId: botId,
180 - sessionId: null,
16 + sessionId: this.getChatSession(botId),
181 17 lastSeenMessageId: '',
182 18 notificationCheckInterval: null,
183 19 pollingInterval: null,
184 20 processedMessageIds: new Set(),
@@ -184,11 +20,9 @@
184 20 processedMessageIds: new Set(),
185 21 activePdfFile: null,
186 22 activeWordFile: null,
187 23 chatHistoryLoaded: false,
188 - isStreaming: false,
189 - // Fresh context timestamp - only used when persistence is OFF
190 - sessionStartTimestamp: chatPersistenceEnabled ? 0 : Date.now()
24 + isStreaming: false
191 25 };
192 26 }
193 27 return this.instances[botId];
194 28 },
@@ -203,77 +37,23 @@
203 37 return Object.keys(this.instances);
204 38 },
205 39
206 40 // 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 41 getChatSession: function(botId) {
210 42 var cookieName = 'mxchat_session_id_' + botId;
211 - var storageKey = 'mxchat_session_id_' + botId;
212 43 var sessionId = getCookie(cookieName);
213 44
214 - // Fallback to localStorage if cookie is missing (e.g. cleared by browser/consent)
215 45 if (!sessionId) {
216 - try { sessionId = localStorage.getItem(storageKey); } catch (e) {}
46 + sessionId = generateSessionId();
47 + this.setChatSession(botId, sessionId);
217 48 }
218 49
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;
50 + return sessionId;
238 51 },
239 52
240 - // Lazy session initializer — called on first user interaction
241 - ensureSession: function(botId) {
242 - botId = botId || 'default';
243 - var instance = this.instances[botId] || this.init(botId);
244 -
245 - if (instance.sessionId) {
246 - return instance.sessionId;
247 - }
248 -
249 - // Check for existing session from cookie or localStorage
250 - var existingSession = this.getChatSession(botId);
251 -
252 - if (existingSession) {
253 - instance.sessionId = existingSession;
254 - } else {
255 - // Brand new session
256 - var newId = generateSessionId();
257 - this.setChatSession(botId, newId);
258 - instance.sessionId = newId;
259 - }
260 -
261 - // Now that we have a session, do the deferred work
262 - refreshNonceIfNeeded();
263 - trackOriginatingPage();
264 -
265 - // Note: loadChatHistory is handled by showChatContainerForBot with loader UI,
266 - // so we do NOT call it here to avoid a race condition.
267 -
268 - return instance.sessionId;
269 - },
270 -
271 53 setChatSession: function(botId, sessionId) {
272 54 var cookieName = 'mxchat_session_id_' + botId;
273 - var storageKey = 'mxchat_session_id_' + botId;
274 55 document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax";
275 - try { localStorage.setItem(storageKey, sessionId); } catch (e) {}
276 56 if (this.instances[botId]) {
277 57 this.instances[botId].sessionId = sessionId;
278 58 }
279 59 },
@@ -278,10 +58,8 @@
278 58 }
279 59 },
280 60
281 61 resetChatSession: function(botId) {
282 - // Clear old session from localStorage before setting new one
283 - try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {}
284 62 var newSessionId = generateSessionId();
285 63 this.setChatSession(botId, newSessionId);
286 64 var $chatBox = getElement(botId, 'chat-box');
287 65 if ($chatBox.length) {
@@ -290,20 +68,8 @@
290 68 if (this.instances[botId]) {
291 69 this.instances[botId].chatHistoryLoaded = false;
292 70 this.instances[botId].processedMessageIds = new Set();
293 71 }
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 72 }
307 73 };
308 74
309 75 // ====================================
@@ -343,20 +109,8 @@
343 109 var id = $floating.attr('id') || '';
344 110 var match = id.match(/floating-chatbot-(.+)/);
345 111 if (match) return match[1];
346 112 }
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 113 // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
360 114 var elementId = $(element).attr('id') || '';
361 115 if (elementId) {
362 116 // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
@@ -396,27 +150,9 @@
396 150 if (parts.length == 2) return parts.pop().split(";").shift();
397 151 }
398 152
399 153 function generateSessionId() {
400 - // Session IDs function as the de-facto bearer token for an anonymous
401 - // chat, so generate them with a CSPRNG when available. Math.random is a
402 - // legacy fallback for ancient/sandboxed environments that lack
403 - // window.crypto. The 'mxchat_chat_' prefix is preserved exactly (other
404 - // code pattern-matches on it). (plan-0c17b5)
405 - var rand;
406 - try {
407 - if (window.crypto && window.crypto.getRandomValues) {
408 - var buf = new Uint8Array(16); // 128 bits
409 - window.crypto.getRandomValues(buf);
410 - rand = Array.prototype.map.call(buf, function (b) {
411 - return ('0' + b.toString(16)).slice(-2);
412 - }).join('');
413 - }
414 - } catch (e) {}
415 - if (!rand) {
416 - rand = Math.random().toString(36).substr(2, 9); // legacy fallback
417 - }
418 - return 'mxchat_chat_' + rand;
154 + return 'mxchat_chat_' + Math.random().toString(36).substr(2, 9);
419 155 }
420 156
421 157 // Legacy function - now delegates to instance manager
422 158 function getChatSession(botId) {
@@ -633,106 +369,13 @@
633 369 sendButton.disabled = false;
634 370 sendButton.style.opacity = '1';
635 371 sendButton.style.pointerEvents = 'auto';
636 372 }
637 - // Every completion path re-enables input, so this is the single restore
638 - // point for the streaming Stop affordance (no-op when not in stop mode).
639 - mxchatRestoreSendButton(botId);
640 373 }
641 374
642 -// --- Streaming Stop control -------------------------------------------------
643 -// One live stream handle per bot instance, so Stop on one widget never aborts
644 -// another bot on the same page.
645 -var mxchatActiveStreams = {};
646 -// Original send-button markup, captured once per bot the first time the Stop
647 -// state is shown (never captured while already in stop mode, so a rapid
648 -// stop-then-resend can't save the stop glyph as the "original").
649 -var mxchatSendMarkup = {};
650 -
651 -function mxchatShowStopButton(botId) {
652 - var btn = getElementDOM(botId, 'send-button');
653 - if (!btn) return;
654 - if (!btn.classList.contains('mxchat-stop-mode')) {
655 - mxchatSendMarkup[botId] = {
656 - html: btn.innerHTML,
657 - label: btn.getAttribute('aria-label')
658 - };
659 - }
660 -
661 - // Mirror the send icon's rendered size + color so the stop glyph looks
662 - // native, including custom send images/colors and theme overrides.
663 - var child = btn.querySelector('svg, img');
664 - var size = 25;
665 - var color = '';
666 - if (child) {
667 - var rect = child.getBoundingClientRect();
668 - if (rect.width) {
669 - size = Math.round(Math.min(rect.width, rect.height));
670 - }
671 - var cs = window.getComputedStyle(child);
672 - color = (child.tagName.toLowerCase() === 'svg' ? cs.fill : cs.color) || '';
673 - }
674 - var stopLabel = (typeof mxchatChat !== 'undefined' && mxchatChat.stop_button_label) || 'Stop response';
675 - 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>';
676 - // An add-on's DIRECT send-button handler (e.g. mxchat-vision's rebind) can
677 - // start a stream synchronously while the originating click is still
678 - // bubbling up to our delegated handler. Without this guard, that handler
679 - // reads the just-added stop-mode class as a user Stop press and aborts the
680 - // brand-new stream — the user's message renders but no reply ever fires
681 - // (plan-4bba64 silent message loss). The flag only spans the current event
682 - // dispatch: cleared on the next macrotask, long before a real Stop click.
683 - btn.__mxchatStopJustShown = true;
684 - setTimeout(function () { btn.__mxchatStopJustShown = false; }, 0);
685 - btn.classList.add('mxchat-stop-mode');
686 - btn.setAttribute('aria-label', stopLabel);
687 - btn.setAttribute('title', stopLabel);
688 - // disableChatInput() ran when the turn was sent; the Stop control itself
689 - // must stay clickable while the textarea remains disabled.
690 - btn.disabled = false;
691 - btn.style.opacity = '1';
692 - btn.style.pointerEvents = 'auto';
693 -}
694 -
695 -function mxchatRestoreSendButton(botId) {
696 - var btn = getElementDOM(botId, 'send-button');
697 - var saved = mxchatSendMarkup[botId];
698 - if (!btn || !btn.classList.contains('mxchat-stop-mode') || !saved) return;
699 - btn.innerHTML = saved.html;
700 - btn.classList.remove('mxchat-stop-mode');
701 - btn.removeAttribute('title');
702 - if (saved.label) {
703 - btn.setAttribute('aria-label', saved.label);
704 - }
705 -}
706 -
707 -function mxchatStopStreaming(botId) {
708 - var entry = mxchatActiveStreams[botId];
709 - if (!entry || !entry.controller) return;
710 - entry.aborted = true;
711 - try { entry.controller.abort(); } catch (e) {}
712 -}
713 -
714 -// Returns true when a stream rejection came from an intentional Stop click:
715 -// keep the partial text as the turn's answer — no error UI, no fallback resend.
716 -function mxchatHandleStreamAbort(botId, accumulatedContent, callback) {
717 - var entry = mxchatActiveStreams[botId];
718 - if (!entry || !entry.aborted) return false;
719 - delete mxchatActiveStreams[botId];
720 - if (!accumulatedContent) {
721 - // Stopped before the first chunk: drop the thinking bubble, no orphan message.
722 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
723 - }
724 - enableChatInput(botId); // also restores the send icon
725 - if (callback) {
726 - callback(accumulatedContent || '');
727 - }
728 - return true;
729 -}
730 -
731 375 // Update your existing sendMessage function
732 376 function sendMessage(botId) {
733 377 botId = botId || 'default';
734 - MxChatInstances.ensureSession(botId);
735 378 var $chatInput = getElement(botId, 'chat-input');
736 379 var message = $chatInput.val();
737 380
738 381 // ADD PROMPT HOOK HERE
@@ -740,18 +383,13 @@
740 383 message = customMxChatFilter(message, "prompt");
741 384 }
742 385
743 386 if (message) {
744 - // Don't disable input in live agent mode - let users chat freely
745 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
746 - var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
747 - if (!isAgentMode) {
748 - disableChatInput(botId);
749 - }
387 + // Disable input while waiting for response
388 + disableChatInput(botId);
750 389
751 390 appendMessage("user", message, '', [], false, botId);
752 391 $chatInput.val('');
753 - mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
754 392 $chatInput.css('height', 'auto');
755 393
756 394 if (hasQuickQuestions(botId)) {
757 395 collapseQuickQuestions(botId);
@@ -758,9 +396,9 @@
758 396 }
759 397 appendThinkingMessage(botId);
760 398 scrollToBottom(botId);
761 399
762 - const currentModel = mxchatChat.model || 'gpt-5.6-sol';
400 + const currentModel = mxchatChat.model || 'gpt-4o';
763 401
764 402 // Check if streaming is enabled AND supported for this model
765 403 if (shouldUseStreaming(currentModel)) {
766 404 callMxChatStream(message, function(response) {
@@ -776,9 +414,8 @@
776 414
777 415 // Update your existing sendMessageToChatbot function
778 416 function sendMessageToChatbot(message, botId) {
779 417 botId = botId || 'default';
780 - MxChatInstances.ensureSession(botId);
781 418
782 419 // ADD PROMPT HOOK HERE
783 420 if (typeof customMxChatFilter === 'function') {
784 421 message = customMxChatFilter(message, "prompt");
@@ -783,14 +420,10 @@
783 420 if (typeof customMxChatFilter === 'function') {
784 421 message = customMxChatFilter(message, "prompt");
785 422 }
786 423
787 - // Don't disable input in live agent mode - let users chat freely
788 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
789 - var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
790 - if (!isAgentMode) {
791 - disableChatInput(botId);
792 - }
424 + // Disable input while waiting for response
425 + disableChatInput(botId);
793 426
794 427 var sessionId = getChatSession(botId);
795 428
796 429 if (hasQuickQuestions(botId)) {
@@ -798,9 +431,9 @@
798 431 }
799 432 appendThinkingMessage(botId);
800 433 scrollToBottom(botId);
801 434
802 - const currentModel = mxchatChat.model || 'gpt-5.6-sol';
435 + const currentModel = mxchatChat.model || 'gpt-4o';
803 436
804 437 // Check if streaming is enabled AND supported for this model
805 438 if (shouldUseStreaming(currentModel)) {
806 439 callMxChatStream(message, function(response) {
@@ -869,15 +502,8 @@
869 502
870 503 function callMxChat(message, callback, botId) {
871 504 botId = botId || getMxChatBotId();
872 505
873 - // Streaming fallbacks land here: drop any leftover stream handle and
874 - // return the button to its send state (no-op for plain non-stream turns).
875 - if (mxchatActiveStreams[botId]) {
876 - delete mxchatActiveStreams[botId];
877 - }
878 - mxchatRestoreSendButton(botId);
879 -
880 506 // Store the message in case we need to retry after session reset
881 507 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
882 508
883 509 // Get page context if contextual awareness is enabled
@@ -882,44 +508,24 @@
882 508
883 509 // Get page context if contextual awareness is enabled
884 510 const pageContext = getPageContext();
885 511
886 - // Get instance for session start timestamp (used when persistence is OFF)
887 - var instance = MxChatInstances.get(botId);
888 -
889 - // Guarantee a non-null session_id before the AJAX leaves. ensureSession() is idempotent
890 - // and returns the guaranteed-present session id from the in-memory instance even when
891 - // cookie/localStorage writes are silently blocked by the browser.
892 - var sessionId = MxChatInstances.ensureSession(botId);
893 - if (!sessionId || sessionId === 'null' || sessionId === 'undefined') {
894 - // Last-resort generation to ensure we never POST a null marker.
895 - sessionId = generateSessionId();
896 - MxChatInstances.setChatSession(botId, sessionId);
897 - }
898 -
899 - // Wait for the page-cache nonce refresh to complete before firing the
900 - // chat-send AJAX. On cached pages the inline mxchatChat.nonce is stale
901 - // until refreshNonceIfNeeded() returns; constructing ajaxData inside the
902 - // callback guarantees we read the fresh value. See plan-c5457f.
903 - refreshNonceIfNeeded(function() {
904 512 // Prepare AJAX data
905 513 const ajaxData = {
906 514 action: 'mxchat_handle_chat_request',
907 515 message: message,
908 - session_id: sessionId,
516 + session_id: getChatSession(botId),
909 517 nonce: mxchatChat.nonce,
910 518 current_page_url: window.location.href,
911 519 current_page_title: document.title,
912 - bot_id: botId,
913 - // Pass session start timestamp so AI context matches what user sees
914 - session_start_timestamp: instance.sessionStartTimestamp || 0
520 + bot_id: botId
915 521 };
916 -
522 +
917 523 // Add page context if available
918 524 if (pageContext) {
919 525 ajaxData.page_context = JSON.stringify(pageContext);
920 526 }
921 -
527 +
922 528 // CHECK FOR VISION FLAGS AND ADD THEM
923 529 if (window.mxchatVisionProcessed) {
924 530 ajaxData.vision_processed = true;
925 531 ajaxData.original_user_message = window.mxchatOriginalMessage || message;
@@ -928,9 +534,9 @@
928 534 window.mxchatVisionProcessed = false;
929 535 window.mxchatOriginalMessage = null;
930 536 window.mxchatVisionImagesCount = 0;
931 537 }
932 -
538 +
933 539 $.ajax({
934 540 url: mxchatChat.ajax_url,
935 541 type: 'POST',
936 542 dataType: 'json',
@@ -968,16 +574,23 @@
968 574 errorMessage = "An error occurred. Please try again or contact support.";
969 575 }
970 576
971 577 // Handle session reset action (IP changed, session expired, etc.)
972 - // Silent reset — keep chat UI intact, just get a new session and retry
973 578 if (response.data && response.data.action === 'reset_session') {
974 - MxChatInstances.silentResetSession(botId);
975 - // Re-send the original message with the new session (user message is already displayed)
579 + // Clear the old session and generate a new one
580 + resetChatSession(botId);
581 + // Remove the temporary loading message
582 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
583 + // Re-send the original message with the new session
976 584 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
977 585 if (originalMessage) {
978 586 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
979 - var currentModel = mxchatChat.model || 'gpt-5.6-sol';
587 + // Re-add the user message and thinking indicator
588 + appendMessage("user", originalMessage, '', [], false, botId);
589 + appendThinkingMessage(botId);
590 + scrollToBottom(botId);
591 + // Determine whether to use streaming
592 + const currentModel = mxchatChat.model || 'gpt-4o';
980 593 if (shouldUseStreaming(currentModel)) {
981 594 callMxChatStream(originalMessage, function(response) {
982 595 getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
983 596 }, botId);
@@ -1034,11 +647,9 @@
1034 647 }
1035 648
1036 649 // Check for live agent response
1037 650 if (response.success && response.data && response.data.status === 'waiting_for_agent') {
1038 - removeThinkingDots(botId);
1039 651 updateChatModeIndicator('agent', botId);
1040 - enableChatInput(botId);
1041 652 return;
1042 653 }
1043 654
1044 655 // Handle the message and show notification if chat is hidden
@@ -1071,13 +682,9 @@
1071 682 $badge.show();
1072 683 }
1073 684 }
1074 685 } else {
1075 - var emptyMsg = "I received an empty response. Please try again or contact support if this persists.";
1076 - if (response.vectorstore_error) {
1077 - emptyMsg = "I received an empty response. Debug info: " + response.vectorstore_error;
1078 - }
1079 - replaceLastMessage("bot", emptyMsg, '', [], botId);
686 + replaceLastMessage("bot", "I received an empty response. Please try again or contact support if this persists.", '', [], botId);
1080 687 }
1081 688
1082 689 if (response.message_id) {
1083 690 var instance = MxChatInstances.get(botId);
@@ -1119,9 +726,8 @@
1119 726
1120 727 replaceLastMessage("bot", errorMessage, '', [], botId);
1121 728 }
1122 729 });
1123 - }); // refreshNonceIfNeeded
1124 730 }
1125 731
1126 732 function callMxChatStream(message, callback, botId) {
1127 733 botId = botId || getMxChatBotId();
@@ -1128,9 +734,9 @@
1128 734
1129 735 // Store the message in case we need to retry after session reset
1130 736 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
1131 737
1132 - const currentModel = mxchatChat.model || 'gpt-5.6-sol';
738 + const currentModel = mxchatChat.model || 'gpt-4o';
1133 739 if (!isStreamingSupported(currentModel)) {
1134 740 callMxChat(message, callback, botId);
1135 741 return;
1136 742 }
@@ -1137,35 +743,17 @@
1137 743
1138 744 // Get page context if contextual awareness is enabled
1139 745 const pageContext = getPageContext();
1140 746
1141 - // Get instance for session start timestamp (used when persistence is OFF)
1142 - var instance = MxChatInstances.get(botId);
1143 -
1144 - // Guarantee a non-null session_id before the fetch. FormData.append() stringifies any
1145 - // non-string value via String(), so passing `null` would POST the literal string "null"
1146 - // and land in the transcripts table as a ghost session. ensureSession() always returns
1147 - // a real string even when cookies/localStorage are blocked.
1148 - var streamSessionId = MxChatInstances.ensureSession(botId);
1149 - if (!streamSessionId || streamSessionId === 'null' || streamSessionId === 'undefined') {
1150 - streamSessionId = generateSessionId();
1151 - MxChatInstances.setChatSession(botId, streamSessionId);
1152 - }
1153 -
1154 - // Wait for the page-cache nonce refresh before constructing formData (which
1155 - // captures mxchatChat.nonce by value). Mirrors callMxChat's wrapping. See plan-c5457f.
1156 - refreshNonceIfNeeded(function() {
1157 747 const formData = new FormData();
1158 748 formData.append('action', 'mxchat_stream_chat');
1159 749 formData.append('message', message);
1160 - formData.append('session_id', streamSessionId);
750 + formData.append('session_id', getChatSession(botId));
1161 751 formData.append('nonce', mxchatChat.nonce);
1162 752 formData.append('current_page_url', window.location.href);
1163 753 formData.append('current_page_title', document.title);
1164 754 formData.append('bot_id', botId);
1165 - // Pass session start timestamp so AI context matches what user sees
1166 - formData.append('session_start_timestamp', instance.sessionStartTimestamp || 0);
1167 -
755 +
1168 756 // Add page context if available
1169 757 if (pageContext) {
1170 758 formData.append('page_context', JSON.stringify(pageContext));
1171 759 }
@@ -1183,25 +771,13 @@
1183 771
1184 772 let accumulatedContent = '';
1185 773 let testingDataReceived = false;
1186 774 let streamingStarted = false;
1187 - // Server-pushed html to append as its OWN bot bubble once the stream
1188 - // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is
1189 - // deferred to [DONE] so the embed always lands BELOW the streamed text.
1190 - let pendingAppendHtml = '';
1191 775
1192 - // Abortable stream: a fresh controller per turn, keyed by bot instance.
1193 - // The Stop control (send button swapped in place) aborts both the read
1194 - // loop and the underlying request.
1195 - var streamControl = { controller: new AbortController(), aborted: false };
1196 - mxchatActiveStreams[botId] = streamControl;
1197 - mxchatShowStopButton(botId);
1198 -
1199 776 fetch(mxchatChat.ajax_url, {
1200 777 method: 'POST',
1201 778 body: formData,
1202 - credentials: 'same-origin',
1203 - signal: streamControl.controller.signal
779 + credentials: 'same-origin'
1204 780 })
1205 781 .then(response => {
1206 782 // Store the response for potential fallback handling
1207 783 const responseClone = response.clone();
@@ -1267,19 +843,8 @@
1267 843 });
1268 844 return;
1269 845 }
1270 846
1271 - // Re-enable chat input when stream ends with content
1272 - enableChatInput(botId);
1273 -
1274 - // Scroll the user's last message to the top now that the
1275 - // bot's full reply has rendered (gives max reading room).
1276 - var $chatBoxDone = getElement(botId, 'chat-box');
1277 - var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
1278 - if ($lastUserMsgDone.length) {
1279 - scrollElementToTop($lastUserMsgDone, botId);
1280 - }
1281 -
1282 847 if (callback) {
1283 848 callback(accumulatedContent);
1284 849 }
1285 850 return;
@@ -1302,25 +867,8 @@
1302 867
1303 868 // Re-enable chat input after streaming completes
1304 869 enableChatInput(botId);
1305 870
1306 - // Render any server-pushed appendix html (e.g. the
1307 - // YouTube embed) as its own bot bubble below the
1308 - // streamed text — mirrors how it is saved in the
1309 - // transcript, so history replays identically.
1310 - if (pendingAppendHtml) {
1311 - appendMessage("bot", "", pendingAppendHtml, [], false, botId);
1312 - pendingAppendHtml = '';
1313 - }
1314 -
1315 - // Scroll the user's last message to the top now
1316 - // that the bot's full reply has rendered.
1317 - var $chatBoxStreamDone = getElement(botId, 'chat-box');
1318 - var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
1319 - if ($lastUserMsgStreamDone.length) {
1320 - scrollElementToTop($lastUserMsgStreamDone, botId);
1321 - }
1322 -
1323 871 if (callback) {
1324 872 callback(accumulatedContent);
1325 873 }
1326 874 return;
@@ -1346,12 +894,8 @@
1346 894 streamingStarted = true;
1347 895 accumulatedContent += json.content;
1348 896 updateStreamingMessage(accumulatedContent, botId);
1349 897 }
1350 - // Stash appendix html (e.g. video embed) for [DONE]
1351 - else if (json.append_html) {
1352 - pendingAppendHtml = json.append_html;
1353 - }
1354 898 // Handle complete response in stream (fallback response)
1355 899 else if (json.text || json.message || json.html) {
1356 900 handleNonStreamResponse(json, callback, botId);
1357 901 return;
@@ -1381,9 +925,8 @@
1381 925 }
1382 926
1383 927 processStream();
1384 928 }).catch(streamError => {
1385 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1386 929 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1387 930 callMxChat(message, callback, botId);
1388 931 });
1389 932 }
@@ -1390,9 +933,8 @@
1390 933
1391 934 processStream();
1392 935 })
1393 936 .catch(error => {
1394 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1395 937 // Check if we have server error data with chat mode
1396 938 if (error && error.isServerError && error.data) {
1397 939 // Check for chat mode in error data
1398 940 if (error.data.chat_mode) {
@@ -1405,9 +947,8 @@
1405 947 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1406 948 callMxChat(message, callback, botId);
1407 949 }
1408 950 });
1409 - }); // refreshNonceIfNeeded
1410 951 }
1411 952
1412 953 // Helper function to handle non-streaming responses
1413 954 function handleNonStreamResponse(data, callback, botId) {
@@ -1446,16 +987,21 @@
1446 987 errorMessage = "An error occurred. Please try again or contact support.";
1447 988 }
1448 989
1449 990 // Handle session reset action (IP changed, session expired, etc.)
1450 - // Silent reset — keep chat UI intact, just get a new session and retry
1451 991 if (data.data && data.data.action === 'reset_session') {
1452 - MxChatInstances.silentResetSession(botId);
1453 - // Re-send the original message with the new session (user message is already displayed)
992 + // Clear the old session and generate a new one
993 + resetChatSession(botId);
994 + // Re-send the original message with the new session
1454 995 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1455 996 if (originalMessage) {
1456 997 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1457 - var currentModel = mxchatChat.model || 'gpt-5.6-sol';
998 + // Re-add the user message and thinking indicator
999 + appendMessage("user", originalMessage, '', [], false, botId);
1000 + appendThinkingMessage(botId);
1001 + scrollToBottom(botId);
1002 + // Determine whether to use streaming
1003 + const currentModel = mxchatChat.model || 'gpt-4o';
1458 1004 if (shouldUseStreaming(currentModel)) {
1459 1005 callMxChatStream(originalMessage, callback, botId);
1460 1006 } else {
1461 1007 callMxChat(originalMessage, callback, botId);
@@ -1477,22 +1023,8 @@
1477 1023 }
1478 1024 return; // Exit early for errors
1479 1025 }
1480 1026
1481 - // Check for live agent response
1482 - if (data.success && data.data && data.data.status === 'waiting_for_agent') {
1483 - removeThinkingDots(botId);
1484 - // Also remove any leftover bot-message that lost its temporary-message class
1485 - var $chatBox = getElement(botId, 'chat-box');
1486 - $chatBox.find('.bot-message .thinking-dots').closest('.bot-message').remove();
1487 - updateChatModeIndicator('agent', botId);
1488 - enableChatInput(botId);
1489 - if (callback) {
1490 - callback('');
1491 - }
1492 - return;
1493 - }
1494 -
1495 1027 // Handle different response formats
1496 1028 if (data.text || data.html || data.message) {
1497 1029
1498 1030 // Apply response hooks
@@ -1537,15 +1069,19 @@
1537 1069 }
1538 1070
1539 1071 // Enhanced updateChatModeIndicator function for immediate DOM updates
1540 1072 function updateChatModeIndicator(mode, botId) {
1073 + console.log('[MxChat] updateChatModeIndicator called with mode:', mode, 'botId:', botId);
1541 1074 botId = botId || 'default';
1542 1075 const indicator = getElementDOM(botId, 'chat-mode-indicator');
1076 + console.log('[MxChat] chat-mode-indicator element found:', !!indicator);
1543 1077 if (indicator) {
1544 1078 const oldText = indicator.textContent;
1079 + console.log('[MxChat] Current indicator text:', oldText, '-> changing to mode:', mode);
1545 1080
1546 1081 if (mode === 'agent') {
1547 1082 indicator.textContent = 'Live Agent';
1083 + console.log('[MxChat] Mode is agent, calling startPolling...');
1548 1084 startPolling(botId);
1549 1085 } else {
1550 1086 // Everything else is AI mode
1551 1087 const customAiText = indicator.getAttribute('data-ai-text') || 'AI Agent';
@@ -1616,21 +1152,9 @@
1616 1152 // Update the event handlers to use the correct function names (using event delegation)
1617 1153 // Use class-based selectors for multi-instance support
1618 1154 $(document).on('click', '.send-button', function() {
1619 1155 var botId = getBotIdFromElement(this);
1620 - // While a response is streaming the button is a Stop control.
1621 - if (this.classList.contains('mxchat-stop-mode')) {
1622 - // Same click that just started this stream (an add-on's direct handler
1623 - // ran before this delegated one) — not a Stop press. See
1624 - // mxchatShowStopButton for the full story (plan-4bba64).
1625 - if (this.__mxchatStopJustShown) return;
1626 - mxchatStopStreaming(botId);
1627 - return;
1628 - }
1629 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1630 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1631 - disableChatInput(botId);
1632 - }
1156 + disableChatInput(botId);
1633 1157 sendMessage(botId);
1634 1158 });
1635 1159
1636 1160 // Override enter key handler (using event delegation)
@@ -1637,378 +1161,14 @@
1637 1161 $(document).on('keypress', '.chat-input', function(e) {
1638 1162 if (e.which == 13 && !e.shiftKey) {
1639 1163 e.preventDefault();
1640 1164 var botId = getBotIdFromElement(this);
1641 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1642 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1643 - disableChatInput(botId);
1644 - }
1165 + disableChatInput(botId);
1645 1166 sendMessage(botId);
1646 1167 }
1647 1168 });
1648 1169
1649 -// Chat input character counter + soft limit feedback (plan 7091a2).
1650 -// Language-neutral: numbers + color only, no translatable strings. The counter
1651 -// reveals near the cap and ramps neutral -> amber -> red; an over-limit keystroke
1652 -// or trimmed paste produces a brief border-flash/shake so the maxlength cap (plan
1653 -// a3fae2) is never a silent "input jumps back". Per-bot scoped via .input-container.
1654 -function mxchatUpdateCharCounter(inputEl) {
1655 - if (!inputEl || !inputEl.closest) return;
1656 - var max = parseInt(inputEl.getAttribute('maxlength'), 10);
1657 - var container = inputEl.closest('.input-container');
1658 - if (!container || !max || max <= 0) return;
1659 - var counter = container.querySelector('.mxchat-char-counter');
1660 - if (!counter) return;
1661 - var len = inputEl.value.length;
1662 - var ratio = len / max;
1663 - var nearThreshold = 0.8; // start surfacing the counter at 80% of the cap
1664 - var cur = counter.querySelector('.mxchat-char-counter-current');
1665 - if (cur) cur.textContent = len;
1666 - var warn = ratio >= nearThreshold && len < max;
1667 - var full = len >= max;
1668 - counter.classList.toggle('is-visible', ratio >= nearThreshold);
1669 - counter.classList.toggle('is-warn', warn);
1670 - counter.classList.toggle('is-full', full);
1671 - container.classList.toggle('mxchat-input-near-limit', warn);
1672 - container.classList.toggle('mxchat-input-at-limit', full);
1673 -}
1674 -
1675 -function mxchatBumpInput(inputEl) {
1676 - var container = inputEl && inputEl.closest ? inputEl.closest('.input-container') : null;
1677 - if (!container) return;
1678 - container.classList.remove('mxchat-input-bump');
1679 - void container.offsetWidth; // reflow so a rapid second hit retriggers the animation
1680 - container.classList.add('mxchat-input-bump');
1681 - clearTimeout($(container).data('mxchatBumpTimeout'));
1682 - var t = setTimeout(function() { container.classList.remove('mxchat-input-bump'); }, 220);
1683 - $(container).data('mxchatBumpTimeout', t);
1684 -}
1685 -
1686 -// Live counter update on every input.
1687 -$(document).on('input', '.chat-input', function() {
1688 - mxchatUpdateCharCounter(this);
1689 -});
1690 -
1691 -// Visible "you've hit the edge" feedback when a printable keystroke is about to be
1692 -// rejected at the cap (maxlength silently swallows it otherwise).
1693 -$(document).on('keydown', '.chat-input', function(e) {
1694 - var max = parseInt(this.getAttribute('maxlength'), 10);
1695 - if (!max || max <= 0 || this.value.length < max) return;
1696 - if (e.ctrlKey || e.metaKey || e.altKey) return;
1697 - // A single printable char with no selection to overwrite WILL be rejected.
1698 - if (e.key && e.key.length === 1 && this.selectionStart === this.selectionEnd) {
1699 - mxchatBumpInput(this);
1700 - }
1701 -});
1702 -
1703 -// A paste that gets trimmed to the cap also bumps, so truncation is never silent.
1704 -$(document).on('paste', '.chat-input', function() {
1705 - var el = this;
1706 - var max = parseInt(el.getAttribute('maxlength'), 10);
1707 - if (!max || max <= 0) return;
1708 - setTimeout(function() {
1709 - mxchatUpdateCharCounter(el);
1710 - if (el.value.length >= max) mxchatBumpInput(el);
1711 - }, 0);
1712 -});
1713 -
1714 -// Builds the list of overflow-menu items for a given bot.
1715 -// Adding a future item is one push to this array — do NOT hardcode "only download."
1716 -function mxchatGetHeaderMenuItems(botId) {
1717 - var items = [];
1718 - var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1719 -
1720 - // The `print_button_*` keys still gate this item for back-compat with
1721 - // existing user options. The action is now a transcript download, not print.
1722 - if (settings.print_button_enabled === 'on') {
1723 - items.push({
1724 - id: 'download-transcript',
1725 - label: settings.print_button_label || 'Download Transcript',
1726 - 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>',
1727 - action: function() {
1728 - mxchatDownloadTranscript(botId);
1729 - }
1730 - });
1731 - }
1732 -
1733 - // "Start new chat" — surfaces the EXISTING per-conversation reset
1734 - // (MxChatInstances.resetChatSession) so a visitor can start a fresh thread
1735 - // without the site owner disabling chat persistence globally. Default OFF;
1736 - // gated by the reset_chat_enabled option. plan ac2e81.
1737 - if (settings.reset_chat_enabled === 'on') {
1738 - items.push({
1739 - id: 'reset-chat',
1740 - label: settings.reset_chat_label || 'Start new chat',
1741 - 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>',
1742 - action: function() {
1743 - var confirmMsg = settings.reset_chat_confirm || 'Start a new chat? This clears the current conversation.';
1744 - if (window.confirm(confirmMsg)) {
1745 - MxChatInstances.resetChatSession(botId);
1746 - }
1747 - }
1748 - });
1749 - }
1750 -
1751 - return items;
1752 -}
1753 -
1754 -// Builds a clean markdown transcript of the current conversation and triggers
1755 -// a file download. Used by the "Download Transcript" menu item.
1756 -function mxchatDownloadTranscript(botId) {
1757 - var $chatBox = getElement(botId, 'chat-box');
1758 - if (!$chatBox || !$chatBox.length) return;
1759 -
1760 - var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1761 - var headerTitle = settings.print_header_title || 'Chat transcript';
1762 - var now = new Date();
1763 - var stamp = now.toLocaleString();
1764 -
1765 - var lines = [];
1766 - lines.push('# ' + headerTitle);
1767 - lines.push('');
1768 - lines.push('Exported: ' + stamp);
1769 - lines.push('');
1770 - lines.push('---');
1771 - lines.push('');
1772 -
1773 - $chatBox.find('.user-message, .bot-message, .agent-message').each(function() {
1774 - var $msg = $(this);
1775 - // Skip thinking placeholders and any in-flight temporary messages.
1776 - if ($msg.find('.thinking-dots').length) return;
1777 - if ($msg.hasClass('temporary-message')) return;
1778 -
1779 - var sender;
1780 - if ($msg.hasClass('user-message')) sender = 'User';
1781 - else if ($msg.hasClass('agent-message')) sender = 'Live Agent';
1782 - else sender = 'AI Agent';
1783 -
1784 - // Strip interactive UI from the cloned message so we get the conversation text.
1785 - var $clone = $msg.clone();
1786 - $clone.find('.copy-button, .message-toolbar, .mxchat-copy, button, script, style').remove();
1787 - var text = $clone.text().replace(/ /g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
1788 - if (!text) return;
1789 -
1790 - lines.push('**' + sender + '**');
1791 - lines.push('');
1792 - lines.push(text);
1793 - lines.push('');
1794 - });
1795 -
1796 - var content = lines.join('\n');
1797 - var iso = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
1798 - var fname = 'mxchat-transcript-' + iso + '.md';
1799 - var blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
1800 - var url = URL.createObjectURL(blob);
1801 - var a = document.createElement('a');
1802 - a.href = url;
1803 - a.download = fname;
1804 - a.style.display = 'none';
1805 - document.body.appendChild(a);
1806 - a.click();
1807 - setTimeout(function() {
1808 - if (a.parentNode) a.parentNode.removeChild(a);
1809 - URL.revokeObjectURL(url);
1810 - }, 100);
1811 -}
1812 -
1813 -// Reads the bot bubble's actual computed bg+fg and writes them as CSS vars
1814 -// on the menu wrap, so the dropdown matches whatever paints the bubble —
1815 -// saved options, AI theme CSS, or the mxchat-theme add-on.
1816 -function mxchatSyncMenuColors(botId, $wrap) {
1817 - if (!$wrap || !$wrap.length) return;
1818 - var $bot = $wrap.closest('.mxchat-chatbot-wrapper').find('.bot-message').not('.temporary-message').first();
1819 - if (!$bot.length) return;
1820 - var cs = window.getComputedStyle($bot[0]);
1821 - if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') {
1822 - $wrap[0].style.setProperty('--mxchat-menu-bg', cs.backgroundColor);
1823 - }
1824 - // Bot text color usually lives on a child div, not .bot-message itself.
1825 - var $textChild = $bot.find('[style*="color"]').first();
1826 - var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
1827 - if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
1828 -}
1829 -
1830 -// Renders (or re-renders) the item list for one menu wrap. Split out of
1831 -// mxchatInitHeaderMenu so the dynamic-settings merge (plan-32db95) can
1832 -// rebuild items + trigger visibility WITHOUT re-binding the one-time
1833 -// open/close/keyboard wiring. closeMenu is passed in by the init closure;
1834 -// a rebuild before init (never happens, but harmless) just skips it.
1835 -function mxchatRenderHeaderMenuItems(botId, $wrap, closeMenuFn) {
1836 - var $trigger = $wrap.find('.mxchat-menu-trigger');
1837 - var $menu = $wrap.find('.mxchat-header-menu');
1838 - var items = mxchatGetHeaderMenuItems(botId);
1839 -
1840 - $menu.empty();
1841 -
1842 - if (!items.length) {
1843 - $trigger.hide();
1844 - $menu.hide();
1845 - return;
1846 - }
1847 -
1848 - // Clear any inline display:none a previous zero-item render left behind —
1849 - // open/close visibility is governed by the hidden prop + is-open class.
1850 - $trigger.css('display', '');
1851 - $menu.css('display', '');
1852 -
1853 - items.forEach(function(item, idx) {
1854 - var $btn = $('<button>', {
1855 - type: 'button',
1856 - 'class': 'mxchat-menu-item',
1857 - 'role': 'menuitem',
1858 - 'tabindex': '-1',
1859 - 'data-menu-id': item.id,
1860 - html: '<span class="mxchat-menu-item-icon">' + item.icon + '</span>' +
1861 - '<span class="mxchat-menu-item-label"></span>'
1862 - });
1863 - $btn.find('.mxchat-menu-item-label').text(item.label);
1864 - $btn.on('click', function(e) {
1865 - e.preventDefault();
1866 - e.stopPropagation();
1867 - if (closeMenuFn) closeMenuFn();
1868 - try { item.action(); } catch (err) { /* no-op */ }
1869 - });
1870 - $menu.append($btn);
1871 - });
1872 -}
1873 -
1874 -// Re-render every menu on the page after a dynamic-settings merge
1875 -// (multi-bot: each wrap re-reads its items). An OPEN menu is left alone —
1876 -// swapping items under the user mid-interaction yanks focus — and the
1877 -// rebuild runs when it closes instead (closeMenu checks the pending flag).
1878 -function mxchatRebuildHeaderMenus() {
1879 - $('.mxchat-header-menu-wrap').each(function() {
1880 - var $wrap = $(this);
1881 - var botId = $wrap.data('bot-id');
1882 - if (!botId) return;
1883 - if (!$wrap.data('mxchatMenuReady')) {
1884 - mxchatInitHeaderMenu(botId);
1885 - return;
1886 - }
1887 - if ($wrap.find('.mxchat-header-menu').hasClass('is-open')) {
1888 - $wrap.data('mxchatMenuRebuildPending', true);
1889 - return;
1890 - }
1891 - mxchatRenderHeaderMenuItems(botId, $wrap, $wrap.data('mxchatMenuClose'));
1892 - });
1893 -}
1894 -
1895 -// One-time per-widget init: renders menu items, wires open/close,
1896 -// outside-click, Escape, and arrow-key navigation. If no items, hides the
1897 -// trigger. Wiring happens even when there are zero items at init, so a
1898 -// later dynamic-settings rebuild that adds items has a working trigger.
1899 -function mxchatInitHeaderMenu(botId) {
1900 - var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
1901 - if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
1902 -
1903 - var $trigger = $wrap.find('.mxchat-menu-trigger');
1904 - var $menu = $wrap.find('.mxchat-header-menu');
1905 -
1906 - // Initial color sync — covers normal page load.
1907 - mxchatSyncMenuColors(botId, $wrap);
1908 -
1909 - function openMenu() {
1910 - // Re-sync each open in case the active theme changed since init.
1911 - mxchatSyncMenuColors(botId, $wrap);
1912 - $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
1913 - $trigger.attr('aria-expanded', 'true');
1914 - // Focus the first item for keyboard users
1915 - setTimeout(function() {
1916 - $menu.find('.mxchat-menu-item').first().attr('tabindex', '0').trigger('focus');
1917 - }, 0);
1918 - }
1919 - function closeMenu(returnFocus) {
1920 - $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
1921 - $trigger.attr('aria-expanded', 'false');
1922 - $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
1923 - if (returnFocus) $trigger.trigger('focus');
1924 - // A dynamic-settings rebuild that arrived while the menu was open
1925 - // was deferred (mxchatRebuildHeaderMenus) — run it now.
1926 - if ($wrap.data('mxchatMenuRebuildPending')) {
1927 - $wrap.removeData('mxchatMenuRebuildPending');
1928 - mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
1929 - }
1930 - }
1931 -
1932 - // Toggle on trigger click — stop propagation so the .chatbot-top-bar
1933 - // click-to-collapse handler does not fire.
1934 - $trigger.on('click', function(e) {
1935 - e.preventDefault();
1936 - e.stopPropagation();
1937 - if ($menu.hasClass('is-open')) closeMenu();
1938 - else openMenu();
1939 - });
1940 -
1941 - // Don't let clicks inside the menu bubble to the top-bar collapse handler.
1942 - $menu.on('click', function(e) {
1943 - e.stopPropagation();
1944 - });
1945 -
1946 - // Outside click closes the menu.
1947 - $(document).on('click.mxchatMenu-' + botId, function(e) {
1948 - if (!$menu.hasClass('is-open')) return;
1949 - if ($wrap.has(e.target).length || $wrap.is(e.target)) return;
1950 - closeMenu();
1951 - });
1952 -
1953 - // Keyboard: Escape closes and returns focus; arrow keys move focus; Enter activates.
1954 - $menu.on('keydown', '.mxchat-menu-item', function(e) {
1955 - var $items = $menu.find('.mxchat-menu-item');
1956 - var idx = $items.index(this);
1957 - if (e.key === 'Escape') {
1958 - e.preventDefault();
1959 - closeMenu(true);
1960 - } else if (e.key === 'ArrowDown') {
1961 - e.preventDefault();
1962 - var $next = $items.eq((idx + 1) % $items.length);
1963 - $items.attr('tabindex', '-1');
1964 - $next.attr('tabindex', '0').trigger('focus');
1965 - } else if (e.key === 'ArrowUp') {
1966 - e.preventDefault();
1967 - var $prev = $items.eq((idx - 1 + $items.length) % $items.length);
1968 - $items.attr('tabindex', '-1');
1969 - $prev.attr('tabindex', '0').trigger('focus');
1970 - } else if (e.key === 'Enter' || e.key === ' ') {
1971 - e.preventDefault();
1972 - $(this).trigger('click');
1973 - }
1974 - });
1975 - $trigger.on('keydown', function(e) {
1976 - if (e.key === 'Escape' && $menu.hasClass('is-open')) {
1977 - e.preventDefault();
1978 - closeMenu(true);
1979 - } else if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !$menu.hasClass('is-open')) {
1980 - e.preventDefault();
1981 - openMenu();
1982 - }
1983 - });
1984 -
1985 - // Expose closeMenu for out-of-closure re-renders (mxchatRebuildHeaderMenus),
1986 - // then do the initial item render.
1987 - $wrap.data('mxchatMenuClose', closeMenu);
1988 - mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
1989 -
1990 - $wrap.data('mxchatMenuReady', true);
1991 -}
1992 -
1993 -// Initialize header menus for every rendered widget on DOM ready.
1994 -$(function() {
1995 - $('.mxchat-header-menu-wrap').each(function() {
1996 - var botId = $(this).data('bot-id');
1997 - if (botId) mxchatInitHeaderMenu(botId);
1998 - });
1999 -
2000 - // Embedded (non-floating) widgets are open from the moment the page
2001 - // renders — refresh dynamic settings at init (plan-32db95). Floating
2002 - // widgets refresh on first launcher open instead.
2003 - var hasEmbeddedWidget = $('.mxchat-chatbot-wrapper').filter(function() {
2004 - return !$(this).closest('.floating-chatbot').length;
2005 - }).length > 0;
2006 - if (hasEmbeddedWidget) {
2007 - mxchatRefreshDynamicSettings();
2008 - }
2009 -});
2010 -
1170 +
2011 1171 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
2012 1172 try {
2013 1173 // Determine styles based on sender type
2014 1174 let messageClass, bgColor, fontColor;
@@ -2046,12 +1206,17 @@
2046 1206 'margin-bottom': '1em'
2047 1207 });
2048 1208 }
2049 1209
2050 - // Process the message content - always run linkify to convert markdown
2051 - // links and format text. linkify() handles existing HTML safely via
2052 - // negative lookaheads that skip URLs already inside <a> tags.
2053 - let fullMessage = linkify(messageText);
1210 + // Process the message content based on sender
1211 + let fullMessage;
1212 + if (sender === "user") {
1213 + // For user messages, apply linkify after sanitization
1214 + fullMessage = linkify(messageText);
1215 + } else {
1216 + // For bot/agent messages, preserve HTML
1217 + fullMessage = messageText;
1218 + }
2054 1219
2055 1220 // Add images if provided
2056 1221 if (images && images.length > 0) {
2057 1222 fullMessage += '<div class="image-gallery" dir="auto">';
@@ -2100,12 +1265,8 @@
2100 1265 if (lastUserMessage.length) {
2101 1266 scrollElementToTop(lastUserMessage, botId);
2102 1267 }
2103 1268 }
2104 -
2105 - if ((sender === "bot" || sender === "agent") && !isTemporary) {
2106 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2107 - }
2108 1269 });
2109 1270
2110 1271 if (messageText.id) {
2111 1272 var instance = MxChatInstances.get(botId);
@@ -2190,12 +1351,26 @@
2190 1351 bgColor = botMessageBgColor;
2191 1352 fontColor = botMessageFontColor;
2192 1353 }
2193 1354
2194 - // Always run linkify to convert markdown links and format text.
2195 - // linkify() already handles existing HTML (its URL patterns use negative lookaheads
2196 - // to avoid double-processing URLs that are already inside <a> tags).
2197 - var fullMessage = linkify(responseText);
1355 + // FIXED: Only linkify if response doesn't already contain HTML links or tags
1356 + // This prevents double-processing of URLs that are already formatted as HTML
1357 + var fullMessage;
1358 + if (sender === "user") {
1359 + // Always linkify user messages (they're plain text)
1360 + fullMessage = linkify(responseText);
1361 + } else {
1362 + // For bot/agent messages, check if HTML already exists
1363 + if (responseText.includes('<a href=') || responseText.includes('</a>') ||
1364 + responseText.includes('<img') || responseText.includes('<div') ||
1365 + responseText.includes('<p>') || responseText.includes('<br>')) {
1366 + // Response already has HTML, don't process it
1367 + fullMessage = responseText;
1368 + } else {
1369 + // Plain text response, apply linkify
1370 + fullMessage = linkify(responseText);
1371 + }
1372 + }
2198 1373
2199 1374 if (responseHtml) {
2200 1375 // Only add line breaks if there's actual text content before the HTML
2201 1376 if (fullMessage && fullMessage.trim()) {
@@ -2252,12 +1427,8 @@
2252 1427 }
2253 1428
2254 1429 // Re-enable chat input after response is displayed
2255 1430 enableChatInput(botId);
2256 -
2257 - if (sender === "bot" || sender === "agent") {
2258 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2259 - }
2260 1431 } else {
2261 1432 appendMessage(sender, responseText, responseHtml, images, false, botId);
2262 1433 // Re-enable chat input after response is displayed
2263 1434 enableChatInput(botId);
@@ -2266,15 +1437,8 @@
2266 1437
2267 1438
2268 1439 function appendThinkingMessage(botId) {
2269 1440 botId = botId || 'default';
2270 -
2271 - // Don't show thinking dots in live agent mode - message is just forwarded to a human
2272 - var indicator = getElementDOM(botId, 'chat-mode-indicator');
2273 - if (indicator && indicator.textContent === 'Live Agent') {
2274 - return;
2275 - }
2276 -
2277 1441 var $chatBox = getElement(botId, 'chat-box');
2278 1442
2279 1443 // Remove any existing thinking dots in this bot's chat first
2280 1444 $chatBox.find('.thinking-dots').remove();
@@ -2296,9 +1460,9 @@
2296 1460 '</div>' +
2297 1461 '</div>';
2298 1462
2299 1463 // Append the thinking dots to this bot's chat container - skip inline colors if AI theme is active
2300 - var messageStyle = skipColors ? '' : ' style="background-color: ' + botMessageBgColor + '; color: ' + botMessageFontColor + ';"';
1464 + var messageStyle = skipColors ? '' : ' style="background-color: ' + botMessageBgColor + ';"';
2301 1465 $chatBox.append('<div class="bot-message temporary-message"' + messageStyle + '>' + thinkingHtml + '</div>');
2302 1466 scrollToBottom(botId);
2303 1467 }
2304 1468
@@ -2304,11 +1468,9 @@
2304 1468
2305 1469 function removeThinkingDots(botId) {
2306 1470 botId = botId || 'default';
2307 1471 var $chatBox = getElement(botId, 'chat-box');
2308 - // Remove by temporary-message class first, then fall back to any bot-message containing thinking dots
2309 1472 $chatBox.find('.thinking-dots').closest('.temporary-message').remove();
2310 - $chatBox.find('.bot-message .thinking-dots').closest('.bot-message').remove();
2311 1473 }
2312 1474
2313 1475 // ====================================
2314 1476 // TEXT FORMATTING & PROCESSING
@@ -2342,12 +1504,9 @@
2342 1504 processedText = formatTextStyling(processedText);
2343 1505
2344 1506 // Process code blocks BEFORE processing links
2345 1507 processedText = formatCodeBlocks(processedText);
2346 -
2347 - // Process markdown tables BEFORE converting newlines to paragraphs
2348 - processedText = formatMarkdownTables(processedText);
2349 -
1508 +
2350 1509 // NOW convert to paragraphs
2351 1510 processedText = convertNewlinesToBreaks(processedText);
2352 1511
2353 1512 // IMPORTANT: Handle citation-style brackets FIRST [URL]
@@ -2360,63 +1519,37 @@
2360 1519 // Return as a proper link without the brackets
2361 1520 return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2362 1521 });
2363 1522
2364 - // Process markdown links: [text](url) and [](url)
2365 - // Uses balanced parenthesis matching to handle URLs containing parens
2366 - // (e.g. PDF filenames with dates like (2025-08-28).pdf)
2367 - processedText = (function(input) {
2368 - var result = '';
2369 - var i = 0;
2370 - while (i < input.length) {
2371 - // Look for [ at current position
2372 - if (input[i] === '[') {
2373 - // Find closing ]
2374 - var closeBracket = input.indexOf(']', i + 1);
2375 - if (closeBracket === -1 || closeBracket + 1 >= input.length || input[closeBracket + 1] !== '(') {
2376 - result += input[i];
2377 - i++;
2378 - continue;
2379 - }
2380 - var linkText = input.substring(i + 1, closeBracket);
2381 - // Check if URL starts with http
2382 - var urlStart = closeBracket + 2;
2383 - if (!input.substring(urlStart).match(/^https?:\/\//)) {
2384 - result += input[i];
2385 - i++;
2386 - continue;
2387 - }
2388 - // Find balanced closing paren
2389 - var depth = 1;
2390 - var j = urlStart;
2391 - while (j < input.length && depth > 0) {
2392 - if (input[j] === '(') depth++;
2393 - else if (input[j] === ')') depth--;
2394 - if (depth > 0) j++;
2395 - }
2396 - if (depth !== 0) {
2397 - result += input[i];
2398 - i++;
2399 - continue;
2400 - }
2401 - var url = input.substring(urlStart, j);
2402 - var cleanUrl = url.replace(/[\].,;!?]+$/, '');
2403 - var encodedUrl = safeEncodeUrl(cleanUrl);
2404 - if (!linkText || !linkText.trim()) {
2405 - result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + cleanUrl + '</a>';
2406 - } else {
2407 - var safeText = sanitizeUserInput(linkText);
2408 - result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + safeText + '</a>';
2409 - }
2410 - i = j + 1; // Skip past the closing )
2411 - } else {
2412 - result += input[i];
2413 - i++;
2414 - }
1523 + // Process proper markdown links with text: [text](url)
1524 + // This MUST have non-empty text in the first brackets
1525 + const markdownLinkPattern = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
1526 + processedText = processedText.replace(markdownLinkPattern, (match, text, url) => {
1527 + // Make sure we have actual text (not just whitespace)
1528 + if (!text || !text.trim()) {
1529 + // If no text, treat the URL as the text
1530 + let cleanUrl = url.replace(/[.,;!?]+$/, '');
1531 + const safeUrl = safeEncodeUrl(cleanUrl);
1532 + return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2415 1533 }
2416 - return result;
2417 - })(processedText);
1534 +
1535 + // Clean the URL
1536 + let cleanUrl = url.replace(/[\].,;!?]+$/, '');
1537 + const safeUrl = safeEncodeUrl(cleanUrl);
1538 + const safeText = sanitizeUserInput(text);
1539 + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
1540 + });
2418 1541
1542 + // Handle empty markdown links: [](url)
1543 + // This is a specific case where there's no text
1544 + const emptyMarkdownPattern = /\[\]\((https?:\/\/[^\s)]+)\)/g;
1545 + processedText = processedText.replace(emptyMarkdownPattern, (match, url) => {
1546 + let cleanUrl = url.replace(/[.,;!?]+$/, '');
1547 + const safeUrl = safeEncodeUrl(cleanUrl);
1548 + // Use the URL itself as the link text
1549 + return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
1550 + });
1551 +
2419 1552 // Process phone numbers: [text](tel:number)
2420 1553 const phonePattern = /\[([^\]]+)\]\((tel:[\d+\-\s()]+)\)/g;
2421 1554 processedText = processedText.replace(phonePattern, (match, text, phone) => {
2422 1555 const safePhone = safeEncodeUrl(phone);
@@ -2568,78 +1701,9 @@
2568 1701 });
2569 1702
2570 1703 return text;
2571 1704 }
2572 -
2573 - function formatMarkdownTables(text) {
2574 - var lines = text.split('\n');
2575 - var result = [];
2576 - var i = 0;
2577 -
2578 - while (i < lines.length) {
2579 - // Check for a table: current line has pipes AND next line is a separator row
2580 - if (i + 1 < lines.length &&
2581 - lines[i].indexOf('|') !== -1 &&
2582 - /^\s*\|?[\s\-:]+(\|[\s\-:]+)+\|?\s*$/.test(lines[i + 1])) {
2583 -
2584 - var tableLines = [];
2585 - var headerLine = lines[i];
2586 - var separatorLine = lines[i + 1];
2587 - tableLines.push(headerLine);
2588 - tableLines.push(separatorLine);
2589 -
2590 - // Collect remaining table rows
2591 - var j = i + 2;
2592 - while (j < lines.length && lines[j].indexOf('|') !== -1 && lines[j].trim() !== '') {
2593 - tableLines.push(lines[j]);
2594 - j++;
2595 - }
2596 -
2597 - // Parse alignment from separator row
2598 - var sepCells = separatorLine.split('|').filter(function(c) { return c.trim() !== ''; });
2599 - var alignments = sepCells.map(function(cell) {
2600 - var trimmed = cell.trim();
2601 - if (trimmed.charAt(0) === ':' && trimmed.charAt(trimmed.length - 1) === ':') return 'center';
2602 - if (trimmed.charAt(trimmed.length - 1) === ':') return 'right';
2603 - return 'left';
2604 - });
2605 -
2606 - // Build HTML table
2607 - var html = '<div class="mxchat-table-wrapper"><table class="mxchat-table">';
2608 -
2609 - // Header row
2610 - var headerCells = tableLines[0].split('|').filter(function(c) { return c.trim() !== ''; });
2611 - html += '<thead><tr>';
2612 - headerCells.forEach(function(cell, idx) {
2613 - var align = alignments[idx] || 'left';
2614 - html += '<th style="text-align:' + align + '">' + cell.trim() + '</th>';
2615 - });
2616 - html += '</tr></thead>';
2617 -
2618 - // Body rows
2619 - html += '<tbody>';
2620 - for (var r = 2; r < tableLines.length; r++) {
2621 - var rowCells = tableLines[r].split('|').filter(function(c) { return c.trim() !== ''; });
2622 - html += '<tr>';
2623 - rowCells.forEach(function(cell, idx) {
2624 - var align = alignments[idx] || 'left';
2625 - html += '<td style="text-align:' + align + '">' + cell.trim() + '</td>';
2626 - });
2627 - html += '</tr>';
2628 - }
2629 - html += '</tbody></table></div>';
2630 -
2631 - result.push(html);
2632 - i = j;
2633 - } else {
2634 - result.push(lines[i]);
2635 - i++;
2636 - }
2637 - }
2638 -
2639 - return result.join('\n');
2640 - }
2641 -
1705 +
2642 1706 function sanitizeUserInput(text) {
2643 1707 const div = document.createElement('div');
2644 1708 div.textContent = text;
2645 1709 return div.innerHTML;
@@ -2710,14 +1774,13 @@
2710 1774 requestAnimationFrame(smoothScroll);
2711 1775 }
2712 1776 }
2713 1777
2714 - function scrollElementToTop(element, botId, topOffset) {
1778 + function scrollElementToTop(element, botId) {
2715 1779 botId = botId || 'default';
2716 - topOffset = (typeof topOffset === 'number') ? topOffset : 2;
2717 1780 var chatBox = getElement(botId, 'chat-box');
2718 1781 var elementTop = element.position().top + chatBox.scrollTop();
2719 - chatBox.animate({ scrollTop: Math.max(0, elementTop - topOffset) }, 500);
1782 + chatBox.animate({ scrollTop: elementTop }, 500);
2720 1783 }
2721 1784
2722 1785 function showChatWidget(botId) {
2723 1786 botId = botId || 'default';
@@ -2861,12 +1924,15 @@
2861 1924 // LIVE AGENT FUNCTIONALITY
2862 1925 // ====================================
2863 1926
2864 1927 function startPolling(botId) {
1928 + console.log('[MxChat] startPolling called for botId:', botId);
2865 1929 botId = botId || 'default';
2866 1930 var instance = MxChatInstances.get(botId);
2867 1931 // Clear any existing interval first
2868 1932 stopPolling(botId);
1933 + // Start new polling interval
1934 + console.log('[MxChat] Starting polling interval (5s) for botId:', botId);
2869 1935 instance.pollingInterval = setInterval(function() {
2870 1936 checkForAgentMessages(botId);
2871 1937 }, 5000);
2872 1938 }
@@ -2871,17 +1937,20 @@
2871 1937 }, 5000);
2872 1938 }
2873 1939
2874 1940 function stopPolling(botId) {
1941 + console.log('[MxChat] stopPolling called for botId:', botId);
2875 1942 botId = botId || 'default';
2876 1943 var instance = MxChatInstances.get(botId);
2877 1944 if (instance.pollingInterval) {
2878 1945 clearInterval(instance.pollingInterval);
2879 1946 instance.pollingInterval = null;
1947 + console.log('[MxChat] Polling stopped for botId:', botId);
2880 1948 }
2881 1949 }
2882 1950
2883 1951 function checkForAgentMessages(botId) {
1952 + console.log('[MxChat] checkForAgentMessages called for botId:', botId);
2884 1953 botId = botId || 'default';
2885 1954 var instance = MxChatInstances.get(botId);
2886 1955 const sessionId = getChatSession(botId);
2887 1956 $.ajax({
@@ -2907,12 +1976,8 @@
2907 1976 instance.processedMessageIds.add(message.id);
2908 1977 }
2909 1978 });
2910 1979
2911 - if (hasNewMessage) {
2912 - enableChatInput(botId);
2913 - }
2914 -
2915 1980 var $floatingChatbot = getElement(botId, 'floating-chatbot');
2916 1981 if (hasNewMessage && $floatingChatbot.hasClass('hidden')) {
2917 1982 showNotification(botId);
2918 1983 }
@@ -2918,13 +1983,8 @@
2918 1983 }
2919 1984
2920 1985 scrollToBottom(botId, true);
2921 1986 }
2922 -
2923 - // Handle chat mode transitions (e.g. agent ended chat via !endchat)
2924 - if (response.success && response.data?.chat_mode) {
2925 - updateChatModeIndicator(response.data.chat_mode, botId);
2926 - }
2927 1987 },
2928 1988 error: function (xhr, status, error) {
2929 1989 // Polling error - silently continue
2930 1990 }
@@ -2934,29 +1994,20 @@
2934 1994 // ====================================
2935 1995 // CHAT HISTORY & PERSISTENCE
2936 1996 // ====================================
2937 1997
2938 -function loadChatHistory(botId, onComplete) {
1998 +function loadChatHistory(botId) {
2939 1999 botId = botId || 'default';
2940 2000 var instance = MxChatInstances.get(botId);
2941 2001
2942 2002 // Prevent duplicate loading
2943 2003 if (instance.chatHistoryLoaded) {
2944 - if (onComplete) onComplete();
2945 2004 return;
2946 2005 }
2947 2006
2948 - // Use getChatSession which returns null if no session exists (does NOT create one)
2949 2007 var sessionId = getChatSession(botId);
2950 2008 var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
2951 2009
2952 - // No session yet — nothing to load. History will load after first message via ensureSession.
2953 - if (!sessionId) {
2954 - instance.chatHistoryLoaded = true;
2955 - if (onComplete) onComplete();
2956 - return;
2957 - }
2958 -
2959 2010 if (chatPersistenceEnabled && sessionId) {
2960 2011 $.ajax({
2961 2012 url: mxchatChat.ajax_url,
2962 2013 type: 'POST',
@@ -2967,12 +2018,11 @@
2967 2018 },
2968 2019 success: function(response) {
2969 2020 // Handle session reset (IP changed while user was away)
2970 2021 if (response.success === false && response.data && response.data.action === 'reset_session') {
2971 - // Silent reset — new session but don't clear UI
2972 - MxChatInstances.silentResetSession(botId);
2022 + // Silently reset session - user will start fresh
2023 + resetChatSession(botId);
2973 2024 instance.chatHistoryLoaded = true; // Prevent retry loop
2974 - if (onComplete) onComplete();
2975 2025 return;
2976 2026 }
2977 2027
2978 2028 // Check if the response indicates success
@@ -3028,20 +2078,9 @@
3028 2078 var content = message.content;
3029 2079 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
3030 2080 content = decodeHTMLEntities(content);
3031 2081
3032 - // Skip linkify for messages containing structured HTML
3033 - // (forms, product cards, galleries, etc.) to avoid
3034 - // markdown formatting corrupting HTML attributes
3035 - // (e.g. underscores in name="field_name" becoming <em> tags)
3036 - if (content.includes("mxchat-product-card") ||
3037 - content.includes("mxchat-image-gallery") ||
3038 - content.includes("mxchat-featured-products") ||
3039 - content.includes("mxchat-youtube-embed") ||
3040 - content.includes("<form") ||
3041 - content.includes("<input") ||
3042 - content.includes("<select") ||
3043 - content.includes("<textarea")) {
2082 + if (content.includes("mxchat-product-card") || content.includes("mxchat-image-gallery")) {
3044 2083 messageElement.html(content);
3045 2084 } else {
3046 2085 var formattedContent = linkify(content);
3047 2086 messageElement.html(formattedContent);
@@ -3081,17 +2120,13 @@
3081 2120 instance.chatHistoryLoaded = true;
3082 2121 }
3083 2122 }
3084 2123 }
3085 - if (onComplete) onComplete();
3086 2124 },
3087 2125 error: function(xhr, status, error) {
3088 2126 // Error loading chat history - silently continue
3089 - if (onComplete) onComplete();
3090 2127 }
3091 2128 });
3092 - } else {
3093 - if (onComplete) onComplete();
3094 2129 }
3095 2130 }
3096 2131
3097 2132
@@ -3155,10 +2190,10 @@
3155 2190 .then(data => {
3156 2191 if (data.success) {
3157 2192 container.style.display = 'none';
3158 2193 nameElement.textContent = '';
3159 - instance.activePdfFile = null;
3160 - appendMessage('bot', 'PDF removed.', '', [], false, botId);
2194 + activePdfFile = null;
2195 + appendMessage('bot', 'PDF removed.');
3161 2196 }
3162 2197 })
3163 2198 .catch(error => {
3164 2199 // Error removing PDF - silently continue
@@ -3164,16 +2199,14 @@
3164 2199 // Error removing PDF - silently continue
3165 2200 });
3166 2201 }
3167 2202
3168 - function removeActiveWord(botId) {
3169 - botId = botId || 'default';
3170 - var instance = MxChatInstances.get(botId);
3171 - const container = getElementDOM(botId, 'active-word-container');
3172 - const nameElement = getElementDOM(botId, 'active-word-name');
3173 -
3174 - if (!container || !nameElement || !instance.activeWordFile) return;
3175 -
2203 + function removeActiveWord() {
2204 + const container = document.getElementById('active-word-container');
2205 + const nameElement = document.getElementById('active-word-name');
2206 +
2207 + if (!container || !nameElement || !activeWordFile) return;
2208 +
3176 2209 fetch(mxchatChat.ajax_url, {
3177 2210 method: 'POST',
3178 2211 headers: {
3179 2212 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3179,9 +2212,9 @@
3179 2212 'Content-Type': 'application/x-www-form-urlencoded',
3180 2213 },
3181 2214 body: new URLSearchParams({
3182 2215 'action': 'mxchat_remove_word',
3183 - 'session_id': getChatSession(botId),
2216 + 'session_id': sessionId,
3184 2217 'nonce': mxchatChat.nonce
3185 2218 })
3186 2219 })
3187 2220 .then(response => response.json())
@@ -3188,10 +2221,10 @@
3188 2221 .then(data => {
3189 2222 if (data.success) {
3190 2223 container.style.display = 'none';
3191 2224 nameElement.textContent = '';
3192 - instance.activeWordFile = null;
3193 - appendMessage('bot', 'Word document removed.', '', [], false, botId);
2225 + activeWordFile = null;
2226 + appendMessage('bot', 'Word document removed.');
3194 2227 }
3195 2228 })
3196 2229 .catch(error => {
3197 2230 // Error removing Word document - silently continue
@@ -3269,35 +2302,45 @@
3269 2302 // ====================================
3270 2303
3271 2304 function checkPreChatDismissal(botId) {
3272 2305 botId = botId || 'default';
3273 - try {
3274 - var dismissedAt = localStorage.getItem('mxchat_pre_chat_dismissed_' + botId);
3275 - if (dismissedAt) {
3276 - // Re-show after 24 hours
3277 - var elapsed = Date.now() - parseInt(dismissedAt, 10);
3278 - if (elapsed < 86400000) {
2306 + $.ajax({
2307 + url: mxchatChat.ajax_url,
2308 + type: 'POST',
2309 + data: {
2310 + action: 'mxchat_check_pre_chat_message_status',
2311 + _ajax_nonce: mxchatChat.nonce
2312 + },
2313 + success: function(response) {
2314 + if (response.success && !response.data.dismissed) {
2315 + getElement(botId, 'pre-chat-message').fadeIn(250);
2316 + } else {
3279 2317 getElement(botId, 'pre-chat-message').hide();
3280 - return;
3281 2318 }
3282 - // Expired — clear and show again
3283 - localStorage.removeItem('mxchat_pre_chat_dismissed_' + botId);
2319 + },
2320 + error: function() {
2321 + // Error checking pre-chat dismissal - silently continue
3284 2322 }
3285 - getElement(botId, 'pre-chat-message').fadeIn(250);
3286 - } catch (e) {
3287 - // localStorage unavailable — show the message
3288 - getElement(botId, 'pre-chat-message').fadeIn(250);
3289 - }
2323 + });
3290 2324 }
3291 2325
3292 2326 function handlePreChatDismissal(botId) {
3293 2327 botId = botId || 'default';
3294 2328 getElement(botId, 'pre-chat-message').fadeOut(200);
3295 - try {
3296 - localStorage.setItem('mxchat_pre_chat_dismissed_' + botId, String(Date.now()));
3297 - } catch (e) {
3298 - // localStorage unavailable — dismissal won't persist
3299 - }
2329 + $.ajax({
2330 + url: mxchatChat.ajax_url,
2331 + type: 'POST',
2332 + data: {
2333 + action: 'mxchat_dismiss_pre_chat_message',
2334 + _ajax_nonce: mxchatChat.nonce
2335 + },
2336 + success: function() {
2337 + $('#pre-chat-message').hide();
2338 + },
2339 + error: function() {
2340 + // Error dismissing pre-chat message - silently continue
2341 + }
2342 + });
3300 2343 }
3301 2344
3302 2345
3303 2346 // ====================================
@@ -3350,38 +2393,11 @@
3350 2393 e.stopPropagation();
3351 2394 var botId = getBotIdFromElement(this);
3352 2395 collapseQuickQuestions(botId);
3353 2396 });
3354 -
3355 -// Consent-safe YouTube embed (plan 03ba33): the server only ever ships a
3356 -// thumbnail facade — no Google iframe exists until the visitor taps play.
3357 -// Delegated so it also works for embeds restored from chat history.
3358 -$(document).on('click', '.mxchat-youtube-embed .mxchat-youtube-facade', function(e) {
3359 - e.preventDefault();
3360 - var $wrap = $(this).closest('.mxchat-youtube-embed');
3361 - var videoId = String($wrap.data('video-id') || '').replace(/[^A-Za-z0-9_-]/g, '');
3362 - if (!videoId) {
3363 - return;
3364 - }
3365 - var title = $wrap.find('.mxchat-youtube-title').text() || 'YouTube video';
3366 - var $iframe = $('<iframe>', {
3367 - src: 'https://www.youtube-nocookie.com/embed/' + videoId + '?autoplay=1&rel=0',
3368 - title: title,
3369 - allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
3370 - allowfullscreen: true,
3371 - frameborder: 0
3372 - }).addClass('mxchat-youtube-iframe');
3373 - $wrap.addClass('mxchat-youtube-playing');
3374 - $(this).replaceWith($iframe);
3375 -});
3376 2397
3377 2398 // Chatbot visibility toggle handlers - use class selector for multi-instance support
3378 - // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3379 - $(document).on('click keydown', '.floating-chatbot-button', function(e) {
3380 - if (e.type === 'keydown') {
3381 - if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
3382 - e.preventDefault();
3383 - }
2399 + $(document).on('click', '.floating-chatbot-button', function() {
3384 2400 var botId = getBotIdFromElement(this);
3385 2401 var $chatbot = getElement(botId, 'floating-chatbot');
3386 2402 var $badge = getElement(botId, 'chat-notification-badge');
3387 2403 var $preChat = getElement(botId, 'pre-chat-message');
@@ -3386,88 +2402,35 @@
3386 2402 var $badge = getElement(botId, 'chat-notification-badge');
3387 2403 var $preChat = getElement(botId, 'pre-chat-message');
3388 2404
3389 2405 if ($chatbot.hasClass('hidden')) {
3390 - $chatbot.removeClass('hidden').addClass('visible')
3391 - .attr('aria-modal', 'true').attr('role', 'dialog');
3392 - $(this).addClass('hidden').attr('aria-expanded', 'true');
2406 + $chatbot.removeClass('hidden').addClass('visible');
2407 + $(this).addClass('hidden');
3393 2408 $badge.hide(); // Hide notification when opening chat
3394 2409 disableScroll();
3395 2410 $preChat.fadeOut(250);
3396 -
3397 - // First open per page load: re-fetch behavior settings in case
3398 - // this page's inline values came from a stale full-page cache
3399 - // (plan-32db95). Idempotent — later opens are a no-op.
3400 - mxchatRefreshDynamicSettings();
3401 -
3402 - // Load chat history for returning visitors (persistence)
3403 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3404 - if (chatPersistenceEnabled) {
3405 - MxChatInstances.ensureSession(botId);
3406 - }
3407 -
3408 - // Deferred email check — only on first widget open
3409 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3410 - var instance = MxChatInstances.get(botId);
3411 - if (emailBlocker && !instance.emailCheckDone) {
3412 - instance.emailCheckDone = true;
3413 - resolveEmailState(botId);
3414 - } else if (!emailBlocker) {
3415 - // No email collection — still route through showChatContainerForBot
3416 - // so the loader is shown while chat history loads
3417 - showChatContainerForBot(botId);
3418 - }
3419 -
3420 - // Move keyboard focus into the message input after the open transition.
3421 - setTimeout(function() {
3422 - var chatInput = getElementDOM(botId, 'chat-input');
3423 - if (chatInput && !chatInput.disabled) {
3424 - try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
3425 - }
3426 - }, 300);
3427 2411 } else {
3428 - $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
3429 - $(this).removeClass('hidden').attr('aria-expanded', 'false');
2412 + $chatbot.removeClass('visible').addClass('hidden');
2413 + $(this).removeClass('hidden');
3430 2414 enableScroll();
3431 2415 checkPreChatDismissal(botId);
3432 2416 }
3433 2417 });
3434 2418
3435 - // Allow clicking anywhere on the title bar to close the chatbot.
3436 - // Returns keyboard focus to the launcher so keyboard users don't get
3437 - // stranded at <body> (WCAG SC 2.4.3 Focus Order). :focus-visible is
3438 - // heuristic-based so mouse-triggered close won't show a focus ring.
2419 + // Allow clicking anywhere on the title bar to close the chatbot
3439 2420 $(document).on('click', '.chatbot-top-bar', function() {
3440 2421 var botId = getBotIdFromElement(this);
3441 - getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3442 - var $launcher = getElement(botId, 'floating-chatbot-button');
3443 - $launcher.removeClass('hidden').attr('aria-expanded', 'false');
2422 + getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible');
2423 + getElement(botId, 'floating-chatbot-button').removeClass('hidden');
3444 2424 enableScroll();
3445 - try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3446 2425 });
3447 2426
3448 - // Global Escape-key handler — closes any visible chat widget and
3449 - // returns focus to its launcher. Standard modal-dismissal pattern;
3450 - // pairs with aria-modal="true" set on the widget when it opens.
3451 - $(document).on('keydown', function(e) {
3452 - if (e.key !== 'Escape' && e.key !== 'Esc') return;
3453 - var $visible = $('.floating-chatbot.visible');
3454 - if (!$visible.length) return;
3455 - e.preventDefault();
3456 - $visible.each(function() {
3457 - var botId = getBotIdFromElement(this);
3458 - $(this).addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3459 - var $launcher = getElement(botId, 'floating-chatbot-button');
3460 - $launcher.removeClass('hidden').attr('aria-expanded', 'false');
3461 - try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3462 - });
3463 - enableScroll();
3464 - });
3465 -
3466 2427 $(document).on('click', '.close-pre-chat-message', function(e) {
3467 2428 e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
3468 2429 var botId = getBotIdFromElement(this);
3469 - handlePreChatDismissal(botId);
2430 + getElement(botId, 'pre-chat-message').fadeOut(200, function() {
2431 + $(this).remove();
2432 + });
3470 2433 });
3471 2434
3472 2435
3473 2436 // PDF upload button handlers - use class selector
@@ -3483,20 +2446,17 @@
3483 2446 var wordInput = getElementDOM(botId, 'word-upload');
3484 2447 if (wordInput) wordInput.click();
3485 2448 });
3486 2449
3487 - // PDF file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'pdf-upload')
3488 - $(document).on('change', '.pdf-upload', async function(e) {
3489 - var botId = getBotIdFromElement(this);
3490 - var instance = MxChatInstances.get(botId);
3491 - const file = this.files[0];
3492 - const sessionId = MxChatInstances.ensureSession(botId);
3493 -
2450 + // PDF file input change handler
2451 + addSafeEventListener('pdf-upload', 'change', async function(e) {
2452 + const file = e.target.files[0];
2453 +
3494 2454 if (!file || file.type !== 'application/pdf') {
3495 2455 alert('Please select a valid PDF file.');
3496 2456 return;
3497 2457 }
3498 -
2458 +
3499 2459 if (!sessionId) {
3500 2460 alert('Error: No session ID found');
3501 2461 return;
3502 2462 }
@@ -3504,49 +2464,47 @@
3504 2464 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3505 2465 alert('Error: Ajax configuration missing');
3506 2466 return;
3507 2467 }
3508 -
2468 +
3509 2469 // Disable buttons and show loading state
3510 - const uploadBtn = getElementDOM(botId, 'pdf-upload-btn');
3511 - const sendBtn = getElementDOM(botId, 'send-button');
3512 - if (!uploadBtn) return;
2470 + const uploadBtn = document.getElementById('pdf-upload-btn');
2471 + const sendBtn = document.getElementById('send-button');
3513 2472 const originalBtnContent = uploadBtn.innerHTML;
3514 -
2473 +
3515 2474 try {
3516 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3517 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3518 2475 const formData = new FormData();
3519 2476 formData.append('action', 'mxchat_upload_pdf');
3520 2477 formData.append('pdf_file', file);
3521 2478 formData.append('session_id', sessionId);
3522 2479 formData.append('nonce', mxchatChat.nonce);
3523 -
2480 +
3524 2481 uploadBtn.disabled = true;
3525 - if (sendBtn) sendBtn.disabled = true;
2482 + sendBtn.disabled = true;
3526 2483 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3527 2484 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3528 2485 </svg>`;
3529 -
2486 +
3530 2487 const response = await fetch(mxchatChat.ajax_url, {
3531 2488 method: 'POST',
3532 2489 body: formData
3533 2490 });
3534 -
2491 +
3535 2492 const data = await response.json();
3536 -
2493 +
3537 2494 if (data.success) {
3538 2495 // Hide popular questions if they exist
3539 - if (hasQuickQuestions(botId)) {
3540 - collapseQuickQuestions(botId);
2496 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2497 + if (hasQuickQuestions()) {
2498 + collapseQuickQuestions();
3541 2499 }
3542 -
2500 +
3543 2501 // Show the active PDF name
3544 - showActivePdf(data.data.filename, botId);
3545 -
3546 - appendMessage('bot', data.data.message, '', [], false, botId);
3547 - scrollToBottom(botId);
3548 - instance.activePdfFile = data.data.filename;
2502 + showActivePdf(data.data.filename);
2503 +
2504 + appendMessage('bot', data.data.message);
2505 + scrollToBottom();
2506 + activePdfFile = data.data.filename;
3549 2507 } else {
3550 2508 alert('Failed to upload PDF. Please try again.');
3551 2509 }
3552 2510 } catch (error) {
@@ -3552,76 +2510,66 @@
3552 2510 } catch (error) {
3553 2511 alert('Error uploading file. Please try again.');
3554 2512 } finally {
3555 2513 uploadBtn.disabled = false;
3556 - if (sendBtn) sendBtn.disabled = false;
2514 + sendBtn.disabled = false;
3557 2515 uploadBtn.innerHTML = originalBtnContent;
3558 2516 this.value = ''; // Reset file input
3559 2517 }
3560 2518 });
3561 2519
3562 - // Word file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'word-upload')
3563 - $(document).on('change', '.word-upload', async function(e) {
3564 - var botId = getBotIdFromElement(this);
3565 - var instance = MxChatInstances.get(botId);
3566 - const file = this.files[0];
3567 - const sessionId = MxChatInstances.ensureSession(botId);
3568 -
2520 + // Word file input change handler
2521 + addSafeEventListener('word-upload', 'change', async function(e) {
2522 + const file = e.target.files[0];
2523 +
3569 2524 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
3570 2525 alert('Please select a valid Word document (.docx).');
3571 2526 return;
3572 2527 }
3573 -
2528 +
3574 2529 if (!sessionId) {
3575 2530 alert('Error: No session ID found');
3576 2531 return;
3577 2532 }
3578 2533
3579 - if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3580 - alert('Error: Ajax configuration missing');
3581 - return;
3582 - }
3583 -
3584 2534 // Disable buttons and show loading state
3585 - const uploadBtn = getElementDOM(botId, 'word-upload-btn');
3586 - const sendBtn = getElementDOM(botId, 'send-button');
3587 - if (!uploadBtn) return;
2535 + const uploadBtn = document.getElementById('word-upload-btn');
2536 + const sendBtn = document.getElementById('send-button');
3588 2537 const originalBtnContent = uploadBtn.innerHTML;
3589 -
2538 +
3590 2539 try {
3591 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3592 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3593 2540 const formData = new FormData();
3594 2541 formData.append('action', 'mxchat_upload_word');
3595 2542 formData.append('word_file', file);
3596 2543 formData.append('session_id', sessionId);
3597 2544 formData.append('nonce', mxchatChat.nonce);
3598 -
2545 +
3599 2546 uploadBtn.disabled = true;
3600 - if (sendBtn) sendBtn.disabled = true;
2547 + sendBtn.disabled = true;
3601 2548 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3602 2549 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3603 2550 </svg>`;
3604 -
2551 +
3605 2552 const response = await fetch(mxchatChat.ajax_url, {
3606 2553 method: 'POST',
3607 2554 body: formData
3608 2555 });
3609 -
2556 +
3610 2557 const data = await response.json();
3611 -
2558 +
3612 2559 if (data.success) {
3613 2560 // Hide popular questions if they exist
3614 - if (hasQuickQuestions(botId)) {
3615 - collapseQuickQuestions(botId);
2561 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2562 + if (hasQuickQuestions()) {
2563 + collapseQuickQuestions();
3616 2564 }
3617 -
2565 +
3618 2566 // Show the active Word document name
3619 - showActiveWord(data.data.filename, botId);
3620 -
3621 - appendMessage('bot', data.data.message, '', [], false, botId);
3622 - scrollToBottom(botId);
3623 - instance.activeWordFile = data.data.filename;
2567 + showActiveWord(data.data.filename);
2568 +
2569 + appendMessage('bot', data.data.message);
2570 + scrollToBottom();
2571 + activeWordFile = data.data.filename;
3624 2572 } else {
3625 2573 alert('Failed to upload Word document. Please try again.');
3626 2574 }
3627 2575 } catch (error) {
@@ -3627,25 +2575,25 @@
3627 2575 } catch (error) {
3628 2576 alert('Error uploading file. Please try again.');
3629 2577 } finally {
3630 2578 uploadBtn.disabled = false;
3631 - if (sendBtn) sendBtn.disabled = false;
2579 + sendBtn.disabled = false;
3632 2580 uploadBtn.innerHTML = originalBtnContent;
3633 2581 this.value = ''; // Reset file input
3634 2582 }
3635 2583 });
3636 2584
3637 - // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids)
3638 - $(document).on('click', '.remove-pdf-btn', function(e) {
2585 + // Remove button click handlers
2586 + document.getElementById('remove-pdf-btn')?.addEventListener('click', function(e) {
3639 2587 e.preventDefault();
3640 2588 e.stopPropagation();
3641 - removeActivePdf(getBotIdFromElement(this));
2589 + removeActivePdf();
3642 2590 });
3643 -
3644 - $(document).on('click', '.remove-word-btn', function(e) {
2591 +
2592 + document.getElementById('remove-word-btn')?.addEventListener('click', function(e) {
3645 2593 e.preventDefault();
3646 2594 e.stopPropagation();
3647 - removeActiveWord(getBotIdFromElement(this));
2595 + removeActiveWord();
3648 2596 });
3649 2597
3650 2598 // Window resize handlers
3651 2599 $(window).on('resize orientationchange', function() {
@@ -3683,437 +2631,380 @@
3683 2631 });
3684 2632
3685 2633
3686 2634 // ====================================
3687 -// INIT LOADER & CHAT CONTAINER HELPERS
2635 +// EMAIL COLLECTION SETUP - FIXED VERSION
3688 2636 // ====================================
3689 -// These must be outside the email collection block so they're always available
3690 -// (used by persistence loading even when email collection is off)
3691 -
3692 -function showInitLoader(botId) {
3693 - var loader = getElementDOM(botId, 'mxchat-init-loader');
3694 - if (loader) loader.style.display = 'flex';
3695 -}
3696 -
3697 -function hideInitLoader(botId) {
3698 - var loader = getElementDOM(botId, 'mxchat-init-loader');
3699 - if (loader) loader.style.display = 'none';
3700 -}
3701 -
3702 -function showEmailFormForBot(botId) {
3703 - hideInitLoader(botId);
3704 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3705 - var chatContainer = getElementDOM(botId, 'chat-container');
3706 - if (emailBlocker) emailBlocker.style.display = 'flex';
3707 - if (chatContainer) chatContainer.style.display = 'none';
3708 -}
3709 -
3710 -function showChatContainerForBot(botId) {
3711 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3712 - var chatContainer = getElementDOM(botId, 'chat-container');
3713 - if (emailBlocker) emailBlocker.style.display = 'none';
3714 -
3715 - var instance = MxChatInstances.get(botId);
3716 - var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3717 -
3718 - // If persistence is on and history hasn't loaded yet, show loader
3719 - // while history loads to prevent flash of empty chat
3720 - if (chatPersistenceEnabled && !instance.chatHistoryLoaded) {
3721 - if (chatContainer) chatContainer.style.display = 'none';
3722 - showInitLoader(botId);
3723 - loadChatHistory(botId, function() {
3724 - hideInitLoader(botId);
3725 - if (chatContainer) chatContainer.style.display = 'flex';
3726 - scrollToBottom(botId, true);
3727 - });
3728 - } else {
3729 - hideInitLoader(botId);
3730 - if (chatContainer) chatContainer.style.display = 'flex';
3731 - if (typeof loadChatHistory === 'function') {
3732 - loadChatHistory(botId);
3733 - }
3734 - }
3735 -}
3736 -
3737 -// ====================================
3738 -// EMAIL COLLECTION SETUP - MULTI-INSTANCE VERSION
3739 -// ====================================
3740 2637 // Only run email collection setup if it's enabled
3741 2638 if (mxchatChat && mxchatChat.email_collection_enabled === 'on') {
2639 + // Email collection form setup and handlers
2640 + const emailForm = document.getElementById('email-collection-form');
2641 + const emailBlocker = document.getElementById('email-blocker');
2642 + const chatbotWrapper = document.getElementById('chat-container');
3742 2643
3743 - // Track submitting state per bot
3744 - const emailSubmittingState = {};
2644 + if (emailForm && emailBlocker && chatbotWrapper) {
2645 +
2646 + // Add loading state management
2647 + let isSubmitting = false;
2648 +
2649 + // Optimized UI transition functions
2650 + function showEmailForm() {
2651 + emailBlocker.style.display = 'flex';
2652 + chatbotWrapper.style.display = 'none';
2653 + }
3745 2654
3746 - // Add CSS animations for email form (once globally)
3747 - if (!document.getElementById('email-error-styles')) {
3748 - const style = document.createElement('style');
3749 - style.id = 'email-error-styles';
3750 - style.textContent = `
3751 - @keyframes fadeInError {
3752 - from { opacity: 0; transform: translateY(-5px); }
3753 - to { opacity: 1; transform: translateY(0); }
2655 + function showChatContainer() {
2656 + // Show chat immediately without delay
2657 + emailBlocker.style.display = 'none';
2658 + chatbotWrapper.style.display = 'flex';
2659 +
2660 + // Load chat history only after showing chat container
2661 + if (typeof loadChatHistory === 'function') {
2662 + loadChatHistory();
3754 2663 }
3755 - .email-input-shake {
3756 - animation: shake 0.5s ease-in-out;
3757 - }
3758 - @keyframes shake {
3759 - 0%, 100% { transform: translateX(0); }
3760 - 25% { transform: translateX(-5px); }
3761 - 75% { transform: translateX(5px); }
3762 - }
3763 - @keyframes spin {
3764 - from { transform: rotate(0deg); }
3765 - to { transform: rotate(360deg); }
3766 - }
3767 - .email-spinner {
3768 - display: inline-block;
3769 - vertical-align: middle;
3770 - }
3771 - `;
3772 - document.head.appendChild(style);
3773 - }
2664 + }
3774 2665
3775 - function isValidEmailAddress(email) {
3776 - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3777 - return emailRegex.test(email.trim()) && email.length <= 254;
3778 - }
2666 + // Enhanced email validation
2667 + function isValidEmail(email) {
2668 + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2669 + return emailRegex.test(email.trim()) && email.length <= 254; // RFC 5321 limit
2670 + }
3779 2671
3780 - function isValidNameInput(name) {
3781 - return name && name.trim().length >= 2 && name.trim().length <= 100;
3782 - }
2672 + // Enhanced name validation
2673 + function isValidName(name) {
2674 + return name && name.trim().length >= 2 && name.trim().length <= 100;
2675 + }
3783 2676
3784 - /**
3785 - * Replace {visitor_name} placeholder in intro message with actual visitor name
3786 - * @param {string} botId - The bot instance ID
3787 - * @param {string} visitorName - The visitor's name to insert
3788 - */
3789 - function replaceVisitorNamePlaceholder(botId, visitorName) {
3790 - var chatBox = getElementDOM(botId, 'chat-box');
3791 - if (!chatBox) return;
3792 -
3793 - // Find the first bot message (intro message)
3794 - var introMessage = chatBox.querySelector('.bot-message');
3795 - if (!introMessage) return;
3796 -
3797 - var messageContent = introMessage.querySelector('div[dir="auto"]');
3798 - if (!messageContent) return;
3799 -
3800 - var html = messageContent.innerHTML;
3801 -
3802 - // Replace {visitor_name} placeholder (case-insensitive)
3803 - if (visitorName && visitorName.trim()) {
3804 - // Escape HTML to prevent XSS
3805 - var safeName = $('<div>').text(visitorName.trim()).html();
3806 - html = html.replace(/\{visitor_name\}/gi, safeName);
3807 - } else {
3808 - // Remove placeholder and clean up spacing if no name provided
3809 - html = html.replace(/\{visitor_name\}/gi, '');
3810 - // Clean up any double spaces that might result
3811 - html = html.replace(/\s{2,}/g, ' ').trim();
2677 + // Show loading state with spinner
2678 + function setSubmissionState(loading) {
2679 + const submitButton = document.getElementById('email-submit-button');
2680 + const emailInput = document.getElementById('user-email');
2681 + const nameInput = document.getElementById('user-name');
2682 +
2683 + if (loading) {
2684 + isSubmitting = true;
2685 + if (submitButton) submitButton.disabled = true;
2686 + if (emailInput) emailInput.disabled = true;
2687 + if (nameInput) nameInput.disabled = true;
2688 +
2689 + // Store original content and add spinner
2690 + if (submitButton && !submitButton.getAttribute('data-original-html')) {
2691 + submitButton.setAttribute('data-original-html', submitButton.innerHTML);
2692 +
2693 + // Add loading spinner while keeping original text
2694 + const originalText = submitButton.textContent;
2695 + submitButton.innerHTML = `
2696 + <svg class="email-spinner" style="width: 16px; height: 16px; margin-right: 8px; animation: spin 1s linear infinite;" viewBox="0 0 24 24">
2697 + <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" stroke-dasharray="31.416" stroke-dashoffset="31.416">
2698 + <animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416" repeatCount="indefinite"/>
2699 + <animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416" repeatCount="indefinite"/>
2700 + </circle>
2701 + </svg>
2702 + ${originalText}
2703 + `;
2704 +
2705 + submitButton.style.opacity = '0.8';
2706 + }
2707 + } else {
2708 + isSubmitting = false;
2709 + if (submitButton) submitButton.disabled = false;
2710 + if (emailInput) emailInput.disabled = false;
2711 + if (nameInput) nameInput.disabled = false;
2712 +
2713 + // Restore original content
2714 + if (submitButton) {
2715 + const originalHtml = submitButton.getAttribute('data-original-html');
2716 + if (originalHtml) {
2717 + submitButton.innerHTML = originalHtml;
2718 + }
2719 + submitButton.style.opacity = '1';
2720 + }
2721 + }
3812 2722 }
3813 2723
3814 - messageContent.innerHTML = html;
3815 - }
3816 -
3817 - function setEmailSubmissionState(botId, loading) {
3818 - var submitButton = getElementDOM(botId, 'email-submit-button');
3819 - var emailInput = getElementDOM(botId, 'user-email');
3820 - var nameInput = getElementDOM(botId, 'user-name');
3821 -
3822 - if (loading) {
3823 - emailSubmittingState[botId] = true;
3824 - if (submitButton) submitButton.disabled = true;
3825 - if (emailInput) emailInput.disabled = true;
3826 - if (nameInput) nameInput.disabled = true;
3827 -
3828 - if (submitButton && !submitButton.getAttribute('data-original-html')) {
3829 - submitButton.setAttribute('data-original-html', submitButton.innerHTML);
3830 - const originalText = submitButton.textContent;
3831 - submitButton.innerHTML = `
3832 - <svg class="email-spinner" style="width: 16px; height: 16px; margin-right: 8px; animation: spin 1s linear infinite;" viewBox="0 0 24 24">
3833 - <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" stroke-dasharray="31.416" stroke-dashoffset="31.416">
3834 - <animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416" repeatCount="indefinite"/>
3835 - <animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416" repeatCount="indefinite"/>
3836 - </circle>
3837 - </svg>
3838 - ${originalText}
2724 + // Error display functions
2725 + function showEmailError(message) {
2726 + clearEmailError();
2727 +
2728 + const errorDiv = document.createElement('div');
2729 + errorDiv.className = 'email-error';
2730 + errorDiv.style.cssText = `
2731 + color: #e74c3c;
2732 + font-size: 12px;
2733 + margin-top: 8px;
2734 + padding: 4px 0;
2735 + animation: fadeInError 0.3s ease;
2736 + `;
2737 + errorDiv.textContent = message;
2738 +
2739 + // Add CSS animation if not already present
2740 + if (!document.getElementById('email-error-styles')) {
2741 + const style = document.createElement('style');
2742 + style.id = 'email-error-styles';
2743 + style.textContent = `
2744 + @keyframes fadeInError {
2745 + from { opacity: 0; transform: translateY(-5px); }
2746 + to { opacity: 1; transform: translateY(0); }
2747 + }
2748 + .email-input-shake {
2749 + animation: shake 0.5s ease-in-out;
2750 + }
2751 + @keyframes shake {
2752 + 0%, 100% { transform: translateX(0); }
2753 + 25% { transform: translateX(-5px); }
2754 + 75% { transform: translateX(5px); }
2755 + }
2756 + @keyframes spin {
2757 + from { transform: rotate(0deg); }
2758 + to { transform: rotate(360deg); }
2759 + }
2760 + .email-spinner {
2761 + display: inline-block;
2762 + vertical-align: middle;
2763 + }
3839 2764 `;
3840 - submitButton.style.opacity = '0.8';
2765 + document.head.appendChild(style);
3841 2766 }
3842 - } else {
3843 - emailSubmittingState[botId] = false;
3844 - if (submitButton) submitButton.disabled = false;
3845 - if (emailInput) emailInput.disabled = false;
3846 - if (nameInput) nameInput.disabled = false;
3847 -
3848 - if (submitButton) {
3849 - const originalHtml = submitButton.getAttribute('data-original-html');
3850 - if (originalHtml) {
3851 - submitButton.innerHTML = originalHtml;
3852 - }
3853 - submitButton.style.opacity = '1';
2767 +
2768 + emailForm.appendChild(errorDiv);
2769 +
2770 + // Add shake animation to inputs
2771 + const emailInput = document.getElementById('user-email');
2772 + const nameInput = document.getElementById('user-name');
2773 +
2774 + if (emailInput) {
2775 + emailInput.classList.add('email-input-shake');
2776 + setTimeout(() => {
2777 + emailInput.classList.remove('email-input-shake');
2778 + }, 500);
3854 2779 }
2780 +
2781 + if (nameInput) {
2782 + nameInput.classList.add('email-input-shake');
2783 + setTimeout(() => {
2784 + nameInput.classList.remove('email-input-shake');
2785 + }, 500);
2786 + }
3855 2787 }
3856 - }
3857 2788
3858 - function showEmailError(botId, message) {
3859 - clearEmailError(botId);
3860 -
3861 - var emailForm = getElementDOM(botId, 'email-collection-form');
3862 - if (!emailForm) return;
3863 -
3864 - const errorDiv = document.createElement('div');
3865 - errorDiv.className = 'email-error';
3866 - errorDiv.style.cssText = `
3867 - color: #e74c3c;
3868 - font-size: 12px;
3869 - margin-top: 8px;
3870 - padding: 4px 0;
3871 - animation: fadeInError 0.3s ease;
3872 - `;
3873 - errorDiv.textContent = message;
3874 - emailForm.appendChild(errorDiv);
3875 -
3876 - // Add shake animation to inputs
3877 - var emailInput = getElementDOM(botId, 'user-email');
3878 - var nameInput = getElementDOM(botId, 'user-name');
3879 -
3880 - if (emailInput) {
3881 - emailInput.classList.add('email-input-shake');
3882 - setTimeout(() => emailInput.classList.remove('email-input-shake'), 500);
3883 - }
3884 - if (nameInput) {
3885 - nameInput.classList.add('email-input-shake');
3886 - setTimeout(() => nameInput.classList.remove('email-input-shake'), 500);
3887 - }
3888 - }
3889 -
3890 - function clearEmailError(botId) {
3891 - var emailForm = getElementDOM(botId, 'email-collection-form');
3892 - if (emailForm) {
2789 + function clearEmailError() {
3893 2790 const existingErrors = emailForm.querySelectorAll('.email-error');
3894 2791 existingErrors.forEach(error => error.remove());
3895 2792 }
3896 - }
3897 2793
3898 - // Resolve email state using server-side data when available, AJAX fallback otherwise
3899 - function resolveEmailState(botId) {
3900 - if (mxchatChat.skip_email_check && mxchatChat.initial_email_state) {
3901 - if (mxchatChat.initial_email_state.show_email_form) {
3902 - showEmailFormForBot(botId);
3903 - } else {
3904 - showChatContainerForBot(botId);
3905 - }
3906 - } else {
3907 - checkSessionAndEmailForBot(botId);
3908 - }
3909 - }
2794 + // MAIN FORM SUBMIT HANDLER
2795 + // Remove any existing event listeners first
2796 + emailForm.removeEventListener('submit', handleFormSubmit);
3910 2797
3911 - function checkSessionAndEmailForBot(botId) {
3912 - const sessionId = MxChatInstances.ensureSession(botId);
2798 + // Add the form submit handler
2799 + emailForm.addEventListener('submit', handleFormSubmit);
3913 2800
3914 - // Hide both panels while we check — show loader instead
3915 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3916 - var chatContainer = getElementDOM(botId, 'chat-container');
3917 - if (emailBlocker) emailBlocker.style.display = 'none';
3918 - if (chatContainer) chatContainer.style.display = 'none';
3919 - showInitLoader(botId);
2801 + function handleFormSubmit(event) {
2802 + event.preventDefault();
2803 + event.stopPropagation();
3920 2804
3921 - fetch(mxchatChat.ajax_url, {
3922 - method: 'POST',
3923 - headers: {
3924 - 'Content-Type': 'application/x-www-form-urlencoded',
3925 - },
3926 - body: new URLSearchParams({
3927 - action: 'mxchat_check_email_provided',
3928 - session_id: sessionId,
3929 - nonce: mxchatChat.nonce,
3930 - })
3931 - })
3932 - .then((response) => {
3933 - if (!response.ok) {
3934 - throw new Error(`HTTP error! status: ${response.status}`);
2805 + // Prevent double submission
2806 + if (isSubmitting) {
2807 + return false;
3935 2808 }
3936 - return response.json();
3937 - })
3938 - .then((data) => {
3939 - if (data.success) {
3940 - if (data.data.logged_in || data.data.email) {
3941 - showChatContainerForBot(botId);
3942 - } else {
3943 - showEmailFormForBot(botId);
3944 - }
3945 - } else {
3946 - showEmailFormForBot(botId);
3947 - }
3948 - })
3949 - .catch((error) => {
3950 - showEmailFormForBot(botId);
3951 - });
3952 - }
3953 2809
3954 - // Event delegation for email form submission
3955 - $(document).on('submit', '.email-collection-form', function(e) {
3956 - e.preventDefault();
3957 - e.stopPropagation();
2810 + const userEmail = document.getElementById('user-email').value.trim();
2811 + const nameInput = document.getElementById('user-name');
2812 + const userName = nameInput ? nameInput.value.trim() : '';
2813 + const sessionId = getChatSession();
3958 2814
3959 - var botId = getBotIdFromElement(this);
2815 + // Validate email before submission
2816 + if (!userEmail) {
2817 + showEmailError('Please enter your email address.');
2818 + return false;
2819 + }
3960 2820
3961 - // Prevent double submission
3962 - if (emailSubmittingState[botId]) {
3963 - return false;
3964 - }
2821 + if (!isValidEmail(userEmail)) {
2822 + showEmailError('Please enter a valid email address.');
2823 + return false;
2824 + }
3965 2825
3966 - var emailInput = getElementDOM(botId, 'user-email');
3967 - var nameInput = getElementDOM(botId, 'user-name');
3968 - var userEmail = emailInput ? emailInput.value.trim() : '';
3969 - var userName = nameInput ? nameInput.value.trim() : '';
3970 - var sessionId = MxChatInstances.ensureSession(botId);
2826 + // Validate name if field exists
2827 + if (nameInput && !isValidName(userName)) {
2828 + showEmailError('Please enter a valid name (2-100 characters).');
2829 + return false;
2830 + }
3971 2831
3972 - // Validate email
3973 - if (!userEmail) {
3974 - showEmailError(botId, 'Please enter your email address.');
3975 - return false;
3976 - }
2832 + // Clear any existing errors
2833 + clearEmailError();
2834 + setSubmissionState(true);
3977 2835
3978 - if (!isValidEmailAddress(userEmail)) {
3979 - showEmailError(botId, 'Please enter a valid email address.');
3980 - return false;
3981 - }
2836 + // Prepare form data with optional name
2837 + const formData = new URLSearchParams({
2838 + action: 'mxchat_handle_save_email_and_response',
2839 + email: userEmail,
2840 + session_id: sessionId,
2841 + nonce: mxchatChat.nonce,
2842 + });
3982 2843
3983 - // Validate name if field exists and has content
3984 - if (nameInput && userName && !isValidNameInput(userName)) {
3985 - showEmailError(botId, 'Please enter a valid name (2-100 characters).');
3986 - return false;
3987 - }
2844 + // Add name to form data if provided
2845 + if (userName) {
2846 + formData.append('name', userName);
2847 + }
3988 2848
3989 - clearEmailError(botId);
3990 - setEmailSubmissionState(botId, true);
2849 + fetch(mxchatChat.ajax_url, {
2850 + method: 'POST',
2851 + headers: {
2852 + 'Content-Type': 'application/x-www-form-urlencoded',
2853 + },
2854 + body: formData
2855 + })
2856 + .then((response) => {
2857 + if (!response.ok) {
2858 + throw new Error(`HTTP error! status: ${response.status}`);
2859 + }
2860 + return response.json();
2861 + })
2862 + .then((data) => {
2863 + setSubmissionState(false);
3991 2864
3992 - // Prepare form data
3993 - const formData = new URLSearchParams({
3994 - action: 'mxchat_handle_save_email_and_response',
3995 - email: userEmail,
3996 - session_id: sessionId,
3997 - nonce: mxchatChat.nonce,
3998 - });
2865 + if (data.success) {
2866 + // Show chat immediately
2867 + showChatContainer();
3999 2868
4000 - if (userName) {
4001 - formData.append('name', userName);
2869 + // Handle bot response if provided
2870 + if (data.message && typeof appendMessage === 'function') {
2871 + setTimeout(() => {
2872 + appendMessage('bot', data.message);
2873 + if (typeof scrollToBottom === 'function') {
2874 + scrollToBottom();
2875 + }
2876 + }, 100);
2877 + }
2878 + } else {
2879 + showEmailError(data.message || 'Failed to save email. Please try again.');
2880 + }
2881 + })
2882 + .catch((error) => {
2883 + setSubmissionState(false);
2884 + showEmailError('An error occurred. Please try again.');
2885 + });
2886 +
2887 + return false; // Extra prevention
4002 2888 }
4003 2889
4004 - fetch(mxchatChat.ajax_url, {
4005 - method: 'POST',
4006 - headers: {
4007 - 'Content-Type': 'application/x-www-form-urlencoded',
4008 - },
4009 - body: formData
4010 - })
4011 - .then((response) => {
4012 - if (!response.ok) {
4013 - throw new Error(`HTTP error! status: ${response.status}`);
4014 - }
4015 - return response.json();
4016 - })
4017 - .then((data) => {
4018 - setEmailSubmissionState(botId, false);
2890 + // Real-time email validation
2891 + const emailInput = document.getElementById('user-email');
2892 + if (emailInput) {
2893 + let validationTimeout;
2894 +
2895 + emailInput.addEventListener('input', function() {
2896 + // Clear previous validation timeout
2897 + if (validationTimeout) {
2898 + clearTimeout(validationTimeout);
2899 + }
2900 +
2901 + // Debounce validation
2902 + validationTimeout = setTimeout(() => {
2903 + const email = this.value.trim();
2904 + clearEmailError();
2905 +
2906 + if (email && !isValidEmail(email)) {
2907 + showEmailError('Please enter a valid email address.');
2908 + }
2909 + }, 500);
2910 + });
4019 2911
4020 - if (data.success) {
4021 - showChatContainerForBot(botId);
2912 + // Handle Enter key
2913 + emailInput.addEventListener('keypress', function(e) {
2914 + if (e.key === 'Enter' && !isSubmitting) {
2915 + e.preventDefault();
2916 + emailForm.dispatchEvent(new Event('submit'));
2917 + }
2918 + });
2919 + }
4022 2920
4023 - // Replace {visitor_name} placeholder in intro message with actual name
4024 - if (userName) {
4025 - replaceVisitorNamePlaceholder(botId, userName);
4026 - } else {
4027 - // Remove placeholder if no name provided
4028 - replaceVisitorNamePlaceholder(botId, '');
2921 + // Real-time name validation
2922 + const nameInput = document.getElementById('user-name');
2923 + if (nameInput) {
2924 + let nameValidationTimeout;
2925 +
2926 + nameInput.addEventListener('input', function() {
2927 + // Clear previous validation timeout
2928 + if (nameValidationTimeout) {
2929 + clearTimeout(nameValidationTimeout);
4029 2930 }
2931 +
2932 + // Debounce validation
2933 + nameValidationTimeout = setTimeout(() => {
2934 + const name = this.value.trim();
2935 + clearEmailError();
2936 +
2937 + if (name && !isValidName(name)) {
2938 + showEmailError('Name must be between 2 and 100 characters.');
2939 + }
2940 + }, 500);
2941 + });
4030 2942
4031 - if (data.message && typeof appendMessage === 'function') {
4032 - setTimeout(() => {
4033 - appendMessage('bot', data.message, '', [], false, botId);
4034 - if (typeof scrollToBottom === 'function') {
4035 - scrollToBottom(botId);
4036 - }
4037 - }, 100);
2943 + // Handle Enter key
2944 + nameInput.addEventListener('keypress', function(e) {
2945 + if (e.key === 'Enter' && !isSubmitting) {
2946 + e.preventDefault();
2947 + emailForm.dispatchEvent(new Event('submit'));
4038 2948 }
2949 + });
2950 + }
2951 +
2952 + // Initial state check
2953 + if (mxchatChat.skip_email_check && mxchatChat.initial_email_state) {
2954 + const emailState = mxchatChat.initial_email_state;
2955 + if (emailState.show_email_form) {
2956 + showEmailForm();
4039 2957 } else {
4040 - showEmailError(botId, data.message || 'Failed to save email. Please try again.');
2958 + showChatContainer();
4041 2959 }
4042 - })
4043 - .catch((error) => {
4044 - setEmailSubmissionState(botId, false);
4045 - showEmailError(botId, 'An error occurred. Please try again.');
4046 - });
4047 -
4048 - return false;
4049 - });
4050 -
4051 - // Real-time email validation using event delegation
4052 - $(document).on('input', '.mxchat-email-input', function() {
4053 - var botId = getBotIdFromElement(this);
4054 - var $input = $(this);
4055 -
4056 - // Clear previous timeout
4057 - clearTimeout($input.data('validationTimeout'));
4058 -
4059 - // Debounce validation
4060 - var timeout = setTimeout(() => {
4061 - var email = this.value.trim();
4062 - clearEmailError(botId);
4063 -
4064 - if (email && !isValidEmailAddress(email)) {
4065 - showEmailError(botId, 'Please enter a valid email address.');
4066 - }
4067 - }, 500);
4068 -
4069 - $input.data('validationTimeout', timeout);
4070 - });
4071 -
4072 - // Handle Enter key in email input
4073 - $(document).on('keypress', '.mxchat-email-input', function(e) {
4074 - if (e.key === 'Enter') {
4075 - e.preventDefault();
4076 - var botId = getBotIdFromElement(this);
4077 - if (!emailSubmittingState[botId]) {
4078 - $(this).closest('.email-collection-form').submit();
4079 - }
2960 + } else {
2961 + // Check email status via AJAX
2962 + setTimeout(checkSessionAndEmail, 100);
4080 2963 }
4081 - });
4082 2964
4083 - // Handle Enter key in name input
4084 - $(document).on('keypress', '.mxchat-name-input', function(e) {
4085 - if (e.key === 'Enter') {
4086 - e.preventDefault();
4087 - var botId = getBotIdFromElement(this);
4088 - if (!emailSubmittingState[botId]) {
4089 - $(this).closest('.email-collection-form').submit();
4090 - }
2965 + // Check if email exists for the current session
2966 + function checkSessionAndEmail() {
2967 + const sessionId = getChatSession();
2968 +
2969 + fetch(mxchatChat.ajax_url, {
2970 + method: 'POST',
2971 + headers: {
2972 + 'Content-Type': 'application/x-www-form-urlencoded',
2973 + },
2974 + body: new URLSearchParams({
2975 + action: 'mxchat_check_email_provided',
2976 + session_id: sessionId,
2977 + nonce: mxchatChat.nonce,
2978 + })
2979 + })
2980 + .then((response) => {
2981 + if (!response.ok) {
2982 + throw new Error(`HTTP error! status: ${response.status}`);
2983 + }
2984 + return response.json();
2985 + })
2986 + .then((data) => {
2987 + if (data.success) {
2988 + if (data.data.logged_in || data.data.email) {
2989 + showChatContainer();
2990 + } else {
2991 + showEmailForm();
2992 + }
2993 + } else {
2994 + // On error, default to showing email form
2995 + showEmailForm();
2996 + }
2997 + })
2998 + .catch((error) => {
2999 + // Email check failed - default to email form
3000 + showEmailForm();
3001 + });
4091 3002 }
4092 - });
4093 3003
4094 - // Initialize email check for all bot instances
4095 - // For floating bots: defer until widget is opened (zero passive AJAX)
4096 - // For embedded bots: check immediately since the form is visible
4097 - $('.mxchat-chatbot-wrapper').each(function() {
4098 - var botId = $(this).data('bot-id') || 'default';
4099 - var emailBlocker = getElementDOM(botId, 'email-blocker');
4100 -
4101 - if (emailBlocker) {
4102 - if (isEmbeddedBot(botId)) {
4103 - // Embedded bots are always visible — check now
4104 - resolveEmailState(botId);
4105 - }
4106 - // Floating bots: handled in the widget open handler
4107 - } else if (isEmbeddedBot(botId)) {
4108 - // Embedded bot, no email collection — load history with loader
4109 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4110 - if (chatPersistenceEnabled) {
4111 - MxChatInstances.ensureSession(botId);
4112 - showChatContainerForBot(botId);
4113 - }
4114 - }
4115 - });
3004 + } else {
3005 + // Email collection is enabled but essential elements are missing - silently continue
3006 + }
4116 3007 }
4117 3008
4118 3009 // Open chatbot when pre-chat message is clicked - use class selector for multi-instance
4119 3010 $(document).on('click', '.pre-chat-message', function() {
@@ -4121,32 +3012,39 @@
4121 3012 var $chatbot = getElement(botId, 'floating-chatbot');
4122 3013 if ($chatbot.hasClass('hidden')) {
4123 3014 $chatbot.removeClass('hidden').addClass('visible');
4124 3015 getElement(botId, 'floating-chatbot-button').addClass('hidden');
4125 - handlePreChatDismissal(botId);
3016 + $(this).fadeOut(250); // Hide pre-chat message
4126 3017 disableScroll(); // Disable scroll when chatbot opens
3018 + }
3019 + });
4127 3020
4128 - // Load chat history for returning visitors (persistence)
4129 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4130 - if (chatPersistenceEnabled) {
4131 - MxChatInstances.ensureSession(botId);
4132 - }
3021 + // Dismiss pre-chat message via close button - handled by event delegation above at line ~2376
3022 + // This is a fallback for legacy support
3023 + $(document).on('click', '.close-pre-chat-message', function() {
3024 + var botId = getBotIdFromElement(this);
3025 + var $preChat = getElement(botId, 'pre-chat-message');
3026 + $preChat.fadeOut(200); // Hide the message
4133 3027
4134 - // Deferred email check — only on first widget open
4135 - var emailBlocker = getElementDOM(botId, 'email-blocker');
4136 - var instance = MxChatInstances.get(botId);
4137 - if (emailBlocker && !instance.emailCheckDone) {
4138 - instance.emailCheckDone = true;
4139 - resolveEmailState(botId);
4140 - } else if (!emailBlocker) {
4141 - showChatContainerForBot(botId);
3028 + // Send an AJAX request to set the transient flag for 24 hours
3029 + $.ajax({
3030 + url: mxchatChat.ajax_url,
3031 + type: 'POST',
3032 + data: {
3033 + action: 'mxchat_dismiss_pre_chat_message',
3034 + _ajax_nonce: mxchatChat.nonce
3035 + },
3036 + success: function() {
3037 + // Ensure the message is hidden after dismissal
3038 + $preChat.hide();
3039 + },
3040 + error: function() {
3041 + // Error dismissing pre-chat message - silently continue
4142 3042 }
4143 - }
3043 + });
4144 3044 });
4145 3045
4146 - // Legacy duplicate close handler removed — handled by single event delegation above
4147 3046
4148 -
4149 3047 function hasQuickQuestions(botId) {
4150 3048 botId = botId || 'default';
4151 3049 var questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
4152 3050 if (!questionsContainer) return false;
@@ -4282,11 +3180,18 @@
4282 3180 });
4283 3181
4284 3182 // Initialize when document is ready
4285 3183 setFullHeight();
3184 + trackOriginatingPage();
4286 3185
4287 - // Note: trackOriginatingPage() and loadChatHistory() are now deferred
4288 - // until the user's first interaction via MxChatInstances.ensureSession()
3186 + // Only load chat history if email collection is disabled
3187 + if (mxchatChat.email_collection_enabled !== 'on') {
3188 + // Load history for all instances
3189 + $('.mxchat-chatbot-wrapper').each(function() {
3190 + var botId = $(this).data('bot-id') || 'default';
3191 + loadChatHistory(botId);
3192 + });
3193 + }
4289 3194
4290 3195 // Initialize chat visibility for all instances
4291 3196 $('.mxchat-chatbot-wrapper').each(function() {
4292 3197 var botId = $(this).data('bot-id') || 'default';
@@ -4339,312 +3244,6 @@
4339 3244 }, 2000);
4340 3245 });
4341 3246 }
4342 3247 }
4343 -});
4344 -
4345 -// ============================================================================
4346 -// SATISFACTION RATING (v3.2.6)
4347 -// ============================================================================
4348 -// Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
4349 -// inactivity following a bot reply. One prompt per session, deduped via
4350 -// localStorage. Runs ONLY when the satisfaction_rating_enabled option is on —
4351 -// the option (default off) is authoritative.
4352 -jQuery(function($) {
4353 - if (typeof mxchatChat === 'undefined') return;
4354 - // wp_localize_script stringifies scalars: a PHP boolean false arrives as
4355 - // '' and true as '1', so this must be an explicit-enable allowlist — the
4356 - // old "disabled when exactly false/'off'" check let '' through and the
4357 - // bubble rendered on sites with the option off/unset (plan-4bba64). PHP
4358 - // now emits 'on'/'off' strings; true/'1'/1 keep cached pre-fix HTML
4359 - // (boolean-true localizations) working.
4360 - // NOTE (plan-32db95): this gate reads the INLINE value at DOM ready and is
4361 - // deliberately NOT re-evaluated after the widget's dynamic-settings refresh
4362 - // merges fresh values over mxchatChat (that merge fires on first widget
4363 - // open, after this module has already decided). Re-evaluating would mean
4364 - // restructuring the whole module to late-bind its listeners — not worth it
4365 - // for a prompt that is at worst stale for one page load on a cached page.
4366 - var sre = mxchatChat.satisfaction_rating_enabled;
4367 - if (sre !== 'on' && sre !== true && sre !== '1' && sre !== 1) return;
4368 -
4369 - // wp_localize_script stringifies ints, so accept both number and numeric string.
4370 - var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
4371 - var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);
4372 - if (!isFinite(idleSeconds)) idleSeconds = 60;
4373 - if (idleSeconds < 5) idleSeconds = 5;
4374 - if (idleSeconds > 600) idleSeconds = 600;
4375 - var IDLE_MS = idleSeconds * 1000;
4376 - var MIN_BOT_REPLIES = 2;
4377 - var ratingState = {};
4378 -
4379 - function getState(botId) {
4380 - if (!ratingState[botId]) {
4381 - ratingState[botId] = { idleTimer: null, botReplies: 0, promptShown: false, dismissed: false };
4382 - }
4383 - return ratingState[botId];
4384 - }
4385 -
4386 - function getSessionId(botId) {
4387 - if (typeof MxChatInstances !== 'undefined' && MxChatInstances.getChatSession) {
4388 - return MxChatInstances.getChatSession(botId);
4389 - }
4390 - return null;
4391 - }
4392 -
4393 - function isAlreadyRated(sessionId) {
4394 - if (!sessionId) return false;
4395 - try { return localStorage.getItem('mxchat_rated:' + sessionId) === '1'; } catch (e) { return false; }
4396 - }
4397 -
4398 - function markRated(sessionId) {
4399 - if (!sessionId) return;
4400 - try { localStorage.setItem('mxchat_rated:' + sessionId, '1'); } catch (e) {}
4401 - }
4402 -
4403 - function esc(s) {
4404 - return String(s == null ? '' : s)
4405 - .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
4406 - .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
4407 - }
4408 -
4409 - // Mirror shouldSkipInlineColors so rating bubbles defer to AI-theme CSS.
4410 - function ratingSkipInlineColors(botId) {
4411 - if (mxchatChat.skip_inline_colors) return true;
4412 - var botAssignments = mxchatChat.bot_theme_assignments || {};
4413 - return botAssignments.hasOwnProperty(botId);
4414 - }
4415 -
4416 - function botBubbleStyleAttr(botId) {
4417 - if (ratingSkipInlineColors(botId)) return '';
4418 - var bg = mxchatChat.bot_message_bg_color;
4419 - var fg = mxchatChat.bot_message_font_color;
4420 - if (!bg && !fg) return '';
4421 - return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"';
4422 - }
4423 -
4424 - // Reads the rating bubble's actual computed fg+bg (whatever paints it —
4425 - // the inline color pickers OR the mxchat-theme AI customizer's injected CSS)
4426 - // and paints the filled "Send" pill so it fills with the bot font color and
4427 - // labels in the bubble bg. Mirrors mxchatSyncMenuColors(~:1512) for the read.
4428 - // We paint the submit button DIRECTLY (inline longhand) rather than relying
4429 - // on the CSS rule's var()s: Chromium resolves an INHERITED custom property
4430 - // unreliably inside a descendant's `background`, so a bubble-level var would
4431 - // silently fall back to the literal (white-block bug all over again). Inline
4432 - // longhand always wins. Same transparent-guard as the menu so we never paint
4433 - // a see-through value — in that case the CSS literal fallbacks keep it legible.
4434 - function syncRatingBubbleColors(botId) {
4435 - var $chatBox = getChatBoxByBotId(botId);
4436 - if (!$chatBox || !$chatBox.length) return;
4437 - var bubbleEl = $chatBox.find('.mxchat-rating-bot-bubble').last()[0];
4438 - if (!bubbleEl) return;
4439 - var cs = window.getComputedStyle(bubbleEl);
4440 - var fg = cs.color;
4441 - var bg = cs.backgroundColor;
4442 - var hasFg = fg && fg !== 'rgba(0, 0, 0, 0)' && fg !== 'transparent';
4443 - var hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
4444 - // Expose on the bubble too, for any inheriting styles / future use.
4445 - if (hasFg) bubbleEl.style.setProperty('--mxchat-bot-fg', fg);
4446 - if (hasBg) bubbleEl.style.setProperty('--mxchat-bot-bg', bg);
4447 - // Paint the Send pill directly — the part that actually fixes the bug.
4448 - var submitEl = bubbleEl.querySelector('.mxchat-rating-submit');
4449 - if (submitEl) {
4450 - if (hasFg) submitEl.style.backgroundColor = fg; // fill = bot font color
4451 - if (hasBg) submitEl.style.color = bg; // label = bubble background
4452 - }
4453 - }
4454 -
4455 - function copy(key) {
4456 - var c = mxchatChat.satisfaction_rating_copy || {};
4457 - var d = {
4458 - question: 'Was this helpful?',
4459 - helpful: 'Helpful',
4460 - not_helpful: 'Not helpful',
4461 - dismiss: 'Dismiss',
4462 - thanks: 'Thanks! Anything we should improve? (optional)',
4463 - placeholder: 'Tell us what could be better…',
4464 - send: 'Send',
4465 - skip: 'Skip',
4466 - saved: 'Thanks for the feedback.'
4467 - };
4468 - return c[key] || d[key];
4469 - }
4470 -
4471 - function thumbUpSvg() {
4472 - 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>';
4473 - }
4474 - function thumbDownSvg() {
4475 - 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>';
4476 - }
4477 -
4478 - function buildPromptHtml(botId) {
4479 - var styleAttr = botBubbleStyleAttr(botId);
4480 - return ''
4481 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4482 - + '<div class="mxchat-rating-prompt" data-bot-id="' + esc(botId) + '" role="group" aria-label="' + esc(copy('question')) + '">'
4483 - + '<div class="mxchat-rating-question">' + esc(copy('question')) + '</div>'
4484 - + '<div class="mxchat-rating-actions">'
4485 - + '<span class="mxchat-rating-buttons">'
4486 - + '<button type="button" class="mxchat-rating-btn" data-rating="1" aria-label="' + esc(copy('helpful')) + '">' + thumbUpSvg() + '</button>'
4487 - + '<button type="button" class="mxchat-rating-btn" data-rating="-1" aria-label="' + esc(copy('not_helpful')) + '">' + thumbDownSvg() + '</button>'
4488 - + '</span>'
4489 - + '<button type="button" class="mxchat-rating-dismiss" aria-label="' + esc(copy('dismiss')) + '">×</button>'
4490 - + '</div>'
4491 - + '</div>'
4492 - + '</div>';
4493 - }
4494 -
4495 - function buildFeedbackHtml(botId, rating) {
4496 - var styleAttr = botBubbleStyleAttr(botId);
4497 - return ''
4498 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4499 - + '<div class="mxchat-rating-feedback" data-bot-id="' + esc(botId) + '" data-rating="' + esc(String(rating)) + '">'
4500 - + '<div class="mxchat-rating-feedback-label">' + esc(copy('thanks')) + '</div>'
4501 - + '<textarea class="mxchat-rating-feedback-input" maxlength="500" placeholder="' + esc(copy('placeholder')) + '" rows="2"></textarea>'
4502 - + '<div class="mxchat-rating-feedback-actions">'
4503 - + '<button type="button" class="mxchat-rating-skip">' + esc(copy('skip')) + '</button>'
4504 - + '<button type="button" class="mxchat-rating-submit">' + esc(copy('send')) + '</button>'
4505 - + '</div>'
4506 - + '</div>'
4507 - + '</div>';
4508 - }
4509 -
4510 - function buildSavedHtml(botId) {
4511 - var styleAttr = botBubbleStyleAttr(botId);
4512 - return ''
4513 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4514 - + '<div class="mxchat-rating-saved">' + esc(copy('saved')) + '</div>'
4515 - + '</div>';
4516 - }
4517 -
4518 - function getChatBoxByBotId(botId) {
4519 - var $byId = $('#chat-box-' + botId);
4520 - if ($byId.length) return $byId.first();
4521 - return $('.chat-box').first();
4522 - }
4523 -
4524 - function scrollChatBoxToBottom($chatBox) {
4525 - if (!$chatBox || !$chatBox.length) return;
4526 - $chatBox.scrollTop($chatBox[0].scrollHeight);
4527 - }
4528 -
4529 - function showPrompt(botId) {
4530 - var s = getState(botId);
4531 - if (s.promptShown || s.dismissed) return;
4532 - var sessionId = getSessionId(botId);
4533 - if (!sessionId) return;
4534 - if (isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4535 - var $chatBox = getChatBoxByBotId(botId);
4536 - if (!$chatBox.length) return;
4537 - if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; }
4538 - $chatBox.append(buildPromptHtml(botId));
4539 - syncRatingBubbleColors(botId);
4540 - s.promptShown = true;
4541 - scrollChatBoxToBottom($chatBox);
4542 - }
4543 -
4544 - function submitRating(botId, rating, feedback) {
4545 - var sessionId = getSessionId(botId);
4546 - if (!sessionId) return;
4547 - $.post(mxchatChat.ajax_url, {
4548 - action: 'mxchat_save_rating',
4549 - session_id: sessionId,
4550 - bot_id: botId,
4551 - rating: rating,
4552 - feedback: feedback || ''
4553 - });
4554 - markRated(sessionId);
4555 - }
4556 -
4557 - function onBotReply(botId) {
4558 - var s = getState(botId);
4559 - s.botReplies += 1;
4560 - if (s.promptShown || s.dismissed) return;
4561 - var sessionId = getSessionId(botId);
4562 - if (sessionId && isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4563 - if (s.botReplies < MIN_BOT_REPLIES) return;
4564 - if (s.idleTimer) clearTimeout(s.idleTimer);
4565 - s.idleTimer = setTimeout(function() { showPrompt(botId); }, IDLE_MS);
4566 - }
4567 -
4568 - function onUserMessage(botId) {
4569 - var s = getState(botId);
4570 - if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = null; }
4571 - }
4572 -
4573 - function botIdFromChatBox(el) {
4574 - var id = el && el.id ? el.id : '';
4575 - return id.indexOf('chat-box-') === 0 ? id.substring('chat-box-'.length) : 'default';
4576 - }
4577 -
4578 - function setupObserver(chatBox) {
4579 - var botId = botIdFromChatBox(chatBox);
4580 - try {
4581 - var observer = new MutationObserver(function(mutations) {
4582 - mutations.forEach(function(m) {
4583 - for (var i = 0; i < m.addedNodes.length; i++) {
4584 - var node = m.addedNodes[i];
4585 - if (!node || node.nodeType !== 1) continue;
4586 - var $n = $(node);
4587 - if ($n.hasClass('mxchat-rating-bot-bubble') || $n.hasClass('mxchat-rating-prompt') || $n.hasClass('mxchat-rating-feedback') || $n.hasClass('mxchat-rating-saved')) continue;
4588 - 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)
4589 - else if ($n.hasClass('user-message')) onUserMessage(botId);
4590 - }
4591 - });
4592 - });
4593 - observer.observe(chatBox, { childList: true });
4594 - } catch (e) { /* noop */ }
4595 - }
4596 -
4597 - $('.chat-box').each(function() { setupObserver(this); });
4598 -
4599 - $(document).on('click', '.mxchat-rating-btn', function(e) {
4600 - e.preventDefault();
4601 - var $btn = $(this);
4602 - var $prompt = $btn.closest('.mxchat-rating-prompt');
4603 - var $wrap = $btn.closest('.mxchat-rating-bot-bubble');
4604 - var botId = $prompt.data('bot-id') || 'default';
4605 - var rating = parseInt($btn.attr('data-rating'), 10);
4606 - if (rating !== 1 && rating !== -1) return;
4607 - submitRating(botId, rating, '');
4608 - ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating));
4609 - syncRatingBubbleColors(botId);
4610 - scrollChatBoxToBottom(getChatBoxByBotId(botId));
4611 - });
4612 -
4613 - $(document).on('click', '.mxchat-rating-dismiss', function(e) {
4614 - e.preventDefault();
4615 - var $prompt = $(this).closest('.mxchat-rating-prompt');
4616 - var $wrap = $(this).closest('.mxchat-rating-bot-bubble');
4617 - var botId = $prompt.data('bot-id') || 'default';
4618 - var s = getState(botId);
4619 - s.dismissed = true;
4620 - markRated(getSessionId(botId));
4621 - ($wrap.length ? $wrap : $prompt).remove();
4622 - });
4623 -
4624 - function closeFeedback($fb) {
4625 - var botId = $fb.data('bot-id') || 'default';
4626 - var $wrap = $fb.closest('.mxchat-rating-bot-bubble');
4627 - ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId));
4628 - syncRatingBubbleColors(botId);
4629 - scrollChatBoxToBottom(getChatBoxByBotId(botId));
4630 - }
4631 -
4632 - $(document).on('click', '.mxchat-rating-skip', function(e) {
4633 - e.preventDefault();
4634 - closeFeedback($(this).closest('.mxchat-rating-feedback'));
4635 - });
4636 -
4637 - $(document).on('click', '.mxchat-rating-submit', function(e) {
4638 - e.preventDefault();
4639 - var $fb = $(this).closest('.mxchat-rating-feedback');
4640 - var botId = $fb.data('bot-id') || 'default';
4641 - var rating = parseInt($fb.attr('data-rating'), 10);
4642 - if (rating !== 1 && rating !== -1) { closeFeedback($fb); return; }
4643 - var text = String($fb.find('.mxchat-rating-feedback-input').val() || '').trim();
4644 - if (text !== '') {
4645 - submitRating(botId, rating, text);
4646 - }
4647 - closeFeedback($fb);
4648 - });
4649 3248 });
4650 3249