PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.11
MxChat – AI Chatbot & Content Generation for WordPress v3.2.11
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
mxchat-basic / js / chat-script.js

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

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