PageSourceSearch

https://threedradio.com/wp-content/plugins/radio-logic/assets/js/add-to-cart-prompt.js?ver=1786505588

js threedradio.com collected 2026-09-24 18:28:11 UTC 19,506 bytes, 474 lines download raw bytes

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('&lt;') !== -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.