PageSourceSearch

https://www.pizzaorigin.com.au/js/deals-popup-loader.js?v=

js pizzaorigin.com.au collected 2026-09-24 12:39:24 UTC 25,979 bytes, 594 lines download raw bytes

1/* OrderArt — new deals popup loader (Phase 2)
2 *
3 * Drops the deal-builder popup onto any page and makes a deal's "Add to cart"
4 * open it INSTEAD of the old full-page redirect flow.
5 *
6 * The popup runs in a transparent full-viewport overlay iframe (/products/deal-popup)
7 * so it can't clash with the theme's CSS.
8 *
9 * Loaded globally via global-js-vars.php; OA_DEALS is populated on the deals listing
10 * and fetched lazily (via AJAX_URL.getDealData) on all other pages.
11 */
12if (window.__oaDealLoaderInit) { /* already loaded once — skip re-execution */ }
13else { window.__oaDealLoaderInit = true; }
14(function () {
15  if (window.__oaDealLoaderDone) return;
16  window.__oaDealLoaderDone = true;
17  // Deal keys defined in the popup app (sample data for the pilot).
18  var SAMPLE_KEYS = ['pizza-meal-deal', 'family-feast', 'flying-solo'];
19
20  var frame = null, frameReady = false, pendingMsg = null, toastEl = null;
21
22  // Set when a deal child is removed so the next "added" event only posts the new item(s).
23  // Shape: { dealId, remainingProductIds: { productId: true } }
24  var pendingRefill = null;
25
26  function injectFrame() {
27    if (document.getElementById('oaDealFrame')) { frame = document.getElementById('oaDealFrame'); return; }
28
29    frame = document.createElement('iframe');
30    frame.id = 'oaDealFrame';
31    frame.src = (window.OA_DEAL_POPUP_URL || '/products/deal-popup') + '?v=11';
32    frame.title = 'Deal builder';
33    frame.setAttribute('allowtransparency', 'true');
34    frame.style.cssText = 'display:none;position:fixed;inset:0;width:100%;height:100%;border:0;z-index:99999;background:transparent;';
35    document.body.appendChild(frame);
36
37    toastEl = document.createElement('div');
38    toastEl.id = 'oaDealToast';
39    toastEl.style.cssText = 'position:fixed;bottom:28px;left:50%;transform:translateX(-50%) translateY(16px);background:#1A1410;color:#fff;padding:13px 22px;border-radius:100px;font:600 14px/1 system-ui,sans-serif;box-shadow:0 6px 24px rgba(0,0,0,.32);z-index:100000;opacity:0;transition:opacity .3s, transform .3s;pointer-events:none;white-space:nowrap;';
40    document.body.appendChild(toastEl);
41  }
42
43  function showFrame() { frame.style.display = 'block'; }
44  function hideFrame() { frame.style.display = 'none'; }
45
46  function toast(msg) {
47    if (!toastEl) return;
48    toastEl.textContent = msg;
49    toastEl.style.opacity = '1';
50    toastEl.style.transform = 'translateX(-50%) translateY(0)';
51    setTimeout(function () {
52      toastEl.style.opacity = '0';
53      toastEl.style.transform = 'translateX(-50%) translateY(16px)';
54    }, 3000);
55  }
56
57  function oaCsrf() {
58    if (window.RESTAURANT_CONFIG && window.RESTAURANT_CONFIG.csrf && window.RESTAURANT_CONFIG.csrf.value) return window.RESTAURANT_CONFIG.csrf.value;
59    var m = document.querySelector('meta[name="csrf-token"]');
60    return m ? m.getAttribute('content') : '';
61  }
62  function oaCsrfKey() {
63    if (window.RESTAURANT_CONFIG && window.RESTAURANT_CONFIG.csrf && window.RESTAURANT_CONFIG.csrf.key) return window.RESTAURANT_CONFIG.csrf.key;
64    return '_csrf';
65  }
66  function cartUrl() {
67    if (window.AJAX_URL && window.AJAX_URL.addToCart) return window.AJAX_URL.addToCart;
68    return (window.OA_DEAL_POPUP_URL || '').replace(/\/products\/deal-popup.*/, '') + '/cart/add-to-cart';
69  }
70  function removeItemUrl() {
71    if (window.AJAX_URL && window.AJAX_URL.removeCartItem) return window.AJAX_URL.removeCartItem;
72    return (window.OA_DEAL_POPUP_URL || '').replace(/\/products\/deal-popup.*/, '') + '/cart/remove-item';
73  }
74  function themeLoader(on) {
75    var l = document.querySelector('.loader-wrapper');
76    if (l) l.classList.toggle('active_loader', !!on);
77  }
78  function parseJsonResponse(t) {
79    var marker = 'YII-BLOCK-BODY-BEGIN]]>';
80    var i = t.indexOf(marker);
81    if (i >= 0) t = t.slice(i + marker.length);
82    try { return JSON.parse(t); } catch (e) { return null; }
83  }
84  function cartPost(params) {
85    var csrfKey = oaCsrfKey();
86    params[csrfKey] = oaCsrf();
87    return fetch(cartUrl(), {
88      method: 'POST',
89      credentials: 'same-origin',
90      headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
91      body: new URLSearchParams(params).toString()
92    }).then(function (r) { return r.text(); }).then(parseJsonResponse);
93  }
94  function setHtml(el, html) {
95    // Use jQuery .html() when available so inline scripts execute and delegated
96    // event handlers are correctly re-attached — matches how products-page.998.js updates the cart.
97    if (window.$ && window.$.fn) { $(el).html(html); } else { el.innerHTML = html; }
98  }
99  function applyCart(resp) {
100    if (!resp) return;
101    // add-to-cart returns html_cart; remove-item returns data — handle both.
102    // Use [data-ajax-ui="cartWrapper"] not .sidebarright because cart_tape.php also
103    // renders a .sidebarright div (mobile tape) which querySelector would find f
103irst.
104    var html = resp.html_cart || (typeof resp.data === 'string' ? resp.data : null);
105    if (html) {
106      var sb = document.querySelector('[data-ajax-ui="cartWrapper"]'); if (sb) setHtml(sb, html);
107    }
108    if (resp.html) {
109      var mf = document.querySelector('.mobile_footer_tape'); if (mf) setHtml(mf, resp.html);
110    }
111    if (typeof resp.cart_qty !== 'undefined') {
112      document.querySelectorAll('[data-cart-qty]').forEach(function (e) { e.innerHTML = resp.cart_qty; });
113    }
114  }
115
116  // Force-refresh the cart sidebar from the server, then show toast / reload.
117  function onCartDone(label) {
118    var onCheckout = /\/cart\/checkout/.test(window.location.pathname)
119      || !!document.querySelector('[data-cart-checkout-items-list]');
120    if (onCheckout) {
121      location.reload();
122      return;
123    }
124    // Non-checkout: refresh sidebar HTML from a dedicated endpoint so the panel
125    // always reflects the true session state, regardless of what html_cart the
126    // addToCart response carried.
127    // Prefer the explicit URL; fall back to deriving it from OA_DEAL_POPUP_URL so
128    // the refresh works even when a cached page predates the getCartHtml key.
129    var refreshUrl = (window.AJAX_URL && window.AJAX_URL.getCartHtml)
130      || (window.OA_DEAL_POPUP_URL && window.OA_DEAL_POPUP_URL.replace(/deal-popup(\?.*)?$/, 'cart-html'));
131    if (refreshUrl) {
132      var csrfKey = oaCsrfKey();
133      var body = new URLSearchParams();
134      body.set(csrfKey, oaCsrf());
135      fetch(refreshUrl, {
136        method: 'POST',
137        credentials: 'same-origin',
138        headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
139        body: body.toString()
140      })
141      .then(function (r) { return r.text(); })
142      .then(function (t) {
143        var resp = parseJsonResponse(t);
144        if (resp && resp.html_cart) {
145          var sb = document.querySelector('[data-ajax-ui="cartWrapper"]');
146          if (sb) setHtml(sb, resp.html_cart);
147        }
148      })
149      .catch(function () { /* sidebar stays as-is on network error */ })
150      .finally(function () {
151        themeLoader(false);
152        toast((label || 'Deal') + ' added to cart');
153      });
154    } else {
155      themeLoader(false);
156      toast((label || 'Deal') + ' added to cart');
157    }
158  }
159  function buildProductParams(s) {
160    var params = { id: s.productId, qty: 1, size: s.size || '' };
161    if (s.included) {
162      Object.keys(s.included).forEach(function (iid) {
163        params['default_ingredients[' + iid + ']'] = 'on';
164        params['default_ingredients_quantity[' + iid + ']'] = s.included[iid];
165      });
166    }
167    if (s.addons) {
168      Object.keys(s.addons).forEach(function (aid) {
169        if ((+s.addons[aid] || 0) > 0) {
170          params['add_on_ingredients[' + aid + ']'] = 'on';
171          params['add_on_ingredients_quantity[' + aid + ']'] = s.addons[aid];
172        }
173      });
174    }
175    if (s.bases) {
176      if (s.basesGrouped) {
177        // Group-based ("new product config") products: gkey is a dynamic
178        // `bases_<group_id>` name, not a literal base1/2/3. Same dual-field
179        // pattern as default_ingredients/add_on_ingredients above —
180        // `bases_ingredients[id]` is what CartHelper::getSelectedProductAttributesPrice()
181        // prices from, `bases_ingredients_quantity[id]` is what
182        // CartHelper::getDishAttributes() persists into the cart row.
183        Object.keys(s.bases).forEach(function (gkey) {
184          if (s.bases[gkey]) {
185            params['bases_ingredients[' + s.bases[gkey] + ']'] = 'on';
186            params['bases_ingredients_quantity[' + s.bases[gkey] + ']'] = 1;
187          }
188        });
189      } else {
190        Object.keys(s.bases).forEach(function (gkey) {
191          if (s.bases[gkey]) { params[gkey + '_ingredients[' + s.bases[gkey] + ']'] = 'on'; }
192        });
193      }
194    }
195    if (s.chilli) { params['chilli'] = s.chilli; }
196    return params;
197  }
198
199  // ── Full deal add (new deal: parent + all children) ──
200  function feedCart(d) {
201    if (!d.dealId) { toast((d.label || 'Deal') + ' added'); return; }
202
203    // Save selections so the popup can be pre-filled if a child is deleted later.
204    window.OA_CART_DEALS = window.OA_CART_DEALS || {};
205    window.OA_CART_DEALS[String(d.dealId)] = { label: d.label, selection: (d.selection || []).slice() };
206
207    themeLoader(true);
208    var chain = cartPost({ id: d.dealId, qty: 1, is_deal: 1 }).then(function (r) {
209      applyCart(r);
210      // Detect products auto-linked by the backend (getFirstProdUrlOnDealsAdd) so we
211      // don't post them again and create an orphaned standalone duplicate.
212      var alreadyLinked = {};
213      var html = (r && r.html_cart) ? r.html_cart : '';
214      if (html) {
215        var tmp = document.createElement('div');
216        tmp.innerHTML = html;
217        var dealBlock = tmp.querySelector('[data-deal-id="' + d.dealId + '"]');
218        if (dealBlock) {
219          dealBlock.querySelectorAll('[data-is-deal-child][data-product-id]').forEach(function (el) {
220            alreadyLinked[String(el.getAttribute('data-product-id'))] = true;
221          });
222        }
223      }
224      return alreadyLinked;
225    });
226    (d.selection || []).forEach(function (s) {
227      if (!s.productId) return;
228      // Auto-selected items are already added by the backend (DealsHelper::getFirstProdUrlOnDealsAdd).
229      if (s.autoSelected) return;
230      chain = chain.then(function (alreadyLinked) {
231        if (alreadyLinked[String(s.productId)]) {
232          return alreadyLinked;  // already added by backend; skip to prevent standalone duplicate
233        }
234        return cartPost(buildProductParams(s)).then(function (r) {
235          applyCart(r);
236          return alreadyLinked;
237        });
238      });
239    });
240    chain.then(function () {
241      onCartDone(d.label);
242    }).catch(function (e) {
243      themeLoader(false);
244      if (window.console) console.error('[oaDeal] cart feed failed', e);
245      toast('Could not add deal to cart');
246    });
247  }
248
249  // ── Refill: only add the item(s) the user just picked for the empty slot ──
250  // DealsHelper::dealsLogic scans all deal parents in the cart and automatically
251  // links any newly added item to an unfilled slot — no need to re-post the parent.
252  function addMissingDealChildren(d, remainingProductIds) {
253    var newItems = (d.selection || []).filter(function (s) {
254      return s.productId && !remainingProductIds[String(s.productId)];
255    });
256    if (newItems.length === 0) { toast((d.label || 'Deal') + ' complete'); return; }
257
258    // Update saved selection to reflect the now-complete deal
259    window.OA_CART_DEALS = window.OA_CART_DEALS || {};
260    window.OA_CART_DEALS[String(d.dealId)] = { label: d.label, selection: (d.selection || []).slice() };
261
262    themeLoader(true);
263    var chain = Promise.resolve(null);
264    newItems.forEach(function (s) {
265      chain = chain
266        .then(function () { return cartPost(buildProductParams(s)); })
267        .then(function (r) { applyCart(r); return r; });
268    });
269    chain.then(function () {
270      onCartDone(d.label || 'Deal');
271    }).catch(function (e) {
272      themeLoader(false);
273      if (window.console) console.error('[oaDeal] refill failed', e);
274      toast('Could not update deal');
275    });
276  }
277
278  // Fill each Hot-Deals card's price.
279  function fillDealPrices() {
280    if (!window.OA_DEALS) return;
281    document.querySelectorAll('[data-action="addToCartDeals"]').forEach(function (btn) {
282      var deal = window.OA_DEALS[btn.getAttribute('data-id')];
283      if (!deal || deal.price == null) return;
284      var card = btn.closest('.oad-card');
285      if (!card) return;
286      var priceStr = Number(deal.price).toFixed(2).replace(/\.00$/, '');
287      var amt = card.querySelector('.oad-amt');
288      if (amt && !/\d/.test(amt.textContent)) amt.textContent = priceStr;
289      var bp = card.querySelector('.oad-btnprice');
290      if (bp && !/\d/.test(bp.textContent)) {
291        var sym = (bp.textContent || '$').replace(/[\s\d.]/g, '') || '$';
292        bp.textContent = sym + priceStr;
293      }
294    });
295  }
296
297  // Return the step index (0-based) that owns a given productId in a deal, or -1.
298  function findStepByProduct(dealData, productId) {
299    if (!dealData || !dealData.products || !dealData.steps) return -1;
300    var pid = String(productId);
301    for (var catId in dealData.products) {
302      var prods = dealData.products[catId];
303      var found = false;
304      for (var i = 0; i < prods.length; i++) { if (String(prods[i].id) === pid) { found = true; break; } }
305      if (!found) continue;
306      for (var si = 0; si < dealData.steps.length; si++) {
307        var tiers = dealData.steps[si].tiers || [];
308        for (var ti = 0; ti < tiers.length; ti++) {
309          if (String(tiers[ti].category) === String(catId)) return si;
310        }
311      }
312    }
313    return -1;
314  }
315
316  function openPopup(dealKey, dealData, initialSelections, targetStepIdx) {
317    showFrame();
318    var msg = { type: 'openDeal', dealId: dealKey };
319    if (dealData) msg.deal = dealData;
320    if (initialSelections && initialSelections.length > 0) msg.initialSelections = initialSelections;
321    if (typeof targetStepIdx === 'number' && targetStepIdx >= 0) msg.targetStepIdx = targetStepIdx;
322    if (frameReady) frame.contentWindow.postMessage(msg, '*');
323    else pendingMsg = msg;
324  }
325
326  // Bridge: listen for the popup app's lifecycle messages.
327  window.addEventListener('message', function (e) {
328    var d = e.data || {};
329    if (d.source !== 'oaDealPopup') return;
330    if (d.type === 'ready') {
331      frameReady = true;
332      if (pendingMsg) { frame.contentWindow.postMessage(pendingMsg, '*'); pendingMsg = null; }
333    } else if (d.type === 'closed') {
334      hideFrame();
335      var wasRefilling = !!pendingRefill;
336      pendingRefill = null;
337      if (wasRefilling) {
338        // User closed without picking a replacement — refresh sidebar so the removed
339        // child is no longer shown (applyCart in the remove flow may have injected
340        // stale html_cart; getCartHtml gives us a fresh render after session commits).
341        var refreshUrl = (window.AJAX_URL && window.AJAX_URL.getCartHtml)
342          || (window.OA_DEAL_POPUP_URL && window.OA_DEAL_POPUP_URL.replace(/deal-popup(\?.*)?$/, 'cart-html'));
343        if (refreshUrl) {
344          var rfBody = new URLSearchParams();
345          rfBody.set(oaCsrfKey(), oaCsrf());
346          fetch(refreshUrl, {
347            method: 'POST', credentials: 'same-origin',
348            headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
349            body: rfBody.toString()
350          })
351          .then(function(r) { return r.text(); })
352          .then(function(t) {
353            var resp = parseJsonResponse(t);
354            if (resp && resp.html_cart) { var sb = document.querySelector('[data-ajax-ui="cartWrapper"]'); if (sb) setHtml(sb, resp.html_cart); }
355          })
356          .catch(function() {});
357        }
358      }
359    } else if (d.type === 'added') {
360      hideFrame();
361      var refill = pendingRefill;
362      pendingRefill = null;
363      if (refill && String(refill.dealId) === String(d.dealId)) {
364        // Refill: only post the newly selected item — backend links it to the existing parent.
365        addMissingDealChildren(d, refill.remainingProductIds);
366      } else {
367        feedCart(d);
368      }
369    }
370  });
371
372  // Capture-phase click intercepts — run before jQuery bubble handlers.
373  document.addEventListener('click', function (e) {
374    // ── "Add to cart" for deals ──
375    var btn = e.target && e.target.closest ? e.target.closest('[data-action="addToCartDeals"]') : null;
376    if (btn) {
377      e.preventDefault();
378      e.stopImmediatePropagation();
379      var dealId = btn.getAttribute('data-id');
380      var real = (window.OA_DEALS && window.OA_DEALS[dealId]) ? window.OA_DEALS[dealId] : null;
381      if (real) {
382        openPopup(dealId, real);
383      } else {
384        var fetchUrl = window.AJAX_URL && window.AJAX_URL.getDealData;
385        if (fetchUrl) {
386          themeLoader(true);
387          fetch(fetchUrl + '?id=' + encodeURIComponent(dealId), {
388            credentials: 'same-origin',
389            headers: { 'X-Requested-With': 'XMLHttpRequest' }
390          })
391          .then(function (r) { return r.json(); })
392          .then(function (data) {
393            themeLoader(false);
394            if (data && !data.error) {
395              window.OA_DEALS = window.OA_DEALS || {};
396              window.OA_DEALS[dealId] = data;
397              openPopup(dealId, data);
398            } else {
399              var fb = btn.closest('form') && btn.closest('form').querySelector('[id^="product_redirect_"]');
400              if (fb && fb.getAttribute('href')) window.location.href = fb.getAttribute('href');
401            }
402          })
403          .catch(function () {
404            themeLoader(false);
405            var fb = btn.closest('form') && btn.closest('form').querySelector('[id^="product_redirect_"]');
406            if (fb && fb.getAttribute('href')) window.location.href = fb.getAttribute('href');
407          });
408        } else {
409          var btns = Array.prototype.slice.call(document.querySelectorAll('[data-action="addToCartDeals"]'));
410          var idx = btns.indexOf(btn);
411          openPopup(SAMPLE_KEYS[(idx >= 0 ? idx : 0) % SAMPLE_KEYS.length]);
412        }
413      }
414      return;
415    }
416
417    // ── "Add Item" link for an unfilled deal slot — open popup instead of old route ──
418    var addItemLink = e.target && e.target.closest ? e.target.closest('[data-add-deal-item]') : null;
419    if (addItemLink) {
420      var dealContainer = addItemLink.closest('[data-deal-id]');
421      if (!dealContainer) return;  // not inside a known deal block; let the default handler run
422
423      e.preventDefault();
424      e.stopImmediatePropagation();
425
426      var dealId = String(dealContainer.getAttribute('data-deal-id'));
427
428      // Build remaining selections: prefer in-memory data (includes customizations),
429      // fall back to DOM child buttons (product-id + size-id only).
430      var remaining = [];
431      var remainingProductIds = {};
432      var saved = window.OA_CART_DEALS && window.OA_CART_DEALS[dealId];
433      if (saved && (saved.selection || []).length > 0) {
434        remaining = saved.selection;
435        remaining.forEach(function (s) { remainingProductIds[String(s.productId)] = true; });
436      } else {
437        dealContainer.querySelectorAll('[data-is-deal-child][data-product-id]').forEach(function (btn) {
438          var pid = btn.getAttribute('data-product-id');
439          if (pid && !remainingProductIds[pid]) {
440            remaining.push({ productId: pid, size: btn.getAttribute('data-size-id') || '' });
441            remainingProductIds[pid] = true;
442          }
443        });
444      }
445
446      pendingRefill = { dealId: dealId, remainingProductIds: remainingProductIds };
447
448      var dealData = window.OA_DEALS && window.OA_DEALS[dealId];
449      var targetCatId = String(addItemLink.getAttribute('data-step-category') || '');
450      function findStepIdx(d) {
451        var idx = -1;
452        if (targetCatId && d.steps) {
453          d.steps.forEach(function (step, si) {
454            if (idx >= 0) return;
455            (step.tiers || []).forEach(function (t) { if (String(t.category) === targetCatId) idx = si; });
456          });
457        }
458        return idx;
459      }
460      if (dealData) {
461        openPopup(dealId, dealData, remaining, findStepIdx(dealData));
462      } else {
463        // OA_DEALS not populated (different page) — fetch lazily then open popup
464        var fetchUrl = window.AJAX_URL && window.AJAX_URL.getDealData;
465        if (fetchUrl) {
466          themeLoader(true);
467          fetch(fetchUrl + '?id=' + encodeURIComponent(dealId), {
468            credentials: 'same-origin',
469            headers: { 'X-Requested-With': 'XMLHttpRequest' }
470          })
471          .then(function (r) { return r.json(); })
472          .then(function (data) {
473            themeLoader(false);
474            if (data && !data.error) {
475              window.OA_DEALS = window.OA_DEALS || {};
476              window.OA_DEALS[dealId] = data;
477              openPopup(dealId, data, remaining, findStepIdx(data));
478            } else {
479              window.location.href = addItemLink.getAttribute('data-fallback-url') || addItemLink.getAttribute('href');
480            }
481          })
482          .catch(function () {
483            themeLoader(false);
484            pendingRefill = null;
485            window.location.href = addItemLink.getAttribute('data-fallback-url') || addItemLink.getAttribute('href');
486          });
487        } else {
488          pendingRefill = null;
489          window.location.href = addItemLink.getAttribute('data-fallback-url') || addItemLink.getAttribute('href');
490        }
491      }
492      return;
493    }
494
495    // ── "Remove" for deal child items — delete it, reopen popup to pick a replacement ──
496    var removeBtn = e.target && e.target.closest ? e.target.closest('[data-ajax="removeCartItem"][data-is-deal-child]') : null;
497    if (!removeBtn) return;
498
499    var dealContainer = removeBtn.closest('[data-deal-id]');
500    if (!dealContainer) return;
501
502    e.preventDefault();
503    e.stopImmediatePropagation();  // prevent products-page.998.js location.reload()
504
505    var dealId = String(dealContainer.getAttribute('data-deal-id'));
506    var deletedProductId = String(removeBtn.getAttribute('data-product-id') || '');
507
508    // Build the set of product IDs that will remain in cart after this deletion.
509    // Prefer in-memory OA_CART_DEALS (includes full customisation state); fall back to
510    // DOM scan of the deal block (works after a page refresh when OA_CART_DEALS is unset).
511    var saved = window.OA_CART_DEALS && window.OA_CART_DEALS[dealId];
512    var remaining;
513    var remainingProductIds = {};
514    if (saved && (saved.selection || []).length > 0) {
515      remaining = (saved.selection || []).filter(function (s) {
516        return String(s.productId) !== deletedProductId;
517      });
518    } else {
519      // Read sibling children from the live DOM BEFORE applyCart replaces it.
520      remaining = [];
521      dealContainer.querySelectorAll('[data-is-deal-child][data-product-id]').forEach(function (el) {
522        var pid = el.getAttribute('data-product-id');
523        if (pid && pid !== deletedProductId && !remainingProductIds[pid]) {
524          remaining.push({ productId: pid, size: el.getAttribute('data-size-id') || '' });
525          remainingProductIds[pid] = true;
526        }
527      });
528    }
529    remaining.forEach(function (s) { remainingProductIds[String(s.productId)] = true; });
530
531    // POST the removal.
532    var urlParams = new URLSearchParams(removeBtn.getAttribute('data-params') || '');
533    urlParams.set(oaCsrfKey(), oaCsrf());
534
535    themeLoader(true);
536    fetch(removeItemUrl(), {
537      method: 'POST',
538      credentials: 'same-origin',
539      headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
540      body: urlParams.toString()
541    })
542      .then(function (r) { return r.text(); })
543      .then(function (t) {
544        applyCart(parseJsonResponse(t));
545
546        // Update saved selection to reflect the current cart state.
547        if (saved) {
548          window.OA_CART_DEALS[dealId] = { label: saved.label, selection: remaining };
549        }
550
551        themeLoader(false);
552
553        // Store refill context: next "added" event posts only the new item, not a whole new deal.
554        pendingRefill = { dealId: dealId, remainingProductIds: remainingProductIds };
555
556        var dealData = window.OA_DEALS && window.OA_DEALS[dealId];
557        if (dealData) {
558          // Open at the step the deleted product belongs to so the user can pick a replacement
559          // for that specific slot instead of always starting at Step 1.
560          openPopup(dealId, dealData, remaining, findStepByProduct(dealData, deletedProductId));
561        } else {
562          var fetchUrl = window.AJAX_URL && window.AJAX_URL.getDealData;
563          if (fetchUrl) {
564            fetch(fetchUrl + '?id=' + encodeURIComponent(dealId), {
565              credentials: 'same-origin',
566              headers: { 'X-Requested-With': 'XMLHttpRequest' }
567            })
568            .then(function (r) { return r.json(); })
569            .then(function (data) {
570              if (data && !data.error) {
571                window.OA_DEALS = window.OA_DEALS || {};
572                window.OA_DEALS[dealId] = data;
573              }
574              var fetched = (data && !data.error) ? data : null;
575              openPopup(dealId, fetched, remaining, findStepByProduct(fetched, deletedProductId));
576            })
577            .catch(function () { openPopup(dealId, null, remaining); });
578          } else {
579            openPopup(dealId, null, remaining);
580          }
581        }
582      })
583      .catch(function (err) {
584        themeLoader(false);
585        pendingRefill = null;
586        if (window.console) console.error('[oaDeal] remove-child failed', err);
587        location.reload();
588      });
589  }, true);
590
591  function oaInit() { injectFrame(); fillDealPrices(); }
592  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', oaInit);
593  else oaInit();
594})();

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.