| @@ -284,9 +284,20 @@ | ||
| 284 | 284 | var newSessionId = generateSessionId(); |
| 285 | 285 | this.setChatSession(botId, newSessionId); |
| 286 | 286 | var $chatBox = getElement(botId, 'chat-box'); |
| 287 | 287 | if ($chatBox.length) { |
| 288 | - $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove(); | |
| 288 | + // Keep the greeting, drop everything else. Identify the greeting | |
| 289 | + // by its marker class, NOT by position (plan a1a79b): after a | |
| 290 | + // chat-persistence restore the first .bot-message is a real | |
| 291 | + // reply, so ":not(:first)" left a stale answer sitting at the | |
| 292 | + // top of an otherwise empty box. The positional fallback only | |
| 293 | + // runs when the marker is absent — a page served from HTML cache | |
| 294 | + // that predates this release — and behaves exactly as before. | |
| 295 | + if ($chatBox.find('.mxchat-intro-message').length) { | |
| 296 | + $chatBox.find('.user-message, .bot-message:not(.mxchat-intro-message), .agent-message').remove(); | |
| 297 | + } else { | |
| 298 | + $chatBox.find('.user-message, .bot-message:not(:first), .agent-message').remove(); | |
| 299 | + } | |
| 289 | 300 | } |
| 290 | 301 | if (this.instances[botId]) { |
| 291 | 302 | this.instances[botId].chatHistoryLoaded = false; |
| 292 | 303 | this.instances[botId].processedMessageIds = new Set(); |
| @@ -396,9 +407,27 @@ | ||
| 396 | 407 | if (parts.length == 2) return parts.pop().split(";").shift(); |
| 397 | 408 | } |
| 398 | 409 | |
| 399 | 410 | function generateSessionId() { |
| 400 | - return 'mxchat_chat_' + Math.random().toString(36).substr(2, 9); | |
| 411 | + // Session IDs function as the de-facto bearer token for an anonymous | |
| 412 | + // chat, so generate them with a CSPRNG when available. Math.random is a | |
| 413 | + // legacy fallback for ancient/sandboxed environments that lack | |
| 414 | + // window.crypto. The 'mxchat_chat_' prefix is preserved exactly (other | |
| 415 | + // code pattern-matches on it). (plan-0c17b5) | |
| 416 | + var rand; | |
| 417 | + try { | |
| 418 | + if (window.crypto && window.crypto.getRandomValues) { | |
| 419 | + var buf = new Uint8Array(16); // 128 bits | |
| 420 | + window.crypto.getRandomValues(buf); | |
| 421 | + rand = Array.prototype.map.call(buf, function (b) { | |
| 422 | + return ('0' + b.toString(16)).slice(-2); | |
| 423 | + }).join(''); | |
| 424 | + } | |
| 425 | + } catch (e) {} | |
| 426 | + if (!rand) { | |
| 427 | + rand = Math.random().toString(36).substr(2, 9); // legacy fallback | |
| 428 | + } | |
| 429 | + return 'mxchat_chat_' + rand; | |
| 401 | 430 | } |
| 402 | 431 | |
| 403 | 432 | // Legacy function - now delegates to instance manager |
| 404 | 433 | function getChatSession(botId) { |
| @@ -601,8 +630,26 @@ | ||
| 601 | 630 | sendButton.style.pointerEvents = 'none'; |
| 602 | 631 | } |
| 603 | 632 | } |
| 604 | 633 | |
| 634 | +// Whether the input may grab focus after a completed reply (plan 03799f). | |
| 635 | +// On coarse-pointer devices focusing a text input summons the on-screen | |
| 636 | +// keyboard over the answer the visitor is trying to read, so 'auto' (the | |
| 637 | +// default) focuses only on fine-pointer devices. The site-wide | |
| 638 | +// mxchat_autofocus_after_reply PHP filter can force 'on'/'off'. | |
| 639 | +// NOT used on widget open (:~3424) — that focus is a deliberate act and is | |
| 640 | +// what makes the widget keyboard-accessible. | |
| 641 | +function mxchatShouldAutofocusAfterReply() { | |
| 642 | + var pref = (typeof mxchatChat !== 'undefined' && mxchatChat.autofocus_after_reply) || 'auto'; | |
| 643 | + if (pref === 'on') return true; | |
| 644 | + if (pref === 'off') return false; | |
| 645 | + try { | |
| 646 | + return !window.matchMedia('(pointer: coarse)').matches; | |
| 647 | + } catch (err) { | |
| 648 | + return true; | |
| 649 | + } | |
| 650 | +} | |
| 651 | + | |
| 605 | 652 | function enableChatInput(botId) { |
| 606 | 653 | botId = botId || 'default'; |
| 607 | 654 | var chatInput = getElementDOM(botId, 'chat-input'); |
| 608 | 655 | var sendButton = getElementDOM(botId, 'send-button'); |
| @@ -608,9 +655,11 @@ | ||
| 608 | 655 | var sendButton = getElementDOM(botId, 'send-button'); |
| 609 | 656 | if (chatInput) { |
| 610 | 657 | chatInput.disabled = false; |
| 611 | 658 | chatInput.style.opacity = '1'; |
| 612 | - chatInput.focus(); | |
| 659 | + if (mxchatShouldAutofocusAfterReply()) { | |
| 660 | + try { chatInput.focus({ preventScroll: true }); } catch (err) { chatInput.focus(); } | |
| 661 | + } | |
| 613 | 662 | } |
| 614 | 663 | if (sendButton) { |
| 615 | 664 | sendButton.disabled = false; |
| 616 | 665 | sendButton.style.opacity = '1'; |
| @@ -740,14 +789,16 @@ | ||
| 740 | 789 | } |
| 741 | 790 | appendThinkingMessage(botId); |
| 742 | 791 | scrollToBottom(botId); |
| 743 | 792 | |
| 744 | - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest'; | |
| 793 | + const currentModel = mxchatChat.model || 'gpt-5.6-sol'; | |
| 745 | 794 | |
| 746 | 795 | // Check if streaming is enabled AND supported for this model |
| 747 | 796 | if (shouldUseStreaming(currentModel)) { |
| 748 | 797 | callMxChatStream(message, function(response) { |
| 749 | - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message'); | |
| 798 | + // Content is final: releasing aria-busy lets the live region | |
| 799 | + // announce the completed reply once (plan 67f126). | |
| 800 | + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false'); | |
| 750 | 801 | }, botId); |
| 751 | 802 | } else { |
| 752 | 803 | callMxChat(message, function(response) { |
| 753 | 804 | replaceLastMessage("bot", response, '', [], botId); |
| @@ -780,14 +831,15 @@ | ||
| 780 | 831 | } |
| 781 | 832 | appendThinkingMessage(botId); |
| 782 | 833 | scrollToBottom(botId); |
| 783 | 834 | |
| 784 | - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest'; | |
| 835 | + const currentModel = mxchatChat.model || 'gpt-5.6-sol'; | |
| 785 | 836 | |
| 786 | 837 | // Check if streaming is enabled AND supported for this model |
| 787 | 838 | if (shouldUseStreaming(currentModel)) { |
| 788 | 839 | callMxChatStream(message, function(response) { |
| 789 | - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message'); | |
| 840 | + // Final content — release aria-busy so the reply announces once (67f126). | |
| 841 | + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false'); | |
| 790 | 842 | }, botId); |
| 791 | 843 | } else { |
| 792 | 844 | callMxChat(message, function(response) { |
| 793 | 845 | getElement(botId, 'chat-box').find('.temporary-message').remove(); |
| @@ -957,12 +1009,13 @@ | ||
| 957 | 1009 | // Re-send the original message with the new session (user message is already displayed) |
| 958 | 1010 | var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message'); |
| 959 | 1011 | if (originalMessage) { |
| 960 | 1012 | getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null); |
| 961 | - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest'; | |
| 1013 | + var currentModel = mxchatChat.model || 'gpt-5.6-sol'; | |
| 962 | 1014 | if (shouldUseStreaming(currentModel)) { |
| 963 | 1015 | callMxChatStream(originalMessage, function(response) { |
| 964 | - getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message'); | |
| 1016 | + // Final content — release aria-busy so the reply announces once (67f126). | |
| 1017 | + getElement(botId, 'chat-box').find('.bot-message.temporary-message').removeClass('temporary-message').attr('aria-busy', 'false'); | |
| 965 | 1018 | }, botId); |
| 966 | 1019 | } else { |
| 967 | 1020 | callMxChat(originalMessage, function(response) { |
| 968 | 1021 | replaceLastMessage("bot", response, '', [], botId); |
| @@ -1110,9 +1163,9 @@ | ||
| 1110 | 1163 | |
| 1111 | 1164 | // Store the message in case we need to retry after session reset |
| 1112 | 1165 | getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', message); |
| 1113 | 1166 | |
| 1114 | - const currentModel = mxchatChat.model || 'gpt-5.1-chat-latest'; | |
| 1167 | + const currentModel = mxchatChat.model || 'gpt-5.6-sol'; | |
| 1115 | 1168 | if (!isStreamingSupported(currentModel)) { |
| 1116 | 1169 | callMxChat(message, callback, botId); |
| 1117 | 1170 | return; |
| 1118 | 1171 | } |
| @@ -1165,8 +1218,12 @@ | ||
| 1165 | 1218 | |
| 1166 | 1219 | let accumulatedContent = ''; |
| 1167 | 1220 | let testingDataReceived = false; |
| 1168 | 1221 | let streamingStarted = false; |
| 1222 | + // Server-pushed html to append as its OWN bot bubble once the stream | |
| 1223 | + // finishes (e.g. the consent-safe YouTube embed, plan 03ba33). Rendering is | |
| 1224 | + // deferred to [DONE] so the embed always lands BELOW the streamed text. | |
| 1225 | + let pendingAppendHtml = ''; | |
| 1169 | 1226 | |
| 1170 | 1227 | // Abortable stream: a fresh controller per turn, keyed by bot instance. |
| 1171 | 1228 | // The Stop control (send button swapped in place) aborts both the read |
| 1172 | 1229 | // loop and the underlying request. |
| @@ -1280,8 +1337,17 @@ | ||
| 1280 | 1337 | |
| 1281 | 1338 | // Re-enable chat input after streaming completes |
| 1282 | 1339 | enableChatInput(botId); |
| 1283 | 1340 | |
| 1341 | + // Render any server-pushed appendix html (e.g. the | |
| 1342 | + // YouTube embed) as its own bot bubble below the | |
| 1343 | + // streamed text — mirrors how it is saved in the | |
| 1344 | + // transcript, so history replays identically. | |
| 1345 | + if (pendingAppendHtml) { | |
| 1346 | + appendMessage("bot", "", pendingAppendHtml, [], false, botId); | |
| 1347 | + pendingAppendHtml = ''; | |
| 1348 | + } | |
| 1349 | + | |
| 1284 | 1350 | // Scroll the user's last message to the top now |
| 1285 | 1351 | // that the bot's full reply has rendered. |
| 1286 | 1352 | var $chatBoxStreamDone = getElement(botId, 'chat-box'); |
| 1287 | 1353 | var $lastUserMsgStreamDone = $chatBoxStreamDone.find('.user-message').last(); |
| @@ -1315,8 +1381,21 @@ | ||
| 1315 | 1381 | streamingStarted = true; |
| 1316 | 1382 | accumulatedContent += json.content; |
| 1317 | 1383 | updateStreamingMessage(accumulatedContent, botId); |
| 1318 | 1384 | } |
| 1385 | + // Stash appendix html (e.g. video embed) for [DONE] | |
| 1386 | + else if (json.append_html) { | |
| 1387 | + pendingAppendHtml = json.append_html; | |
| 1388 | + } | |
| 1389 | + // Server-side final pass changed the assembled text | |
| 1390 | + // (ffef6f: dead-link stripping) — swap the rendered | |
| 1391 | + // bubble for the validated version. Arrives at most | |
| 1392 | + // once, just before [DONE]. | |
| 1393 | + else if (json.replace_content) { | |
| 1394 | + streamingStarted = true; | |
| 1395 | + accumulatedContent = json.replace_content; | |
| 1396 | + updateStreamingMessage(accumulatedContent, botId); | |
| 1397 | + } | |
| 1319 | 1398 | // Handle complete response in stream (fallback response) |
| 1320 | 1399 | else if (json.text || json.message || json.html) { |
| 1321 | 1400 | handleNonStreamResponse(json, callback, botId); |
| 1322 | 1401 | return; |
| @@ -1418,9 +1497,9 @@ | ||
| 1418 | 1497 | // Re-send the original message with the new session (user message is already displayed) |
| 1419 | 1498 | var originalMessage = getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message'); |
| 1420 | 1499 | if (originalMessage) { |
| 1421 | 1500 | getElement(botId, 'mxchat-chatbot-wrapper').find('.mxchat-input-holder textarea').data('pending-message', null); |
| 1422 | - var currentModel = mxchatChat.model || 'gpt-5.1-chat-latest'; | |
| 1501 | + var currentModel = mxchatChat.model || 'gpt-5.6-sol'; | |
| 1423 | 1502 | if (shouldUseStreaming(currentModel)) { |
| 1424 | 1503 | callMxChatStream(originalMessage, callback, botId); |
| 1425 | 1504 | } else { |
| 1426 | 1505 | callMxChat(originalMessage, callback, botId); |
| @@ -1553,8 +1632,15 @@ | ||
| 1553 | 1632 | var $chatBox = getElement(botId, 'chat-box'); |
| 1554 | 1633 | const tempMessage = $chatBox.find('.bot-message.temporary-message').last(); |
| 1555 | 1634 | |
| 1556 | 1635 | if (tempMessage.length) { |
| 1636 | + // aria-busy=true for the whole stream: the bubble is rewritten on | |
| 1637 | + // every chunk, and without busy a polite live region announces those | |
| 1638 | + // rewrites continuously. Flipped false once the reply is final, so | |
| 1639 | + // assistive tech announces the completed message ONCE (plan 67f126). | |
| 1640 | + if (tempMessage.attr('aria-busy') !== 'true') { | |
| 1641 | + tempMessage.attr('aria-busy', 'true'); | |
| 1642 | + } | |
| 1557 | 1643 | // Update existing message |
| 1558 | 1644 | tempMessage.html(formattedContent); |
| 1559 | 1645 | } else { |
| 1560 | 1646 | // Create new temporary message if it doesn't exist |
| @@ -2048,9 +2134,11 @@ | ||
| 2048 | 2134 | |
| 2049 | 2135 | messageDiv.html(fullMessage); |
| 2050 | 2136 | |
| 2051 | 2137 | if (isTemporary) { |
| 2052 | - messageDiv.addClass('temporary-message'); | |
| 2138 | + // In-flight bubble: hold aria-busy so the live region stays quiet | |
| 2139 | + // until the content is finalized (plan 67f126). | |
| 2140 | + messageDiv.addClass('temporary-message').attr('aria-busy', 'true'); | |
| 2053 | 2141 | } |
| 2054 | 2142 | |
| 2055 | 2143 | // Append to the correct chatbot instance's chat-box |
| 2056 | 2144 | var $chatBox = getElement(botId, 'chat-box'); |
| @@ -2185,13 +2273,16 @@ | ||
| 2185 | 2273 | } |
| 2186 | 2274 | |
| 2187 | 2275 | if (lastMessageDiv.length) { |
| 2188 | 2276 | // Replace content immediately to prevent visual gap between thinking dots and response |
| 2277 | + // aria-busy released AFTER the final content is set, so the live region | |
| 2278 | + // announces the finished message once (plan 67f126). | |
| 2189 | 2279 | lastMessageDiv |
| 2190 | 2280 | .html(fullMessage) |
| 2191 | 2281 | .removeClass('bot-message user-message temporary-message') |
| 2192 | 2282 | .addClass(messageClass) |
| 2193 | - .attr('dir', 'auto'); | |
| 2283 | + .attr('dir', 'auto') | |
| 2284 | + .attr('aria-busy', 'false'); | |
| 2194 | 2285 | |
| 2195 | 2286 | // Only apply inline colors if AI theme is not active (let CSS handle it) |
| 2196 | 2287 | var skipColors = mxchatChat.skip_inline_colors || shouldSkipInlineColors(botId); |
| 2197 | 2288 | if (!skipColors) { |
| @@ -2251,10 +2342,15 @@ | ||
| 2251 | 2342 | var botMessageFontColor = mxchatChat.bot_message_font_color; |
| 2252 | 2343 | var botMessageBgColor = mxchatChat.bot_message_bg_color; |
| 2253 | 2344 | |
| 2254 | 2345 | // Build thinking dots HTML - skip inline colors if AI theme is active |
| 2346 | + // The dots are decorative; the sr-only span is what the live region | |
| 2347 | + // announces for the waiting state (plan 67f126). Server-localized | |
| 2348 | + // string — safe to inject (esc_html__ output, no user content). | |
| 2255 | 2349 | var dotStyle = skipColors ? '' : ' style="background-color: ' + botMessageFontColor + ';"'; |
| 2256 | - var thinkingHtml = '<div class="thinking-dots-container">' + | |
| 2350 | + var srThinking = mxchatChat.thinking_announcement || 'Assistant is typing'; | |
| 2351 | + var thinkingHtml = '<span class="sr-only">' + srThinking + '</span>' + | |
| 2352 | + '<div class="thinking-dots-container" aria-hidden="true">' + | |
| 2257 | 2353 | '<div class="thinking-dots">' + |
| 2258 | 2354 | '<span class="dot"' + dotStyle + '></span>' + |
| 2259 | 2355 | '<span class="dot"' + dotStyle + '></span>' + |
| 2260 | 2356 | '<span class="dot"' + dotStyle + '></span>' + |
| @@ -2954,9 +3050,30 @@ | ||
| 2954 | 3050 | } |
| 2955 | 3051 | |
| 2956 | 3052 | // Only process if there are actual messages |
| 2957 | 3053 | if (response.data.conversation.length > 0) { |
| 2958 | - // IMPORTANT: Clear existing messages before loading history | |
| 3054 | + // Restored history must be SILENT to screen readers | |
| 3055 | + // (plan 67f126): these are DOM additions inside the | |
| 3056 | + // live region and would otherwise announce as if | |
| 3057 | + // they just arrived. Lift aria-live for the batch | |
| 3058 | + // repopulate, restore it after the browser has | |
| 3059 | + // processed the mutations. | |
| 3060 | + var mxLiveRegionEl = $chatBox.get(0); | |
| 3061 | + var mxSavedAriaLive = mxLiveRegionEl ? mxLiveRegionEl.getAttribute('aria-live') : null; | |
| 3062 | + if (mxLiveRegionEl) { | |
| 3063 | + mxLiveRegionEl.setAttribute('aria-live', 'off'); | |
| 3064 | + } | |
| 3065 | + | |
| 3066 | + // IMPORTANT: Clear existing messages before loading history. | |
| 3067 | + // Detach the greeting first and put it back below — | |
| 3068 | + // it is server-rendered and never stored in the | |
| 3069 | + // transcript, so the old unconditional .empty() | |
| 3070 | + // deleted it for the rest of the page life (plan | |
| 3071 | + // a1a79b). Detach rather than rebuild: intro_message | |
| 3072 | + // is not localized to JS, and this node already | |
| 3073 | + // carries the per-bot inline colors and any | |
| 3074 | + // {visitor_name} substitution already applied to it. | |
| 3075 | + var $mxIntro = $chatBox.find('.mxchat-intro-message').first().detach(); | |
| 2959 | 3076 | $chatBox.empty(); |
| 2960 | 3077 | |
| 2961 | 3078 | $.each(response.data.conversation, function(index, message) { |
| 2962 | 3079 | // Skip agent messages if persistence is off |
| @@ -2996,12 +3113,16 @@ | ||
| 2996 | 3113 | |
| 2997 | 3114 | // Skip linkify for messages containing structured HTML |
| 2998 | 3115 | // (forms, product cards, galleries, etc.) to avoid |
| 2999 | 3116 | // markdown formatting corrupting HTML attributes |
| 3000 | - // (e.g. underscores in name="field_name" becoming <em> tags) | |
| 3001 | - if (content.includes("mxchat-product-card") || | |
| 3002 | - content.includes("mxchat-image-gallery") || | |
| 3003 | - content.includes("mxchat-featured-products") || | |
| 3117 | + // (e.g. underscores in name="field_name" becoming <em> tags). | |
| 3118 | + // One family check instead of a per-card literal list: any | |
| 3119 | + // element carrying an mxchat- prefixed class is MxChat-generated | |
| 3120 | + // structured markup and replays raw. The old list drifted every | |
| 3121 | + // time an add-on minted a new card class — the filtered-search | |
| 3122 | + // card ("mxchat-filtered-product-card") missed it and replayed | |
| 3123 | + // through linkify as visible markup. | |
| 3124 | + if (/<[a-z][^>]*class\s*=\s*["'][^"']*\bmxchat-/i.test(content) || | |
| 3004 | 3125 | content.includes("<form") || |
| 3005 | 3126 | content.includes("<input") || |
| 3006 | 3127 | content.includes("<select") || |
| 3007 | 3128 | content.includes("<textarea")) { |
| @@ -3019,12 +3140,29 @@ | ||
| 3019 | 3140 | instance.processedMessageIds.add(message.id); |
| 3020 | 3141 | } |
| 3021 | 3142 | }); |
| 3022 | 3143 | |
| 3023 | - // Only append messages and scroll if we have content | |
| 3144 | + // Only append messages and scroll if we have content. | |
| 3145 | + // Greeting goes back FIRST, above the restored | |
| 3146 | + // history: "Hello — [earlier conversation]" is the | |
| 3147 | + // natural reading and matches the order a fresh | |
| 3148 | + // visitor sees (plan a1a79b). | |
| 3149 | + if ($mxIntro && $mxIntro.length) { | |
| 3150 | + $chatBox.append($mxIntro); | |
| 3151 | + } | |
| 3024 | 3152 | $chatBox.append($fragment); |
| 3025 | 3153 | scrollToBottom(botId, true); |
| 3026 | 3154 | |
| 3155 | + // Re-attach live semantics AFTER the rehydration | |
| 3156 | + // mutations have been processed with the region off | |
| 3157 | + // (plan 67f126). Restoring later announces nothing | |
| 3158 | + // retroactively; new turns announce normally. | |
| 3159 | + if (mxLiveRegionEl) { | |
| 3160 | + setTimeout(function() { | |
| 3161 | + mxLiveRegionEl.setAttribute('aria-live', mxSavedAriaLive || 'polite'); | |
| 3162 | + }, 200); | |
| 3163 | + } | |
| 3164 | + | |
| 3027 | 3165 | // Collapse quick questions if we have conversation history |
| 3028 | 3166 | // BUT skip auto-collapse for embedded bots (they should stay expanded) |
| 3029 | 3167 | if (hasQuickQuestions(botId) && !isEmbeddedBot(botId)) { |
| 3030 | 3168 | collapseQuickQuestions(botId); |
| @@ -3314,8 +3452,30 @@ | ||
| 3314 | 3452 | e.stopPropagation(); |
| 3315 | 3453 | var botId = getBotIdFromElement(this); |
| 3316 | 3454 | collapseQuickQuestions(botId); |
| 3317 | 3455 | }); |
| 3456 | + | |
| 3457 | +// Consent-safe YouTube embed (plan 03ba33): the server only ever ships a | |
| 3458 | +// thumbnail facade — no Google iframe exists until the visitor taps play. | |
| 3459 | +// Delegated so it also works for embeds restored from chat history. | |
| 3460 | +$(document).on('click', '.mxchat-youtube-embed .mxchat-youtube-facade', function(e) { | |
| 3461 | + e.preventDefault(); | |
| 3462 | + var $wrap = $(this).closest('.mxchat-youtube-embed'); | |
| 3463 | + var videoId = String($wrap.data('video-id') || '').replace(/[^A-Za-z0-9_-]/g, ''); | |
| 3464 | + if (!videoId) { | |
| 3465 | + return; | |
| 3466 | + } | |
| 3467 | + var title = $wrap.find('.mxchat-youtube-title').text() || 'YouTube video'; | |
| 3468 | + var $iframe = $('<iframe>', { | |
| 3469 | + src: 'https://www.youtube-nocookie.com/embed/' + videoId + '?autoplay=1&rel=0', | |
| 3470 | + title: title, | |
| 3471 | + allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture', | |
| 3472 | + allowfullscreen: true, | |
| 3473 | + frameborder: 0 | |
| 3474 | + }).addClass('mxchat-youtube-iframe'); | |
| 3475 | + $wrap.addClass('mxchat-youtube-playing'); | |
| 3476 | + $(this).replaceWith($iframe); | |
| 3477 | +}); | |
| 3318 | 3478 | |
| 3319 | 3479 | // Chatbot visibility toggle handlers - use class selector for multi-instance support |
| 3320 | 3480 | // Handles click + Enter/Space keypresses for keyboard accessibility (WCAG 2.1 SC 2.1.1). |
| 3321 | 3481 | $(document).on('click keydown', '.floating-chatbot-button', function(e) { |
| @@ -3731,10 +3891,13 @@ | ||
| 3731 | 3891 | function replaceVisitorNamePlaceholder(botId, visitorName) { |
| 3732 | 3892 | var chatBox = getElementDOM(botId, 'chat-box'); |
| 3733 | 3893 | if (!chatBox) return; |
| 3734 | 3894 | |
| 3735 | - // Find the first bot message (intro message) | |
| 3736 | - var introMessage = chatBox.querySelector('.bot-message'); | |
| 3895 | + // Find the greeting by its marker, not by position (plan a1a79b) — | |
| 3896 | + // after a persistence restore the first .bot-message is a restored | |
| 3897 | + // reply, and {visitor_name} was being substituted into that instead. | |
| 3898 | + // Positional fallback for HTML cached before this release only. | |
| 3899 | + var introMessage = chatBox.querySelector('.mxchat-intro-message') || chatBox.querySelector('.bot-message'); | |
| 3737 | 3900 | if (!introMessage) return; |
| 3738 | 3901 | |
| 3739 | 3902 | var messageContent = introMessage.querySelector('div[dir="auto"]'); |
| 3740 | 3903 | if (!messageContent) return; |
| @@ -3906,8 +4069,9 @@ | ||
| 3906 | 4069 | } |
| 3907 | 4070 | |
| 3908 | 4071 | var emailInput = getElementDOM(botId, 'user-email'); |
| 3909 | 4072 | var nameInput = getElementDOM(botId, 'user-name'); |
| 4073 | + var consentInput = getElementDOM(botId, 'user-consent'); | |
| 3910 | 4074 | var userEmail = emailInput ? emailInput.value.trim() : ''; |
| 3911 | 4075 | var userName = nameInput ? nameInput.value.trim() : ''; |
| 3912 | 4076 | var sessionId = MxChatInstances.ensureSession(botId); |
| 3913 | 4077 | |
| @@ -3927,8 +4091,15 @@ | ||
| 3927 | 4091 | showEmailError(botId, 'Please enter a valid name (2-100 characters).'); |
| 3928 | 4092 | return false; |
| 3929 | 4093 | } |
| 3930 | 4094 | |
| 4095 | + // Consent checkbox (b062c4): backstop behind the native required | |
| 4096 | + // attribute; the server enforces this independently either way. | |
| 4097 | + if (consentInput && consentInput.required && !consentInput.checked) { | |
| 4098 | + showEmailError(botId, 'Please tick the consent box to continue.'); | |
| 4099 | + return false; | |
| 4100 | + } | |
| 4101 | + | |
| 3931 | 4102 | clearEmailError(botId); |
| 3932 | 4103 | setEmailSubmissionState(botId, true); |
| 3933 | 4104 | |
| 3934 | 4105 | // Prepare form data |
| @@ -3940,8 +4111,14 @@ | ||
| 3940 | 4111 | }); |
| 3941 | 4112 | |
| 3942 | 4113 | if (userName) { |
| 3943 | 4114 | formData.append('name', userName); |
| 4115 | + } | |
| 4116 | + | |
| 4117 | + // Ticked/unticked both travel when the checkbox is rendered, so an | |
| 4118 | + // optional-consent "no" is recorded as a decision, not an absence. | |
| 4119 | + if (consentInput) { | |
| 4120 | + formData.append('consent', consentInput.checked ? '1' : '0'); | |
| 3944 | 4121 | } |
| 3945 | 4122 | |
| 3946 | 4123 | fetch(mxchatChat.ajax_url, { |
| 3947 | 4124 | method: 'POST', |