PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.25
MxChat – AI Chatbot & Content Generation for WordPress v3.2.25
3.2.25 3.2.24 3.2.23 3.2.22 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 All 156 releases
mxchat-basic / js / chat-script.js

chat-script.js in MxChat – AI Chatbot & Content Generation for WordPress 3.2.25, at js/chat-script.js

5,013 lines 212.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 jQuery(document).ready(function($) {
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 // ====================================
164 // MULTI-INSTANCE MANAGEMENT SYSTEM
165 // ====================================
166
167 // Instance registry - tracks all chatbot instances on the page
168 const MxChatInstances = {
169 instances: {},
170
171 // Lifecycle facts add-ons can read after the fact (plan 65c9b6). The
172 // events below fire once; a script that loads late checks these instead.
173 toolbarReady: false,
174 opened: {},
175 hasOpened: function(botId) {
176 return !!this.opened[botId || 'default'];
177 },
178
179 // Initialize an instance for a bot
180 init: function(botId) {
181 if (!this.instances[botId]) {
182 // When persistence is OFF, track when this session started
183 // so the AI only sees messages from this page load
184 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
185
186 this.instances[botId] = {
187 botId: botId,
188 sessionId: null,
189 lastSeenMessageId: '',
190 notificationCheckInterval: null,
191 pollingInterval: null,
192 processedMessageIds: new Set(),
193 activePdfFile: null,
194 activeWordFile: null,
195 chatHistoryLoaded: false,
196 isStreaming: false,
197 // Fresh context timestamp - only used when persistence is OFF
198 sessionStartTimestamp: chatPersistenceEnabled ? 0 : Date.now()
199 };
200 }
201 return this.instances[botId];
202 },
203
204 // Get instance by botId
205 get: function(botId) {
206 return this.instances[botId] || this.init(botId);
207 },
208
209 // Get all active bot IDs
210 getAllBotIds: function() {
211 return Object.keys(this.instances);
212 },
213
214 // Session management per bot
215 // Returns existing session ID from cookie or localStorage (with in-memory fallback),
216 // or null if none exists. Does NOT create a new session — use ensureSession() for that.
217 getChatSession: function(botId) {
218 var cookieName = 'mxchat_session_id_' + botId;
219 var storageKey = 'mxchat_session_id_' + botId;
220 var sessionId = getCookie(cookieName);
221
222 // Fallback to localStorage if cookie is missing (e.g. cleared by browser/consent)
223 if (!sessionId) {
224 try { sessionId = localStorage.getItem(storageKey); } catch (e) {}
225 }
226
227 // Fallback to in-memory instance when cookie AND localStorage are both blocked
228 // (Safari ITP, strict tracking prevention, cross-origin iframes with partitioned
229 // storage). Without this, ensureSession() can generate and store an ID that
230 // getChatSession() then can't read back, causing null session_ids on send.
231 if (!sessionId && this.instances[botId] && this.instances[botId].sessionId) {
232 sessionId = this.instances[botId].sessionId;
233 }
234
235 // Guard against stored sentinel values that indicate earlier broken writes.
236 if (sessionId === 'null' || sessionId === 'undefined') {
237 sessionId = null;
238 }
239
240 // Re-sync cookie from localStorage if cookie was lost
241 if (sessionId && !getCookie(cookieName)) {
242 document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax";
243 }
244
245 return sessionId || null;
246 },
247
248 // Lazy session initializer — called on first user interaction
249 ensureSession: function(botId) {
250 botId = botId || 'default';
251 var instance = this.instances[botId] || this.init(botId);
252
253 if (instance.sessionId) {
254 return instance.sessionId;
255 }
256
257 // Check for existing session from cookie or localStorage
258 var existingSession = this.getChatSession(botId);
259
260 if (existingSession) {
261 instance.sessionId = existingSession;
262 } else {
263 // Brand new session
264 var newId = generateSessionId();
265 this.setChatSession(botId, newId);
266 instance.sessionId = newId;
267 }
268
269 // Now that we have a session, do the deferred work
270 refreshNonceIfNeeded();
271 trackOriginatingPage();
272
273 // Note: loadChatHistory is handled by showChatContainerForBot with loader UI,
274 // so we do NOT call it here to avoid a race condition.
275
276 return instance.sessionId;
277 },
278
279 setChatSession: function(botId, sessionId) {
280 var cookieName = 'mxchat_session_id_' + botId;
281 var storageKey = 'mxchat_session_id_' + botId;
282 document.cookie = cookieName + "=" + sessionId + "; path=/; max-age=86400; SameSite=Lax";
283 try { localStorage.setItem(storageKey, sessionId); } catch (e) {}
284 if (this.instances[botId]) {
285 this.instances[botId].sessionId = sessionId;
286 }
287 },
288
289 resetChatSession: function(botId) {
290 // Clear old session from localStorage before setting new one
291 try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {}
292 var newSessionId = generateSessionId();
293 this.setChatSession(botId, newSessionId);
294 var $chatBox = getElement(botId, 'chat-box');
295 if ($chatBox.length) {
296 // Keep the greeting, drop everything else. Identify the greeting
297 // by its marker class, NOT by position (plan a1a79b): after a
298 // chat-persistence restore the first .bot-message is a real
299 // reply, so ":not(:first)" left a stale answer sitting at the
300 // top of an otherwise empty box. The positional fallback only
301 // runs when the marker is absent — a page served from HTML cache
302 // that predates this release — and behaves exactly as before.
303 if ($chatBox.find('.mxchat-intro-message').length) {
304 $chatBox.find('.user-message, .bot-message:not(.mxchat-intro-message), .agent-message').remove();
305 } else {
306 $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove();
307 }
308 }
309 if (this.instances[botId]) {
310 this.instances[botId].chatHistoryLoaded = false;
311 this.instances[botId].processedMessageIds = new Set();
312 }
313 },
314
315 // Silent reset — new session ID without clearing the chat UI
316 // Used when IP changes mid-conversation so the user doesn't see messages vanish
317 silentResetSession: function(botId) {
318 try { localStorage.removeItem('mxchat_session_id_' + botId); } catch (e) {}
319 var newSessionId = generateSessionId();
320 this.setChatSession(botId, newSessionId);
321 if (this.instances[botId]) {
322 this.instances[botId].sessionId = newSessionId;
323 }
324 return newSessionId;
325 }
326 };
327
328 // ====================================
329 // LIFECYCLE EVENTS FOR ADD-ONS (plan 65c9b6)
330 // ====================================
331 //
332 // Document-level CustomEvents an add-on listens for instead of polling the
333 // DOM or firing requests on page load:
334 // mxchat:toolbar-ready detail { enabled } once, right after the
335 // toolbar show/hide pass; items rendered through the
336 // mxchat_chat_toolbar_items filter can bind now.
337 // mxchat:opened detail { botId, embedded } once per bot per page
338 // load, the first time its window is shown — the
339 // launcher, the pre-chat bubble, an embedded widget
340 // (at load), or any script that reveals it.
341 // Same facts, readable late: MxChatInstances.toolbarReady / hasOpened(botId).
342 function mxchatDispatch(name, detail) {
343 try {
344 document.dispatchEvent(new CustomEvent(name, { detail: detail || {} }));
345 } catch (err) { /* CustomEvent unsupported — nothing to notify */ }
346 }
347
348 function mxchatFireOpened(botId) {
349 botId = botId || 'default';
350 if (MxChatInstances.opened[botId]) return;
351 MxChatInstances.opened[botId] = true;
352 mxchatDispatch('mxchat:opened', { botId: botId, embedded: isEmbeddedBot(botId) });
353 }
354
355 // Catch openers this file does not own (the Trigger add-on, theme buttons,
356 // custom scripts): a floating window gaining .visible counts as opened.
357 function mxchatWatchForOpen(botId) {
358 var el = getElementDOM(botId, 'floating-chatbot');
359 if (!el || typeof MutationObserver === 'undefined') return;
360 if (el.classList.contains('visible')) { mxchatFireOpened(botId); return; }
361 var obs = new MutationObserver(function() {
362 if (el.classList.contains('visible')) {
363 mxchatFireOpened(botId);
364 obs.disconnect();
365 }
366 });
367 obs.observe(el, { attributes: true, attributeFilter: ['class'] });
368 }
369
370 // ====================================
371 // ELEMENT SELECTOR HELPERS
372 // ====================================
373
374 // Check if a specific bot has an AI theme assigned (skip inline colors)
375 function shouldSkipInlineColors(botId) {
376 // If global AI theme is active, skip inline colors for all bots
377 if (mxchatChat.skip_inline_colors) {
378 return true;
379 }
380 // Check if this specific bot has a theme assignment
381 var botAssignments = mxchatChat.bot_theme_assignments || {};
382 return botAssignments.hasOwnProperty(botId);
383 }
384
385 // Get element by ID with bot suffix - returns jQuery object
386 function getElement(botId, elementName) {
387 return $('#' + elementName + '-' + botId);
388 }
389
390 // Get element by ID with bot suffix - returns DOM element
391 function getElementDOM(botId, elementName) {
392 return document.getElementById(elementName + '-' + botId);
393 }
394
395 // Get bot ID from any element within a chatbot instance
396 function getBotIdFromElement(element) {
397 var $wrapper = $(element).closest('.mxchat-chatbot-wrapper');
398 if ($wrapper.length) {
399 return $wrapper.data('bot-id') || 'default';
400 }
401 // Fallback: try to find from floating container
402 var $floating = $(element).closest('.floating-chatbot');
403 if ($floating.length) {
404 var id = $floating.attr('id') || '';
405 var match = id.match(/floating-chatbot-(.+)/);
406 if (match) return match[1];
407 }
408 // Fallback: the pre-chat teaser bubble (#pre-chat-message-{bot_id}) is a SIBLING
409 // outside .mxchat-chatbot-wrapper / .floating-chatbot, so its children — e.g. the
410 // .close-pre-chat-message button, which carries only a class and no id — miss both
411 // branches above. Walk to the nearest ancestor whose id is pre-chat-message-{bot_id}
412 // and read the suffix. (closest() includes the element itself, so a click directly on
413 // #pre-chat-message-{bot_id} resolves here too.)
414 var $preChat = $(element).closest('[id^="pre-chat-message-"]');
415 if ($preChat.length) {
416 var preId = $preChat.attr('id') || '';
417 var preMatch = preId.match(/^pre-chat-message-(.+)$/);
418 if (preMatch) return preMatch[1];
419 }
420 // Fallback: check if element itself has an ID with bot suffix (e.g., floating-chatbot-button-{bot_id})
421 var elementId = $(element).attr('id') || '';
422 if (elementId) {
423 // Match patterns like: floating-chatbot-button-{bot_id}, pre-chat-message-{bot_id}
424 var idMatch = elementId.match(/^(?:floating-chatbot-button|pre-chat-message|chat-notification-badge)-(.+)$/);
425 if (idMatch) return idMatch[1];
426 }
427 return 'default';
428 }
429
430 // Get wrapper element for a bot
431 function getWrapper(botId) {
432 return getElement(botId, 'mxchat-chatbot-wrapper');
433 }
434
435 // ====================================
436 // GLOBAL VARIABLES & CONFIGURATION
437 // ====================================
438 const toolbarIconColor = mxchatChat.toolbar_icon_color || '#212121';
439
440 // Initialize color settings (these are global as they come from PHP)
441 var userMessageBgColor = mxchatChat.user_message_bg_color;
442 var userMessageFontColor = mxchatChat.user_message_font_color;
443 var botMessageBgColor = mxchatChat.bot_message_bg_color;
444 var botMessageFontColor = mxchatChat.bot_message_font_color;
445 var liveAgentMessageBgColor = mxchatChat.live_agent_message_bg_color;
446 var liveAgentMessageFontColor = mxchatChat.live_agent_message_font_color;
447
448 var linkTarget = mxchatChat.link_target_toggle === 'on' ? '_blank' : '_self';
449
450 // ====================================
451 // SESSION MANAGEMENT (Legacy compatibility)
452 // ====================================
453
454 function getCookie(name) {
455 let value = "; " + document.cookie;
456 let parts = value.split("; " + name + "=");
457 if (parts.length == 2) return parts.pop().split(";").shift();
458 }
459
460 function generateSessionId() {
461 // Session IDs function as the de-facto bearer token for an anonymous
462 // chat, so generate them with a CSPRNG when available. Math.random is a
463 // legacy fallback for ancient/sandboxed environments that lack
464 // window.crypto. The 'mxchat_chat_' prefix is preserved exactly (other
465 // code pattern-matches on it). (plan-0c17b5)
466 var rand;
467 try {
468 if (window.crypto && window.crypto.getRandomValues) {
469 var buf = new Uint8Array(16); // 128 bits
470 window.crypto.getRandomValues(buf);
471 rand = Array.prototype.map.call(buf, function (b) {
472 return ('0' + b.toString(16)).slice(-2);
473 }).join('');
474 }
475 } catch (e) {}
476 if (!rand) {
477 rand = Math.random().toString(36).substr(2, 9); // legacy fallback
478 }
479 return 'mxchat_chat_' + rand;
480 }
481
482 // Legacy function - now delegates to instance manager
483 function getChatSession(botId) {
484 botId = botId || 'default';
485 return MxChatInstances.getChatSession(botId);
486 }
487
488 function setChatSession(sessionId, botId) {
489 botId = botId || 'default';
490 MxChatInstances.setChatSession(botId, sessionId);
491 }
492
493 function resetChatSession(botId) {
494 botId = botId || 'default';
495 MxChatInstances.resetChatSession(botId);
496 }
497
498 // ====================================
499 // INITIALIZE ALL CHATBOT INSTANCES
500 // ====================================
501
502 function initializeAllInstances() {
503 // Find all chatbot wrappers on the page
504 $('.mxchat-chatbot-wrapper').each(function() {
505 var botId = $(this).data('bot-id') || 'default';
506 MxChatInstances.init(botId);
507 initializeBotInstance(botId);
508 });
509 }
510
511 function initializeBotInstance(botId) {
512 var instance = MxChatInstances.get(botId);
513
514 // Initialize quick questions state for this bot
515 checkQuickQuestionsState(botId);
516
517 // Note: Event handlers use event delegation with class selectors,
518 // so they work automatically for all instances without per-bot setup
519 }
520
521 // ====================================
522 // CONTEXTUAL AWARENESS FUNCTIONALITY
523 // ====================================
524
525 // Plan f3e515: the page context is computed once per page, not once per message.
526 // Keyed on URL + title + the content element's text length — cheap to check,
527 // and enough to notice a navigation or a re-rendered content area.
528 var pageContextCache = { key: null, value: null };
529
530 function getPageContext() {
531 // Check if contextual awareness is enabled
532 if (mxchatChat.contextual_awareness_toggle !== 'on') {
533 return null;
534 }
535
536 // Get page URL
537 const pageUrl = window.location.href;
538
539 // Get page title
540 const pageTitle = document.title || '';
541
542 // Get main content from the page
543 let pageContent = '';
544
545 // Try to get content from common content areas
546 const contentSelectors = [
547 'main',
548 '[role="main"]',
549 '.content',
550 '.main-content',
551 '.post-content',
552 '.entry-content',
553 '.page-content',
554 'article',
555 '#content',
556 '#main'
557 ];
558
559 let contentElement = null;
560 for (const selector of contentSelectors) {
561 contentElement = document.querySelector(selector);
562 if (contentElement) {
563 break;
564 }
565 }
566
567 // If no specific content area found, use body but exclude header, footer, nav, sidebar
568 if (!contentElement) {
569 contentElement = document.body;
570 }
571
572 if (contentElement) {
573 const cacheKey = pageUrl + '\u0000' + pageTitle + '\u0000' + (contentElement.textContent || '').length;
574 if (pageContextCache.key === cacheKey) {
575 return pageContextCache.value;
576 }
577
578 // Copy the element into a document that has no browsing context
579 // (plan f3e515). A live cloneNode(true) constructed real <video> /
580 // <audio> / <img> elements: media with a src runs resource selection
581 // on creation whether or not it is attached, and an autoplay attribute
582 // made the detached copy PLAY — audibly, once the visible player had
583 // been unmuted through the property. Every message spawned another
584 // one. Elements in an inert document never fetch or play, while
585 // querySelectorAll / textContent / dataset all still work on them.
586 const inertDocument = document.implementation.createHTMLDocument('');
587 const clone = inertDocument.importNode(contentElement, true);
588
589 // Remove unwanted elements
590 const unwantedSelectors = [
591 'header',
592 'footer',
593 'nav',
594 '.navigation',
595 '.sidebar',
596 '.widget',
597 '.menu',
598 'script',
599 'style',
600 '.comments',
601 '#comments',
602 '.breadcrumb',
603 '.breadcrumbs',
604 '#floating-chatbot',
605 '#floating-chatbot-button',
606 '.mxchat',
607 '[class*="chat"]',
608 '[id*="chat"]',
609 // Media contributes no text and (outside the inert document) fetches.
610 'video',
611 'audio',
612 'iframe',
613 'object',
614 'embed',
615 'picture',
616 'img',
617 'source'
618 ];
619
620 unwantedSelectors.forEach(selector => {
621 const elements = clone.querySelectorAll(selector);
622 elements.forEach(el => el.remove());
623 });
624
625 // Extract MxChat context data attributes before getting text content
626 const contextData = [];
627 clone.querySelectorAll('[data-mxchat-context]').forEach(el => {
628 const contextValue = el.dataset.mxchatContext;
629 if (contextValue && contextValue.trim()) {
630 contextData.push(contextValue);
631 }
632 });
633
634 // Get text content and clean it up
635 pageContent = clone.textContent || clone.innerText || '';
636
637 // Add context data to page content if any were found
638 if (contextData.length > 0) {
639 pageContent += '\n\nAdditional Context:\n' + contextData.join('\n');
640 }
641
642 // Clean up whitespace and limit length
643 pageContent = pageContent
644 .replace(/\s+/g, ' ')
645 .trim()
646 .substring(0, 3000); // Limit to 3000 characters to avoid token limits
647
648 // Only return context if we have meaningful content
649 const result = (!pageContent || pageContent.length < 50) ? null : {
650 url: pageUrl,
651 title: pageTitle,
652 content: pageContent
653 };
654 pageContextCache = { key: cacheKey, value: result };
655 return result;
656 }
657
658 return null;
659 }
660
661 // Track originating page when chat starts
662 function trackOriginatingPage() {
663 const sessionId = getChatSession();
664 const pageUrl = window.location.href;
665 const pageTitle = document.title || 'Untitled Page';
666
667 // Only track once per session
668 const trackingKey = 'mxchat_originating_tracked_' + sessionId;
669 if (sessionStorage.getItem(trackingKey)) {
670 return;
671 }
672
673 $.ajax({
674 url: mxchatChat.ajax_url,
675 type: 'POST',
676 data: {
677 action: 'mxchat_track_originating_page',
678 session_id: sessionId,
679 page_url: pageUrl,
680 page_title: pageTitle,
681 nonce: mxchatChat.nonce
682 },
683 success: function(response) {
684 if (response.success) {
685 sessionStorage.setItem(trackingKey, 'true');
686 }
687 }
688 });
689 }
690
691 // ====================================
692 // CORE CHAT FUNCTIONALITY
693 // ====================================
694
695 // Helper functions to disable/enable chat input while waiting for response
696 function disableChatInput(botId) {
697 botId = botId || 'default';
698 var chatInput = getElementDOM(botId, 'chat-input');
699 var sendButton = getElementDOM(botId, 'send-button');
700 if (chatInput) {
701 chatInput.disabled = true;
702 chatInput.style.opacity = '0.6';
703 }
704 if (sendButton) {
705 sendButton.disabled = true;
706 sendButton.style.opacity = '0.5';
707 sendButton.style.pointerEvents = 'none';
708 }
709 }
710
711 // Whether the input may grab focus after a completed reply (plan 03799f).
712 // On coarse-pointer devices focusing a text input summons the on-screen
713 // keyboard over the answer the visitor is trying to read, so 'auto' (the
714 // default) focuses only on fine-pointer devices. The site-wide
715 // mxchat_autofocus_after_reply PHP filter can force 'on'/'off'.
716 // NOT used on widget open (:~3424) — that focus is a deliberate act and is
717 // what makes the widget keyboard-accessible.
718 function mxchatShouldAutofocusAfterReply() {
719 var pref = (typeof mxchatChat !== 'undefined' && mxchatChat.autofocus_after_reply) || 'auto';
720 if (pref === 'on') return true;
721 if (pref === 'off') return false;
722 try {
723 return !window.matchMedia('(pointer: coarse)').matches;
724 } catch (err) {
725 return true;
726 }
727 }
728
729 function enableChatInput(botId) {
730 botId = botId || 'default';
731 var chatInput = getElementDOM(botId, 'chat-input');
732 var sendButton = getElementDOM(botId, 'send-button');
733 if (chatInput) {
734 chatInput.disabled = false;
735 chatInput.style.opacity = '1';
736 if (mxchatShouldAutofocusAfterReply()) {
737 try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
738 }
739 }
740 if (sendButton) {
741 sendButton.disabled = false;
742 sendButton.style.opacity = '1';
743 sendButton.style.pointerEvents = 'auto';
744 }
745 // Every completion path re-enables input, so this is the single restore
746 // point for the streaming Stop affordance (no-op when not in stop mode).
747 mxchatRestoreSendButton(botId);
748 }
749
750 // --- Streaming Stop control -------------------------------------------------
751 // One live stream handle per bot instance, so Stop on one widget never aborts
752 // another bot on the same page.
753 var mxchatActiveStreams = {};
754 // Original send-button markup, captured once per bot the first time the Stop
755 // state is shown (never captured while already in stop mode, so a rapid
756 // stop-then-resend can't save the stop glyph as the "original").
757 var mxchatSendMarkup = {};
758
759 function mxchatShowStopButton(botId) {
760 var btn = getElementDOM(botId, 'send-button');
761 if (!btn) return;
762 if (!btn.classList.contains('mxchat-stop-mode')) {
763 mxchatSendMarkup[botId] = {
764 html: btn.innerHTML,
765 label: btn.getAttribute('aria-label')
766 };
767 }
768
769 // Mirror the send icon's rendered size + color so the stop glyph looks
770 // native, including custom send images/colors and theme overrides.
771 var child = btn.querySelector('svg, img');
772 var size = 25;
773 var color = '';
774 if (child) {
775 var rect = child.getBoundingClientRect();
776 if (rect.width) {
777 size = Math.round(Math.min(rect.width, rect.height));
778 }
779 var cs = window.getComputedStyle(child);
780 color = (child.tagName.toLowerCase() === 'svg' ? cs.fill : cs.color) || '';
781 }
782 var stopLabel = (typeof mxchatChat !== 'undefined' && mxchatChat.stop_button_label) || 'Stop response';
783 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>';
784 // An add-on's DIRECT send-button handler (e.g. mxchat-vision's rebind) can
785 // start a stream synchronously while the originating click is still
786 // bubbling up to our delegated handler. Without this guard, that handler
787 // reads the just-added stop-mode class as a user Stop press and aborts the
788 // brand-new stream — the user's message renders but no reply ever fires
789 // (plan-4bba64 silent message loss). The flag only spans the current event
790 // dispatch: cleared on the next macrotask, long before a real Stop click.
791 btn.__mxchatStopJustShown = true;
792 setTimeout(function () { btn.__mxchatStopJustShown = false; }, 0);
793 btn.classList.add('mxchat-stop-mode');
794 btn.setAttribute('aria-label', stopLabel);
795 btn.setAttribute('title', stopLabel);
796 // disableChatInput() ran when the turn was sent; the Stop control itself
797 // must stay clickable while the textarea remains disabled.
798 btn.disabled = false;
799 btn.style.opacity = '1';
800 btn.style.pointerEvents = 'auto';
801 }
802
803 function mxchatRestoreSendButton(botId) {
804 var btn = getElementDOM(botId, 'send-button');
805 var saved = mxchatSendMarkup[botId];
806 if (!btn || !btn.classList.contains('mxchat-stop-mode') || !saved) return;
807 btn.innerHTML = saved.html;
808 btn.classList.remove('mxchat-stop-mode');
809 btn.removeAttribute('title');
810 if (saved.label) {
811 btn.setAttribute('aria-label', saved.label);
812 }
813 }
814
815 function mxchatStopStreaming(botId) {
816 var entry = mxchatActiveStreams[botId];
817 if (!entry || !entry.controller) return;
818 entry.aborted = true;
819 try { entry.controller.abort(); } catch (e) {}
820 }
821
822 // Returns true when a stream rejection came from an intentional Stop click:
823 // keep the partial text as the turn's answer — no error UI, no fallback resend.
824 function mxchatHandleStreamAbort(botId, accumulatedContent, callback) {
825 var entry = mxchatActiveStreams[botId];
826 if (!entry || !entry.aborted) return false;
827 delete mxchatActiveStreams[botId];
828 if (!accumulatedContent) {
829 // Stopped before the first chunk: drop the thinking bubble, no orphan message.
830 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
831 }
832 enableChatInput(botId); // also restores the send icon
833 if (callback) {
834 callback(accumulatedContent || '');
835 }
836 return true;
837 }
838
839 // Update your existing sendMessage function
840 function sendMessage(botId) {
841 botId = botId || 'default';
842 MxChatInstances.ensureSession(botId);
843 var $chatInput = getElement(botId, 'chat-input');
844 var message = $chatInput.val();
845
846 // ADD PROMPT HOOK HERE
847 if (typeof customMxChatFilter === 'function') {
848 message = customMxChatFilter(message, "prompt");
849 }
850
851 // Whitespace-only is empty: nothing is sent and nothing is disabled. When
852 // there IS a message the original string (spaces and all) is what goes out,
853 // so the transcript is unchanged (plan b67fd5).
854 if (message && String(message).trim() !== '') {
855 // Don't disable input in live agent mode - let users chat freely
856 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
857 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
858 if (!isAgentMode) {
859 disableChatInput(botId);
860 }
861
862 appendMessage("user", message, '', [], false, botId);
863 $chatInput.val('');
864 mxchatUpdateCharCounter($chatInput[0]); // reset the char counter after send (plan 7091a2)
865 $chatInput.css('height', 'auto');
866
867 if (hasQuickQuestions(botId)) {
868 collapseQuickQuestions(botId);
869 }
870 appendThinkingMessage(botId);
871 scrollToBottom(botId);
872
873 const currentModel = mxchatChat.model || 'gpt-5.6-sol';
874
875 // Check if streaming is enabled AND supported for this model
876 if (shouldUseStreaming(currentModel)) {
877 callMxChatStream(message, function(response) {
878 // Content is final: releasing aria-busy lets the live region
879 // announce the completed reply once (plan 67f126).
880 getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
881 }, botId);
882 } else {
883 callMxChat(message, function(response) {
884 replaceLastMessage("bot", response, '', [], botId);
885 }, botId);
886 }
887 }
888 }
889
890 // Update your existing sendMessageToChatbot function
891 function sendMessageToChatbot(message, botId) {
892 botId = botId || 'default';
893 MxChatInstances.ensureSession(botId);
894
895 // ADD PROMPT HOOK HERE
896 if (typeof customMxChatFilter === 'function') {
897 message = customMxChatFilter(message, "prompt");
898 }
899
900 // Nothing to send means nothing to disable (plan b67fd5): a caller handing
901 // over an empty string must not lock the box the way Enter-on-empty did.
902 if (!message || String(message).trim() === '') {
903 return;
904 }
905
906 // Don't disable input in live agent mode - let users chat freely
907 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
908 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
909 if (!isAgentMode) {
910 disableChatInput(botId);
911 }
912
913 var sessionId = getChatSession(botId);
914
915 if (hasQuickQuestions(botId)) {
916 collapseQuickQuestions(botId);
917 }
918 appendThinkingMessage(botId);
919 scrollToBottom(botId);
920
921 const currentModel = mxchatChat.model || 'gpt-5.6-sol';
922
923 // Check if streaming is enabled AND supported for this model
924 if (shouldUseStreaming(currentModel)) {
925 callMxChatStream(message, function(response) {
926 // Final content — release aria-busy so the reply announces once (67f126).
927 getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
928 }, botId);
929 } else {
930 callMxChat(message, function(response) {
931 getElement(botId, 'chat-box').find('.temporary-message').remove();
932 replaceLastMessage("bot", response, '', [], botId);
933 }, botId);
934 }
935 }
936
937 // Updated shouldUseStreaming function with debugging
938 function shouldUseStreaming(model) {
939 // Check if streaming is enabled in settings (using your toggle naming pattern)
940 const streamingEnabled = mxchatChat.enable_streaming_toggle === 'on';
941
942 // Check if model supports streaming
943 const streamingSupported = isStreamingSupported(model);
944
945
946 // Only use streaming if both enabled and supported
947 return streamingEnabled && streamingSupported;
948 }
949
950 // Helper function to handle chat mode updates
951 function handleChatModeUpdates(response, responseText) {
952 // Check for explicit chat mode in response (THIS IS THE KEY FIX)
953 if (response.chat_mode) {
954 updateChatModeIndicator(response.chat_mode);
955 return; // Return early since we found explicit mode
956 }
957 // Check for fallback response chat mode
958 else if (response.fallbackResponse && response.fallbackResponse.chat_mode) {
959 updateChatModeIndicator(response.fallbackResponse.chat_mode);
960 return; // Return early since we found explicit mode
961 }
962
963 // Only do text-based detection if no explicit mode was provided
964 // Check for specific AI chatbot response text
965 if (responseText === 'You are now chatting with the AI chatbot.' ||
966 responseText.includes('now chatting with the AI') ||
967 responseText.includes('switched to AI mode') ||
968 responseText.includes('AI chatbot is now')) {
969 updateChatModeIndicator('ai');
970 }
971 // Check for agent transfer messages
972 else if (responseText.includes('agent') &&
973 (responseText.includes('transfer') || responseText.includes('connected'))) {
974 updateChatModeIndicator('agent');
975 }
976 }
977
978 // Function to get bot ID from any element or wrapper
979 // If element is provided, finds the bot ID from its wrapper
980 // If no element, returns 'default' (for backward compatibility)
981 function getMxChatBotId(element) {
982 if (element) {
983 return getBotIdFromElement(element);
984 }
985 // Fallback: find first chatbot wrapper on page
986 const chatbotWrapper = document.querySelector('.mxchat-chatbot-wrapper');
987 return chatbotWrapper ? chatbotWrapper.getAttribute('data-bot-id') || 'default' : 'default';
988 }
989
990 function callMxChat(message, callback, botId) {
991 botId = botId || getMxChatBotId();
992
993 // Streaming fallbacks land here: drop any leftover stream handle and
994 // return the button to its send state (no-op for plain non-stream turns).
995 if (mxchatActiveStreams[botId]) {
996 delete mxchatActiveStreams[botId];
997 }
998 mxchatRestoreSendButton(botId);
999
1000 // Store the message in case we need to retry after session reset
1001 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
1002
1003 // Get page context if contextual awareness is enabled
1004 const pageContext = getPageContext();
1005
1006 // Get instance for session start timestamp (used when persistence is OFF)
1007 var instance = MxChatInstances.get(botId);
1008
1009 // Guarantee a non-null session_id before the AJAX leaves. ensureSession() is idempotent
1010 // and returns the guaranteed-present session id from the in-memory instance even when
1011 // cookie/localStorage writes are silently blocked by the browser.
1012 var sessionId = MxChatInstances.ensureSession(botId);
1013 if (!sessionId || sessionId === 'null' || sessionId === 'undefined') {
1014 // Last-resort generation to ensure we never POST a null marker.
1015 sessionId = generateSessionId();
1016 MxChatInstances.setChatSession(botId, sessionId);
1017 }
1018
1019 // Wait for the page-cache nonce refresh to complete before firing the
1020 // chat-send AJAX. On cached pages the inline mxchatChat.nonce is stale
1021 // until refreshNonceIfNeeded() returns; constructing ajaxData inside the
1022 // callback guarantees we read the fresh value. See plan-c5457f.
1023 refreshNonceIfNeeded(function() {
1024 // Prepare AJAX data
1025 const ajaxData = {
1026 action: 'mxchat_handle_chat_request',
1027 message: message,
1028 session_id: sessionId,
1029 nonce: mxchatChat.nonce,
1030 current_page_url: window.location.href,
1031 current_page_title: document.title,
1032 bot_id: botId,
1033 // Pass session start timestamp so AI context matches what user sees
1034 session_start_timestamp: instance.sessionStartTimestamp || 0
1035 };
1036
1037 // Add page context if available
1038 if (pageContext) {
1039 ajaxData.page_context = JSON.stringify(pageContext);
1040 }
1041
1042 // CHECK FOR VISION FLAGS AND ADD THEM
1043 if (window.mxchatVisionProcessed) {
1044 ajaxData.vision_processed = true;
1045 ajaxData.original_user_message = window.mxchatOriginalMessage || message;
1046 ajaxData.vision_images_count = window.mxchatVisionImagesCount || 0;
1047 // Clear the flags after use
1048 window.mxchatVisionProcessed = false;
1049 window.mxchatOriginalMessage = null;
1050 window.mxchatVisionImagesCount = 0;
1051 }
1052
1053 $.ajax({
1054 url: mxchatChat.ajax_url,
1055 type: 'POST',
1056 dataType: 'json',
1057 data: ajaxData,
1058 success: function(response) {
1059 // IMMEDIATE CHAT MODE UPDATE - This should be FIRST
1060 if (response.chat_mode) {
1061 updateChatModeIndicator(response.chat_mode, botId);
1062 }
1063
1064 // Also check in data property if response is wrapped
1065 if (response.data && response.data.chat_mode) {
1066 updateChatModeIndicator(response.data.chat_mode, botId);
1067 }
1068
1069 // SECURITY FIX: Check for errors FIRST before checking for success
1070 // This ensures API errors (quota exceeded, invalid key, rate limit) are properly displayed
1071 if (response.success === false || (response.data && response.data.error_message)) {
1072 let errorMessage = "";
1073 let errorCode = "";
1074
1075 // Check various possible error locations in the response
1076 if (response.data && response.data.error_message) {
1077 errorMessage = response.data.error_message;
1078 errorCode = response.data.error_code || "";
1079 } else if (response.error_message) {
1080 errorMessage = response.error_message;
1081 errorCode = response.error_code || "";
1082 } else if (response.message) {
1083 errorMessage = response.message;
1084 } else if (typeof response.data === 'string') {
1085 errorMessage = response.data;
1086 } else {
1087 // Fallback for any other unexpected response format
1088 errorMessage = "An error occurred. Please try again or contact support.";
1089 }
1090
1091 // Handle session reset action (IP changed, session expired, etc.)
1092 // Silent reset — keep chat UI intact, just get a new session and retry
1093 if (response.data && response.data.action === 'reset_session') {
1094 MxChatInstances.silentResetSession(botId);
1095 // Re-send the original message with the new session (user message is already displayed)
1096 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1097 if (originalMessage) {
1098 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1099 var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1100 if (shouldUseStreaming(currentModel)) {
1101 callMxChatStream(originalMessage, function(response) {
1102 // Final content — release aria-busy so the reply announces once (67f126).
1103 getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false');
1104 }, botId);
1105 } else {
1106 callMxChat(originalMessage, function(response) {
1107 replaceLastMessage("bot", response, '', [], botId);
1108 }, botId);
1109 }
1110 }
1111 return;
1112 }
1113
1114 // Format user-friendly error message
1115 let displayMessage = errorMessage;
1116
1117 // Customize message for admin users
1118 if (mxchatChat.is_admin) {
1119 // For admin users, show more technical details including error code
1120 displayMessage = errorMessage + (errorCode ? " (Error code: " + errorCode + ")" : "");
1121 }
1122
1123 replaceLastMessage("bot", displayMessage, '', [], botId);
1124 return; // Exit early for errors
1125 }
1126
1127 // NOW check if this is a successful response by looking for text, html, or message fields
1128 // This preserves compatibility with your server response format
1129 if (response.text !== undefined || response.html !== undefined || response.message !== undefined ||
1130 (response.success === true && response.data && response.data.status === 'waiting_for_agent')) {
1131
1132 // Handle successful response - this is your original success handling code
1133
1134 // Handle other responses
1135 let responseText = response.text || '';
1136 let responseHtml = response.html || '';
1137 let responseMessage = response.message || '';
1138
1139 // Add PDF filename handling
1140 if (response.data && response.data.filename) {
1141 showActivePdf(response.data.filename, botId);
1142 var instance = MxChatInstances.get(botId);
1143 instance.activePdfFile = response.data.filename;
1144 }
1145
1146 // Add redirect check here
1147 if (response.redirect_url) {
1148 if (responseText) {
1149 replaceLastMessage("bot", responseText, '', [], botId);
1150 }
1151 setTimeout(() => {
1152 window.location.href = response.redirect_url;
1153 }, 1500);
1154 return;
1155 }
1156
1157 // Check for live agent response
1158 if (response.success && response.data && response.data.status === 'waiting_for_agent') {
1159 removeThinkingDots(botId);
1160 updateChatModeIndicator('agent', botId);
1161 enableChatInput(botId);
1162 return;
1163 }
1164
1165 // Handle the message and show notification if chat is hidden
1166 if (responseText || responseHtml || responseMessage) {
1167
1168 // ADD RESPONSE HOOKS HERE - BEFORE DISPLAYING
1169 if (responseText && typeof customMxChatFilter === 'function') {
1170 responseText = customMxChatFilter(responseText, "response");
1171 }
1172 if (responseMessage && typeof customMxChatFilter === 'function') {
1173 responseMessage = customMxChatFilter(responseMessage, "response");
1174 }
1175
1176 // Update the messages as before
1177 if (responseText && responseHtml) {
1178 replaceLastMessage("bot", responseText, responseHtml, [], botId);
1179 } else if (responseText) {
1180 replaceLastMessage("bot", responseText, '', [], botId);
1181 } else if (responseHtml) {
1182 replaceLastMessage("bot", "", responseHtml, [], botId);
1183 } else if (responseMessage) {
1184 replaceLastMessage("bot", responseMessage, '', [], botId);
1185 }
1186
1187 // Check if chat is hidden and show notification
1188 var $floatingChatbot = getElement(botId, 'floating-chatbot');
1189 if ($floatingChatbot.hasClass('hidden')) {
1190 var $badge = getElement(botId, 'chat-notification-badge');
1191 if ($badge.length) {
1192 $badge.show();
1193 }
1194 }
1195 } else {
1196 var emptyMsg = "I received an empty response. Please try again or contact support if this persists.";
1197 if (response.vectorstore_error) {
1198 emptyMsg = "I received an empty response. Debug info: " + response.vectorstore_error;
1199 }
1200 replaceLastMessage("bot", emptyMsg, '', [], botId);
1201 }
1202
1203 if (response.message_id) {
1204 var instance = MxChatInstances.get(botId);
1205 instance.lastSeenMessageId = response.message_id;
1206 }
1207
1208 return;
1209 }
1210
1211 // Fallback for truly unexpected response formats
1212 replaceLastMessage("bot", "Unexpected response format. Please try again or contact support.", '', [], botId);
1213 },
1214 error: function(xhr, status, error) {
1215 let errorMessage = "An unexpected error occurred.";
1216
1217 // Try to parse the response if it's JSON
1218 try {
1219 const responseJson = JSON.parse(xhr.responseText);
1220
1221 if (responseJson.data && responseJson.data.error_message) {
1222 errorMessage = responseJson.data.error_message;
1223 } else if (responseJson.message) {
1224 errorMessage = responseJson.message;
1225 }
1226 } catch (e) {
1227 // Not JSON or parsing failed, use HTTP status based messages
1228 if (xhr.status === 0) {
1229 errorMessage = "Network error: Please check your internet connection.";
1230 } else if (xhr.status === 403) {
1231 errorMessage = "Access denied: Your session may have expired. Please refresh the page.";
1232 } else if (xhr.status === 404) {
1233 errorMessage = "API endpoint not found. Please contact support.";
1234 } else if (xhr.status === 429) {
1235 errorMessage = "Too many requests. Please try again in a moment.";
1236 } else if (xhr.status >= 500) {
1237 errorMessage = "Server error: The server encountered an issue. Please try again later.";
1238 }
1239 }
1240
1241 replaceLastMessage("bot", errorMessage, '', [], botId);
1242 }
1243 });
1244 }); // refreshNonceIfNeeded
1245 }
1246
1247 function callMxChatStream(message, callback, botId) {
1248 botId = botId || getMxChatBotId();
1249
1250 // Store the message in case we need to retry after session reset
1251 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message);
1252
1253 const currentModel = mxchatChat.model || 'gpt-5.6-sol';
1254 if (!isStreamingSupported(currentModel)) {
1255 callMxChat(message, callback, botId);
1256 return;
1257 }
1258
1259 // Get page context if contextual awareness is enabled
1260 const pageContext = getPageContext();
1261
1262 // Get instance for session start timestamp (used when persistence is OFF)
1263 var instance = MxChatInstances.get(botId);
1264
1265 // Guarantee a non-null session_id before the fetch. FormData.append() stringifies any
1266 // non-string value via String(), so passing `null` would POST the literal string "null"
1267 // and land in the transcripts table as a ghost session. ensureSession() always returns
1268 // a real string even when cookies/localStorage are blocked.
1269 var streamSessionId = MxChatInstances.ensureSession(botId);
1270 if (!streamSessionId || streamSessionId === 'null' || streamSessionId === 'undefined') {
1271 streamSessionId = generateSessionId();
1272 MxChatInstances.setChatSession(botId, streamSessionId);
1273 }
1274
1275 // Wait for the page-cache nonce refresh before constructing formData (which
1276 // captures mxchatChat.nonce by value). Mirrors callMxChat's wrapping. See plan-c5457f.
1277 refreshNonceIfNeeded(function() {
1278 const formData = new FormData();
1279 formData.append('action', 'mxchat_stream_chat');
1280 formData.append('message', message);
1281 formData.append('session_id', streamSessionId);
1282 formData.append('nonce', mxchatChat.nonce);
1283 formData.append('current_page_url', window.location.href);
1284 formData.append('current_page_title', document.title);
1285 formData.append('bot_id', botId);
1286 // Pass session start timestamp so AI context matches what user sees
1287 formData.append('session_start_timestamp', instance.sessionStartTimestamp || 0);
1288
1289 // Add page context if available
1290 if (pageContext) {
1291 formData.append('page_context', JSON.stringify(pageContext));
1292 }
1293
1294 // CHECK FOR VISION FLAGS AND ADD THEM
1295 if (window.mxchatVisionProcessed) {
1296 formData.append('vision_processed', 'true');
1297 formData.append('original_user_message', window.mxchatOriginalMessage || message);
1298 formData.append('vision_images_count', window.mxchatVisionImagesCount || '0');
1299 // Clear the flags after use
1300 window.mxchatVisionProcessed = false;
1301 window.mxchatOriginalMessage = null;
1302 window.mxchatVisionImagesCount = 0;
1303 }
1304
1305 let accumulatedContent = '';
1306 let testingDataReceived = false;
1307 let streamingStarted = false;
1308 // Server-pushed html to append as its OWN bot bubble once the stream
1309 // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is
1310 // deferred to the end of the stream so the embed always lands BELOW the
1311 // streamed text.
1312 let pendingAppendHtml = '';
1313
1314 // Render the stashed appendix as its own bot bubble below the streamed
1315 // text — mirrors how it is saved in the transcript, so history replays
1316 // identically. Called from BOTH ends of a stream: the [DONE] line and
1317 // the stream simply closing (the OpenAI Responses path and Gemini end
1318 // without a [DONE] line). The stash is cleared, so a stream that sends
1319 // [DONE] and then closes renders it once. Any future appendix-type
1320 // event belongs in here, not in one of the two branches.
1321 function flushAppendHtml() {
1322 if (pendingAppendHtml) {
1323 appendMessage("bot", "", pendingAppendHtml, [], false, botId);
1324 pendingAppendHtml = '';
1325 }
1326 }
1327
1328 // Abortable stream: a fresh controller per turn, keyed by bot instance.
1329 // The Stop control (send button swapped in place) aborts both the read
1330 // loop and the underlying request.
1331 var streamControl = { controller: new AbortController(), aborted: false };
1332 mxchatActiveStreams[botId] = streamControl;
1333 mxchatShowStopButton(botId);
1334
1335 fetch(mxchatChat.ajax_url, {
1336 method: 'POST',
1337 body: formData,
1338 credentials: 'same-origin',
1339 signal: streamControl.controller.signal
1340 })
1341 .then(response => {
1342 // Store the response for potential fallback handling
1343 const responseClone = response.clone();
1344
1345 if (!response.ok) {
1346 // Try to get error details from response
1347 return responseClone.json().then(errorData => {
1348 throw { isServerError: true, data: errorData };
1349 }).catch(() => {
1350 throw new Error('Network response was not ok');
1351 });
1352 }
1353
1354 // Check if response is JSON instead of streaming
1355 const contentType = response.headers.get('content-type');
1356 if (contentType && contentType.includes('application/json')) {
1357 return responseClone.json().then(data => {
1358 // IMMEDIATE CHAT MODE UPDATE for JSON response
1359 if (data.chat_mode) {
1360 updateChatModeIndicator(data.chat_mode, botId);
1361 }
1362
1363 // Check for testing panel
1364 if (window.mxchatTestPanelInstance && data.testing_data) {
1365 window.mxchatTestPanelInstance.handleTestingData(data.testing_data);
1366 }
1367
1368 // Handle the JSON response directly
1369 handleNonStreamResponse(data, callback, botId);
1370 return Promise.resolve(); // Prevent further processing
1371 });
1372 }
1373
1374 // Continue with streaming processing
1375 const reader = response.body.getReader();
1376 const decoder = new TextDecoder();
1377 let buffer = '';
1378
1379 function processStream() {
1380 reader.read().then(({ done, value }) => {
1381 if (done) {
1382 // If streaming completed but no content was received, try to get response as fallback
1383 if (!streamingStarted || !accumulatedContent) {
1384 // Try to read the response as JSON
1385 responseClone.text().then(text => {
1386 try {
1387 const data = JSON.parse(text);
1388 if (data.text || data.message || data.html) {
1389 handleNonStreamResponse(data, callback, botId);
1390 } else {
1391 // No valid data, fall back to regular call
1392 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1393 callMxChat(message, callback, botId);
1394 }
1395 } catch (e) {
1396 // Could not parse, fall back to regular call
1397 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1398 callMxChat(message, callback, botId);
1399 }
1400 }).catch(() => {
1401 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1402 callMxChat(message, callback, botId);
1403 });
1404 return;
1405 }
1406
1407 // Re-enable chat input when stream ends with content
1408 enableChatInput(botId);
1409
1410 flushAppendHtml();
1411
1412 // Scroll the user's last message to the top now that the
1413 // bot's full reply has rendered (gives max reading room).
1414 var $chatBoxDone = getElement(botId, 'chat-box');
1415 var $lastUserMsgDone = $chatBoxDone.find('.user-message').last();
1416 if ($lastUserMsgDone.length) {
1417 scrollElementToTop($lastUserMsgDone, botId);
1418 }
1419
1420 if (callback) {
1421 callback(accumulatedContent);
1422 }
1423 return;
1424 }
1425
1426 buffer += decoder.decode(value, { stream: true });
1427 const lines = buffer.split('\n');
1428 buffer = lines.pop() || '';
1429
1430 for (const line of lines) {
1431 if (line.startsWith('data: ')) {
1432 const data = line.substring(6);
1433
1434 if (data === '[DONE]') {
1435 if (!accumulatedContent) {
1436 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1437 callMxChat(message, callback, botId);
1438 return;
1439 }
1440
1441 // Re-enable chat input after streaming completes
1442 enableChatInput(botId);
1443
1444 flushAppendHtml();
1445
1446 // Scroll the user's last message to the top now
1447 // that the bot's full reply has rendered.
1448 var $chatBoxStreamDone = getElement(botId, 'chat-box');
1449 var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last();
1450 if ($lastUserMsgStreamDone.length) {
1451 scrollElementToTop($lastUserMsgStreamDone, botId);
1452 }
1453
1454 if (callback) {
1455 callback(accumulatedContent);
1456 }
1457 return;
1458 }
1459
1460 try {
1461 const json = JSON.parse(data);
1462
1463 // IMMEDIATE CHAT MODE UPDATE FOR STREAMING
1464 if (json.chat_mode) {
1465 updateChatModeIndicator(json.chat_mode, botId);
1466 }
1467
1468 // Handle testing data
1469 if (json.testing_data && !testingDataReceived) {
1470 if (window.mxchatTestPanelInstance) {
1471 window.mxchatTestPanelInstance.handleTestingData(json.testing_data);
1472 testingDataReceived = true;
1473 }
1474 }
1475 // Handle content streaming
1476 else if (json.content) {
1477 streamingStarted = true;
1478 accumulatedContent += json.content;
1479 updateStreamingMessage(accumulatedContent, botId);
1480 }
1481 // Stash appendix html (e.g. video embed) for [DONE]
1482 else if (json.append_html) {
1483 pendingAppendHtml = json.append_html;
1484 }
1485 // Server-side final pass changed the assembled text
1486 // (ffef6f: dead-link stripping) — swap the rendered
1487 // bubble for the validated version. Arrives at most
1488 // once, just before [DONE].
1489 else if (json.replace_content) {
1490 streamingStarted = true;
1491 accumulatedContent = json.replace_content;
1492 updateStreamingMessage(accumulatedContent, botId);
1493 }
1494 // Handle complete response in stream (fallback response)
1495 else if (json.text || json.message || json.html) {
1496 handleNonStreamResponse(json, callback, botId);
1497 return;
1498 }
1499 // Handle errors
1500 else if (json.error) {
1501
1502 // Get error message from various possible fields
1503 let errorMessage = json.error_message || json.message || json.text ||
1504 (typeof json.error === 'string' ? json.error : 'An error occurred. Please try again.');
1505
1506 // Re-enable chat input on error
1507 enableChatInput(botId);
1508
1509 // Display the error directly in the chat
1510 replaceLastMessage("bot", errorMessage, '', [], botId);
1511
1512 if (callback) {
1513 callback(errorMessage);
1514 }
1515 return;
1516 }
1517 } catch (e) {
1518 // SSE data parsing error - silently continue
1519 }
1520 }
1521 }
1522
1523 processStream();
1524 }).catch(streamError => {
1525 if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1526 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1527 callMxChat(message, callback, botId);
1528 });
1529 }
1530
1531 processStream();
1532 })
1533 .catch(error => {
1534 if (mxchatHandleStreamAbort(botId, accumulatedContent, callback)) return;
1535 // Check if we have server error data with chat mode
1536 if (error && error.isServerError && error.data) {
1537 // Check for chat mode in error data
1538 if (error.data.chat_mode) {
1539 updateChatModeIndicator(error.data.chat_mode, botId);
1540 }
1541
1542 handleNonStreamResponse(error.data, callback, botId);
1543 } else {
1544 // Only fall back to regular call if we don't have any response data
1545 getElement(botId, 'chat-box').find('.bot-message.temporary-message').remove();
1546 callMxChat(message, callback, botId);
1547 }
1548 });
1549 }); // refreshNonceIfNeeded
1550 }
1551
1552 // Helper function to handle non-streaming responses
1553 function handleNonStreamResponse(data, callback, botId) {
1554 botId = botId || 'default';
1555
1556 // IMMEDIATE CHAT MODE UPDATE FOR NON-STREAMING RESPONSES
1557 if (data.chat_mode) {
1558 updateChatModeIndicator(data.chat_mode, botId);
1559 }
1560
1561 // Also check in data property if response is wrapped
1562 if (data.data && data.data.chat_mode) {
1563 updateChatModeIndicator(data.data.chat_mode, botId);
1564 }
1565
1566 // NOTE: Don't remove temporary message here - let replaceLastMessage handle it
1567 // This prevents a visual gap between thinking dots disappearing and content appearing
1568
1569 // SECURITY FIX: Check for errors FIRST
1570 if (data.success === false || (data.data && data.data.error_message)) {
1571 let errorMessage = "";
1572 let errorCode = "";
1573
1574 // Check various possible error locations
1575 if (data.data && data.data.error_message) {
1576 errorMessage = data.data.error_message;
1577 errorCode = data.data.error_code || "";
1578 } else if (data.error_message) {
1579 errorMessage = data.error_message;
1580 errorCode = data.error_code || "";
1581 } else if (data.message) {
1582 errorMessage = data.message;
1583 } else if (typeof data.data === 'string') {
1584 errorMessage = data.data;
1585 } else {
1586 errorMessage = "An error occurred. Please try again or contact support.";
1587 }
1588
1589 // Handle session reset action (IP changed, session expired, etc.)
1590 // Silent reset — keep chat UI intact, just get a new session and retry
1591 if (data.data && data.data.action === 'reset_session') {
1592 MxChatInstances.silentResetSession(botId);
1593 // Re-send the original message with the new session (user message is already displayed)
1594 var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message');
1595 if (originalMessage) {
1596 getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null);
1597 var currentModel = mxchatChat.model || 'gpt-5.6-sol';
1598 if (shouldUseStreaming(currentModel)) {
1599 callMxChatStream(originalMessage, callback, botId);
1600 } else {
1601 callMxChat(originalMessage, callback, botId);
1602 }
1603 }
1604 return;
1605 }
1606
1607 // Format user-friendly error message
1608 let displayMessage = errorMessage;
1609 if (mxchatChat.is_admin) {
1610 displayMessage = errorMessage + (errorCode ? " (Error code: " + errorCode + ")" : "");
1611 }
1612
1613 replaceLastMessage("bot", displayMessage, '', [], botId);
1614
1615 if (callback) {
1616 callback('');
1617 }
1618 return; // Exit early for errors
1619 }
1620
1621 // Check for live agent response
1622 if (data.success && data.data && data.data.status === 'waiting_for_agent') {
1623 removeThinkingDots(botId);
1624 // Also remove any leftover bot-message that lost its temporary-message class
1625 var $chatBox = getElement(botId, 'chat-box');
1626 $chatBox.find('.bot-message .thinking-dots').closest('.bot-message').remove();
1627 updateChatModeIndicator('agent', botId);
1628 enableChatInput(botId);
1629 if (callback) {
1630 callback('');
1631 }
1632 return;
1633 }
1634
1635 // Handle different response formats
1636 if (data.text || data.html || data.message) {
1637
1638 // Apply response hooks
1639 if (data.text && typeof customMxChatFilter === 'function') {
1640 data.text = customMxChatFilter(data.text, "response");
1641 }
1642 if (data.message && typeof customMxChatFilter === 'function') {
1643 data.message = customMxChatFilter(data.message, "response");
1644 }
1645
1646 // Display the response
1647 if (data.text && data.html) {
1648 replaceLastMessage("bot", data.text, data.html, [], botId);
1649 } else if (data.text) {
1650 replaceLastMessage("bot", data.text, '', [], botId);
1651 } else if (data.html) {
1652 replaceLastMessage("bot", "", data.html, [], botId);
1653 } else if (data.message) {
1654 replaceLastMessage("bot", data.message, '', [], botId);
1655 }
1656 }
1657
1658 // Handle other response properties
1659 if (data.data && data.data.filename) {
1660 showActivePdf(data.data.filename, botId);
1661 var instance = MxChatInstances.get(botId);
1662 instance.activePdfFile = data.data.filename;
1663 }
1664
1665 if (data.redirect_url) {
1666 setTimeout(() => {
1667 window.location.href = data.redirect_url;
1668 }, 1500);
1669 }
1670
1671 // Ensure chat input is re-enabled (safety net for edge cases)
1672 enableChatInput(botId);
1673
1674 if (callback) {
1675 callback(data.text || data.message || '');
1676 }
1677 }
1678
1679 // Enhanced updateChatModeIndicator function for immediate DOM updates
1680 function updateChatModeIndicator(mode, botId) {
1681 botId = botId || 'default';
1682 const indicator = getElementDOM(botId, 'chat-mode-indicator');
1683 if (indicator) {
1684 const oldText = indicator.textContent;
1685
1686 if (mode === 'agent') {
1687 indicator.textContent = 'Live Agent';
1688 startPolling(botId);
1689 } else {
1690 // Everything else is AI mode
1691 const customAiText = indicator.getAttribute('data-ai-text') || 'AI Agent';
1692 indicator.textContent = customAiText;
1693 stopPolling(botId);
1694 }
1695
1696 // Force immediate DOM update and reflow
1697 if (oldText !== indicator.textContent) {
1698 // Force a reflow to ensure the change is visible immediately
1699 indicator.style.display = 'none';
1700 indicator.offsetHeight; // Trigger reflow
1701 indicator.style.display = '';
1702
1703 // Double-check after a brief moment to ensure the change stuck
1704 setTimeout(() => {
1705 if (mode === 'agent' && indicator.textContent !== 'Live Agent') {
1706 indicator.textContent = 'Live Agent';
1707 } else if (mode !== 'agent' && indicator.textContent === 'Live Agent') {
1708 const customAiText = indicator.getAttribute('data-ai-text') || 'AI Agent';
1709 indicator.textContent = customAiText;
1710 }
1711 }, 50);
1712 }
1713 }
1714 }
1715
1716 // Function to update message during streaming
1717 function updateStreamingMessage(content, botId) {
1718 botId = botId || 'default';
1719
1720 // ADD RESPONSE HOOK FOR REAL-TIME STREAMING
1721 if (typeof customMxChatFilter === 'function') {
1722 content = customMxChatFilter(content, "response");
1723 }
1724
1725 const formattedContent = linkify(content);
1726
1727 // Find the temporary message in this bot's chat box
1728 var $chatBox = getElement(botId, 'chat-box');
1729 const tempMessage = $chatBox.find('.bot-message.temporary-message').last();
1730
1731 if (tempMessage.length) {
1732 // aria-busy=true for the whole stream: the bubble is rewritten on
1733 // every chunk, and without busy a polite live region announces those
1734 // rewrites continuously. Flipped false once the reply is final, so
1735 // assistive tech announces the completed message ONCE (plan 67f126).
1736 if (tempMessage.attr('aria-busy') !== 'true') {
1737 tempMessage.attr('aria-busy', 'true');
1738 }
1739 // Update existing message
1740 tempMessage.html(formattedContent);
1741 } else {
1742 // Create new temporary message if it doesn't exist
1743 appendMessage("bot", content, '', [], true, botId);
1744 }
1745 }
1746
1747 function isStreamingSupported(model) {
1748 if (!model) return false;
1749
1750 const modelPrefix = model.split('-')[0].toLowerCase();
1751
1752 // Support streaming for OpenAI, Claude, Grok, DeepSeek, and OpenRouter models
1753 const isSupported = modelPrefix === 'gpt' ||
1754 modelPrefix === 'o1' ||
1755 modelPrefix === 'claude' ||
1756 modelPrefix === 'grok' ||
1757 modelPrefix === 'deepseek' ||
1758 model === 'openrouter'; // Add this line - check full model name for OpenRouter
1759
1760 return isSupported;
1761 }
1762
1763 // Update the event handlers to use the correct function names (using event delegation)
1764 // Use class-based selectors for multi-instance support
1765 $(document).on('click', '.send-button', function() {
1766 var botId = getBotIdFromElement(this);
1767 // While a response is streaming the button is a Stop control.
1768 if (this.classList.contains('mxchat-stop-mode')) {
1769 // Same click that just started this stream (an add-on's direct handler
1770 // ran before this delegated one) — not a Stop press. See
1771 // mxchatShowStopButton for the full story (plan-4bba64).
1772 if (this.__mxchatStopJustShown) return;
1773 mxchatStopStreaming(botId);
1774 return;
1775 }
1776 // sendMessage() owns the disable - see the Enter handler below (plan b67fd5).
1777 sendMessage(botId);
1778 });
1779
1780 // Enter sends, Shift+Enter inserts a newline (using event delegation).
1781 // sendMessage() owns the disable: it greys the box only when a real message is
1782 // about to go out. Disabling here first left the widget locked for good after
1783 // Enter on an empty box - the send returned early and no reply path ever
1784 // re-enabled it (plan b67fd5).
1785 $(document).on('keypress', '.chat-input', function(e) {
1786 if (e.which == 13 && !e.shiftKey) {
1787 e.preventDefault();
1788 sendMessage(getBotIdFromElement(this));
1789 }
1790 });
1791
1792 // Chat input character counter + soft limit feedback (plan 7091a2).
1793 // Language-neutral: numbers + color only, no translatable strings. The counter
1794 // reveals near the cap and ramps neutral -> amber -> red; an over-limit keystroke
1795 // or trimmed paste produces a brief border-flash/shake so the maxlength cap (plan
1796 // a3fae2) is never a silent "input jumps back". Per-bot scoped via .input-container.
1797 function mxchatUpdateCharCounter(inputEl) {
1798 if (!inputEl || !inputEl.closest) return;
1799 var max = parseInt(inputEl.getAttribute('maxlength'), 10);
1800 var container = inputEl.closest('.input-container');
1801 if (!container || !max || max <= 0) return;
1802 var counter = container.querySelector('.mxchat-char-counter');
1803 if (!counter) return;
1804 var len = inputEl.value.length;
1805 var ratio = len / max;
1806 var nearThreshold = 0.8; // start surfacing the counter at 80% of the cap
1807 var cur = counter.querySelector('.mxchat-char-counter-current');
1808 if (cur) cur.textContent = len;
1809 var warn = ratio >= nearThreshold && len < max;
1810 var full = len >= max;
1811 counter.classList.toggle('is-visible', ratio >= nearThreshold);
1812 counter.classList.toggle('is-warn', warn);
1813 counter.classList.toggle('is-full', full);
1814 container.classList.toggle('mxchat-input-near-limit', warn);
1815 container.classList.toggle('mxchat-input-at-limit', full);
1816 }
1817
1818 function mxchatBumpInput(inputEl) {
1819 var container = inputEl && inputEl.closest ? inputEl.closest('.input-container') : null;
1820 if (!container) return;
1821 container.classList.remove('mxchat-input-bump');
1822 void container.offsetWidth; // reflow so a rapid second hit retriggers the animation
1823 container.classList.add('mxchat-input-bump');
1824 clearTimeout($(container).data('mxchatBumpTimeout'));
1825 var t = setTimeout(function() { container.classList.remove('mxchat-input-bump'); }, 220);
1826 $(container).data('mxchatBumpTimeout', t);
1827 }
1828
1829 // Live counter update on every input.
1830 $(document).on('input', '.chat-input', function() {
1831 mxchatUpdateCharCounter(this);
1832 });
1833
1834 // Visible "you've hit the edge" feedback when a printable keystroke is about to be
1835 // rejected at the cap (maxlength silently swallows it otherwise).
1836 $(document).on('keydown', '.chat-input', function(e) {
1837 var max = parseInt(this.getAttribute('maxlength'), 10);
1838 if (!max || max <= 0 || this.value.length < max) return;
1839 if (e.ctrlKey || e.metaKey || e.altKey) return;
1840 // A single printable char with no selection to overwrite WILL be rejected.
1841 if (e.key && e.key.length === 1 && this.selectionStart === this.selectionEnd) {
1842 mxchatBumpInput(this);
1843 }
1844 });
1845
1846 // A paste that gets trimmed to the cap also bumps, so truncation is never silent.
1847 $(document).on('paste', '.chat-input', function() {
1848 var el = this;
1849 var max = parseInt(el.getAttribute('maxlength'), 10);
1850 if (!max || max <= 0) return;
1851 setTimeout(function() {
1852 mxchatUpdateCharCounter(el);
1853 if (el.value.length >= max) mxchatBumpInput(el);
1854 }, 0);
1855 });
1856
1857 // Builds the list of overflow-menu items for a given bot.
1858 // Adding a future item is one push to this array — do NOT hardcode "only download."
1859 function mxchatGetHeaderMenuItems(botId) {
1860 var items = [];
1861 var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1862
1863 // The `print_button_*` keys still gate this item for back-compat with
1864 // existing user options. The action is now a transcript download, not print.
1865 if (settings.print_button_enabled === 'on') {
1866 items.push({
1867 id: 'download-transcript',
1868 label: settings.print_button_label || 'Download Transcript',
1869 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>',
1870 action: function() {
1871 mxchatDownloadTranscript(botId);
1872 }
1873 });
1874 }
1875
1876 // "Start new chat" — surfaces the EXISTING per-conversation reset
1877 // (MxChatInstances.resetChatSession) so a visitor can start a fresh thread
1878 // without the site owner disabling chat persistence globally. Default OFF;
1879 // gated by the reset_chat_enabled option. plan ac2e81.
1880 if (settings.reset_chat_enabled === 'on') {
1881 items.push({
1882 id: 'reset-chat',
1883 label: settings.reset_chat_label || 'Start new chat',
1884 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>',
1885 action: function() {
1886 var confirmMsg = settings.reset_chat_confirm || 'Start a new chat? This clears the current conversation.';
1887 if (window.confirm(confirmMsg)) {
1888 MxChatInstances.resetChatSession(botId);
1889 }
1890 }
1891 });
1892 }
1893
1894 return items;
1895 }
1896
1897 // Builds a clean markdown transcript of the current conversation and triggers
1898 // a file download. Used by the "Download Transcript" menu item.
1899 function mxchatDownloadTranscript(botId) {
1900 var $chatBox = getElement(botId, 'chat-box');
1901 if (!$chatBox || !$chatBox.length) return;
1902
1903 var settings = (typeof mxchatChat !== 'undefined') ? mxchatChat : {};
1904 var headerTitle = settings.print_header_title || 'Chat transcript';
1905 var now = new Date();
1906 var stamp = now.toLocaleString();
1907
1908 var lines = [];
1909 lines.push('# ' + headerTitle);
1910 lines.push('');
1911 lines.push('Exported: ' + stamp);
1912 lines.push('');
1913 lines.push('---');
1914 lines.push('');
1915
1916 $chatBox.find('.user-message, .bot-message, .agent-message').each(function() {
1917 var $msg = $(this);
1918 // Skip thinking placeholders and any in-flight temporary messages.
1919 if ($msg.find('.thinking-dots').length) return;
1920 if ($msg.hasClass('temporary-message')) return;
1921
1922 var sender;
1923 if ($msg.hasClass('user-message')) sender = 'User';
1924 else if ($msg.hasClass('agent-message')) sender = 'Live Agent';
1925 else sender = 'AI Agent';
1926
1927 // Strip interactive UI from the cloned message so we get the conversation text.
1928 var $clone = $msg.clone();
1929 $clone.find('.copy-button, .message-toolbar, .mxchat-copy, button, script, style').remove();
1930 var text = $clone.text().replace(/ /g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
1931 if (!text) return;
1932
1933 lines.push('**' + sender + '**');
1934 lines.push('');
1935 lines.push(text);
1936 lines.push('');
1937 });
1938
1939 var content = lines.join('\n');
1940 var iso = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
1941 var fname = 'mxchat-transcript-' + iso + '.md';
1942 var blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
1943 var url = URL.createObjectURL(blob);
1944 var a = document.createElement('a');
1945 a.href = url;
1946 a.download = fname;
1947 a.style.display = 'none';
1948 document.body.appendChild(a);
1949 a.click();
1950 setTimeout(function() {
1951 if (a.parentNode) a.parentNode.removeChild(a);
1952 URL.revokeObjectURL(url);
1953 }, 100);
1954 }
1955
1956 // Reads the bot bubble's actual computed bg+fg and writes them as CSS vars
1957 // on the menu wrap, so the dropdown matches whatever paints the bubble —
1958 // saved options, AI theme CSS, or the mxchat-theme add-on.
1959 function mxchatSyncMenuColors(botId, $wrap) {
1960 if (!$wrap || !$wrap.length) return;
1961 var $bot = $wrap.closest('.mxchat-chatbot-wrapper').find('.bot-message').not('.temporary-message').first();
1962 if (!$bot.length) return;
1963 var cs = window.getComputedStyle($bot[0]);
1964 if (cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent') {
1965 $wrap[0].style.setProperty('--mxchat-menu-bg', cs.backgroundColor);
1966 }
1967 // Bot text color usually lives on a child div, not .bot-message itself.
1968 var $textChild = $bot.find('[style*="color"]').first();
1969 var fg = ($textChild.length ? window.getComputedStyle($textChild[0]).color : cs.color);
1970 if (fg) $wrap[0].style.setProperty('--mxchat-menu-fg', fg);
1971 }
1972
1973 // Renders (or re-renders) the item list for one menu wrap. Split out of
1974 // mxchatInitHeaderMenu so the dynamic-settings merge (plan-32db95) can
1975 // rebuild items + trigger visibility WITHOUT re-binding the one-time
1976 // open/close/keyboard wiring. closeMenu is passed in by the init closure;
1977 // a rebuild before init (never happens, but harmless) just skips it.
1978 function mxchatRenderHeaderMenuItems(botId, $wrap, closeMenuFn) {
1979 var $trigger = $wrap.find('.mxchat-menu-trigger');
1980 var $menu = $wrap.find('.mxchat-header-menu');
1981 var items = mxchatGetHeaderMenuItems(botId);
1982
1983 $menu.empty();
1984
1985 if (!items.length) {
1986 $trigger.hide();
1987 $menu.hide();
1988 return;
1989 }
1990
1991 // Clear any inline display:none a previous zero-item render left behind —
1992 // open/close visibility is governed by the hidden prop + is-open class.
1993 $trigger.css('display', '');
1994 $menu.css('display', '');
1995
1996 items.forEach(function(item, idx) {
1997 var $btn = $('<button>', {
1998 type: 'button',
1999 'class': 'mxchat-menu-item',
2000 'role': 'menuitem',
2001 'tabindex': '-1',
2002 'data-menu-id': item.id,
2003 html: '<span class="mxchat-menu-item-icon">' + item.icon + '</span>' +
2004 '<span class="mxchat-menu-item-label"></span>'
2005 });
2006 $btn.find('.mxchat-menu-item-label').text(item.label);
2007 $btn.on('click', function(e) {
2008 e.preventDefault();
2009 e.stopPropagation();
2010 if (closeMenuFn) closeMenuFn();
2011 try { item.action(); } catch (err) { /* no-op */ }
2012 });
2013 $menu.append($btn);
2014 });
2015 }
2016
2017 // Re-render every menu on the page after a dynamic-settings merge
2018 // (multi-bot: each wrap re-reads its items). An OPEN menu is left alone —
2019 // swapping items under the user mid-interaction yanks focus — and the
2020 // rebuild runs when it closes instead (closeMenu checks the pending flag).
2021 function mxchatRebuildHeaderMenus() {
2022 $('.mxchat-header-menu-wrap').each(function() {
2023 var $wrap = $(this);
2024 var botId = $wrap.data('bot-id');
2025 if (!botId) return;
2026 if (!$wrap.data('mxchatMenuReady')) {
2027 mxchatInitHeaderMenu(botId);
2028 return;
2029 }
2030 if ($wrap.find('.mxchat-header-menu').hasClass('is-open')) {
2031 $wrap.data('mxchatMenuRebuildPending', true);
2032 return;
2033 }
2034 mxchatRenderHeaderMenuItems(botId, $wrap, $wrap.data('mxchatMenuClose'));
2035 });
2036 }
2037
2038 // One-time per-widget init: renders menu items, wires open/close,
2039 // outside-click, Escape, and arrow-key navigation. If no items, hides the
2040 // trigger. Wiring happens even when there are zero items at init, so a
2041 // later dynamic-settings rebuild that adds items has a working trigger.
2042 function mxchatInitHeaderMenu(botId) {
2043 var $wrap = $('.mxchat-header-menu-wrap[data-bot-id="' + botId + '"]').first();
2044 if (!$wrap.length || $wrap.data('mxchatMenuReady')) return;
2045
2046 var $trigger = $wrap.find('.mxchat-menu-trigger');
2047 var $menu = $wrap.find('.mxchat-header-menu');
2048
2049 // Initial color sync — covers normal page load.
2050 mxchatSyncMenuColors(botId, $wrap);
2051
2052 function openMenu() {
2053 // Re-sync each open in case the active theme changed since init.
2054 mxchatSyncMenuColors(botId, $wrap);
2055 $menu.prop('hidden', false).attr('aria-hidden', 'false').addClass('is-open');
2056 $trigger.attr('aria-expanded', 'true');
2057 // Focus the first item for keyboard users
2058 setTimeout(function() {
2059 $menu.find('.mxchat-menu-item').first().attr('tabindex', '0').trigger('focus');
2060 }, 0);
2061 }
2062 function closeMenu(returnFocus) {
2063 $menu.prop('hidden', true).attr('aria-hidden', 'true').removeClass('is-open');
2064 $trigger.attr('aria-expanded', 'false');
2065 $menu.find('.mxchat-menu-item').attr('tabindex', '-1');
2066 if (returnFocus) $trigger.trigger('focus');
2067 // A dynamic-settings rebuild that arrived while the menu was open
2068 // was deferred (mxchatRebuildHeaderMenus) — run it now.
2069 if ($wrap.data('mxchatMenuRebuildPending')) {
2070 $wrap.removeData('mxchatMenuRebuildPending');
2071 mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
2072 }
2073 }
2074
2075 // Toggle on trigger click — stop propagation so the .chatbot-top-bar
2076 // click-to-collapse handler does not fire.
2077 $trigger.on('click', function(e) {
2078 e.preventDefault();
2079 e.stopPropagation();
2080 if ($menu.hasClass('is-open')) closeMenu();
2081 else openMenu();
2082 });
2083
2084 // Don't let clicks inside the menu bubble to the top-bar collapse handler.
2085 $menu.on('click', function(e) {
2086 e.stopPropagation();
2087 });
2088
2089 // Outside click closes the menu.
2090 $(document).on('click.mxchatMenu-' + botId, function(e) {
2091 if (!$menu.hasClass('is-open')) return;
2092 if ($wrap.has(e.target).length || $wrap.is(e.target)) return;
2093 closeMenu();
2094 });
2095
2096 // Keyboard: Escape closes and returns focus; arrow keys move focus; Enter activates.
2097 $menu.on('keydown', '.mxchat-menu-item', function(e) {
2098 var $items = $menu.find('.mxchat-menu-item');
2099 var idx = $items.index(this);
2100 if (e.key === 'Escape') {
2101 e.preventDefault();
2102 closeMenu(true);
2103 } else if (e.key === 'ArrowDown') {
2104 e.preventDefault();
2105 var $next = $items.eq((idx + 1) % $items.length);
2106 $items.attr('tabindex', '-1');
2107 $next.attr('tabindex', '0').trigger('focus');
2108 } else if (e.key === 'ArrowUp') {
2109 e.preventDefault();
2110 var $prev = $items.eq((idx - 1 + $items.length) % $items.length);
2111 $items.attr('tabindex', '-1');
2112 $prev.attr('tabindex', '0').trigger('focus');
2113 } else if (e.key === 'Enter' || e.key === ' ') {
2114 e.preventDefault();
2115 $(this).trigger('click');
2116 }
2117 });
2118 $trigger.on('keydown', function(e) {
2119 if (e.key === 'Escape' && $menu.hasClass('is-open')) {
2120 e.preventDefault();
2121 closeMenu(true);
2122 } else if ((e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') && !$menu.hasClass('is-open')) {
2123 e.preventDefault();
2124 openMenu();
2125 }
2126 });
2127
2128 // Expose closeMenu for out-of-closure re-renders (mxchatRebuildHeaderMenus),
2129 // then do the initial item render.
2130 $wrap.data('mxchatMenuClose', closeMenu);
2131 mxchatRenderHeaderMenuItems(botId, $wrap, closeMenu);
2132
2133 $wrap.data('mxchatMenuReady', true);
2134 }
2135
2136 // Initialize header menus for every rendered widget on DOM ready.
2137 $(function() {
2138 $('.mxchat-header-menu-wrap').each(function() {
2139 var botId = $(this).data('bot-id');
2140 if (botId) mxchatInitHeaderMenu(botId);
2141 });
2142
2143 // Embedded (non-floating) widgets are open from the moment the page
2144 // renders — refresh dynamic settings at init (plan-32db95). Floating
2145 // widgets refresh on first launcher open instead.
2146 var hasEmbeddedWidget = $('.mxchat-chatbot-wrapper').filter(function() {
2147 return !$(this).closest('.floating-chatbot').length;
2148 }).length > 0;
2149 if (hasEmbeddedWidget) {
2150 mxchatRefreshDynamicSettings();
2151 }
2152 });
2153
2154 function appendMessage(sender, messageText = '', messageHtml = '', images = [], isTemporary = false, botId = 'default') {
2155 try {
2156 // Determine styles based on sender type
2157 let messageClass, bgColor, fontColor;
2158
2159 if (sender === "user") {
2160 messageClass = "user-message";
2161 bgColor = userMessageBgColor;
2162 fontColor = userMessageFontColor;
2163 // Only sanitize user input
2164 messageText = sanitizeUserInput(messageText);
2165 } else if (sender === "agent") {
2166 messageClass = "agent-message";
2167 bgColor = liveAgentMessageBgColor;
2168 fontColor = liveAgentMessageFontColor;
2169 } else {
2170 messageClass = "bot-message";
2171 bgColor = botMessageBgColor;
2172 fontColor = botMessageFontColor;
2173 }
2174
2175 const messageDiv = $('<div>')
2176 .addClass(messageClass)
2177 .attr('dir', 'auto');
2178
2179 // Only apply inline colors if AI theme is not active (let CSS handle it)
2180 var skipColors = shouldSkipInlineColors(botId);
2181 if (skipColors) {
2182 messageDiv.css({
2183 'margin-bottom': '1em'
2184 });
2185 } else {
2186 messageDiv.css({
2187 'background': bgColor,
2188 'color': fontColor,
2189 'margin-bottom': '1em'
2190 });
2191 }
2192
2193 // Process the message content - always run linkify to convert markdown
2194 // links and format text. linkify() handles existing HTML safely via
2195 // negative lookaheads that skip URLs already inside <a> tags.
2196 let fullMessage = linkify(messageText);
2197
2198 // Add images if provided
2199 if (images && images.length > 0) {
2200 fullMessage += '<div class="image-gallery" dir="auto">';
2201 images.forEach(img => {
2202 const safeTitle = sanitizeUserInput(img.title);
2203 const safeUrl = encodeURI(img.image_url);
2204 const safeThumbnail = encodeURI(img.thumbnail_url);
2205
2206 fullMessage += `
2207 <div style="margin-bottom: 10px;">
2208 <strong>${safeTitle}</strong><br>
2209 <a href="${safeUrl}" target="_blank">
2210 <img src="${safeThumbnail}" alt="${safeTitle}" style="max-width: 100px; height: auto; margin: 5px;" />
2211 </a>
2212 </div>`;
2213 });
2214 fullMessage += '</div>';
2215 }
2216
2217 // Append HTML content if provided
2218 if (messageHtml && sender !== "user") {
2219 // Only add line breaks if there's actual text content before the HTML
2220 if (fullMessage && fullMessage.trim()) {
2221 fullMessage += '<br><br>' + messageHtml;
2222 } else {
2223 fullMessage = messageHtml;
2224 }
2225 }
2226
2227 messageDiv.html(fullMessage);
2228
2229 if (isTemporary) {
2230 // In-flight bubble: hold aria-busy so the live region stays quiet
2231 // until the content is finalized (plan 67f126).
2232 messageDiv.addClass('temporary-message').attr('aria-busy', 'true');
2233 }
2234
2235 // Append to the correct chatbot instance's chat-box
2236 var $chatBox = getElement(botId, 'chat-box');
2237 messageDiv.hide().appendTo($chatBox).fadeIn(300, function() {
2238 // FIXED: Use event delegation for link tracking
2239 if (sender === "bot" || sender === "agent") {
2240 attachLinkTracking(messageDiv, messageText, botId);
2241 }
2242
2243 if (sender === "bot") {
2244 const lastUserMessage = $chatBox.find('.user-message').last();
2245 if (lastUserMessage.length) {
2246 scrollElementToTop(lastUserMessage, botId);
2247 }
2248 }
2249
2250 if ((sender === "bot" || sender === "agent") && !isTemporary) {
2251 if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2252 }
2253 });
2254
2255 if (messageText.id) {
2256 var instance = MxChatInstances.get(botId);
2257 instance.lastSeenMessageId = messageText.id;
2258 hideNotification(botId);
2259 }
2260 } catch (error) {
2261 // Error rendering message - silently continue
2262 }
2263 }
2264
2265 // Link clicks in bot and agent messages: the click is recorded, the browser
2266 // follows the anchor itself. Navigation must not wait for the tracking
2267 // request: a window.open() made from that request's callback is outside the
2268 // visitor's tap, and Safari's pop-up blocker drops it without a message.
2269
2270 // Give a tracked anchor the target it should open in, and noopener on a new tab.
2271 function prepareTrackedLink($link) {
2272 if (linkTarget === '_blank' && $link.attr('target') !== '_blank') {
2273 $link.attr('target', '_blank');
2274 }
2275 if ($link.attr('target') === '_blank') {
2276 var rel = $link.attr('rel') || '';
2277 if (!/(^|\s)noopener(\s|$)/.test(rel)) {
2278 $link.attr('rel', $.trim(rel + ' noopener'));
2279 }
2280 }
2281 }
2282
2283 // Record one click, fire-and-forget. sendBeacon survives the page unloading
2284 // when the link opens in the same tab.
2285 function trackLinkClick(href, messageContext, botId) {
2286 var fields = {
2287 action: 'mxchat_track_url_click',
2288 session_id: getChatSession(botId),
2289 url: href,
2290 message_context: messageContext,
2291 nonce: mxchatChat.nonce
2292 };
2293 try {
2294 if (navigator.sendBeacon && typeof FormData !== 'undefined') {
2295 var body = new FormData();
2296 $.each(fields, function(name, value) {
2297 body.append(name, value == null ? '' : value);
2298 });
2299 if (navigator.sendBeacon(mxchatChat.ajax_url, body)) {
2300 return;
2301 }
2302 }
2303 } catch (error) {
2304 // Fall through to the plain request
2305 }
2306 $.ajax({
2307 url: mxchatChat.ajax_url,
2308 type: 'POST',
2309 data: fields
2310 });
2311 }
2312
2313 // Helper function to attach link tracking with proper event handling
2314 function attachLinkTracking(messageDiv, messageText, botId) {
2315 botId = botId || 'default';
2316 // Use a slight delay to ensure DOM is ready
2317 setTimeout(function() {
2318 const links = messageDiv.find('a[href]').not('[data-tracked]');
2319
2320 links.each(function() {
2321 const $link = $(this);
2322 const originalHref = $link.attr('href');
2323
2324 // Mark as tracked to avoid duplicate handlers
2325 $link.attr('data-tracked', 'true');
2326
2327 // Only track external URLs
2328 if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
2329 // Remove any existing click handlers first
2330 $link.off('click.tracking');
2331
2332 // The anchor carries its own target: the browser follows it
2333 prepareTrackedLink($link);
2334
2335 // Add new click handler with namespace
2336 $link.on('click.tracking', function(e) {
2337 e.stopPropagation();
2338
2339 const messageContext = typeof messageText === 'string'
2340 ? messageText.substring(0, 200)
2341 : '';
2342
2343 prepareTrackedLink($link);
2344 trackLinkClick(originalHref, messageContext, botId);
2345 });
2346 }
2347 });
2348 }, 100); // Small delay to ensure DOM is ready
2349 }
2350
2351 function replaceLastMessage(sender, responseText, responseHtml = '', images = [], botId = 'default') {
2352 var messageClass = sender === "user" ? "user-message" : sender === "agent" ? "agent-message" : "bot-message";
2353 var $chatBox = getElement(botId, 'chat-box');
2354 var lastMessageDiv = $chatBox.find('.bot-message.temporary-message, .agent-message.temporary-message').last();
2355
2356 // Determine styles
2357 let bgColor, fontColor;
2358 if (sender === "user") {
2359 bgColor = userMessageBgColor;
2360 fontColor = userMessageFontColor;
2361 } else if (sender === "agent") {
2362 bgColor = liveAgentMessageBgColor;
2363 fontColor = liveAgentMessageFontColor;
2364 } else {
2365 bgColor = botMessageBgColor;
2366 fontColor = botMessageFontColor;
2367 }
2368
2369 // Always run linkify to convert markdown links and format text.
2370 // linkify() already handles existing HTML (its URL patterns use negative lookaheads
2371 // to avoid double-processing URLs that are already inside <a> tags).
2372 var fullMessage = linkify(responseText);
2373
2374 if (responseHtml) {
2375 // Only add line breaks if there's actual text content before the HTML
2376 if (fullMessage && fullMessage.trim()) {
2377 fullMessage += '<br><br>' + responseHtml;
2378 } else {
2379 fullMessage = responseHtml;
2380 }
2381 }
2382
2383 if (images.length > 0) {
2384 fullMessage += '<div class="image-gallery" dir="auto">';
2385 images.forEach(img => {
2386 const safeTitle = sanitizeUserInput(img.title);
2387 const safeUrl = escapeAttr(img.image_url);
2388 const safeThumbnail = escapeAttr(img.thumbnail_url);
2389 fullMessage += `
2390 <div style="margin-bottom: 10px;">
2391 <strong>${safeTitle}</strong><br>
2392 <a href="${safeUrl}" target="_blank">
2393 <img src="${safeThumbnail}" alt="${safeTitle}" style="max-width: 100px; height: auto; margin: 5px;" />
2394 </a>
2395 </div>`;
2396 });
2397 fullMessage += '</div>';
2398 }
2399
2400 if (lastMessageDiv.length) {
2401 // Replace content immediately to prevent visual gap between thinking dots and response
2402 // aria-busy released AFTER the final content is set, so the live region
2403 // announces the finished message once (plan 67f126).
2404 lastMessageDiv
2405 .html(fullMessage)
2406 .removeClass('bot-message user-message temporary-message')
2407 .addClass(messageClass)
2408 .attr('dir', 'auto')
2409 .attr('aria-busy', 'false');
2410
2411 // Only apply inline colors if AI theme is not active (let CSS handle it)
2412 var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId);
2413 if (!skipColors) {
2414 lastMessageDiv.css({
2415 'background-color': bgColor,
2416 'color': fontColor,
2417 });
2418 }
2419
2420 // Handle link tracking and scroll
2421 if (sender === "bot" || sender === "agent") {
2422 attachLinkTracking(lastMessageDiv, responseText, botId);
2423
2424 const lastUserMessage = $chatBox.find('.user-message').last();
2425 if (lastUserMessage.length) {
2426 scrollElementToTop(lastUserMessage, botId);
2427 }
2428 // Show notification if chat is hidden
2429 var $floatingChatbot = getElement(botId, 'floating-chatbot');
2430 if ($floatingChatbot.hasClass('hidden')) {
2431 showNotification(botId);
2432 }
2433 }
2434
2435 // Re-enable chat input after response is displayed
2436 enableChatInput(botId);
2437
2438 if (sender === "bot" || sender === "agent") {
2439 if (typeof mxchatInitHeaderMenu === 'function') mxchatInitHeaderMenu(botId);
2440 }
2441 } else {
2442 appendMessage(sender, responseText, responseHtml, images, false, botId);
2443 // Re-enable chat input after response is displayed
2444 enableChatInput(botId);
2445 }
2446 }
2447
2448
2449 function appendThinkingMessage(botId) {
2450 botId = botId || 'default';
2451
2452 // Don't show thinking dots in live agent mode - message is just forwarded to a human
2453 var indicator = getElementDOM(botId, 'chat-mode-indicator');
2454 if (indicator && indicator.textContent === 'Live Agent') {
2455 return;
2456 }
2457
2458 var $chatBox = getElement(botId, 'chat-box');
2459
2460 // Remove any existing thinking dots in this bot's chat first
2461 $chatBox.find('.thinking-dots').remove();
2462
2463 // Check if we should skip inline colors (AI theme is active)
2464 var skipColors = shouldSkipInlineColors(botId);
2465
2466 // Retrieve the bot message font color and background color
2467 var botMessageFontColor = mxchatChat.bot_message_font_color;
2468 var botMessageBgColor = mxchatChat.bot_message_bg_color;
2469
2470 // Build thinking dots HTML - skip inline colors if AI theme is active
2471 // The dots are decorative; the sr-only span is what the live region
2472 // announces for the waiting state (plan 67f126). Server-localized
2473 // string — safe to inject (esc_html__ output, no user content).
2474 var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"';
2475 var srThinking = mxchatChat.thinking_announcement || 'Assistant is typing';
2476 var thinkingHtml = '<span class="sr-only">' + srThinking + '</span>' +
2477 '<div class="thinking-dots-container" aria-hidden="true">' +
2478 '<div class="thinking-dots">' +
2479 '<span class="dot"' + dotStyle + '></span>' +
2480 '<span class="dot"' + dotStyle + '></span>' +
2481 '<span class="dot"' + dotStyle + '></span>' +
2482 '</div>' +
2483 '</div>';
2484
2485 // Append the thinking dots to this bot's chat container - skip inline colors if AI theme is active
2486 var messageStyle = skipColors ? '' : ' style="background-color: ' + botMessageBgColor + '; color: ' + botMessageFontColor + ';"';
2487 $chatBox.append('<div class="bot-message temporary-message"' + messageStyle + '>' + thinkingHtml + '</div>');
2488 scrollToBottom(botId);
2489 }
2490
2491 function removeThinkingDots(botId) {
2492 botId = botId || 'default';
2493 var $chatBox = getElement(botId, 'chat-box');
2494 // Remove by temporary-message class first, then fall back to any bot-message containing thinking dots
2495 $chatBox.find('.thinking-dots').closest('.temporary-message').remove();
2496 $chatBox.find('.bot-message .thinking-dots').closest('.bot-message').remove();
2497 }
2498
2499 // ====================================
2500 // TEXT FORMATTING & PROCESSING
2501 // ====================================
2502
2503 function linkify(inputText) {
2504 if (!inputText) {
2505 return '';
2506 }
2507
2508 // Helper function to check if URL is already encoded
2509 function isUrlEncoded(url) {
2510 // Check for % followed by exactly 2 hex digits
2511 return /%[0-9a-fA-F]{2}/.test(url);
2512 }
2513
2514 // Helper function to safely encode URLs only if needed, then make the
2515 // result attribute-safe. "Don't double-encode an already-encoded URL"
2516 // used to mean "return it untouched" — so a literal quote riding next
2517 // to a %20 reached href="…" intact and closed the attribute
2518 // (plan 2db182). Every href this function feeds now goes through
2519 // escapeAttr(), whether or not the URL was already percent-encoded.
2520 function safeEncodeUrl(url) {
2521 const encoded = isUrlEncoded(url) ? url : encodeURI(url);
2522 return escapeAttr(encoded);
2523 }
2524
2525 // Take backslash escapes and code (fenced + inline) out of the text FIRST
2526 // (plan 9dc34f). Styling used to run before formatCodeBlocks(), so a
2527 // backtick could not protect an underscore and \_ was not an escape at
2528 // all; the header rule also ran over fenced code. Each escape becomes a
2529 // placeholder carrying its literal character, each code span a
2530 // placeholder carrying its rendered HTML; both are restored below, after
2531 // the styling rules have run, so nothing downstream changes.
2532 const literals = protectLiterals(inputText);
2533 let processedText = literals.text;
2534
2535 // Process markdown headers
2536 processedText = formatMarkdownHeaders(processedText);
2537
2538 // Process text styling (bold, italic, strikethrough)
2539 processedText = formatTextStyling(processedText);
2540
2541 // Legacy raw <?php ... ?> blocks (fenced + inline code were already
2542 // rendered inside protectLiterals) — still BEFORE processing links
2543 processedText = formatCodeBlocks(processedText);
2544
2545 // Put the rendered code and the escaped characters back
2546 processedText = literals.restore(processedText);
2547
2548 // Process markdown tables BEFORE converting newlines to paragraphs
2549 processedText = formatMarkdownTables(processedText);
2550
2551 // NOW convert to paragraphs
2552 processedText = convertNewlinesToBreaks(processedText);
2553
2554 // IMPORTANT: Handle citation-style brackets FIRST [URL]
2555 // This prevents them from being processed as markdown links
2556 // Match [URL] where URL is a complete URL in square brackets (common in AI citations)
2557 processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => {
2558 // Clean the URL of any trailing punctuation
2559 let cleanUrl = url.replace(/[.,;!?]+$/, '');
2560 const safeUrl = safeEncodeUrl(cleanUrl);
2561 // The bracket body is the only link text that can carry angle
2562 // brackets (the standalone matcher excludes them) — keep it text.
2563 const safeText = cleanUrl.replace(/</g, '&lt;').replace(/>/g, '&gt;');
2564 // Return as a proper link without the brackets
2565 return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
2566 });
2567
2568 // Process markdown links: [text](url) and [](url)
2569 // Uses balanced parenthesis matching to handle URLs containing parens
2570 // (e.g. PDF filenames with dates like (2025-08-28).pdf)
2571 processedText = (function(input) {
2572 var result = '';
2573 var i = 0;
2574 while (i < input.length) {
2575 // Look for [ at current position
2576 if (input[i] === '[') {
2577 // Find closing ]
2578 var closeBracket = input.indexOf(']', i + 1);
2579 if (closeBracket === -1 || closeBracket + 1 >= input.length || input[closeBracket + 1] !== '(') {
2580 result += input[i];
2581 i++;
2582 continue;
2583 }
2584 var linkText = input.substring(i + 1, closeBracket);
2585 // Check if URL starts with http
2586 var urlStart = closeBracket + 2;
2587 if (!input.substring(urlStart).match(/^https?:\/\//)) {
2588 result += input[i];
2589 i++;
2590 continue;
2591 }
2592 // Find balanced closing paren
2593 var depth = 1;
2594 var j = urlStart;
2595 while (j < input.length && depth > 0) {
2596 if (input[j] === '(') depth++;
2597 else if (input[j] === ')') depth--;
2598 if (depth > 0) j++;
2599 }
2600 if (depth !== 0) {
2601 result += input[i];
2602 i++;
2603 continue;
2604 }
2605 var url = input.substring(urlStart, j);
2606 var cleanUrl = url.replace(/[\].,;!?]+$/, '');
2607 var encodedUrl = safeEncodeUrl(cleanUrl);
2608 if (!linkText || !linkText.trim()) {
2609 result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + cleanUrl + '</a>';
2610 } else {
2611 var safeText = sanitizeUserInput(linkText);
2612 result += '<a href="' + encodedUrl + '" target="' + linkTarget + '">' + safeText + '</a>';
2613 }
2614 i = j + 1; // Skip past the closing )
2615 } else {
2616 result += input[i];
2617 i++;
2618 }
2619 }
2620 return result;
2621 })(processedText);
2622
2623 // Process phone numbers: [text](tel:number)
2624 const phonePattern = /\[([^\]]+)\]\((tel:[\d+\-\s()]+)\)/g;
2625 processedText = processedText.replace(phonePattern, (match, text, phone) => {
2626 const safePhone = safeEncodeUrl(phone);
2627 const safeText = sanitizeUserInput(text);
2628 return `<a href="${safePhone}">${safeText}</a>`;
2629 });
2630
2631 // Process mailto links: [text](mailto:email)
2632 const mailtoPattern = /\[([^\]]+)\]\((mailto:[^\)]+)\)/g;
2633 processedText = processedText.replace(mailtoPattern, (match, text, mailto) => {
2634 const safeMailto = safeEncodeUrl(mailto);
2635 const safeText = sanitizeUserInput(text);
2636 return `<a href="${safeMailto}">${safeText}</a>`;
2637 });
2638
2639 // Process standalone URLs - but NOT if they're already in <a> tags or brackets
2640 // Updated pattern to be more careful about what it matches
2641 //
2642 // The prefix group (plan 78618d) is the start of a line, an ordinary
2643 // character, OR a tag this formatter itself opened or closed (paragraph,
2644 // list item, table cell, heading, emphasis, line break), so an address
2645 // that opens a paragraph or sits inside **bold** is linked too. Never
2646 // an anchor (its text would be linked twice; the lookahead guards that
2647 // as well) and never a code span, which stays as typed. No lookbehind,
2648 // for Safari.
2649 const urlPattern = /(^|[^">=\[\]]|<\/?(?:p|li|td|th|h[1-6]|strong|em|b|i|del|br)(?:\s[^<>]*)?\/?>)(https?:\/\/[^\s<"\[\]]+)(?![^<]*<\/a>)(?!\])/gim;
2650 processedText = processedText.replace(urlPattern, (match, prefix, url) => {
2651 // Extra check: make sure this isn't already linked
2652 if (match.includes('href=') || match.includes('</a>')) {
2653 return match;
2654 }
2655
2656 // Clean trailing punctuation
2657 let cleanUrl = url.replace(/[.,;!?)]+$/, '');
2658 const safeUrl = safeEncodeUrl(cleanUrl);
2659 return `${prefix}<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2660 });
2661
2662 // Process www. URLs - but NOT if they're already in <a> tags or brackets.
2663 // The address stops at the same characters the https pattern stops at
2664 // (plan 78618d): it used to run through the closing paragraph tag, so
2665 // "Try www.example.com/page." linked to www.example.com/page.%3C/p%3E.
2666 // Same prefix group as the https pattern, same reasons.
2667 const wwwPattern = /(^|[^">/\[\]]|<\/?(?:p|li|td|th|h[1-6]|strong|em|b|i|del|br)(?:\s[^<>]*)?\/?>)(www\.[^\s<"\[\]]+)(?![^<]*<\/a>)(?!\])/gim;
2668 processedText = processedText.replace(wwwPattern, (match, prefix, url) => {
2669 // Extra check: make sure this isn't already linked
2670 if (match.includes('href=') || match.includes('</a>')) {
2671 return match;
2672 }
2673
2674 // Clean trailing punctuation
2675 let cleanUrl = url.replace(/[.,;!?)]+$/, '');
2676 const safeUrl = safeEncodeUrl(`http://${cleanUrl}`);
2677 return `${prefix}<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2678 });
2679
2680 return processedText;
2681 }
2682
2683 // One left-to-right pass over backslash escapes and code (plan 9dc34f).
2684 // A single alternation keeps the two honest with each other: an escaped
2685 // backtick is consumed as an escape before it can open a code span, and a
2686 // code span is consumed whole so the escapes inside it stay verbatim.
2687 // Placeholders are private-use characters — no underscore, asterisk,
2688 // backtick, hash, pipe or bracket for a later rule to match.
2689 function protectLiterals(text) {
2690 const store = [];
2691 const out = String(text).replace(/\\([\\`*_~])|(```[\s\S]*?```)|(`[^`\n]+`)/g, function(match, escaped) {
2692 store.push(escaped !== undefined ? escaped : formatCodeBlocks(match));
2693 return '\uE000' + (store.length - 1) + '\uE001';
2694 });
2695 return {
2696 text: out,
2697 restore: function(t) {
2698 return String(t).replace(/\uE000(\d+)\uE001/g, function(m, i) {
2699 return store[Number(i)];
2700 });
2701 }
2702 };
2703 }
2704
2705 function formatMarkdownHeaders(text) {
2706 // Handle h1 to h6 headers
2707 return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) {
2708 const level = hashes.length;
2709 return `<h${level} class="chat-heading chat-heading-${level}">${content.trim()}</h${level}>`;
2710 });
2711 }
2712
2713 function formatTextStyling(text) {
2714 // IMPORTANT: Protect BOTH HTML href and Markdown URLs from formatting
2715 const protectedSegments = [];
2716 let protectedText = text;
2717
2718 // Step 1a: Protect HTML href="..." attributes
2719 protectedText = protectedText.replace(/href\s*=\s*["']([^"']+)["']/gi, function(match) {
2720 const placeholder = `__PROTECTED_${protectedSegments.length}__`;
2721 protectedSegments.push(match);
2722 return placeholder;
2723 });
2724
2725 // Step 1b: Protect Markdown links [text](url)
2726 // This is crucial - we need to protect the URLs in markdown format
2727 protectedText = protectedText.replace(/\[([^\]]*)\]\(([^)]+)\)/g, function(match) {
2728 const placeholder = `__PROTECTED_${protectedSegments.length}__`;
2729 protectedSegments.push(match);
2730 return placeholder;
2731 });
2732
2733 // Step 1c: Also protect bare URLs that might exist. Trailing emphasis
2734 // markers are not part of the address (plan 78618d): **https://x.com**
2735 // used to protect "https://x.com**", so the bold never paired and the
2736 // asterisks ended up inside the link. The markers stay outside the
2737 // placeholder so the styling rules below can pair them.
2738 protectedText = protectedText.replace(/(https?:\/\/[^\s<>"]+)/gi, function(match) {
2739 const markers = match.match(/[*_~]+$/);
2740 const url = markers ? match.slice(0, match.length - markers[0].length) : match;
2741 const placeholder = `__PROTECTED_${protectedSegments.length}__`;
2742 protectedSegments.push(url);
2743 return placeholder + (markers ? markers[0] : '');
2744 });
2745
2746 // Step 2: Now apply text styling to the protected text
2747 // Handle bold text (**text**)
2748 protectedText = protectedText.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
2749
2750 // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind)
2751 // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another *
2752 protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
2753
2754 // Handle underscores for italic - Safari-compatible (no lookbehind), and
2755 // word-bounded (plan 9dc34f): the opener must follow the start of the
2756 // text or a non-word character, the closer must precede the end or a
2757 // non-word character, and the content may not start or end with a
2758 // space. So my_var_name, MXCHAT_API_KEY and snake_case stay as typed
2759 // while "use _this_ word" still italicises. A token whose content is
2760 // entirely upper-case letters and digits (_VALUE_, _ID_) is a placeholder
2761 // convention, not prose emphasis — it is left verbatim too (ticket 440).
2762 // __PROTECTED_N__ placeholders cannot match: their underscores are all
2763 // adjacent to another underscore or a word character.
2764 protectedText = protectedText.replace(/(^|[^\p{L}\p{N}_])_((?!PROTECTED)[^\s_](?:[^_\n]*[^\s_])?)_(?![\p{L}\p{N}_])/gu, function(match, before, content) {
2765 if (/^[\p{Lu}\p{N}]+$/u.test(content)) {
2766 return match;
2767 }
2768 return before + '<em>' + content + '</em>';
2769 });
2770
2771 // Handle strikethrough (~~text~~)
2772 protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>');
2773
2774 // Step 3: Restore all protected segments
2775 protectedSegments.forEach((original, index) => {
2776 const placeholder = `__PROTECTED_${index}__`;
2777 protectedText = protectedText.replace(placeholder, original);
2778 });
2779
2780 return protectedText;
2781 }
2782 function formatBoldText(text) {
2783 // This function is kept for compatibility but now uses formatTextStyling
2784 return formatTextStyling(text);
2785 }
2786
2787 function convertNewlinesToBreaks(text) {
2788 // Split the text into paragraphs (marked by double newlines or multiple <br> tags)
2789 const paragraphs = text.split(/(?:\n\n|\<br\>\s*\<br\>)/g);
2790
2791 // Filter out empty paragraphs and wrap each paragraph in <p> tags
2792 return paragraphs
2793 .map(para => para.trim())
2794 .filter(para => para.length > 0) // Remove empty paragraphs
2795 .map(para => `<p>${para}</p>`)
2796 .join('');
2797 }
2798 function formatCodeBlocks(text) {
2799 // Handle fenced code blocks with language specification (```language)
2800 text = text.replace(/```(\w+)?\n?([\s\S]*?)```/g, (match, language, code) => {
2801 const lang = language || 'text';
2802 const escapedCode = escapeHtml(code.trim());
2803 return `<div class="mxchat-code-block-container">
2804 <div class="mxchat-code-header">
2805 <span class="mxchat-code-language">${lang}</span>
2806 <button class="mxchat-copy-button" aria-label="Copy to clipboard">Copy</button>
2807 </div>
2808 <pre class="mxchat-code-block"><code class="language-${lang}">${escapedCode}</code></pre>
2809 </div>`;
2810 });
2811
2812 // Handle inline code with single backticks
2813 text = text.replace(/`([^`\n]+)`/g, '<code class="mxchat-inline-code">$1</code>');
2814
2815 // Handle raw PHP tags (legacy support)
2816 text = text.replace(/(<\?php[\s\S]*?\?>)/g, (match) => {
2817 const escapedCode = escapeHtml(match);
2818 return `<div class="mxchat-code-block-container">
2819 <div class="mxchat-code-header">
2820 <span class="mxchat-code-language">php</span>
2821 <button class="mxchat-copy-button" aria-label="Copy to clipboard">Copy</button>
2822 </div>
2823 <pre class="mxchat-code-block"><code class="language-php">${escapedCode}</code></pre>
2824 </div>`;
2825 });
2826
2827 return text;
2828 }
2829
2830 function formatMarkdownTables(text) {
2831 var lines = text.split('\n');
2832 var result = [];
2833 var i = 0;
2834
2835 while (i < lines.length) {
2836 // Check for a table: current line has pipes AND next line is a separator row
2837 if (i + 1 < lines.length &&
2838 lines[i].indexOf('|') !== -1 &&
2839 /^\s*\|?[\s\-:]+(\|[\s\-:]+)+\|?\s*$/.test(lines[i + 1])) {
2840
2841 var tableLines = [];
2842 var headerLine = lines[i];
2843 var separatorLine = lines[i + 1];
2844 tableLines.push(headerLine);
2845 tableLines.push(separatorLine);
2846
2847 // Collect remaining table rows
2848 var j = i + 2;
2849 while (j < lines.length && lines[j].indexOf('|') !== -1 && lines[j].trim() !== '') {
2850 tableLines.push(lines[j]);
2851 j++;
2852 }
2853
2854 // Parse alignment from separator row
2855 var sepCells = separatorLine.split('|').filter(function(c) { return c.trim() !== ''; });
2856 var alignments = sepCells.map(function(cell) {
2857 var trimmed = cell.trim();
2858 if (trimmed.charAt(0) === ':' && trimmed.charAt(trimmed.length - 1) === ':') return 'center';
2859 if (trimmed.charAt(trimmed.length - 1) === ':') return 'right';
2860 return 'left';
2861 });
2862
2863 // Build HTML table
2864 var html = '<div class="mxchat-table-wrapper"><table class="mxchat-table">';
2865
2866 // Header row
2867 var headerCells = tableLines[0].split('|').filter(function(c) { return c.trim() !== ''; });
2868 html += '<thead><tr>';
2869 headerCells.forEach(function(cell, idx) {
2870 var align = alignments[idx] || 'left';
2871 html += '<th style="text-align:' + align + '">' + cell.trim() + '</th>';
2872 });
2873 html += '</tr></thead>';
2874
2875 // Body rows
2876 html += '<tbody>';
2877 for (var r = 2; r < tableLines.length; r++) {
2878 var rowCells = tableLines[r].split('|').filter(function(c) { return c.trim() !== ''; });
2879 html += '<tr>';
2880 rowCells.forEach(function(cell, idx) {
2881 var align = alignments[idx] || 'left';
2882 html += '<td style="text-align:' + align + '">' + cell.trim() + '</td>';
2883 });
2884 html += '</tr>';
2885 }
2886 html += '</tbody></table></div>';
2887
2888 result.push(html);
2889 i = j;
2890 } else {
2891 result.push(lines[i]);
2892 i++;
2893 }
2894 }
2895
2896 return result.join('\n');
2897 }
2898
2899 function sanitizeUserInput(text) {
2900 const div = document.createElement('div');
2901 div.textContent = text;
2902 return div.innerHTML;
2903 }
2904
2905 // Attribute-safe URL for href="…" / src="…" interpolation (plan 2db182).
2906 // Percent-encodes the characters that can end or extend an attribute.
2907 // Valid in every scheme the widget emits (http, https, tel, mailto) and
2908 // never changes where a legitimate link resolves — encodeURI() already
2909 // produces most of these, this just guarantees them for URLs that skip
2910 // encodeURI() because they were percent-encoded on arrival.
2911 function escapeAttr(url) {
2912 return String(url)
2913 .replace(/"/g, '%22')
2914 .replace(/'/g, '%27')
2915 .replace(/</g, '%3C')
2916 .replace(/>/g, '%3E')
2917 .replace(/`/g, '%60')
2918 .replace(/ /g, '%20');
2919 }
2920
2921 function escapeHtml(unsafe) {
2922 // Skip escaping if it's already escaped or contains HTML code block markup
2923 if (unsafe.includes('&lt;') || unsafe.includes('&gt;') ||
2924 unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) {
2925 return unsafe;
2926 }
2927
2928 return unsafe
2929 .replace(/&/g, "&amp;")
2930 .replace(/</g, "&lt;")
2931 .replace(/>/g, "&gt;")
2932 .replace(/"/g, "&quot;")
2933 .replace(/'/g, "&#039;");
2934 }
2935
2936 function decodeHTMLEntities(text) {
2937 var textArea = document.createElement('textarea');
2938 textArea.innerHTML = text;
2939 return textArea.value;
2940 }
2941
2942 // ====================================
2943 // UI & SCROLLING CONTROLS
2944 // ====================================
2945
2946 function scrollToBottom(botIdOrInstant, instant) {
2947 // Handle backward compatibility: scrollToBottom() or scrollToBottom(true/false)
2948 var botId = 'default';
2949 if (typeof botIdOrInstant === 'string') {
2950 botId = botIdOrInstant;
2951 instant = instant || false;
2952 } else if (typeof botIdOrInstant === 'boolean') {
2953 instant = botIdOrInstant;
2954 } else {
2955 instant = false;
2956 }
2957
2958 var chatBox = getElement(botId, 'chat-box');
2959 if (instant) {
2960 // Instantly set the scroll position to the bottom
2961 chatBox.scrollTop(chatBox.prop("scrollHeight"));
2962 } else {
2963 // Use requestAnimationFrame for smoother scrolling if needed
2964 let start = null;
2965 const scrollHeight = chatBox.prop("scrollHeight");
2966 const initialScroll = chatBox.scrollTop();
2967 const distance = scrollHeight - initialScroll;
2968 const duration = 500; // Duration in ms
2969
2970 function smoothScroll(timestamp) {
2971 if (!start) start = timestamp;
2972 const progress = timestamp - start;
2973 const currentScroll = initialScroll + (distance * (progress / duration));
2974 chatBox.scrollTop(currentScroll);
2975
2976 if (progress < duration) {
2977 requestAnimationFrame(smoothScroll);
2978 } else {
2979 chatBox.scrollTop(scrollHeight); // Ensure it's exactly at the bottom
2980 }
2981 }
2982
2983 requestAnimationFrame(smoothScroll);
2984 }
2985 }
2986
2987 function scrollElementToTop(element, botId, topOffset) {
2988 botId = botId || 'default';
2989 topOffset = (typeof topOffset === 'number') ? topOffset : 2;
2990 var chatBox = getElement(botId, 'chat-box');
2991 var elementTop = element.position().top + chatBox.scrollTop();
2992 chatBox.animate({ scrollTop: Math.max(0, elementTop - topOffset) }, 500);
2993 }
2994
2995 function showChatWidget(botId) {
2996 botId = botId || 'default';
2997 var $button = getElement(botId, 'floating-chatbot-button');
2998 // First ensure display is set
2999 $button.css('display', 'flex');
3000 // Then handle the fade
3001 $button.fadeTo(500, 1);
3002 // Force visibility
3003 $button.removeClass('hidden');
3004 }
3005
3006 function hideChatWidget(botId) {
3007 botId = botId || 'default';
3008 var $button = getElement(botId, 'floating-chatbot-button');
3009 $button.css('display', 'none');
3010 $button.addClass('hidden');
3011 }
3012
3013 function disableScroll() {
3014 if (isMobile()) {
3015 $('body').css('overflow', 'hidden');
3016 }
3017 }
3018
3019 function enableScroll() {
3020 if (isMobile()) {
3021 $('body').css('overflow', '');
3022 }
3023 }
3024
3025 function isMobile() {
3026 // This can be a simple check, or more sophisticated detection of mobile devices
3027 return window.innerWidth <= 768; // Example threshold for mobile devices
3028 }
3029
3030 function setFullHeight() {
3031 var vh = $(window).innerHeight() * 0.01;
3032 $(':root').css('--vh', vh + 'px');
3033 }
3034
3035
3036 // ====================================
3037 // NOTIFICATION SYSTEM
3038 // ====================================
3039
3040 function createNotificationBadge() {
3041 const chatButton = document.getElementById('floating-chatbot-button');
3042
3043 if (!chatButton) return;
3044
3045 // Remove any existing badge first
3046 const existingBadge = chatButton.querySelector('.chat-notification-badge');
3047 if (existingBadge) {
3048 existingBadge.remove();
3049 }
3050
3051 notificationBadge = document.createElement('div');
3052 notificationBadge.className = 'chat-notification-badge';
3053 notificationBadge.style.cssText = `
3054 display: none;
3055 position: absolute;
3056 top: -5px;
3057 right: -5px;
3058 background-color: red;
3059 color: white;
3060 border-radius: 50%;
3061 padding: 4px 8px;
3062 font-size: 12px;
3063 font-weight: bold;
3064 z-index: 10001;
3065 `;
3066 chatButton.style.position = 'relative';
3067 chatButton.appendChild(notificationBadge);
3068
3069 }
3070
3071 function showNotification(botId) {
3072 botId = botId || 'default';
3073 const badge = getElementDOM(botId, 'chat-notification-badge');
3074 var $floatingChatbot = getElement(botId, 'floating-chatbot');
3075 if (badge && $floatingChatbot.hasClass('hidden')) {
3076 badge.style.display = 'block';
3077 badge.textContent = '1';
3078 }
3079 }
3080
3081 function hideNotification(botId) {
3082 botId = botId || 'default';
3083 const badge = getElementDOM(botId, 'chat-notification-badge');
3084 if (badge) {
3085 badge.style.display = 'none';
3086 }
3087 }
3088
3089 function startNotificationChecking(botId) {
3090 botId = botId || 'default';
3091 const chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3092 if (!chatPersistenceEnabled) return;
3093
3094 createNotificationBadge(botId);
3095 var instance = MxChatInstances.get(botId);
3096 instance.notificationCheckInterval = setInterval(function() {
3097 checkForNewMessages(botId);
3098 }, 30000); // Check every 30 seconds
3099 }
3100
3101 function stopNotificationChecking(botId) {
3102 botId = botId || 'default';
3103 var instance = MxChatInstances.get(botId);
3104 if (instance.notificationCheckInterval) {
3105 clearInterval(instance.notificationCheckInterval);
3106 }
3107 }
3108
3109 function checkForNewMessages() {
3110 const sessionId = getChatSession();
3111 const chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3112
3113 if (!chatPersistenceEnabled) return;
3114
3115 $.ajax({
3116 url: mxchatChat.ajax_url,
3117 type: 'POST',
3118 data: {
3119 action: 'mxchat_check_new_messages',
3120 session_id: sessionId,
3121 last_seen_id: lastSeenMessageId,
3122 nonce: mxchatChat.nonce
3123 },
3124 success: function(response) {
3125 if (response.success && response.data.hasNewMessages) {
3126 showNotification();
3127 }
3128 }
3129 });
3130 }
3131
3132
3133 // ====================================
3134 // LIVE AGENT FUNCTIONALITY
3135 // ====================================
3136
3137 function startPolling(botId) {
3138 botId = botId || 'default';
3139 var instance = MxChatInstances.get(botId);
3140 // Clear any existing interval first
3141 stopPolling(botId);
3142 instance.pollingInterval = setInterval(function() {
3143 checkForAgentMessages(botId);
3144 }, 5000);
3145 }
3146
3147 function stopPolling(botId) {
3148 botId = botId || 'default';
3149 var instance = MxChatInstances.get(botId);
3150 if (instance.pollingInterval) {
3151 clearInterval(instance.pollingInterval);
3152 instance.pollingInterval = null;
3153 }
3154 }
3155
3156 function checkForAgentMessages(botId) {
3157 botId = botId || 'default';
3158 var instance = MxChatInstances.get(botId);
3159 const sessionId = getChatSession(botId);
3160 $.ajax({
3161 url: mxchatChat.ajax_url,
3162 type: 'POST',
3163 dataType: 'json',
3164 data: {
3165 action: 'mxchat_fetch_new_messages',
3166 session_id: sessionId,
3167 last_seen_id: instance.lastSeenMessageId,
3168 persistence_enabled: 'true',
3169 nonce: mxchatChat.nonce
3170 },
3171 success: function (response) {
3172 if (response.success && response.data?.new_messages) {
3173 let hasNewMessage = false;
3174
3175 response.data.new_messages.forEach(function (message) {
3176 if (message.role === "agent" && !instance.processedMessageIds.has(message.id)) {
3177 hasNewMessage = true;
3178 appendMessage("agent", message.content, '', [], false, botId);
3179 instance.lastSeenMessageId = message.id;
3180 instance.processedMessageIds.add(message.id);
3181 }
3182 });
3183
3184 if (hasNewMessage) {
3185 enableChatInput(botId);
3186 }
3187
3188 var $floatingChatbot = getElement(botId, 'floating-chatbot');
3189 if (hasNewMessage && $floatingChatbot.hasClass('hidden')) {
3190 showNotification(botId);
3191 }
3192
3193 scrollToBottom(botId, true);
3194 }
3195
3196 // Handle chat mode transitions (e.g. agent ended chat via !endchat)
3197 if (response.success && response.data?.chat_mode) {
3198 updateChatModeIndicator(response.data.chat_mode, botId);
3199 }
3200 },
3201 error: function (xhr, status, error) {
3202 // Polling error - silently continue
3203 }
3204 });
3205 }
3206
3207 // ====================================
3208 // CHAT HISTORY & PERSISTENCE
3209 // ====================================
3210
3211 function loadChatHistory(botId, onComplete) {
3212 botId = botId || 'default';
3213 var instance = MxChatInstances.get(botId);
3214
3215 // Prevent duplicate loading
3216 if (instance.chatHistoryLoaded) {
3217 if (onComplete) onComplete();
3218 return;
3219 }
3220
3221 // Use getChatSession which returns null if no session exists (does NOT create one)
3222 var sessionId = getChatSession(botId);
3223 var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
3224
3225 // No session yet — nothing to load. History will load after first message via ensureSession.
3226 if (!sessionId) {
3227 instance.chatHistoryLoaded = true;
3228 if (onComplete) onComplete();
3229 return;
3230 }
3231
3232 if (chatPersistenceEnabled && sessionId) {
3233 $.ajax({
3234 url: mxchatChat.ajax_url,
3235 type: 'POST',
3236 dataType: 'json',
3237 data: {
3238 action: 'mxchat_fetch_conversation_history',
3239 session_id: sessionId
3240 },
3241 success: function(response) {
3242 // Handle session reset (IP changed while user was away)
3243 if (response.success === false && response.data && response.data.action === 'reset_session') {
3244 // Silent reset — new session but don't clear UI
3245 MxChatInstances.silentResetSession(botId);
3246 instance.chatHistoryLoaded = true; // Prevent retry loop
3247 if (onComplete) onComplete();
3248 return;
3249 }
3250
3251 // Check if the response indicates success
3252 if (response.success) {
3253 // Handle case where conversation data exists and is an array
3254 if (response.data && Array.isArray(response.data.conversation)) {
3255 var $chatBox = getElement(botId, 'chat-box');
3256 var $fragment = $(document.createDocumentFragment());
3257 let highestMessageId = instance.lastSeenMessageId;
3258
3259 // Update chat mode if provided
3260 if (response.data.chat_mode) {
3261 updateChatModeIndicator(response.data.chat_mode, botId);
3262 }
3263
3264 // Only process if there are actual messages
3265 if (response.data.conversation.length > 0) {
3266 // Restored history must be SILENT to screen readers
3267 // (plan 67f126): these are DOM additions inside the
3268 // live region and would otherwise announce as if
3269 // they just arrived. Lift aria-live for the batch
3270 // repopulate, restore it after the browser has
3271 // processed the mutations.
3272 var mxLiveRegionEl = $chatBox.get(0);
3273 var mxSavedAriaLive = mxLiveRegionEl ? mxLiveRegionEl.getAttribute('aria-live') : null;
3274 if (mxLiveRegionEl) {
3275 mxLiveRegionEl.setAttribute('aria-live', 'off');
3276 }
3277
3278 // IMPORTANT: Clear existing messages before loading history.
3279 // Detach the greeting first and put it back below —
3280 // it is server-rendered and never stored in the
3281 // transcript, so the old unconditional .empty()
3282 // deleted it for the rest of the page life (plan
3283 // a1a79b). Detach rather than rebuild: intro_message
3284 // is not localized to JS, and this node already
3285 // carries the per-bot inline colors and any
3286 // {visitor_name} substitution already applied to it.
3287 var $mxIntro = $chatBox.find('.mxchat-intro-message').first().detach();
3288 $chatBox.empty();
3289
3290 $.each(response.data.conversation, function(index, message) {
3291 // Skip agent messages if persistence is off
3292 if (!chatPersistenceEnabled && message.role === 'agent') {
3293 return;
3294 }
3295
3296 var messageClass, messageBgColor, messageFontColor;
3297
3298 switch (message.role) {
3299 case 'user':
3300 messageClass = 'user-message';
3301 messageBgColor = userMessageBgColor;
3302 messageFontColor = userMessageFontColor;
3303 break;
3304 case 'agent':
3305 messageClass = 'agent-message';
3306 messageBgColor = liveAgentMessageBgColor;
3307 messageFontColor = liveAgentMessageFontColor;
3308 break;
3309 default:
3310 messageClass = 'bot-message';
3311 messageBgColor = botMessageBgColor;
3312 messageFontColor = botMessageFontColor;
3313 break;
3314 }
3315
3316 var messageElement = $('<div>').addClass(messageClass)
3317 .css({
3318 'background': messageBgColor,
3319 'color': messageFontColor
3320 });
3321
3322 var content = message.content;
3323 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
3324 content = decodeHTMLEntities(content);
3325
3326 // A visitor's own rows get the same treatment as the
3327 // live turn (appendMessage escapes user text before
3328 // linkify). Without this a replayed user message came
3329 // back as live markup after a refresh (plan 2db182).
3330 // Bot/agent rows are trusted HTML and stay as they were.
3331 if (message.role === 'user') {
3332 content = sanitizeUserInput(content);
3333 }
3334
3335 // Skip linkify for messages containing structured HTML
3336 // (forms, product cards, galleries, etc.) to avoid
3337 // markdown formatting corrupting HTML attributes
3338 // (e.g. underscores in name="field_name" becoming <em> tags).
3339 // One family check instead of a per-card literal list: any
3340 // element carrying an mxchat- prefixed class is MxChat-generated
3341 // structured markup and replays raw. The old list drifted every
3342 // time an add-on minted a new card class — the filtered-search
3343 // card ("mxchat-filtered-product-card") missed it and replayed
3344 // through linkify as visible markup.
3345 if (/<[a-z][^>]*class\s*=\s*["'][^"']*\bmxchat-/i.test(content) ||
3346 content.includes("<form") ||
3347 content.includes("<input") ||
3348 content.includes("<select") ||
3349 content.includes("<textarea")) {
3350 messageElement.html(content);
3351 } else {
3352 var formattedContent = linkify(content);
3353 messageElement.html(formattedContent);
3354 }
3355
3356 $fragment.append(messageElement);
3357
3358 // Track message IDs
3359 if (message.id) {
3360 highestMessageId = Math.max(highestMessageId, message.id);
3361 instance.processedMessageIds.add(message.id);
3362 }
3363 });
3364
3365 // Only append messages and scroll if we have content.
3366 // Greeting goes back FIRST, above the restored
3367 // history: "Hello — [earlier conversation]" is the
3368 // natural reading and matches the order a fresh
3369 // visitor sees (plan a1a79b).
3370 if ($mxIntro && $mxIntro.length) {
3371 $chatBox.append($mxIntro);
3372 }
3373 $chatBox.append($fragment);
3374 scrollToBottom(botId, true);
3375
3376 // Re-attach live semantics AFTER the rehydration
3377 // mutations have been processed with the region off
3378 // (plan 67f126). Restoring later announces nothing
3379 // retroactively; new turns announce normally.
3380 if (mxLiveRegionEl) {
3381 setTimeout(function() {
3382 mxLiveRegionEl.setAttribute('aria-live', mxSavedAriaLive || 'polite');
3383 }, 200);
3384 }
3385
3386 // Collapse quick questions if we have conversation history
3387 // BUT skip auto-collapse for embedded bots (they should stay expanded)
3388 if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) {
3389 collapseQuickQuestions(botId);
3390 }
3391
3392 // Update lastSeenMessageId after history loads
3393 instance.lastSeenMessageId = highestMessageId;
3394
3395 // Only update chat mode if persistence is enabled and we have messages
3396 if (chatPersistenceEnabled) {
3397 var lastMessage = response.data.conversation[response.data.conversation.length - 1];
3398 if (lastMessage.role === 'agent') {
3399 updateChatModeIndicator('agent', botId);
3400 }
3401 }
3402
3403 // Mark as loaded ONLY after successful load
3404 instance.chatHistoryLoaded = true;
3405 }
3406 }
3407 }
3408 if (onComplete) onComplete();
3409 },
3410 error: function(xhr, status, error) {
3411 // Error loading chat history - silently continue
3412 if (onComplete) onComplete();
3413 }
3414 });
3415 } else {
3416 if (onComplete) onComplete();
3417 }
3418 }
3419
3420
3421 // ====================================
3422 // FILE UPLOAD FUNCTIONALITY
3423 // ====================================
3424
3425 function addSafeEventListener(elementId, eventType, handler) {
3426 const element = document.getElementById(elementId);
3427 if (element) {
3428 element.addEventListener(eventType, handler);
3429 }
3430 }
3431
3432 function showActivePdf(filename, botId) {
3433 botId = botId || 'default';
3434 const container = getElementDOM(botId, 'active-pdf-container');
3435 const nameElement = getElementDOM(botId, 'active-pdf-name');
3436
3437 if (!container || !nameElement) {
3438 return;
3439 }
3440
3441 nameElement.textContent = filename;
3442 container.style.display = 'flex';
3443 }
3444
3445 function showActiveWord(filename, botId) {
3446 botId = botId || 'default';
3447 const container = getElementDOM(botId, 'active-word-container');
3448 const nameElement = getElementDOM(botId, 'active-word-name');
3449
3450 if (!container || !nameElement) {
3451 return;
3452 }
3453
3454 nameElement.textContent = filename;
3455 container.style.display = 'flex';
3456 }
3457
3458 function removeActivePdf(botId) {
3459 botId = botId || 'default';
3460 var instance = MxChatInstances.get(botId);
3461 const container = getElementDOM(botId, 'active-pdf-container');
3462 const nameElement = getElementDOM(botId, 'active-pdf-name');
3463
3464 if (!container || !nameElement || !instance.activePdfFile) return;
3465
3466 fetch(mxchatChat.ajax_url, {
3467 method: 'POST',
3468 headers: {
3469 'Content-Type': 'application/x-www-form-urlencoded',
3470 },
3471 body: new URLSearchParams({
3472 'action': 'mxchat_remove_pdf',
3473 'session_id': getChatSession(botId),
3474 'nonce': mxchatChat.nonce
3475 })
3476 })
3477 .then(response => response.json())
3478 .then(data => {
3479 if (data.success) {
3480 container.style.display = 'none';
3481 nameElement.textContent = '';
3482 instance.activePdfFile = null;
3483 appendMessage('bot', 'PDF removed.', '', [], false, botId);
3484 }
3485 })
3486 .catch(error => {
3487 // Error removing PDF - silently continue
3488 });
3489 }
3490
3491 function removeActiveWord(botId) {
3492 botId = botId || 'default';
3493 var instance = MxChatInstances.get(botId);
3494 const container = getElementDOM(botId, 'active-word-container');
3495 const nameElement = getElementDOM(botId, 'active-word-name');
3496
3497 if (!container || !nameElement || !instance.activeWordFile) return;
3498
3499 fetch(mxchatChat.ajax_url, {
3500 method: 'POST',
3501 headers: {
3502 'Content-Type': 'application/x-www-form-urlencoded',
3503 },
3504 body: new URLSearchParams({
3505 'action': 'mxchat_remove_word',
3506 'session_id': getChatSession(botId),
3507 'nonce': mxchatChat.nonce
3508 })
3509 })
3510 .then(response => response.json())
3511 .then(data => {
3512 if (data.success) {
3513 container.style.display = 'none';
3514 nameElement.textContent = '';
3515 instance.activeWordFile = null;
3516 appendMessage('bot', 'Word document removed.', '', [], false, botId);
3517 }
3518 })
3519 .catch(error => {
3520 // Error removing Word document - silently continue
3521 });
3522 }
3523
3524 // ====================================
3525 // CONSENT & COMPLIANCE (GDPR)
3526 // ====================================
3527
3528 function initializeChatVisibility(botId) {
3529 botId = botId || 'default';
3530 const complianzEnabled = mxchatChat.complianz_toggle === 'on' ||
3531 mxchatChat.complianz_toggle === '1' ||
3532 mxchatChat.complianz_toggle === 1;
3533
3534 if (complianzEnabled && typeof cmplz_has_consent === "function" && typeof complianz !== 'undefined') {
3535 // Initial check
3536 checkConsentAndShowChat(botId);
3537
3538 // Listen for consent changes
3539 $(document).on('cmplz_status_change', function(event) {
3540 checkConsentAndShowChat(botId);
3541 });
3542 } else {
3543 // If Complianz is not enabled, always show
3544 getElement(botId, 'floating-chatbot-button')
3545 .css('display', 'flex')
3546 .removeClass('hidden no-consent')
3547 .fadeTo(500, 1);
3548
3549 // Also check pre-chat message when Complianz is not enabled
3550 checkPreChatDismissal(botId);
3551 }
3552 }
3553
3554
3555 function checkConsentAndShowChat(botId) {
3556 botId = botId || 'default';
3557 var consentStatus = cmplz_has_consent('marketing');
3558 var consentType = complianz.consenttype;
3559
3560 let $widget = getElement(botId, 'floating-chatbot-button');
3561 let $chatbot = getElement(botId, 'floating-chatbot');
3562 let $preChat = getElement(botId, 'pre-chat-message');
3563
3564 if (consentStatus === true) {
3565 $widget
3566 .removeClass('no-consent')
3567 .css('display', 'flex')
3568 .removeClass('hidden')
3569 .fadeTo(500, 1);
3570 $chatbot.removeClass('no-consent');
3571
3572 // Show pre-chat message if not dismissed
3573 checkPreChatDismissal(botId);
3574 } else {
3575 $widget
3576 .addClass('no-consent')
3577 .fadeTo(500, 0, function() {
3578 $(this)
3579 .css('display', 'none')
3580 .addClass('hidden');
3581 });
3582 $chatbot.addClass('no-consent');
3583
3584 // Hide pre-chat message when no consent
3585 $preChat.hide();
3586 }
3587 }
3588
3589
3590 // ====================================
3591 // PRE-CHAT MESSAGE HANDLING
3592 // ====================================
3593
3594 function checkPreChatDismissal(botId) {
3595 botId = botId || 'default';
3596 try {
3597 var dismissedAt = localStorage.getItem('mxchat_pre_chat_dismissed_' + botId);
3598 if (dismissedAt) {
3599 // Re-show after 24 hours
3600 var elapsed = Date.now() - parseInt(dismissedAt, 10);
3601 if (elapsed < 86400000) {
3602 getElement(botId, 'pre-chat-message').hide();
3603 return;
3604 }
3605 // Expired — clear and show again
3606 localStorage.removeItem('mxchat_pre_chat_dismissed_' + botId);
3607 }
3608 getElement(botId, 'pre-chat-message').fadeIn(250);
3609 } catch (e) {
3610 // localStorage unavailable — show the message
3611 getElement(botId, 'pre-chat-message').fadeIn(250);
3612 }
3613 }
3614
3615 function handlePreChatDismissal(botId) {
3616 botId = botId || 'default';
3617 getElement(botId, 'pre-chat-message').fadeOut(200);
3618 try {
3619 localStorage.setItem('mxchat_pre_chat_dismissed_' + botId, String(Date.now()));
3620 } catch (e) {
3621 // localStorage unavailable — dismissal won't persist
3622 }
3623 }
3624
3625
3626 // ====================================
3627 // UTILITY FUNCTIONS
3628 // ====================================
3629
3630 function copyToClipboard(text) {
3631 var tempInput = $('<input>');
3632 $('body').append(tempInput);
3633 tempInput.val(text).select();
3634 document.execCommand('copy');
3635 tempInput.remove();
3636 }
3637
3638
3639 function isImageHtml(str) {
3640 return str.startsWith('<img') && str.endsWith('>');
3641 }
3642
3643
3644 // ====================================
3645 // EVENT HANDLERS & INITIALIZATION
3646 // ====================================
3647
3648 $(document).on('click', '.mxchat-popular-question', function () {
3649 var question = $(this).text();
3650 var botId = getBotIdFromElement(this);
3651
3652 // Append the question as if the user typed it
3653 appendMessage("user", question, '', [], false, botId);
3654
3655 // Only collapse if there are questions
3656 if (hasQuickQuestions(botId)) {
3657 collapseQuickQuestions(botId);
3658 }
3659
3660 // Send the question to the server
3661 sendMessageToChatbot(question, botId);
3662 });
3663
3664 $(document).on('click', '.questions-toggle-btn', function(e) {
3665 e.preventDefault();
3666 e.stopPropagation();
3667 var botId = getBotIdFromElement(this);
3668 expandQuickQuestions(botId);
3669 });
3670
3671 $(document).on('click', '.questions-collapse-btn', function(e) {
3672 e.preventDefault();
3673 e.stopPropagation();
3674 var botId = getBotIdFromElement(this);
3675 collapseQuickQuestions(botId);
3676 });
3677
3678 // Consent-safe YouTube embed (plan 03ba33): the server only ever ships a
3679 // thumbnail facade — no Google iframe exists until the visitor taps play.
3680 // Delegated so it also works for embeds restored from chat history.
3681 $(document).on('click', '.mxchat-youtube-embed .mxchat-youtube-facade', function(e) {
3682 e.preventDefault();
3683 var $wrap = $(this).closest('.mxchat-youtube-embed');
3684 var videoId = String($wrap.data('video-id') || '').replace(/[^A-Za-z0-9_-]/g, '');
3685 if (!videoId) {
3686 return;
3687 }
3688 var title = $wrap.find('.mxchat-youtube-title').text() || 'YouTube video';
3689 var $iframe = $('<iframe>', {
3690 src: 'https://www.youtube-nocookie.com/embed/' + videoId + '?autoplay=1&rel=0',
3691 title: title,
3692 allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
3693 allowfullscreen: true,
3694 frameborder: 0
3695 }).addClass('mxchat-youtube-iframe');
3696 $wrap.addClass('mxchat-youtube-playing');
3697 $(this).replaceWith($iframe);
3698 });
3699
3700 // Chatbot visibility toggle handlers - use class selector for multi-instance support
3701 // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1).
3702 $(document).on('click keydown', '.floating-chatbot-button', function(e) {
3703 if (e.type === 'keydown') {
3704 if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
3705 e.preventDefault();
3706 }
3707 var botId = getBotIdFromElement(this);
3708 var $chatbot = getElement(botId, 'floating-chatbot');
3709 var $badge = getElement(botId, 'chat-notification-badge');
3710 var $preChat = getElement(botId, 'pre-chat-message');
3711
3712 if ($chatbot.hasClass('hidden')) {
3713 $chatbot.removeClass('hidden').addClass('visible')
3714 .attr('aria-modal', 'true').attr('role', 'dialog');
3715 $(this).addClass('hidden').attr('aria-expanded', 'true');
3716 $badge.hide(); // Hide notification when opening chat
3717 disableScroll();
3718 $preChat.fadeOut(250);
3719 mxchatFireOpened(botId);
3720
3721 // First open per page load: re-fetch behavior settings in case
3722 // this page's inline values came from a stale full-page cache
3723 // (plan-32db95). Idempotent — later opens are a no-op.
3724 mxchatRefreshDynamicSettings();
3725
3726 // Load chat history for returning visitors (persistence)
3727 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
3728 if (chatPersistenceEnabled) {
3729 MxChatInstances.ensureSession(botId);
3730 }
3731
3732 // Deferred email check — only on first widget open
3733 var emailBlocker = getElementDOM(botId, 'email-blocker');
3734 var instance = MxChatInstances.get(botId);
3735 if (emailBlocker && !instance.emailCheckDone) {
3736 instance.emailCheckDone = true;
3737 resolveEmailState(botId);
3738 } else if (!emailBlocker) {
3739 // No email collection — still route through showChatContainerForBot
3740 // so the loader is shown while chat history loads
3741 showChatContainerForBot(botId);
3742 }
3743
3744 // Move keyboard focus into the message input after the open transition.
3745 setTimeout(function() {
3746 var chatInput = getElementDOM(botId, 'chat-input');
3747 if (chatInput && !chatInput.disabled) {
3748 try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); }
3749 }
3750 }, 300);
3751 } else {
3752 $chatbot.removeClass('visible').addClass('hidden').removeAttr('aria-modal');
3753 $(this).removeClass('hidden').attr('aria-expanded', 'false');
3754 enableScroll();
3755 checkPreChatDismissal(botId);
3756 }
3757 });
3758
3759 // Allow clicking anywhere on the title bar to close the chatbot.
3760 // Returns keyboard focus to the launcher so keyboard users don't get
3761 // stranded at <body> (WCAG SC 2.4.3 Focus Order). :focus-visible is
3762 // heuristic-based so mouse-triggered close won't show a focus ring.
3763 $(document).on('click', '.chatbot-top-bar', function() {
3764 var botId = getBotIdFromElement(this);
3765 getElement(botId, 'floating-chatbot').addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3766 var $launcher = getElement(botId, 'floating-chatbot-button');
3767 $launcher.removeClass('hidden').attr('aria-expanded', 'false');
3768 enableScroll();
3769 try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3770 });
3771
3772 // Global Escape-key handler — closes any visible chat widget and
3773 // returns focus to its launcher. Standard modal-dismissal pattern;
3774 // pairs with aria-modal="true" set on the widget when it opens.
3775 $(document).on('keydown', function(e) {
3776 if (e.key !== 'Escape' && e.key !== 'Esc') return;
3777 var $visible = $('.floating-chatbot.visible');
3778 if (!$visible.length) return;
3779 e.preventDefault();
3780 $visible.each(function() {
3781 var botId = getBotIdFromElement(this);
3782 $(this).addClass('hidden').removeClass('visible').removeAttr('aria-modal');
3783 var $launcher = getElement(botId, 'floating-chatbot-button');
3784 $launcher.removeClass('hidden').attr('aria-expanded', 'false');
3785 try { $launcher.trigger('focus'); } catch (err) { /* no-op */ }
3786 });
3787 enableScroll();
3788 });
3789
3790 $(document).on('click', '.close-pre-chat-message', function(e) {
3791 e.stopPropagation(); // Prevent triggering the parent .pre-chat-message click
3792 var botId = getBotIdFromElement(this);
3793 handlePreChatDismissal(botId);
3794 });
3795
3796
3797 // PDF upload button handlers - use class selector
3798 $(document).on('click', '.pdf-upload-btn', function() {
3799 var botId = getBotIdFromElement(this);
3800 var pdfInput = getElementDOM(botId, 'pdf-upload');
3801 if (pdfInput) pdfInput.click();
3802 });
3803
3804 // Word upload button handlers - use class selector
3805 $(document).on('click', '.word-upload-btn', function() {
3806 var botId = getBotIdFromElement(this);
3807 var wordInput = getElementDOM(botId, 'word-upload');
3808 if (wordInput) wordInput.click();
3809 });
3810
3811 // PDF file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'pdf-upload')
3812 $(document).on('change', '.pdf-upload', async function(e) {
3813 var botId = getBotIdFromElement(this);
3814 var instance = MxChatInstances.get(botId);
3815 const file = this.files[0];
3816 const sessionId = MxChatInstances.ensureSession(botId);
3817
3818 if (!file || file.type !== 'application/pdf') {
3819 alert('Please select a valid PDF file.');
3820 return;
3821 }
3822
3823 if (!sessionId) {
3824 alert('Error: No session ID found');
3825 return;
3826 }
3827
3828 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3829 alert('Error: Ajax configuration missing');
3830 return;
3831 }
3832
3833 // Disable buttons and show loading state
3834 const uploadBtn = getElementDOM(botId, 'pdf-upload-btn');
3835 const sendBtn = getElementDOM(botId, 'send-button');
3836 if (!uploadBtn) return;
3837 const originalBtnContent = uploadBtn.innerHTML;
3838
3839 try {
3840 // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3841 await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3842 const formData = new FormData();
3843 formData.append('action', 'mxchat_upload_pdf');
3844 formData.append('pdf_file', file);
3845 formData.append('session_id', sessionId);
3846 formData.append('nonce', mxchatChat.nonce);
3847
3848 uploadBtn.disabled = true;
3849 if (sendBtn) sendBtn.disabled = true;
3850 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3851 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3852 </svg>`;
3853
3854 const response = await fetch(mxchatChat.ajax_url, {
3855 method: 'POST',
3856 body: formData
3857 });
3858
3859 const data = await response.json();
3860
3861 if (data.success) {
3862 // Hide popular questions if they exist
3863 if (hasQuickQuestions(botId)) {
3864 collapseQuickQuestions(botId);
3865 }
3866
3867 // Show the active PDF name
3868 showActivePdf(data.data.filename, botId);
3869
3870 appendMessage('bot', data.data.message, '', [], false, botId);
3871 scrollToBottom(botId);
3872 instance.activePdfFile = data.data.filename;
3873 } else {
3874 alert('Failed to upload PDF. Please try again.');
3875 }
3876 } catch (error) {
3877 alert('Error uploading file. Please try again.');
3878 } finally {
3879 uploadBtn.disabled = false;
3880 if (sendBtn) sendBtn.disabled = false;
3881 uploadBtn.innerHTML = originalBtnContent;
3882 this.value = ''; // Reset file input
3883 }
3884 });
3885
3886 // Word file input change handler - delegated, bot-aware (was bound to stale un-suffixed id 'word-upload')
3887 $(document).on('change', '.word-upload', async function(e) {
3888 var botId = getBotIdFromElement(this);
3889 var instance = MxChatInstances.get(botId);
3890 const file = this.files[0];
3891 const sessionId = MxChatInstances.ensureSession(botId);
3892
3893 if (!file || file.type !== 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
3894 alert('Please select a valid Word document (.docx).');
3895 return;
3896 }
3897
3898 if (!sessionId) {
3899 alert('Error: No session ID found');
3900 return;
3901 }
3902
3903 if (!mxchatChat || !mxchatChat.ajax_url || !mxchatChat.nonce) {
3904 alert('Error: Ajax configuration missing');
3905 return;
3906 }
3907
3908 // Disable buttons and show loading state
3909 const uploadBtn = getElementDOM(botId, 'word-upload-btn');
3910 const sendBtn = getElementDOM(botId, 'send-button');
3911 if (!uploadBtn) return;
3912 const originalBtnContent = uploadBtn.innerHTML;
3913
3914 try {
3915 // Wait for page-cache nonce refresh before reading mxchatChat.nonce. See plan-c5457f.
3916 await new Promise(function(resolve) { refreshNonceIfNeeded(resolve); });
3917 const formData = new FormData();
3918 formData.append('action', 'mxchat_upload_word');
3919 formData.append('word_file', file);
3920 formData.append('session_id', sessionId);
3921 formData.append('nonce', mxchatChat.nonce);
3922
3923 uploadBtn.disabled = true;
3924 if (sendBtn) sendBtn.disabled = true;
3925 uploadBtn.innerHTML = `<svg class="spinner" viewBox="0 0 50 50">
3926 <circle cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
3927 </svg>`;
3928
3929 const response = await fetch(mxchatChat.ajax_url, {
3930 method: 'POST',
3931 body: formData
3932 });
3933
3934 const data = await response.json();
3935
3936 if (data.success) {
3937 // Hide popular questions if they exist
3938 if (hasQuickQuestions(botId)) {
3939 collapseQuickQuestions(botId);
3940 }
3941
3942 // Show the active Word document name
3943 showActiveWord(data.data.filename, botId);
3944
3945 appendMessage('bot', data.data.message, '', [], false, botId);
3946 scrollToBottom(botId);
3947 instance.activeWordFile = data.data.filename;
3948 } else {
3949 alert('Failed to upload Word document. Please try again.');
3950 }
3951 } catch (error) {
3952 alert('Error uploading file. Please try again.');
3953 } finally {
3954 uploadBtn.disabled = false;
3955 if (sendBtn) sendBtn.disabled = false;
3956 uploadBtn.innerHTML = originalBtnContent;
3957 this.value = ''; // Reset file input
3958 }
3959 });
3960
3961 // Remove button click handlers - delegated, bot-aware (were bound to stale un-suffixed ids)
3962 $(document).on('click', '.remove-pdf-btn', function(e) {
3963 e.preventDefault();
3964 e.stopPropagation();
3965 removeActivePdf(getBotIdFromElement(this));
3966 });
3967
3968 $(document).on('click', '.remove-word-btn', function(e) {
3969 e.preventDefault();
3970 e.stopPropagation();
3971 removeActiveWord(getBotIdFromElement(this));
3972 });
3973
3974 // Window resize handlers
3975 $(window).on('resize orientationchange', function() {
3976 setFullHeight();
3977 });
3978
3979
3980 // ====================================
3981 // TOOLBAR & STYLING SETUP
3982 // ====================================
3983
3984 // Apply toolbar settings
3985 if (mxchatChat.chat_toolbar_toggle === 'on') {
3986 $('.chat-toolbar').show();
3987 } else {
3988 $('.chat-toolbar').hide();
3989 }
3990 // Toolbar pass done — server-rendered add-on items can bind (plan 65c9b6).
3991 MxChatInstances.toolbarReady = true;
3992 mxchatDispatch('mxchat:toolbar-ready', { enabled: mxchatChat.chat_toolbar_toggle === 'on' });
3993
3994 // Apply toolbar icon colors
3995 const toolbarElements = [
3996 '#mxchat-chatbot .toolbar-btn svg',
3997 '#mxchat-chatbot .active-pdf-name',
3998 '#mxchat-chatbot .active-word-name',
3999 '#mxchat-chatbot .remove-pdf-btn svg',
4000 '#mxchat-chatbot .remove-word-btn svg',
4001 '#mxchat-chatbot .toolbar-perplexity svg'
4002 ];
4003
4004 toolbarElements.forEach(selector => {
4005 $(selector).css({
4006 'fill': toolbarIconColor,
4007 'stroke': toolbarIconColor,
4008 'color': toolbarIconColor
4009 });
4010 });
4011
4012
4013 // ====================================
4014 // INIT LOADER & CHAT CONTAINER HELPERS
4015 // ====================================
4016 // These must be outside the email collection block so they're always available
4017 // (used by persistence loading even when email collection is off)
4018
4019 function showInitLoader(botId) {
4020 var loader = getElementDOM(botId, 'mxchat-init-loader');
4021 if (loader) loader.style.display = 'flex';
4022 }
4023
4024 function hideInitLoader(botId) {
4025 var loader = getElementDOM(botId, 'mxchat-init-loader');
4026 if (loader) loader.style.display = 'none';
4027 }
4028
4029 function showEmailFormForBot(botId) {
4030 hideInitLoader(botId);
4031 var emailBlocker = getElementDOM(botId, 'email-blocker');
4032 var chatContainer = getElementDOM(botId, 'chat-container');
4033 if (emailBlocker) emailBlocker.style.display = 'flex';
4034 if (chatContainer) chatContainer.style.display = 'none';
4035 }
4036
4037 function showChatContainerForBot(botId) {
4038 var emailBlocker = getElementDOM(botId, 'email-blocker');
4039 var chatContainer = getElementDOM(botId, 'chat-container');
4040 if (emailBlocker) emailBlocker.style.display = 'none';
4041
4042 var instance = MxChatInstances.get(botId);
4043 var chatPersistenceEnabled = mxchatChat.chat_persistence_toggle === 'on';
4044
4045 // If persistence is on and history hasn't loaded yet, show loader
4046 // while history loads to prevent flash of empty chat
4047 if (chatPersistenceEnabled && !instance.chatHistoryLoaded) {
4048 if (chatContainer) chatContainer.style.display = 'none';
4049 showInitLoader(botId);
4050 loadChatHistory(botId, function() {
4051 hideInitLoader(botId);
4052 if (chatContainer) chatContainer.style.display = 'flex';
4053 scrollToBottom(botId, true);
4054 });
4055 } else {
4056 hideInitLoader(botId);
4057 if (chatContainer) chatContainer.style.display = 'flex';
4058 if (typeof loadChatHistory === 'function') {
4059 loadChatHistory(botId);
4060 }
4061 }
4062 }
4063
4064 // ====================================
4065 // EMAIL COLLECTION SETUP - MULTI-INSTANCE VERSION
4066 // ====================================
4067 // Only run email collection setup if it's enabled
4068 if (mxchatChat && mxchatChat.email_collection_enabled === 'on') {
4069
4070 // Track submitting state per bot
4071 const emailSubmittingState = {};
4072
4073 // Add CSS animations for email form (once globally)
4074 if (!document.getElementById('email-error-styles')) {
4075 const style = document.createElement('style');
4076 style.id = 'email-error-styles';
4077 style.textContent = `
4078 @keyframes fadeInError {
4079 from { opacity: 0; transform: translateY(-5px); }
4080 to { opacity: 1; transform: translateY(0); }
4081 }
4082 .email-input-shake {
4083 animation: shake 0.5s ease-in-out;
4084 }
4085 @keyframes shake {
4086 0%, 100% { transform: translateX(0); }
4087 25% { transform: translateX(-5px); }
4088 75% { transform: translateX(5px); }
4089 }
4090 @keyframes spin {
4091 from { transform: rotate(0deg); }
4092 to { transform: rotate(360deg); }
4093 }
4094 .email-spinner {
4095 display: inline-block;
4096 vertical-align: middle;
4097 }
4098 `;
4099 document.head.appendChild(style);
4100 }
4101
4102 function isValidEmailAddress(email) {
4103 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
4104 return emailRegex.test(email.trim()) && email.length <= 254;
4105 }
4106
4107 function isValidNameInput(name) {
4108 return name && name.trim().length >= 2 && name.trim().length <= 100;
4109 }
4110
4111 /**
4112 * Replace {visitor_name} placeholder in intro message with actual visitor name
4113 * @param {string} botId - The bot instance ID
4114 * @param {string} visitorName - The visitor's name to insert
4115 */
4116 function replaceVisitorNamePlaceholder(botId, visitorName) {
4117 var chatBox = getElementDOM(botId, 'chat-box');
4118 if (!chatBox) return;
4119
4120 // Find the greeting by its marker, not by position (plan a1a79b) —
4121 // after a persistence restore the first .bot-message is a restored
4122 // reply, and {visitor_name} was being substituted into that instead.
4123 // Positional fallback for HTML cached before this release only.
4124 var introMessage = chatBox.querySelector('.mxchat-intro-message') || chatBox.querySelector('.bot-message');
4125 if (!introMessage) return;
4126
4127 var messageContent = introMessage.querySelector('div[dir="auto"]');
4128 if (!messageContent) return;
4129
4130 var html = messageContent.innerHTML;
4131
4132 // Replace {visitor_name} placeholder (case-insensitive)
4133 if (visitorName && visitorName.trim()) {
4134 // Escape HTML to prevent XSS
4135 var safeName = $('<div>').text(visitorName.trim()).html();
4136 html = html.replace(/\{visitor_name\}/gi, safeName);
4137 } else {
4138 // Remove placeholder and clean up spacing if no name provided
4139 html = html.replace(/\{visitor_name\}/gi, '');
4140 // Clean up any double spaces that might result
4141 html = html.replace(/\s{2,}/g, ' ').trim();
4142 }
4143
4144 messageContent.innerHTML = html;
4145 }
4146
4147 function setEmailSubmissionState(botId, loading) {
4148 var submitButton = getElementDOM(botId, 'email-submit-button');
4149 var emailInput = getElementDOM(botId, 'user-email');
4150 var nameInput = getElementDOM(botId, 'user-name');
4151
4152 if (loading) {
4153 emailSubmittingState[botId] = true;
4154 if (submitButton) submitButton.disabled = true;
4155 if (emailInput) emailInput.disabled = true;
4156 if (nameInput) nameInput.disabled = true;
4157
4158 if (submitButton && !submitButton.getAttribute('data-original-html')) {
4159 submitButton.setAttribute('data-original-html', submitButton.innerHTML);
4160 const originalText = submitButton.textContent;
4161 submitButton.innerHTML = `
4162 <svg class="email-spinner" style="width: 16px; height: 16px; margin-right: 8px; animation: spin 1s linear infinite;" viewBox="0 0 24 24">
4163 <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" stroke-dasharray="31.416" stroke-dashoffset="31.416">
4164 <animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416" repeatCount="indefinite"/>
4165 <animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416" repeatCount="indefinite"/>
4166 </circle>
4167 </svg>
4168 ${originalText}
4169 `;
4170 submitButton.style.opacity = '0.8';
4171 }
4172 } else {
4173 emailSubmittingState[botId] = false;
4174 if (submitButton) submitButton.disabled = false;
4175 if (emailInput) emailInput.disabled = false;
4176 if (nameInput) nameInput.disabled = false;
4177
4178 if (submitButton) {
4179 const originalHtml = submitButton.getAttribute('data-original-html');
4180 if (originalHtml) {
4181 submitButton.innerHTML = originalHtml;
4182 }
4183 submitButton.style.opacity = '1';
4184 }
4185 }
4186 }
4187
4188 function showEmailError(botId, message) {
4189 clearEmailError(botId);
4190
4191 var emailForm = getElementDOM(botId, 'email-collection-form');
4192 if (!emailForm) return;
4193
4194 const errorDiv = document.createElement('div');
4195 errorDiv.className = 'email-error';
4196 errorDiv.style.cssText = `
4197 color: #e74c3c;
4198 font-size: 12px;
4199 margin-top: 8px;
4200 padding: 4px 0;
4201 animation: fadeInError 0.3s ease;
4202 `;
4203 errorDiv.textContent = message;
4204 emailForm.appendChild(errorDiv);
4205
4206 // Add shake animation to inputs
4207 var emailInput = getElementDOM(botId, 'user-email');
4208 var nameInput = getElementDOM(botId, 'user-name');
4209
4210 if (emailInput) {
4211 emailInput.classList.add('email-input-shake');
4212 setTimeout(() => emailInput.classList.remove('email-input-shake'), 500);
4213 }
4214 if (nameInput) {
4215 nameInput.classList.add('email-input-shake');
4216 setTimeout(() => nameInput.classList.remove('email-input-shake'), 500);
4217 }
4218 }
4219
4220 function clearEmailError(botId) {
4221 var emailForm = getElementDOM(botId, 'email-collection-form');
4222 if (emailForm) {
4223 const existingErrors = emailForm.querySelectorAll('.email-error');
4224 existingErrors.forEach(error => error.remove());
4225 }
4226 }
4227
4228 // Resolve email state using server-side data when available, AJAX fallback otherwise
4229 function resolveEmailState(botId) {
4230 if (mxchatChat.skip_email_check && mxchatChat.initial_email_state) {
4231 if (mxchatChat.initial_email_state.show_email_form) {
4232 showEmailFormForBot(botId);
4233 } else {
4234 showChatContainerForBot(botId);
4235 }
4236 } else {
4237 checkSessionAndEmailForBot(botId);
4238 }
4239 }
4240
4241 function checkSessionAndEmailForBot(botId) {
4242 const sessionId = MxChatInstances.ensureSession(botId);
4243
4244 // Hide both panels while we check — show loader instead
4245 var emailBlocker = getElementDOM(botId, 'email-blocker');
4246 var chatContainer = getElementDOM(botId, 'chat-container');
4247 if (emailBlocker) emailBlocker.style.display = 'none';
4248 if (chatContainer) chatContainer.style.display = 'none';
4249 showInitLoader(botId);
4250
4251 fetch(mxchatChat.ajax_url, {
4252 method: 'POST',
4253 headers: {
4254 'Content-Type': 'application/x-www-form-urlencoded',
4255 },
4256 body: new URLSearchParams({
4257 action: 'mxchat_check_email_provided',
4258 session_id: sessionId,
4259 nonce: mxchatChat.nonce,
4260 })
4261 })
4262 .then((response) => {
4263 if (!response.ok) {
4264 throw new Error(`HTTP error! status: ${response.status}`);
4265 }
4266 return response.json();
4267 })
4268 .then((data) => {
4269 if (data.success) {
4270 // A success response means the server has already decided the
4271 // required info is on file. With email optional (c0cfaf) that
4272 // info can be a name and no address, so keying on data.email
4273 // alone re-prompted a name-only visitor on every page load.
4274 if (data.data.logged_in || data.data.email || data.data.name) {
4275 showChatContainerForBot(botId);
4276 } else {
4277 showEmailFormForBot(botId);
4278 }
4279 } else {
4280 // Pre-fill for a signed-in visitor being shown the form
4281 // (c0cfaf). It arrives on this nocache'd response rather than
4282 // in the page HTML, which is cacheable. Never overwrite what
4283 // the visitor has already typed.
4284 if (data.data) {
4285 prefillEmailForm(botId, data.data.prefill_name, data.data.prefill_email);
4286 }
4287 showEmailFormForBot(botId);
4288 }
4289 })
4290 .catch((error) => {
4291 showEmailFormForBot(botId);
4292 });
4293 }
4294
4295 function prefillEmailForm(botId, name, email) {
4296 var nameInput = getElementDOM(botId, 'user-name');
4297 var emailInput = getElementDOM(botId, 'user-email');
4298 if (nameInput && name && !nameInput.value) {
4299 nameInput.value = name;
4300 }
4301 if (emailInput && email && !emailInput.value) {
4302 emailInput.value = email;
4303 }
4304 }
4305
4306 // Event delegation for email form submission
4307 $(document).on('submit', '.email-collection-form', function(e) {
4308 e.preventDefault();
4309 e.stopPropagation();
4310
4311 var botId = getBotIdFromElement(this);
4312
4313 // Prevent double submission
4314 if (emailSubmittingState[botId]) {
4315 return false;
4316 }
4317
4318 var emailInput = getElementDOM(botId, 'user-email');
4319 var nameInput = getElementDOM(botId, 'user-name');
4320 var consentInput = getElementDOM(botId, 'user-consent');
4321 var userEmail = emailInput ? emailInput.value.trim() : '';
4322 var userName = nameInput ? nameInput.value.trim() : '';
4323 var sessionId = MxChatInstances.ensureSession(botId);
4324
4325 // Validate email. The address is optional when the owner turned
4326 // "Require Email Address" off (c0cfaf) — but anything actually typed
4327 // still has to be a valid address. The server enforces both rules
4328 // independently.
4329 var emailRequired = (mxchatChat.lead_capture_require_email || 'on') !== 'off';
4330
4331 if (emailRequired && !userEmail) {
4332 showEmailError(botId, 'Please enter your email address.');
4333 return false;
4334 }
4335
4336 if (userEmail && !isValidEmailAddress(userEmail)) {
4337 showEmailError(botId, 'Please enter a valid email address.');
4338 return false;
4339 }
4340
4341 if (!emailRequired && !userEmail && !userName) {
4342 showEmailError(botId, 'Please enter your name or email address.');
4343 return false;
4344 }
4345
4346 // Validate name if field exists and has content
4347 if (nameInput && userName && !isValidNameInput(userName)) {
4348 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
4349 return false;
4350 }
4351
4352 // Consent checkbox (b062c4): backstop behind the native required
4353 // attribute; the server enforces this independently either way.
4354 if (consentInput && consentInput.required && !consentInput.checked) {
4355 showEmailError(botId, 'Please tick the consent box to continue.');
4356 return false;
4357 }
4358
4359 clearEmailError(botId);
4360 setEmailSubmissionState(botId, true);
4361
4362 // Prepare form data
4363 const formData = new URLSearchParams({
4364 action: 'mxchat_handle_save_email_and_response',
4365 email: userEmail,
4366 session_id: sessionId,
4367 nonce: mxchatChat.nonce,
4368 });
4369
4370 if (userName) {
4371 formData.append('name', userName);
4372 }
4373
4374 // Ticked/unticked both travel when the checkbox is rendered, so an
4375 // optional-consent "no" is recorded as a decision, not an absence.
4376 if (consentInput) {
4377 formData.append('consent', consentInput.checked ? '1' : '0');
4378 }
4379
4380 fetch(mxchatChat.ajax_url, {
4381 method: 'POST',
4382 headers: {
4383 'Content-Type': 'application/x-www-form-urlencoded',
4384 },
4385 body: formData
4386 })
4387 .then((response) => {
4388 if (!response.ok) {
4389 throw new Error(`HTTP error! status: ${response.status}`);
4390 }
4391 return response.json();
4392 })
4393 .then((data) => {
4394 setEmailSubmissionState(botId, false);
4395
4396 if (data.success) {
4397 showChatContainerForBot(botId);
4398
4399 // Replace {visitor_name} placeholder in intro message with actual name
4400 if (userName) {
4401 replaceVisitorNamePlaceholder(botId, userName);
4402 } else {
4403 // Remove placeholder if no name provided
4404 replaceVisitorNamePlaceholder(botId, '');
4405 }
4406
4407 if (data.message && typeof appendMessage === 'function') {
4408 setTimeout(() => {
4409 appendMessage('bot', data.message, '', [], false, botId);
4410 if (typeof scrollToBottom === 'function') {
4411 scrollToBottom(botId);
4412 }
4413 }, 100);
4414 }
4415 } else {
4416 showEmailError(botId, data.message || 'Failed to save email. Please try again.');
4417 }
4418 })
4419 .catch((error) => {
4420 setEmailSubmissionState(botId, false);
4421 showEmailError(botId, 'An error occurred. Please try again.');
4422 });
4423
4424 return false;
4425 });
4426
4427 // Real-time email validation using event delegation
4428 $(document).on('input', '.mxchat-email-input', function() {
4429 var botId = getBotIdFromElement(this);
4430 var $input = $(this);
4431
4432 // Clear previous timeout
4433 clearTimeout($input.data('validationTimeout'));
4434
4435 // Debounce validation
4436 var timeout = setTimeout(() => {
4437 var email = this.value.trim();
4438 clearEmailError(botId);
4439
4440 if (email && !isValidEmailAddress(email)) {
4441 showEmailError(botId, 'Please enter a valid email address.');
4442 }
4443 }, 500);
4444
4445 $input.data('validationTimeout', timeout);
4446 });
4447
4448 // Handle Enter key in email input
4449 $(document).on('keypress', '.mxchat-email-input', function(e) {
4450 if (e.key === 'Enter') {
4451 e.preventDefault();
4452 var botId = getBotIdFromElement(this);
4453 if (!emailSubmittingState[botId]) {
4454 $(this).closest('.email-collection-form').submit();
4455 }
4456 }
4457 });
4458
4459 // Handle Enter key in name input
4460 $(document).on('keypress', '.mxchat-name-input', function(e) {
4461 if (e.key === 'Enter') {
4462 e.preventDefault();
4463 var botId = getBotIdFromElement(this);
4464 if (!emailSubmittingState[botId]) {
4465 $(this).closest('.email-collection-form').submit();
4466 }
4467 }
4468 });
4469
4470 // Initialize email check for all bot instances
4471 // For floating bots: defer until widget is opened (zero passive AJAX)
4472 // For embedded bots: check immediately since the form is visible
4473 $('.mxchat-chatbot-wrapper').each(function() {
4474 var botId = $(this).data('bot-id') || 'default';
4475 var emailBlocker = getElementDOM(botId, 'email-blocker');
4476
4477 if (emailBlocker) {
4478 if (isEmbeddedBot(botId)) {
4479 // Embedded bots are always visible — check now
4480 resolveEmailState(botId);
4481 }
4482 // Floating bots: handled in the widget open handler
4483 } else if (isEmbeddedBot(botId)) {
4484 // Embedded bot, no email collection — load history with loader
4485 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4486 if (chatPersistenceEnabled) {
4487 MxChatInstances.ensureSession(botId);
4488 showChatContainerForBot(botId);
4489 }
4490 }
4491 });
4492 }
4493
4494 // Open chatbot when pre-chat message is clicked - use class selector for multi-instance
4495 $(document).on('click', '.pre-chat-message', function() {
4496 var botId = getBotIdFromElement(this);
4497 var $chatbot = getElement(botId, 'floating-chatbot');
4498 if ($chatbot.hasClass('hidden')) {
4499 $chatbot.removeClass('hidden').addClass('visible');
4500 getElement(botId, 'floating-chatbot-button').addClass('hidden');
4501 handlePreChatDismissal(botId);
4502 disableScroll(); // Disable scroll when chatbot opens
4503 mxchatFireOpened(botId);
4504
4505 // Load chat history for returning visitors (persistence)
4506 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4507 if (chatPersistenceEnabled) {
4508 MxChatInstances.ensureSession(botId);
4509 }
4510
4511 // Deferred email check — only on first widget open
4512 var emailBlocker = getElementDOM(botId, 'email-blocker');
4513 var instance = MxChatInstances.get(botId);
4514 if (emailBlocker && !instance.emailCheckDone) {
4515 instance.emailCheckDone = true;
4516 resolveEmailState(botId);
4517 } else if (!emailBlocker) {
4518 showChatContainerForBot(botId);
4519 }
4520 }
4521 });
4522
4523 // Legacy duplicate close handler removed — handled by single event delegation above
4524
4525
4526 function hasQuickQuestions(botId) {
4527 botId = botId || 'default';
4528 var questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
4529 if (!questionsContainer) return false;
4530 const questionButtons = questionsContainer.querySelectorAll('.mxchat-popular-question');
4531 return questionButtons.length > 0;
4532 }
4533
4534 /**
4535 * Check if a bot is embedded (not floating)
4536 * Embedded bots don't have a .floating-chatbot wrapper
4537 */
4538 function isEmbeddedBot(botId) {
4539 botId = botId || 'default';
4540 var floatingWrapper = document.getElementById('floating-chatbot-' + botId);
4541 return !floatingWrapper;
4542 }
4543
4544 function collapseQuickQuestions(botId) {
4545 botId = botId || 'default';
4546 const questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
4547 if (questionsContainer && hasQuickQuestions(botId)) {
4548 questionsContainer.classList.add('collapsed');
4549 questionsContainer.classList.add('has-been-collapsed');
4550 try {
4551 sessionStorage.setItem('mxchat_questions_collapsed_' + botId, 'true');
4552 sessionStorage.setItem('mxchat_questions_has_been_collapsed_' + botId, 'true');
4553 } catch (e) {
4554 // Ignore if sessionStorage is not available
4555 }
4556 }
4557 }
4558
4559 function expandQuickQuestions(botId) {
4560 botId = botId || 'default';
4561 const questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
4562 if (questionsContainer && hasQuickQuestions(botId)) {
4563 questionsContainer.classList.remove('collapsed');
4564 try {
4565 sessionStorage.setItem('mxchat_questions_collapsed_' + botId, 'false');
4566 } catch (e) {
4567 // Ignore if sessionStorage is not available
4568 }
4569 }
4570 }
4571
4572 function checkQuickQuestionsState(botId) {
4573 botId = botId || 'default';
4574 if (!hasQuickQuestions(botId)) {
4575 return; // Don't do anything if no questions exist
4576 }
4577
4578 // Skip restoring collapsed state for embedded bots - they should always start expanded
4579 if (isEmbeddedBot(botId)) {
4580 return;
4581 }
4582
4583 try {
4584 const isCollapsed = sessionStorage.getItem('mxchat_questions_collapsed_' + botId);
4585 const hasBeenCollapsed = sessionStorage.getItem('mxchat_questions_has_been_collapsed_' + botId);
4586
4587 const questionsContainer = getElementDOM(botId, 'mxchat-popular-questions');
4588 if (questionsContainer) {
4589 if (hasBeenCollapsed === 'true') {
4590 questionsContainer.classList.add('has-been-collapsed');
4591 }
4592 if (isCollapsed === 'true') {
4593 questionsContainer.classList.add('collapsed');
4594 }
4595 }
4596 } catch (e) {
4597 // Ignore if sessionStorage is not available
4598 }
4599 }
4600
4601 // Global delegation for dynamically added links as fallback
4602 // Use class selector for multi-instance support
4603 $(document).on('click', '.chat-box a[href]:not([data-tracked])', function(e) {
4604 const $link = $(this);
4605 const messageDiv = $link.closest('.bot-message, .agent-message');
4606
4607 // Only process bot/agent message links
4608 if (messageDiv.length > 0) {
4609 const originalHref = $link.attr('href');
4610
4611 if (originalHref && (originalHref.startsWith('http://') || originalHref.startsWith('https://'))) {
4612 // Get bot ID from the chat box context
4613 var botId = getBotIdFromElement(this);
4614
4615 // Get message context from the message div
4616 const messageText = messageDiv.text().substring(0, 200);
4617
4618 // Same path as attachLinkTracking(): set the target, record the
4619 // click, and let the browser follow the anchor.
4620 prepareTrackedLink($link);
4621 trackLinkClick(originalHref, messageText, botId);
4622 }
4623 }
4624 });
4625
4626 // ====================================
4627 // MAIN INITIALIZATION
4628 // ====================================
4629
4630 // Initialize all chatbot instances on the page
4631 initializeAllInstances();
4632
4633 // Legacy initialization for single bot compatibility
4634 $('.floating-chatbot.hidden').each(function() {
4635 var botId = getBotIdFromElement(this);
4636 getElement(botId, 'floating-chatbot-button').removeClass('hidden');
4637 });
4638
4639 // Initialize when document is ready
4640 setFullHeight();
4641
4642 // Note: trackOriginatingPage() and loadChatHistory() are now deferred
4643 // until the user's first interaction via MxChatInstances.ensureSession()
4644
4645 // Initialize chat visibility for all instances
4646 $('.mxchat-chatbot-wrapper').each(function() {
4647 var botId = $(this).data('bot-id') || 'default';
4648 initializeChatVisibility(botId);
4649 // An embedded widget is on screen from the first paint; a floating one
4650 // reports mxchat:opened when its window is shown (plan 65c9b6).
4651 if (isEmbeddedBot(botId)) {
4652 mxchatFireOpened(botId);
4653 } else {
4654 mxchatWatchForOpen(botId);
4655 }
4656 });
4657
4658 // Make functions globally available for add-ons
4659 window.hasQuickQuestions = hasQuickQuestions;
4660 window.collapseQuickQuestions = collapseQuickQuestions;
4661 window.appendMessage = appendMessage;
4662 window.appendThinkingMessage = appendThinkingMessage;
4663 window.scrollToBottom = scrollToBottom;
4664 window.scrollElementToTop = scrollElementToTop;
4665 window.replaceLastMessage = replaceLastMessage;
4666 window.callMxChat = callMxChat;
4667 window.callMxChatStream = callMxChatStream;
4668 window.shouldUseStreaming = shouldUseStreaming;
4669 window.getChatSession = getChatSession;
4670 window.getPageContext = getPageContext;
4671 window.updateStreamingMessage = updateStreamingMessage;
4672 window.MxChatInstances = MxChatInstances;
4673 window.getElement = getElement;
4674 window.getElementDOM = getElementDOM;
4675 window.getBotIdFromElement = getBotIdFromElement;
4676
4677 }); // End of jQuery ready
4678
4679
4680 // ====================================
4681 // GLOBAL EVENT LISTENERS (Outside jQuery)
4682 // ====================================
4683
4684 // Event listener for copy button (code blocks)
4685 document.addEventListener("click", (e) => {
4686 if (e.target.classList.contains("mxchat-copy-button")) {
4687 const copyButton = e.target;
4688 const codeBlock = copyButton
4689 .closest(".mxchat-code-block-container")
4690 .querySelector(".mxchat-code-block code");
4691
4692 if (codeBlock) {
4693 // Preserve formatting using innerText
4694 navigator.clipboard.writeText(codeBlock.innerText).then(() => {
4695 copyButton.textContent = "Copied!";
4696 copyButton.setAttribute("aria-label", "Copied to clipboard");
4697
4698 setTimeout(() => {
4699 copyButton.textContent = "Copy";
4700 copyButton.setAttribute("aria-label", "Copy to clipboard");
4701 }, 2000);
4702 });
4703 }
4704 }
4705 });
4706
4707 // ============================================================================
4708 // SATISFACTION RATING (v3.2.6)
4709 // ============================================================================
4710 // Per-session 👍/👎 prompt that appears in the chat-box after 60s of user
4711 // inactivity following a bot reply. One prompt per session, deduped via
4712 // localStorage. Runs ONLY when the satisfaction_rating_enabled option is on —
4713 // the option (default off) is authoritative.
4714 jQuery(function($) {
4715 if (typeof mxchatChat === 'undefined') return;
4716 // wp_localize_script stringifies scalars: a PHP boolean false arrives as
4717 // '' and true as '1', so this must be an explicit-enable allowlist — the
4718 // old "disabled when exactly false/'off'" check let '' through and the
4719 // bubble rendered on sites with the option off/unset (plan-4bba64). PHP
4720 // now emits 'on'/'off' strings; true/'1'/1 keep cached pre-fix HTML
4721 // (boolean-true localizations) working.
4722 // NOTE (plan-32db95): this gate reads the INLINE value at DOM ready and is
4723 // deliberately NOT re-evaluated after the widget's dynamic-settings refresh
4724 // merges fresh values over mxchatChat (that merge fires on first widget
4725 // open, after this module has already decided). Re-evaluating would mean
4726 // restructuring the whole module to late-bind its listeners — not worth it
4727 // for a prompt that is at worst stale for one page load on a cached page.
4728 var sre = mxchatChat.satisfaction_rating_enabled;
4729 if (sre !== 'on' && sre !== true && sre !== '1' && sre !== 1) return;
4730
4731 // wp_localize_script stringifies ints, so accept both number and numeric string.
4732 var idleRaw = mxchatChat.satisfaction_rating_idle_seconds;
4733 var idleSeconds = (typeof idleRaw === 'number') ? idleRaw : parseInt(idleRaw, 10);
4734 if (!isFinite(idleSeconds)) idleSeconds = 60;
4735 if (idleSeconds < 5) idleSeconds = 5;
4736 if (idleSeconds > 600) idleSeconds = 600;
4737 var IDLE_MS = idleSeconds * 1000;
4738 var MIN_BOT_REPLIES = 2;
4739 var ratingState = {};
4740
4741 function getState(botId) {
4742 if (!ratingState[botId]) {
4743 ratingState[botId] = { idleTimer: null, botReplies: 0, promptShown: false, dismissed: false };
4744 }
4745 return ratingState[botId];
4746 }
4747
4748 function getSessionId(botId) {
4749 if (typeof MxChatInstances !== 'undefined' && MxChatInstances.getChatSession) {
4750 return MxChatInstances.getChatSession(botId);
4751 }
4752 return null;
4753 }
4754
4755 function isAlreadyRated(sessionId) {
4756 if (!sessionId) return false;
4757 try { return localStorage.getItem('mxchat_rated:' + sessionId) === '1'; } catch (e) { return false; }
4758 }
4759
4760 function markRated(sessionId) {
4761 if (!sessionId) return;
4762 try { localStorage.setItem('mxchat_rated:' + sessionId, '1'); } catch (e) {}
4763 }
4764
4765 function esc(s) {
4766 return String(s == null ? '' : s)
4767 .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
4768 .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
4769 }
4770
4771 // Mirror shouldSkipInlineColors so rating bubbles defer to AI-theme CSS.
4772 function ratingSkipInlineColors(botId) {
4773 if (mxchatChat.skip_inline_colors) return true;
4774 var botAssignments = mxchatChat.bot_theme_assignments || {};
4775 return botAssignments.hasOwnProperty(botId);
4776 }
4777
4778 function botBubbleStyleAttr(botId) {
4779 if (ratingSkipInlineColors(botId)) return '';
4780 var bg = mxchatChat.bot_message_bg_color;
4781 var fg = mxchatChat.bot_message_font_color;
4782 if (!bg && !fg) return '';
4783 return ' style="background-color: ' + esc(bg || '') + '; color: ' + esc(fg || '') + ';"';
4784 }
4785
4786 // Reads the rating bubble's actual computed fg+bg (whatever paints it —
4787 // the inline color pickers OR the mxchat-theme AI customizer's injected CSS)
4788 // and paints the filled "Send" pill so it fills with the bot font color and
4789 // labels in the bubble bg. Mirrors mxchatSyncMenuColors(~:1512) for the read.
4790 // We paint the submit button DIRECTLY (inline longhand) rather than relying
4791 // on the CSS rule's var()s: Chromium resolves an INHERITED custom property
4792 // unreliably inside a descendant's `background`, so a bubble-level var would
4793 // silently fall back to the literal (white-block bug all over again). Inline
4794 // longhand always wins. Same transparent-guard as the menu so we never paint
4795 // a see-through value — in that case the CSS literal fallbacks keep it legible.
4796 function syncRatingBubbleColors(botId) {
4797 var $chatBox = getChatBoxByBotId(botId);
4798 if (!$chatBox || !$chatBox.length) return;
4799 var bubbleEl = $chatBox.find('.mxchat-rating-bot-bubble').last()[0];
4800 if (!bubbleEl) return;
4801 var cs = window.getComputedStyle(bubbleEl);
4802 var fg = cs.color;
4803 var bg = cs.backgroundColor;
4804 var hasFg = fg && fg !== 'rgba(0, 0, 0, 0)' && fg !== 'transparent';
4805 var hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
4806 // Expose on the bubble too, for any inheriting styles / future use.
4807 if (hasFg) bubbleEl.style.setProperty('--mxchat-bot-fg', fg);
4808 if (hasBg) bubbleEl.style.setProperty('--mxchat-bot-bg', bg);
4809 // Paint the Send pill directly — the part that actually fixes the bug.
4810 var submitEl = bubbleEl.querySelector('.mxchat-rating-submit');
4811 if (submitEl) {
4812 if (hasFg) submitEl.style.backgroundColor = fg; // fill = bot font color
4813 if (hasBg) submitEl.style.color = bg; // label = bubble background
4814 }
4815 }
4816
4817 function copy(key) {
4818 var c = mxchatChat.satisfaction_rating_copy || {};
4819 var d = {
4820 question: 'Was this helpful?',
4821 helpful: 'Helpful',
4822 not_helpful: 'Not helpful',
4823 dismiss: 'Dismiss',
4824 thanks: 'Thanks! Anything we should improve? (optional)',
4825 placeholder: 'Tell us what could be better…',
4826 send: 'Send',
4827 skip: 'Skip',
4828 saved: 'Thanks for the feedback.'
4829 };
4830 return c[key] || d[key];
4831 }
4832
4833 function thumbUpSvg() {
4834 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>';
4835 }
4836 function thumbDownSvg() {
4837 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>';
4838 }
4839
4840 function buildPromptHtml(botId) {
4841 var styleAttr = botBubbleStyleAttr(botId);
4842 return ''
4843 + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4844 + '<div class="mxchat-rating-prompt" data-bot-id="' + esc(botId) + '" role="group" aria-label="' + esc(copy('question')) + '">'
4845 + '<div class="mxchat-rating-question">' + esc(copy('question')) + '</div>'
4846 + '<div class="mxchat-rating-actions">'
4847 + '<span class="mxchat-rating-buttons">'
4848 + '<button type="button" class="mxchat-rating-btn" data-rating="1" aria-label="' + esc(copy('helpful')) + '">' + thumbUpSvg() + '</button>'
4849 + '<button type="button" class="mxchat-rating-btn" data-rating="-1" aria-label="' + esc(copy('not_helpful')) + '">' + thumbDownSvg() + '</button>'
4850 + '</span>'
4851 + '<button type="button" class="mxchat-rating-dismiss" aria-label="' + esc(copy('dismiss')) + '">×</button>'
4852 + '</div>'
4853 + '</div>'
4854 + '</div>';
4855 }
4856
4857 function buildFeedbackHtml(botId, rating) {
4858 var styleAttr = botBubbleStyleAttr(botId);
4859 return ''
4860 + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4861 + '<div class="mxchat-rating-feedback" data-bot-id="' + esc(botId) + '" data-rating="' + esc(String(rating)) + '">'
4862 + '<div class="mxchat-rating-feedback-label">' + esc(copy('thanks')) + '</div>'
4863 + '<textarea class="mxchat-rating-feedback-input" maxlength="500" placeholder="' + esc(copy('placeholder')) + '" rows="2"></textarea>'
4864 + '<div class="mxchat-rating-feedback-actions">'
4865 + '<button type="button" class="mxchat-rating-skip">' + esc(copy('skip')) + '</button>'
4866 + '<button type="button" class="mxchat-rating-submit">' + esc(copy('send')) + '</button>'
4867 + '</div>'
4868 + '</div>'
4869 + '</div>';
4870 }
4871
4872 function buildSavedHtml(botId) {
4873 var styleAttr = botBubbleStyleAttr(botId);
4874 return ''
4875 + '<div class="bot-message mxchat-rating-bot-bubble"' + styleAttr + '>'
4876 + '<div class="mxchat-rating-saved">' + esc(copy('saved')) + '</div>'
4877 + '</div>';
4878 }
4879
4880 function getChatBoxByBotId(botId) {
4881 var $byId = $('#chat-box-' + botId);
4882 if ($byId.length) return $byId.first();
4883 return $('.chat-box').first();
4884 }
4885
4886 function scrollChatBoxToBottom($chatBox) {
4887 if (!$chatBox || !$chatBox.length) return;
4888 $chatBox.scrollTop($chatBox[0].scrollHeight);
4889 }
4890
4891 function showPrompt(botId) {
4892 var s = getState(botId);
4893 if (s.promptShown || s.dismissed) return;
4894 var sessionId = getSessionId(botId);
4895 if (!sessionId) return;
4896 if (isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4897 var $chatBox = getChatBoxByBotId(botId);
4898 if (!$chatBox.length) return;
4899 if ($chatBox.find('.mxchat-rating-prompt').length) { s.promptShown = true; return; }
4900 $chatBox.append(buildPromptHtml(botId));
4901 syncRatingBubbleColors(botId);
4902 s.promptShown = true;
4903 scrollChatBoxToBottom($chatBox);
4904 }
4905
4906 function submitRating(botId, rating, feedback) {
4907 var sessionId = getSessionId(botId);
4908 if (!sessionId) return;
4909 $.post(mxchatChat.ajax_url, {
4910 action: 'mxchat_save_rating',
4911 session_id: sessionId,
4912 bot_id: botId,
4913 rating: rating,
4914 feedback: feedback || ''
4915 });
4916 markRated(sessionId);
4917 }
4918
4919 function onBotReply(botId) {
4920 var s = getState(botId);
4921 s.botReplies += 1;
4922 if (s.promptShown || s.dismissed) return;
4923 var sessionId = getSessionId(botId);
4924 if (sessionId && isAlreadyRated(sessionId)) { s.promptShown = true; return; }
4925 if (s.botReplies < MIN_BOT_REPLIES) return;
4926 if (s.idleTimer) clearTimeout(s.idleTimer);
4927 s.idleTimer = setTimeout(function() { showPrompt(botId); }, IDLE_MS);
4928 }
4929
4930 function onUserMessage(botId) {
4931 var s = getState(botId);
4932 if (s.idleTimer) { clearTimeout(s.idleTimer); s.idleTimer = null; }
4933 }
4934
4935 function botIdFromChatBox(el) {
4936 var id = el && el.id ? el.id : '';
4937 return id.indexOf('chat-box-') === 0 ? id.substring('chat-box-'.length) : 'default';
4938 }
4939
4940 function setupObserver(chatBox) {
4941 var botId = botIdFromChatBox(chatBox);
4942 try {
4943 var observer = new MutationObserver(function(mutations) {
4944 mutations.forEach(function(m) {
4945 for (var i = 0; i < m.addedNodes.length; i++) {
4946 var node = m.addedNodes[i];
4947 if (!node || node.nodeType !== 1) continue;
4948 var $n = $(node);
4949 if ($n.hasClass('mxchat-rating-bot-bubble') || $n.hasClass('mxchat-rating-prompt') || $n.hasClass('mxchat-rating-feedback') || $n.hasClass('mxchat-rating-saved')) continue;
4950 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)
4951 else if ($n.hasClass('user-message')) onUserMessage(botId);
4952 }
4953 });
4954 });
4955 observer.observe(chatBox, { childList: true });
4956 } catch (e) { /* noop */ }
4957 }
4958
4959 $('.chat-box').each(function() { setupObserver(this); });
4960
4961 $(document).on('click', '.mxchat-rating-btn', function(e) {
4962 e.preventDefault();
4963 var $btn = $(this);
4964 var $prompt = $btn.closest('.mxchat-rating-prompt');
4965 var $wrap = $btn.closest('.mxchat-rating-bot-bubble');
4966 var botId = $prompt.data('bot-id') || 'default';
4967 var rating = parseInt($btn.attr('data-rating'), 10);
4968 if (rating !== 1 && rating !== -1) return;
4969 submitRating(botId, rating, '');
4970 ($wrap.length ? $wrap : $prompt).replaceWith(buildFeedbackHtml(botId, rating));
4971 syncRatingBubbleColors(botId);
4972 scrollChatBoxToBottom(getChatBoxByBotId(botId));
4973 });
4974
4975 $(document).on('click', '.mxchat-rating-dismiss', function(e) {
4976 e.preventDefault();
4977 var $prompt = $(this).closest('.mxchat-rating-prompt');
4978 var $wrap = $(this).closest('.mxchat-rating-bot-bubble');
4979 var botId = $prompt.data('bot-id') || 'default';
4980 var s = getState(botId);
4981 s.dismissed = true;
4982 markRated(getSessionId(botId));
4983 ($wrap.length ? $wrap : $prompt).remove();
4984 });
4985
4986 function closeFeedback($fb) {
4987 var botId = $fb.data('bot-id') || 'default';
4988 var $wrap = $fb.closest('.mxchat-rating-bot-bubble');
4989 ($wrap.length ? $wrap : $fb).replaceWith(buildSavedHtml(botId));
4990 syncRatingBubbleColors(botId);
4991 scrollChatBoxToBottom(getChatBoxByBotId(botId));
4992 }
4993
4994 $(document).on('click', '.mxchat-rating-skip', function(e) {
4995 e.preventDefault();
4996 closeFeedback($(this).closest('.mxchat-rating-feedback'));
4997 });
4998
4999 $(document).on('click', '.mxchat-rating-submit', function(e) {
5000 e.preventDefault();
5001 var $fb = $(this).closest('.mxchat-rating-feedback');
5002 var botId = $fb.data('bot-id') || 'default';
5003 var rating = parseInt($fb.attr('data-rating'), 10);
5004 if (rating !== 1 && rating !== -1) { closeFeedback($fb); return; }
5005 var text = String($fb.find('.mxchat-rating-feedback-input').val() || '').trim();
5006 if (text !== '') {
5007 submitRating(botId, rating, text);
5008 }
5009 closeFeedback($fb);
5010 });
5011 });
5012
5013