PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.0.3
MxChat – AI Chatbot & Content Generation for WordPress v3.0.3
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
← All changes | js/chat-script.js +589 -2106 3.2.213.0.3 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,43 +58,18 @@
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) {
288 - // Keep the greeting, drop everything else. Identify the greeting
289 - // by its marker class, NOT by position (plan a1a79b): after a
290 - // chat-persistence restore the first .bot-message is a real
291 - // reply, so ":not(:first)" left a stale answer sitting at the
292 - // top of an otherwise empty box. The positional fallback only
293 - // runs when the marker is absent — a page served from HTML cache
294 - // that predates this release — and behaves exactly as before.
295 - if ($chatBox.find('.mxchat-intro-message').length) {
296 - $chatBox.find('.user-message, .bot-message:not(.mxchat-intro-message), .agent-message').remove();
297 - } else {
298 - $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove();
299 - }
66 + $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove();
300 67 }
301 68 if (this.instances[botId]) {
302 69 this.instances[botId].chatHistoryLoaded = false;
303 70 this.instances[botId].processedMessageIds = new Set();
304 71 }
305 - },
306 -
307 - // Silent reset — new session ID without clearing the chat UI
308 - // Used when IP changes mid-conversation so the user doesn't see messages vanish
309 - silentResetSession: function(botId) {
310 - try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {}
311 - var newSessionId = generateSessionId();
312 - this.setChatSession(botId, newSessionId);
313 - if (this.instances[botId]) {
314 - this.instances[botId].sessionId = newSessionId;
315 - }
316 - return newSessionId;
317 72 }
318 73 };
319 74
320 75 // ====================================
@@ -354,20 +109,8 @@
354 109 var id = $floating.attr('id') || '';
355 110 var match = id.match(/floating-chatbot-(.+)/);
356 111 if (match) return match[1];
357 112 }
358 - // Fallback: the pre-chat teaser bubble (#pre-chat-message-{bot_id}) is a SIBLING
359 - // outside .mxchat-chatbot-wrapper / .floating-chatbot, so its children — e.g. the
360 - // .close-pre-chat-message button, which carries only a class and no id — miss both
361 - // branches above. Walk to the nearest ancestor whose id is pre-chat-message-{bot_id}
362 - // and read the suffix. (closest() includes the element itself, so a click directly on
363 - // #pre-chat-message-{bot_id} resolves here too.)
364 - var $preChat = $(element).closest('[id^="pre-chat-message-"]');
365 - if ($preChat.length) {
366 - var preId = $preChat.attr('id') || '';
367 - var preMatch = preId.match(/^pre-chat-message-(.+)$/);
368 - if (preMatch) return preMatch[1];
369 - }
370 113 // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
371 114 var elementId = $(element).attr('id') || '';
372 115 if (elementId) {
373 116 // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
@@ -407,27 +150,9 @@
407 150 if (parts.length == 2) return parts.pop().split(";").shift();
408 151 }
409 152
410 153 function generateSessionId() {
411 - // Session IDs function as the de-facto bearer token for an anonymous
412 - // chat, so generate them with a CSPRNG when available. Math.random is a
413 - // legacy fallback for ancient/sandboxed environments that lack
414 - // window.crypto. The 'mxchat_chat_' prefix is preserved exactly (other
415 - // code pattern-matches on it). (plan-0c17b5)
416 - var rand;
417 - try {
418 - if (window.crypto && window.crypto.getRandomValues) {
419 - var buf = new Uint8Array(16); // 128 bits
420 - window.crypto.getRandomValues(buf);
421 - rand = Array.prototype.map.call(buf, function (b) {
422 - return ('0' + b.toString(16)).slice(-2);
423 - }).join('');
424 - }
425 - } catch (e) {}
426 - if (!rand) {
427 - rand = Math.random().toString(36).substr(2, 9); // legacy fallback
428 - }
429 - return 'mxchat_chat_' + rand;
154 + return 'mxchat_chat_' + Math.random().toString(36).substr(2, 9);
430 155 }
431 156
432 157 // Legacy function - now delegates to instance manager
433 158 function getChatSession(botId) {
@@ -630,26 +355,8 @@
630 355 sendButton.style.pointerEvents = 'none';
631 356 }
632 357 }
633 358
634 -// Whether the input may grab focus after a completed reply (plan 03799f).
635 -// On coarse-pointer devices focusing a text input summons the on-screen
636 -// keyboard over the answer the visitor is trying to read, so 'auto' (the
637 -// default) focuses only on fine-pointer devices. The site-wide
638 -// mxchat_autofocus_after_reply PHP filter can force 'on'/'off'.
639 -// NOT used on widget open (:~3424) — that focus is a deliberate act and is
640 -// what makes the widget keyboard-accessible.
641 -function mxchatShouldAutofocusAfterReply() {
642 - var pref = (typeof mxchatChat !== 'undefined' && mxchatChat.autofocus_after_reply) || 'auto';
643 - if (pref === 'on') return true;
644 - if (pref === 'off') return false;
645 - try {
646 - return !window.matchMedia('(pointer: coarse)').matches;
647 - } catch (err) {
648 - return true;
649 - }
650 -}
651 -
652 359 function enableChatInput(botId) {
653 360 botId = botId || 'default';
654 361 var chatInput = getElementDOM(botId, 'chat-input');
655 362 var sendButton = getElementDOM(botId, 'send-button');
@@ -655,11 +362,9 @@
655 362 var sendButton = getElementDOM(botId, 'send-button');
656 363 if (chatInput) {
657 364 chatInput.disabled = false;
658 365 chatInput.style.opacity = '1';
659 - if (mxchatShouldAutofocusAfterReply()) {
660 - try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
661 - }
366 + chatInput.focus();
662 367 }
663 368 if (sendButton) {
664 369 sendButton.disabled = false;
665 370 sendButton.style.opacity = '1';
@@ -664,106 +369,13 @@
664 369 sendButton.disabled = false;
665 370 sendButton.style.opacity = '1';
666 371 sendButton.style.pointerEvents = 'auto';
667 372 }
668 - // Every completion path re-enables input, so this is the single restore
669 - // point for the streaming Stop affordance (no-op when not in stop mode).
670 - mxchatRestoreSendButton(botId);
671 373 }
672 374
673 -// --- Streaming Stop control -------------------------------------------------
674 -// One live stream handle per bot instance, so Stop on one widget never aborts
675 -// another bot on the same page.
676 -var mxchatActiveStreams = {};
677 -// Original send-button markup, captured once per bot the first time the Stop
678 -// state is shown (never captured while already in stop mode, so a rapid
679 -// stop-then-resend can't save the stop glyph as the "original").
680 -var mxchatSendMarkup = {};
681 -
682 -function mxchatShowStopButton(botId) {
683 - var btn = getElementDOM(botId, 'send-button');
684 - if (!btn) return;
685 - if (!btn.classList.contains('mxchat-stop-mode')) {
686 - mxchatSendMarkup[botId] = {
687 - html: btn.innerHTML,
688 - label: btn.getAttribute('aria-label')
689 - };
690 - }
691 -
692 - // Mirror the send icon's rendered size + color so the stop glyph looks
693 - // native, including custom send images/colors and theme overrides.
694 - var child = btn.querySelector('svg, img');
695 - var size = 25;
696 - var color = '';
697 - if (child) {
698 - var rect = child.getBoundingClientRect();
699 - if (rect.width) {
700 - size = Math.round(Math.min(rect.width, rect.height));
701 - }
702 - var cs = window.getComputedStyle(child);
703 - color = (child.tagName.toLowerCase() === 'svg' ? cs.fill : cs.color) || '';
704 - }
705 - var stopLabel = (typeof mxchatChat !== 'undefined' && mxchatChat.stop_button_label) || 'Stop response';
706 - 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>';
707 - // An add-on's DIRECT send-button handler (e.g. mxchat-vision's rebind) can
708 - // start a stream synchronously while the originating click is still
709 - // bubbling up to our delegated handler. Without this guard, that handler
710 - // reads the just-added stop-mode class as a user Stop press and aborts the
711 - // brand-new stream — the user's message renders but no reply ever fires
712 - // (plan-4bba64 silent message loss). The flag only spans the current event
713 - // dispatch: cleared on the next macrotask, long before a real Stop click.
714 - btn.__mxchatStopJustShown = true;
715 - setTimeout(function () { btn.__mxchatStopJustShown = false; }, 0);
716 - btn.classList.add('mxchat-stop-mode');
717 - btn.setAttribute('aria-label', stopLabel);
718 - btn.setAttribute('title', stopLabel);
719 - // disableChatInput() ran when the turn was sent; the Stop control itself
720 - // must stay clickable while the textarea remains disabled.
721 - btn.disabled = false;
722 - btn.style.opacity = '1';
723 - btn.style.pointerEvents = 'auto';
724 -}
725 -
726 -function mxchatRestoreSendButton(botId) {
727 - var btn = getElementDOM(botId, 'send-button');
728 - var saved = mxchatSendMarkup[botId];
729 - if (!btn || !btn.classList.contains('mxchat-stop-mode') || !saved) return;
730 - btn.innerHTML = saved.html;
731 - btn.classList.remove('mxchat-stop-mode');
732 - btn.removeAttribute('title');
733 - if (saved.label) {
734 - btn.setAttribute('aria-label', saved.label);
735 - }
736 -}
737 -
738 -function mxchatStopStreaming(botId) {
739 - var entry = mxchatActiveStreams[botId];
740 - if (!entry || !entry.controller) return;
741 - entry.aborted = true;
742 - try { entry.controller.abort(); } catch (e) {}
743 -}
744 -
745 -// Returns true when a stream rejection came from an intentional Stop click:
746 -// keep the partial text as the turn's answer — no error UI, no fallback resend.
747 -function mxchatHandleStreamAbort(botId, accumulatedContent, callback) {
748 - var entry = mxchatActiveStreams[botId];
749 - if (!entry || !entry.aborted) return false;
750 - delete mxchatActiveStreams[botId];
751 - if (!accumulatedContent) {
752 - // Stopped before the first chunk: drop the thinking bubble, no orphan message.
753 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
754 - }
755 - enableChatInput(botId); // also restores the send icon
756 - if (callback) {
757 - callback(accumulatedContent || '');
758 - }
759 - return true;
760 -}
761 -
762 375 // Update your existing sendMessage function
763 376 function sendMessage(botId) {
764 377 botId = botId || 'default';
765 - MxChatInstances.ensureSession(botId);
766 378 var $chatInput = getElement(botId, 'chat-input');
767 379 var message = $chatInput.val();
768 380
769 381 // ADD PROMPT HOOK HERE
@@ -771,18 +383,13 @@
771 383 message = customMxChatFilter(message, "prompt");
772 384 }
773 385
774 386 if (message) {
775 - // Don't disable input in live agent mode - let users chat freely
776 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
777 - var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
778 - if (!isAgentMode) {
779 - disableChatInput(botId);
780 - }
387 + // Disable input while waiting for response
388 + disableChatInput(botId);
781 389
782 390 appendMessage("user", message, '', [], false, botId);
783 391 $chatInput.val('');
784 - mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
785 392 $chatInput.css('height', 'auto');
786 393
787 394 if (hasQuickQuestions(botId)) {
788 395 collapseQuickQuestions(botId);
@@ -789,16 +396,14 @@
789 396 }
790 397 appendThinkingMessage(botId);
791 398 scrollToBottom(botId);
792 399
793 - const currentModel = mxchatChat.model || 'gpt-5.6-sol';
400 + const currentModel = mxchatChat.model || 'gpt-4o';
794 401
795 402 // Check if streaming is enabled AND supported for this model
796 403 if (shouldUseStreaming(currentModel)) {
797 404 callMxChatStream(message, function(response) {
798 - // Content is final: releasing aria-busy lets the live region
799 - // announce the completed reply once (plan 67f126).
800 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
405 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
801 406 }, botId);
802 407 } else {
803 408 callMxChat(message, function(response) {
804 409 replaceLastMessage("bot", response, '', [], botId);
@@ -809,9 +414,8 @@
809 414
810 415 // Update your existing sendMessageToChatbot function
811 416 function sendMessageToChatbot(message, botId) {
812 417 botId = botId || 'default';
813 - MxChatInstances.ensureSession(botId);
814 418
815 419 // ADD PROMPT HOOK HERE
816 420 if (typeof customMxChatFilter === 'function') {
817 421 message = customMxChatFilter(message, "prompt");
@@ -816,14 +420,10 @@
816 420 if (typeof customMxChatFilter === 'function') {
817 421 message = customMxChatFilter(message, "prompt");
818 422 }
819 423
820 - // Don't disable input in live agent mode - let users chat freely
821 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
822 - var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
823 - if (!isAgentMode) {
824 - disableChatInput(botId);
825 - }
424 + // Disable input while waiting for response
425 + disableChatInput(botId);
826 426
827 427 var sessionId = getChatSession(botId);
828 428
829 429 if (hasQuickQuestions(botId)) {
@@ -831,15 +431,14 @@
831 431 }
832 432 appendThinkingMessage(botId);
833 433 scrollToBottom(botId);
834 434
835 - const currentModel = mxchatChat.model || 'gpt-5.6-sol';
435 + const currentModel = mxchatChat.model || 'gpt-4o';
836 436
837 437 // Check if streaming is enabled AND supported for this model
838 438 if (shouldUseStreaming(currentModel)) {
839 439 callMxChatStream(message, function(response) {
840 - // Final content — release aria-busy so the reply announces once (67f126).
841 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
440 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
842 441 }, botId);
843 442 } else {
844 443 callMxChat(message, function(response) {
845 444 getElement(botId, 'chat-box').find('.temporary-message').remove();
@@ -903,15 +502,8 @@
903 502
904 503 function callMxChat(message, callback, botId) {
905 504 botId = botId || getMxChatBotId();
906 505
907 - // Streaming fallbacks land here: drop any leftover stream handle and
908 - // return the button to its send state (no-op for plain non-stream turns).
909 - if (mxchatActiveStreams[botId]) {
910 - delete mxchatActiveStreams[botId];
911 - }
912 - mxchatRestoreSendButton(botId);
913 -
914 506 // Store the message in case we need to retry after session reset
915 507 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
916 508
917 509 // Get page context if contextual awareness is enabled
@@ -916,44 +508,24 @@
916 508
917 509 // Get page context if contextual awareness is enabled
918 510 const pageContext = getPageContext();
919 511
920 - // Get instance for session start timestamp (used when persistence is OFF)
921 - var instance = MxChatInstances.get(botId);
922 -
923 - // Guarantee a non-null session_id before the AJAX leaves. ensureSession() is idempotent
924 - // and returns the guaranteed-present session id from the in-memory instance even when
925 - // cookie/localStorage writes are silently blocked by the browser.
926 - var sessionId = MxChatInstances.ensureSession(botId);
927 - if (!sessionId || sessionId === 'null' || sessionId === 'undefined') {
928 - // Last-resort generation to ensure we never POST a null marker.
929 - sessionId = generateSessionId();
930 - MxChatInstances.setChatSession(botId, sessionId);
931 - }
932 -
933 - // Wait for the page-cache nonce refresh to complete before firing the
934 - // chat-send AJAX. On cached pages the inline mxchatChat.nonce is stale
935 - // until refreshNonceIfNeeded() returns; constructing ajaxData inside the
936 - // callback guarantees we read the fresh value. See plan-c5457f.
937 - refreshNonceIfNeeded(function() {
938 512 // Prepare AJAX data
939 513 const ajaxData = {
940 514 action: 'mxchat_handle_chat_request',
941 515 message: message,
942 - session_id: sessionId,
516 + session_id: getChatSession(botId),
943 517 nonce: mxchatChat.nonce,
944 518 current_page_url: window.location.href,
945 519 current_page_title: document.title,
946 - bot_id: botId,
947 - // Pass session start timestamp so AI context matches what user sees
948 - session_start_timestamp: instance.sessionStartTimestamp || 0
520 + bot_id: botId
949 521 };
950 -
522 +
951 523 // Add page context if available
952 524 if (pageContext) {
953 525 ajaxData.page_context = JSON.stringify(pageContext);
954 526 }
955 -
527 +
956 528 // CHECK FOR VISION FLAGS AND ADD THEM
957 529 if (window.mxchatVisionProcessed) {
958 530 ajaxData.vision_processed = true;
959 531 ajaxData.original_user_message = window.mxchatOriginalMessage || message;
@@ -962,9 +534,9 @@
962 534 window.mxchatVisionProcessed = false;
963 535 window.mxchatOriginalMessage = null;
964 536 window.mxchatVisionImagesCount = 0;
965 537 }
966 -
538 +
967 539 $.ajax({
968 540 url: mxchatChat.ajax_url,
969 541 type: 'POST',
970 542 dataType: 'json',
@@ -1002,20 +574,26 @@
1002 574 errorMessage = "An error occurred. Please try again or contact support.";
1003 575 }
1004 576
1005 577 // Handle session reset action (IP changed, session expired, etc.)
1006 - // Silent reset — keep chat UI intact, just get a new session and retry
1007 578 if (response.data && response.data.action === 'reset_session') {
1008 - MxChatInstances.silentResetSession(botId);
1009 - // 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
1010 584 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1011 585 if (originalMessage) {
1012 586 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1013 - 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';
1014 593 if (shouldUseStreaming(currentModel)) {
1015 594 callMxChatStream(originalMessage, function(response) {
1016 - // Final content — release aria-busy so the reply announces once (67f126).
1017 - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
595 + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message');
1018 596 }, botId);
1019 597 } else {
1020 598 callMxChat(originalMessage, function(response) {
1021 599 replaceLastMessage("bot", response, '', [], botId);
@@ -1069,11 +647,9 @@
1069 647 }
1070 648
1071 649 // Check for live agent response
1072 650 if (response.success && response.data && response.data.status === 'waiting_for_agent') {
1073 - removeThinkingDots(botId);
1074 651 updateChatModeIndicator('agent', botId);
1075 - enableChatInput(botId);
1076 652 return;
1077 653 }
1078 654
1079 655 // Handle the message and show notification if chat is hidden
@@ -1106,13 +682,9 @@
1106 682 $badge.show();
1107 683 }
1108 684 }
1109 685 } else {
1110 - var emptyMsg = "I received an empty response. Please try again or contact support if this persists.";
1111 - if (response.vectorstore_error) {
1112 - emptyMsg = "I received an empty response. Debug info: " + response.vectorstore_error;
1113 - }
1114 - replaceLastMessage("bot", emptyMsg, '', [], botId);
686 + replaceLastMessage("bot", "I received an empty response. Please try again or contact support if this persists.", '', [], botId);
1115 687 }
1116 688
1117 689 if (response.message_id) {
1118 690 var instance = MxChatInstances.get(botId);
@@ -1154,9 +726,8 @@
1154 726
1155 727 replaceLastMessage("bot", errorMessage, '', [], botId);
1156 728 }
1157 729 });
1158 - }); // refreshNonceIfNeeded
1159 730 }
1160 731
1161 732 function callMxChatStream(message, callback, botId) {
1162 733 botId = botId || getMxChatBotId();
@@ -1163,9 +734,9 @@
1163 734
1164 735 // Store the message in case we need to retry after session reset
1165 736 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
1166 737
1167 - const currentModel = mxchatChat.model || 'gpt-5.6-sol';
738 + const currentModel = mxchatChat.model || 'gpt-4o';
1168 739 if (!isStreamingSupported(currentModel)) {
1169 740 callMxChat(message, callback, botId);
1170 741 return;
1171 742 }
@@ -1172,35 +743,17 @@
1172 743
1173 744 // Get page context if contextual awareness is enabled
1174 745 const pageContext = getPageContext();
1175 746
1176 - // Get instance for session start timestamp (used when persistence is OFF)
1177 - var instance = MxChatInstances.get(botId);
1178 -
1179 - // Guarantee a non-null session_id before the fetch. FormData.append() stringifies any
1180 - // non-string value via String(), so passing `null` would POST the literal string "null"
1181 - // and land in the transcripts table as a ghost session. ensureSession() always returns
1182 - // a real string even when cookies/localStorage are blocked.
1183 - var streamSessionId = MxChatInstances.ensureSession(botId);
1184 - if (!streamSessionId || streamSessionId === 'null' || streamSessionId === 'undefined') {
1185 - streamSessionId = generateSessionId();
1186 - MxChatInstances.setChatSession(botId, streamSessionId);
1187 - }
1188 -
1189 - // Wait for the page-cache nonce refresh before constructing formData (which
1190 - // captures mxchatChat.nonce by value). Mirrors callMxChat's wrapping. See plan-c5457f.
1191 - refreshNonceIfNeeded(function() {
1192 747 const formData = new FormData();
1193 748 formData.append('action', 'mxchat_stream_chat');
1194 749 formData.append('message', message);
1195 - formData.append('session_id', streamSessionId);
750 + formData.append('session_id', getChatSession(botId));
1196 751 formData.append('nonce', mxchatChat.nonce);
1197 752 formData.append('current_page_url', window.location.href);
1198 753 formData.append('current_page_title', document.title);
1199 754 formData.append('bot_id', botId);
1200 - // Pass session start timestamp so AI context matches what user sees
1201 - formData.append('session_start_timestamp', instance.sessionStartTimestamp || 0);
1202 -
755 +
1203 756 // Add page context if available
1204 757 if (pageContext) {
1205 758 formData.append('page_context', JSON.stringify(pageContext));
1206 759 }
@@ -1218,25 +771,13 @@
1218 771
1219 772 let accumulatedContent = '';
1220 773 let testingDataReceived = false;
1221 774 let streamingStarted = false;
1222 - // Server-pushed html to append as its OWN bot bubble once the stream
1223 - // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is
1224 - // deferred to [DONE] so the embed always lands BELOW the streamed text.
1225 - let pendingAppendHtml = '';
1226 775
1227 - // Abortable stream: a fresh controller per turn, keyed by bot instance.
1228 - // The Stop control (send button swapped in place) aborts both the read
1229 - // loop and the underlying request.
1230 - var streamControl = { controller: new AbortController(), aborted: false };
1231 - mxchatActiveStreams[botId] = streamControl;
1232 - mxchatShowStopButton(botId);
1233 -
1234 776 fetch(mxchatChat.ajax_url, {
1235 777 method: 'POST',
1236 778 body: formData,
1237 - credentials: 'same-origin',
1238 - signal: streamControl.controller.signal
779 + credentials: 'same-origin'
1239 780 })
1240 781 .then(response => {
1241 782 // Store the response for potential fallback handling
1242 783 const responseClone = response.clone();
@@ -1305,16 +846,8 @@
1305 846
1306 847 // Re-enable chat input when stream ends with content
1307 848 enableChatInput(botId);
1308 849
1309 - // Scroll the user's last message to the top now that the
1310 - // bot's full reply has rendered (gives max reading room).
1311 - var $chatBoxDone = getElement(botId, 'chat-box');
1312 - var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
1313 - if ($lastUserMsgDone.length) {
1314 - scrollElementToTop($lastUserMsgDone, botId);
1315 - }
1316 -
1317 850 if (callback) {
1318 851 callback(accumulatedContent);
1319 852 }
1320 853 return;
@@ -1337,25 +870,8 @@
1337 870
1338 871 // Re-enable chat input after streaming completes
1339 872 enableChatInput(botId);
1340 873
1341 - // Render any server-pushed appendix html (e.g. the
1342 - // YouTube embed) as its own bot bubble below the
1343 - // streamed text — mirrors how it is saved in the
1344 - // transcript, so history replays identically.
1345 - if (pendingAppendHtml) {
1346 - appendMessage("bot", "", pendingAppendHtml, [], false, botId);
1347 - pendingAppendHtml = '';
1348 - }
1349 -
1350 - // Scroll the user's last message to the top now
1351 - // that the bot's full reply has rendered.
1352 - var $chatBoxStreamDone = getElement(botId, 'chat-box');
1353 - var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
1354 - if ($lastUserMsgStreamDone.length) {
1355 - scrollElementToTop($lastUserMsgStreamDone, botId);
1356 - }
1357 -
1358 874 if (callback) {
1359 875 callback(accumulatedContent);
1360 876 }
1361 877 return;
@@ -1381,21 +897,8 @@
1381 897 streamingStarted = true;
1382 898 accumulatedContent += json.content;
1383 899 updateStreamingMessage(accumulatedContent, botId);
1384 900 }
1385 - // Stash appendix html (e.g. video embed) for [DONE]
1386 - else if (json.append_html) {
1387 - pendingAppendHtml = json.append_html;
1388 - }
1389 - // Server-side final pass changed the assembled text
1390 - // (ffef6f: dead-link stripping) — swap the rendered
1391 - // bubble for the validated version. Arrives at most
1392 - // once, just before [DONE].
1393 - else if (json.replace_content) {
1394 - streamingStarted = true;
1395 - accumulatedContent = json.replace_content;
1396 - updateStreamingMessage(accumulatedContent, botId);
1397 - }
1398 901 // Handle complete response in stream (fallback response)
1399 902 else if (json.text || json.message || json.html) {
1400 903 handleNonStreamResponse(json, callback, botId);
1401 904 return;
@@ -1425,9 +928,8 @@
1425 928 }
1426 929
1427 930 processStream();
1428 931 }).catch(streamError => {
1429 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1430 932 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1431 933 callMxChat(message, callback, botId);
1432 934 });
1433 935 }
@@ -1434,9 +936,8 @@
1434 936
1435 937 processStream();
1436 938 })
1437 939 .catch(error => {
1438 - if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1439 940 // Check if we have server error data with chat mode
1440 941 if (error && error.isServerError && error.data) {
1441 942 // Check for chat mode in error data
1442 943 if (error.data.chat_mode) {
@@ -1449,9 +950,8 @@
1449 950 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1450 951 callMxChat(message, callback, botId);
1451 952 }
1452 953 });
1453 - }); // refreshNonceIfNeeded
1454 954 }
1455 955
1456 956 // Helper function to handle non-streaming responses
1457 957 function handleNonStreamResponse(data, callback, botId) {
@@ -1490,16 +990,21 @@
1490 990 errorMessage = "An error occurred. Please try again or contact support.";
1491 991 }
1492 992
1493 993 // Handle session reset action (IP changed, session expired, etc.)
1494 - // Silent reset — keep chat UI intact, just get a new session and retry
1495 994 if (data.data && data.data.action === 'reset_session') {
1496 - MxChatInstances.silentResetSession(botId);
1497 - // Re-send the original message with the new session (user message is already displayed)
995 + // Clear the old session and generate a new one
996 + resetChatSession(botId);
997 + // Re-send the original message with the new session
1498 998 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1499 999 if (originalMessage) {
1500 1000 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1501 - var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1001 + // Re-add the user message and thinking indicator
1002 + appendMessage("user", originalMessage, '', [], false, botId);
1003 + appendThinkingMessage(botId);
1004 + scrollToBottom(botId);
1005 + // Determine whether to use streaming
1006 + const currentModel = mxchatChat.model || 'gpt-4o';
1502 1007 if (shouldUseStreaming(currentModel)) {
1503 1008 callMxChatStream(originalMessage, callback, botId);
1504 1009 } else {
1505 1010 callMxChat(originalMessage, callback, botId);
@@ -1521,22 +1026,8 @@
1521 1026 }
1522 1027 return; // Exit early for errors
1523 1028 }
1524 1029
1525 - // Check for live agent response
1526 - if (data.success && data.data && data.data.status === 'waiting_for_agent') {
1527 - removeThinkingDots(botId);
1528 - // Also remove any leftover bot-message that lost its temporary-message class
1529 - var $chatBox = getElement(botId, 'chat-box');
1530 - $chatBox.find('.bot-message .thinking-dots').closest('.bot-message').remove();
1531 - updateChatModeIndicator('agent', botId);
1532 - enableChatInput(botId);
1533 - if (callback) {
1534 - callback('');
1535 - }
1536 - return;
1537 - }
1538 -
1539 1030 // Handle different response formats
1540 1031 if (data.text || data.html || data.message) {
1541 1032
1542 1033 // Apply response hooks
@@ -1581,15 +1072,19 @@
1581 1072 }
1582 1073
1583 1074 // Enhanced updateChatModeIndicator function for immediate DOM updates
1584 1075 function updateChatModeIndicator(mode, botId) {
1076 + console.log('[MxChat] updateChatModeIndicator called with mode:', mode, 'botId:', botId);
1585 1077 botId = botId || 'default';
1586 1078 const indicator = getElementDOM(botId, 'chat-mode-indicator');
1079 + console.log('[MxChat] chat-mode-indicator element found:', !!indicator);
1587 1080 if (indicator) {
1588 1081 const oldText = indicator.textContent;
1082 + console.log('[MxChat] Current indicator text:', oldText, '-> changing to mode:', mode);
1589 1083
1590 1084 if (mode === 'agent') {
1591 1085 indicator.textContent = 'Live Agent';
1086 + console.log('[MxChat] Mode is agent, calling startPolling...');
1592 1087 startPolling(botId);
1593 1088 } else {
1594 1089 // Everything else is AI mode
1595 1090 const customAiText = indicator.getAttribute('data-ai-text') || 'AI Agent';
@@ -1632,15 +1127,8 @@
1632 1127 var $chatBox = getElement(botId, 'chat-box');
1633 1128 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1634 1129
1635 1130 if (tempMessage.length) {
1636 - // aria-busy=true for the whole stream: the bubble is rewritten on
1637 - // every chunk, and without busy a polite live region announces those
1638 - // rewrites continuously. Flipped false once the reply is final, so
1639 - // assistive tech announces the completed message ONCE (plan 67f126).
1640 - if (tempMessage.attr('aria-busy') !== 'true') {
1641 - tempMessage.attr('aria-busy', 'true');
1642 - }
1643 1131 // Update existing message
1644 1132 tempMessage.html(formattedContent);
1645 1133 } else {
1646 1134 // Create new temporary message if it doesn't exist
@@ -1667,21 +1155,9 @@
1667 1155 // Update the event handlers to use the correct function names (using event delegation)
1668 1156 // Use class-based selectors for multi-instance support
1669 1157 $(document).on('click', '.send-button', function() {
1670 1158 var botId = getBotIdFromElement(this);
1671 - // While a response is streaming the button is a Stop control.
1672 - if (this.classList.contains('mxchat-stop-mode')) {
1673 - // Same click that just started this stream (an add-on's direct handler
1674 - // ran before this delegated one) — not a Stop press. See
1675 - // mxchatShowStopButton for the full story (plan-4bba64).
1676 - if (this.__mxchatStopJustShown) return;
1677 - mxchatStopStreaming(botId);
1678 - return;
1679 - }
1680 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1681 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1682 - disableChatInput(botId);
1683 - }
1159 + disableChatInput(botId);
1684 1160 sendMessage(botId);
1685 1161 });
1686 1162
1687 1163 // Override enter key handler (using event delegation)
@@ -1688,378 +1164,14 @@
1688 1164 $(document).on('keypress', '.chat-input', function(e) {
1689 1165 if (e.which == 13 && !e.shiftKey) {
1690 1166 e.preventDefault();
1691 1167 var botId = getBotIdFromElement(this);
1692 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1693 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1694 - disableChatInput(botId);
1695 - }
1168 + disableChatInput(botId);
1696 1169 sendMessage(botId);
1697 1170 }
1698 1171 });
1699 1172
1700 -// Chat input character counter + soft limit feedback (plan 7091a2).
1701 -// Language-neutral: numbers + color only, no translatable strings. The counter
1702 -// reveals near the cap and ramps neutral -> amber -> red; an over-limit keystroke
1703 -// or trimmed paste produces a brief border-flash/shake so the maxlength cap (plan
1704 -// a3fae2) is never a silent "input jumps back". Per-bot scoped via .input-container.
1705 -function mxchatUpdateCharCounter(inputEl) {
1706 - if (!inputEl || !inputEl.closest) return;
1707 - var max = parseInt(inputEl.getAttribute('maxlength'), 10);
1708 - var container = inputEl.closest('.input-container');
1709 - if (!container || !max || max <= 0) return;
1710 - var counter = container.querySelector('.mxchat-char-counter');
1711 - if (!counter) return;
1712 - var len = inputEl.value.length;
1713 - var ratio = len / max;
1714 - var nearThreshold = 0.8; // start surfacing the counter at 80% of the cap
1715 - var cur = counter.querySelector('.mxchat-char-counter-current');
1716 - if (cur) cur.textContent = len;
1717 - var warn = ratio >= nearThreshold && len < max;
1718 - var full = len >= max;
1719 - counter.classList.toggle('is-visible', ratio >= nearThreshold);
1720 - counter.classList.toggle('is-warn', warn);
1721 - counter.classList.toggle('is-full', full);
1722 - container.classList.toggle('mxchat-input-near-limit', warn);
1723 - container.classList.toggle('mxchat-input-at-limit', full);
1724 -}
1725 -
1726 -function mxchatBumpInput(inputEl) {
1727 - var container = inputEl && inputEl.closest ? inputEl.closest('.input-container') : null;
1728 - if (!container) return;
1729 - container.classList.remove('mxchat-input-bump');
1730 - void container.offsetWidth; // reflow so a rapid second hit retriggers the animation
1731 - container.classList.add('mxchat-input-bump');
1732 - clearTimeout($(container).data('mxchatBumpTimeout'));
1733 - var t = setTimeout(function() { container.classList.remove('mxchat-input-bump'); }, 220);
1734 - $(container).data('mxchatBumpTimeout', t);
1735 -}
1736 -
1737 -// Live counter update on every input.
1738 -$(document).on('input', '.chat-input', function() {
1739 - mxchatUpdateCharCounter(this);
1740 -});
1741 -
1742 -// Visible "you've hit the edge" feedback when a printable keystroke is about to be
1743 -// rejected at the cap (maxlength silently swallows it otherwise).
1744 -$(document).on('keydown', '.chat-input', function(e) {
1745 - var max = parseInt(this.getAttribute('maxlength'), 10);
1746 - if (!max || max <= 0 || this.value.length < max) return;
1747 - if (e.ctrlKey || e.metaKey || e.altKey) return;
1748 - // A single printable char with no selection to overwrite WILL be rejected.
1749 - if (e.key && e.key.length === 1 && this.selectionStart === this.selectionEnd) {
1750 - mxchatBumpInput(this);
1751 - }
1752 -});
1753 -
1754 -// A paste that gets trimmed to the cap also bumps, so truncation is never silent.
1755 -$(document).on('paste', '.chat-input', function() {
1756 - var el = this;
1757 - var max = parseInt(el.getAttribute('maxlength'), 10);
1758 - if (!max || max <= 0) return;
1759 - setTimeout(function() {
1760 - mxchatUpdateCharCounter(el);
1761 - if (el.value.length >= max) mxchatBumpInput(el);
1762 - }, 0);
1763 -});
1764 -
1765 -// Builds the list of overflow-menu items for a given bot.
1766 -// Adding a future item is one push to this array — do NOT hardcode "only download."
1767 -function mxchatGetHeaderMenuItems(botId) {
1768 - var items = [];
1769 - var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1770 -
1771 - // The `print_button_*` keys still gate this item for back-compat with
1772 - // existing user options. The action is now a transcript download, not print.
1773 - if (settings.print_button_enabled === 'on') {
1774 - items.push({
1775 - id: 'download-transcript',
1776 - label: settings.print_button_label || 'Download Transcript',
1777 - 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>',
1778 - action: function() {
1779 - mxchatDownloadTranscript(botId);
1780 - }
1781 - });
1782 - }
1783 -
1784 - // "Start new chat" — surfaces the EXISTING per-conversation reset
1785 - // (MxChatInstances.resetChatSession) so a visitor can start a fresh thread
1786 - // without the site owner disabling chat persistence globally. Default OFF;
1787 - // gated by the reset_chat_enabled option. plan ac2e81.
1788 - if (settings.reset_chat_enabled === 'on') {
1789 - items.push({
1790 - id: 'reset-chat',
1791 - label: settings.reset_chat_label || 'Start new chat',
1792 - 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>',
1793 - action: function() {
1794 - var confirmMsg = settings.reset_chat_confirm || 'Start a new chat? This clears the current conversation.';
1795 - if (window.confirm(confirmMsg)) {
1796 - MxChatInstances.resetChatSession(botId);
1797 - }
1798 - }
1799 - });
1800 - }
1801 -
1802 - return items;
1803 -}
1804 -
1805 -// Builds a clean markdown transcript of the current conversation and triggers
1806 -// a file download. Used by the "Download Transcript" menu item.
1807 -function mxchatDownloadTranscript(botId) {
1808 - var $chatBox = getElement(botId, 'chat-box');
1809 - if (!$chatBox || !$chatBox.length) return;
1810 -
1811 - var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1812 - var headerTitle = settings.print_header_title || 'Chat transcript';
1813 - var now = new Date();
1814 - var stamp = now.toLocaleString();
1815 -
1816 - var lines = [];
1817 - lines.push('# ' + headerTitle);
1818 - lines.push('');
1819 - lines.push('Exported: ' + stamp);
1820 - lines.push('');
1821 - lines.push('---');
1822 - lines.push('');
1823 -
1824 - $chatBox.find('.user-message, .bot-message, .agent-message').each(function() {
1825 - var $msg = $(this);
1826 - // Skip thinking placeholders and any in-flight temporary messages.
1827 - if ($msg.find('.thinking-dots').length) return;
1828 - if ($msg.hasClass('temporary-message')) return;
1829 -
1830 - var sender;
1831 - if ($msg.hasClass('user-message')) sender = 'User';
1832 - else if ($msg.hasClass('agent-message')) sender = 'Live Agent';
1833 - else sender = 'AI Agent';
1834 -
1835 - // Strip interactive UI from the cloned message so we get the conversation text.
1836 - var $clone = $msg.clone();
1837 - $clone.find('.copy-button, .message-toolbar, .mxchat-copy, button, script, style').remove();
1838 - var text = $clone.text().replace(/ /g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
1839 - if (!text) return;
1840 -
1841 - lines.push('**' + sender + '**');
1842 - lines.push('');
1843 - lines.push(text);
1844 - lines.push('');
1845 - });
1846 -
1847 - var content = lines.join('\n');
1848 - var iso = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
1849 - var fname = 'mxchat-transcript-' + iso + '.md';
1850 - var blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
1851 - var url = URL.createObjectURL(blob);
1852 - var a = document.createElement('a');
1853 - a.href = url;
1854 - a.download = fname;
1855 - a.style.display = 'none';
1856 - document.body.appendChild(a);
1857 - a.click();
1858 - setTimeout(function() {
1859 - if (a.parentNode) a.parentNode.removeChild(a);
1860 - URL.revokeObjectURL(url);
1861 - }, 100);
1862 -}
1863 -
1864 -// Reads the bot bubble's actual computed bg+fg and writes them as CSS vars
1865 -// on the menu wrap, so the dropdown matches whatever paints the bubble —
1866 -// saved options, AI theme CSS, or the mxchat-theme add-on.
1867 -function mxchatSyncMenuColors(botId, $wrap) {
1868 - if (!$wrap || !$wrap.length) return;
1869 - var $bot = $wrap.closest('.mxchat-chatbot-wrapper').find('.bot-message').not('.temporary-message').first();
1870 - if (!$bot.length) return;
1871 - var cs = window.getComputedStyle($bot[0]);
1872 - if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') {
1873 - $wrap[0].style.setProperty('--mxchat-menu-bg', cs.backgroundColor);
1874 - }
1875 - // Bot text color usually lives on a child div, not .bot-message itself.
1876 - var $textChild = $bot.find('[style*="color"]').first();
1877 - var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
1878 - if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
1879 -}
1880 -
1881 -// Renders (or re-renders) the item list for one menu wrap. Split out of
1882 -// mxchatInitHeaderMenu so the dynamic-settings merge (plan-32db95) can
1883 -// rebuild items + trigger visibility WITHOUT re-binding the one-time
1884 -// open/close/keyboard wiring. closeMenu is passed in by the init closure;
1885 -// a rebuild before init (never happens, but harmless) just skips it.
1886 -function mxchatRenderHeaderMenuItems(botId, $wrap, closeMenuFn) {
1887 - var $trigger = $wrap.find('.mxchat-menu-trigger');
1888 - var $menu = $wrap.find('.mxchat-header-menu');
1889 - var items = mxchatGetHeaderMenuItems(botId);
1890 -
1891 - $menu.empty();
1892 -
1893 - if (!items.length) {
1894 - $trigger.hide();
1895 - $menu.hide();
1896 - return;
1897 - }
1898 -
1899 - // Clear any inline display:none a previous zero-item render left behind —
1900 - // open/close visibility is governed by the hidden prop + is-open class.
1901 - $trigger.css('display', '');
1902 - $menu.css('display', '');
1903 -
1904 - items.forEach(function(item, idx) {
1905 - var $btn = $('<button>', {
1906 - type: 'button',
1907 - 'class': 'mxchat-menu-item',
1908 - 'role': 'menuitem',
1909 - 'tabindex': '-1',
1910 - 'data-menu-id': item.id,
1911 - html: '<span class="mxchat-menu-item-icon">' + item.icon + '</span>' +
1912 - '<span class="mxchat-menu-item-label"></span>'
1913 - });
1914 - $btn.find('.mxchat-menu-item-label').text(item.label);
1915 - $btn.on('click', function(e) {
1916 - e.preventDefault();
1917 - e.stopPropagation();
1918 - if (closeMenuFn) closeMenuFn();
1919 - try { item.action(); } catch (err) { /* no-op */ }
1920 - });
1921 - $menu.append($btn);
1922 - });
1923 -}
1924 -
1925 -// Re-render every menu on the page after a dynamic-settings merge
1926 -// (multi-bot: each wrap re-reads its items). An OPEN menu is left alone —
1927 -// swapping items under the user mid-interaction yanks focus — and the
1928 -// rebuild runs when it closes instead (closeMenu checks the pending flag).
1929 -function mxchatRebuildHeaderMenus() {
1930 - $('.mxchat-header-menu-wrap').each(function() {
1931 - var $wrap = $(this);
1932 - var botId = $wrap.data('bot-id');
1933 - if (!botId) return;
1934 - if (!$wrap.data('mxchatMenuReady')) {
1935 - mxchatInitHeaderMenu(botId);
1936 - return;
1937 - }
1938 - if ($wrap.find('.mxchat-header-menu').hasClass('is-open')) {
1939 - $wrap.data('mxchatMenuRebuildPending', true);
1940 - return;
1941 - }
1942 - mxchatRenderHeaderMenuItems(botId, $wrap, $wrap.data('mxchatMenuClose'));
1943 - });
1944 -}
1945 -
1946 -// One-time per-widget init: renders menu items, wires open/close,
1947 -// outside-click, Escape, and arrow-key navigation. If no items, hides the
1948 -// trigger. Wiring happens even when there are zero items at init, so a
1949 -// later dynamic-settings rebuild that adds items has a working trigger.
1950 -function mxchatInitHeaderMenu(botId) {
1951 - var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
1952 - if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
1953 -
1954 - var $trigger = $wrap.find('.mxchat-menu-trigger');
1955 - var $menu = $wrap.find('.mxchat-header-menu');
1956 -
1957 - // Initial color sync — covers normal page load.
1958 - mxchatSyncMenuColors(botId, $wrap);
1959 -
1960 - function openMenu() {
1961 - // Re-sync each open in case the active theme changed since init.
1962 - mxchatSyncMenuColors(botId, $wrap);
1963 - $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
1964 - $trigger.attr('aria-expanded', 'true');
1965 - // Focus the first item for keyboard users
1966 - setTimeout(function() {
1967 - $menu.find('.mxchat-menu-item').first().attr('tabindex', '0').trigger('focus');
1968 - }, 0);
1969 - }
1970 - function closeMenu(returnFocus) {
1971 - $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
1972 - $trigger.attr('aria-expanded', 'false');
1973 - $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
1974 - if (returnFocus) $trigger.trigger('focus');
1975 - // A dynamic-settings rebuild that arrived while the menu was open
1976 - // was deferred (mxchatRebuildHeaderMenus) — run it now.
1977 - if ($wrap.data('mxchatMenuRebuildPending')) {
1978 - $wrap.removeData('mxchatMenuRebuildPending');
1979 - mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
1980 - }
1981 - }
1982 -
1983 - // Toggle on trigger click — stop propagation so the .chatbot-top-bar
1984 - // click-to-collapse handler does not fire.
1985 - $trigger.on('click', function(e) {
1986 - e.preventDefault();
1987 - e.stopPropagation();
1988 - if ($menu.hasClass('is-open')) closeMenu();
1989 - else openMenu();
1990 - });
1991 -
1992 - // Don't let clicks inside the menu bubble to the top-bar collapse handler.
1993 - $menu.on('click', function(e) {
1994 - e.stopPropagation();
1995 - });
1996 -
1997 - // Outside click closes the menu.
1998 - $(document).on('click.mxchatMenu-' + botId, function(e) {
1999 - if (!$menu.hasClass('is-open')) return;
2000 - if ($wrap.has(e.target).length || $wrap.is(e.target)) return;
2001 - closeMenu();
2002 - });
2003 -
2004 - // Keyboard: Escape closes and returns focus; arrow keys move focus; Enter activates.
2005 - $menu.on('keydown', '.mxchat-menu-item', function(e) {
2006 - var $items = $menu.find('.mxchat-menu-item');
2007 - var idx = $items.index(this);
2008 - if (e.key === 'Escape') {
2009 - e.preventDefault();
2010 - closeMenu(true);
2011 - } else if (e.key === 'ArrowDown') {
2012 - e.preventDefault();
2013 - var $next = $items.eq((idx + 1) % $items.length);
2014 - $items.attr('tabindex', '-1');
2015 - $next.attr('tabindex', '0').trigger('focus');
2016 - } else if (e.key === 'ArrowUp') {
2017 - e.preventDefault();
2018 - var $prev = $items.eq((idx - 1 + $items.length) % $items.length);
2019 - $items.attr('tabindex', '-1');
2020 - $prev.attr('tabindex', '0').trigger('focus');
2021 - } else if (e.key === 'Enter' || e.key === ' ') {
2022 - e.preventDefault();
2023 - $(this).trigger('click');
2024 - }
2025 - });
2026 - $trigger.on('keydown', function(e) {
2027 - if (e.key === 'Escape' && $menu.hasClass('is-open')) {
2028 - e.preventDefault();
2029 - closeMenu(true);
2030 - } else if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !$menu.hasClass('is-open')) {
2031 - e.preventDefault();
2032 - openMenu();
2033 - }
2034 - });
2035 -
2036 - // Expose closeMenu for out-of-closure re-renders (mxchatRebuildHeaderMenus),
2037 - // then do the initial item render.
2038 - $wrap.data('mxchatMenuClose', closeMenu);
2039 - mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
2040 -
2041 - $wrap.data('mxchatMenuReady', true);
2042 -}
2043 -
2044 -// Initialize header menus for every rendered widget on DOM ready.
2045 -$(function() {
2046 - $('.mxchat-header-menu-wrap').each(function() {
2047 - var botId = $(this).data('bot-id');
2048 - if (botId) mxchatInitHeaderMenu(botId);
2049 - });
2050 -
2051 - // Embedded (non-floating) widgets are open from the moment the page
2052 - // renders — refresh dynamic settings at init (plan-32db95). Floating
2053 - // widgets refresh on first launcher open instead.
2054 - var hasEmbeddedWidget = $('.mxchat-chatbot-wrapper').filter(function() {
2055 - return !$(this).closest('.floating-chatbot').length;
2056 - }).length > 0;
2057 - if (hasEmbeddedWidget) {
2058 - mxchatRefreshDynamicSettings();
2059 - }
2060 -});
2061 -
1173 +
2062 1174 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
2063 1175 try {
2064 1176 // Determine styles based on sender type
2065 1177 let messageClass, bgColor, fontColor;
@@ -2097,12 +1209,17 @@
2097 1209 'margin-bottom': '1em'
2098 1210 });
2099 1211 }
2100 1212
2101 - // Process the message content - always run linkify to convert markdown
2102 - // links and format text. linkify() handles existing HTML safely via
2103 - // negative lookaheads that skip URLs already inside <a> tags.
2104 - let fullMessage = linkify(messageText);
1213 + // Process the message content based on sender
1214 + let fullMessage;
1215 + if (sender === "user") {
1216 + // For user messages, apply linkify after sanitization
1217 + fullMessage = linkify(messageText);
1218 + } else {
1219 + // For bot/agent messages, preserve HTML
1220 + fullMessage = messageText;
1221 + }
2105 1222
2106 1223 // Add images if provided
2107 1224 if (images && images.length > 0) {
2108 1225 fullMessage += '<div class="image-gallery" dir="auto">';
@@ -2134,11 +1251,9 @@
2134 1251
2135 1252 messageDiv.html(fullMessage);
2136 1253
2137 1254 if (isTemporary) {
2138 - // In-flight bubble: hold aria-busy so the live region stays quiet
2139 - // until the content is finalized (plan 67f126).
2140 - messageDiv.addClass('temporary-message').attr('aria-busy', 'true');
1255 + messageDiv.addClass('temporary-message');
2141 1256 }
2142 1257
2143 1258 // Append to the correct chatbot instance's chat-box
2144 1259 var $chatBox = getElement(botId, 'chat-box');
@@ -2153,12 +1268,8 @@
2153 1268 if (lastUserMessage.length) {
2154 1269 scrollElementToTop(lastUserMessage, botId);
2155 1270 }
2156 1271 }
2157 -
2158 - if ((sender === "bot" || sender === "agent") && !isTemporary) {
2159 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2160 - }
2161 1272 });
2162 1273
2163 1274 if (messageText.id) {
2164 1275 var instance = MxChatInstances.get(botId);
@@ -2243,12 +1354,26 @@
2243 1354 bgColor = botMessageBgColor;
2244 1355 fontColor = botMessageFontColor;
2245 1356 }
2246 1357
2247 - // Always run linkify to convert markdown links and format text.
2248 - // linkify() already handles existing HTML (its URL patterns use negative lookaheads
2249 - // to avoid double-processing URLs that are already inside <a> tags).
2250 - var fullMessage = linkify(responseText);
1358 + // FIXED: Only linkify if response doesn't already contain HTML links or tags
1359 + // This prevents double-processing of URLs that are already formatted as HTML
1360 + var fullMessage;
1361 + if (sender === "user") {
1362 + // Always linkify user messages (they're plain text)
1363 + fullMessage = linkify(responseText);
1364 + } else {
1365 + // For bot/agent messages, check if HTML already exists
1366 + if (responseText.includes('<a href=') || responseText.includes('</a>') ||
1367 + responseText.includes('<img') || responseText.includes('<div') ||
1368 + responseText.includes('<p>') || responseText.includes('<br>')) {
1369 + // Response already has HTML, don't process it
1370 + fullMessage = responseText;
1371 + } else {
1372 + // Plain text response, apply linkify
1373 + fullMessage = linkify(responseText);
1374 + }
1375 + }
2251 1376
2252 1377 if (responseHtml) {
2253 1378 // Only add line breaks if there's actual text content before the HTML
2254 1379 if (fullMessage && fullMessage.trim()) {
@@ -2273,16 +1398,13 @@
2273 1398 }
2274 1399
2275 1400 if (lastMessageDiv.length) {
2276 1401 // Replace content immediately to prevent visual gap between thinking dots and response
2277 - // aria-busy released AFTER the final content is set, so the live region
2278 - // announces the finished message once (plan 67f126).
2279 1402 lastMessageDiv
2280 1403 .html(fullMessage)
2281 1404 .removeClass('bot-message user-message temporary-message')
2282 1405 .addClass(messageClass)
2283 - .attr('dir', 'auto')
2284 - .attr('aria-busy', 'false');
1406 + .attr('dir', 'auto');
2285 1407
2286 1408 // Only apply inline colors if AI theme is not active (let CSS handle it)
2287 1409 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
2288 1410 if (!skipColors) {
@@ -2308,12 +1430,8 @@
2308 1430 }
2309 1431
2310 1432 // Re-enable chat input after response is displayed
2311 1433 enableChatInput(botId);
2312 -
2313 - if (sender === "bot" || sender === "agent") {
2314 - if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2315 - }
2316 1434 } else {
2317 1435 appendMessage(sender, responseText, responseHtml, images, false, botId);
2318 1436 // Re-enable chat input after response is displayed
2319 1437 enableChatInput(botId);
@@ -2322,15 +1440,8 @@
2322 1440
2323 1441
2324 1442 function appendThinkingMessage(botId) {
2325 1443 botId = botId || 'default';
2326 -
2327 - // Don't show thinking dots in live agent mode - message is just forwarded to a human
2328 - var indicator = getElementDOM(botId, 'chat-mode-indicator');
2329 - if (indicator && indicator.textContent === 'Live Agent') {
2330 - return;
2331 - }
2332 -
2333 1444 var $chatBox = getElement(botId, 'chat-box');
2334 1445
2335 1446 // Remove any existing thinking dots in this bot's chat first
2336 1447 $chatBox.find('.thinking-dots').remove();
@@ -2342,15 +1453,10 @@
2342 1453 var botMessageFontColor = mxchatChat.bot_message_font_color;
2343 1454 var botMessageBgColor = mxchatChat.bot_message_bg_color;
2344 1455
2345 1456 // Build thinking dots HTML - skip inline colors if AI theme is active
2346 - // The dots are decorative; the sr-only span is what the live region
2347 - // announces for the waiting state (plan 67f126). Server-localized
2348 - // string — safe to inject (esc_html__ output, no user content).
2349 1457 var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
2350 - var srThinking = mxchatChat.thinking_announcement || 'Assistant is typing';
2351 - var thinkingHtml = '<span class="sr-only">' + srThinking + '</span>' +
2352 - '<div class="thinking-dots-container" aria-hidden="true">' +
1458 + var thinkingHtml = '<div class="thinking-dots-container">' +
2353 1459 '<div class="thinking-dots">' +
2354 1460 '<span class="dot"' + dotStyle + '></span>' +
2355 1461 '<span class="dot"' + dotStyle + '></span>' +
2356 1462 '<span class="dot"' + dotStyle + '></span>' +
@@ -2357,9 +1463,9 @@
2357 1463 '</div>' +
2358 1464 '</div>';
2359 1465
2360 1466 // Append the thinking dots to this bot's chat container - skip inline colors if AI theme is active
2361 - var messageStyle = skipColors ? '' : ' style="background-color: ' + botMessageBgColor + '; color: ' + botMessageFontColor + ';"';
1467 + var messageStyle = skipColors ? '' : ' style="background-color: ' + botMessageBgColor + ';"';
2362 1468 $chatBox.append('<div class="bot-message temporary-message"' + messageStyle + '>' + thinkingHtml + '</div>');
2363 1469 scrollToBottom(botId);
2364 1470 }
2365 1471
@@ -2365,11 +1471,9 @@
2365 1471
2366 1472 function removeThinkingDots(botId) {
2367 1473 botId = botId || 'default';
2368 1474 var $chatBox = getElement(botId, 'chat-box');
2369 - // Remove by temporary-message class first, then fall back to any bot-message containing thinking dots
2370 1475 $chatBox.find('.thinking-dots').closest('.temporary-message').remove();
2371 - $chatBox.find('.bot-message .thinking-dots').closest('.bot-message').remove();
2372 1476 }
2373 1477
2374 1478 // ====================================
2375 1479 // TEXT FORMATTING & PROCESSING
@@ -2403,12 +1507,9 @@
2403 1507 processedText = formatTextStyling(processedText);
2404 1508
2405 1509 // Process code blocks BEFORE processing links
2406 1510 processedText = formatCodeBlocks(processedText);
2407 -
2408 - // Process markdown tables BEFORE converting newlines to paragraphs
2409 - processedText = formatMarkdownTables(processedText);
2410 -
1511 +
2411 1512 // NOW convert to paragraphs
2412 1513 processedText = convertNewlinesToBreaks(processedText);
2413 1514
2414 1515 // IMPORTANT: Handle citation-style brackets FIRST [URL]
@@ -2421,63 +1522,37 @@
2421 1522 // Return as a proper link without the brackets
2422 1523 return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2423 1524 });
2424 1525
2425 - // Process markdown links: [text](url) and [](url)
2426 - // Uses balanced parenthesis matching to handle URLs containing parens
2427 - // (e.g. PDF filenames with dates like (2025-08-28).pdf)
2428 - processedText = (function(input) {
2429 - var result = '';
2430 - var i = 0;
2431 - while (i < input.length) {
2432 - // Look for [ at current position
2433 - if (input[i] === '[') {
2434 - // Find closing ]
2435 - var closeBracket = input.indexOf(']', i + 1);
2436 - if (closeBracket === -1 || closeBracket + 1 >= input.length || input[closeBracket + 1] !== '(') {
2437 - result += input[i];
2438 - i++;
2439 - continue;
2440 - }
2441 - var linkText = input.substring(i + 1, closeBracket);
2442 - // Check if URL starts with http
2443 - var urlStart = closeBracket + 2;
2444 - if (!input.substring(urlStart).match(/^https?:\/\//)) {
2445 - result += input[i];
2446 - i++;
2447 - continue;
2448 - }
2449 - // Find balanced closing paren
2450 - var depth = 1;
2451 - var j = urlStart;
2452 - while (j < input.length && depth > 0) {
2453 - if (input[j] === '(') depth++;
2454 - else if (input[j] === ')') depth--;
2455 - if (depth > 0) j++;
2456 - }
2457 - if (depth !== 0) {
2458 - result += input[i];
2459 - i++;
2460 - continue;
2461 - }
2462 - var url = input.substring(urlStart, j);
2463 - var cleanUrl = url.replace(/[\].,;!?]+$/, '');
2464 - var encodedUrl = safeEncodeUrl(cleanUrl);
2465 - if (!linkText || !linkText.trim()) {
2466 - result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + cleanUrl + '</a>';
2467 - } else {
2468 - var safeText = sanitizeUserInput(linkText);
2469 - result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + safeText + '</a>';
2470 - }
2471 - i = j + 1; // Skip past the closing )
2472 - } else {
2473 - result += input[i];
2474 - i++;
2475 - }
1526 + // Process proper markdown links with text: [text](url)
1527 + // This MUST have non-empty text in the first brackets
1528 + const markdownLinkPattern = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
1529 + processedText = processedText.replace(markdownLinkPattern, (match, text, url) => {
1530 + // Make sure we have actual text (not just whitespace)
1531 + if (!text || !text.trim()) {
1532 + // If no text, treat the URL as the text
1533 + let cleanUrl = url.replace(/[.,;!?]+$/, '');
1534 + const safeUrl = safeEncodeUrl(cleanUrl);
1535 + return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2476 1536 }
2477 - return result;
2478 - })(processedText);
1537 +
1538 + // Clean the URL
1539 + let cleanUrl = url.replace(/[\].,;!?]+$/, '');
1540 + const safeUrl = safeEncodeUrl(cleanUrl);
1541 + const safeText = sanitizeUserInput(text);
1542 + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
1543 + });
2479 1544
1545 + // Handle empty markdown links: [](url)
1546 + // This is a specific case where there's no text
1547 + const emptyMarkdownPattern = /\[\]\((https?:\/\/[^\s)]+)\)/g;
1548 + processedText = processedText.replace(emptyMarkdownPattern, (match, url) => {
1549 + let cleanUrl = url.replace(/[.,;!?]+$/, '');
1550 + const safeUrl = safeEncodeUrl(cleanUrl);
1551 + // Use the URL itself as the link text
1552 + return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
1553 + });
1554 +
2480 1555 // Process phone numbers: [text](tel:number)
2481 1556 const phonePattern = /\[([^\]]+)\]\((tel:[\d+\-\s()]+)\)/g;
2482 1557 processedText = processedText.replace(phonePattern, (match, text, phone) => {
2483 1558 const safePhone = safeEncodeUrl(phone);
@@ -2629,78 +1704,9 @@
2629 1704 });
2630 1705
2631 1706 return text;
2632 1707 }
2633 -
2634 - function formatMarkdownTables(text) {
2635 - var lines = text.split('\n');
2636 - var result = [];
2637 - var i = 0;
2638 -
2639 - while (i < lines.length) {
2640 - // Check for a table: current line has pipes AND next line is a separator row
2641 - if (i + 1 < lines.length &&
2642 - lines[i].indexOf('|') !== -1 &&
2643 - /^\s*\|?[\s\-:]+(\|[\s\-:]+)+\|?\s*$/.test(lines[i + 1])) {
2644 -
2645 - var tableLines = [];
2646 - var headerLine = lines[i];
2647 - var separatorLine = lines[i + 1];
2648 - tableLines.push(headerLine);
2649 - tableLines.push(separatorLine);
2650 -
2651 - // Collect remaining table rows
2652 - var j = i + 2;
2653 - while (j < lines.length && lines[j].indexOf('|') !== -1 && lines[j].trim() !== '') {
2654 - tableLines.push(lines[j]);
2655 - j++;
2656 - }
2657 -
2658 - // Parse alignment from separator row
2659 - var sepCells = separatorLine.split('|').filter(function(c) { return c.trim() !== ''; });
2660 - var alignments = sepCells.map(function(cell) {
2661 - var trimmed = cell.trim();
2662 - if (trimmed.charAt(0) === ':' && trimmed.charAt(trimmed.length - 1) === ':') return 'center';
2663 - if (trimmed.charAt(trimmed.length - 1) === ':') return 'right';
2664 - return 'left';
2665 - });
2666 -
2667 - // Build HTML table
2668 - var html = '<div class="mxchat-table-wrapper"><table class="mxchat-table">';
2669 -
2670 - // Header row
2671 - var headerCells = tableLines[0].split('|').filter(function(c) { return c.trim() !== ''; });
2672 - html += '<thead><tr>';
2673 - headerCells.forEach(function(cell, idx) {
2674 - var align = alignments[idx] || 'left';
2675 - html += '<th style="text-align:' + align + '">' + cell.trim() + '</th>';
2676 - });
2677 - html += '</tr></thead>';
2678 -
2679 - // Body rows
2680 - html += '<tbody>';
2681 - for (var r = 2; r < tableLines.length; r++) {
2682 - var rowCells = tableLines[r].split('|').filter(function(c) { return c.trim() !== ''; });
2683 - html += '<tr>';
2684 - rowCells.forEach(function(cell, idx) {
2685 - var align = alignments[idx] || 'left';
2686 - html += '<td style="text-align:' + align + '">' + cell.trim() + '</td>';
2687 - });
2688 - html += '</tr>';
2689 - }
2690 - html += '</tbody></table></div>';
2691 -
2692 - result.push(html);
2693 - i = j;
2694 - } else {
2695 - result.push(lines[i]);
2696 - i++;
2697 - }
2698 - }
2699 -
2700 - return result.join('\n');
2701 - }
2702 -
1708 +
2703 1709 function sanitizeUserInput(text) {
2704 1710 const div = document.createElement('div');
2705 1711 div.textContent = text;
2706 1712 return div.innerHTML;
@@ -2771,14 +1777,13 @@
2771 1777 requestAnimationFrame(smoothScroll);
2772 1778 }
2773 1779 }
2774 1780
2775 - function scrollElementToTop(element, botId, topOffset) {
1781 + function scrollElementToTop(element, botId) {
2776 1782 botId = botId || 'default';
2777 - topOffset = (typeof topOffset === 'number') ? topOffset : 2;
2778 1783 var chatBox = getElement(botId, 'chat-box');
2779 1784 var elementTop = element.position().top + chatBox.scrollTop();
2780 - chatBox.animate({ scrollTop: Math.max(0, elementTop - topOffset) }, 500);
1785 + chatBox.animate({ scrollTop: elementTop }, 500);
2781 1786 }
2782 1787
2783 1788 function showChatWidget(botId) {
2784 1789 botId = botId || 'default';
@@ -2922,12 +1927,15 @@
2922 1927 // LIVE AGENT FUNCTIONALITY
2923 1928 // ====================================
2924 1929
2925 1930 function startPolling(botId) {
1931 + console.log('[MxChat] startPolling called for botId:', botId);
2926 1932 botId = botId || 'default';
2927 1933 var instance = MxChatInstances.get(botId);
2928 1934 // Clear any existing interval first
2929 1935 stopPolling(botId);
1936 + // Start new polling interval
1937 + console.log('[MxChat] Starting polling interval (5s) for botId:', botId);
2930 1938 instance.pollingInterval = setInterval(function() {
2931 1939 checkForAgentMessages(botId);
2932 1940 }, 5000);
2933 1941 }
@@ -2932,17 +1940,20 @@
2932 1940 }, 5000);
2933 1941 }
2934 1942
2935 1943 function stopPolling(botId) {
1944 + console.log('[MxChat] stopPolling called for botId:', botId);
2936 1945 botId = botId || 'default';
2937 1946 var instance = MxChatInstances.get(botId);
2938 1947 if (instance.pollingInterval) {
2939 1948 clearInterval(instance.pollingInterval);
2940 1949 instance.pollingInterval = null;
1950 + console.log('[MxChat] Polling stopped for botId:', botId);
2941 1951 }
2942 1952 }
2943 1953
2944 1954 function checkForAgentMessages(botId) {
1955 + console.log('[MxChat] checkForAgentMessages called for botId:', botId);
2945 1956 botId = botId || 'default';
2946 1957 var instance = MxChatInstances.get(botId);
2947 1958 const sessionId = getChatSession(botId);
2948 1959 $.ajax({
@@ -2968,12 +1979,8 @@
2968 1979 instance.processedMessageIds.add(message.id);
2969 1980 }
2970 1981 });
2971 1982
2972 - if (hasNewMessage) {
2973 - enableChatInput(botId);
2974 - }
2975 -
2976 1983 var $floatingChatbot = getElement(botId, 'floating-chatbot');
2977 1984 if (hasNewMessage && $floatingChatbot.hasClass('hidden')) {
2978 1985 showNotification(botId);
2979 1986 }
@@ -2979,13 +1986,8 @@
2979 1986 }
2980 1987
2981 1988 scrollToBottom(botId, true);
2982 1989 }
2983 -
2984 - // Handle chat mode transitions (e.g. agent ended chat via !endchat)
2985 - if (response.success && response.data?.chat_mode) {
2986 - updateChatModeIndicator(response.data.chat_mode, botId);
2987 - }
2988 1990 },
2989 1991 error: function (xhr, status, error) {
2990 1992 // Polling error - silently continue
2991 1993 }
@@ -2995,29 +1997,20 @@
2995 1997 // ====================================
2996 1998 // CHAT HISTORY & PERSISTENCE
2997 1999 // ====================================
2998 2000
2999 -function loadChatHistory(botId, onComplete) {
2001 +function loadChatHistory(botId) {
3000 2002 botId = botId || 'default';
3001 2003 var instance = MxChatInstances.get(botId);
3002 2004
3003 2005 // Prevent duplicate loading
3004 2006 if (instance.chatHistoryLoaded) {
3005 - if (onComplete) onComplete();
3006 2007 return;
3007 2008 }
3008 2009
3009 - // Use getChatSession which returns null if no session exists (does NOT create one)
3010 2010 var sessionId = getChatSession(botId);
3011 2011 var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3012 2012
3013 - // No session yet — nothing to load. History will load after first message via ensureSession.
3014 - if (!sessionId) {
3015 - instance.chatHistoryLoaded = true;
3016 - if (onComplete) onComplete();
3017 - return;
3018 - }
3019 -
3020 2013 if (chatPersistenceEnabled && sessionId) {
3021 2014 $.ajax({
3022 2015 url: mxchatChat.ajax_url,
3023 2016 type: 'POST',
@@ -3028,12 +2021,11 @@
3028 2021 },
3029 2022 success: function(response) {
3030 2023 // Handle session reset (IP changed while user was away)
3031 2024 if (response.success === false && response.data && response.data.action === 'reset_session') {
3032 - // Silent reset — new session but don't clear UI
3033 - MxChatInstances.silentResetSession(botId);
2025 + // Silently reset session - user will start fresh
2026 + resetChatSession(botId);
3034 2027 instance.chatHistoryLoaded = true; // Prevent retry loop
3035 - if (onComplete) onComplete();
3036 2028 return;
3037 2029 }
3038 2030
3039 2031 // Check if the response indicates success
@@ -3050,30 +2042,9 @@
3050 2042 }
3051 2043
3052 2044 // Only process if there are actual messages
3053 2045 if (response.data.conversation.length > 0) {
3054 - // Restored history must be SILENT to screen readers
3055 - // (plan 67f126): these are DOM additions inside the
3056 - // live region and would otherwise announce as if
3057 - // they just arrived. Lift aria-live for the batch
3058 - // repopulate, restore it after the browser has
3059 - // processed the mutations.
3060 - var mxLiveRegionEl = $chatBox.get(0);
3061 - var mxSavedAriaLive = mxLiveRegionEl ? mxLiveRegionEl.getAttribute('aria-live') : null;
3062 - if (mxLiveRegionEl) {
3063 - mxLiveRegionEl.setAttribute('aria-live', 'off');
3064 - }
3065 -
3066 - // IMPORTANT: Clear existing messages before loading history.
3067 - // Detach the greeting first and put it back below —
3068 - // it is server-rendered and never stored in the
3069 - // transcript, so the old unconditional .empty()
3070 - // deleted it for the rest of the page life (plan
3071 - // a1a79b). Detach rather than rebuild: intro_message
3072 - // is not localized to JS, and this node already
3073 - // carries the per-bot inline colors and any
3074 - // {visitor_name} substitution already applied to it.
3075 - var $mxIntro = $chatBox.find('.mxchat-intro-message').first().detach();
2046 + // IMPORTANT: Clear existing messages before loading history
3076 2047 $chatBox.empty();
3077 2048
3078 2049 $.each(response.data.conversation, function(index, message) {
3079 2050 // Skip agent messages if persistence is off
@@ -3110,23 +2081,9 @@
3110 2081 var content = message.content;
3111 2082 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
3112 2083 content = decodeHTMLEntities(content);
3113 2084
3114 - // Skip linkify for messages containing structured HTML
3115 - // (forms, product cards, galleries, etc.) to avoid
3116 - // markdown formatting corrupting HTML attributes
3117 - // (e.g. underscores in name="field_name" becoming <em> tags).
3118 - // One family check instead of a per-card literal list: any
3119 - // element carrying an mxchat- prefixed class is MxChat-generated
3120 - // structured markup and replays raw. The old list drifted every
3121 - // time an add-on minted a new card class — the filtered-search
3122 - // card ("mxchat-filtered-product-card") missed it and replayed
3123 - // through linkify as visible markup.
3124 - if (/<[a-z][^>]*class\s*=\s*["'][^"']*\bmxchat-/i.test(content) ||
3125 - content.includes("<form") ||
3126 - content.includes("<input") ||
3127 - content.includes("<select") ||
3128 - content.includes("<textarea")) {
2085 + if (content.includes("mxchat-product-card") || content.includes("mxchat-image-gallery")) {
3129 2086 messageElement.html(content);
3130 2087 } else {
3131 2088 var formattedContent = linkify(content);
3132 2089 messageElement.html(formattedContent);
@@ -3140,29 +2097,12 @@
3140 2097 instance.processedMessageIds.add(message.id);
3141 2098 }
3142 2099 });
3143 2100
3144 - // Only append messages and scroll if we have content.
3145 - // Greeting goes back FIRST, above the restored
3146 - // history: "Hello — [earlier conversation]" is the
3147 - // natural reading and matches the order a fresh
3148 - // visitor sees (plan a1a79b).
3149 - if ($mxIntro && $mxIntro.length) {
3150 - $chatBox.append($mxIntro);
3151 - }
2101 + // Only append messages and scroll if we have content
3152 2102 $chatBox.append($fragment);
3153 2103 scrollToBottom(botId, true);
3154 2104
3155 - // Re-attach live semantics AFTER the rehydration
3156 - // mutations have been processed with the region off
3157 - // (plan 67f126). Restoring later announces nothing
3158 - // retroactively; new turns announce normally.
3159 - if (mxLiveRegionEl) {
3160 - setTimeout(function() {
3161 - mxLiveRegionEl.setAttribute('aria-live', mxSavedAriaLive || 'polite');
3162 - }, 200);
3163 - }
3164 -
3165 2105 // Collapse quick questions if we have conversation history
3166 2106 // BUT skip auto-collapse for embedded bots (they should stay expanded)
3167 2107 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
3168 2108 collapseQuickQuestions(botId);
@@ -3183,17 +2123,13 @@
3183 2123 instance.chatHistoryLoaded = true;
3184 2124 }
3185 2125 }
3186 2126 }
3187 - if (onComplete) onComplete();
3188 2127 },
3189 2128 error: function(xhr, status, error) {
3190 2129 // Error loading chat history - silently continue
3191 - if (onComplete) onComplete();
3192 2130 }
3193 2131 });
3194 - } else {
3195 - if (onComplete) onComplete();
3196 2132 }
3197 2133 }
3198 2134
3199 2135
@@ -3257,10 +2193,10 @@
3257 2193 .then(data => {
3258 2194 if (data.success) {
3259 2195 container.style.display = 'none';
3260 2196 nameElement.textContent = '';
3261 - instance.activePdfFile = null;
3262 - appendMessage('bot', 'PDF removed.', '', [], false, botId);
2197 + activePdfFile = null;
2198 + appendMessage('bot', 'PDF removed.');
3263 2199 }
3264 2200 })
3265 2201 .catch(error => {
3266 2202 // Error removing PDF - silently continue
@@ -3266,16 +2202,14 @@
3266 2202 // Error removing PDF - silently continue
3267 2203 });
3268 2204 }
3269 2205
3270 - function removeActiveWord(botId) {
3271 - botId = botId || 'default';
3272 - var instance = MxChatInstances.get(botId);
3273 - const container = getElementDOM(botId, 'active-word-container');
3274 - const nameElement = getElementDOM(botId, 'active-word-name');
3275 -
3276 - if (!container || !nameElement || !instance.activeWordFile) return;
3277 -
2206 + function removeActiveWord() {
2207 + const container = document.getElementById('active-word-container');
2208 + const nameElement = document.getElementById('active-word-name');
2209 +
2210 + if (!container || !nameElement || !activeWordFile) return;
2211 +
3278 2212 fetch(mxchatChat.ajax_url, {
3279 2213 method: 'POST',
3280 2214 headers: {
3281 2215 'Content-Type': 'application/x-www-form-urlencoded',
@@ -3281,9 +2215,9 @@
3281 2215 'Content-Type': 'application/x-www-form-urlencoded',
3282 2216 },
3283 2217 body: new URLSearchParams({
3284 2218 'action': 'mxchat_remove_word',
3285 - 'session_id': getChatSession(botId),
2219 + 'session_id': sessionId,
3286 2220 'nonce': mxchatChat.nonce
3287 2221 })
3288 2222 })
3289 2223 .then(response => response.json())
@@ -3290,10 +2224,10 @@
3290 2224 .then(data => {
3291 2225 if (data.success) {
3292 2226 container.style.display = 'none';
3293 2227 nameElement.textContent = '';
3294 - instance.activeWordFile = null;
3295 - appendMessage('bot', 'Word document removed.', '', [], false, botId);
2228 + activeWordFile = null;
2229 + appendMessage('bot', 'Word document removed.');
3296 2230 }
3297 2231 })
3298 2232 .catch(error => {
3299 2233 // Error removing Word document - silently continue
@@ -3371,35 +2305,45 @@
3371 2305 // ====================================
3372 2306
3373 2307 function checkPreChatDismissal(botId) {
3374 2308 botId = botId || 'default';
3375 - try {
3376 - var dismissedAt = localStorage.getItem('mxchat_pre_chat_dismissed_' + botId);
3377 - if (dismissedAt) {
3378 - // Re-show after 24 hours
3379 - var elapsed = Date.now() - parseInt(dismissedAt, 10);
3380 - if (elapsed < 86400000) {
2309 + $.ajax({
2310 + url: mxchatChat.ajax_url,
2311 + type: 'POST',
2312 + data: {
2313 + action: 'mxchat_check_pre_chat_message_status',
2314 + _ajax_nonce: mxchatChat.nonce
2315 + },
2316 + success: function(response) {
2317 + if (response.success && !response.data.dismissed) {
2318 + getElement(botId, 'pre-chat-message').fadeIn(250);
2319 + } else {
3381 2320 getElement(botId, 'pre-chat-message').hide();
3382 - return;
3383 2321 }
3384 - // Expired — clear and show again
3385 - localStorage.removeItem('mxchat_pre_chat_dismissed_' + botId);
2322 + },
2323 + error: function() {
2324 + // Error checking pre-chat dismissal - silently continue
3386 2325 }
3387 - getElement(botId, 'pre-chat-message').fadeIn(250);
3388 - } catch (e) {
3389 - // localStorage unavailable — show the message
3390 - getElement(botId, 'pre-chat-message').fadeIn(250);
3391 - }
2326 + });
3392 2327 }
3393 2328
3394 2329 function handlePreChatDismissal(botId) {
3395 2330 botId = botId || 'default';
3396 2331 getElement(botId, 'pre-chat-message').fadeOut(200);
3397 - try {
3398 - localStorage.setItem('mxchat_pre_chat_dismissed_' + botId, String(Date.now()));
3399 - } catch (e) {
3400 - // localStorage unavailable — dismissal won't persist
3401 - }
2332 + $.ajax({
2333 + url: mxchatChat.ajax_url,
2334 + type: 'POST',
2335 + data: {
2336 + action: 'mxchat_dismiss_pre_chat_message',
2337 + _ajax_nonce: mxchatChat.nonce
2338 + },
2339 + success: function() {
2340 + $('#pre-chat-message').hide();
2341 + },
2342 + error: function() {
2343 + // Error dismissing pre-chat message - silently continue
2344 + }
2345 + });
3402 2346 }
3403 2347
3404 2348
3405 2349 // ====================================
@@ -3452,38 +2396,11 @@
3452 2396 e.stopPropagation();
3453 2397 var botId = getBotIdFromElement(this);
3454 2398 collapseQuickQuestions(botId);
3455 2399 });
3456 -
3457 -// Consent-safe YouTube embed (plan 03ba33): the server only ever ships a
3458 -// thumbnail facade — no Google iframe exists until the visitor taps play.
3459 -// Delegated so it also works for embeds restored from chat history.
3460 -$(document).on('click', '.mxchat-youtube-embed .mxchat-youtube-facade', function(e) {
3461 - e.preventDefault();
3462 - var $wrap = $(this).closest('.mxchat-youtube-embed');
3463 - var videoId = String($wrap.data('video-id') || '').replace(/[^A-Za-z0-9_-]/g, '');
3464 - if (!videoId) {
3465 - return;
3466 - }
3467 - var title = $wrap.find('.mxchat-youtube-title').text() || 'YouTube video';
3468 - var $iframe = $('<iframe>', {
3469 - src: 'https://www.youtube-nocookie.com/embed/' + videoId + '?autoplay=1&rel=0',
3470 - title: title,
3471 - allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
3472 - allowfullscreen: true,
3473 - frameborder: 0
3474 - }).addClass('mxchat-youtube-iframe');
3475 - $wrap.addClass('mxchat-youtube-playing');
3476 - $(this).replaceWith($iframe);
3477 -});
3478 2400
3479 2401 // Chatbot visibility toggle handlers - use class selector for multi-instance support
3480 - // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3481 - $(document).on('click keydown', '.floating-chatbot-button', function(e) {
3482 - if (e.type === 'keydown') {
3483 - if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
3484 - e.preventDefault();
3485 - }
2402 + $(document).on('click', '.floating-chatbot-button', function() {
3486 2403 var botId = getBotIdFromElement(this);
3487 2404 var $chatbot = getElement(botId, 'floating-chatbot');
3488 2405 var $badge = getElement(botId, 'chat-notification-badge');
3489 2406 var $preChat = getElement(botId, 'pre-chat-message');
@@ -3488,88 +2405,35 @@
3488 2405 var $badge = getElement(botId, 'chat-notification-badge');
3489 2406 var $preChat = getElement(botId, 'pre-chat-message');
3490 2407
3491 2408 if ($chatbot.hasClass('hidden')) {
3492 - $chatbot.removeClass('hidden').addClass('visible')
3493 - .attr('aria-modal', 'true').attr('role', 'dialog');
3494 - $(this).addClass('hidden').attr('aria-expanded', 'true');
2409 + $chatbot.removeClass('hidden').addClass('visible');
2410 + $(this).addClass('hidden');
3495 2411 $badge.hide(); // Hide notification when opening chat
3496 2412 disableScroll();
3497 2413 $preChat.fadeOut(250);
3498 -
3499 - // First open per page load: re-fetch behavior settings in case
3500 - // this page's inline values came from a stale full-page cache
3501 - // (plan-32db95). Idempotent — later opens are a no-op.
3502 - mxchatRefreshDynamicSettings();
3503 -
3504 - // Load chat history for returning visitors (persistence)
3505 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3506 - if (chatPersistenceEnabled) {
3507 - MxChatInstances.ensureSession(botId);
3508 - }
3509 -
3510 - // Deferred email check — only on first widget open
3511 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3512 - var instance = MxChatInstances.get(botId);
3513 - if (emailBlocker && !instance.emailCheckDone) {
3514 - instance.emailCheckDone = true;
3515 - resolveEmailState(botId);
3516 - } else if (!emailBlocker) {
3517 - // No email collection — still route through showChatContainerForBot
3518 - // so the loader is shown while chat history loads
3519 - showChatContainerForBot(botId);
3520 - }
3521 -
3522 - // Move keyboard focus into the message input after the open transition.
3523 - setTimeout(function() {
3524 - var chatInput = getElementDOM(botId, 'chat-input');
3525 - if (chatInput && !chatInput.disabled) {
3526 - try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
3527 - }
3528 - }, 300);
3529 2414 } else {
3530 - $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
3531 - $(this).removeClass('hidden').attr('aria-expanded', 'false');
2415 + $chatbot.removeClass('visible').addClass('hidden');
2416 + $(this).removeClass('hidden');
3532 2417 enableScroll();
3533 2418 checkPreChatDismissal(botId);
3534 2419 }
3535 2420 });
3536 2421
3537 - // Allow clicking anywhere on the title bar to close the chatbot.
3538 - // Returns keyboard focus to the launcher so keyboard users don't get
3539 - // stranded at <body> (WCAG SC 2.4.3 Focus Order). :focus-visible is
3540 - // heuristic-based so mouse-triggered close won't show a focus ring.
2422 + // Allow clicking anywhere on the title bar to close the chatbot
3541 2423 $(document).on('click', '.chatbot-top-bar', function() {
3542 2424 var botId = getBotIdFromElement(this);
3543 - getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3544 - var $launcher = getElement(botId, 'floating-chatbot-button');
3545 - $launcher.removeClass('hidden').attr('aria-expanded', 'false');
2425 + getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible');
2426 + getElement(botId, 'floating-chatbot-button').removeClass('hidden');
3546 2427 enableScroll();
3547 - try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3548 2428 });
3549 2429
3550 - // Global Escape-key handler — closes any visible chat widget and
3551 - // returns focus to its launcher. Standard modal-dismissal pattern;
3552 - // pairs with aria-modal="true" set on the widget when it opens.
3553 - $(document).on('keydown', function(e) {
3554 - if (e.key !== 'Escape' && e.key !== 'Esc') return;
3555 - var $visible = $('.floating-chatbot.visible');
3556 - if (!$visible.length) return;
3557 - e.preventDefault();
3558 - $visible.each(function() {
3559 - var botId = getBotIdFromElement(this);
3560 - $(this).addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3561 - var $launcher = getElement(botId, 'floating-chatbot-button');
3562 - $launcher.removeClass('hidden').attr('aria-expanded', 'false');
3563 - try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3564 - });
3565 - enableScroll();
3566 - });
3567 -
3568 2430 $(document).on('click', '.close-pre-chat-message', function(e) {
3569 2431 e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
3570 2432 var botId = getBotIdFromElement(this);
3571 - handlePreChatDismissal(botId);
2433 + getElement(botId, 'pre-chat-message').fadeOut(200, function() {
2434 + $(this).remove();
2435 + });
3572 2436 });
3573 2437
3574 2438
3575 2439 // PDF upload button handlers - use class selector
@@ -3585,20 +2449,17 @@
3585 2449 var wordInput = getElementDOM(botId, 'word-upload');
3586 2450 if (wordInput) wordInput.click();
3587 2451 });
3588 2452
3589 - // PDF file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'pdf-upload')
3590 - $(document).on('change', '.pdf-upload', async function(e) {
3591 - var botId = getBotIdFromElement(this);
3592 - var instance = MxChatInstances.get(botId);
3593 - const file = this.files[0];
3594 - const sessionId = MxChatInstances.ensureSession(botId);
3595 -
2453 + // PDF file input change handler
2454 + addSafeEventListener('pdf-upload', 'change', async function(e) {
2455 + const file = e.target.files[0];
2456 +
3596 2457 if (!file || file.type !== 'application/pdf') {
3597 2458 alert('Please select a valid PDF file.');
3598 2459 return;
3599 2460 }
3600 -
2461 +
3601 2462 if (!sessionId) {
3602 2463 alert('Error: No session ID found');
3603 2464 return;
3604 2465 }
@@ -3606,49 +2467,47 @@
3606 2467 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3607 2468 alert('Error: Ajax configuration missing');
3608 2469 return;
3609 2470 }
3610 -
2471 +
3611 2472 // Disable buttons and show loading state
3612 - const uploadBtn = getElementDOM(botId, 'pdf-upload-btn');
3613 - const sendBtn = getElementDOM(botId, 'send-button');
3614 - if (!uploadBtn) return;
2473 + const uploadBtn = document.getElementById('pdf-upload-btn');
2474 + const sendBtn = document.getElementById('send-button');
3615 2475 const originalBtnContent = uploadBtn.innerHTML;
3616 -
2476 +
3617 2477 try {
3618 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3619 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3620 2478 const formData = new FormData();
3621 2479 formData.append('action', 'mxchat_upload_pdf');
3622 2480 formData.append('pdf_file', file);
3623 2481 formData.append('session_id', sessionId);
3624 2482 formData.append('nonce', mxchatChat.nonce);
3625 -
2483 +
3626 2484 uploadBtn.disabled = true;
3627 - if (sendBtn) sendBtn.disabled = true;
2485 + sendBtn.disabled = true;
3628 2486 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3629 2487 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3630 2488 </svg>`;
3631 -
2489 +
3632 2490 const response = await fetch(mxchatChat.ajax_url, {
3633 2491 method: 'POST',
3634 2492 body: formData
3635 2493 });
3636 -
2494 +
3637 2495 const data = await response.json();
3638 -
2496 +
3639 2497 if (data.success) {
3640 2498 // Hide popular questions if they exist
3641 - if (hasQuickQuestions(botId)) {
3642 - collapseQuickQuestions(botId);
2499 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2500 + if (hasQuickQuestions()) {
2501 + collapseQuickQuestions();
3643 2502 }
3644 -
2503 +
3645 2504 // Show the active PDF name
3646 - showActivePdf(data.data.filename, botId);
3647 -
3648 - appendMessage('bot', data.data.message, '', [], false, botId);
3649 - scrollToBottom(botId);
3650 - instance.activePdfFile = data.data.filename;
2505 + showActivePdf(data.data.filename);
2506 +
2507 + appendMessage('bot', data.data.message);
2508 + scrollToBottom();
2509 + activePdfFile = data.data.filename;
3651 2510 } else {
3652 2511 alert('Failed to upload PDF. Please try again.');
3653 2512 }
3654 2513 } catch (error) {
@@ -3654,76 +2513,66 @@
3654 2513 } catch (error) {
3655 2514 alert('Error uploading file. Please try again.');
3656 2515 } finally {
3657 2516 uploadBtn.disabled = false;
3658 - if (sendBtn) sendBtn.disabled = false;
2517 + sendBtn.disabled = false;
3659 2518 uploadBtn.innerHTML = originalBtnContent;
3660 2519 this.value = ''; // Reset file input
3661 2520 }
3662 2521 });
3663 2522
3664 - // Word file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'word-upload')
3665 - $(document).on('change', '.word-upload', async function(e) {
3666 - var botId = getBotIdFromElement(this);
3667 - var instance = MxChatInstances.get(botId);
3668 - const file = this.files[0];
3669 - const sessionId = MxChatInstances.ensureSession(botId);
3670 -
2523 + // Word file input change handler
2524 + addSafeEventListener('word-upload', 'change', async function(e) {
2525 + const file = e.target.files[0];
2526 +
3671 2527 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
3672 2528 alert('Please select a valid Word document (.docx).');
3673 2529 return;
3674 2530 }
3675 -
2531 +
3676 2532 if (!sessionId) {
3677 2533 alert('Error: No session ID found');
3678 2534 return;
3679 2535 }
3680 2536
3681 - if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3682 - alert('Error: Ajax configuration missing');
3683 - return;
3684 - }
3685 -
3686 2537 // Disable buttons and show loading state
3687 - const uploadBtn = getElementDOM(botId, 'word-upload-btn');
3688 - const sendBtn = getElementDOM(botId, 'send-button');
3689 - if (!uploadBtn) return;
2538 + const uploadBtn = document.getElementById('word-upload-btn');
2539 + const sendBtn = document.getElementById('send-button');
3690 2540 const originalBtnContent = uploadBtn.innerHTML;
3691 -
2541 +
3692 2542 try {
3693 - // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3694 - await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3695 2543 const formData = new FormData();
3696 2544 formData.append('action', 'mxchat_upload_word');
3697 2545 formData.append('word_file', file);
3698 2546 formData.append('session_id', sessionId);
3699 2547 formData.append('nonce', mxchatChat.nonce);
3700 -
2548 +
3701 2549 uploadBtn.disabled = true;
3702 - if (sendBtn) sendBtn.disabled = true;
2550 + sendBtn.disabled = true;
3703 2551 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3704 2552 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3705 2553 </svg>`;
3706 -
2554 +
3707 2555 const response = await fetch(mxchatChat.ajax_url, {
3708 2556 method: 'POST',
3709 2557 body: formData
3710 2558 });
3711 -
2559 +
3712 2560 const data = await response.json();
3713 -
2561 +
3714 2562 if (data.success) {
3715 2563 // Hide popular questions if they exist
3716 - if (hasQuickQuestions(botId)) {
3717 - collapseQuickQuestions(botId);
2564 + const popularQuestionsContainer = document.getElementById('mxchat-popular-questions');
2565 + if (hasQuickQuestions()) {
2566 + collapseQuickQuestions();
3718 2567 }
3719 -
2568 +
3720 2569 // Show the active Word document name
3721 - showActiveWord(data.data.filename, botId);
3722 -
3723 - appendMessage('bot', data.data.message, '', [], false, botId);
3724 - scrollToBottom(botId);
3725 - instance.activeWordFile = data.data.filename;
2570 + showActiveWord(data.data.filename);
2571 +
2572 + appendMessage('bot', data.data.message);
2573 + scrollToBottom();
2574 + activeWordFile = data.data.filename;
3726 2575 } else {
3727 2576 alert('Failed to upload Word document. Please try again.');
3728 2577 }
3729 2578 } catch (error) {
@@ -3729,25 +2578,25 @@
3729 2578 } catch (error) {
3730 2579 alert('Error uploading file. Please try again.');
3731 2580 } finally {
3732 2581 uploadBtn.disabled = false;
3733 - if (sendBtn) sendBtn.disabled = false;
2582 + sendBtn.disabled = false;
3734 2583 uploadBtn.innerHTML = originalBtnContent;
3735 2584 this.value = ''; // Reset file input
3736 2585 }
3737 2586 });
3738 2587
3739 - // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids)
3740 - $(document).on('click', '.remove-pdf-btn', function(e) {
2588 + // Remove button click handlers
2589 + document.getElementById('remove-pdf-btn')?.addEventListener('click', function(e) {
3741 2590 e.preventDefault();
3742 2591 e.stopPropagation();
3743 - removeActivePdf(getBotIdFromElement(this));
2592 + removeActivePdf();
3744 2593 });
3745 -
3746 - $(document).on('click', '.remove-word-btn', function(e) {
2594 +
2595 + document.getElementById('remove-word-btn')?.addEventListener('click', function(e) {
3747 2596 e.preventDefault();
3748 2597 e.stopPropagation();
3749 - removeActiveWord(getBotIdFromElement(this));
2598 + removeActiveWord();
3750 2599 });
3751 2600
3752 2601 // Window resize handlers
3753 2602 $(window).on('resize orientationchange', function() {
@@ -3785,454 +2634,380 @@
3785 2634 });
3786 2635
3787 2636
3788 2637 // ====================================
3789 -// INIT LOADER & CHAT CONTAINER HELPERS
2638 +// EMAIL COLLECTION SETUP - FIXED VERSION
3790 2639 // ====================================
3791 -// These must be outside the email collection block so they're always available
3792 -// (used by persistence loading even when email collection is off)
3793 -
3794 -function showInitLoader(botId) {
3795 - var loader = getElementDOM(botId, 'mxchat-init-loader');
3796 - if (loader) loader.style.display = 'flex';
3797 -}
3798 -
3799 -function hideInitLoader(botId) {
3800 - var loader = getElementDOM(botId, 'mxchat-init-loader');
3801 - if (loader) loader.style.display = 'none';
3802 -}
3803 -
3804 -function showEmailFormForBot(botId) {
3805 - hideInitLoader(botId);
3806 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3807 - var chatContainer = getElementDOM(botId, 'chat-container');
3808 - if (emailBlocker) emailBlocker.style.display = 'flex';
3809 - if (chatContainer) chatContainer.style.display = 'none';
3810 -}
3811 -
3812 -function showChatContainerForBot(botId) {
3813 - var emailBlocker = getElementDOM(botId, 'email-blocker');
3814 - var chatContainer = getElementDOM(botId, 'chat-container');
3815 - if (emailBlocker) emailBlocker.style.display = 'none';
3816 -
3817 - var instance = MxChatInstances.get(botId);
3818 - var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3819 -
3820 - // If persistence is on and history hasn't loaded yet, show loader
3821 - // while history loads to prevent flash of empty chat
3822 - if (chatPersistenceEnabled && !instance.chatHistoryLoaded) {
3823 - if (chatContainer) chatContainer.style.display = 'none';
3824 - showInitLoader(botId);
3825 - loadChatHistory(botId, function() {
3826 - hideInitLoader(botId);
3827 - if (chatContainer) chatContainer.style.display = 'flex';
3828 - scrollToBottom(botId, true);
3829 - });
3830 - } else {
3831 - hideInitLoader(botId);
3832 - if (chatContainer) chatContainer.style.display = 'flex';
3833 - if (typeof loadChatHistory === 'function') {
3834 - loadChatHistory(botId);
3835 - }
3836 - }
3837 -}
3838 -
3839 -// ====================================
3840 -// EMAIL COLLECTION SETUP - MULTI-INSTANCE VERSION
3841 -// ====================================
3842 2640 // Only run email collection setup if it's enabled
3843 2641 if (mxchatChat && mxchatChat.email_collection_enabled === 'on') {
2642 + // Email collection form setup and handlers
2643 + const emailForm = document.getElementById('email-collection-form');
2644 + const emailBlocker = document.getElementById('email-blocker');
2645 + const chatbotWrapper = document.getElementById('chat-container');
3844 2646
3845 - // Track submitting state per bot
3846 - const emailSubmittingState = {};
3847 -
3848 - // Add CSS animations for email form (once globally)
3849 - if (!document.getElementById('email-error-styles')) {
3850 - const style = document.createElement('style');
3851 - style.id = 'email-error-styles';
3852 - style.textContent = `
3853 - @keyframes fadeInError {
3854 - from { opacity: 0; transform: translateY(-5px); }
3855 - to { opacity: 1; transform: translateY(0); }
3856 - }
3857 - .email-input-shake {
3858 - animation: shake 0.5s ease-in-out;
3859 - }
3860 - @keyframes shake {
3861 - 0%, 100% { transform: translateX(0); }
3862 - 25% { transform: translateX(-5px); }
3863 - 75% { transform: translateX(5px); }
3864 - }
3865 - @keyframes spin {
3866 - from { transform: rotate(0deg); }
3867 - to { transform: rotate(360deg); }
3868 - }
3869 - .email-spinner {
3870 - display: inline-block;
3871 - vertical-align: middle;
3872 - }
3873 - `;
3874 - document.head.appendChild(style);
3875 - }
3876 -
3877 - function isValidEmailAddress(email) {
3878 - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3879 - return emailRegex.test(email.trim()) && email.length <= 254;
3880 - }
3881 -
3882 - function isValidNameInput(name) {
3883 - return name && name.trim().length >= 2 && name.trim().length <= 100;
3884 - }
3885 -
3886 - /**
3887 - * Replace {visitor_name} placeholder in intro message with actual visitor name
3888 - * @param {string} botId - The bot instance ID
3889 - * @param {string} visitorName - The visitor's name to insert
3890 - */
3891 - function replaceVisitorNamePlaceholder(botId, visitorName) {
3892 - var chatBox = getElementDOM(botId, 'chat-box');
3893 - if (!chatBox) return;
3894 -
3895 - // Find the greeting by its marker, not by position (plan a1a79b) —
3896 - // after a persistence restore the first .bot-message is a restored
3897 - // reply, and {visitor_name} was being substituted into that instead.
3898 - // Positional fallback for HTML cached before this release only.
3899 - var introMessage = chatBox.querySelector('.mxchat-intro-message') || chatBox.querySelector('.bot-message');
3900 - if (!introMessage) return;
3901 -
3902 - var messageContent = introMessage.querySelector('div[dir="auto"]');
3903 - if (!messageContent) return;
3904 -
3905 - var html = messageContent.innerHTML;
3906 -
3907 - // Replace {visitor_name} placeholder (case-insensitive)
3908 - if (visitorName && visitorName.trim()) {
3909 - // Escape HTML to prevent XSS
3910 - var safeName = $('<div>').text(visitorName.trim()).html();
3911 - html = html.replace(/\{visitor_name\}/gi, safeName);
3912 - } else {
3913 - // Remove placeholder and clean up spacing if no name provided
3914 - html = html.replace(/\{visitor_name\}/gi, '');
3915 - // Clean up any double spaces that might result
3916 - html = html.replace(/\s{2,}/g, ' ').trim();
2647 + if (emailForm && emailBlocker && chatbotWrapper) {
2648 +
2649 + // Add loading state management
2650 + let isSubmitting = false;
2651 +
2652 + // Optimized UI transition functions
2653 + function showEmailForm() {
2654 + emailBlocker.style.display = 'flex';
2655 + chatbotWrapper.style.display = 'none';
3917 2656 }
3918 2657
3919 - messageContent.innerHTML = html;
3920 - }
3921 -
3922 - function setEmailSubmissionState(botId, loading) {
3923 - var submitButton = getElementDOM(botId, 'email-submit-button');
3924 - var emailInput = getElementDOM(botId, 'user-email');
3925 - var nameInput = getElementDOM(botId, 'user-name');
3926 -
3927 - if (loading) {
3928 - emailSubmittingState[botId] = true;
3929 - if (submitButton) submitButton.disabled = true;
3930 - if (emailInput) emailInput.disabled = true;
3931 - if (nameInput) nameInput.disabled = true;
3932 -
3933 - if (submitButton && !submitButton.getAttribute('data-original-html')) {
3934 - submitButton.setAttribute('data-original-html', submitButton.innerHTML);
3935 - const originalText = submitButton.textContent;
3936 - submitButton.innerHTML = `
3937 - <svg class="email-spinner" style="width: 16px; height: 16px; margin-right: 8px; animation: spin 1s linear infinite;" viewBox="0 0 24 24">
3938 - <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" stroke-dasharray="31.416" stroke-dashoffset="31.416">
3939 - <animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416" repeatCount="indefinite"/>
3940 - <animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416" repeatCount="indefinite"/>
3941 - </circle>
3942 - </svg>
3943 - ${originalText}
3944 - `;
3945 - submitButton.style.opacity = '0.8';
2658 + function showChatContainer() {
2659 + // Show chat immediately without delay
2660 + emailBlocker.style.display = 'none';
2661 + chatbotWrapper.style.display = 'flex';
2662 +
2663 + // Load chat history only after showing chat container
2664 + if (typeof loadChatHistory === 'function') {
2665 + loadChatHistory();
3946 2666 }
3947 - } else {
3948 - emailSubmittingState[botId] = false;
3949 - if (submitButton) submitButton.disabled = false;
3950 - if (emailInput) emailInput.disabled = false;
3951 - if (nameInput) nameInput.disabled = false;
3952 -
3953 - if (submitButton) {
3954 - const originalHtml = submitButton.getAttribute('data-original-html');
3955 - if (originalHtml) {
3956 - submitButton.innerHTML = originalHtml;
3957 - }
3958 - submitButton.style.opacity = '1';
3959 - }
3960 2667 }
3961 - }
3962 2668
3963 - function showEmailError(botId, message) {
3964 - clearEmailError(botId);
3965 -
3966 - var emailForm = getElementDOM(botId, 'email-collection-form');
3967 - if (!emailForm) return;
3968 -
3969 - const errorDiv = document.createElement('div');
3970 - errorDiv.className = 'email-error';
3971 - errorDiv.style.cssText = `
3972 - color: #e74c3c;
3973 - font-size: 12px;
3974 - margin-top: 8px;
3975 - padding: 4px 0;
3976 - animation: fadeInError 0.3s ease;
3977 - `;
3978 - errorDiv.textContent = message;
3979 - emailForm.appendChild(errorDiv);
3980 -
3981 - // Add shake animation to inputs
3982 - var emailInput = getElementDOM(botId, 'user-email');
3983 - var nameInput = getElementDOM(botId, 'user-name');
3984 -
3985 - if (emailInput) {
3986 - emailInput.classList.add('email-input-shake');
3987 - setTimeout(() => emailInput.classList.remove('email-input-shake'), 500);
2669 + // Enhanced email validation
2670 + function isValidEmail(email) {
2671 + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2672 + return emailRegex.test(email.trim()) && email.length <= 254; // RFC 5321 limit
3988 2673 }
3989 - if (nameInput) {
3990 - nameInput.classList.add('email-input-shake');
3991 - setTimeout(() => nameInput.classList.remove('email-input-shake'), 500);
3992 - }
3993 - }
3994 2674
3995 - function clearEmailError(botId) {
3996 - var emailForm = getElementDOM(botId, 'email-collection-form');
3997 - if (emailForm) {
3998 - const existingErrors = emailForm.querySelectorAll('.email-error');
3999 - existingErrors.forEach(error => error.remove());
2675 + // Enhanced name validation
2676 + function isValidName(name) {
2677 + return name && name.trim().length >= 2 && name.trim().length <= 100;
4000 2678 }
4001 - }
4002 2679
4003 - // Resolve email state using server-side data when available, AJAX fallback otherwise
4004 - function resolveEmailState(botId) {
4005 - if (mxchatChat.skip_email_check && mxchatChat.initial_email_state) {
4006 - if (mxchatChat.initial_email_state.show_email_form) {
4007 - showEmailFormForBot(botId);
2680 + // Show loading state with spinner
2681 + function setSubmissionState(loading) {
2682 + const submitButton = document.getElementById('email-submit-button');
2683 + const emailInput = document.getElementById('user-email');
2684 + const nameInput = document.getElementById('user-name');
2685 +
2686 + if (loading) {
2687 + isSubmitting = true;
2688 + if (submitButton) submitButton.disabled = true;
2689 + if (emailInput) emailInput.disabled = true;
2690 + if (nameInput) nameInput.disabled = true;
2691 +
2692 + // Store original content and add spinner
2693 + if (submitButton && !submitButton.getAttribute('data-original-html')) {
2694 + submitButton.setAttribute('data-original-html', submitButton.innerHTML);
2695 +
2696 + // Add loading spinner while keeping original text
2697 + const originalText = submitButton.textContent;
2698 + submitButton.innerHTML = `
2699 + <svg class="email-spinner" style="width: 16px; height: 16px; margin-right: 8px; animation: spin 1s linear infinite;" viewBox="0 0 24 24">
2700 + <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" stroke-dasharray="31.416" stroke-dashoffset="31.416">
2701 + <animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416" repeatCount="indefinite"/>
2702 + <animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416" repeatCount="indefinite"/>
2703 + </circle>
2704 + </svg>
2705 + ${originalText}
2706 + `;
2707 +
2708 + submitButton.style.opacity = '0.8';
2709 + }
4008 2710 } else {
4009 - showChatContainerForBot(botId);
2711 + isSubmitting = false;
2712 + if (submitButton) submitButton.disabled = false;
2713 + if (emailInput) emailInput.disabled = false;
2714 + if (nameInput) nameInput.disabled = false;
2715 +
2716 + // Restore original content
2717 + if (submitButton) {
2718 + const originalHtml = submitButton.getAttribute('data-original-html');
2719 + if (originalHtml) {
2720 + submitButton.innerHTML = originalHtml;
2721 + }
2722 + submitButton.style.opacity = '1';
2723 + }
4010 2724 }
4011 - } else {
4012 - checkSessionAndEmailForBot(botId);
4013 2725 }
4014 - }
4015 2726
4016 - function checkSessionAndEmailForBot(botId) {
4017 - const sessionId = MxChatInstances.ensureSession(botId);
4018 -
4019 - // Hide both panels while we check — show loader instead
4020 - var emailBlocker = getElementDOM(botId, 'email-blocker');
4021 - var chatContainer = getElementDOM(botId, 'chat-container');
4022 - if (emailBlocker) emailBlocker.style.display = 'none';
4023 - if (chatContainer) chatContainer.style.display = 'none';
4024 - showInitLoader(botId);
4025 -
4026 - fetch(mxchatChat.ajax_url, {
4027 - method: 'POST',
4028 - headers: {
4029 - 'Content-Type': 'application/x-www-form-urlencoded',
4030 - },
4031 - body: new URLSearchParams({
4032 - action: 'mxchat_check_email_provided',
4033 - session_id: sessionId,
4034 - nonce: mxchatChat.nonce,
4035 - })
4036 - })
4037 - .then((response) => {
4038 - if (!response.ok) {
4039 - throw new Error(`HTTP error! status: ${response.status}`);
2727 + // Error display functions
2728 + function showEmailError(message) {
2729 + clearEmailError();
2730 +
2731 + const errorDiv = document.createElement('div');
2732 + errorDiv.className = 'email-error';
2733 + errorDiv.style.cssText = `
2734 + color: #e74c3c;
2735 + font-size: 12px;
2736 + margin-top: 8px;
2737 + padding: 4px 0;
2738 + animation: fadeInError 0.3s ease;
2739 + `;
2740 + errorDiv.textContent = message;
2741 +
2742 + // Add CSS animation if not already present
2743 + if (!document.getElementById('email-error-styles')) {
2744 + const style = document.createElement('style');
2745 + style.id = 'email-error-styles';
2746 + style.textContent = `
2747 + @keyframes fadeInError {
2748 + from { opacity: 0; transform: translateY(-5px); }
2749 + to { opacity: 1; transform: translateY(0); }
2750 + }
2751 + .email-input-shake {
2752 + animation: shake 0.5s ease-in-out;
2753 + }
2754 + @keyframes shake {
2755 + 0%, 100% { transform: translateX(0); }
2756 + 25% { transform: translateX(-5px); }
2757 + 75% { transform: translateX(5px); }
2758 + }
2759 + @keyframes spin {
2760 + from { transform: rotate(0deg); }
2761 + to { transform: rotate(360deg); }
2762 + }
2763 + .email-spinner {
2764 + display: inline-block;
2765 + vertical-align: middle;
2766 + }
2767 + `;
2768 + document.head.appendChild(style);
4040 2769 }
4041 - return response.json();
4042 - })
4043 - .then((data) => {
4044 - if (data.success) {
4045 - if (data.data.logged_in || data.data.email) {
4046 - showChatContainerForBot(botId);
4047 - } else {
4048 - showEmailFormForBot(botId);
4049 - }
4050 - } else {
4051 - showEmailFormForBot(botId);
2770 +
2771 + emailForm.appendChild(errorDiv);
2772 +
2773 + // Add shake animation to inputs
2774 + const emailInput = document.getElementById('user-email');
2775 + const nameInput = document.getElementById('user-name');
2776 +
2777 + if (emailInput) {
2778 + emailInput.classList.add('email-input-shake');
2779 + setTimeout(() => {
2780 + emailInput.classList.remove('email-input-shake');
2781 + }, 500);
4052 2782 }
4053 - })
4054 - .catch((error) => {
4055 - showEmailFormForBot(botId);
4056 - });
4057 - }
4058 -
4059 - // Event delegation for email form submission
4060 - $(document).on('submit', '.email-collection-form', function(e) {
4061 - e.preventDefault();
4062 - e.stopPropagation();
4063 -
4064 - var botId = getBotIdFromElement(this);
4065 -
4066 - // Prevent double submission
4067 - if (emailSubmittingState[botId]) {
4068 - return false;
2783 +
2784 + if (nameInput) {
2785 + nameInput.classList.add('email-input-shake');
2786 + setTimeout(() => {
2787 + nameInput.classList.remove('email-input-shake');
2788 + }, 500);
2789 + }
4069 2790 }
4070 2791
4071 - var emailInput = getElementDOM(botId, 'user-email');
4072 - var nameInput = getElementDOM(botId, 'user-name');
4073 - var consentInput = getElementDOM(botId, 'user-consent');
4074 - var userEmail = emailInput ? emailInput.value.trim() : '';
4075 - var userName = nameInput ? nameInput.value.trim() : '';
4076 - var sessionId = MxChatInstances.ensureSession(botId);
4077 -
4078 - // Validate email
4079 - if (!userEmail) {
4080 - showEmailError(botId, 'Please enter your email address.');
4081 - return false;
2792 + function clearEmailError() {
2793 + const existingErrors = emailForm.querySelectorAll('.email-error');
2794 + existingErrors.forEach(error => error.remove());
4082 2795 }
4083 2796
4084 - if (!isValidEmailAddress(userEmail)) {
4085 - showEmailError(botId, 'Please enter a valid email address.');
4086 - return false;
4087 - }
2797 + // MAIN FORM SUBMIT HANDLER
2798 + // Remove any existing event listeners first
2799 + emailForm.removeEventListener('submit', handleFormSubmit);
4088 2800
4089 - // Validate name if field exists and has content
4090 - if (nameInput && userName && !isValidNameInput(userName)) {
4091 - showEmailError(botId, 'Please enter a valid name (2-100 characters).');
4092 - return false;
4093 - }
2801 + // Add the form submit handler
2802 + emailForm.addEventListener('submit', handleFormSubmit);
4094 2803
4095 - // Consent checkbox (b062c4): backstop behind the native required
4096 - // attribute; the server enforces this independently either way.
4097 - if (consentInput && consentInput.required && !consentInput.checked) {
4098 - showEmailError(botId, 'Please tick the consent box to continue.');
4099 - return false;
4100 - }
2804 + function handleFormSubmit(event) {
2805 + event.preventDefault();
2806 + event.stopPropagation();
4101 2807
4102 - clearEmailError(botId);
4103 - setEmailSubmissionState(botId, true);
2808 + // Prevent double submission
2809 + if (isSubmitting) {
2810 + return false;
2811 + }
4104 2812
4105 - // Prepare form data
4106 - const formData = new URLSearchParams({
4107 - action: 'mxchat_handle_save_email_and_response',
4108 - email: userEmail,
4109 - session_id: sessionId,
4110 - nonce: mxchatChat.nonce,
4111 - });
2813 + const userEmail = document.getElementById('user-email').value.trim();
2814 + const nameInput = document.getElementById('user-name');
2815 + const userName = nameInput ? nameInput.value.trim() : '';
2816 + const sessionId = getChatSession();
4112 2817
4113 - if (userName) {
4114 - formData.append('name', userName);
4115 - }
2818 + // Validate email before submission
2819 + if (!userEmail) {
2820 + showEmailError('Please enter your email address.');
2821 + return false;
2822 + }
4116 2823
4117 - // Ticked/unticked both travel when the checkbox is rendered, so an
4118 - // optional-consent "no" is recorded as a decision, not an absence.
4119 - if (consentInput) {
4120 - formData.append('consent', consentInput.checked ? '1' : '0');
4121 - }
2824 + if (!isValidEmail(userEmail)) {
2825 + showEmailError('Please enter a valid email address.');
2826 + return false;
2827 + }
4122 2828
4123 - fetch(mxchatChat.ajax_url, {
4124 - method: 'POST',
4125 - headers: {
4126 - 'Content-Type': 'application/x-www-form-urlencoded',
4127 - },
4128 - body: formData
4129 - })
4130 - .then((response) => {
4131 - if (!response.ok) {
4132 - throw new Error(`HTTP error! status: ${response.status}`);
2829 + // Validate name if field exists
2830 + if (nameInput && !isValidName(userName)) {
2831 + showEmailError('Please enter a valid name (2-100 characters).');
2832 + return false;
4133 2833 }
4134 - return response.json();
4135 - })
4136 - .then((data) => {
4137 - setEmailSubmissionState(botId, false);
4138 2834
4139 - if (data.success) {
4140 - showChatContainerForBot(botId);
2835 + // Clear any existing errors
2836 + clearEmailError();
2837 + setSubmissionState(true);
4141 2838
4142 - // Replace {visitor_name} placeholder in intro message with actual name
4143 - if (userName) {
4144 - replaceVisitorNamePlaceholder(botId, userName);
4145 - } else {
4146 - // Remove placeholder if no name provided
4147 - replaceVisitorNamePlaceholder(botId, '');
4148 - }
2839 + // Prepare form data with optional name
2840 + const formData = new URLSearchParams({
2841 + action: 'mxchat_handle_save_email_and_response',
2842 + email: userEmail,
2843 + session_id: sessionId,
2844 + nonce: mxchatChat.nonce,
2845 + });
4149 2846
4150 - if (data.message && typeof appendMessage === 'function') {
4151 - setTimeout(() => {
4152 - appendMessage('bot', data.message, '', [], false, botId);
4153 - if (typeof scrollToBottom === 'function') {
4154 - scrollToBottom(botId);
4155 - }
4156 - }, 100);
4157 - }
4158 - } else {
4159 - showEmailError(botId, data.message || 'Failed to save email. Please try again.');
2847 + // Add name to form data if provided
2848 + if (userName) {
2849 + formData.append('name', userName);
4160 2850 }
4161 - })
4162 - .catch((error) => {
4163 - setEmailSubmissionState(botId, false);
4164 - showEmailError(botId, 'An error occurred. Please try again.');
4165 - });
4166 2851
4167 - return false;
4168 - });
2852 + fetch(mxchatChat.ajax_url, {
2853 + method: 'POST',
2854 + headers: {
2855 + 'Content-Type': 'application/x-www-form-urlencoded',
2856 + },
2857 + body: formData
2858 + })
2859 + .then((response) => {
2860 + if (!response.ok) {
2861 + throw new Error(`HTTP error! status: ${response.status}`);
2862 + }
2863 + return response.json();
2864 + })
2865 + .then((data) => {
2866 + setSubmissionState(false);
4169 2867
4170 - // Real-time email validation using event delegation
4171 - $(document).on('input', '.mxchat-email-input', function() {
4172 - var botId = getBotIdFromElement(this);
4173 - var $input = $(this);
2868 + if (data.success) {
2869 + // Show chat immediately
2870 + showChatContainer();
4174 2871
4175 - // Clear previous timeout
4176 - clearTimeout($input.data('validationTimeout'));
2872 + // Handle bot response if provided
2873 + if (data.message && typeof appendMessage === 'function') {
2874 + setTimeout(() => {
2875 + appendMessage('bot', data.message);
2876 + if (typeof scrollToBottom === 'function') {
2877 + scrollToBottom();
2878 + }
2879 + }, 100);
2880 + }
2881 + } else {
2882 + showEmailError(data.message || 'Failed to save email. Please try again.');
2883 + }
2884 + })
2885 + .catch((error) => {
2886 + setSubmissionState(false);
2887 + showEmailError('An error occurred. Please try again.');
2888 + });
2889 +
2890 + return false; // Extra prevention
2891 + }
4177 2892
4178 - // Debounce validation
4179 - var timeout = setTimeout(() => {
4180 - var email = this.value.trim();
4181 - clearEmailError(botId);
2893 + // Real-time email validation
2894 + const emailInput = document.getElementById('user-email');
2895 + if (emailInput) {
2896 + let validationTimeout;
2897 +
2898 + emailInput.addEventListener('input', function() {
2899 + // Clear previous validation timeout
2900 + if (validationTimeout) {
2901 + clearTimeout(validationTimeout);
2902 + }
2903 +
2904 + // Debounce validation
2905 + validationTimeout = setTimeout(() => {
2906 + const email = this.value.trim();
2907 + clearEmailError();
2908 +
2909 + if (email && !isValidEmail(email)) {
2910 + showEmailError('Please enter a valid email address.');
2911 + }
2912 + }, 500);
2913 + });
4182 2914
4183 - if (email && !isValidEmailAddress(email)) {
4184 - showEmailError(botId, 'Please enter a valid email address.');
4185 - }
4186 - }, 500);
2915 + // Handle Enter key
2916 + emailInput.addEventListener('keypress', function(e) {
2917 + if (e.key === 'Enter' && !isSubmitting) {
2918 + e.preventDefault();
2919 + emailForm.dispatchEvent(new Event('submit'));
2920 + }
2921 + });
2922 + }
4187 2923
4188 - $input.data('validationTimeout', timeout);
4189 - });
2924 + // Real-time name validation
2925 + const nameInput = document.getElementById('user-name');
2926 + if (nameInput) {
2927 + let nameValidationTimeout;
2928 +
2929 + nameInput.addEventListener('input', function() {
2930 + // Clear previous validation timeout
2931 + if (nameValidationTimeout) {
2932 + clearTimeout(nameValidationTimeout);
2933 + }
2934 +
2935 + // Debounce validation
2936 + nameValidationTimeout = setTimeout(() => {
2937 + const name = this.value.trim();
2938 + clearEmailError();
2939 +
2940 + if (name && !isValidName(name)) {
2941 + showEmailError('Name must be between 2 and 100 characters.');
2942 + }
2943 + }, 500);
2944 + });
4190 2945
4191 - // Handle Enter key in email input
4192 - $(document).on('keypress', '.mxchat-email-input', function(e) {
4193 - if (e.key === 'Enter') {
4194 - e.preventDefault();
4195 - var botId = getBotIdFromElement(this);
4196 - if (!emailSubmittingState[botId]) {
4197 - $(this).closest('.email-collection-form').submit();
4198 - }
2946 + // Handle Enter key
2947 + nameInput.addEventListener('keypress', function(e) {
2948 + if (e.key === 'Enter' && !isSubmitting) {
2949 + e.preventDefault();
2950 + emailForm.dispatchEvent(new Event('submit'));
2951 + }
2952 + });
4199 2953 }
4200 - });
4201 2954
4202 - // Handle Enter key in name input
4203 - $(document).on('keypress', '.mxchat-name-input', function(e) {
4204 - if (e.key === 'Enter') {
4205 - e.preventDefault();
4206 - var botId = getBotIdFromElement(this);
4207 - if (!emailSubmittingState[botId]) {
4208 - $(this).closest('.email-collection-form').submit();
2955 + // Initial state check
2956 + if (mxchatChat.skip_email_check && mxchatChat.initial_email_state) {
2957 + const emailState = mxchatChat.initial_email_state;
2958 + if (emailState.show_email_form) {
2959 + showEmailForm();
2960 + } else {
2961 + showChatContainer();
4209 2962 }
2963 + } else {
2964 + // Check email status via AJAX
2965 + setTimeout(checkSessionAndEmail, 100);
4210 2966 }
4211 - });
4212 2967
4213 - // Initialize email check for all bot instances
4214 - // For floating bots: defer until widget is opened (zero passive AJAX)
4215 - // For embedded bots: check immediately since the form is visible
4216 - $('.mxchat-chatbot-wrapper').each(function() {
4217 - var botId = $(this).data('bot-id') || 'default';
4218 - var emailBlocker = getElementDOM(botId, 'email-blocker');
2968 + // Check if email exists for the current session
2969 + function checkSessionAndEmail() {
2970 + const sessionId = getChatSession();
2971 +
2972 + fetch(mxchatChat.ajax_url, {
2973 + method: 'POST',
2974 + headers: {
2975 + 'Content-Type': 'application/x-www-form-urlencoded',
2976 + },
2977 + body: new URLSearchParams({
2978 + action: 'mxchat_check_email_provided',
2979 + session_id: sessionId,
2980 + nonce: mxchatChat.nonce,
2981 + })
2982 + })
2983 + .then((response) => {
2984 + if (!response.ok) {
2985 + throw new Error(`HTTP error! status: ${response.status}`);
2986 + }
2987 + return response.json();
2988 + })
2989 + .then((data) => {
2990 + if (data.success) {
2991 + if (data.data.logged_in || data.data.email) {
2992 + showChatContainer();
2993 + } else {
2994 + showEmailForm();
2995 + }
2996 + } else {
2997 + // On error, default to showing email form
2998 + showEmailForm();
2999 + }
3000 + })
3001 + .catch((error) => {
3002 + // Email check failed - default to email form
3003 + showEmailForm();
3004 + });
3005 + }
4219 3006
4220 - if (emailBlocker) {
4221 - if (isEmbeddedBot(botId)) {
4222 - // Embedded bots are always visible — check now
4223 - resolveEmailState(botId);
4224 - }
4225 - // Floating bots: handled in the widget open handler
4226 - } else if (isEmbeddedBot(botId)) {
4227 - // Embedded bot, no email collection — load history with loader
4228 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4229 - if (chatPersistenceEnabled) {
4230 - MxChatInstances.ensureSession(botId);
4231 - showChatContainerForBot(botId);
4232 - }
4233 - }
4234 - });
3007 + } else {
3008 + // Email collection is enabled but essential elements are missing - silently continue
3009 + }
4235 3010 }
4236 3011
4237 3012 // Open chatbot when pre-chat message is clicked - use class selector for multi-instance
4238 3013 $(document).on('click', '.pre-chat-message', function() {
@@ -4240,32 +3015,39 @@
4240 3015 var $chatbot = getElement(botId, 'floating-chatbot');
4241 3016 if ($chatbot.hasClass('hidden')) {
4242 3017 $chatbot.removeClass('hidden').addClass('visible');
4243 3018 getElement(botId, 'floating-chatbot-button').addClass('hidden');
4244 - handlePreChatDismissal(botId);
3019 + $(this).fadeOut(250); // Hide pre-chat message
4245 3020 disableScroll(); // Disable scroll when chatbot opens
3021 + }
3022 + });
4246 3023
4247 - // Load chat history for returning visitors (persistence)
4248 - var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4249 - if (chatPersistenceEnabled) {
4250 - MxChatInstances.ensureSession(botId);
4251 - }
3024 + // Dismiss pre-chat message via close button - handled by event delegation above at line ~2376
3025 + // This is a fallback for legacy support
3026 + $(document).on('click', '.close-pre-chat-message', function() {
3027 + var botId = getBotIdFromElement(this);
3028 + var $preChat = getElement(botId, 'pre-chat-message');
3029 + $preChat.fadeOut(200); // Hide the message
4252 3030
4253 - // Deferred email check — only on first widget open
4254 - var emailBlocker = getElementDOM(botId, 'email-blocker');
4255 - var instance = MxChatInstances.get(botId);
4256 - if (emailBlocker && !instance.emailCheckDone) {
4257 - instance.emailCheckDone = true;
4258 - resolveEmailState(botId);
4259 - } else if (!emailBlocker) {
4260 - showChatContainerForBot(botId);
3031 + // Send an AJAX request to set the transient flag for 24 hours
3032 + $.ajax({
3033 + url: mxchatChat.ajax_url,
3034 + type: 'POST',
3035 + data: {
3036 + action: 'mxchat_dismiss_pre_chat_message',
3037 + _ajax_nonce: mxchatChat.nonce
3038 + },
3039 + success: function() {
3040 + // Ensure the message is hidden after dismissal
3041 + $preChat.hide();
3042 + },
3043 + error: function() {
3044 + // Error dismissing pre-chat message - silently continue
4261 3045 }
4262 - }
3046 + });
4263 3047 });
4264 3048
4265 - // Legacy duplicate close handler removed — handled by single event delegation above
4266 3049
4267 -
4268 3050 function hasQuickQuestions(botId) {
4269 3051 botId = botId || 'default';
4270 3052 var questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
4271 3053 if (!questionsContainer) return false;
@@ -4401,11 +3183,18 @@
4401 3183 });
4402 3184
4403 3185 // Initialize when document is ready
4404 3186 setFullHeight();
3187 + trackOriginatingPage();
4405 3188
4406 - // Note: trackOriginatingPage() and loadChatHistory() are now deferred
4407 - // until the user's first interaction via MxChatInstances.ensureSession()
3189 + // Only load chat history if email collection is disabled
3190 + if (mxchatChat.email_collection_enabled !== 'on') {
3191 + // Load history for all instances
3192 + $('.mxchat-chatbot-wrapper').each(function() {
3193 + var botId = $(this).data('bot-id') || 'default';
3194 + loadChatHistory(botId);
3195 + });
3196 + }
4408 3197
4409 3198 // Initialize chat visibility for all instances
4410 3199 $('.mxchat-chatbot-wrapper').each(function() {
4411 3200 var botId = $(this).data('bot-id') || 'default';
@@ -4458,312 +3247,6 @@
4458 3247 }, 2000);
4459 3248 });
4460 3249 }
4461 3250 }
4462 -});
4463 -
4464 -// ============================================================================
4465 -// SATISFACTION RATING (v3.2.6)
4466 -// ============================================================================
4467 -// Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
4468 -// inactivity following a bot reply. One prompt per session, deduped via
4469 -// localStorage. Runs ONLY when the satisfaction_rating_enabled option is on —
4470 -// the option (default off) is authoritative.
4471 -jQuery(function($) {
4472 - if (typeof mxchatChat === 'undefined') return;
4473 - // wp_localize_script stringifies scalars: a PHP boolean false arrives as
4474 - // '' and true as '1', so this must be an explicit-enable allowlist — the
4475 - // old "disabled when exactly false/'off'" check let '' through and the
4476 - // bubble rendered on sites with the option off/unset (plan-4bba64). PHP
4477 - // now emits 'on'/'off' strings; true/'1'/1 keep cached pre-fix HTML
4478 - // (boolean-true localizations) working.
4479 - // NOTE (plan-32db95): this gate reads the INLINE value at DOM ready and is
4480 - // deliberately NOT re-evaluated after the widget's dynamic-settings refresh
4481 - // merges fresh values over mxchatChat (that merge fires on first widget
4482 - // open, after this module has already decided). Re-evaluating would mean
4483 - // restructuring the whole module to late-bind its listeners — not worth it
4484 - // for a prompt that is at worst stale for one page load on a cached page.
4485 - var sre = mxchatChat.satisfaction_rating_enabled;
4486 - if (sre !== 'on' && sre !== true && sre !== '1' && sre !== 1) return;
4487 -
4488 - // wp_localize_script stringifies ints, so accept both number and numeric string.
4489 - var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
4490 - var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);
4491 - if (!isFinite(idleSeconds)) idleSeconds = 60;
4492 - if (idleSeconds < 5) idleSeconds = 5;
4493 - if (idleSeconds > 600) idleSeconds = 600;
4494 - var IDLE_MS = idleSeconds * 1000;
4495 - var MIN_BOT_REPLIES = 2;
4496 - var ratingState = {};
4497 -
4498 - function getState(botId) {
4499 - if (!ratingState[botId]) {
4500 - ratingState[botId] = { idleTimer: null, botReplies: 0, promptShown: false, dismissed: false };
4501 - }
4502 - return ratingState[botId];
4503 - }
4504 -
4505 - function getSessionId(botId) {
4506 - if (typeof MxChatInstances !== 'undefined' && MxChatInstances.getChatSession) {
4507 - return MxChatInstances.getChatSession(botId);
4508 - }
4509 - return null;
4510 - }
4511 -
4512 - function isAlreadyRated(sessionId) {
4513 - if (!sessionId) return false;
4514 - try { return localStorage.getItem('mxchat_rated:' + sessionId) === '1'; } catch (e) { return false; }
4515 - }
4516 -
4517 - function markRated(sessionId) {
4518 - if (!sessionId) return;
4519 - try { localStorage.setItem('mxchat_rated:' + sessionId, '1'); } catch (e) {}
4520 - }
4521 -
4522 - function esc(s) {
4523 - return String(s == null ? '' : s)
4524 - .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
4525 - .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
4526 - }
4527 -
4528 - // Mirror shouldSkipInlineColors so rating bubbles defer to AI-theme CSS.
4529 - function ratingSkipInlineColors(botId) {
4530 - if (mxchatChat.skip_inline_colors) return true;
4531 - var botAssignments = mxchatChat.bot_theme_assignments || {};
4532 - return botAssignments.hasOwnProperty(botId);
4533 - }
4534 -
4535 - function botBubbleStyleAttr(botId) {
4536 - if (ratingSkipInlineColors(botId)) return '';
4537 - var bg = mxchatChat.bot_message_bg_color;
4538 - var fg = mxchatChat.bot_message_font_color;
4539 - if (!bg && !fg) return '';
4540 - return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"';
4541 - }
4542 -
4543 - // Reads the rating bubble's actual computed fg+bg (whatever paints it —
4544 - // the inline color pickers OR the mxchat-theme AI customizer's injected CSS)
4545 - // and paints the filled "Send" pill so it fills with the bot font color and
4546 - // labels in the bubble bg. Mirrors mxchatSyncMenuColors(~:1512) for the read.
4547 - // We paint the submit button DIRECTLY (inline longhand) rather than relying
4548 - // on the CSS rule's var()s: Chromium resolves an INHERITED custom property
4549 - // unreliably inside a descendant's `background`, so a bubble-level var would
4550 - // silently fall back to the literal (white-block bug all over again). Inline
4551 - // longhand always wins. Same transparent-guard as the menu so we never paint
4552 - // a see-through value — in that case the CSS literal fallbacks keep it legible.
4553 - function syncRatingBubbleColors(botId) {
4554 - var $chatBox = getChatBoxByBotId(botId);
4555 - if (!$chatBox || !$chatBox.length) return;
4556 - var bubbleEl = $chatBox.find('.mxchat-rating-bot-bubble').last()[0];
4557 - if (!bubbleEl) return;
4558 - var cs = window.getComputedStyle(bubbleEl);
4559 - var fg = cs.color;
4560 - var bg = cs.backgroundColor;
4561 - var hasFg = fg && fg !== 'rgba(0, 0, 0, 0)' && fg !== 'transparent';
4562 - var hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
4563 - // Expose on the bubble too, for any inheriting styles / future use.
4564 - if (hasFg) bubbleEl.style.setProperty('--mxchat-bot-fg', fg);
4565 - if (hasBg) bubbleEl.style.setProperty('--mxchat-bot-bg', bg);
4566 - // Paint the Send pill directly — the part that actually fixes the bug.
4567 - var submitEl = bubbleEl.querySelector('.mxchat-rating-submit');
4568 - if (submitEl) {
4569 - if (hasFg) submitEl.style.backgroundColor = fg; // fill = bot font color
4570 - if (hasBg) submitEl.style.color = bg; // label = bubble background
4571 - }
4572 - }
4573 -
4574 - function copy(key) {
4575 - var c = mxchatChat.satisfaction_rating_copy || {};
4576 - var d = {
4577 - question: 'Was this helpful?',
4578 - helpful: 'Helpful',
4579 - not_helpful: 'Not helpful',
4580 - dismiss: 'Dismiss',
4581 - thanks: 'Thanks! Anything we should improve? (optional)',
4582 - placeholder: 'Tell us what could be better…',
4583 - send: 'Send',
4584 - skip: 'Skip',
4585 - saved: 'Thanks for the feedback.'
4586 - };
4587 - return c[key] || d[key];
4588 - }
4589 -
4590 - function thumbUpSvg() {
4591 - 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>';
4592 - }
4593 - function thumbDownSvg() {
4594 - 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>';
4595 - }
4596 -
4597 - function buildPromptHtml(botId) {
4598 - var styleAttr = botBubbleStyleAttr(botId);
4599 - return ''
4600 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4601 - + '<div class="mxchat-rating-prompt" data-bot-id="' + esc(botId) + '" role="group" aria-label="' + esc(copy('question')) + '">'
4602 - + '<div class="mxchat-rating-question">' + esc(copy('question')) + '</div>'
4603 - + '<div class="mxchat-rating-actions">'
4604 - + '<span class="mxchat-rating-buttons">'
4605 - + '<button type="button" class="mxchat-rating-btn" data-rating="1" aria-label="' + esc(copy('helpful')) + '">' + thumbUpSvg() + '</button>'
4606 - + '<button type="button" class="mxchat-rating-btn" data-rating="-1" aria-label="' + esc(copy('not_helpful')) + '">' + thumbDownSvg() + '</button>'
4607 - + '</span>'
4608 - + '<button type="button" class="mxchat-rating-dismiss" aria-label="' + esc(copy('dismiss')) + '">×</button>'
4609 - + '</div>'
4610 - + '</div>'
4611 - + '</div>';
4612 - }
4613 -
4614 - function buildFeedbackHtml(botId, rating) {
4615 - var styleAttr = botBubbleStyleAttr(botId);
4616 - return ''
4617 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4618 - + '<div class="mxchat-rating-feedback" data-bot-id="' + esc(botId) + '" data-rating="' + esc(String(rating)) + '">'
4619 - + '<div class="mxchat-rating-feedback-label">' + esc(copy('thanks')) + '</div>'
4620 - + '<textarea class="mxchat-rating-feedback-input" maxlength="500" placeholder="' + esc(copy('placeholder')) + '" rows="2"></textarea>'
4621 - + '<div class="mxchat-rating-feedback-actions">'
4622 - + '<button type="button" class="mxchat-rating-skip">' + esc(copy('skip')) + '</button>'
4623 - + '<button type="button" class="mxchat-rating-submit">' + esc(copy('send')) + '</button>'
4624 - + '</div>'
4625 - + '</div>'
4626 - + '</div>';
4627 - }
4628 -
4629 - function buildSavedHtml(botId) {
4630 - var styleAttr = botBubbleStyleAttr(botId);
4631 - return ''
4632 - + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4633 - + '<div class="mxchat-rating-saved">' + esc(copy('saved')) + '</div>'
4634 - + '</div>';
4635 - }
4636 -
4637 - function getChatBoxByBotId(botId) {
4638 - var $byId = $('#chat-box-' + botId);
4639 - if ($byId.length) return $byId.first();
4640 - return $('.chat-box').first();
4641 - }
4642 -
4643 - function scrollChatBoxToBottom($chatBox) {
4644 - if (!$chatBox || !$chatBox.length) return;
4645 - $chatBox.scrollTop($chatBox[0].scrollHeight);
4646 - }
4647 -
4648 - function showPrompt(botId) {
4649 - var s = getState(botId);
4650 - if (s.promptShown || s.dismissed) return;
4651 - var sessionId = getSessionId(botId);
4652 - if (!sessionId) return;
4653 - if (isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4654 - var $chatBox = getChatBoxByBotId(botId);
4655 - if (!$chatBox.length) return;
4656 - if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; }
4657 - $chatBox.append(buildPromptHtml(botId));
4658 - syncRatingBubbleColors(botId);
4659 - s.promptShown = true;
4660 - scrollChatBoxToBottom($chatBox);
4661 - }
4662 -
4663 - function submitRating(botId, rating, feedback) {
4664 - var sessionId = getSessionId(botId);
4665 - if (!sessionId) return;
4666 - $.post(mxchatChat.ajax_url, {
4667 - action: 'mxchat_save_rating',
4668 - session_id: sessionId,
4669 - bot_id: botId,
4670 - rating: rating,
4671 - feedback: feedback || ''
4672 - });
4673 - markRated(sessionId);
4674 - }
4675 -
4676 - function onBotReply(botId) {
4677 - var s = getState(botId);
4678 - s.botReplies += 1;
4679 - if (s.promptShown || s.dismissed) return;
4680 - var sessionId = getSessionId(botId);
4681 - if (sessionId && isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4682 - if (s.botReplies < MIN_BOT_REPLIES) return;
4683 - if (s.idleTimer) clearTimeout(s.idleTimer);
4684 - s.idleTimer = setTimeout(function() { showPrompt(botId); }, IDLE_MS);
4685 - }
4686 -
4687 - function onUserMessage(botId) {
4688 - var s = getState(botId);
4689 - if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = null; }
4690 - }
4691 -
4692 - function botIdFromChatBox(el) {
4693 - var id = el && el.id ? el.id : '';
4694 - return id.indexOf('chat-box-') === 0 ? id.substring('chat-box-'.length) : 'default';
4695 - }
4696 -
4697 - function setupObserver(chatBox) {
4698 - var botId = botIdFromChatBox(chatBox);
4699 - try {
4700 - var observer = new MutationObserver(function(mutations) {
4701 - mutations.forEach(function(m) {
4702 - for (var i = 0; i < m.addedNodes.length; i++) {
4703 - var node = m.addedNodes[i];
4704 - if (!node || node.nodeType !== 1) continue;
4705 - var $n = $(node);
4706 - if ($n.hasClass('mxchat-rating-bot-bubble') || $n.hasClass('mxchat-rating-prompt') || $n.hasClass('mxchat-rating-feedback') || $n.hasClass('mxchat-rating-saved')) continue;
4707 - 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)
4708 - else if ($n.hasClass('user-message')) onUserMessage(botId);
4709 - }
4710 - });
4711 - });
4712 - observer.observe(chatBox, { childList: true });
4713 - } catch (e) { /* noop */ }
4714 - }
4715 -
4716 - $('.chat-box').each(function() { setupObserver(this); });
4717 -
4718 - $(document).on('click', '.mxchat-rating-btn', function(e) {
4719 - e.preventDefault();
4720 - var $btn = $(this);
4721 - var $prompt = $btn.closest('.mxchat-rating-prompt');
4722 - var $wrap = $btn.closest('.mxchat-rating-bot-bubble');
4723 - var botId = $prompt.data('bot-id') || 'default';
4724 - var rating = parseInt($btn.attr('data-rating'), 10);
4725 - if (rating !== 1 && rating !== -1) return;
4726 - submitRating(botId, rating, '');
4727 - ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating));
4728 - syncRatingBubbleColors(botId);
4729 - scrollChatBoxToBottom(getChatBoxByBotId(botId));
4730 - });
4731 -
4732 - $(document).on('click', '.mxchat-rating-dismiss', function(e) {
4733 - e.preventDefault();
4734 - var $prompt = $(this).closest('.mxchat-rating-prompt');
4735 - var $wrap = $(this).closest('.mxchat-rating-bot-bubble');
4736 - var botId = $prompt.data('bot-id') || 'default';
4737 - var s = getState(botId);
4738 - s.dismissed = true;
4739 - markRated(getSessionId(botId));
4740 - ($wrap.length ? $wrap : $prompt).remove();
4741 - });
4742 -
4743 - function closeFeedback($fb) {
4744 - var botId = $fb.data('bot-id') || 'default';
4745 - var $wrap = $fb.closest('.mxchat-rating-bot-bubble');
4746 - ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId));
4747 - syncRatingBubbleColors(botId);
4748 - scrollChatBoxToBottom(getChatBoxByBotId(botId));
4749 - }
4750 -
4751 - $(document).on('click', '.mxchat-rating-skip', function(e) {
4752 - e.preventDefault();
4753 - closeFeedback($(this).closest('.mxchat-rating-feedback'));
4754 - });
4755 -
4756 - $(document).on('click', '.mxchat-rating-submit', function(e) {
4757 - e.preventDefault();
4758 - var $fb = $(this).closest('.mxchat-rating-feedback');
4759 - var botId = $fb.data('bot-id') || 'default';
4760 - var rating = parseInt($fb.attr('data-rating'), 10);
4761 - if (rating !== 1 && rating !== -1) { closeFeedback($fb); return; }
4762 - var text = String($fb.find('.mxchat-rating-feedback-input').val() || '').trim();
4763 - if (text !== '') {
4764 - submitRating(botId, rating, text);
4765 - }
4766 - closeFeedback($fb);
4767 - });
4768 3251 });
4769 3252