PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.23
MxChat – AI Chatbot & Content Generation for WordPress v3.2.23
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
← All changes | js/chat-script.js +270 -63 3.2.21 → 3.2.23 View file →
@@ -167,8 +167,16 @@
167 167 // Instance registry - tracks all chatbot instances on the page
168 168 const MxChatInstances = {
169 169 instances: {},
170 170
171 + // Lifecycle facts add-ons can read after the fact (plan 65c9b6). The
172 + // events below fire once; a script that loads late checks these instead.
173 + toolbarReady: false,
174 + opened: {},
175 + hasOpened: function(botId) {
176 + return !!this.opened[botId || 'default'];
177 + },
178 +
171 179 // Initialize an instance for a bot
172 180 init: function(botId) {
173 181 if (!this.instances[botId]) {
174 182 // When persistence is OFF, track when this session started
@@ -317,8 +325,50 @@
317 325 }
318 326 };
319 327
320 328 // ====================================
329 + // LIFECYCLE EVENTS FOR ADD-ONS (plan 65c9b6)
330 + // ====================================
331 + //
332 + // Document-level CustomEvents an add-on listens for instead of polling the
333 + // DOM or firing requests on page load:
334 + // mxchat:toolbar-ready detail { enabled } once, right after the
335 + // toolbar show/hide pass; items rendered through the
336 + // mxchat_chat_toolbar_items filter can bind now.
337 + // mxchat:opened detail { botId, embedded } once per bot per page
338 + // load, the first time its window is shown — the
339 + // launcher, the pre-chat bubble, an embedded widget
340 + // (at load), or any script that reveals it.
341 + // Same facts, readable late: MxChatInstances.toolbarReady / hasOpened(botId).
342 + function mxchatDispatch(name, detail) {
343 + try {
344 + document.dispatchEvent(new CustomEvent(name, { detail: detail || {} }));
345 + } catch (err) { /* CustomEvent unsupported — nothing to notify */ }
346 + }
347 +
348 + function mxchatFireOpened(botId) {
349 + botId = botId || 'default';
350 + if (MxChatInstances.opened[botId]) return;
351 + MxChatInstances.opened[botId] = true;
352 + mxchatDispatch('mxchat:opened', { botId: botId, embedded: isEmbeddedBot(botId) });
353 + }
354 +
355 + // Catch openers this file does not own (the Trigger add-on, theme buttons,
356 + // custom scripts): a floating window gaining .visible counts as opened.
357 + function mxchatWatchForOpen(botId) {
358 + var el = getElementDOM(botId, 'floating-chatbot');
359 + if (!el || typeof MutationObserver === 'undefined') return;
360 + if (el.classList.contains('visible')) { mxchatFireOpened(botId); return; }
361 + var obs = new MutationObserver(function() {
362 + if (el.classList.contains('visible')) {
363 + mxchatFireOpened(botId);
364 + obs.disconnect();
365 + }
366 + });
367 + obs.observe(el, { attributes: true, attributeFilter: ['class'] });
368 + }
369 +
370 + // ====================================
321 371 // ELEMENT SELECTOR HELPERS
322 372 // ====================================
323 373
324 374 // Check if a specific bot has an AI theme assigned (skip inline colors)
@@ -471,23 +521,28 @@
471 521 // ====================================
472 522 // CONTEXTUAL AWARENESS FUNCTIONALITY
473 523 // ====================================
474 524
525 +// Plan f3e515: the page context is computed once per page, not once per message.
526 +// Keyed on URL + title + the content element's text length — cheap to check,
527 +// and enough to notice a navigation or a re-rendered content area.
528 +var pageContextCache = { key: null, value: null };
529 +
475 530 function getPageContext() {
476 531 // Check if contextual awareness is enabled
477 532 if (mxchatChat.contextual_awareness_toggle !== 'on') {
478 533 return null;
479 534 }
480 -
535 +
481 536 // Get page URL
482 537 const pageUrl = window.location.href;
483 -
538 +
484 539 // Get page title
485 540 const pageTitle = document.title || '';
486 -
541 +
487 542 // Get main content from the page
488 543 let pageContent = '';
489 -
544 +
490 545 // Try to get content from common content areas
491 546 const contentSelectors = [
492 547 'main',
493 548 '[role="main"]',
@@ -499,9 +554,9 @@
499 554 'article',
500 555 '#content',
501 556 '#main'
502 557 ];
503 -
558 +
504 559 let contentElement = null;
505 560 for (const selector of contentSelectors) {
506 561 contentElement = document.querySelector(selector);
507 562 if (contentElement) {
@@ -507,18 +562,31 @@
507 562 if (contentElement) {
508 563 break;
509 564 }
510 565 }
511 -
566 +
512 567 // If no specific content area found, use body but exclude header, footer, nav, sidebar
513 568 if (!contentElement) {
514 569 contentElement = document.body;
515 570 }
516 -
571 +
517 572 if (contentElement) {
518 - // Clone the element to avoid modifying the original
519 - const clone = contentElement.cloneNode(true);
520 -
573 + const cacheKey = pageUrl + '\u0000' + pageTitle + '\u0000' + (contentElement.textContent || '').length;
574 + if (pageContextCache.key === cacheKey) {
575 + return pageContextCache.value;
576 + }
577 +
578 + // Copy the element into a document that has no browsing context
579 + // (plan f3e515). A live cloneNode(true) constructed real <video> /
580 + // <audio> / <img> elements: media with a src runs resource selection
581 + // on creation whether or not it is attached, and an autoplay attribute
582 + // made the detached copy PLAY — audibly, once the visible player had
583 + // been unmuted through the property. Every message spawned another
584 + // one. Elements in an inert document never fetch or play, while
585 + // querySelectorAll / textContent / dataset all still work on them.
586 + const inertDocument = document.implementation.createHTMLDocument('');
587 + const clone = inertDocument.importNode(contentElement, true);
588 +
521 589 // Remove unwanted elements
522 590 const unwantedSelectors = [
523 591 'header',
524 592 'footer',
@@ -536,16 +604,25 @@
536 604 '#floating-chatbot',
537 605 '#floating-chatbot-button',
538 606 '.mxchat',
539 607 '[class*="chat"]',
540 - '[id*="chat"]'
608 + '[id*="chat"]',
609 + // Media contributes no text and (outside the inert document) fetches.
610 + 'video',
611 + 'audio',
612 + 'iframe',
613 + 'object',
614 + 'embed',
615 + 'picture',
616 + 'img',
617 + 'source'
541 618 ];
542 -
619 +
543 620 unwantedSelectors.forEach(selector => {
544 621 const elements = clone.querySelectorAll(selector);
545 622 elements.forEach(el => el.remove());
546 623 });
547 -
624 +
548 625 // Extract MxChat context data attributes before getting text content
549 626 const contextData = [];
550 627 clone.querySelectorAll('[data-mxchat-context]').forEach(el => {
551 628 const contextValue = el.dataset.mxchatContext;
@@ -552,34 +629,34 @@
552 629 if (contextValue && contextValue.trim()) {
553 630 contextData.push(contextValue);
554 631 }
555 632 });
556 -
633 +
557 634 // Get text content and clean it up
558 635 pageContent = clone.textContent || clone.innerText || '';
559 -
636 +
560 637 // Add context data to page content if any were found
561 638 if (contextData.length > 0) {
562 639 pageContent += '\n\nAdditional Context:\n' + contextData.join('\n');
563 640 }
564 -
641 +
565 642 // Clean up whitespace and limit length
566 643 pageContent = pageContent
567 644 .replace(/\s+/g, ' ')
568 645 .trim()
569 646 .substring(0, 3000); // Limit to 3000 characters to avoid token limits
647 +
648 + // Only return context if we have meaningful content
649 + const result = (!pageContent || pageContent.length < 50) ? null : {
650 + url: pageUrl,
651 + title: pageTitle,
652 + content: pageContent
653 + };
654 + pageContextCache = { key: cacheKey, value: result };
655 + return result;
570 656 }
571 -
572 - // Only return context if we have meaningful content
573 - if (!pageContent || pageContent.length < 50) {
574 - return null;
575 - }
576 -
577 - return {
578 - url: pageUrl,
579 - title: pageTitle,
580 - content: pageContent
581 - };
657 +
658 + return null;
582 659 }
583 660
584 661 // Track originating page when chat starts
585 662 function trackOriginatingPage() {
@@ -770,9 +847,12 @@
770 847 if (typeof customMxChatFilter === 'function') {
771 848 message = customMxChatFilter(message, "prompt");
772 849 }
773 850
774 - if (message) {
851 + // Whitespace-only is empty: nothing is sent and nothing is disabled. When
852 + // there IS a message the original string (spaces and all) is what goes out,
853 + // so the transcript is unchanged (plan b67fd5).
854 + if (message && String(message).trim() !== '') {
775 855 // Don't disable input in live agent mode - let users chat freely
776 856 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
777 857 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
778 858 if (!isAgentMode) {
@@ -816,8 +896,14 @@
816 896 if (typeof customMxChatFilter === 'function') {
817 897 message = customMxChatFilter(message, "prompt");
818 898 }
819 899
900 + // Nothing to send means nothing to disable (plan b67fd5): a caller handing
901 + // over an empty string must not lock the box the way Enter-on-empty did.
902 + if (!message || String(message).trim() === '') {
903 + return;
904 + }
905 +
820 906 // Don't disable input in live agent mode - let users chat freely
821 907 var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
822 908 var isAgentMode = modeIndicator && modeIndicator.textContent === 'Live Agent';
823 909 if (!isAgentMode) {
@@ -1676,25 +1762,21 @@
1676 1762 if (this.__mxchatStopJustShown) return;
1677 1763 mxchatStopStreaming(botId);
1678 1764 return;
1679 1765 }
1680 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1681 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1682 - disableChatInput(botId);
1683 - }
1766 + // sendMessage() owns the disable - see the Enter handler below (plan b67fd5).
1684 1767 sendMessage(botId);
1685 1768 });
1686 1769
1687 -// Override enter key handler (using event delegation)
1770 +// Enter sends, Shift+Enter inserts a newline (using event delegation).
1771 +// sendMessage() owns the disable: it greys the box only when a real message is
1772 +// about to go out. Disabling here first left the widget locked for good after
1773 +// Enter on an empty box - the send returned early and no reply path ever
1774 +// re-enabled it (plan b67fd5).
1688 1775 $(document).on('keypress', '.chat-input', function(e) {
1689 1776 if (e.which == 13 && !e.shiftKey) {
1690 1777 e.preventDefault();
1691 - var botId = getBotIdFromElement(this);
1692 - var modeIndicator = getElementDOM(botId, 'chat-mode-indicator');
1693 - if (!(modeIndicator && modeIndicator.textContent === 'Live Agent')) {
1694 - disableChatInput(botId);
1695 - }
1696 - sendMessage(botId);
1778 + sendMessage(getBotIdFromElement(this));
1697 1779 }
1698 1780 });
1699 1781
1700 1782 // Chat input character counter + soft limit feedback (plan 7091a2).
@@ -2260,13 +2342,16 @@
2260 2342
2261 2343 if (images.length > 0) {
2262 2344 fullMessage += '<div class="image-gallery" dir="auto">';
2263 2345 images.forEach(img => {
2346 + const safeTitle = sanitizeUserInput(img.title);
2347 + const safeUrl = escapeAttr(img.image_url);
2348 + const safeThumbnail = escapeAttr(img.thumbnail_url);
2264 2349 fullMessage += `
2265 2350 <div style="margin-bottom: 10px;">
2266 - <strong>${img.title}</strong><br>
2267 - <a href="${img.image_url}" target="_blank">
2268 - <img src="${img.thumbnail_url}" alt="${img.title}" style="max-width: 100px; height: auto; margin: 5px;" />
2351 + <strong>${safeTitle}</strong><br>
2352 + <a href="${safeUrl}" target="_blank">
2353 + <img src="${safeThumbnail}" alt="${safeTitle}" style="max-width: 100px; height: auto; margin: 5px;" />
2269 2354 </a>
2270 2355 </div>`;
2271 2356 });
2272 2357 fullMessage += '</div>';
@@ -2385,27 +2470,42 @@
2385 2470 // Check for % followed by exactly 2 hex digits
2386 2471 return /%[0-9a-fA-F]{2}/.test(url);
2387 2472 }
2388 2473
2389 - // Helper function to safely encode URLs only if needed
2474 + // Helper function to safely encode URLs only if needed, then make the
2475 + // result attribute-safe. "Don't double-encode an already-encoded URL"
2476 + // used to mean "return it untouched" — so a literal quote riding next
2477 + // to a %20 reached href="…" intact and closed the attribute
2478 + // (plan 2db182). Every href this function feeds now goes through
2479 + // escapeAttr(), whether or not the URL was already percent-encoded.
2390 2480 function safeEncodeUrl(url) {
2391 - // If URL already contains encoded characters, return as-is
2392 - if (isUrlEncoded(url)) {
2393 - return url;
2394 - }
2395 - // Otherwise, encode it
2396 - return encodeURI(url);
2481 + const encoded = isUrlEncoded(url) ? url : encodeURI(url);
2482 + return escapeAttr(encoded);
2397 2483 }
2398 2484
2399 - // Process markdown headers FIRST
2400 - let processedText = formatMarkdownHeaders(inputText);
2401 -
2485 + // Take backslash escapes and code (fenced + inline) out of the text FIRST
2486 + // (plan 9dc34f). Styling used to run before formatCodeBlocks(), so a
2487 + // backtick could not protect an underscore and \_ was not an escape at
2488 + // all; the header rule also ran over fenced code. Each escape becomes a
2489 + // placeholder carrying its literal character, each code span a
2490 + // placeholder carrying its rendered HTML; both are restored below, after
2491 + // the styling rules have run, so nothing downstream changes.
2492 + const literals = protectLiterals(inputText);
2493 + let processedText = literals.text;
2494 +
2495 + // Process markdown headers
2496 + processedText = formatMarkdownHeaders(processedText);
2497 +
2402 2498 // Process text styling (bold, italic, strikethrough)
2403 2499 processedText = formatTextStyling(processedText);
2404 -
2405 - // Process code blocks BEFORE processing links
2500 +
2501 + // Legacy raw <?php ... ?> blocks (fenced + inline code were already
2502 + // rendered inside protectLiterals) — still BEFORE processing links
2406 2503 processedText = formatCodeBlocks(processedText);
2407 2504
2505 + // Put the rendered code and the escaped characters back
2506 + processedText = literals.restore(processedText);
2507 +
2408 2508 // Process markdown tables BEFORE converting newlines to paragraphs
2409 2509 processedText = formatMarkdownTables(processedText);
2410 2510
2411 2511 // NOW convert to paragraphs
@@ -2417,10 +2517,13 @@
2417 2517 processedText = processedText.replace(/\[(https?:\/\/[^\]]+)\]/g, (match, url) => {
2418 2518 // Clean the URL of any trailing punctuation
2419 2519 let cleanUrl = url.replace(/[.,;!?]+$/, '');
2420 2520 const safeUrl = safeEncodeUrl(cleanUrl);
2521 + // The bracket body is the only link text that can carry angle
2522 + // brackets (the standalone matcher excludes them) — keep it text.
2523 + const safeText = cleanUrl.replace(/</g, '&lt;').replace(/>/g, '&gt;');
2421 2524 // Return as a proper link without the brackets
2422 - return `<a href="${safeUrl}" target="${linkTarget}">${cleanUrl}</a>`;
2525 + return `<a href="${safeUrl}" target="${linkTarget}">${safeText}</a>`;
2423 2526 });
2424 2527
2425 2528 // Process markdown links: [text](url) and [](url)
2426 2529 // Uses balanced parenthesis matching to handle URLs containing parens
@@ -2524,8 +2627,30 @@
2524 2627
2525 2628 return processedText;
2526 2629 }
2527 2630
2631 + // One left-to-right pass over backslash escapes and code (plan 9dc34f).
2632 + // A single alternation keeps the two honest with each other: an escaped
2633 + // backtick is consumed as an escape before it can open a code span, and a
2634 + // code span is consumed whole so the escapes inside it stay verbatim.
2635 + // Placeholders are private-use characters — no underscore, asterisk,
2636 + // backtick, hash, pipe or bracket for a later rule to match.
2637 + function protectLiterals(text) {
2638 + const store = [];
2639 + const out = String(text).replace(/\\([\\`*_~])|(```[\s\S]*?```)|(`[^`\n]+`)/g, function(match, escaped) {
2640 + store.push(escaped !== undefined ? escaped : formatCodeBlocks(match));
2641 + return '\uE000' + (store.length - 1) + '\uE001';
2642 + });
2643 + return {
2644 + text: out,
2645 + restore: function(t) {
2646 + return String(t).replace(/\uE000(\d+)\uE001/g, function(m, i) {
2647 + return store[Number(i)];
2648 + });
2649 + }
2650 + };
2651 + }
2652 +
2528 2653 function formatMarkdownHeaders(text) {
2529 2654 // Handle h1 to h6 headers
2530 2655 return text.replace(/^(#{1,6})\s+(.+)$/gm, function(match, hashes, content) {
2531 2656 const level = hashes.length;
@@ -2567,11 +2692,24 @@
2567 2692 // Handle italic text (*text* or _text_) - Safari-compatible (no lookbehind)
2568 2693 // Match single asterisks that aren't part of bold (**) by checking they're not followed/preceded by another *
2569 2694 protectedText = protectedText.replace(/(?!\*\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
2570 2695
2571 - // Handle underscores for italic - Safari-compatible (no lookbehind)
2572 - // Exclude __PROTECTED_N__ placeholders by checking the content doesn't contain PROTECTED
2573 - protectedText = protectedText.replace(/(?!__)_((?!PROTECTED)[^_\n]+)_(?!_)/g, '<em>$1</em>');
2696 + // Handle underscores for italic - Safari-compatible (no lookbehind), and
2697 + // word-bounded (plan 9dc34f): the opener must follow the start of the
2698 + // text or a non-word character, the closer must precede the end or a
2699 + // non-word character, and the content may not start or end with a
2700 + // space. So my_var_name, MXCHAT_API_KEY and snake_case stay as typed
2701 + // while "use _this_ word" still italicises. A token whose content is
2702 + // entirely upper-case letters and digits (_VALUE_, _ID_) is a placeholder
2703 + // convention, not prose emphasis — it is left verbatim too (ticket 440).
2704 + // __PROTECTED_N__ placeholders cannot match: their underscores are all
2705 + // adjacent to another underscore or a word character.
2706 + protectedText = protectedText.replace(/(^|[^\p{L}\p{N}_])_((?!PROTECTED)[^\s_](?:[^_\n]*[^\s_])?)_(?![\p{L}\p{N}_])/gu, function(match, before, content) {
2707 + if (/^[\p{Lu}\p{N}]+$/u.test(content)) {
2708 + return match;
2709 + }
2710 + return before + '<em>' + content + '</em>';
2711 + });
2574 2712
2575 2713 // Handle strikethrough (~~text~~)
2576 2714 protectedText = protectedText.replace(/~~(.*?)~~/g, '<del>$1</del>');
2577 2715
@@ -2705,8 +2843,24 @@
2705 2843 div.textContent = text;
2706 2844 return div.innerHTML;
2707 2845 }
2708 2846
2847 + // Attribute-safe URL for href="…" / src="…" interpolation (plan 2db182).
2848 + // Percent-encodes the characters that can end or extend an attribute.
2849 + // Valid in every scheme the widget emits (http, https, tel, mailto) and
2850 + // never changes where a legitimate link resolves — encodeURI() already
2851 + // produces most of these, this just guarantees them for URLs that skip
2852 + // encodeURI() because they were percent-encoded on arrival.
2853 + function escapeAttr(url) {
2854 + return String(url)
2855 + .replace(/"/g, '%22')
2856 + .replace(/'/g, '%27')
2857 + .replace(/</g, '%3C')
2858 + .replace(/>/g, '%3E')
2859 + .replace(/`/g, '%60')
2860 + .replace(/ /g, '%20');
2861 + }
2862 +
2709 2863 function escapeHtml(unsafe) {
2710 2864 // Skip escaping if it's already escaped or contains HTML code block markup
2711 2865 if (unsafe.includes('&lt;') || unsafe.includes('&gt;') ||
2712 2866 unsafe.includes('<pre><code') || unsafe.includes('</code></pre>')) {
@@ -3110,8 +3264,17 @@
3110 3264 var content = message.content;
3111 3265 content = content.replace(/\\'/g, "'").replace(/\\"/g, '"');
3112 3266 content = decodeHTMLEntities(content);
3113 3267
3268 + // A visitor's own rows get the same treatment as the
3269 + // live turn (appendMessage escapes user text before
3270 + // linkify). Without this a replayed user message came
3271 + // back as live markup after a refresh (plan 2db182).
3272 + // Bot/agent rows are trusted HTML and stay as they were.
3273 + if (message.role === 'user') {
3274 + content = sanitizeUserInput(content);
3275 + }
3276 +
3114 3277 // Skip linkify for messages containing structured HTML
3115 3278 // (forms, product cards, galleries, etc.) to avoid
3116 3279 // markdown formatting corrupting HTML attributes
3117 3280 // (e.g. underscores in name="field_name" becoming <em> tags).
@@ -3494,8 +3657,9 @@
3494 3657 $(this).addClass('hidden').attr('aria-expanded', 'true');
3495 3658 $badge.hide(); // Hide notification when opening chat
3496 3659 disableScroll();
3497 3660 $preChat.fadeOut(250);
3661 + mxchatFireOpened(botId);
3498 3662
3499 3663 // First open per page load: re-fetch behavior settings in case
3500 3664 // this page's inline values came from a stale full-page cache
3501 3665 // (plan-32db95). Idempotent — later opens are a no-op.
@@ -3764,9 +3928,12 @@
3764 3928 $('.chat-toolbar').show();
3765 3929 } else {
3766 3930 $('.chat-toolbar').hide();
3767 3931 }
3768 -
3932 + // Toolbar pass done — server-rendered add-on items can bind (plan 65c9b6).
3933 + MxChatInstances.toolbarReady = true;
3934 + mxchatDispatch('mxchat:toolbar-ready', { enabled: mxchatChat.chat_toolbar_toggle === 'on' });
3935 +
3769 3936 // Apply toolbar icon colors
3770 3937 const toolbarElements = [
3771 3938 '#mxchat-chatbot .toolbar-btn svg',
3772 3939 '#mxchat-chatbot .active-pdf-name',
@@ -4041,14 +4208,25 @@
4041 4208 return response.json();
4042 4209 })
4043 4210 .then((data) => {
4044 4211 if (data.success) {
4045 - if (data.data.logged_in || data.data.email) {
4212 + // A success response means the server has already decided the
4213 + // required info is on file. With email optional (c0cfaf) that
4214 + // info can be a name and no address, so keying on data.email
4215 + // alone re-prompted a name-only visitor on every page load.
4216 + if (data.data.logged_in || data.data.email || data.data.name) {
4046 4217 showChatContainerForBot(botId);
4047 4218 } else {
4048 4219 showEmailFormForBot(botId);
4049 4220 }
4050 4221 } else {
4222 + // Pre-fill for a signed-in visitor being shown the form
4223 + // (c0cfaf). It arrives on this nocache'd response rather than
4224 + // in the page HTML, which is cacheable. Never overwrite what
4225 + // the visitor has already typed.
4226 + if (data.data) {
4227 + prefillEmailForm(botId, data.data.prefill_name, data.data.prefill_email);
4228 + }
4051 4229 showEmailFormForBot(botId);
4052 4230 }
4053 4231 })
4054 4232 .catch((error) => {
@@ -4055,8 +4233,19 @@
4055 4233 showEmailFormForBot(botId);
4056 4234 });
4057 4235 }
4058 4236
4237 + function prefillEmailForm(botId, name, email) {
4238 + var nameInput = getElementDOM(botId, 'user-name');
4239 + var emailInput = getElementDOM(botId, 'user-email');
4240 + if (nameInput && name && !nameInput.value) {
4241 + nameInput.value = name;
4242 + }
4243 + if (emailInput && email && !emailInput.value) {
4244 + emailInput.value = email;
4245 + }
4246 + }
4247 +
4059 4248 // Event delegation for email form submission
4060 4249 $(document).on('submit', '.email-collection-form', function(e) {
4061 4250 e.preventDefault();
4062 4251 e.stopPropagation();
@@ -4074,19 +4263,29 @@
4074 4263 var userEmail = emailInput ? emailInput.value.trim() : '';
4075 4264 var userName = nameInput ? nameInput.value.trim() : '';
4076 4265 var sessionId = MxChatInstances.ensureSession(botId);
4077 4266
4078 - // Validate email
4079 - if (!userEmail) {
4267 + // Validate email. The address is optional when the owner turned
4268 + // "Require Email Address" off (c0cfaf) — but anything actually typed
4269 + // still has to be a valid address. The server enforces both rules
4270 + // independently.
4271 + var emailRequired = (mxchatChat.lead_capture_require_email || 'on') !== 'off';
4272 +
4273 + if (emailRequired && !userEmail) {
4080 4274 showEmailError(botId, 'Please enter your email address.');
4081 4275 return false;
4082 4276 }
4083 4277
4084 - if (!isValidEmailAddress(userEmail)) {
4278 + if (userEmail && !isValidEmailAddress(userEmail)) {
4085 4279 showEmailError(botId, 'Please enter a valid email address.');
4086 4280 return false;
4087 4281 }
4088 4282
4283 + if (!emailRequired && !userEmail && !userName) {
4284 + showEmailError(botId, 'Please enter your name or email address.');
4285 + return false;
4286 + }
4287 +
4089 4288 // Validate name if field exists and has content
4090 4289 if (nameInput && userName && !isValidNameInput(userName)) {
4091 4290 showEmailError(botId, 'Please enter a valid name (2-100 characters).');
4092 4291 return false;
@@ -4242,8 +4441,9 @@
4242 4441 $chatbot.removeClass('hidden').addClass('visible');
4243 4442 getElement(botId, 'floating-chatbot-button').addClass('hidden');
4244 4443 handlePreChatDismissal(botId);
4245 4444 disableScroll(); // Disable scroll when chatbot opens
4445 + mxchatFireOpened(botId);
4246 4446
4247 4447 // Load chat history for returning visitors (persistence)
4248 4448 var chatPersistenceEnabled = typeof mxchatChat !== 'undefined' && mxchatChat.chat_persistence_toggle === 'on';
4249 4449 if (chatPersistenceEnabled) {
@@ -4409,8 +4609,15 @@
4409 4609 // Initialize chat visibility for all instances
4410 4610 $('.mxchat-chatbot-wrapper').each(function() {
4411 4611 var botId = $(this).data('bot-id') || 'default';
4412 4612 initializeChatVisibility(botId);
4613 + // An embedded widget is on screen from the first paint; a floating one
4614 + // reports mxchat:opened when its window is shown (plan 65c9b6).
4615 + if (isEmbeddedBot(botId)) {
4616 + mxchatFireOpened(botId);
4617 + } else {
4618 + mxchatWatchForOpen(botId);
4619 + }
4413 4620 });
4414 4621
4415 4622 // Make functions globally available for add-ons
4416 4623 window.hasQuickQuestions = hasQuickQuestions;