1/** 2 * Add-to-cart prompt. 3 * 4 * After a product is added to the cart, ask whether to go to the cart page. 5 * 6 * Why this exists: woocommerce-filter.js in the child theme hides WooCommerce's own 7 * "View cart" link on AJAX add (`$('.added_to_cart.wc-forward').hide()`) and instead 8 * flashes "Added to Cart" on the button for two seconds. That leaves no route to the 9 * cart from an archive, which is what this restores â as an explicit question rather 10 * than a link that is easy to miss. 11 * 12 * Two add-to-cart paths are covered: 13 * 1. AJAX (archive / loop buttons) -> the `added_to_cart` body event. 14 * 2. Form POST (single product) -> the page reloads carrying WooCommerce's 15 * "â¦has been added to your cart" notice, which we detect on load. 16 * 17 * The handler is delegated on document.body and the script is enqueued site-wide, so 18 * it survives Swup swapping #brx-content (Swup replaces only that container, so a 19 * conditionally-enqueued script would be missing after a client-side navigation). 20 */ 21(function ($) { 22 'use strict'; 23 24 var CFG = window.radioLogicAddToCartPrompt || {}; 25 var PROMPT_ID = 'rl-atc-prompt'; 26 var AUTO_DISMISS_MS = 10000; 27 var lastFocused = null; 28 var autoDismissTimer = null; 29 30 function stopAutoDismiss() { 31 if (autoDismissTimer) { 32 window.clearTimeout(autoDismissTimer); 33 autoDismissTimer = null; 34 } 35 } 36 37 /** 38 * Close on its own after ten seconds of being left alone. 39 * 40 * Started after the initial focus below, so focusing the "View cart" link does not count 41 * as the visitor having acted. Hovering pauses the countdown rather than shortening it, 42 * and any keyboard interaction cancels it outright â someone reading or tabbing through 43 * the prompt should not have it pulled away mid-sentence. 44 */ 45 function startAutoDismiss() { 46 stopAutoDismiss(); 47 autoDismissTimer = window.setTimeout(dismiss, AUTO_DISMISS_MS); 48 } 49 50 function dismiss() { 51 var el = document.getElementById(PROMPT_ID); 52 53 stopAutoDismiss(); 54 55 if (el) { 56 el.parentNode.removeChild(el); 57 } 58 59 document.removeEventListener('keydown', onKeydown); 60 61 if (lastFocused && document.contains(lastFocused)) { 62 lastFocused.focus({ preventScroll: true }); 63 } 64 65 lastFocused = null; 66 } 67 68 function onKeydown(e) { 69 if (e.key === 'Escape' || e.keyCode === 27) { 70 dismiss(); 71 } 72 } 73 74 function show(productName) { 75 // Never ask to go to the cart when already on the cart or checkout. 76 if (CFG.isCart || CFG.isCheckout) { 77 return; 78 } 79 80 dismiss(); 81 lastFocused = document.activeElement; 82 83 var wrap = document.createElement('div'); 84 wrap.id = PROMPT_ID; 85 wrap.className = 'rl-atc-prompt'; 86 wrap.setAttribute('role', 'alertdialog'); 87 wrap.setAttribute('aria-label', CFG.i18n.added); 88 89 var msg = document.createElement('p'); 90 msg.className = 'rl-atc-prompt__msg'; 91 msg.textContent = productName 92 ? CFG.i18n.messageNamed.replace('%s', productName) 93 : CFG.i18n.message; 94 95 var actions = document.createElement('div'); 96 actions.className = 'rl-atc-prompt__actions'; 97 98 var yes = document.createElement('a'); 99 yes.className = 'rl-atc-prompt__yes'; 100 yes.href = CFG.cartUrl; 101 yes.textContent = CFG.i18n.yes; 102 103 var no = document.createElement('button'); 104 no.type = 'button'; 105 no.className = 'rl-atc-prompt__no'; 106 no.textContent = CFG.i18n.no; 107 no.addEventListener('click', dismiss); 108 109 // Explicit close, for a visitor who wants it gone now and does not read "Keep 110 // shopping" as the way to do that. 111 var close = document.createElement('button'); 112 close.type = 'button'; 113 close.className = 'rl-atc-prompt__close'; 114 close.setAttribute('aria-label', CFG.i18n.close || 'Close'); 115 close.textContent = 'Ã'; 116 close.addEventListener('click', dismiss); 117 118 actions.appendChild(yes); 119 actions.appendChild(no); 120 wrap.appendChild(msg); 121 wrap.appendChild(actions); 122 wrap.appendChild(close); 123 document.body.appendChild(wrap); 124 125 wrap.addEventListener('mouseenter', stopAutoDismiss); 126 wrap.addEventListener('mouseleave', startAutoDismiss); 127 wrap.addEventListener('keydown', stopAutoDismiss); 128 129 document.addEventListener('keydown', onKeydown); 130 yes.focus({ preventScroll: true }); 131 132 // After the focus above, so it is not treated as the visitor having acted. 133 startAutoDismiss(); 134 } 135 136 /** 137 * The same prompt, carrying a rejection instead of a success (issue #43). 138 * 139 * Once the product page adds to the cart over AJAX, a refused add â out of stock, or a 140 * gift subscription missing a field that is required when gifting â no longer arrives as 141 * a page reload with the notice at the top. Bricks hands it to us as rendered notice 142 * markup instead, and puts a copy in `.woocommerce-notices-wrapper`, which on this theme 143 * sits at the very top of `#brx-content`: from a button most of a page down, that reads 144 * as the click having done nothing at all. 145 * 146 * Three deliberate differences from the success prompt: 147 * 148 * - No auto-dismiss. Something the visitor has to act on must not time out. 149 * - No "View cart": nothing was added, so offering the cart would be a lie. 150 * - The notices are reduced to their sentences by errorSentences() and written as TEXT. 151 */ 152 153 /** 154 * Reduce whatever Bricks hands back into the sentences a visitor should read. 155 * 156 * WHY THIS IS NOT JUST `innerHTML`. It was, and it put this on screen verbatim: 157 * 158 * <a href="https://threedradio.dev/cart/" class="button wc-forward">
158View cart</a> "Full 159 * House Subscription" cannot be added to your cart. There is not enough stock of ... 160 * 161 * Two separate faults in one string. The markup was ESCAPED before it reached us, so 162 * `innerHTML` painted the tags instead of rendering them â and the tag it painted was a 163 * "View cart" button belonging to a queued SUCCESS notice from an earlier add, riding along 164 * in the same blob as the failure. Offering the cart on a failed add is wrong even when it 165 * renders properly: nothing was added. 166 * 167 * So the payload is not trusted to be well-formed markup. It is parsed, everything that is 168 * not an error is dropped, and only the resulting sentences are returned â written to the 169 * DOM as text by the caller, which is what makes it impossible for a tag to be displayed 170 * again whether it arrives escaped, unescaped, or half of each. 171 * 172 * The tag-stripping is done on the extracted TEXT, and anchors go first with their contents: 173 * removing `<a ...>` and `</a>` alone would leave the words "View cart" stranded in front of 174 * the error message. 175 * 176 * @param {string} noticesHtml Notice markup from the Bricks add-to-cart error payload. 177 * @return {string[]} One sentence per problem, in the order WooCommerce raised them. 178 */ 179 function errorSentences(noticesHtml) { 180 if (!noticesHtml) { 181 return []; 182 } 183 184 var raw = String(noticesHtml); 185 186 // Escaped wholesale â no real tags, only entities. Decode once. A textarea is used 187 // rather than innerHTML on a div because it decodes without ever parsing the result 188 // as markup. 189 if (raw.indexOf('<') !== -1 && raw.indexOf('<') === -1) { 190 var decoder = document.createElement('textarea'); 191 decoder.innerHTML = raw; 192 raw = decoder.value; 193 } 194 195 var holder = document.createElement('div'); 196 holder.innerHTML = raw; 197 198 // "Added to your cart" beside "cannot be added to your cart" is a contradiction the 199 // visitor has to unpick, so a success or info notice never survives into an error prompt.
200 strip(holder, '.woocommerce-message, .woocommerce-info'); 201 202 // Any real link out of the prompt goes with them. 203 strip(holder, 'a'); 204 205 var blocks = holder.querySelectorAll('.woocommerce-error li'); 206 207 if (!blocks.length) { 208 blocks = holder.querySelectorAll('.woocommerce-error'); 209 } 210 211 if (!blocks.length) { 212 blocks = [holder]; 213 } 214 215 var out = []; 216 217 Array.prototype.forEach.call(blocks, function (block) { 218 var text = (block.textContent || '') 219 // Escaped anchors, contents and all. 220 .replace(/<a\b[^>]*>[\s\S]*?<\/a>/gi, ' ') 221 // Any other escaped tag that came through as text. 222 .replace(/<\/?[a-z][^>]*>/gi, ' ') 223 .replace(/\s+/g, ' ') 224 .trim(); 225 226 if (text && out.indexOf(text) === -1) { 227 out.push(text); 228 } 229 }); 230 231 return out; 232 } 233 234 /** Remove every match of a selector from a subtree. */ 235 function strip(root, selector) { 236 Array.prototype.forEach.call(root.querySelectorAll(selector), function (el) { 237 if (el.parentNode) { 238 el.parentNode.removeChild(el); 239 } 240 }); 241 } 242 function showError(noticesHtml) { 243 var sentences = errorSentences(noticesHtml); 244 245 if (!sentences.length) { 246 return; 247 } 248 249 dismiss(); 250 lastFocused = document.activeElement; 251 252 var wrap = document.createElement('div'); 253 wrap.id = PROMPT_ID; 254 wrap.className = 'rl-atc-prompt rl-atc-prompt--error'; 255 wrap.setAttribute('role', 'alertdialog'); 256 wrap.setAttribute('aria-label', CFG.i18n.errorTitle || 'Could not add to cart'); 257 258 var msg = document.createElement('div'); 259 msg.className = 'rl-atc-prompt__msg rl-atc-prompt__notices'; 260 261 if (sentences.length === 1) { 262 var only = document.createElement('p'); 263 only.textContent = sentences[0]; 264 msg.appendChild(only); 265 } else { 266 var list = document.createElement('ul'); 267 268 sentences.forEach(function (sentence) { 269 var li = document.createElement('li'); 270 li.textContent = sentence; 271 list.appendChild(li); 272 }); 273 274 msg.appendChild(list); 275 } 276 277 var actions = document.createElement('div'); 278 actions.className = 'rl-atc-prompt__actions'; 279 280 var ok = document.createElement('button'); 281 ok.type = 'button'; 282 ok.className = 'rl-atc-prompt__no'; 283 ok.textContent = CFG.i18n.close || 'Close'; 284 ok.addEventListener('click', dismiss); 285 286 var close = document.createElement('button'); 287 close.type = 'button'; 288 close.className = 'rl-atc-prompt__close'; 289 close.setAttribute('aria-label', CFG.i18n.close || 'Close'); 290 close.textContent = 'Ã'; 291 close.addEventListener('click', dismiss); 292 293 actions.appendChild(ok); 294 wrap.appendChild(msg); 295 wrap.appendChild(actions); 296 wrap.appendChild(close); 297 document.body.appendChild(wrap); 298 299 document.addEventListener('keydown', onKeydown); 300 ok.focus({ preventScroll: true }); 301 302 // Pointedly no startAutoDismiss() here. 303 } 304 305 /** 306 * Best-effort product name from the button that was clicked. Falls back to the 307 * generic message when the loop markup does not expose a title we recognise. 308 */ 309 function nameFromButton($button) { 310 if (!$button || !$button.length) { 311 return ''; 312 } 313 314 var $card = $button.closest('.product, li.product, .brxe-woocommerce-products li, .card'); 315 316 if (!$card.length) { 317 return ''; 318 } 319 320 return $.trim( 321 $card.find('.woocommerce-loop-product__title, .card-title, h2, h3').first().text() 322 ); 323 } 324 325 // 1. AJAX add-to-cart. Namespaced so it cannot be torn down by the child theme's 326 // own `.off('added_to_cart.radioLogic')` reset. 327 $(document.body).on('added_to_cart.radioLogicPrompt', function (e, fragments, cartHash, $button) { 328 show(nameFromButton($button)); 329 }); 330 331 // 2. Form POST add-to-cart. WooCommerce's added-to-cart notice is the one that 332 // carries a .wc-forward link to the cart; other notices (coupons, errors) do not. 333 function checkAddedNotice() {
334 if (document.getElementById(PROMPT_ID)) { 335 return; 336 } 337 338 if (document.querySelector('.woocommerce-message a.wc-forward')) { 339 show(''); 340 } 341 } 342 343 // 3. Add-to-cart paths that neither reload the page nor fire `added_to_cart`, so neither 344 // of the two routes above can see them. The bundle form is one: it submits through its 345 // own AJAX call in the child theme, which is why bundles never raised this prompt. 346 // Anything in that position announces itself with this event. 347 document.addEventListener('radio-logic:added-to-cart', function (e) { 348 show((e && e.detail && e.detail.productName) || ''); 349 }); 350 351 // 4b. A refused add on an ARCHIVE button. WooCommerce's own AJAX add-to-cart returns no 352 // message and navigates to the product page instead; ajax-add-to-cart.js suppresses that, 353 // fetches the queued notice, and raises this. Same presentation as 4, different origin. 354 document.addEventListener('radio-logic:add-to-cart-error', function (e) { 355 var notices = (e && e.detail && e.detail.notices) || ''; 356 357 showError(notices || CFG.i18n.errorGeneric || 'That item could not be added to your cart.'); 358 }); 359 360 // 4. A refused add. Bricks raises this instead of `added_to_cart` when validation or 361 // stock turns the add down, passing the rendered notices. 362 $(document.body).on('bricks_add_to_cart_error.radioLogicPrompt', function (e, notices) { 363 showError(notices); 364 365 // Bricks writes the same notices into the wrapper at the top of the content. Now 366 // that they are in the prompt, empty it â otherwise the visitor scrolls up later and 367 // meets a stale error about something they have since fixed. Deferred by a tick so 368 // this runs after Bricks' own handler regardless of which bound first. 369 window.setTimeout(function () { 370 $('.woocommerce-notices-wrapper').empty(); 371 }, 0); 372 }); 373 374 /** 375 * 5. A refused bundle is reported ONCE, inline against the item that ran short. 376 * 377 * The bundle form is deliberately not on Bricks' AJAX path (the child theme's capture-phase 378 * guard lets it submit normally, so Product Add-Ons and the bundle's own validation run), so a 379 * refused bundle raises neither `bricks_add_to_cart_error` nor `radio-logic:add-to-cart-error` 380 * and no prompt is involved at all. Instead the child theme puts the reason where it is most 381 * useful â `p.rl-bundle-item-error`, in red, beside the bundled product that could not be 382 * supplied (fix-bundle-product-page.js). 383 * 384 * WooCommerce ALSO renders the same sentence into `.woocommerce-notices-wrapper`, which on this 385 * theme sits at the very top of `#brx-content`. That is the copy nobody reads: the submission is 386 * soft-navigated, so the visitor stays where they were with the explanation scrolled off above 387 * them. Two copies of one refusal, one of them invisible. 388 * 389 * So the wrapper's duplicate is removed and the inline one is left to do the work. Decided 390 * 2026-08-06 â the alternative was to promote the wrapper copy into the prompt, which was built 391 * and measured working, but the inline message points at the offending item and a prompt cannot. 392 * 393 * ONLY WHEN THE INLINE COPY IS ACTUALLY THERE. That guard is the whole safety of this: a 394 * refusal the child theme does not attribute to a bundled item â a bundle-level error, or a 395 * product page it does not handle â keeps the wrapper notice exactly as WooCommerce rendered it, 396 * because removing the only message on screen would be far worse than showing it twice. The two 397 * are rendered by different code at different moments, which is fine: whichever lands second 398 * triggers the observer below and the pair is reconciled then. 399 * 400 * Scoped to product pages regardless. On the cart and checkout the wrapper is precisely where a 401 * notice belongs â a declined coupon is about the form the visitor is looking at. 402 * 403 * Only `.woocommerce-error` nodes are removed; a success message beside them is left alone. 404 */ 405 function dedupeInlineRefusal() { 406 if (!document.body.classList.contains('single-product')) { 407 return; 408 } 409 410 if (!document.querySelector('.rl-bundle-item-error')) { 411 return; 412 } 413 414 var errors = document.querySelectorAll('.woocommerce-notices-wrapper .woocommerce-error'); 415 416 Array.prototype.forEach.call(errors, function (node) { 417 if (node.parentNode) { 418 node.parentNode.removeChild(node); 419 } 420 }); 421 } 422 423 function checkLandedNotices() { 424 dedupeInlineRefusal(); 425 checkAddedNotice(); 426 } 427 428 if (document.readyState === 'loading') { 429 document.addEventListener('DOMContentLoaded', checkLandedNotices); 430 } else { 431 checkLandedNotices(); 432 } 433 434 // Swup re-renders #brx-content without a page load; re-check on its content events. 435 ['swup:contentReplaced', 'contentReplaced', 'swup:page:view'].forEach(function (evt) { 436 document.addEventListener(evt, checkLandedNotices); 437 }); 438 439 /** 440 * ...and watch the DOM as well, because the bundle's refusal arrives through none of the above. 441 * 442 * Measured locally 2026-08-05, submitting the bundle form with its child out of stock: the page 443 * did NOT reload (instrumentation on `window` survived) and NOT ONE event fired â nothing 444 * matching swup/content/brf/page/transition/ajax/cart, on `document`, on `window`, or through 445 * jQuery's trigger. The notice simply appeared in `.woocommerce-notices-wrapper`. Something 446 * posts the form by XHR and swaps the markup in silently. 447 *
448 * So this stops guessing at the transport. However the markup arrives â link navigation, form 449 * POST, full load, or a future mechanism â a landed refusal is noticed here. It also covers the 450 * ordering between the two copies: the wrapper notice and the inline `p.rl-bundle-item-error` 451 * are written by different code, and whichever lands second re-runs this. 452 * 453 * Kept cheap deliberately: the callback runs one selector query per debounce tick rather than 454 * per mutation, and `dedupeInlineRefusal` removes the nodes it takes, so a settled page does no 455 * work at all. 456 */ 457 if (window.MutationObserver) { 458 var pending = null; 459 460 new MutationObserver(function () { 461 if (pending) { 462 return; 463 } 464 465 pending = window.setTimeout(function () { 466 pending = null; 467 468 if (document.querySelector('.woocommerce-notices-wrapper .woocommerce-error')) { 469 checkLandedNotices(); 470 } 471 }, 120); 472 }).observe(document.body, { childList: true, subtree: true }); 473 } 474})(jQuery);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.