PageSourceSearch

https://forestkebabhouse.co.uk/js/appf.js?v=143

js forestkebabhouse.co.uk collected 2026-10-01 14:30:02 UTC 214,457 bytes, 4,149 lines download raw bytes

1(function () {
2  'use strict';
3
4  var Config = window.BO_V2_CONFIG || window.MENU_V2_CONFIG || {};
5
6  var State = {
7    items: [],
8    categories: [],
9    activeCategory: ''
10  };
11
12  var Dom = {};
13  var toastTimer = null;
14  var PRODUCT_DESCRIPTION_LIMIT = 58;
15
16  // One scroll lock for all open dialogs, including stacked auth/legal dialogs.
17  var DialogLayout = {
18    saved: null,
19    syncLock: function () {
20      var open = document.querySelector('.bo-dialog[open]');
21      var body = document.body;
22      if (open && !DialogLayout.saved) {
23        DialogLayout.saved = { x: window.scrollX, y: window.scrollY, css: body.style.cssText };
24        body.style.position = 'fixed';
25        body.style.top = -DialogLayout.saved.y + 'px';
26        body.style.left = -DialogLayout.saved.x + 'px';
27        body.style.width = '100%';
28        body.style.overflow = 'hidden';
29        document.documentElement.classList.add('bo-modal-open');
30      } else if (!open && DialogLayout.saved) {
31        var saved = DialogLayout.saved;
32        DialogLayout.saved = null;
33        body.style.cssText = saved.css;
34        document.documentElement.classList.remove('bo-modal-open');
35        var root = document.documentElement;
36        var behavior = root.style.scrollBehavior;
37        root.style.scrollBehavior = 'auto';
38        window.scrollTo(saved.x, saved.y);
39        root.style.scrollBehavior = behavior;
40      }
41    },
42    measure: function () {
43      var viewport = window.visualViewport;
44      var top = viewport ? viewport.offsetTop : 0;
45      var height = viewport ? viewport.height : window.innerHeight;
46      var root = document.documentElement;
47      var nav = document.querySelector('.bo-bottom-nav');
48      var rect = nav && nav.getBoundingClientRect();
49      var navInset = rect && rect.height && rect.top < top + height
50        ? Math.max(0, top + height - rect.top) : 0;
51      var app = document.body.classList.contains('bo-page--app');
52      var android = app && (Config.platform === 'android' || /Android/i.test(navigator.userAgent));
53      // Native UI cannot be measured from DOM. The host may supply the overlap
54      // in CSS pixels, including zero when it already resizes the WebView.
55      var explicitInset = window.BO_NATIVE_BOTTOM_INSET;
56      var nativeInset = typeof explicitInset === 'number' && Number.isFinite(explicitInset)
57        ? Math.max(0, explicitInset)
58        : (android ? parseFloat(getComputedStyle(root).getPropertyValue('--bo-bottom-height')) || 74 : 0);
59      root.style.setProperty('--bo-modal-top', (top + 10) + 'px');
60      var availableHeight = Math.max(1, height - Math.max(navInset, nativeInset) - 20);
61      root.style.setProperty('--bo-modal-height', availableHeight + 'px');
62      document.querySelectorAll('.bo-dialog[open]').forEach(function (dialog) {
63        var dialogHeight = dialog.getBoundingClientRect().height;
64        // Centre within the usable viewport, then lift by 3.5% of its height.
65        // Tall dialogs keep the same safe top and bottom limits.
66        var offset = Math.max(0, (availableHeight - dialogHeight) / 2 - height * 0.035);
67        dialog.style.setProperty('--bo-dialog-top', (top + 10 + offset) + 'px');
68      });
69    },
70    pinActions: function () {
71      var body = Dom.productDialogBody;
72      var actions = body && body.querySelector('.bo-product-actions');
73      if (!actions || actions.parentElement === body) return;
74      var scroller = document.createElement('div');
75      scroller.className = 'bo-product-scroll';
76      // Keep controls within productDialogBody so existing pricing, quantity,
77      // customization and cart selectors continue to address the same nodes.
78      actions.remove();
79      while (body.firstChild) scroller.appendChild(body.firstChild);
80      body.append(scroller, actions);
81    },
82    init: function () {
83      document.querySelectorAll('.bo-dialog').forEach(function (dialog) {
84        dialog.addEventListener('close', DialogLayout.syncLock);
85        new MutationObserver(function () {
86          DialogLayout.syncLock();
87          DialogLayout.measure();
88        }).observe(dialog, { attributes: true, attributeFilter: ['open'] });
89        if (typeof ResizeObserver !== 'undefined') {
90          // Images, validation messages and option changes can alter height.
91          new ResizeObserver(DialogLayout.measure).observe(dialog);
92        }
93      });
94      window.addEventListener('resize', DialogLayout.measure);
95      window.addEventListener('bo-native-insets-changed', DialogLayout.measure);
96      if (window.visualViewport) {
97        window.visualViewport.addEventListener('resize', DialogLayout.measure);
98        window.visualViewport.addEventListener('scroll', DialogLayout.measure);
99      }
100      DialogLayout.measure();
101    }
102  };
103
104  var Utils = {
105    toArray: function (value) {
106      if (Array.isArray(value)) return value;
107      return String(value || '').split(',').map(function (part) {
108        return part.trim();
109      }).filter(Boolean);
110    },
111
112    categoryKey: function (value) {
113      return String(value || '').replace(/\s+/g, '').trim();
114    },
115
116    numericPrices: function (value) {
117      return String(value || '').split(',').map(function (price) {
118        return Number.parseFloat(price);
119      }).filter(function (price) {
120        return Number.isFinite(price) && price >= 0;
121      });
122    },
123
124    values: function (value) {
125      if (Array.isArray(value)) return value.map(String);
126      return String(value == null ? '' : value).split(',').map(function (part) {
127        return part.trim();
128      });
129    },
130
131    branchJson: function (value, fallback) {
132      if (Array.isArray(value)) return value;
133      try {
134        var parsed = JSON.parse(value || '');
135        return Array.isArray(parsed) ? parsed : fallback;
136      } catch (error) {
137        return fallback;
138      }
139    },
140
141    additionalSaladGroups: function (value) {
142      var parsed = value;
143      if (typeof parsed === 'string') {
144        try { parsed = JSON.parse(parsed); } catch (error) { return []; }
145      }
146      var groups = Array.isArray(parsed) ? parsed : parsed && parsed.groups;
147      if (!Array.isArray(groups)) return [];
148      return groups.filter(function (group) {
149        return group && (group.type === 'checkbox' || group.type === 'radio') && Array.isArray(group.options);
150      }).map(function (group) {
151        return {
152          heading: String(group.heading || '').trim(),
153          type: group.type,
154          options: group.options.filter(function (option) {
155            return option && typeof option.name === 'string' && option.name.trim() &&
156              option.price !== null && option.price !== '' &&
157              Number.isFinite(Number(option.price)) && Number(option.price) >= 0;
158          }).map(function (option) {
159            return { name: option.name.trim(), price: Number(option.price) };
160          })
161        };
162      }).filter(function (group) { return group.options.length; });
163    },
164
165    optionLabel: function (value) {
166      if (value && typeof value === 'object') {
167        return String(value.topping || value.name || value.extra || value.label || '').trim();
168      }
169      return String(value == null ? '' : value).trim();
170    },
171
172    enabled: function (value) {
173      return ['true', 'yes', '1'].indexOf(String(value == null ? '' : value).trim().toLowerCase()) !== -1;
174    },
175
176    escapeHtml: function (value) {
177      return String(value == null ? '' : value).replace(/[&<>'"]/g, function (character) {
178        return { '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[character];
179      });
180    },
181
182    displayPrices: function (value) {
183      var prices = Utils.numericPrices(value);
184      if (!prices.length) return '';
185      if (prices.length === 1 || prices[0] === prices[prices.length - 1]) {
186        return '£' + prices[0].toFixed(2);
187      }
188      return '£' + prices[0].toFixed(2) + ' – £' + prices[prices.length - 1].toFixed(2);
189    },
190
191    shortenedText: function (value, limit) {
192      var text = String(value || '').replace(/\s+/g, ' ').trim();
193      var maximum = Math.max(1, Number(limit) || PRODUCT_DESCRIPTION_LIMIT);
194      if (text.length <= maximum) return { text: text, shortened: false };
195      var cut = text.slice(0, maximum + 1);
196      var lastSpace = cut.lastIndexOf(' ');
197      if (lastSpace >= Math.floor(maximum * .65)) cut = cut.slice(0, lastSpace);
198      else cut = cut.slice(0, maximum);
199      return { text: cut.replace(/[\s,;:.-]+$/, '') + '…', shortened: true };
200    },
201
202    quantityLocked: function (item) {
203      var type = String((item || {}).type || '').trim().toLowerCase();
204      var typex = String((item || {}).typex || '').trim().toLowerCase();
205      return type === 'offer' || (typex !== '' && typex !== 'no');
206    },
207
208    cartKeys: function () {
209      return Object.keys(window.localStorage).filter(function (key) {
210        return /^\d+$/.test(key) || key === 'shop_free_selection';
211      }).sort(function (a, b) {
212        if (a === 'shop_free_selection') return 1;
213        if (b === 'shop_free_selection') return -1;
214        return Number(a) - Number(b);
215      });
216    },
217
218    cartItems: function () {
219      return Utils.cartKeys().map(function (key) {
220        try {
221          var item = JSON.parse(window.localStorage.getItem(key));
222          if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
223          item._storageKey = key;
224          return item;
225        } catch (error) {
226          return null;
227        }
228      }
228).filter(Boolean);
229    },
230
231    customizationLines: function (item) {
232      var customization = item.customization || {};
233      var html = String(customization.additions || '') + ' ' + String(customization.features || '');
234      if (!html.trim()) return [];
235      var container = document.createElement('div');
236      container.innerHTML = html;
237      var blocks = Array.from(container.querySelectorAll('p, li, div')).map(function (node) {
238        return String(node.textContent || '').replace(/\s+/g, ' ').trim();
239      }).filter(Boolean);
240      if (blocks.length) return blocks;
241      return String(container.textContent || '').split(/\n|\s{2,}/).map(function (line) {
242        return line.replace(/\s+/g, ' ').trim();
243      }).filter(Boolean);
244    },
245
246    displayCustomizationLines: function (item) {
247      var customization = item.customization || {};
248      return Utils.customizationLines(item).map(function (line) {
249        if (!/^Extra: /.test(line)) return line;
250        var legacy = line.match(/\(\+£([\d.]+) per pizza\)$/);
251        var charge = customization.extraToppingsUnitCharge;
252        if (charge == null && legacy) charge = Number(legacy[1]);
253        if (charge == null || !Number.isFinite(Number(charge))) return line;
254        var total = Number(charge) * Math.max(1, Number(item.amount) || 1);
255        var label = line.replace(/\s*\((?:\+£[\d.]+(?: per pizza)?|included)\)$/, '');
256        return label + (total > 0 ? ' (+£' + total.toFixed(2) + ')' : ' (included)');
257      });
258    },
259
260    customizationText: function (item) {
261      return Utils.customizationLines(item).join(' ');
262    },
263
264    showDialog: function (dialog) {
265      if (!dialog) return;
266      if (dialog.open || dialog.hasAttribute('open')) return;
267      DialogLayout.measure();
268      if (typeof dialog.showModal === 'function') dialog.showModal();
269      else dialog.setAttribute('open', '');
270      DialogLayout.syncLock();
271      DialogLayout.measure();
272    },
273
274    resetProductScroll: function () {
275      var dialog = Dom.productDialog;
276      if (!dialog) return;
277      DialogLayout.pinActions();
278      [dialog, dialog.querySelector('.bo-dialog__surface'), Dom.productDialogBody, dialog.querySelector('.bo-product-scroll')].forEach(function (node) {
279        if (node) { node.scrollTop = 0; node.scrollLeft = 0; }
280      });
281      Utils.trackProductDialogHeight();
282    },
283
284    trackProductDialogHeight: function () {
285      DialogLayout.measure();
286    },
287
288    closeDialog: function (dialog) {
289      if (!dialog) return;
290      if (typeof dialog.close === 'function') dialog.close();
291      else dialog.removeAttribute('open');
292      DialogLayout.syncLock();
293    },
294
295    toast: function (message, type) {
296      window.clearTimeout(toastTimer);
297      var activeProductSurface = Dom.productDialog &&
298        (Dom.productDialog.open || Dom.productDialog.hasAttribute('open')) &&
299        Dom.productDialog.querySelector('.bo-dialog__surface');
300      var host = type === 'error' && activeProductSurface ? activeProductSurface : document.body;
301      if (Dom.toast.parentNode !== host) host.appendChild(Dom.toast);
302      Dom.toast.textContent = message;
303      Dom.toast.classList.toggle('is-error', type === 'error');
304      Dom.toast.setAttribute('role', type === 'error' ? 'alert' : 'status');
305      Dom.toast.hidden = false;
306      toastTimer = window.setTimeout(function () {
307        Dom.toast.hidden = true;
308      }, 2400);
309    },
310
311    requiredPrompt: function (control, fallback) {
312      var label = String((control && (control.dataset.requiredLabel || control.getAttribute('aria-label'))) || fallback || 'an option').trim();
313      label = label.replace(/^please\s+choose\s+/i, '').replace(/^choose\s+/i, '');
314      return 'Please choose ' + label + '.';
315    },
316
317    requireChoice: function (control, message, container) {
318      if (!control) {
319        Utils.toast(message, 'error');
320        return;
321      }
322      var target = container || control.closest('.bo-deal-step__content, .bo-dynamic-select, .bo-config-section') || control;
323      target.classList.add('is-required-missing');
324      control.setAttribute('aria-invalid', 'true');
325      try { target.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (error) {}
326      window.setTimeout(function () {
327        try { control.focus({ preventScroll: true }); } catch (error) { control.focus(); }
328      }, 180);
329      var clear = function () {
330        target.classList.remove('is-required-missing');
331        control.removeAttribute('aria-invalid');
332      };
333      control.addEventListener('change', clear, { once: true });
334      Utils.toast(message, 'error');
335    }
336  };
337
338  var InfoDialog = {
339    open: function (item) {
340      Dom.infoDialogTitle.textContent = item.itemName || 'Item information';
341      Dom.infoDialogText.textContent = String(item.explanation || '').replace(/\s+/g, ' ').trim();
342      Utils.showDialog(Dom.infoDialog);
343    }
344  };
345
346  var FontProfile = {
347    probe: null,
348    expectedWidth: 0,
349    observer: null,
350    init: function () {
351      try {
352        var probe = document.createElement('span');
353        probe.textContent = 'MMMMMMMMMM';
354        probe.setAttribute('aria-hidden', 'true');
355        probe.style.cssText = 'position:fixed;left:-10000px;top:0;visibility:hidden;pointer-events:none;display:inline-block;width:auto;max-width:none;padding:0;border:0;margin:0;font:normal 16px Arial;line-height:normal;letter-spacing:0;white-space:nowrap;';
356        document.body.appendChild(probe);
357        FontProfile.probe = probe;
358        var context = document.createElement('canvas').getContext('2d');
359        if (context) { context.font = '16px Arial'; FontProfile.expectedWidth = context.measureText(probe.textContent).width; }
360        if (window.ResizeObserver) {
361          FontProfile.observer = new window.ResizeObserver(FontProfile.apply);
362          FontProfile.observer.observe(probe);
363        }
364      } catch (error) { /* Width and root-font checks remain available. */ }
365      FontProfile.apply();
366      if (document.fonts && document.fonts.ready) document.fonts.ready.then(FontProfile.apply);
367    },
368    apply: function () {
369      var rootSize = Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16;
370      document.body.classList.remove('bo-font-normal', 'bo-font-large', 'bo-font-extreme');
371      if (rootSize >= 22) document.body.classList.add('bo-font-extreme');
372      else if (rootSize >= 18) document.body.classList.add('bo-font-large');
373      else document.body.classList.add('bo-font-normal');
374      document.body.dataset.rootFontSize = String(rootSize);
375      var renderedScale = FontProfile.probe && FontProfile.expectedWidth > 0 ? FontProfile.probe.getBoundingClientRect().width / FontProfile.expectedWidth : 1;
376      var scale = Math.max(rootSize / 16, renderedScale || 1);
377      var width = document.documentElement.clientWidth || window.innerWidth;
378      document.body.classList.toggle('bo-enlarged-text', width <= 700 && scale >= 1.10);
379      document.body.classList.toggle('bo-accessible-layout', width <= 700 && scale >= 1.75);
380      document.body.dataset.textScale = scale.toFixed(2);
381    }
382  };
383
384  var Api = {
385    getMenuItems: function () {
386      return window.fetch('/branchItems', {
387        method: 'GET',
388        credentials: 'same-origin',
389        headers: { Accept: 'application/json' }
390      }).then(function (response) {
391        if (!response.ok) throw new Error('Menu request failed with status ' + response.status);
392        return response.json();
393      }).then(function (items) {
394        return Array.isArray(items) ? items : [];
395      });
396    }
397  };
398
399  var Cart = {
400    fixedPromotionPrice: function (typex, portion) {
401      var raw = String(typex || '').trim().toLowerCase();
402      if (raw.indexOf('fixed_') !== 0) return null;
403      var config = raw.slice(6);
404      if (config.indexOf(':') === -1 && config.indexOf('|') === -1) {
405        var fixed = Number.parseFloat(config);
406        return Number.isFinite(fixed) ? Number(fixed.toFixed(2)) : null;
407      }
408      var portionMatch = String(portion || '').toLowerCase().match(/\d+(?:\.\d+)?/);
409      if (!portionMatch) return null;
410      var pairs = config.split('|');
411      for (var index = 0; index < pairs.length; index += 1) {
412        var parts = pairs[index].split(':');
413        var price = Number.parseFloat(parts[1]);
414        if (parts.length === 2 && String(parts[0]).trim() === portionMatch[0] && Number.isFinite(price)) {
415          return Number(price.toFixed(2));
416        }
417      }
418      return null;
419    },
420
421    promotionEligible: function (item) {
422      if (!item || String(item.name || '') === 'Delivery Fee') return false;
423      var raw = String(item.typex == null ? '' : item.typex).trim().toLowerCase();
424      if (!raw || raw === 'no') return false;
425      if (raw.indexOf('fixed_') === 0) return true;
426      var percentage = Number.parseFloat(raw);
427      return Number.isFinite(percentage) && percentage >= 0 && percentage <= 100;
428    },
429
430    promotionGroupKey: function (item) {
431      var divider = Number.parseInt(item.divider, 10);
432      if (!Number.isFinite(divider) || divider <= 0) divider = 2;
433      var typex = String(item.typex || '').trim().toLowerCase();
434      if (typex.indexOf('fixed_') === 0 && typex.indexOf(':') !== -1) {
435        return divider + '|' + typex + '|' + String(item.portion || '').trim().toLowerCase();
436      }
437      return divider + '|' + typex;
438    },
439
440    discountedPromotionBase: function (item) {
441      var typex = String(item.typex || '').trim().toLowerCase();
442      if (typex.indexOf('fixed_') === 0) {
443        var fixed = Cart.fixedPromotionPrice(item.typex, item.portion);
444        return fixed === null ? Number(item.rPrice || item.basePrice || 0) : fixed;
445      }
446      var percentage = Number.parseFloat(typex);
447      if (!Number.isFinite(percentage)) return Number(item.rPrice || item.basePrice || 0);
448      return Number((Number(item.rPrice || item.basePrice || 0) * (percentage / 100)).toFixed(2));
449    },
450
451    applyPromotions: function () {
452      var items = Utils.cartItems();
453      var promotional = [];
454      var groupedUnits = {};
455
456      items.forEach(function (item) {
457        if (!Cart.promotionEligible(item)) return;
458        item.rName = item.rName || item.name || item.displayName || '';
459        item.amount = Math.max(1, Number(item.amount) || 1);
460        item.basePrice = Number(item.basePrice) || 0;
461        item.fullPrice = Number(item.fullPrice) || item.basePrice;
462        if (item.rPrice == null) item.rPrice = item.basePrice;
463        if (item.ePrice == null) item.ePrice = Math.max(0, item.fullPrice - item.basePrice);
464        promotional.push(item);
465
466        var groupKey = Cart.promotionGroupKey(item);
467        if (!groupedUnits[groupKey]) groupedUnits[groupKey] = [];
468        for (var unit = 0; unit < item.amount; unit += 1) {
469          groupedUnits[groupKey].push({
470            key: item._storageKey,
471            basePrice: Number(item.rPrice || item.basePrice || 0)
472          });
473        }
474      });
475
476      if (!promotional.length) return;
477      var discountedCounts = {};
478      Object.keys(groupedUnits).forEach(function (groupKey) {
479        var units = groupedUnits[groupKey];
480        units.sort(function (left, right) { return right.basePrice - left.basePrice; });
481        var divider = Number.parseInt(groupKey.split('|')[0], 10);
482        if (!Number.isFinite(divider) || divider <= 0) divider = 2;
483        for (var index = divider - 1; index < units.length; index += divider) {
484          discountedCounts[units[index].key] = (discountedCounts[units[index].key] || 0) + 1;
485        }
486      });
487
488      promotional.forEach(function (item) {
489        var discountedCount = Math.min(item.amount, discountedCounts[item._storageKey] || 0);
490        var normalCount = item.amount - discountedCount;
491        var normalBase = Number(item.rPrice || item.basePrice || 0);
492        var extra = Number(item.ePrice || 0);
493        var discountedBase = Cart.discountedPromotionBase(item);
494        item.basePrice = Number(((normalCount * normalBase) + (discountedCount * discountedBase)).toFixed(2));
495        item.fullPrice = Number((item.basePrice + (extra * item.amount)).toFixed(2));
496        item.name = item.rName;
497        item.displayName = item.rName;
498
499        if (discountedCount > 0) {
500          var typex = String(item.typex || '').trim().toLowerCase();
501          var percentage = Number.parseFloat(typex);
502          if (typex === '0') item.displayName = item.rName + ' (FREE)';
503          else if (typex.indexOf('fixed_') === 0) {
504            var fixed = Cart.fixedPromotionPrice(item.typex, item.portion);
505            if (fixed !== null) item.displayName = item.rName + ' (£' + fixed.toFixed(2) + ')';
506          } else if (Number.isFinite(percentage) && percentage < 100) {
507            item.displayName = item.rName + ' (' + percentage + '% OFF)';
508          }
509        }
510
511        var stored = Object.assign({}, item);
512        delete stored._storageKey;
513        window.localStorage.setItem(String(item._storageKey), JSON.stringify(stored));
514      });
515    },
516
517    animateAdd: function (imageUrl) {
518      var target = Dom.cartButton;
519      if (!target) return;
520      var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
521      target.classList.remove('is-cart-bumping');
522      if (reduceMotion) return;
523
524      var source = Dom.productDialogBody.querySelector('.bo-simple-product-image');
525      var start = source ? source.getBoundingClientRect() : Dom.productDialog.getBoundingClientRect();
526      var finish = target.getBoundingClientRect();
527      var flyer = document.createElement(imageUrl ? 'img' : 'span');
528      flyer.className = 'bo-cart-flyer';
529      if (imageUrl) flyer.src = imageUrl;
530      else flyer.textContent = '+';
531      flyer.style.left = (start.left + start.width / 2 - 21) + 'px';
532      flyer.style.top = (start.top + Math.min(start.height / 2, 120) - 21) + 'px';
533      if (!imageUrl) {
534        flyer.style.display = 'grid';
535        flyer.style.placeItems = 'center';
536        flyer.style.background = 'var(--bo-success)';
537        flyer.style.color = '#fff';
538        flyer.style.fontWeight = '800';
539        flyer.style.fontSize = '26px';
540      }
541      document.body.appendChild(flyer);
542      var deltaX = finish.left + finish.width / 2 - (start.left + start.width / 2);
543      var deltaY = finish.top + finish.height / 2 - (start.top + Math.min(start.height / 2, 120));
544      window.requestAnimationFrame(function () {
545        flyer.style.transform = 'translate(' + deltaX + 'px,' + deltaY + 'px) scale(.25)';
546        flyer.style.opacity = '0.25';
547      });
548      window.setTimeout(function () {
549        flyer.remove();
550        target.classList.add('is-cart-bumping');
551        window.setTimeout(function () { target.classList.remove('is-cart-bumping'); }, 300);
552      }, 310);
553    },
554
555    groupingKey: function (item) {
556      var amount = Math.max(1, Number(item.amount) || 1);
557      var unitBase = (Number(item.basePrice) || 0) / amount;
558      var unitFull = (Number(item.fullPrice) || 0) / amount;
559      return JSON.stringify([
560        String(item.id || ''),
561        String(item.name || item.itemName || ''),
562        String(item.portion || ''),
563        String(item.type || ''),
564        String(item.typex || ''),
565        String(item.offersx || ''),
566        Utils.customizationText(item),
567        unitBase.toFixed(4),
568        unitFull.toFixed(4)
569      ]);
570    },
571
572    groupItems: function (items) {
573      var groups = new Map();
574      items.forEach(function (item) {
575        var key = Cart.groupingKey(item);
576        var amount = Math.max(1, Number(item.amount) || 1);
577        if (!groups.has(key)) {
578          var grouped = Object.assign({}, item);
579          grouped.amount = amount;
580          grouped.basePrice = (Number(item.basePrice) || 0).toFixed(2);
581          grouped.fullPrice = (Number(item.fullPrice) || 0).toFixed(2);
582          grouped._storageKeys = [item._storageKey];
583          groups.set(key, grouped);
584          return;
585        }
586        var existing = groups.get(key);
587        existing.amount += amount;
588        existing.basePrice = ((Number(existing.basePrice) || 0) + (Number(item.basePrice) || 0)).toFixed(2);
589        existing.fullPrice = ((Number(existing.fullPrice) || 0) + (Number(item.fullPrice) || 0)).toFixed(2);
590        existing._storageKeys.push(item._storageKey);
591      });
592      return Array.from(groups.values());
593    },
594
595    summary: function () {
596      Cart.applyPromotions();
597      var storedItems = Utils.cartItems();
598      var total = storedItems.reduce(function (sum, item) {
599        return sum + (Number(item.fullPrice) || 0);
600      }, 0);
601      var count = storedItems.reduce(function (sum, item) {
602        return sum + Math.max(1, Number(item.amount) || 1);
603      }, 0);
604      return { count: count, total: total, items: Cart.groupItems(storedItems) };
605    },
606
607    refresh: function () {
608      var cart = Cart.summary();
609      Dom.cartSummary.textContent = cart.count + ' Items - £' + cart.total.toFixed(2);
610      Dom.cartDialogSummary.textContent = cart.count + ' Items - £' + cart.total.toFixed(2);
611      Cart.render(cart.items);
612
613      try {
614        if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.scriptHandler) {
615          window.webkit.messageHandlers.scriptHandler.postMessage(String(cart.count));
616        }
617      } catch (error) {}
618    },
619
620    render: function (items) {
621      Dom.cartList.replaceChildren();
622      if (!items.length) {
623        var empty = document.createElement('div');
624        empty.className = 'bo-cart-empty';
625        empty.textContent = 'Your cart is empty.';
626        Dom.cartList.appendChild(empty);
627        return;
628      }
629
630      items.forEach(function (item) {
631        var row = document.createElement('article');
632        row.className = 'bo-cart-item';
633
634        var image = document.createElement('img');
635        image.alt = '';
636        image.loading = 'lazy';
637        image.src = item.image || '';
638
639        var text = document.createElement('div');
640        text.className = 'bo-cart-item__content';
641        var heading = document.createElement('div');
642        heading.className = 'bo-cart-item__heading';
643        var name = document.createElement('div');
644        name.className = 'bo-cart-item__name';
645        name.textContent = (Number(item.amount) || 1) + ' × ' + [item.portion, item.displayName || item.name].filter(Boolean).join(' - ');
646
647        var detailLines = Utils.displayCustomizationLines(item);
648        if (detailLines.length) {
649          var details = document.createElement('div');
650          details.className = 'bo-cart-item__details';
651          detailLines.forEach(function (line) {
652            var detail = document.createElement('span');
653            detail.className = 'bo-cart-item__detail-line';
654            detail.textContent = line;
655            details.appendChild(detail);
656          });
657          text.appendChild(details);
658        }
659
660        var price = document.createElement('div');
661        price.className = 'bo-cart-item__price';
662        price.textContent = '£' + (Number(item.fullPrice) || 0).toFixed(2);
663
664        var remove = document.createElement('button');
665        remove.type = 'button';
666        remove.className = 'bo-cart-item__remove';
667        remove.setAttribute('aria-label', 'Remove ' + (item.displayName || item.name || 'item') + ' from cart');
668        remove.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3m-8 0 1 13h8l1-13M10 10v7m4-7v7"/></svg>';
669        remove.addEventListener('click', function () {
670          (item._storageKeys || [item._storageKey]).filter(Boolean).forEach(function (key) {
671            window.localStorage.removeItem(String(key));
672          });
673          Cart.refresh();
674        });
675
676        heading.append(name, price, remove);
677        text.prepend(heading);
678        row.append(image, text);
679        Dom.cartList.appendChild(row);
680      });
681    },
682
683    open: function () {
684      Cart.refresh();
685      Utils.showDialog(Dom.cartDialog);
686    }
687  };
688
689  var Navigation = {
690    openDrawer: function () {
691      if (!Dom.drawer) return;
692      Dom.drawer.classList.add('is-open');
693      Dom.drawer.setAttribute('aria-hidden', 'false');
694      Dom.drawerBackdrop.hidden = false;
695      Dom.menuButton.setAttribute('aria-expanded', 'true');
696    },
697
698    closeDrawer: function () {
699      if (!Dom.drawer) return;
700      if (Dom.drawer.contains(document.activeElement)) {
701        if (Dom.menuButton) Dom.menuButton.focus();
702        if (Dom.drawer.contains(document.activeElement)) document.activeElement.blur();
703      }
704      Dom.drawer.classList.remove('is-open');
705      Dom.drawer.setAttribute('aria-hidden', 'true');
706      Dom.drawerBackdrop.hidden = true;
707      Dom.menuButton.setAttribute('aria-expanded', 'false');
708    }
709  };
710
711  var Auth = {
712    appleSdkPromise: null,
713    appleBusy: false,
714    addressResolver: null,
715    selectedAddress: '',
716
717    formBody: function (data) {
718      var body = new URLSearchParams();
719      Object.keys(data || {}).forEach(function (key) { body.append(key, data[key] == null ? '' : data[key]); });
720      return body;
721    },
722
723    request: function (url, data, method) {
724      var options = { method: method || 'POST', credentials: 'same-origin', headers: { Accept: 'text/plain' } };
725      if (options.method !== 'GET') {
726        options.headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
727        options.body = Auth.formBody(data);
728      }
729      return window.fetch(url, options).then(function (response) {
730        return response.text().then(function (text) { return { status: response.status, text: text }; });
731      });
732    },
733
734    dialog: function (name) {
735      return document.getElementById('bo' + name.charAt(0).toUpperCase() + name.slice(1) + 'Dialog');
736    },
737
738    open: function (name) {
739      ['login', 'register', 'forgot'].forEach(function (candidate) {
740        var dialog = Auth.dialog(candidate);
741        if (dialog && dialog.open) Utils.closeDialog(dialog);
742      });
743      Navigation.closeDrawer();
744      var wanted = Auth.dialog(name);
745      if (wanted) Utils.showDialog(wanted);
746      else if (name === 'login') {
747        var loginPanel = document.querySelector('.bo-checkout-login');
748        if (loginPanel) {
749          loginPanel.hidden = false;
750          var grid = document.getElementById('boCheckoutGrid');
751          if (grid) grid.hidden = true;
752          var email = document.getElementById('boLoginEmail');
753          if (email) email.focus({ preventScroll: true });
754          loginPanel.scrollIntoView({ block: 'start', behavior: 'smooth' });
755        }
756      }
757    },
758
759    setBusy: function (form, busy) {
760      if (!form) return;
761      form.querySelectorAll('button, input').forEach(function (control) { control.disabled = Boolean(busy); });
762      form.setAttribute('aria-busy', busy ? 'true' : 'false');
763    },
764
765    validTelephone: function (value) {
766      var telephone = String(value || '').trim().replace(/[\s().-]/g, '');
767      if (telephone.indexOf('00') === 0) telephone = '+' + telephone.slice(2);
768      if (telephone.indexOf('+44') === 0) telephone = '0' + telephone.slice(3);
769      return (/^0\d{10}$/.test(telephone) || /^\+[1-9]\d{7,14}$/.test(telephone)) ? telephone : '';
770    },
771
772    suspiciousAddress: function (address, postcode) {
773      var cleanAddress = String(address || '').trim().toUpperCase();
774      var cleanPostcode = String(postcode || '').trim().toUpperCase().replace(/\s+/g, '');
775      return !/\d/.test(cleanAddress) || (cleanPostcode && cleanAddress.replace(/\s+/g, '').indexOf(cleanPostcode) !== -1);
776    },
777
778    addressSuggestions: function (postcode, address) {
779      var url = '/address-suggestions?postcode=' + encodeURIComponent(postcode) + '&address=' + encodeURIComponent(address);
780      return window.fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' } }).then(function (response) {
781        if (!response.ok) return [];
782        return response.json();
783      }).then(function (result) {
784        return result && Array.isArray(result.suggestions) ? result.suggestions : [];
785      }).catch(function () { return []; });
786    },
787
788    chooseAddress: function (message, suggestions, allowTyped) {
789      return new Promise(function (resolve) {
790        var dialog = document.getElementById('boAddressDialog');
791        var messageElement = document.getElementById('boAddressMessage');
792        var list = document.getElementById('boAddressSuggestions');
793        if (!dialog || !messageElement || !list) { resolve({ action: 'typed' }); return; }
794        messageElement.textContent = message;
795        list.replaceChildren();
796        Auth.selectedAddress = '';
797        var typedButton = dialog.querySelector('[data-address-action="typed"]');
798        if (typedButton) typedButton.hidden = allowTyped === false;
799        dialog.classList.toggle('is-strict', allowTyped === false);
800        var orderedSuggestions = (suggestions || []).slice().sort(function (left, right) {
801          var leftMatch = String(left).match(/\d+/);
802          var rightMatch = String(right).match(/\d+/);
803          var leftNumber = leftMatch ? Number(leftMatch[0]) : Number.MAX_SAFE_INTEGER;
804          var rightNumber = rightMatch ? Number(rightMatch[0]) : Number.MAX_SAFE_INTEGER;
805          if (leftNumber !== rightNumber) return leftNumber - rightNumber;
806          return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
807        });
808        orderedSuggestions.forEach(function (suggestion) {
809          var button = document.createElement('button');
810          button.type = 'button';
811          button.className = 'bo-address-suggestion';
812          var address = document.createElement('span');
813          address.textContent = suggestion;
814          button.appendChild(address);
815          button.addEventListener('click', function () {
816            var savedButtons = Array.prototype.slice.call(list.childNodes);
817            var savedScroll = list.scrollTop;
818            messageElement.textContent = 'Are you sure you would like to select this address?';
819            if (typedButton) typedButton.hidden = true;
820            var confirmation = document.createElement('div');
821            var selected = document.createElement('p');
822            selected.textContent = suggestion;
823            selected.style.fontWeight = '700';
824            var actions = document.createElement('div');
825            actions.style.cssText = 'display:grid;gap:12px;padding:8px 0';
826            var yes = document.createElement('button');
827            yes.type = 'button';
828            yes.className = 'bo-primary-button bo-secondary-button';
829            yes.textContent = 'Yes, use this address';
830            var no = document.createElement('button');
831            no.type = 'button';
832            no.className = 'bo-primary-button';
833            no.textContent = 'No, go back';
834            yes.addEventListener('click', function () {
835              var resolver = Auth.addressResolver;
836              Auth.addressResolver = null;
837              Auth.selectedAddress = '';
838              Utils.closeDialog(dialog);
839              if (resolver) resolver({ action: 'pick', address: suggestion });
840            });
841            no.addEventListener('click', function () {
842              list.replaceChildren();
843              savedButtons.forEach(function (node) { list.appendChild(node); });
844              messageElement.textContent = message;
845              if (typedButton) typedButton.hidden = allowTyped === false;
846              button.focus({ preventScroll: true });
847              list.scrollTop = savedScroll;
848            });
849            actions.append(yes, no);
850            confirmation.append(selected, actions);
851            list.replaceChildren(confirmation);
852            list.scrollTop = 0;
853            no.focus({ preventScroll: true });
854          });
855          list.appendChild(button);
856        });
857        Auth.addressResolver = resolve;
858        Utils.showDialog(dialog);
859      });
860    },
861
862    validateRegistrationAddress: function (data, allowTyped) {
863      var postcode = String(data.postcode || '').trim();
864      if (!/^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$/i.test(postcode)) {
865        return Promise.reject(new Error("Your postcode doesn't look right. Please check it."));
866      }
867      var postcodeUrl = 'https://api.postcodes.io/postcodes/' + encodeURIComponent(postcode);
868      return window.fetch(postcodeUrl, { headers: { Accept: 'application/json' } }).then(function (response) {
869        if (!response.ok) throw new Error('postcode-not-found');
870        return response.json();
871      }).then(function (result) {
872        if (result && result.result && result.result.postcode) data.postcode = result.result.postcode;
873        return data;
874      }).catch(function () { return data; }).then(function () {
875        if (!Auth.suspiciousAddress(data.address, data.postcode)) return data;
876        return Auth.addressSuggestions(data.postcode, data.address).then(function (suggestions) {
877          var message = allowTyped === false ?
878            'Select your full address or edit it.' :
879            'Select your full address or use what you entered.';
880          return Auth.chooseAddress(message, suggestions, allowTyped);
881        }).then(function (choice) {
882          if (choice.action === 'edit') throw new Error('edit-address');
883          if (choice.action === 'pick') {
884            data.address = String(choice.address || '').split(',').slice(0, 2).map(function (part) { return part.trim(); }).join(', ');
885            var addressInput = document.getElementById('boRegisterAddress');
886            if (addressInput) addressInput.value = data.address;
887          }
888          return data;
889        });
890      });
891    },
892
893    login: function (event) {
894      event.preventDefault();
895      var form = event.currentTarget;
896      var email = String(document.getElementById('boLoginEmail').value || '').trim().toLowerCase();
897      var password = document.getElementById('boLoginPassword').value;
898      Auth.setBusy(form, true);
899      Auth.request('/logMeIn2', { username: email, password: password }).then(function (response) {
900        if (response.text === 'ACCESS') window.location.reload();
901        else if (response.text === 'Password or Username is Wrong') throw new Error('Your password or email is wrong. Please try again or use the forgotten-password link.');
902        else throw new Error('There has been a problem. Please try again later.');
903      }).catch(function (error) {
904        Utils.toast(error.message || 'Login could not be completed.');
905        Auth.setBusy(form, false);
906      });
907    },
908
909    register: function (event) {
910      event.preventDefault();
911      var form = event.currentTarget;
912      var telephoneInput = document.getElementById('boRegisterTelephone');
913      var telephone = Auth.validTelephone(telephoneInput.value);
914      var data = {
915        name: document.getElementById('boRegisterName').value,
916        username: String(document.getElementById('boRegisterEmail').value || '').trim().toLowerCase(),
917        telephone: telephone,
918        address: document.getElementById('boRegisterAddress').value,
919        postcode: document.getElementById('boRegisterPostcode').value,
920        password: document.getElementById('boRegisterPassword').value,
921        verifypassword: document.getElementById('boRegisterPasswordAgain').value
922      };
923      if (!telephone) { Utils.toast('Please enter an 11-digit UK number, or an international number including its country code.'); telephoneInput.focus(); return; }
924      telephoneInput.value = telephone;
925      if (data.password.length < 5) { Utils.toast('Your password must contain at least 5 characters.'); return; }
926      if (data.password !== data.verifypassword) { Utils.toast("Passwords don't match."); return; }
927      if (String(data.address).trim().length < 2) { Utils.toast('Please enter your address.'); return; }
928      Auth.setBusy(form, true);
929      Auth.validateRegistrationAddress(data).then(function (validated) {
930        return Auth.request('/register', validated);
931      }).then(function (response) {
932        if (response.status === 429) throw new Error('Too many accounts have been created from this connection. Please try again after an hour.');
933        if (response.text === 'success') { Utils.toast('You have successfully registered.'); window.setTimeout(function () { window.location.reload(); }, 700); return; }
934        if (response.text === 'Email used before') throw new Error('This email is already registered. Please log in or reset your password.');
935        throw new Error('Registration could not be completed. Please try again later.');
936      }).catch(function (error) {
937        Auth.setBusy(form, false);
938        if (error.message === 'edit-address') { document.getElementById('boRegisterAddress').focus(); return; }
939        Utils.toast(error.message || 'Registration could not be completed.');
940      });
941    },
942
943    forgot: function (event) {
944      event.preventDefault();
945      var form = event.currentTarget;
946      var email = String(document.getElementById('boForgotEmail').value || '').trim().toLowerCase();
947      Auth.setBusy(form, true);
948      Auth.request('/forgetPassword', { email: email }).then(function (response) {
949        if (response.text !== 'successfull') throw new Error('This email was not found, or you must wait six minutes before requesting another email.');
950        Utils.toast('Reset instructions have been sent. Please also check your spam folder.');
951      }).catch(function (error) { Utils.toast(error.message); }).finally(function () { Auth.setBusy(form, false); });
952    },
953
954    resetCode: function (event) {
955      event.preventDefault();
956      var form = event.currentTarget;
957      var password = document.getElementById('boResetPassword').value;
958      var confirmation = document.getElementById('boResetPasswordAgain').value;
959      if (password.length < 5) { Utils.toast('Your password must contain at least 5 characters.'); return; }
960      if (password !== confirmation) { Utils.toast("Passwords don't match."); return; }
961      Auth.setBusy(form, true);
962      Auth.request('/resetPasswordCode', {
963        username: String(document.getElementById('boResetEmail').value || '').trim().toLowerCase(),
964        reset: document.getElementById('boResetCode').value,
965        password: password,
966        verifypassword: confirmation
967      }).then(function (response) {
968        if (response.text === 'success') { window.location.reload(); return; }
969        if (response.text === 'Expired') throw new Error('Your reset code has expired. Please request a new one.');
970        throw new Error('Please check the reset code and email address.');
971      }).catch(function (error) { Utils.toast(error.message); Auth.setBusy(form, false); });
972    },
973
974    ensureAppleSdk: function () {
975      if (window.AppleID && window.AppleID.auth) return Promise.resolve(window.AppleID);
976      if (Auth.appleSdkPromise) return Auth.appleSdkPromise;
977      Auth.appleSdkPromise = new Promise(function (resolve, reject) {
978        var script = document.createElement('script');
979        script.src = 'https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js';
980        script.async = true;
981        script.onload = function () {
982          try {
983            window.AppleID.auth.init({
984              clientId: Config.apple.clientId,
985              scope: 'name email',
986              redirectURI: Config.apple.redirectURI,
987              state: 'origin:web',
988              usePopup: true
989            });
990            resolve(window.AppleID);
991          } catch (error) { reject(error); }
992        };
993        script.onerror = function () { reject(new Error('Apple Sign-In could not be loaded.')); };
994        document.head.appendChild(script);
995      });
996      return Auth.appleSdkPromise;
997    },
998
999    appleCode: function (code) {
1000      return Auth.request('/auth/apple/callback?code=' + encodeURIComponent(code), null, 'GET').then(function (response) {
1001        if (response.text !== 'access') throw new Error('Apple login could not be completed.');
1002        window.location.reload();
1003      });
1004    },
1005
1006    social: function (provider, button) {
1007      if (Auth.appleBusy) return;
1008      if (provider === 'google') {
1009        var androidGoogle = window.Android && typeof window.Android.triggerGLogin === 'function';
1010        var iosHandler = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.scriptHandler;
1011        var nativePage = Config.isApp || ['ios', 'android', 'n'].indexOf(Config.platform) !== -1;
1012        if (androidGoogle || iosHandler || nativePage) {
1013          try {
1014            if (androidGoogle) window.Android.triggerGLogin();
1015            else if (iosHandler && typeof iosHandler.postMessage === 'function') iosHandler.postMessage('glog');
1016            else throw new Error('Google login bridge is unavailable.');
1017          } catch (error) {
1018            Utils.toast('Google login is unavailable in this app. Please try again or use another login method.');
1019          }
1020          return;
1021        }
1022      }
1023      if (Config.platform === 'ios') {
1024        var message = provider === 'apple' ? 'asign' : (provider === 'facebook' ? 'fblog' : 'glog');
1025        try { window.webkit.messageHandlers.scriptHandler.postMessage(message); } catch (error) { Utils.toast('This login method is unavailable.'); }
1026        return;
1027      }
1028      if (Config.platform === 'android') {
1029        try {
1030          if (provider === 'facebook') window.Android.triggerFbLogin();
1031          else if (provider === 'google') window.Android.triggerGLogin();
1032        } catch (error) { Utils.toast('This login method is unavailable.'); }
1033        return;
1034      }
1035      if (provider === 'facebook' || provider === 'google') {
1036        var returnTo = window.location.pathname + window.location.search;
1037        window.location.href = '/auth/' + provider + '?returnTo=' + encodeURIComponent(returnTo);
1038        return;
1039      }
1040      if (!Config.apple || !Config.apple.clientId || !Config.apple.redirectURI) { Utils.toast('Apple login is not configured.'); return; }
1041      Auth.appleBusy = true;
1042      if (button) { button.disabled = true; button.textContent = 'Opening Apple…'; }
1043      Auth.ensureAppleSdk().then(function () { return window.AppleID.auth.signIn(); }).then(function (response) {
1044        var code = response && response.authorization && response.authorization.code;
1045        if (!code) throw new Error('Apple returned no authorization code.');
1046        return Auth.appleCode(code);
1047      }).catch(function (error) {
1048        if (!error || (error.error !== 'popup_closed_by_user' && error.message !== 'popup_closed_by_user')) Utils.toast((error && error.message) || 'Apple login could not be completed.');
1049      }).finally(function () {
1050        Auth.appleBusy = false;
1051        if (button) { button.disabled = false; button.textContent = 'Sign in with Apple'; }
1052      });
1053    },
1054
1055    init: function () {
1056      document.querySelectorAll('[data-auth-open]').forEach(function (button) {
1057        button.addEventListener('click', function () { Auth.open(button.dataset.authOpen); });
1058      });
1059      document.querySelectorAll('[data-password-toggle]').forEach(function (button) {
1060        button.addEventListener('click', function () {
1061          var input = button.parentElement.querySelector('input');
1062          if (!input) return;
1063          var showing = input.type === 'text';
1064          input.type = showing ? 'password' : 'text';
1065          button.textContent = showing ? 'Show' : 'Hide';
1066        });
1067      });
1068      var loginForm = document.getElementById('boLoginForm');
1069      var registerForm = document.getElementById('boRegisterForm');
1070      var forgotForm = document.getElementById('boForgotForm');
1071      var resetForm = document.getElementById('boResetCodeForm');
1072      if (loginForm) loginForm.addEventListener('submit', Auth.login);
1073      if (registerForm) registerForm.addEventListener('submit', Auth.register);
1074      if (forgotForm) forgotForm.addEventListener('submit', Auth.forgot);
1075      if (resetForm) resetForm.addEventListener('submit', Auth.resetCode);
1076      document.querySelectorAll('[data-auth-provider]').forEach(function (button) {
1077        button.addEventListener('click', function () { Auth.social(button.dataset.authProvider, button); });
1078      });
1079      document.querySelectorAll('[data-address-action]').forEach(function (button) {
1080        button.addEventListener('click', function () {
1081          var action = button.dataset.addressAction;
1082          var resolver = Auth.addressResolver;
1083          Auth.addressResolver = null;
1084          Utils.closeDialog(document.getElementById('boAddressDialog'));
1085          if (resolver) resolver({ action: action });
1086          Auth.selectedAddress = '';
1087        });
1088      });
1089      var addressDialog = document.getElementById('boAddressDialog');
1090      if (addressDialog) {
1091        addressDialog.addEventListener('close', function () {
1092          var resolver = Auth.addressResolver;
1093          Auth.addressResolver = null;
1094          Auth.selectedAddress = '';
1095          if (resolver) resolver({ action: 'edit' });
1096        });
1097      }
1098
1099      window.appleSign = function (sign) {
1100        var token = sign && sign.id_token;
1101        if (!token) { Utils.toast('Apple returned no login token.'); return; }
1102        Auth.request('/auth/apple/callback?id_token=' + encodeURIComponent(token), null, 'GET').then(function (response) {
1103          if (response.text === 'access') window.location.reload(); else throw new Error();
1104        }).catch(function () { Utils.toast('Apple login could not be completed.'); });
1105      };
1106      window.faSign = function (sign) { window.faSignG(sign && sign.access_token); };
1107      window.faSignG = function (token) {
1108        Auth.request('/fsign?access_token=' + encodeURIComponent(token || ''), null, 'GET').then(function (response) {
1109          if (response.text === 'access') window.location.reload(); else throw new Error();
1110        }).catch(function () { Utils.toast('Facebook login could not be completed.'); });
1111      };
1112      window.gaSign = function (sign) { return window.gSignG(sign && sign.id_token); };
1113      window.gaSignG = function (token) {
1114        if (!token) { Utils.toast('Google returned no login token. Please try again.'); return; }
1115        return Auth.request('/gsign?id_token=' + encodeURIComponent(token), null, 'GET').then(function (response) {
1116          if (response.text !== 'access') throw new Error('Google login was not accepted.');
1117          // Preserve the native iOS refresh contract used by the original gSignG.
1118          try { window.webkit.messageHandlers.scriptHandler.postMessage('refresh'); }
1119          catch (error) { window.location.reload(); }
1120        }).catch(function () { Utils.toast('Google login could not be completed.'); });
1121      };
1122      window.faSignF = window.faSignG;
1123      window.gSignG = window.gaSignG;
1124      window.ggSign = function (sign) { return window.gSignG(sign); };
1125    }
1126  };
1127
1128  var Home = {
1129    init: function () {
1130      var hero = document.querySelector('[data-home-image]');
1131      if (!hero) return;
1132      var image = String(hero.dataset.homeImage || '').trim();
1133      if (image) hero.style.setProperty('--bo-home-image', 'url(' + JSON.stringify(image) + ')');
1134      else hero.classList.add('has-no-image');
1135    }
1136  };
1137
1138  var Checkout = {
1139    state: { orderType: '', tipType: 'none', tipValue: 0, voucherRate: 0, freePromptSignature: '', pendingOrderType: '', editingOrderType: '', payment: '', submitting: false, completed: false, paymentAttempt: null, nativeWalletAttempt: null, isGuest: !Config.user, guestPrepared: false, guestDistance: null, guestQuote: null, guestQuoteRequest: null, guestDeliveryError: '', guestAddressKey: '', guestAddressRequest: null, guestModal: null, stripe: null, elements: null, cardNumber: null, cardExpiry: null, cardCvc: null, paymentRequest: null, paymentRequestButton: null, walletReady: false, walletChecking: false, walletActive: false, totals: null },
1140
1141    freeItemKey: 'shop_free_selection',
1142
1143    money: function (value) { return '£' + (Number(value) || 0).toFixed(2); },
1144
1145    info: function () {
1146      return (Config.pageData && Config.pageData.checkout) || {};
1147    },
1148
1149    initStripe: function () {
1150      var key = String(Checkout.info().stripePublic || '').trim();
1151      if (!key || typeof window.Stripe !== 'function' || Checkout.state.stripe) return;
1152      try {
1153        Checkout.state.stripe = window.Stripe(key);
1154        Checkout.state.elements = Checkout.state.stripe.elements();
1155        var elementStyle = { base: { color: '#1b1b24', fontSize: '16px', fontFamily: 'Arial, sans-serif', '::placeholder': { color: '#77777f' } }, invalid: { color: '#b42318' } };
1156        Checkout.state.cardNumber = Checkout.state.elements.create('cardNumber', { style: elementStyle });
1157        Checkout.state.cardExpiry = Checkout.state.elements.create('cardExpiry', { style: elementStyle });
1158        Checkout.state.cardCvc = Checkout.state.elements.create('cardCvc', { style: elementStyle });
1159        Checkout.state.cardNumber.mount('#boCardNumber');
1160        Checkout.state.cardExpiry.mount('#boCardExpiry');
1161        Checkout.state.cardCvc.mount('#boCardCvc');
1162        [Checkout.state.cardNumber, Checkout.state.cardExpiry, Checkout.state.cardCvc].forEach(function (element) { element.on('change', function (event) {
1163          var errors = document.getElementById('boCardErrors');
1164          if (errors) errors.textContent = event.error ? event.error.message : '';
1165        }); });
1166      } catch (error) {
1167        console.error('Stripe could not be initialised', error);
1168      }
1169    },
1170
1171    ensureWallet: function () {
1172      var state = Checkout.state;
1173      var total = state.totals ? Math.round(state.totals.total * 100) : 0;
1174      if (!state.stripe || !state.elements || total <= 0 || state.walletReady || state.walletChecking || state.paymentRequest) return;
1175      state.walletChecking = true;
1176      var requestOptions = { country: 'GB', currency: 'gbp', total: { label: 'Takeaway Order', amount: total } };
1177      if (Config.platform === 'android') requestOptions.disableWallets = ['link'];
1178      var request = state.stripe.paymentRequest(requestOptions);
1179      state.paymentRequest = request;
1180      request.canMakePayment().then(function (available) {
1181        state.walletChecking = false;
1182        if (!available) { state.paymentRequest = null; return; }
1183        state.walletReady = true;
1184        state.paymentRequestButton = state.elements.create('paymentRequestButton', { paymentRequest: request, style: { paymentRequestButton: { type: 'default', theme: 'dark', height: '52px' } } });
1185        state.paymentRequestButton.on('click', function (event) {
1186          if (state.submitting || state.completed) { event.preventDefault(); return; }
1187          var problem = Checkout.validateOrder();
1188          if (problem) { event.preventDefault(); Utils.toast(problem, 'error'); }
1189        });
1190        state.paymentRequestButton.mount('#boPaymentRequestButton');
1191        var host = document.getElementById('boWalletPayment');
1192        if (host) host.hidden = false;
1193        Checkout.updatePaymentButton();
1194        Checkout.updateWalletTotal();
1195        request.on('cancel', function () { Checkout.logPayment('wallet_cancel'); state.walletActive = false; });
1196        request.on('paymentmethod', function (event) {
1197          state.walletActive = true;
1198          Checkout.startPaymentLog('browser_wallet');
1199          Checkout.submitOrder(event.paymentMethod.id, 'no', event);
1200        });
1201      }).catch(function () { state.walletChecking = false; state.paymentRequest = null; });
1202    },
1203
1204    updateWalletTotal: function () {
1205      var state = Checkout.state;
1206      if (!state.paymentRequest || !state.walletReady || state.walletActive || !state.totals) return;
1207      var amount = Math.round(state.totals.total * 100);
1208      if (amount <= 0) return;
1209      try { state.paymentRequest.update({ total: { label: 'Takeaway Order', amount: amount } }); } catch (error) {}
1210    },
1211
1212    logPayment: function (type, details) {
1213      // Best-effort diagnostics only; never send checkout payloads or Stripe secrets.
1214      try {
1215        var state=Checkout.state, data={version:121,attempt:state.paymentLogAttempt || '',method:state.paymentLogMethod || 'card',guest:!!state.isGuest,platform:String(Config.platform || 'web'),orderType:state.orderType || '',amountPence:Math.round(Number(state.totals && state.totals.total || 0)*100),url:window.location.origin+window.location.pathname+'?ios='+encodeURIComponent(Config.platform || 'web')};
1216        ['stage','status','code','decline_code','orderId'].forEach(function(key){
1217          var value=details && details[key];
1218          if(value!=null && /^[a-zA-Z0-9_ .:-]{1,100}$/.test(String(value)))data[key]=String(value);
1219        });
1220        var body=new URLSearchParams();body.set('type','checkout_v2_'+type);body.set('data',JSON.stringify(data));
1221        var url='/logPayment?ios='+encodeURIComponent(Config.platform || 'web');
1222        if(window.navigator && typeof window.navigator.sendBeacon==='function' && window.navigator.sendBeacon(url,body))return;
1223        window.fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/x-www-form-urlencoded;charset=UTF-8'},body:body,keepalive:true}).catch(function(){});
1224      } catch (_) {}
1225    },
1226
1227    startPaymentLog: function (method) {
1228      Checkout.state.paymentLogAttempt=Date.now().toString(36)+'-'+Math.random().toString(36).slice(2,10);
1229      Checkout.state.paymentLogMethod=method;
1230      Checkout.logPayment('attempt');
1231    },
1232
1233    initNativeWallet: function () {
1234      if (Config.platform !== 'android') return;
1235      window.gversion = function (version, available) {
1236        if (String(available).toLowerCase() !== 'yes') return;
1237        var host = document.getElementById('boWalletPayment');
1238        var button = document.getElementById('boNativeWallet');
1239        if (host) { host.hidden = false; host.classList.add('has-native-wallet'); }
1240        if (button) button.hidden = false;
1241        Checkout.updatePaymentButton();
1242      };
1243      function finishNativeWallet(paymentMethod) {
1244        var attempt = Checkout.state.nativeWalletAttempt;
1245        if (!attempt) return;
1246        Checkout.state.nativeWalletAttempt = null;
1247        if (String(paymentMethod || '').indexOf('pm_') === 0) return Checkout.submitOrder(paymentMethod, 'no', null, attempt);
1248        Checkout.finishPaymentAttempt(attempt);
1249      }
1250      window.gpaysuccess = finishNativeWallet;
1251      window.gpayfail = finishNativeWallet;
1252      window.gpayerror = function () {
1253        if (!Checkout.state.nativeWalletAttempt) return;
1254        Checkout.logPayment('wallet_error',{stage:'native_wallet'});
1255        finishNativeWallet('');
1256        Utils.toast('Google Pay was not completed.', 'error');
1257      };
1258      try { if (window.Android && typeof window.Android.gPayAvailable === 'function') window.Android.gPayAvailable(); } catch (error) {}
1259      var button = document.getElementById('boNativeWallet');
1260      if (button) button.addEventListener('click', function () {
1261        if (Checkout.state.submitting || Checkout.state.completed) return;
1262        var problem = Checkout.validateOrder();
1263        if (problem) { Utils.toast(problem, 'error'); return; }
1264        var attempt = Checkout.beginPaymentAttempt();
1265        if (!attempt) return;
1266        Checkout.state.nativeWalletAttempt = attempt;
1267        Checkout.startPaymentLog('native_google_pay');
1268        try { window.Android.triggerGPay(Checkout.state.totals.total.toFixed(2)); } catch (error) { window.gpayerror(); }
1269      });
1270    },
1271
1272    firstNumber: function (value, fallback) {
1273      var number = Number.parseFloat(String(value == null ? '' : value).split(',')[0]);
1274      return Number.isFinite(number) ? number : (fallback || 0);
1275    },
1276
1277    isFreeItem: function (item) {
1278      return Boolean(item && (Number(item.autoFreeItem) === 1 || String(item.id || '').indexOf('free_') === 0));
1279    },
1280
1281    isDiscountable: function (item) {
1282      return Boolean(item) && (item.dable == null || String(item.dable) === '1');
1283    },
1284
1285    eligibleSubtotal: function (items) {
1286      return (items || []).reduce(function (total, item) {
1287        if (Checkout.isFreeItem(item) || !Checkout.isDiscountable(item)) return total;
1288        return total + (Number(item.fullPrice) || 0);
1289      }, 0);
1290    },
1291
1292    freeItems: function () {
1293      var value = Checkout.info().freeItems;
1294      if (Array.isArray(value)) return value;
1295      try {
1296        var parsed = JSON.parse(value || '[]');
1297        return Array.isArray(parsed) ? parsed : [];
1298      } catch (error) {
1299        return [];
1300      }
1301    },
1302
1303    makeFreeItem: function (source) {
1304      var item = Object.assign({}, source || {});
1305      item.id = 'free_315';
1306      item.name = item.itemName || item.name || 'Free item';
1307      item.amount = 1;
1308      item.basePrice = 0;
1309      item.fullPrice = 0;
1310      item.rPrice = 0;
1311      item.autoFreeItem = 1;
1312      item.typex = 'no';
1313      item.portion = ' ';
1314      item.customization = item.customization || { additions: '', features: '' };
1315      return item;
1316    },
1317
1318    selectFreeItem: function (item) {
1319      window.localStorage.setItem(Checkout.freeItemKey, JSON.stringify(Checkout.makeFreeItem(item)));
1320      var dialog = document.getElementById('boFreeItemDialog');
1321      if (dialog && dialog.open) Utils.closeDialog(dialog);
1322      Checkout.calculate();
1323    },
1324
1325    showFreeItemChoices: function (items) {
1326      var dialog = document.getElementById('boFreeItemDialog');
1327      var host = document.getElementById('boFreeItemOptions');
1328      if (!dialog || !host) return;
1329      host.replaceChildren();
1330      items.forEach(function (item) {
1331        var button = document.createElement('button');
1332        button.type = 'button';
1333        button.className = 'bo-primary-button';
1334        button.textContent = item.itemName || item.name || 'Free item';
1335        button.addEventListener('click', function () { Checkout.selectFreeItem(item); });
1336        host.appendChild(button);
1337      });
1338      Utils.showDialog(dialog);
1339    },
1340
1341    syncFreeItem: function (storedItems) {
1342      var qualifyingTotal = Checkout.eligibleSubtotal(storedItems);
1343      var choices = Checkout.freeItems().filter(function (item) {
1344        return Boolean(item && (item.itemName || item.name)) && qualifyingTotal >= (Number.parseFloat(item.ordersOver) || 0);
1345      });
1346      var selected = (storedItems || []).find(Checkout.isFreeItem);
1347      if (!choices.length) {
1348        window.localStorage.removeItem(Checkout.freeItemKey);
1349        Checkout.state.freePromptSignature = '';
1350        return;
1351      }
1352      if (selected && choices.some(function (item) { return String(item.itemName || item.name) === String(selected.itemName || selected.name); })) return;
1353      window.localStorage.removeItem(Checkout.freeItemKey);
1354      if (choices.length === 1) {
1355        window.localStorage.setItem(Checkout.freeItemKey, JSON.stringify(Checkout.makeFreeItem(choices[0])));
1356        Checkout.state.freePromptSignature = '';
1357        return;
1358      }
1359      var signature = choices.map(function (item) { return String(item.itemName || item.name) + '|' + String(item.ordersOver || 0); }).join('||');
1360      if (Checkout.state.freePromptSignature !== signature) {
1361        Checkout.state.freePromptSignature = signature;
1362        window.setTimeout(function () { Checkout.showFreeItemChoices(choices); }, 0);
1363      }
1364    },
1365
1366    hasCollectionOnlyItems: function (items) {
1367      return (items || []).some(function (item) {
1368        return !Checkout.isFreeItem(item) && String(item.conly || '').trim().toLowerCase() === 'yes';
1369      });
1370    },
1371
1372    collectionOnlyItems: function () {
1373      return Utils.cartItems().filter(function (item) {
1374        return !Checkout.isFreeItem(item) && String(item.conly || '').trim().toLowerCase() === 'yes';
1375      });
1376    },
1377
1378    showCollectionOnlyDialog: function () {
1379      var dialog = document.getElementById('boCollectionOnlyDialog');
1380      var host = document.getElementById('boCollectionOnlyItems');
1381      if (!dialog || !host) return;
1382      host.replaceChildren();
1383      Checkout.collectionOnlyItems().forEach(function (item) {
1384        var row = document.createElement('div');
1385        row.className = 'bo-collection-only-item';
1386        var name = document.createElement('strong');
1387        name.textContent = (Math.max(1, Number(item.amount) || 1)) + ' × ' + (item.displayName || item.name || 'Item');
1388        if (item.portion && String(item.portion).trim()) name.textContent += ' - ' + item.portion;
1389        row.appendChild(name);
1390        host.appendChild(row);
1391      });
1392      Utils.showDialog(dialog);
1393    },
1394
1395    chooseCollectionFromDialog: function () {
1396      var input = document.querySelector('input[name="boOrderType"][value="Collection"]');
1397      if (input) input.checked = true;
1398      var dialog = document.getElementById('boCollectionOnlyDialog');
1399      if (dialog && dialog.open) Utils.closeDialog(dialog);
1400      Checkout.chooseOrderType('Collection');
1401    },
1402
1403    deleteCollectionOnlyItems: function () {
1404      Checkout.collectionOnlyItems().forEach(function (item) {
1405        if (item._storageKey) window.localStorage.removeItem(String(item._storageKey));
1406      });
1407      var dialog = document.getElementById('boCollectionOnlyDialog');
1408      if (dialog && dialog.open) Utils.closeDialog(dialog);
1409      var input = document.querySelector('input[name="boOrderType"][value="Delivery"]');
1410      if (input) input.checked = true;
1411      if (Dom.cartSummary && Dom.cartDialogSummary && Dom.cartList) Cart.refresh();
1412      Checkout.chooseOrderType('Delivery');
1413    },
1414
1415    openDetails: function (orderType) {
1416      var dialog = document.getElementById('boCheckoutDetailsDialog');
1417      if (!dialog) return;
1418      var type = typeof orderType === 'string' ? orderType : Checkout.state.orderType;
1419      Checkout.state.editingOrderType = type || '';
1420      var phoneOnly = type === 'Collection';
1421      var customer = (Config.pageData && Config.pageData.customer) || {};
1422      var cleanDefault = function (value, label) { return new RegExp('^' + label + '$', 'i').test(String(value || '').trim()) ? '' : String(value || ''); };
1423      document.getElementById('boEditTelephone').value = cleanDefault(customer.telephone, 'telephone') || cleanDefault(document.getElementById('boCheckoutTelephone').value, 'telephone');
1424      document.getElementById('boEditAddress').value = cleanDefault(customer.address, 'address') || cleanDefault(document.getElementById('boCheckoutAddress').value, 'address');
1425      document.getElementById('boEditPostcode').value = cleanDefault(customer.postcode, 'postcode') || cleanDefault(document.getElementById('boCheckoutPostcode').value, 'postcode');
1426      document.querySelectorAll('.bo-edit-delivery-field').forEach(function (field) {
1427        field.hidden = phoneOnly;
1428        var input = field.querySelector('input');
1429        if (input) input.disabled = phoneOnly;
1430      });
1431      var intro = document.getElementById('boCheckoutDetailsIntro');
1432      if (intro) intro.textContent = phoneOnly ? 'Update your telephone number.' : 'Update your delivery contact details.';
1433      Utils.showDialog(dialog);
1434    },
1435
1436    missingCustomerDetail: function (orderType) {
1437      if (Checkout.state.isGuest) return '';
1438      var telephone = document.getElementById('boCheckoutTelephone').value;
1439      if (!Auth.validTelephone(telephone) || /^telephone$/i.test(String(telephone).trim())) return 'telephone';
1440      if (orderType !== 'Delivery') return '';
1441      var address = String(document.getElementById('boCheckoutAddress').value || '').trim();
1442      var postcode = String(document.getElementById('boCheckoutPostcode').value || '').trim();
1443      if (!address || /^address$/i.test(address)) return 'address';
1444      if (!postcode || /^postcode$/i.test(postcode) || !/^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$/i.test(postcode)) return 'postcode';
1445      return '';
1446    },
1447
1448    saveDetails: function (event) {
1449      event.preventDefault();
1450      var form = event.currentTarget;
1451      Config.pageData = Config.pageData || {};
1452      var customer = Config.pageData.customer = Config.pageData.customer || {};
1453      var telephone = Auth.validTelephone(document.getElementById('boEditTelephone').value);
1454      var address = String(document.getElementById('boEditAddress').value || '').trim();
1455      var postcode = String(document.getElementById('boEditPostcode').value || '').trim().toUpperCase();
1456      var needsDeliveryDetails = Checkout.state.editingOrderType !== 'Collection';
1457      if (!telephone) {
1458        Utils.toast('Please enter an 11-digit UK number, or an international number with its country code.', 'error');
1459        document.getElementById('boEditTelephone').focus();
1460        return;
1461      }
1462      if (needsDeliveryDetails && address.length < 2) {
1463        Utils.toast('Your address must contain at least 2 characters.', 'error');
1464        document.getElementById('boEditAddress').focus();
1465        return;
1466      }
1467      if (needsDeliveryDetails && !/^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$/i.test(postcode)) {
1468        Utils.toast("Your postcode doesn't look right. Please check it.", 'error');
1469        document.getElementById('boEditPostcode').focus();
1470        return;
1471      }
1472      var data = {
1473        name: customer.name || document.getElementById('boCheckoutName').value || '',
1474        email: String(customer.email || document.getElementById('boCheckoutEmail').value || '').toLowerCase(),
1475        telephone: telephone,
1476        address: address,
1477        postcode: postcode,
1478        emailOrder: customer.emailOrder == null ? 0 : customer.emailOrder,
1479        addressConfirmed: needsDeliveryDetails ? false : Boolean(customer.addressConfirmed)
1480      };
1481      Auth.setBusy(form, true);
1482      var validation = needsDeliveryDetails ? Auth.validateRegistrationAddress(data, true) : Promise.resolve(data);
1483      validation.then(function (validated) {
1484        if (needsDeliveryDetails) validated.addressConfirmed = !Auth.suspiciousAddress(validated.address, validated.postcode);
1485        data = validated;
1486        return Auth.request(needsDeliveryDetails ? '/updateInformation' : '/updatePhone', needsDeliveryDetails ? data : { name: data.name, telephone: data.telephone });
1487      }).then(function (response) {
1488        if (response.text === 'Email Same') throw new Error('This email is already registered.');
1489        if (response.text === 'error') throw new Error('Your details could not be updated.');
1490        var result = {};
1491        try { result = JSON.parse(response.text || '{}'); } catch (error) {}
1492        customer.telephone = data.telephone;
1493        customer.address = data.address;
1494        customer.postcode = data.postcode;
1495        customer.addressConfirmed = data.addressConfirmed;
1496        if (result.distance != null) customer.distance = result.distance;
1497        document.getElementById('boCheckoutTelephone').value = data.telephone;
1498        document.getElementById('boCheckoutAddress').value = data.address;
1499        document.getElementById('boCheckoutPostcode').value = data.postcode;
1500        var dialog = document.getElementById('boCheckoutDetailsDialog');
1501        if (dialog && dialog.open) Utils.closeDialog(dialog);
1502        Utils.toast('Your details have been updated.');
1503        var pendingType = Checkout.state.pendingOrderType;
1504        Checkout.state.pendingOrderType = '';
1505        if (pendingType) {
1506          var pendingInput = document.querySelector('input[name="boOrderType"][value="' + pendingType + '"]');
1507          if (pendingInput) pendingInput.checked = true;
1508          Checkout.chooseOrderType(pendingType);
1509        } else {
1510          // Refresh fees, minimum and payment readiness after editing an active order.
1511          Checkout.calculate();
1512        }
1513      }).catch(function (error) {
1514        if (error && error.message === 'edit-address') {
1515          document.getElementById('boEditAddress').focus();
1516          return;
1517        }
1518        Utils.toast((error && error.message) || 'Your details could not be updated.', 'error');
1519      }).finally(function () { Auth.setBusy(form, false); });
1520    },
1521
1522    discountRate: function () {
1523      var info = Checkout.info();
1524      if (!Checkout.state.orderType) return 0;
1525      var raw = Checkout.state.orderType === 'Collection' ? info.collectionDiscount : info.discount;
1526      var rate = Number.parseFloat(raw);
1527      return Number.isFinite(rate) && rate > 0 && rate <= 100 ? rate : 0;
1528    },
1529
1530    deliveryQuote: function () {
1531      if (Checkout.state.orderType !== 'Delivery') return { allowed: true, fee: 0, minimum: 0 };
1532      var info = Checkout.info();
1533      if (Checkout.state.isGuest) {
1534        var savedQuote = Checkout.state.guestQuote;
1535        var entered = String(document.getElementById('boCheckoutPostcode').value || '').replace(/\s/g, '').toUpperCase();
1536        if (savedQuote && savedQuote.expires > Date.now() && savedQuote.postcode.replace(/\s/g, '') === entered) return savedQuote;
1537        return { allowed: false, reason: Checkout.state.guestDeliveryError || 'Please check your delivery postcode before paying.' };
1538      }
1539      var customer = (Config.pageData && Config.pageData.customer) || {};
1540      var unavailable = { allowed: false, reason: 'Unable to calculate delivery. Please check your address or contact the shop.' };
1541      function quote(fee, minimum) {
1542        var amount = Number(fee);
1543        var min = String(minimum == null ? '' : minimum).trim();
1544        var minimumAmount = !min || min.toLowerCase() === 'no minimum' ? 0 : Number(min);
1545        if (fee == null || String(fee).trim() === '' || !Number.isFinite(amount) || amount < 0 || !Number.isFinite(minimumAmount) || minimumAmount < 0) return unavailable;
1546        return { allowed: true, fee: amount, minimum: minimumAmount };
1547      }
1548      var field = document.getElementById('boCheckoutPostcode');
1549      var postcode = String(field ? field.value : '').replace(/\s/g, '').toUpperCase();
1550      var areas = Utils.values(info.postcodes);
1551      var fees = Utils.values(info.deliveryFees);
1552      var minimums = Utils.values(info.minimumOrder);
1553      var enabled = Utils.values(info.postcodeMode);
1554      // Explicit postcode rules override mileage, including temporarily closed areas.
1555      for (var i = 0; i < areas.length; i += 1) {
1556        var rule = String(areas[i] || '').split('-');
1557        var prefix = String(rule[0] || '').replace(/\s/g, '').toUpperCase();
1558        if (prefix && postcode.length === Number(rule[1]) && postcode.indexOf(prefix) !== -1) {
1559          if (String(enabled[i] || '').trim().toLowerCase() === 'off') return { allowed: fal
1559se, reason: 'Delivery to your area is temporarily unavailable.' };
1560          return quote(fees[i], minimums[i]);
1561        }
1562      }
1563      try {
1564        var bands = Array.isArray(info.deliveryMiles) ? info.deliveryMiles : JSON.parse(info.deliveryMiles || '[]');
1565        if (!Array.isArray(bands)) return unavailable;
1566        if (!bands.length) return { allowed: false, reason: 'We do not currently deliver to this postcode.' };
1567        var rawDistance = Checkout.state.isGuest ? Checkout.state.guestDistance : customer.distance;
1568        if (rawDistance == null || String(rawDistance).trim() === '') return unavailable;
1569        var distance = Number(rawDistance);
1570        if (!Number.isFinite(distance) || distance < 0) return unavailable;
1571        for (var j = 0; j < bands.length; j += 1) {
1572          if (!bands[j] || bands[j].distance == null || String(bands[j].distance).trim() === '' || !Number.isFinite(Number(bands[j].distance)) || Number(bands[j].distance) < 0) return unavailable;
1573          if (j && Number(bands[j].distance) <= Number(bands[j - 1].distance)) return unavailable;
1574        }
1575        var maximum = Number(bands[bands.length - 1].distance);
1576        if (distance > maximum) return { allowed: false, reason: 'We do not deliver to your postcode. Maximum delivery distance is ' + maximum + ' miles.' };
1577        if (bands.length === 1) return quote(bands[0].fee, bands[0].minimum);
1578        for (var k = 0; k < bands.length - 1; k += 1) {
1579          var start = Number(bands[k].distance);
1580          var end = Number(bands[k + 1].distance);
1581          // Include the exact maximum in the last interval instead of falling through.
1582          if (distance >= start && (distance < end || (k === bands.length - 2 && distance === end))) return quote(bands[k].fee, bands[k].minimum);
1583        }
1584      } catch (error) {}
1585      return unavailable;
1586    },
1587
1588    renderItems: function (items) {
1589      var host = document.getElementById('boCheckoutItems');
1590      if (!host) return;
1591      host.replaceChildren();
1592      if (!items.length) {
1593        var empty = document.createElement('p');
1594        empty.className = 'bo-checkout-empty';
1595        empty.textContent = 'Your basket is empty.';
1596        host.appendChild(empty);
1597        return;
1598      }
1599      items.forEach(function (item) {
1600        var row = document.createElement('article');
1601        row.className = 'bo-checkout-item';
1602        var content = document.createElement('div');
1603        var heading = document.createElement('div');
1604        heading.className = 'bo-checkout-item__heading';
1605        var name = document.createElement('strong');
1606        name.textContent = (Number(item.amount) || 1) + ' × ' + [item.displayName || item.name, item.portion].filter(Boolean).join(' - ');
1607        var price = document.createElement('span');
1608        price.textContent = Checkout.money(item.fullPrice);
1609        heading.append(name, price);
1610        content.appendChild(heading);
1611        Utils.displayCustomizationLines(item).forEach(function (line) {
1612          var detail = document.createElement('span');
1613          detail.className = 'bo-checkout-item__detail';
1614          detail.textContent = line;
1615          content.appendChild(detail);
1616        });
1617        row.appendChild(content);
1618        host.appendChild(row);
1619      });
1620    },
1621
1622    syncInitialLayout: function () {
1623      var details = document.querySelector('#boCheckoutGrid > .bo-checkout-details');
1624      if (!details) return;
1625      var initial = !Checkout.state.orderType;
1626      details.style.gridColumn = initial ? '1 / -1' : '';
1627      details.style.width = initial ? '100%' : '';
1628      details.style.maxWidth = initial ? '750px' : '';
1629      details.style.marginInline = initial ? 'auto' : '';
1630    },
1631
1632    calculate: function () {
1633      Checkout.syncInitialLayout();
1634      if (Checkout.state.orderType) Checkout.syncFreeItem(Utils.cartItems());
1635      var cart = Cart.summary();
1636      var info = Checkout.info();
1637      var subtotal = cart.total;
1638      var eligibleSubtotal = Checkout.eligibleSubtotal(cart.items);
1639      var rate = Checkout.discountRate();
1640      var limit = Number.parseFloat(info.discountLimit) || 0;
1641      var discount = rate && (!limit || eligibleSubtotal >= limit) ? eligibleSubtotal * rate / 100 : 0;
1642      if (Checkout.state.voucherRate > 0) discount = eligibleSubtotal * Checkout.state.voucherRate / 100;
1643      var service = Number(info.serviceFee) || 0;
1644      var quote = Checkout.deliveryQuote();
1645      var delivery = quote.allowed ? (Number(quote.fee) || 0) : 0;
1646      var freeDelivery = Number.parseFloat(Checkout.state.isGuest && quote.allowed ? quote.freeDelivery : info.freeDelivery);
1647      if (Checkout.state.orderType === 'Delivery' && Number.isFinite(freeDelivery) && freeDelivery > 0 && subtotal - discount + service >= freeDelivery) delivery = 0;
1648      var tipBase = Math.max(0, subtotal - discount);
1649      var tip = 0;
1650      if (subtotal >
1650 0 && Checkout.state.tipType === 'fixed') tip = Checkout.state.tipValue;
1651      if (subtotal > 0 && Checkout.state.tipType === 'percent') tip = tipBase * Checkout.state.tipValue / 100;
1652      var total = Math.max(0, subtotal - discount + service + delivery + tip);
1653      Checkout.state.totals = { subtotal: subtotal, eligibleSubtotal: eligibleSubtotal, discount: discount, service: service, delivery: delivery, tip: tip, total: total, quote: quote };
1654
1655      Checkout.renderItems(cart.items);
1656      var startCount = document.getElementById('boCheckoutStartCount');
1657      if (startCount) startCount.textContent = cart.count + (cart.count === 1 ? ' item' : ' items') + ' ready in your basket';
1658      document.getElementById('boCheckoutSubtotal').textContent = Checkout.money(subtotal);
1659      document.getElementById('boCheckoutService').textContent = Checkout.money(service);
1660      document.getElementById('boCheckoutDelivery').textContent = Checkout.money(delivery);
1661      document.getElementById('boCheckoutTotal').textContent = Checkout.money(total);
1662      var discountRow = document.getElementById('boCheckoutDiscountRow');
1663      discountRow.hidden = discount <= 0;
1664      document.getElementById('boCheckoutDiscount').textContent = discount > 0 ? '−' + Checkout.money(discount) : '';
1665      var serviceRow = document.getElementById('boCheckoutServiceRow');
1666      serviceRow.hidden = service <= 0;
1667      document.getElementById('boCheckoutService').textContent = service > 0 ? Checkout.money(service) : '';
1668      var deliveryRow = document.getElementById('boCheckoutDeliveryRow');
1669      deliveryRow.hidden = Checkout.state.orderType !== 'Delivery' || delivery <= 0;
1670      document.getElementById('boCheckoutDelivery').textContent = delivery >
1670 0 ? Checkout.money(delivery) : '';
1671      var tipRow = document.getElementById('boCheckoutTipRow');
1672      tipRow.hidden = tip <= 0;
1673      document.getElementById('boCheckoutTip').textContent = tip > 0 ? Checkout.money(tip) : '';
1674      Checkout.updatePaymentButton();
1675      Checkout.ensureWallet();
1676      Checkout.updateWalletTotal();
1677    },
1678
1679    timeMinutes: function (value) {
1680      var text = String(value == null ? '' : value).trim();
1681      var match = /^(\d{1,2})(?::([0-5]\d))?$/.exec(text);
1682      if (!match) return NaN;
1683      return Number(match[1]) * 60 + Number(match[2] || 0);
1684    },
1685
1686    addTimes: function () {
1687      var select = document.getElementById('boCheckoutTime');
1688      if (!select) return;
1689      var info = Checkout.info();
1690      var opening = Checkout.timeMinutes(Checkout.state.orderType === 'Collection' ? info.collectionStart : info.deliveryStart);
1691      var closing = Checkout.timeMinutes(info.closingTime);
1692      // Shop times are London wall-clock times, independent of the customer's timezone.
1693      var parts = new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date());
1694      var hour = Number(parts.find(function (part) { return part.type === 'hour'; }).value) % 24;
1695      var minute = Number(parts.find(function (part) { return part.type === 'minute'; }).value);
1696      var now = hour * 60 + minute;
1697      if (Number.isFinite(opening) && closing <= opening) closing += 1440;
1698      // The route identifies yesterday's service after midnight. Support the old
1699      // scalar settings too, but the route patch is needed for differing weekdays.
1700      var offset = Number(info.scheduleDayOffset);
1701      var previousService = info.scheduleDayOffset != null
1702        ? offset === -1 : closing > 1440 && now < closing - 1440;
1703      if (previousService) now += 1440;
1704      function label(minutes) {
1705        minutes = ((minutes % 1440) + 1440) % 1440;
1706        return String(Math.floor(minutes / 60)).padStart(2, '0') + ':' + String(minutes % 60).padStart(2, '0');
1707      }
1708      select.replaceChildren();
1709      var asap = document.createElement('option');
1710      asap.textContent = Number.isFinite(opening) && now < opening ? 'ASAP (after ' + label(opening) + ')' : 'ASAP';
1711      select.appendChild(asap);
1712      if (!Number.isFinite(closing)) return;
1713      // Preserve the existing lead time and quarter-hour slots, without crossing closing.
1714      var first = Math.ceil(now / 15) * 15 + 15;
1715      if (Number.isFinite(opening)) first = Math.max(first, Math.ceil(opening / 15) * 15);
1716      for (var cursor = first; cursor <= closing && select.options.length < 193; cursor += 15) {
1717        var option = document.createElement('option');
1718        option.value = label(cursor);
1719        option.textContent = option.value;
1720        select.appendChild(option);
1721      }
1722    },
1723
1724    chooseOrderType: function (value) {
1725      if (Checkout.state.isGuest) {
1726        Checkout.state.guestPrepared = false;
1727        Checkout.state.guestDistance = null;
1728      }
1729      var storedItems = Utils.cartItems();
1730      if (value === 'Delivery' && Checkout.hasCollectionOnlyItems(storedItems)) {
1731        var deliveryInput = document.querySelector('input[name="boOrderType"][value="Delivery"]');
1732        if (deliveryInput) deliveryInput.checked = false;
1733        Checkout.state.orderType = '';
1734        var collectionStartPanel = document.getElementById('boCheckoutStart');
1735        if (collectionStartPanel) collectionStartPanel.hidden = false;
1736        var hiddenProgressive = document.getElementById('boCheckoutProgressive');
1737        if (hiddenProgressive) hiddenProgressive.hidden = true;
1738        document.querySelectorAll('.bo-checkout-after-type').forEach(function (section) { section.hidden = true; });
1739        Checkout.showCollectionOnlyDialog();
1740        Checkout.calculate();
1741        return;
1742      }
1743      var missingDetail = Checkout.missingCustomerDetail(value);
1744      if (missingDetail) {
1745        var selectedInput = document.querySelector('input[name="boOrderType"][value="' + value + '"]');
1746        if (selectedInput) selectedInput.checked = false;
1747        Checkout.state.orderType = '';
1748        var missingStartPanel = document.getElementById('boCheckoutStart');
1749        if (missingStartPanel) missingStartPanel.hidden = false;
1750        Checkout.state.pendingOrderType = value;
1751        var progressiveFields = document.getElementById('boCheckoutProgressive');
1752        if (progressiveFields) progressiveFields.hidden = true;
1753        document.querySelectorAll('.bo-checkout-after-type').forEach(function (section) { section.hidden = true; });
1754        Checkout.openDetails(value);
1755        Utils.toast(missingDetail === 'telephone' ? 'Please add a valid telephone number.' : 'Please add a complete delivery address.', 'error');
1756        Checkout.calculate();
1757        return;
1758      }
1759      Checkout.state.orderType = value;
1760      var startPanel = document.getElementById('boCheckoutStart');
1761      if (startPanel) startPanel.hidden = Boolean(value);
1762      var progressive = document.getElementById('boCheckoutProgressive');
1763      if (progressive) progressive.hidden = !value;
1764      document.querySelectorAll('.bo-checkout-after-type').forEach(function (section) { section.hidden = !value; });
1765      document.querySelectorAll('.bo-delivery-field').forEach(function (field) { field.hidden = value !== 'Delivery'; });
1766      Checkout.addTimes();
1767      Checkout.calculate();
1768      Checkout.refreshGuestChecks();
1769    },
1770
1771    saveNote: function () {
1772      var button = document.getElementById('boSaveNote');
1773      var notes = String(document.getElementById('boCheckoutNotes').value || '').replace(/\r?\n|\r/g, ' ').trim();
1774      if (!notes || notes.length > 300) { Utils.toast('Your note must contain between 1 and 300 characters.', 'error'); return; }
1775      button.disabled = true;
1776      Auth.request('/savenote', { notes: notes }).then(function (response) {
1777        if (response.text !== 'success') throw new Error('The note could not be saved.');
1778        Utils.toast('Your note has been saved.');
1779      }).catch(function (error) { Utils.toast(error.message, 'error'); }).finally(function () { button.disabled = false; });
1780    },
1781
1782    applyVoucher: function () {
1783      var button = document.getElementById('boApplyVoucher');
1784      var code = String(document.getElementById('boVoucherCode').value || '').trim().toUpperCase();
1785      if (!code) { Utils.toast('Enter a voucher code.', 'error'); return; }
1786      button.disabled = true;
1787      window.fetch('/voucheradd?voucher=' + encodeURIComponent(code), { credentials: 'same-origin', headers: { Accept: 'application/json' } }).then(function (response) {
1788        if (!response.ok) throw new Error();
1789        return response.json();
1790      }).then(function (voucher) {
1791        if (!voucher || Number(voucher.valid) !== 1) throw new Error();
1792        Checkout.state.voucherRate = Number(voucher.rate) || 0;
1793        Checkout.calculate();
1794        Utils.toast('Voucher ' + (voucher.code || code) + ' applied.');
1795      }).catch(function () { Utils.toast('This voucher has not been found.', 'error'); }).finally(function () { button.disabled = false; });
1796    },
1797
1798    updatePaymentButton: function () {
1799      var button = document.querySelector('.bo-place-order');
1800      if (!button) return;
1801      var totals = Checkout.state.totals;
1802      var closed = String(Checkout.info().situation || '').toLowerCase() === 'closed';
1803      var deliveryBlocked = Checkout.state.orderType === 'Delivery' && (!Checkout.deliveryQuote().allowed || (totals && totals.subtotal < Number(totals.quote.minimum || 0)) || (Checkout.state.isGuest && !Checkout.guestAddressReady()));
1804      button.disabled = deliveryBlocked || Checkout.state.submitting || !Checkout.state.payment || !Checkout.state.orderType || !totals || totals.total <= 0 || closed;
1805      button.textContent = Checkout.state.submitting ? 'Placing order…' : 'Complete order';
1806      var status = document.getElementById('boDeliveryStatus');
1807      if (!status) { status = document.createElement('p'); status.id = 'boDeliveryStatus'; status.setAttribute('role', 'status'); button.parentNode.insertBefore(status, button); }
1808      status.textContent = deliveryBlocked ? (!Checkout.deliveryQuote().allowed ? Checkout.deliveryQuote().reason : (C
1808heckout.state.isGuest && !Checkout.guestAddressReady() ? 'Please check your delivery address.' : 'Minimum delivery order is ' + Checkout.money(totals.quote.minimum) + ' before fees.')) : '';
1809      status.hidden = !deliveryBlocked;
1810      var wallet = document.getElementById('boWalletPayment');
1811      if (wallet && (Checkout.state.walletReady || wallet.classList.contains('has-native-wallet'))) wallet.hidden = Boolean(deliveryBlocked);
1812
1813    },
1814
1815    cashRestriction: function () {
1816      var info = Checkout.info();
1817      if (info.cardOnly) return 'This shop accepts card payments only.';
1818      if (Checkout.state.isGuest && String(info.guestOnlyCard) !== '0') {
1819        return 'Please log in to place a cash order, or choose Card to continue as a guest.';
1820      }
1821      return '';
1822    },
1823
1824    selectPayment: function (method, button) {
1825      if (method === 'cash') {
1826        var restriction = Checkout.cashRestriction();
1827        if (restriction) { Utils.toast(restriction, 'error'); return; }
1828      }
1829      Checkout.state.payment = method;
1830      document.querySelectorAll('[data-payment]').forEach(function (candidate) { c
1830andidate.classList.toggle('is-selected', candidate === button); });
1831      var card = document.getElementById('boCardPayment');
1832      if (card) card.hidden = method !== 'card';
1833      Checkout.updatePaymentButton();
1834    },
1835
1836    cleanCartItems: function () {
1837      return Utils.cartItems().map(function (item) {
1838        var clean = Object.assign({}, item);
1839        delete clean._storageKey;
1840        delete clean._storageKeys;
1841        return clean;
1842      });
1843    },
1844
1845    appendFormValue: function (body, key, value) {
1846      if (Array.isArray(value)) {
1847        value.forEach(function (entry, index) { Checkout.appendFormValue(body, key + '[' + index + ']', entry); });
1848      } else if (value && typeof value === 'object') {
1849        Object.keys(value).forEach(function (child) { Checkout.appendFormValue(body, key + '[' + child + ']', value[child]); });
1850      } else {
1851        body.append(key, value == null ? '' : String(value));
1852      }
1853    },
1854
1855    orderRequest: function (url, payload) {
1856      if (payload.paymentType === 'Card Payment') Checkout.logPayment('request',{stage:url.replace(/[^a-zA-Z0-9_]/g,''),status:payload.intent_id === 'yes'?'confirm':'create'});
1857      var body = new URLSearchParams();
1858      Object.keys(payload).forEach(function (key) { Checkout.appendFormValue(body, key, payload[key]); });
1859      return window.fetch(url, { method: 'POST', credentials: 'same-origin', headers: { Accept: 'application/json,text/plain', 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, body: body }).then(function (response) {
1860        if (payload.paymentType === 'Card Payment') Checkout.logPayment('http_response',{status:response.status});
1861        return response.text().then(function (text) { return { ok: response.ok, status: response.status, text: text }; });
1862      });
1863    },
1864
1865    recoverOrder: function (payload) {
1866      Checkout.logPayment('recovery_started');
1867      Utils.toast('Checking your payment status. Please do not pay again.');
1868      if (Checkout.state.isGuest) {
1869        return Promise.reject(new Error('Your payment may have completed. Please contact the shop before trying again.'));
1870      }
1871      return Checkout.orderRequest('/orderRecovery', payload).then(function (response) {
1872        var result;
1873        try { result = JSON.parse(response.text); } catch (error) { result = null; }
1874        if (result && result.situation === 'Completed') {
1875          Checkout.logPayment('success',{stage:'recovery',orderId:result.id});
1876          Checkout.state.completed = true;
1877          Checkout.state.submitting = true;
1878          window.localStorage.clear();
1879          window.location.href = '/orderTracking?id=' + encodeURIComponent(result.id);
1880          return;
1881        }
1882        if (result && result.situation === 'Pending') throw new Error('Your payment is still processing. Check My Profile before paying again.');
1883        throw new Error('We could not verify the payment. Contact the shop or check My Profile before paying again.');
1884      });
1885    },
1886
1887    makeOrderPayload: function (paymentType, intent, intentId) {
1888      var totals = Checkout.state.totals;
1889      var customer = (Config.pageData && Config.pageData.customer) || {};
1890      var delivery = Checkout.state.orderType === 'Delivery';
1891      return {
1892        intent_id: intentId || 'no', intent: intent || '', paymentType: paymentType,
1893        address: delivery ? document.getElementById('boCheckoutAddress').value : 'Collection Order',
1894        postcode: delivery ? document.getElementById('boCheckoutPostcode').value : ' ',
1895        orderDetails: Checkout.cleanCartItems(), orderType: Checkout.state.orderType,
1896        orderTime: document.getElementById('boCheckoutTime').value || 'ASAP',
1897        message: String(document.getElementById('boCheckoutNotes').value || '').replace(/\r?\n|\r/g, ' '),
1898        telephone: document.getElementById('boCheckoutTelephone').value,
1899        email: document.getElementById('boCheckoutEmail').value,
1900        name: document.getElementById('boCheckoutName').value,
1901        tipp: totals.tip.toFixed(2), orderPrice: totals.total.toFixed(2),
1902        deliveryfee: totals.delivery.toFixed(2), servicefee: totals.service.toFixed(2), discount: totals.discount.toFixed(2),
1903        promotion: 'no', branchName: customer.branchName || ''
1904      };
1905    },
1906
1907    validateOrder: function () {
1908      var totals = Checkout.state.totals;
1909      if (!Checkout.state.orderType) return 'Choose Delivery or Collection.';
1910      if (!Checkout.cleanCartItems().length || !totals || totals.subtotal <= 0) return 'Your basket is empty.';
1911      if (String(Checkout.info().situation || '').toLowerCase() === 'closed') return Checkout.info().closeReason || 'The shop is currently closed.';
1912      if (Checkout.state.orderType === 'Delivery') {
1913        var currentQuote = Checkout.deliveryQuote();
1914        if (!currentQuote.allowed) return currentQuote.reason;
1915        if (Checkout.state.isGuest && !Checkout.guestAddressReady()) return 'Please check your delivery address.';
1916        if (!totals.quote.allowed) return totals.quote.reason;
1917        if (totals.subtotal < (Number(totals.quote.minimum) || 0)) return 'Minimum delivery order is ' + Checkout.money(totals.quote.minimum) + ' before fees.';
1918      }
1919      return '';
1920    },
1921
1922    guestDetails: function () {
1923      return {
1924        name: String(document.getElementById('boCheckoutName').value || '').trim(),
1925        email: String(document.getElementById('boCheckoutEmail').value || '').trim().toLowerCase(),
1926        telephone: Auth.validTelephone(document.getElementById('boCheckoutTelephone').value),
1927        address: String(document.getElementById('boCheckoutAddress').value || '').trim(),
1928        postcode: String(document.getElementById('boCheckoutPostcode').value || '').trim().toUpperCase()
1929      };
1930    },
1931
1932    guestAddressSignature: function () {
1933      return String(document.getElementById('boCheckoutAddress').value || '').trim() + '|' + String(document.getElementById('boCheckoutPostcode').value || '').replace(/\s/g, '').toUpperCase();
1934    },
1935    guestAddressReady: function () {
1936      var address = String(document.getElementById('boCheckoutAddress').value || '').trim();
1937      var postcode = document.getElementById('boCheckoutPostcode').value;
1938      return address.length >= 2 && (!Auth.suspiciousAddress(address, postcode) || Checkout.state.guestAddressKey === Checkout.guestAddressSignature());
1939    },
1940    deliveryModal: function (message, canContinue) {
1941      return new Promise(function (resolve) {
1942        var dialog = document.createElement('dialog');
1943        dialog.className = 'bo-dialog bo-address-dialog';
1944        var surface = document.createElement('div'); surface.className = 'bo-dialog__surface';
1945        var title = document.createElement('h2'); title.textContent = 'Check delivery';
1946        var text = document.createElement('p'); text.textContent = message;
1947        var actions = document.createElement('div');
1948        Object.assign(actions.style, { display: 'grid', gridTemplateColumns: canContinue ? 'repeat(2, minmax(0, 1fr))' : 'minmax(0, 1fr)', gap: '12px', marginTop: '24px' });
1949        var edit = document.createElement('button'); edit.type = 'button'; edit.className = 'bo-primary-button'; edit.textContent = canContinue ? 'Edit postcode' : 'OK';
1950        Object.assign(edit.style, { margin: '0', minWidth: '0', minHeight: '52px', padding: '12px', borderRadius: '10px', fontSize: '16px', lineHeight: '1.3' });
1951        if (canContinue) Object.assign(edit.style, { background: '#f3f3f5', color: '#292733', border: '1px solid #d4d4da' });
1952        actions.appendChild(edit);
1953        surface.append(title, text, actions);
1954        function finish(value) { if (!dialog.isConnected) return; Utils.closeDialog(dialog); dialog.remove(); resolve(value); }
1955        edit.addEventListener('click', function () { finish(false); });
1956        if (canContinue) {
1957          var proceed = document.createElement('button'); proceed.type = 'button'; proceed.className = 'bo-primary-button bo-secondary-button'; proceed.textContent = 'Use this postcode';
1958          Object.assign(proceed.style, { margin: '0', minWidth: '0', minHeight: '52px', padding: '12px', borderRadius: '10px', fontSize: '16px', lineHeight: '1.3' });
1959          proceed.addEventListener('click', function () { finish(true); }); actions.appendChild(proceed);
1960        }
1961        dialog.addEventListener('cancel', function (event) { event.preventDefault(); finish(false); });
1962        dialog.appendChild(surface); document.body.appendChild(dialog); Utils.showDialog(dialog);
1963      });
1964    },
1965    checkGuestAddress: function () {
1966      if (Checkout.guestAddressReady()) return Promise.resolve();
1967      if (Checkout.state.guestAddressRequest) return Checkout.state.guestAddressRequest;
1968      var address = String(document.getElementById('boCheckoutAddress').value || '').trim();
1969      var postcode = document.getElementById('boCheckoutPostcode').value;
1970      if (address.length < 2) return Promise.reject(new Error('Please enter your delivery address.'));
1971      var signature = Checkout.guestAddressSignature();
1972      var task = Auth.addressSuggestions(postcode, address).catch(function () { return []; }).then(function (suggestions) {
1973        if (signature !== Checkout.guestAddressSignature() || Checkout.state.orderType !== 'Delivery') throw new Error('Address changed. Please check it again.');
1974        return Auth.chooseAddress('Please check your full address, including the house number or building name. Choose a suggestion or use what you entered.', suggestions, true);
1975      }).then(function (choice) {
1976        if (signature !== Checkout.guestAddressSignature()) throw new Error('Address changed. Please check it again.');
1977        if (choice.action === 'edit') throw new Error('Please check your delivery address.');
1978        if (choice.action === 'pick') document.getElementById('boCheckoutAddress').value = 
1978String(choice.address || '');
1979        Checkout.state.guestAddressKey = Checkout.guestAddressSignature();
1980      }).finally(function () { Checkout.state.guestAddressRequest = null; Checkout.updatePaymentButton(); });
1981      Checkout.state.guestAddressRequest = task;
1982      return task;
1983    },
1984    checkGuestDelivery: function () {
1985      var field = document.getElementById('boCheckoutPostcode');
1986      var key = String(field.value || '').replace(/\s/g, '').toUpperCase();
1987      var state = Checkout.state;
1988      if (state.guestQuote && state.guestQuote.postcode.replace(/\s/g, '') === key && state.guestQuote.expires > Date.now()) return Promise.resolve(state.guestQuote);
1989      if (state.guestQuoteRequest && state.guestQuoteRequest.key === key) return state.guestQuoteRequest.promise;
1990      if (!/^(GIR0AA|[A-Z]{1,2}\d[A-Z\d]?\d[A-Z]{2})$/.test(key)) return Promise.reject(new Error('Please enter a valid UK postcode.'));
1991      state.guestDeliveryError = 'Checking delivery…'; Checkout.updatePaymentButton();
1992      var request = { key: key };
1993      function stillCurrent() { return String(field.value || '').replace(/\s/g, '').toUpperCase() === key && state.orderType === 'Delivery'; }
1994      function load(allowUnverified) {
1995        return window.fetch('/guestDeliveryQuote', {
1996          method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ postcode: key, allowUnverifiedPostcode: allowUnverified === true })
1997        }).then(function (response) {
1998          return response.json().then(function (quote) {
1999            if (!stillCurrent()) throw new Error('Postcode changed. Please check the new postcode.');
2000            if (quote.code === 'POSTCODE_UNVERIFIED' && !allowUnverified) {
2001              return Checkout.deliveryModal(quote.message, true).then(function (proceed) {
2002                if (!stillCurrent()) throw new Error('Postcode changed. Please check the new postcode.');
2003                if (!proceed) { var cancelled = new Error('Please check your postcode.'); cancelled.shown = true; throw cancelled; }
2004                return load(true);
2005              });
2006            }
2007            if (!response.ok || !quote.allowed) throw new Error(quote.message || 'Unable to check delivery. Please try again.');
2008            state.guestQuote = quote; state.guestDistance = quote.distance; state.guestDeliveryError = '';
2009            field.value = quote.postcode; Checkout.calculate(); return quote;
2010          });
2011        });
2012      }
2013      request.promise = load(false).catch(function (error) {
2014        if (stillCurrent()) {
2015          state.guestQuote = null; state.guestDeliveryError = error.message || 'Unable to check delivery.'; Checkout.calculate();
2016          if (!error.shown) return Checkout.deliveryModal(state.guestDeliveryError, false).then(function () { error.shown = true; throw error; });
2017        }
2018        throw error;
2019      }).finally(function () { if (state.guestQuoteRequest === request) state.guestQuoteRequest = null; });
2020      state.guestQuoteRequest = request;
2021      return request.promise;
2022    },
2023    refreshGuestChecks: function () {
2024      if (!Checkout.state.isGuest || Checkout.state.orderType !== 'Delivery') return;
2025      var postcode = String(document.getElementById('boCheckoutPostcode').value || '').replace(/\s/g, '');
2026      if (!/^(GIR0AA|[A-Z]{1,2}\d[A-Z\d]?\d[A-Z]{2})$/i.test(postcode)) return;
2027      Checkout.checkGuestDelivery().then(function () {
2028        if (String(document.getElementById('boCheckoutAddress').value || '').trim().length >= 2) return Checkout.checkGuestAddress();
2029      }).catch(function () {}).finally(function () { Checkout.updatePaymentButton(); });
2030    },
2031
2032    prepareGuest: function () {
2033      if (!Checkout.state.isGuest) return Promise.resolve();
2034      var details = Checkout.guestDetails();
2035      if (details.name.length < 2) return Promise.reject(new Error('Please enter your name.'));
2036      if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(details.email)) return Promise.reject(new Error('Please enter a valid email address.'));
2037      if (!details.telephone) return Promise.reject(new Error('Please enter a valid telephone number.'));
2038      if (Checkout.state.orderType !== 'Delivery') { Checkout.state.guestPrepared = true; return Promise.resolve(); }
2039      if (details.address.length < 2) return Promise.reject(new Error('Please enter your delivery address.'));
2040      if (!/^(GIR0AA|[A-Z]{1,2}\d[A-Z\d]?\d[A-Z]{2})$/i.test(details.postcode.replace(/\s/g, ''))) return Promise.reject(new Error("Your postcode doesn't look right."));
2041      return Checkout.checkGuestDelivery().then(function (quote) {
2042        details.postcode = quote.postcode;
2043        return Checkout.checkGuestAddress();
2044      }).then(function () {
2045        Checkout.state.guestPrepared = true;
2046        Checkout.calculate();
2047        var problem = Checkout.validateOrder();
2048        if (problem) throw new Error(problem);
2049      });
2050    },
2051
2052    applyServerChanges: function (change) {
2053      var unavailable = String(change.deleted || '').split(',').concat(String(change.notforsaleid || '').split(',')).filter(
2053Boolean);
2054      var priced = String(change.priced || '').split(',');
2055      var portions = String(change.portion || '').split(',');
2056      var prices = String(change.pricedPrices || '').split(',');
2057      var differences = String(change.pricedDiff || '').split(',');
2058      Utils.cartItems().forEach(function (item) {
2059        var storageKey = item._storageKey;
2060        if (unavailable.indexOf(String(item.id)) !== -1) { if (storageKey) window.localStorage.removeItem(storageKey); return; }
2061        for (var i = 0; i < priced.length; i += 1) {
2062          if (String(item.id) === priced[i] && String(item.portion) === portions[i]) {
2063            item.basePrice = (Number(item.amount) || 1) * (Number(prices[i]) || 0);
2064            item.fullPrice = item.basePrice + (Number(differences[i]) || 0);
2065            delete item._storageKey;
2066            if (storageKey) window.localStorage.setItem(String(storageKey), JSON.stringify(item));
2067          }
2068        }
2069      });
2070      Checkout.calculate();
2071      if (Dom.cartSummary && Dom.cartDialogSummary && Dom.cartList) Cart.refresh();
2072      Utils.toast('Your basket changed because an item price or availability was updated. Please review it.', 'error');
2073    },
2074
2075    handleOrderResponse: function (response, payload, walletEvent) {
2076      if (response.text === 'Not Logged In') throw new Error('Please log in again.');
2077      if (response.text === 'Too Early') throw new Error('This may be a duplicate order. Please check My Profile before ordering again.');
2078      if (response.text === 'Payment Failed') throw new Error('Payment was declined by your bank. Please check the card or use another one.');
2079      var deliveryError;
2080      try { deliveryError = JSON.parse(response.text); } catch (_) {}
2081      if (deliveryError && deliveryError.situation === 'Delivery Check Failed') {
2082        Checkout.state.guestPrepared = false; Checkout.state.guestQuote = null;
2083        throw new Error(deliveryError.information || 'Please check your delivery postcode again.');
2084      }
2085      if (!response.ok || response.text === 'error') throw new Error('The order could not be processed. Please try again.');
2086      var result;
2087      try { result = JSON.parse(response.text); } catch (error) { throw new Error('The server returned an unexpected response.'); }
2088      if (payload.paymentType === 'Card Payment') Checkout.logPayment('server_result',{status:result.situation || (result.type?'basket_changed':'unknown')});
2089      if (result.type) { Checkout.applyServerChanges(result); throw new Error('Basket updated. Please check it and submit again.'); }
2090      if (result.situation === 'Completed') {
2091        if (payload.paymentType === 'Card Payment') Checkout.logPayment('success',{orderId:result.id});
2092        if (walletEvent && typeof walletEvent.complete === 'function') walletEvent.complete('success');
2093        Checkout.state.walletActive = false;
2094        Checkout.state.completed = true;
2095        Checkout.state.submitting = true;
2096        window.localStorage.clear();
2097        window.location.href = result.token ? '/orderTrackingGuest?token=' + encodeURIComponent(result.token) : '/orderTracking?id=' + encodeURIComponent(result.id);
2098        return;
2099      }
2100      if (result.situation === 'SDK') {
2101        Checkout.logPayment('authentication_required');
2102        if (walletEvent && typeof walletEvent.complete === 'function') walletEvent.complete('fail');
2103        Checkout.state.walletActive = false;
2104        return Checkout.state.stripe.handleCardAction(result.payment_intent_client_secret).then(function (action) {
2105          if (action.error) { Checkout.logPayment('authentication_failed',{code:action.error.code,decline_code:action.error.decline_code}); throw new Error(action.error.message || 'Authentication was not completed.'); }
2106          Checkout.logPayment('authentication_completed');
2107          var confirmed = Object.assign({}, payload, { intent: action.paymentIntent.id, intent_id: 'yes' });
2108          var confirmationUrl = Checkout.state.isGuest ? '/orderCompletedGuest' : '/orderCompletedcd';
2109          return Checkout.orderRequest(confirmationUrl, confirmed).then(function (next) { return Checkout.handleOrderResponse(next, confirmed, null); }, function () { return Checkout.recoverOrder(confirmed); });
2110        });
2111      }
2112      if (result.situation === 'Failed' || result.situation === 'Payment Failed') throw new Error(result.information || result.reason || 'Payment was declined.');
2113      throw new Error('The order could not be confirmed. Please check My Profile before trying again.');
2114    },
2115
2116    beginPaymentAttempt: function () {
2117      var state = Checkout.state;
2118      if (state.submitting || state.completed) return null;
2119      var attempt = { submitted: false };
2120      state.paymentAttempt = attempt;
2121      state.submitting = true;
2122      Checkout.updatePaymentButton();
2123      return attempt;
2124    },
2125
2126    finishPaymentAttempt: function (attempt) {
2127      var state = Checkout.state;
2128      if (state.paymentAttempt !== attempt || state.completed) return;
2129      state.paymentAttempt = null;
2130      state.submitting = false;
2131      Checkout.updatePaymentButton();
2132    },
2133
2134    submitOrder: function (paymentMethodId, intentId, walletEvent, attempt) {
2135      attempt = attempt || Checkout.beginPaymentAttempt();
2136      if (!attempt || Checkout.state.paymentAttempt !== attempt || attempt.submitted || Checkout.state.completed) {
2137        if (walletEvent && typeof walletEvent.complete === 'function') walletEvent.complete('fail');
2138        return Promise.resolve();
2139      }
2140      attempt.submitted = true;
2141      var paymentType = paymentMethodId === 'Cash' ? 'Cash' : 'Card Payment';
2142      return Promise.resolve().then(function () {
2143        if (paymentType === 'Cash') {
2144          var restriction = Checkout.cashRestriction();
2145          if (restriction) throw new Error(restriction);
2146        }
2147        if (Checkout.state.isGuest && (!Checkout.state.guestPrepared || (Checkout.state.orderType === 'Delivery' && !Checkout.deliveryQuote().allowed))) return Checkout.prepareGuest();
2148      }).then(function () {
2149        var problem = Checkout.validateOrder();
2150        if (problem) throw new Error(problem);
2151        var payload = Checkout.makeOrderPayload(paymentType, paymentMethodId, intentId);
2152        if (!Array.isArray(payload.orderDetails) || !payload.orderDetails.length) throw new Error('Your basket is empty. Please return to the menu.');
2153        var url = Checkout.state.isGuest ? '/orderCompletedGuest' : (paymentType === 'Cash' ? '/orderCompletedc' : '/orderCompletedcd');
2154        return Checkout.orderRequest(url, payload).then(function (response) { return Checkout.handleOrderResponse(response, payload, walletEvent); });
2155      }).catch(function (error) {
2156        if (paymentType === 'Card Payment') Checkout.logPayment('failed',{stage:'order_submission',code:error.code});
2157        if (walletEvent && typeof walletEvent.complete === 'function') walletEvent.complete('fail');
2158        Checkout.state.walletActive = false;
2159        Utils.toast(error.message || 'The order could not be completed.', 'error');
2160      }).finally(function () { Checkout.finishPaymentAttempt(attempt); });
2161    },
2162
2163    placeOrder: function () {
2164      var attempt = Checkout.beginPaymentAttempt();
2165      if (!attempt) return Promise.resolve();
2166      return Promise.resolve().then(function () {
2167        if (Checkout.state.isGuest && (!Checkout.state.guestPrepared || (Checkout.state.orderType === 'Delivery' && !Checkout.deliveryQuote().allowed))) return Checkout.prepareGuest();
2168      }).then(function () {
2169        var problem = Checkout.validateOrder();
2170        if (problem) throw new Error(problem);
2171        if (Checkout.state.payment === 'cash') return Checkout.submitOrder('Cash', 'no', null, attempt);
2172        if (Checkout.state.payment !== 'card' || !Checkout.state.stripe || !Checkout.state.cardNumber) throw new Error('Choose a payment method.');
2173        Checkout.startPaymentLog('card');
2174        return Checkout.state.stripe.createPaymentMethod({ type: 'card', card: Checkout.state.cardNumber, billing_details: { name: document.getElementById('boCheckoutName').value } }).then(function (result) {
2175          if (result.error || !result.paymentMethod) {
2176            Checkout.logPayment('payment_method_failed',{code:result.error && result.error.code,decline_code:result.error && result.error.decline_code});
2177            throw new Error((result.error && result.error.message) || 'Please check your card details.');
2178          }
2179          Checkout.logPayment('payment_method_created');
2180          return Checkout.submitOrder(result.paymentMethod.id, 'no', null, attempt);
2181        });
2182      }).catch(function (error) {
2183        Checkout.logPayment('failed',{stage:'card_entry',code:error.code});
2184        Utils.toast(error.message || 'Please check your card details.', 'error');
2185      }).finally(function () { Checkout.finishPaymentAttempt(attempt); });
2186    },
2187
2188    init: function () {
2189      var items = document.getElementById('boCheckoutItems');
2190      if (!items) return;
2191      Checkout.initStripe();
2192      Checkout.initNativeWallet();
2193      Checkout.state.orderType = '';
2194      document.querySelectorAll('input[name="boOrderType"]').forEach(function (input) { input.checked = false; });
2195      document.querySelectorAll('input[name="boOrderType"]').forEach(function (input) {
2196        input.addEventListener('click', function () { Checkout.chooseOrderType(input.value); });
2197      });
2198      document.querySelectorAll('input[name="boTip"]').forEach(function (input) {
2199        input.addEventListener('click', function (event) {
2200          var parts = String(input.value || 'none:0').split(':');
2201          var value = Number(parts[1]) || 0;
2202          if (Checkout.state.tipType === parts[0] && Checkout.state.tipValue === value) {
2203            // Cancelling a radio click would restore its previous checked state.
2204            input.checked = false;
2205            Checkout.state.tipType = 'none';
2206            Checkout.state.tipValue = 0;
2207            Checkout.calculate();
2208          }
2209        });
2210        input.addEventListener('change', function () {
2211          var parts = String(input.value || 'none:0').split(':');
2212          Checkout.state.tipType = input.checked ? parts[0] : 'none';
2213          Checkout.state.tipValue = input.checked ? Number(parts[1]) || 0 : 0;
2214          Checkout.calculate();
2215        });
2216      });
2217      document.querySelectorAll('[data-payment]').forEach(function (button) {
2218        button.addEventListener('click', function () {
2219          Checkout.selectPayment(button.dataset.payment, button);
2220        });
2221      });
2222      var placeOrder = document.querySelector('.bo-place-order');
2223      if (placeOrder) placeOrder.addEventListener('click', Checkout.placeOrder);
2224      var guestButton = document.getElementById('boContinueGuest');
2225      if (guestButton) guestButton.addEventListener('click', function () {
2226        var loginPanel = document.querySelector('.bo-checkout-login');
2227        var grid = document.getElementById('boCheckoutGrid');
2228        if (loginPanel) loginPanel.hidden = true;
2229        if (grid) grid.hidden = false;
2230        window.scrollTo({ top: 0, behavior: 'smooth' });
2231      });
2232      if (Checkout.state.isGuest) {
2233        ['boCheckoutName', 'boCheckoutEmail', 'boCheckoutTelephone', 'boCheckoutAddress', 'boCheckoutPostcode'].forEach(function (id) {
2234          var field = document.getElementById(id);
2235          if (!field) return;
2236          field.addEventListener('input', function () {
2237            Checkout.state.guestPrepared = false;
2238            if (id === 'boCheckoutPostcode') { Checkout.state.guestDistance = null; Checkout.state.guestQuote = null; Checkout.state.guestDeliveryError = ''; }
2239            if (id === 'boCheckoutAddress' || id === 'boCheckoutPostcode') { Checkout.state.guestAddressKey = ''; Checkout.calculate(); }
2240          });
2241        });
2242      }
2243      if (Checkout.state.isGuest) {
2244        var guestPostcodeField = document.getElementById('boCheckoutPostcode');
2245        var guestPostcodeTimer;
2246        function refreshGuestDelivery() { Checkout.refreshGuestChecks(); }
2247        document.getElementById('boCheckoutAddress').addEventListener('blur', refreshGuestDelivery);
2248        guestPostcodeField.addEventListener('input', function () { window.clearTimeout(guestPostcodeTimer); guestPostcodeTimer = window.setTimeout(refreshGuestDelivery, 700); });
2249        guestPostcodeField.addEventListener('blur', function () { window.clearTimeout(guestPostcodeTimer); refreshGuestDelivery(); });
2250      }
2251      var noteButton = document.getElementById('boSaveNote');
2252      if (noteButton) noteButton.addEventListener('click', Checkout.saveNote);
2253      var savedNotes = document.getElementById('boSavedNotes');
2254      if (savedNotes) savedNotes.addEventListener('change', function () {
2255        if (savedNotes.value) document.getElementById('boCheckoutNotes').value = savedNotes.value.replace(/\r?\n|\r/g, ' ');
2256      });
2257      var voucherButton = document.getElementById('boApplyVoucher');
2258      if (voucherButton) voucherButton.addEventListener('click', Checkout.applyVoucher);
2259      var collectionButton = document.getElementById('boChooseCollection');
2260      if (collectionButton) collectionButton.addEventListener('click', Checkout.chooseCollectionFromDialog);
2261      var deleteCollectionButton = document.getElementById('boDeleteCollectionOnly');
2262      if (deleteCollectionButton) deleteCollectionButton.addEventListener('click', Checkout.deleteCollectionOnlyItems);
2263      document.querySelectorAll('[data-checkout-details-open]').forEach(function (button) {
2264        button.addEventListener('click', Checkout.openDetails);
2265      });
2266      var detailsForm = document.getElementById('boCheckoutDetailsForm');
2267      if (detailsForm) detailsForm.addEventListener('submit', Checkout.saveDetails);
2268      Checkout.calculate();
2269      var situation = String(Checkout.info().situation || '').toLowerCase();
2270      if (situation === 'closed') Utils.toast(Checkout.info().closeReason || 'The shop is currently closed.', 'error');
2271    }
2272  };
2273
2274  var Menu = {
2275    categoriesFromBranch: function (items) {
2276      var info = Config.branchInfo || {};
2277      var names = Utils.toArray(info.headerNames);
2278      var availability = Utils.toArray(info.headerAvailable);
2279      var categories = names.filter(function (name, index) {
2280        return name.toLowerCase() !== 'custom' && String(availability[index] == null ? '1' : availability[index]) !== '0';
2281      }).map(function (name) {
2282        return { name: name, key: Utils.categoryKey(name) };
2283      });
2284
2285      if (!categories.length) {
2286        var seen = {};
2287        items.forEach(function (item) {
2288          var name = String(item.menuHeader || '').trim();
2289          var key = Utils.categoryKey(name);
2290          if (name && !seen[key]) {
2291            seen[key] = true;
2292            categories.push({ name: name, key: key });
2293          }
2294        });
2295      }
2296      return categories;
2297    },
2298
2299    hasFlag: function (item, flag) {
2300      return String((item || {}).misc || '').toLowerCase().split(/[\s,;]+/).includes(flag);
2301    },
2302
2303    normalizeItem: function (item) {
2304      var normalized = Object.assign({}, item);
2305      normalized.categoryKey = Utils.categoryKey(item.menuHeader);
2306      normalized.available = Number(item.availability) !== 0;
2307      normalized.priceText = Utils.displayPrices(item.prices);
2308      return normalized;
2309    },
2310
2311    load: function () {
2312      Dom.menuContent.setAttribute('aria-busy', 'true');
2313      Api.getMenuItems().then(function (items) {
2314        State.items = items.map(Menu.normalizeItem);
2315        State.categories = Menu.categoriesFromBranch(State.items);
2316        State.activeCategory = State.categories.length ? State.categories[0].key : '';
2317        Menu.renderCategories();
2318        Menu.renderProducts();
2319        Menu.filter(State.activeCategory);
2320        Dom.status.hidden = true;
2321        Dom.menuContent.setAttribute('aria-busy', 'false');
2322      }).catch(function (error) {
2323        console.error('Menu V2 load error:', error);
2324        Dom.status.innerHTML = '';
2325        var message = document.createElement('span');
2326        message.textContent = 'The menu could not be loaded. Please refresh and try again.';
2327        Dom.status.appendChild(message);
2328        Dom.menuContent.setAttribute('aria-busy', 'false');
2329      });
2330    },
2331
2332    renderCategories: function () {
2333      Dom.categories.replaceChildren();
2334      State.categories.forEach(function (category) {
2335        var button = document.createElement('button');
2336        button.type = 'button';
2337        button.className = 'bo-category';
2338        button.dataset.category = category.key;
2339        button.textContent = category.name;
2340        button.addEventListener('click', function () {
2341          Menu.filter(category.key);
2342          window.scrollTo({ top: 0, behavior: 'smooth' });
2343        });
2344        Dom.categories.appendChild(button);
2345      });
2346    },
2347
2348    renderProducts: function () {
2349      Dom.products.replaceChildren();
2350      State.items.forEach(function (item) {
2351        if (String(item.misc || '').toLowerCase() === 'yes') return;
2352
2353        var promo = Menu.hasFlag(item, 'promo');
2354        var list = Menu.hasFlag(item, 'list');
2355        var feature = !list && Menu.hasFlag(item, 'feature');
2356        var card = document.createElement('article');
2357        card.className = 'bo-product-card' + (item.available || promo ? '' : ' is-unavailable') +
2358          (feature ? ' bo-product-card--feature' : '') + (list ? ' bo-product-card--list' : '') + (promo ? ' bo-product-card--promo' : '');
2359        card.dataset.category = item.categoryKey;
2360
2361        var open = document.createElement('button');
2362        open.type = 'button';
2363        open.className = 'bo-product-card__open';
2364        open.setAttribute('aria-label', 'View ' + (item.itemName || 'item'));
2365        open.addEventListener('click', function () {
2366          if (promo) { InfoDialog.open(item); return; }
2367          if (!item.available) {
2368            Utils.toast('Sorry, this item is currently unavailable.', 'error');
2369            return;
2370          }
2371          if (String(item.type || '').toLowerCase() === 'offer') {
2372            DealDialog.open(item);
2373          } else {
2374            ProductDialog.open(item);
2375          }
2376        });
2377
2378        var add = document.createElement('span');
2379        add.className = 'bo-product-card__add';
2380        add.textContent = item.available ? '+' : '−';
2381        add.setAttribute('aria-hidden', 'true');
2382
2383        var imageWrap = document.createElement('span');
2384        imageWrap.className = 'bo-product-card__image-wrap';
2385        if (item.image) {
2386          var image = document.createElement('img');
2387          image.className = 'bo-product-card__image';
2388          image.src = item.image;
2389          image.alt = '';
2390          image.loading = 'lazy';
2391          imageWrap.appendChild(image);
2392        } else {
2393          var placeholder = document.createElement('span');
2394          placeholder.className = 'bo-product-card__image-placeholder';
2395          imageWrap.appendChild(placeholder);
2396        }
2397
2398        var body = document.createElement('span');
2399        body.className = 'bo-product-card__body';
2400        var name = document.createElement('h2');
2401        name.className = 'bo-product-card__name';
2402        name.textContent = item.itemName || 'Unnamed item';
2403        var descriptionText = Utils.shortenedText(item.explanation, feature ? 150 : (list ? 100 : PRODUCT_DESCRIPTION_LIMIT));
2404        var description = document.createElement('span');
2405        description.className = 'bo-product-card__description';
2406        description.textContent = descriptionText.text;
2407        var price = document.createElement('span');
2408        price.className = 'bo-product-card__price';
2409        price.textContent = item.priceText;
2410        body.append(name, description);
2411        if (!promo) body.appendChild(price);
2412        if (feature || promo) {
2413          var action = document.createElement('span');
2414          action.className = 'bo-product-card__action';
2415          action.textContent = promo ? 'View promotion' : (item.available ? 'Choose options →' : 'Currently unavailable');
2416          body.appendChild(action);
2417        }
2418
2419        if (!promo && !feature) open.appendChild(add);
2420        open.append(imageWrap, body);
2421        card.appendChild(open);
2422        if (descriptionText.shortened && !promo) {
2423          card.classList.add('has-info');
2424          var infoButton = document.createElement('span');
2425          infoButton.className = 'bo-product-card__info';
2426          infoButton.textContent = '(i)';
2427          infoButton.setAttribute('role', 'button');
2428          infoButton.setAttribute('tabindex', '0');
2429          infoButton.setAttribute('aria-label', 'Read full description for ' + (item.itemName || 'this item'));
2430          function openInformation(event) {
2431            event.preventDefault();
2432            event.stopPropagation();
2433            InfoDialog.open(item);
2434          }
2435          infoButton.addEventListener('click', openInformation);
2436          infoButton.addEventListener('keydown', function (event) {
2437            if (event.key === 'Enter' || event.key === ' ') openInformation(event);
2438          });
2439          description.appendChild(document.createTextNode(' '));
2440          description.appendChild(infoButton);
2441        }
2442        Dom.products.appendChild(card);
2443      });
2444    },
2445
2446    filter: function (categoryKey) {
2447      State.activeCategory = categoryKey;
2448      Dom.categories.querySelectorAll('.bo-category').forEach(function (button) {
2449        var active = button.dataset.category === categoryKey;
2450        button.classList.toggle('is-active', active);
2451        button.setAttribute('aria-pressed', active ? 'true' : 'false');
2452      });
2453      Dom.products.querySelectorAll('.bo-product-card').forEach(function (card) {
2454        card.hidden = Boolean(categoryKey) && card.dataset.category !== categoryKey;
2455      });
2456    }
2457  };
2458
2459  var DealDialog = {
2460    selectedItem: null,
2461    steps: [],
2462    selectedIds: {},
2463    customizations: {},
2464
2465    comparableName: function (value) {
2466      return String(value || '').toLowerCase().replace(/\b\d+(?:\.\d+)?\s*(?:ml|ltr|l|oz|inch|in)\b/g, ' ').replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim();
2467    },
2468
2469    normalizeListKey: function (value) {
2470      return String(value || '').trim().replace(/\|+$/, '').replace(/\s+/g, '').toLowerCase().replace(/s$/, '');
2471    },
2472
2473    configuredList: function (token) {
2474      var info = Config.branchInfo || {};
2475      var lists = Utils.branchJson(info.optionLists, []);
2476      var wanted = DealDialog.normalizeListKey(token);
2477      return lists.find(function (list) {
2478        return list && DealDialog.normalizeListKey(list.key) === wanted;
2479      }) || null;
2480    },
2481
2482    configOptions: function (token) {
2483      var info = Config.branchInfo || {};
2484      var configured = DealDialog.configuredList(token);
2485      if (configured) {
2486        var configuredOptions = Array.isArray(configured.options) ? configured.options : [];
2487        if (!configuredOptions.length && configured.items) {
2488          var itemNames = String(configured.items).split(String(configured.items).indexOf('|') !== -1 ? '|' : ',');
2489          var itemPrices = String(configured.prices == null ? '' : configured.prices).split(';');
2490          configuredOptions = itemNames.map(function (name, index) {
2491            return { name: name, price: itemPrices.length === 1 ? itemPrices[0] : itemPrices[index] };
2492          });
2493        }
2494        var normalizedOptions = configuredOptions.map(function (option) {
2495          if (option && typeof option === 'object') {
2496            return { name: String(option.name || '').trim(), price: Number(option.price) || 0 };
2497          }
2498          return { name: String(option || '').trim(), price: 0 };
2499        }).filter(function (option) { return Boolean(option.name); });
2500        if (normalizedOptions.length) return normalizedOptions;
2501      }
2502
2503      var key = DealDialog.normalizeListKey(token);
2504      var sourceMap = {
2505        pizza: info.pizzaNames,
2506        kebab: info.kebabNames,
2507        side: info.sides,
2508        bottle: info.bottles,
2509        can: info.cans,
2510        dip: info.dips,
2511        kid: info.kids,
2512        elist: info.elist,
2513        elistx: info.elistx
2514      };
2515      return Utils.values(sourceMap[key]).filter(Boolean).map(function (name) {
2516        return { name: name, price: 0 };
2517      });
2518    },
2519
2520    candidates: function (token) {
2521      var cleanToken = String(token || '').trim();
2522      if (/^\d+$/.test(cleanToken)) {
2523        return State.items.filter(function (item) { return !Menu.hasFlag(item, 'promo') && String(item.id) === cleanToken; });
2524      }
2525
2526      var configuredOptions = DealDialog.configOptions(cleanToken);
2527      if (configuredOptions.length) {
2528        var matches = [];
2529        configuredOptions.forEach(function (configuredOption, optionIndex) {
2530          // List entries are authoritative: never substitute a partial menu-name match.
2531          var exactName = function (name) { return String(name || '').trim().replace(/\s+/g, ' ').toLowerCase(); };
2532          var wanted = exactName(configuredOption.name);
2533          var match = State.items.find(function (item) {
2534            return !Menu.hasFlag(item, 'promo') && item.available && wanted && exactName(item.itemName) === wanted;
2535          });
2536          if (match && !matches.some(function (item) { return String(item.id) === String(match.id); })) {
2537            matches.push(Object.assign({}, match, {
2538              itemName: configuredOption.name,
2539              optionListPrice: Number(configuredOption.price) || 0,
2540              optionListName: configuredOption.name,
2541              optionListKey: cleanToken
2542            }));
2543          } else if (!match) {
2544            if (State.items.some(function (item) { return Menu.hasFlag(item, 'promo') && exactName(item.itemName) === wanted; })) return;
2545            matches.push({
2546              id: 'option-list:' + DealDialog.normalizeListKey(cleanToken) + ':' + optionIndex,
2547              itemName: configuredOption.name,
2548              available: true,
2549              optionListPrice: Number(configuredOption.price) || 0,
2550              optionListName: configuredOption.name,
2551              optionListKey: cleanToken,
2552              optionListIndex: optionIndex,
2553              isVirtualOption: true
2554            });
2555          }
2556        });
2557        if (matches.length) return matches;
2558      }
2559
2560      var tokenKey = Utils.categoryKey(cleanToken).toLowerCase();
2561      var singularKey = tokenKey.replace(/s$/, '');
2562      return State.items.filter(function (item) {
2563        if (Menu.hasFlag(item, 'promo') || !item.available || String(item.type || '').toLowerCase() === 'offer') return false;
2564        var categoryKey = String(item.categoryKey || '').toLowerCase();
2565        return categoryKey === tokenKey || categoryKey.replace(/s$/, '') === singularKey;
2566      });
2567    },
2568
2569    rememberSelections: function () {
2570      Dom.productDialogBody.querySelectorAll('[data-deal-step]').forEach(function (select) {
2571        DealDialog.selectedIds[select.dataset.dealStep] = select.value;
2572      });
2573    },
2574
2575    customizeStep: function (stepIndex) {
2576      DealDialog.rememberSelections();
2577      var step = DealDialog.steps[stepIndex];
2578      var selectedId = step.fixed ? String((step.candidates[0] || {}).id || '') : DealDialog.selectedIds[String(stepIndex)];
2579      var selected = step.candidates.find(function (item) { return String(item.id) === String(selectedId); }) ||
2580        State.items.find(function (item) { return String(item.id) === String(selectedId); });
2581      if (!selected) {
2582        Utils.toast('Please choose an item first.', 'error');
2583        return;
2584      }
2585      if (selected.isVirtualOption) {
2586        Utils.toast('This option has no customizations.');
2587        return;
2588      }
2589      ProductDialog.open(selected, {
2590        fixedPortion: step.size,
2591        lockPortion: true,
2592        hideMakeMeal: true,
2593        actionLabel: 'Finished here',
2594        onSave: function (configuration) {
2595          DealDialog.customizations[String(stepIndex)] = configuration;
2596          DealDialog.open(DealDialog.selectedItem, true);
2597        }
2598      });
2599    },
2600
2601    stepLabel: function (token, index) {
2602      if (/^\d+$/.test(String(token || '').trim())) return 'Included item ' + (index + 1);
2603      return 'Choose item';
2604    },
2605
2606    price: function () {
2607      var prices = Utils.numericPrices((DealDialog.selectedItem || {}).prices);
2608      var base = prices.length ? prices[0] : 0;
2609      DealDialog.steps.forEach(function (step) {
2610        var selectedId = step.fixed ? String((step.candidates[0] || {}).id || '') : DealDialog.selectedIds[String(step.index)];
2611        var selected = step.candidates.find(function (candidate) {
2612          return String(candidate.id) === String(selectedId);
2613        });
2614        base += Number((selected || {}).optionListPrice) || 0;
2615      });
2616      Object.keys(DealDialog.customizations).forEach(function (key) {
2617        var saved = DealDialog.customizations[key] || {};
2618        base += Math.max(0, (Number(saved.fullPrice) || 0) - (Number(saved.basePrice) || 0));
2619      });
2620      return base;
2621    },
2622
2623    updateTotal: function () {
2624      var quantity = Math.max(1, Number((Dom.productDialogBody.querySelector('#boProductQuantity') || {}).value) || 1);
2625      var total = Dom.productDialogBody.querySelector('#boProductTotal');
2626      if (total) total.textContent = '£' + (DealDialog.price() * quantity).toFixed(2);
2627    },
2628
2629    selections: function () {
2630      DealDialog.rememberSelections();
2631      return DealDialog.steps.map(function (step) {
2632        if (step.fixed) return step.candidates[0] || null;
2633        var selectedId = DealDialog.selectedIds[String(step.index)];
2634        return step.candidates.find(function (item) { return String(item.id) === String(selectedId); }) || null;
2635      });
2636    },
2637
2638    addToCart: function () {
2639      if (Menu.hasFlag(DealDialog.selectedItem, 'promo')) return;
2640      var selections = DealDialog.selections();
2641      var missing = selections.findIndex(function (selection) { return !selection; });
2642      if (missing !== -1) {
2643        var missingSelect = Dom.productDialogBody.querySelector('[data-deal-step="' + missing + '"]');
2644        Utils.requireChoice(missingSelect, Utils.requiredPrompt(missingSelect, (DealDialog.steps[missing] || {}).token));
2645        return;
2646      }
2647
2648      var item = DealDialog.selectedItem;
2649      var quantity = Math.max(1, Number((Dom.productDialogBody.querySelector('#boProductQuantity') || {}).value) || 1);
2650      var unit = DealDialog.price();
2651      var originalUnit = Utils.numericPrices(item.prices)[0] || 0;
2652      var selectedText = selections.map(function (selection, index) {
2653        var size = DealDialog.steps[index].size;
2654        var text = [selection.itemName, size].filter(Boolean).join(' - ');
2655        var optionPrice = Number(selection.optionListPrice) || 0;
2656        return text + (optionPrice ? ' (+£' + optionPrice.toFixed(2) + ')' : '');
2657      });
2658      var features = selectedText.map(function (line, index) {
2659        var saved = DealDialog.customizations[String(index)] || {};
2660        return '<p class="itemOptions">' + Utils.escapeHtml(line) + '</p>' + String((saved.customization || {}).features || '') + String((saved.customization || {}).additions || '');
2661      }).join('');
2662      var stored = Object.assign({}, item, {
2663        id: item.id,
2664        name: item.itemName,
2665        displayName: item.itemName,
2666        portion: ' ',
2667        amount: quantity,
2668        basePrice: (originalUnit * quantity).toFixed(2),
2669        fullPrice: (unit * quantity).toFixed(2),
2670        offersx: selectedText.join(' | '),
2671        customization: {
2672          offerItems: selections.map(function (selection) {
2673            return selection.isVirtualOption ? null : selection.id;
2674          }),
2675          offerSelections: selections.map(function (selection) {
2676            return {
2677              id: selection.isVirtualOption ? null : selection.id,
2678              name: selection.itemName,
2679              price: Number(selection.optionListPrice) || 0,
2680              virtual: Boolean(selection.isVirtualOption)
2681            };
2682          }),
2683          offerCustomizations: DealDialog.customizations,
2684          features: features,
2685          additions: ''
2686        }
2687      });
2688      window.localStorage.setItem(ProductDialog.nextCartKey(), JSON.stringify(stored));
2689      Cart.animateAdd(item.image);
2690      Cart.refresh();
2691      Utils.closeDialog(Dom.productDialog);
2692      Utils.toast(item.itemName + ' added to your cart.');
2693    },
2694
2695    open: function (item, preserveState) {
2696      if (Menu.hasFlag(item, 'promo')) return InfoDialog.open(item);
2697      DealDialog.selectedItem = item;
2698      // This body is shared with normal items; discard their bubbling change handler.
2699      Dom.productDialogBody.onchange = null;
2700      DealDialog.steps = [];
2701      Dom.productDialog.classList.remove('has-simple-product');
2702      if (!preserveState) {
2703        DealDialog.selectedIds = {};
2704        DealDialog.customizations = {};
2705      }
2706      ModifierLayout.reset();
2707      Dom.productDialogBody.replaceChildren();
2708
2709      var view = document.createElement('article');
2710      view.className = 'bo-product-view bo-deal-configurator';
2711      var title = document.createElement('h2');
2712      title.id = 'boProductDialogTitle';
2713      title.textContent = item.itemName || 'Meal deal';
2714      view.appendChild(title);
2715
2716      var tokens = Utils.values(item.offers).filter(Boolean);
2717      var sizes = Utils.values(item.offerSizes);
2718      var list = document.createElement('div');
2719      list.className = 'bo-deal-steps';
2720
2721      tokens.forEach(function (token, index) {
2722        var candidates = DealDialog.candidates(token);
2723        var fixed = /^\d+$/.test(String(token).trim()) && candidates.length === 1;
2724        var step = { index: index, token: token, size: sizes[index] || '', candidates: candidates, fixed: fixed };
2725        DealDialog.steps.push(step);
2726
2727        var section = document.createElement('section');
2728        section.className = 'bo-deal-step';
2729        var content = document.createElement('div');
2730        content.className = 'bo-deal-step__content';
2731        var label = document.createElement('strong');
2732        if (fixed) {
2733          content.classList.add('bo-deal-step__content--included');
2734          label.textContent = 'Included';
2735          content.appendChild(label);
2736        } else {
2737          content.classList.add('bo-deal-step__content--unlabelled');
2738        }
2739
2740        if (fixed) {
2741          var fixedName = document.createElement('span');
2742          fixedName.textContent = [candidates[0].itemName, step.size].filter(Boolean).join(' - ');
2743          content.appendChild(fixedName);
2744        } else {
2745          var select = document.createElement('select');
2746          select.dataset.dealStep = String(index);
2747          select.dataset.requiredLabel = DealDialog.stepLabel(token, index);
2748          select.setAttribute('aria-label', DealDialog.stepLabel(token, index));
2749          var placeholder = document.createElement('option');
2750          placeholder.value = '';
2751          placeholder.textContent = candidates.length ? 'Please choose…' : 'No matching items';
2752          select.appendChild(placeholder);
2753          candidates.forEach(function (candidate) {
2754            var option = document.createElement('option');
2755            option.value = String(candidate.id);
2756            var optionText = [candidate.itemName, step.size].filter(Boolean).join(' - ');
2757            var optionPrice = Number(candidate.optionListPrice) || 0;
2758            option.textContent = optionText + (optionPrice ? ' (+£' + optionPrice.toFixed(2) + ')' : '');
2759            select.appendChild(option);
2760          });
2761          select.value = DealDialog.selectedIds[String(index)] || '';
2762          select.addEventListener('change', function () {
2763            DealDialog.selectedIds[String(index)] = select.value;
2764            DealDialog.updateCustomizeButton(index);
2765            DealDialog.updateTotal();
2766          });
2767          select.disabled = !candidates.length;
2768          content.appendChild(select);
2769        }
2770        var customize = document.createElement('button');
2771        customize.type = 'button';
2772        customize.className = 'bo-deal-customize';
2773        customize.textContent = DealDialog.customizations[String(index)] ? '✓ Customized' : 'Customize';
2774        customize.addEventListener('click', function () { DealDialog.customizeStep(index); });
2775        customize.dataset.dealCustomize = String(index);
2776        content.appendChild(customize);
2777        section.appendChild(content);
2778        list.appendChild(section);
2779      });
2780      view.appendChild(list);
2781
2782      var footer = document.createElement('div');
2783      footer.className = 'bo-product-actions bo-product-actions--fixed-quantity';
2784      var addButton = document.createElement('button');
2785      addButton.type = 'button';
2786      addButton.className = 'bo-primary-button bo-add-product';
2787      addButton.innerHTML = '<span>Add to cart</span><strong id="boProductTotal"></strong>';
2788      addButton.addEventListener('click', DealDialog.addToCart);
2789      footer.appendChild(addButton);
2790      view.appendChild(footer);
2791      Dom.productDialogBody.appendChild(view);
2792
2793      Dom.productDialogBody.querySelectorAll('[data-quantity-change]').forEach(function (button) {
2794        button.addEventListener('click', function () {
2795          var input = Dom.productDialogBody.querySelector('#boProductQuantity');
2796          input.value = String(Math.max(1, Number(input.value) + Number(button.dataset.quantityChange)));
2797          DealDialog.updateTotal();
2798        });
2799      });
2800      DealDialog.updateTotal();
2801      DealDialog.steps.forEach(function (step) {
2802        DealDialog.updateCustomizeButton(step.index);
2803      });
2804      Dom.productDialog.classList.toggle('is-compact', tokens.length <= 1);
2805      Utils.showDialog(Dom.productDialog);
2806      Utils.resetProductScroll();
2807    },
2808
2809    updateCustomizeButton: function (stepIndex) {
2810      var step = DealDialog.steps[stepIndex];
2811      var button = Dom.productDialogBody.querySelector('[data-deal-customize="' + stepIndex + '"]');
2812      if (!step || !button) return;
2813      var selectedId = step.fixed ? String((step.candidates[0] || {}).id || '') : DealDialog.selectedIds[String(stepIndex)];
2814      var selected = step.candidates.find(function (candidate) {
2815        return String(candidate.id) === String(selectedId);
2816      });
2817      var cannotCustomize = Boolean(selected && selected.isVirtualOption);
2818      button.parentElement.classList.toggle('bo-deal-step__content--no-customize', cannotCustomize);
2819      button.hidden = cannotCustomize;
2820      button.disabled = cannotCustomize;
2821    }
2822  };
2823
2824  // Measure once on opening and again only when the available width changes.
2825  // Move original controls: their values, listeners and pricing data stay intact.
2826  var ModifierLayout = {
2827    cleanup: null,
2828    reset: function () {
2829      if (ModifierLayout.cleanup) ModifierLayout.cleanup();
2830      ModifierLayout.cleanup = null;
2831    },
2832    attachPizzaMeal: function (view) {
2833      var sections = Array.from(view.children).filter(function (node) {
2834        return !node.hidden && (node.classList.contains('bo-included-section') || node.classList.contains('bo-custom-section'));
2835      });
2836      if (!sections.length) return;
2837      var frame = 0, disposed = false, lastWidth = 0;
2838      function apply(wide) {
2839        sections.forEach(function (section, index) {
2840          section.style.setProperty('--bo-pizza-span', sections.length === 1 ? '6' : wide < 0 ? '3' : index === wide ? '4' : '2');
2841          section.classList.toggle('bo-pizza-options-wide', sections.length === 1 || index === wide);
2842        });
2843      }
2844      function balance() {
2845        frame = 0;
2846        if (disposed || !view.isConnected || !view.getBoundingClientRect().width) return;
2847        apply(-1);
2848        if (sections.length !== 2) return;
2849        var best = -1;
2850        var baseline = view.getBoundingClientRect().height;
2851        var bestHeight = baseline;
2852        // Compare actual wrapped labels, not counts: either section may be longer.
2853        [0, 1].forEach(function (index) {
2854          apply(index);
2855          var height = view.getBoundingClientRect().height;
2856          if (height < bestHeight - 1 && height <= baseline - 20) {
2857            bestHeight = height;
2858            best = index;
2859          }
2860        });
2861        apply(best);
2862      }
2863      function schedule() {
2864        if (!disposed && !frame) frame = window.requestAnimationFrame(balance);
2865      }
2866      var observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(function () {
2867        var width = view.getBoundingClientRect().width;
2868        if (Math.abs(width - lastWidth) > 1) { lastWidth = width; schedule(); }
2869      }) : null;
2870      if (observer) observer.observe(view);
2871      window.addEventListener('resize', schedule);
2872      if (document.fonts && document.fonts.ready) document.fonts.ready.then(schedule);
2873      ModifierLayout.cleanup = function () {
2874        disposed = true;
2875        if (observer) observer.disconnect();
2876        if (frame) window.cancelAnimationFrame(frame);
2877        window.removeEventListener('resize', schedule);
2878      };
2879      schedule();
2880    },
2881    attach: function (view) {
2882      ModifierLayout.reset();
2883      if (view.classList.contains('bo-pizza-meal-layout')) {
2884        ModifierLayout.attachPizzaMeal(view);
2885        return;
2886      }
2887      var stack = view.querySelector('.bo-config-side-stack');
2888      var extras = stack && stack.querySelector('.bo-extra-section');
2889      var salads = view.querySelector('.bo-salad-section');
2890      var sauces = view.querySelector('.bo-sauce-section');
2891      // Expanding meal controls keep their established layout.
2892      if (!extras || !salads || !sauces || stack.querySelector('.bo-make-meal-section')) return;
2893      var grid = extras.querySelector('.bo-choice-grid');
2894      var originalRows = Array.from(grid.children);
2895      // Fill the side column with checkboxes before placing dropdowns below.
2896      // Move the existing controls so selections, listeners and prices survive.
2897      var rows = originalRows.filter(function (row) { return !row.classList.contains('bo-dynamic-select'); })
2898        .concat(originalRows.filter(function (row) { return row.classList.contains('bo-dynamic-select'); }));
2899      if (rows.length < 2) return;
2900      var continued = null;
2901      var frame = 0;
2902      var disposed = false;
2903      var lastWidth = 0;
2904      function restore() {
2905        extras.hidden = false;
2906        rows.forEach(function (row) { grid.appendChild(row); });
2907        if (continued) continued.remove();
2908        continued = null;
2909      }
2910      function balance() {
2911        frame = 0;
2912        if (disposed || !view.isConnected || !view.getBoundingClientRect().width) return;
2913        // Never relocate a control while the customer is using it.
2914        if (grid.contains(document.activeElement) || (continued && continued.contains(document.activeElement))) return;
2915        restore();
2916        var originalHeight = view.getBoundingClientRect().height;
2917        var limit = Math.max(salads.getBoundingClientRect().bottom, sauces.getBoundingClientRect().bottom);
2918        if (stack.getBoundingClientRect().bottom - limit < 80) return;
2919        var keep = 0;
2920        rows.forEach(function (row, index) {
2921          if (row.getBoundingClientRect().bottom <= limit + 2) keep = index + 1;
2922        });
2923        // Keep related dropdowns together when extras continue below the columns.
2924        var firstSelect = rows.findIndex(function (row) { return row.classList.contains('bo-dynamic-select'); });
2925        if (firstSelect >= 0 && rows.slice(keep).some(function (row) { return row.classList.contains('bo-dynamic-select'); })) {
2926          keep = Math.min(keep, firstSelect);
2927        }
2928        if (rows.length - keep < 2) return;
2929        var section = ProductDialog.section(keep > 0 ? 'More extras' : 'Extras', 'bo-extra-section bo-extras-continued');
2930        section.heading.hidden = false;
2931        section.section.setAttribute('aria-label', 'Extras');
2932        extras.hidden = keep === 0;
2933        continued = section.section;
2934        var selectGroup = document.createElement('div');
2935        selectGroup.className = 'bo-extra-select-group';
2936        rows.slice(keep).forEach(function (row) {
2937          if (row.classList.contains('bo-dynamic-select')) selectGroup.appendChild(row);
2938          else section.grid.appendChild(row);
2939        });
2940        if (selectGroup.children.length) section.grid.appendChild(selectGroup);
2941        view.insertBefore(continued, view.querySelector('.bo-product-actions'));
2942        // Wrapped labels and dropdowns can make splitting counterproductive.
2943        if (view.getBoundingClientRect().height > originalHeight - 24) restore();
2944      }
2945      function schedule() {
2946        if (!disposed && !frame) frame = window.requestAnimationFrame(balance);
2947      }
2948      var observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(function () {
2949        var width = view.getBoundingClientRect().width;
2950        if (Math.abs(width - lastWidth) > 1) {
2951          lastWidth = width;
2952          schedule();
2953        }
2954      }) : null;
2955      if (observer) observer.observe(view);
2956      window.addEventListener('resize', schedule);
2957      if (document.fonts && document.fonts.ready) document.fonts.ready.then(schedule);
2958      ModifierLayout.cleanup = function () {
2959        disposed = true;
2960        if (observer) observer.disconnect();
2961        if (frame) window.cancelAnimationFrame(frame);
2962        window.removeEventListener('resize', schedule);
2963      };
2964      schedule();
2965    }
2966  };
2967
2968  var ProductDialog = {
2969    selectedItem: null,
2970    context: null,
2971
2972    hasModifiers: function (item) {
2973      return ['salads', 'sauces', 'extraCheese', 'chipsCheese', 'burgerCheese', 'saltvinegar', 'noCheese', 'etoppings1', 'etoppings2', 'etoppings3', 'etoppings5', 'makeMeal'].some(function (name) {
2974        var value = item && item[name];
2975        var normalized = String(value == null ? '' : value).toLowerCase();
2976        return normalized !== '' && normalized !== 'no' && normalized !== '0' && normalized !== 'false';
2977      });
2978    },
2979
2980    mealOptionExtra: function (value) {
2981      var match = String(value || '').match(/\+\s*£?\s*(\d+(?:\.\d+)?)/i);
2982      return match ? Number(match[1]) || 0 : 0;
2983    },
2984
2985    nextCartKey: function () {
2986      var key = 1;
2987      while (window.localStorage.getItem(String(key)) !== null) key += 1;
2988      return String(key);
2989    },
2990
2991    option: function (type, group, value, label, checked, price, index) {
2992      var option = document.createElement('label');
2993      option.className = 'bo-choice';
2994
2995      var input = document.createElement('input');
2996      input.type = type;
2997      input.name = group;
2998      input.value = value;
2999      input.checked = Boolean(checked);
3000      input.dataset.price = String(Number(price) || 0);
3001      input.dataset.index = String(index == null ? '' : index);
3002
3003      var mark = document.createElement('span');
3004      mark.className = 'bo-choice__mark';
3005      var text = document.createElement('span');
3006      text.className = 'bo-choice__text';
3007      text.textContent = label;
3008
3009      option.append(input, mark, text);
3010      option.addEventListener('click', function (event) {
3011        if (input.dataset.sizeBlocked !== 'true') return;
3012        event.preventDefault();
3013        event.stopPropagation();
3014        event.stopImmediatePropagation();
3015        var size = ProductDialog.activeSizeToken();
3016        Utils.toast(String(value || label || 'This option') + ' is not available for ' + (size ? size + '″' : 'the selected size') + '.', 'error');
3017      });
3018      return option;
3019    },
3020
3021    section: function (title, className) {
3022      var section = document.createElement('section');
3023      section.className = 'bo-config-section ' + (className || '');
3024      var heading = document.createElement('h3');
3025      heading.textContent = title;
3026      var grid = document.createElement('div');
3027      grid.className = 'bo-choice-grid';
3028      section.append(heading, grid);
3029      return { section: section, heading: heading, grid: grid };
3030    },
3031
3032    hideCalzoneModifier: function (item, label) {
3033      var category = String((item || {}).menuHeader || '').trim().toLowerCase();
3034      var option = String(label || '').replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
3035      if (category === 'italian pizzas' && (option === 'cheesy stuffed crust' || option === 'sausage stuffed crust')) return true;
3036      var name = String((item || {}).itemName || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
3037      if (!/\bcalzon[a-z]*\b/.test(name) || /\bcalzon[a-z]*\s+pizzas?\b/.test(name)) return false;
3038      return /\b(?:crusts?|bases?)\b|\bchees(?:y|e)\s*bites?\b/i.test(String(label || '').replace(/[-_]+/g, ' '));
3039    },
3040
3041    configureRequiredSelect: function (select, heading) {
3042      var title = String(heading || '').trim();
3043      var required = /\*$/.test(title);
3044      var label = title.replace(/\*$/, '').trim();
3045      select.dataset.required = String(required);
3046      select.dataset.requiredLabel = label || 'an option';
3047      select.setAttribute('aria-required', String(required));
3048      select.setAttribute('aria-label', label || 'Choose an option');
3049      select.options[0].textContent = required ? 'Please choose…' : 'No change';
3050      return label;
3051    },
3052
3053    validateRequiredExtras: function () {
3054      var missing = Array.from(Dom.productDialogBody.querySelectorAll('select[data-required="true"]')).find(function (select) {
3055        var option = select.options[select.selectedIndex];
3056        return !select.value || !option || option.disabled;
3057      });
3058      if (!missing) return true;
3059      Utils.requireChoice(missing, Utils.requiredPrompt(missing));
3060      return false;
3061    },
3062
3063    dynamicModifiers: function (definitions, target, item) {
3064      var count = 0;
3065      definitions.forEach(function (definition, index) {
3066        var kind = String(definition.type || '').toLowerCase();
3067        if (kind === 'selecty') {
3068          var parts = String(definition.name || '').split('::');
3069          var heading = parts.length > 1 ? parts.shift().trim() : '';
3070          if (ProductDialog.hideCalzoneModifier(item, heading)) return;
3071          var optionNames = parts.join('::').split('|').map(function (value) { return value.trim(); }).filter(Boolean);
3072          var priceDefinitions = String(definition.price || '0').split(';');
3073          var selectWrap = document.createElement('label');
3074          selectWrap.className = 'bo-dynamic-select';
3075          var selectHeading = document.createElement('span');
3076          selectHeading.textContent = heading.replace(/\*$/, '').trim();
3077          var select = document.createElement('select');
3078          select.dataset.paidOption = 'true';
3079          select.dataset.customOption = 'true';
3080          select.innerHTML = '<option value="">No change</option>';
3081          ProductDialog.configureRequiredSelect(select, heading);
3082          optionNames.forEach(function (name, optionIndex) {
3083            if (ProductDialog.hideCalzoneModifier(item, name)) return;
3084            var option = document.createElement('option');
3085            var rule = ProductDialog.modifierRule(priceDefinitions.length === 1 ? priceDefinitions[0] : (priceDefinitions[optionIndex] || '0'));
3086            option.value = name;
3087            option.textContent = name;
3088            option.dataset.prices = rule.prices;
3089            option.dataset.unavailableSizes = rule.unavailable.join(',');
3090            select.appendChild(option);
3091          });
3092          if (select.options.length < 2) return;
3093          selectWrap.append(selectHeading, select);
3094          target.appendChild(selectWrap);
3095          count += 1;
3096          return;
3097        }
3098
3099        if (kind !== 'entryx' && kind !== 'entryy') return;
3100        if (ProductDialog.hideCalzoneModifier(item, definition.name)) return;
3101
3102        var group = String(definition.class || 'dynamic');
3103        var choice = ProductDialog.option(
3104          'radio',
3105          'bo-dynamic-' + group,
3106          definition.name || 'Option',
3107          definition.name || 'Option',
3108          false,
3109          0,
3110          index
3111        );
3112        var input = choice.querySelector('input');
3113        var rule = ProductDialog.modifierRule(definition.price || '0');
3114        input.dataset.paidOption = 'true';
3115        input.dataset.customOption = 'true';
3116        input.dataset.prices = rule.prices;
3117        input.dataset.unavailableSizes = rule.unavailable.join(',');
3118        input.dataset.dynamicType = kind;
3119        target.appendChild(choice);
3120        count += 1;
3121      });
3122      return count;
3123    },
3124
3125    modifierRule: function (value) {
3126      var raw = String(value == null ? '0' : value).trim();
3127      var unavailable = [];
3128      if (raw.toLowerCase().indexOf('notavailable:') === 0) {
3129        var parts = raw.split('|');
3130        unavailable = parts.shift().replace(/^notavailable:/i, '').split(',').map(function (size) {
3131          return String(size).replace(/["']/g, '').trim();
3132        }
3132).filter(Boolean);
3133        raw = parts.join('|').trim() || '0';
3134      }
3135      return { prices: raw, unavailable: unavailable };
3136    },
3137
3138    activeSizeToken: function () {
3139      var checked = Dom.productDialogBody.querySelector('input[name="bo-size"]:checked');
3140      var match = String(checked ? checked.value : '').match(/\d+(?:\.\d+)?/);
3141      return match ? match[0] : '';
3142    },
3143
3144    blockedForSize: function (source, size) {
3145      if (!source || !size) return false;
3146      return String(source.dataset.unavailableSizes || '').split(',').map(function (value) {
3147        return value.trim();
3148      }).filter(Boolean).indexOf(String(size)) !== -1;
3149    },
3150
3151    refreshAvailability: function (notify) {
3152      var size = ProductDialog.activeSizeToken();
3153      var removed = [];
3154
3155      Dom.productDialogBody.querySelectorAll('input[data-unavailable-sizes]').forEach(function (input) {
3156        var blocked = ProductDialog.blockedForSize(input, size);
3157        input.dataset.sizeBlocked = blocked ? 'true' : 'false';
3158        input.setAttribute('aria-disabled', blocked ? 'true' : 'false');
3159        var label = input.closest('.bo-choice');
3160        if (label) label.classList.toggle('is-disabled', blocked);
3161        if (blocked && input.checked) {
3162          input.checked = false;
3163          removed.push(input.value);
3164        }
3165      });
3166
3167      Dom.productDialogBody.querySelectorAll('select[data-paid-option]').forEach(function (select) {
3168        Array.from(select.options).forEach(function (option) {
3169          option.disabled = ProductDialog.blockedForSize(option, size);
3170        });
3171        var selected = select.options[select.selectedIndex];
3172        if (selected && selected.value && selected.disabled) {
3173          removed.push(selected.value);
3174          select.selectedIndex = 0;
3175        }
3176      });
3177
3178      if (notify && removed.length) {
3179        Utils.toast(removed.join(', ') + ' is not available on ' + size + '″.', 'error');
3180      }
3181      return removed;
3182    },
3183
3184    activeSizeIndex: function () {
3185      var checked = Dom.productDialogBody.querySelector('input[name="bo-size"]:checked');
3186      return checked ? Number(checked.dataset.index) || 0 : 0;
3187    },
3188
3189    comboSelection: function (select) {
3190      var option = select.options[select.selectedIndex];
3191      if (!option || !option.value) return null;
3192      // The selected dropdown option is authoritative; no menu-item lookup.
3193      return {
3194        itemName: option.textContent,
3195        optionListPrice: Number(option.dataset.optionListPrice) || 0
3196      };
3197    },
3198
3199    calculate: function () {
3200      var item = ProductDialog.selectedItem;
3201      if (!item) return { total: 0, unit: 0, quantity: 1 };
3202      var quantityInput = Dom.productDialogBody.querySelector('#boProductQuantity');
3203      var quantity = Math.max(1, Number(quantityInput && quantityInput.value) || 1);
3204      var size = ProductDialog.activeSizeIndex();
3205      var prices = Utils.values(item.prices).map(Number);
3206      var unit = Number(prices[size]) || Number(prices[0]) || 0;
3207      var baseUnit = unit;
3208
3209      Dom.productDialogBody.querySelectorAll('[data-combo-step]').forEach(function (select) {
3210        var selection = ProductDialog.comboSelection(select);
3211        if (selection) unit += Number(selection.optionListPrice) || 0;
3212      });
3213
3214      Dom.productDialogBody.querySelectorAll('[data-paid-option]').forEach(function (input) {
3215        var isSelect = input.tagName === 'SELECT';
3216        if ((!isSelect && !input.checked) || (isSelect && !input.value)) return;
3217        var selectedOption = isSelect ? input.options[input.selectedIndex] : null;
3218        var rawPrices = selectedOption ? selectedOption.dataset.prices : (input.dataset.prices || input.dataset.price);
3219        var optionPrices = Utils.values(rawPrices).map(Number);
3220        unit += Number(optionPrices[size] == null ? optionPrices[0] : optionPrices[size]) || 0;
3221      });
3222
3223      Dom.productDialogBody.querySelectorAll('[data-flat-price]:checked').forEach(function (input) {
3224        unit += Number(input.dataset.flatPrice) || 0;
3225      });
3226
3227      var extraCount = Dom.productDialogBody.querySelectorAll('input[name="bo-extra-topping"]:checked').length;
3228      var removedCount = Dom.productDialogBody.querySelectorAll('input[name="bo-included-topping"]:not(:checked)').length;
3229      var chargeable = String((Config.branchInfo || {}).swaptop) === '1' ? extraCount : Math.max(0, extraCount - removedCount);
3230      if (String(item.type || '').indexOf('freechoice') !== -1) {
3231        var free = Number(String(item.type || '').slice(-1)) || 4;
3232        chargeable = Math.max(0, extraCount - free);
3233      }
3234      var toppingPrices = Utils.values((Config.branchInfo || {}).toppingPrices).map(Number);
3235      var toppingExtra = (Number(toppingPrices[size]) || Number(toppingPrices[0]) || 0) * chargeable;
3236      unit += toppingExtra;
3237
3238      var makeMeal = Dom.productDialogBody.querySelector('input[name="bo-make-meal"]');
3239      if (makeMeal && makeMeal.checked) {
3240        unit += Number((Config.branchInfo || {}).makeMeal) || 0;
3241        Dom.productDialogBody.querySelectorAll('[data-meal-option]').forEach(function (select) {
3242          unit += ProductDialog.mealOptionExtra(select.value);
3243        });
3244      }
3245      return { total: unit * quantity, unit: unit, baseUnit: baseUnit, quantity: quantity, toppingExtra: toppingExtra };
3246    },
3247
3248    updateTotal: function () {
3249      var result = ProductDialog.calculate();
3250      var total = Dom.productDialogBody.querySelector('#boProductTotal');
3251      if (total) {
3252        var includedInMeal = Boolean(ProductDialog.context && ProductDialog.context.lockPortion);
3253        var extra = Math.max(0, result.total - result.baseUnit * result.quantity);
3254        total.textContent = includedInMeal ? (extra > 0 ? '+£' + extra.toFixed(2) : '') : '£' + result.total.toFixed(2);
3255        total.hidden = includedInMeal && extra === 0;
3256      }
3257    },
3258
3259    handleChange: function (event) {
3260      var target = event.target;
3261
3262      if (target && target.name === 'bo-size') {
3263        ProductDialog.refreshAvailability(true);
3264      }
3265
3266      if (target && target.name === 'bo-salad-all') {
3267        Dom.productDialogBody.querySelectorAll('input[name="bo-salad"]').forEach(function (input) {
3268          input.checked = target.checked;
3269        });
3270      }
3271
3272      if (target && target.name === 'bo-make-meal') {
3273        Dom.productDialogBody.querySelectorAll('.bo-meal-option').forEach(function (option) {
3274          option.hidden = !target.checked;
3275        });
3276      }
3277
3278      if (target && target.name === 'bo-sauce' && target.checked) {
3279        var limit = Number((Config.branchInfo || {}).maxSauces) || 2;
3280        var selectedSauces = Dom.productDialogBody.querySelectorAll('input[name="bo-sauce"]:checked');
3281        if (selectedSauces.length > limit) {
3282          target.checked = false;
3283          Utils.toast('Maximum ' + limit + ' sauces.', 'error');
3284        }
3285      }
3286
3287      ProductDialog.updateTotal();
3288    },
3289
3290    bindOptionalRadios: function () {
3291      Dom.productDialogBody.querySelectorAll('input[type="radio"]:not([name="bo-size"]):not([disabled])').forEach(function (input) {
3292        var target = input.closest('.bo-choice') || input;
3293        var rememberState = function () {
3294          input.dataset.wasChecked = input.checked ? 'true' : 'false';
3295        };
3296        target.addEventListener('pointerdown', rememberState, true);
3297        input.addEventListener('keydown', function (event) {
3298          if (event.key === ' ' || event.key === 'Enter') rememberState();
3299        });
3300        target.addEventListener('click', function (event) {
3301          if (input.dataset.sizeBlocked === 'true') return;
3302          if (input.dataset.wasChecked === 'true') {
3303            event.preventDefault();
3304            input.checked = false;
3305            input.dataset.wasChecked = 'false';
3306            ProductDialog.updateTotal();
3307            window.setTimeout(function () {
3308              input.checked = false;
3309              ProductDialog.updateTotal();
3310            }, 0);
3311          }
3312        });
3313      });
3314    },
3315
3316    addToCart: function () {
3317      var item = ProductDialog.selectedItem;
3318      if (Menu.hasFlag(item, 'promo')) return;
3319      if (!ProductDialog.validateRequiredExtras()) return;
3320      var result = ProductDialog.calculate();
3321      var sizeInput = Dom.productDialogBody.querySelector('input[name="bo-size"]:checked');
3322      if (!sizeInput) {
3323        Utils.toast('Please choose a size or portion.', 'error');
3324        return;
3325      }
3326
3327      var included = Array.from(Dom.productDialogBody.querySelectorAll('input[name="bo-included-topping"]'));
3328      var removed = included.filter(function (input) { return !input.checked; }).map(function (input) { return 'No ' + input.value; });
3329      var extras = Array.from(Dom.productDialogBody.querySelectorAll('input[name="bo-extra-topping"]:checked')).map(function (input) { return input.value; });
3330      var custom = Array.from(Dom.productDialogBody.querySelectorAll('[data-custom-option]')).filter(function (input) {
3331        return input.tagName === 'SELECT' ? Boolean(input.value) : Boolean(input.checked);
3332      }).map(function (input) {
3333        var optionPrice = 0;
3334        if (Object.prototype.hasOwnProperty.call(input.dataset, 'paidOption')) {
3335          var selectedOption = input.tagName === 'SELECT' ? input.options[input.selectedIndex] : null;
3336          var rawPrices = selectedOption ? selectedOption.dataset.prices : (input.dataset.prices || input.dataset.price);
3337          var optionPrices = Utils.values(rawPrices).map(Number);
3338          var sizeIndex = ProductDialog.activeSizeIndex();
3339          optionPrice += Number(optionPrices[sizeIndex] == null ? optionPrices[0] : optionPrices[sizeIndex]) || 0;
3340        }
3341        if (input.tagName !== 'SELECT' && input.checked) optionPrice += Number(input.dataset.flatPrice) || 0;
3342        return input.value + (optionPrice ? ' (£' + optionPrice.toFixed(2) + ')' : '');
3343      });
3344      var saladInputs = Array.from(Dom.productDialogBody.querySelectorAll('input[name="bo-salad"]'));
3345      var salads = saladInputs.filter(function (input) { return input.checked; }).map(function (input) { return input.value; });
3346      var omittedSalads = saladInputs.filter(function (input) { return !input.checked; }).map(function (input) { return input.value; });
3347      var saladText = '';
3348      if (salads.length) {
3349        if (!omittedSalads.length) {
3350          saladText = 'All Salads';
3351        } else if (omittedSalads.length === 1) {
3352          saladText = 'All Salads, ' + omittedSalads.map(function (name) { return 'No ' + name; }).join(', ');
3353        } else {
3354          saladText = salads.join(', ');
3355        }
3356      }
3357      var sauces = Array.from(Dom.productDialogBody.querySelectorAll('input[name="bo-sauce"]:checked, input[name="bo-dip"]:checked')).map(function (input) { return input.value; });
3358      var makeMealInput = Dom.productDialogBody.querySelector('input[name="bo-make-meal"]');
3359      var makeMealSelected = Boolean(makeMealInput && makeMealInput.checked);
3360      var mealSelections = makeMealSelected ? Array.from(Dom.productDialogBody.querySelectorAll('[data-meal-option]')).map(function (select) {
3361        return select.value;
3362      }
3362).filter(Boolean) : [];
3363      var comboSelects = Array.from(Dom.productDialogBody.querySelectorAll('[data-combo-step]'));
3364      var dealSelections = comboSelects.map(ProductDialog.comboSelection);
3365      var missingCombo = dealSelections.findIndex(function (selection) { return !selection; });
3366      if (missingCombo !== -1) {
3367        Utils.requireChoice(comboSelects[missingCombo], Utils.requiredPrompt(comboSelects[missingCombo], 'option ' + (missingCombo + 1)));
3368        return;
3369      }
3370      var extraLabel = extras.join(', ') + (result.toppingExtra > 0 ? ' (+£' + result.toppingExtra.toFixed(2) + ')' : ' (included)');
3371      var featureLines = removed.concat(extras.length ? ['Extra: ' + extraLabel] : []);
3372      if (saladText) featureLines.push('Salads: ' + saladText);
3373      if (sauces.length) featureLines.push('Sauces: ' + sauces.join(', '));
3374      dealSelections.forEach(function (selection, index) {
3375        var choicePrice = Number(selection.optionListPrice) || 0;
3376        featureLines.push(selection.itemName + (choicePrice ? ' (£' + choicePrice.toFixed(2) + ')' : ''));
3377      });
3378
3379      var additionLines = custom.slice();
3380      if (makeMealSelected) {
3381        additionLines.push('Make it a Meal (£' + (Number((Config.branchInfo || {}).makeMeal) || 0).toFixed(2) + ')');
3382        mealSelections.forEach(function (selection) { additionLines.push(selection); });
3383      }
3384
3385      var stored = Object.assign({}, item, {
3386        id: item.id,
3387        name: item.itemName,
3388        displayName: item.itemName,
3389        portion: sizeInput.value,
3390        amount: result.quantity,
3391        basePrice: (result.baseUnit * result.quantity).toFixed(2),
3392        fullPrice: result.total.toFixed(2),
3393        customization: {
3394          toppings: removed.join(', '),
3395          extraToppings: extras.join(', '),
3396          extraToppingsUnitCharge: result.toppingExtra,
3397          salads: saladText,
3398          sauces: sauces.join(', '),
3399          makeMeal: makeMealSelected ? 'yes' : 'no',
3400          mealSelections: mealSelections.join(', '),
3401          features: featureLines.map(function (line) { return '<p class="itemOptions">' + Utils.escapeHtml(line) + '</p>'; }).join(''),
3402          additions: additionLines.map(function (line) { return '<p class="itemOptions">' + Utils.escapeHtml(line) + '</p>'; }).join('')
3403        }
3404      });
3405
3406      if (ProductDialog.context && typeof ProductDialog.context.onSave === 'function') {
3407        ProductDialog.context.onSave(stored);
3408        return;
3409      }
3410
3411      window.localStorage.setItem(ProductDialog.nextCartKey(), JSON.stringify(stored));
3412      Cart.animateAdd(item.image);
3413      Cart.refresh();
3414      Utils.closeDialog(Dom.productDialog);
3415      Utils.toast(item.itemName + ' added to your cart.');
3416    },
3417
3418    open: function (item, context) {
3419      if (Menu.hasFlag(item, 'promo')) return InfoDialog.open(item);
3420      ProductDialog.selectedItem = item;
3421      ProductDialog.context = context || null;
3422      Dom.productDialog.classList.remove('has-simple-product');
3423      ModifierLayout.reset();
3424      Dom.productDialogBody.replaceChildren();
3425      var view = document.createElement('article');
3426      view.className = 'bo-product-view bo-product-configurator';
3427
3428      var title = document.createElement('h2');
3429      title.id = 'boProductDialogTitle';
3430      title.textContent = item.itemName || 'Item';
3431      view.appendChild(title);
3432
3433      // Portion values are backend identifiers: never trim or replace them.
3434      var rawPortions = Array.isArray(item.portions) ? item.portions.map(String) : String(item.portions == null ? '' : item.portions).split(',');
3435      if (!rawPortions.length) rawPortions = [''];
3436      var prices = Utils.values(item.prices).map(Number);
3437      var portions = rawPortions.map(function (portion, index) {
3438        return portion.trim() || (index === 0 ? 'Regular' : 'Option ' + (index + 1));
3439      });
3440      if (!portions.length) portions = ['Regular'];
3441      var lockPortion = Boolean(ProductDialog.context && ProductDialog.context.lockPortion);
3442      var fixedIndex = 0;
3443      if (ProductDialog.context && ProductDialog.context.fixedPortion) {
3444        var normalizePortion = function (value) {
3445          return String(value).toLowerCase().replace(/["'″]/g, '').replace(/\s*(?:inches|inch)\s*/g, '').trim();
3446        };
3447        fixedIndex = portions.findIndex(function (portion) {
3448          return normalizePortion(portion) === normalizePortion(ProductDialog.context.fixedPortion);
3449        });
3450        // Offer size labels are informational; a mismatch must not block customization.
3451        // Keep matching modifier prices where possible, otherwise use the first portion.
3452        if (fixedIndex === -1) fixedIndex = 0;
3453        lockPortion = true;
3454      }
3455      var isComboItem = String(item.type || '').toLowerCase() === 'combo';
3456      var isPizzaItem = item.pizza !== 'no' && !isComboItem;
3457      view.classList.toggle('bo-pizza-configurator', isPizzaItem);
3458      view.classList.toggle('bo-pizza-meal-layout', isPizzaItem && lockPortion);
3459      var hasMakeMeal = Utils.enabled(item.makeMeal) && !(ProductDialog.context && ProductDialog.context.hideMakeMeal);
3460      var implicitSinglePortion = !isPizzaItem && portions.length === 1;
3461      var simpleSingleItem = implicitSinglePortion && !ProductDialog.hasModifiers(item) && String(item.type || '').toLowerCase() !== 'combo';
3462      Dom.productDialog.classList.toggle('has-simple-product', simpleSingleItem && Boolean(item.image));
3463      var info = Config.branchInfo || {};
3464      var productSideStack = null;
3465      var sizeSection = ProductDialog.section(isPizzaItem ? 'Size' : 'Portion', 'bo-size-section');
3466      if (!isPizzaItem) sizeSection.section.classList.add('bo-config-section--single');
3467      portions.forEach(function (portion, index) {
3468        if (lockPortion && index !== fixedIndex) return;
3469        var label = portion + (Number.isFinite(prices[index]) ? ' · £' + prices[index].toFixed(2) : '');
3470        sizeSection.grid.appendChild(ProductDialog.option('radio', 'bo-size', rawPortions[index], label, lockPortion || index === 0, 0, index));
3471      });
3472      if (implicitSinglePortion || lockPortion) {
3473        sizeSection.section.hidden = true;
3474        if (simpleSingleItem && item.image) {
3475          var simpleImage = document.createElement('img');
3476          simpleImage.className = 'bo-simple-product-image';
3477          simpleImage.src = item.image;
3478          simpleImage.alt = '';
3479          view.appendChild(simpleImage);
3480        }
3481      }
3482      if (isPizzaItem) {
3483        var itemToppings = Utils.values(item.toppings).filter(Boolean);
3484        if (itemToppings.length) {
3485          var includedSection = ProductDialog.section('Toppings', 'bo-included-section');
3486          itemToppings.forEach(function (topping, index) {
3487            includedSection.grid.appendChild(ProductDialog.option('checkbox', 'bo-included-topping', topping, topping, true, 0, index));
3488          });
3489          view.appendChild(includedSection.section);
3490        }
3491
3492        var customOptions = ProductDialog.section('Customise', 'bo-custom-section');
3493        var customCount = 0;
3494        var stuffedGroups = String(info.stuffed || '').split('|');
3495        var traditionalCrusts = Utils.values(stuffedGroups[0]);
3496        var useTraditionalCustomisation = traditionalCrusts.length && String(traditionalCrusts[0]).toLowerCase() !== 'no';
3497
3498        if (useTraditionalCustomisation) {
3499          [['Stuffed Crust', stuffedGroups[0]], ['Hot Dog Crust', stuffedGroups[1]]].forEach(function (entry, index) {
3500            var optionPrices = Utils.values(entry[1]).map(Number);
3501            var hasCrustPrice = Utils.values(entry[1]).some(function (value) {
3502              return String(value).toLowerCase() !== 'no' && Number.isFinite(Number(value));
3503            });
3504            if (!hasCrustPrice || ProductDialog.hideCalzoneModifier(item, entry[0])) return;
3505            var choice = ProductDialog.option('checkbox', 'bo-custom-' + index, entry[0], entry[0], false, 0, index);
3506            choice.querySelector('input').dataset.paidOption = 'true';
3507            choice.querySelector('input').dataset.customOption = 'true';
3508            choice.querySelector('input').dataset.prices = optionPrices.join(',');
3509            customOptions.grid.appendChild(choice);
3510            customCount += 1;
3511          });
3512
3513          var baseTypes = Utils.values(info.boxt);
3514          Utils.values(info.bases).filter(Boolean).forEach(function (base, index) {
3515            if (ProductDialog.hideCalzoneModifier(item, base)) return;
3516            var type = String(baseTypes[index] || '').toLowerCase() === 'radio' ? 'radio' : 'checkbox';
3517            var baseChoice = ProductDialog.option(type, type === 'radio' ? 'bo-base' : 'bo-base-' + index, base, base, false, 0, index);
3518            baseChoice.querySelector('input').dataset.customOption = 'true';
3519            customOptions.grid.appendChild(baseChoice);
3520            customCount += 1;
3521          });
3522        } else {
3523          var dynamicDefinitions = Utils.branchJson(info.extraTypes, []);
3524          customCount += ProductDialog.dynamicModifiers(dynamicDefinitions, customOptions.grid, item);
3525        }
3526        if (customCount) view.appendChild(customOptions.section);
3527
3528        if (hasMakeMeal && !sizeSection.section.hidden) {
3529          productSideStack = document.createElement('div');
3530          productSideStack.className = 'bo-config-side-stack bo-pizza-size-stack';
3531          productSideStack.appendChild(sizeSection.section);
3532          view.appendChild(productSideStack);
3533        } else {
3534          view.appendChild(sizeSection.section);
3535        }
3536
3537        var allToppings = Utils.branchJson(info.toppings, Utils.values(info.toppings)).map(Utils.optionLabel).filter(Boolean);
3538        if (allToppings.length) {
3539          var extras = ProductDialog.section('Extra toppings', 'bo-extra-section');
3540          allToppings.forEach(function (topping, index) {
3541            extras.grid.appendChild(ProductDialog.option('checkbox', 'bo-extra-topping', topping, topping, false, 0, index));
3542          });
3543          view.appendChild(extras.section);
3544        }
3545      } else {
3546        var hasSalads = Utils.enabled(item.salads);
3547        var hasSauces = Utils.enabled(item.sauces);
3548        var saladOptionCount = 0;
3549        var sauceOptionCount = 0;
3550
3551        if (hasSalads) {
3552          sizeSection.section.classList.remove('bo-config-section--single');
3553          var saladSection = ProductDialog.section('Salads', 'bo-salad-section');
3554          var allSaladsChoice = ProductDialog.option('checkbox', 'bo-salad-all', 'All Salads', 'All Salads', false, 0, 0);
3555          allSaladsChoice.classList.add('bo-salad-master', 'bo-salad-heading-choice');
3556          saladSection.heading.replaceChildren(allSaladsChoice);
3557          Utils.values(info.salads).filter(Boolean).forEach(function (salad, index) {
3558            saladSection.grid.appendChild(ProductDialog.option('checkbox', 'bo-salad', salad, salad, false, 0, index));
3559          });
3560          Utils.additionalSaladGroups(info.seperateItems).forEach(function (group, groupIndex) {
3561            var block = document.createElement('div');
3562            block.className = 'bo-additional-salad-group';
3563            if (group.heading) {
3564              var heading = document.createElement('h3');
3565              heading.textContent = group.heading;
3566              block.appendChild(heading);
3567            }
3568            var choices = document.createElement('div');
3569            choices.className = 'bo-choice-grid';
3570            choices.setAttribute('role', group.type === 'radio' ? 'radiogroup' : 'group');
3571            choices.setAttribute('aria-label', group.heading || 'Additional salad options');
3572            group.options.forEach(function (option, optionIndex) {
3573              var label = option.name + (option.price ? ' (+£' + option.price.toFixed(2) + ')' : '');
3574              var choice = ProductDialog.option(group.type, 'bo-additional-salad-' + groupIndex,
3575                option.name, label, false, 0, optionIndex);
3576              var input = choice.querySelector('input');
3577              input.dataset.flatPrice = String(option.price);
3578              input.dataset.customOption = 'true';
3579              choices.appendChild(choice);
3580            });
3581            block.appendChild(choices);
3582            saladSection.section.appendChild(block);
3583          });
3584          saladOptionCount = saladSection.grid.children.length + 1;
3585          view.appendChild(saladSection.section);
3586        }
3587
3588        if (hasSauces) {
3589          sizeSection.section.classList.remove('bo-config-section--single');
3590          var sauceSection = ProductDialog.section('Sauces', 'bo-sauce-section');
3591          var itemNameKey = String(item.itemName || '').toLowerCase();
3592          var isDipItem = itemNameKey === 'pot of sauce' || itemNameKey === 'dips';
3593          var sauceValues = Utils.values(isDipItem ? info.dips : info.sauces).filter(Boolean);
3594          sauceValues.forEach(function (sauce, index) {
3595            sauceSection.grid.appendChild(ProductDialog.option(isDipItem ? 'radio' : 'checkbox', isDipItem ? 'bo-dip' : 'bo-sauce', sauce, sauce, false, 0, index));
3596          });
3597          sauceOptionCount = sauceSection.grid.children.length;
3598          view.appendChild(sauceSection.section);
3599        }
3600
3601        var otherSection = ProductDialog.section('Options', 'bo-other-section');
3602        var otherCount = 0;
3603        var additionalSection = ProductDialog.section('Extras', 'bo-extra-section');
3604        var additionalCount = 0;
3605        function addOther(label, price) {
3606          if (ProductDialog.hideCalzoneModifier(item, label)) return;
3607          var choice = ProductDialog.option('checkbox', 'bo-other-' + otherCount, label, label, false, 0, otherCount);
3608          var input = choice.querySelector('input');
3609          if (Number(price)) input.dataset.flatPrice = String(Number(price));
3610          input.dataset.customOption = 'true';
3611          otherSection.grid.appendChild(choice);
3612          otherCount += 1;
3613        }
3614        function addAdditional(label, price) {
3615          if (ProductDialog.hideCalzoneModifier(item, label)) return;
3616          var choice = ProductDialog.option('checkbox', 'bo-additional-' + additionalCount, label, label, false, 0, additionalCount);
3617          var input = choice.querySelector('input');
3618          if (Number(price)) input.dataset.flatPrice = String(Number(price));
3619          input.dataset.customOption = 'true';
3620          additionalSection.grid.appendChild(choice);
3621          additionalCount += 1;
3622        }
3623        if (String(item.extraCheese || '').toLowerCase() === 'yes') addOther('Extra Cheese', info.cheesePrice);
3624        if (String(item.chipsCheese || '').toLowerCase() === 'yes') addOther('Ex. Cheese (Chips)', info.cheesePrice);
3625        if (String(item.extraBurgerCheese || item.burgerCheese || '').toLowerCase() === 'yes') addOther('with Cheese', info.bcheesePrice);
3626        if (String(item.saltvinegar || '').toLowerCase() === 'yes') {
3627          addOther('Salt', 0);
3628          addOther('Vinegar', 0);
3629        }
3630        if (String(item.noCheese || '').toLowerCase() === 'yes') addOther('No Cheese', 0);
3631        [['etoppings1', 'etoppings1Prices'], ['etoppings2', null], ['etoppings3', 'etoppings3Prices']].forEac
3631h(function (definition) {
3632          if (String(item[definition[0]] || '').toLowerCase() !== 'yes') return;
3633          var names = Utils.values(info[definition[0]]).filter(Boolean);
3634          var optionPrices = definition[1] ? Utils.values(info[definition[1]]).map(Number) : [];
3635          names.forEach(function (name, index) { addAdditional(name, optionPrices[index] || 0); });
3636        });
3637        if (item.etoppings5 && String(item.etoppings5).toLowerCase() !== 'no') {
3638          var allowedExtraIds = Utils.values(item.etoppings5);
3639          var allExtras = Utils.branchJson(info.extraAll, []);
3640          allowedExtraIds.forEach(function (extraId) {
3641            var extra = allExtras.find(function (candidate) { return String(candidate.id) === String(extraId); });
3642            if (!extra) return;
3643            if (String(extra.type || '').toLowerCase() === 'selectbox') {
3644              var selectParts = String(extra.extra || '').split('::');
3645              var selectHeading = selectParts.length > 1 ? selectParts.shift().trim() : '';
3646              if (ProductDialog.hideCalzoneModifier(item, selectHeading)) return;
3647              var selectNames = selectParts.join('::').split('|').map(function (name) {
3648                return name.trim();
3649              }).filter(Boolean);
3650              var selectPrices = String(extra.price == null ? '0' : extra.price).split(';');
3651              var selectWrap = document.createElement('label');
3652              selectWrap.className = 'bo-dynamic-select';
3653              var heading = document.createElement('span');
3654              heading.textContent = selectHeading;
3655              var select = document.createElement('select');
3656              select.dataset.customOption = 'true';
3657              select.dataset.paidOption = 'true';
3658              select.dataset.cartPrice = 'true';
3659              select.innerHTML = '<option value="">No change</option>';
3660              heading.textContent = ProductDialog.configureRequiredSelect(select, selectHeading);
3661              selectNames.forEach(function (name, optionIndex) {
3662                if (ProductDialog.hideCalzoneModifier(item, name)) return;
3663                var option = document.createElement('option');
3664                var priceRule = ProductDialog.modifierRule(selectPrices.length === 1 ? selectPrices[0] : (selectPrices[optionIndex] || '0'));
3665                var optionPrice = Number(Utils.values(priceRule.prices)[0]) || 0;
3666                option.value = name;
3667                option.textContent = name + (optionPrice ? ' · £' + optionPrice.toFixed(2) : '');
3668                option.dataset.prices = priceRule.prices;
3669                option.dataset.unavailableSizes = priceRule.unavailable.join(',');
3670                select.appendChild(option);
3671              });
3672              if (select.options.length < 2) return;
3673              selectWrap.append(heading, select);
3674              additionalSection.grid.appendChild(selectWrap);
3675              additionalCount += 1;
3676            } else {
3677              addAdditional(extra.extra || 'Extra', extra.price);
3678            }
3679          });
3680        }
3681
3682        if (String(item.type || '').toLowerCase() === 'combo') {
3683          Utils.values(item.offers).filter(Boolean).forEach(function (token, index) {
3684            var candidates = DealDialog.candidates(token);
3685            var selectWrap = document.createElement('label');
3686            selectWrap.className = 'bo-dynamic-select bo-combo-select';
3687            var select = document.createElement('select');
3688            select.setAttribute('aria-label', 'Choose item');
3689            select.dataset.comboStep = String(index);
3690            select.dataset.requiredLabel = 'Choose item';
3691            select.innerHTML = '<option value="">Please choose…</option>';
3692            candidates.forEach(function (candidate) {
3693              var option = document.createElement('option');
3694              option.value = String(candidate.id);
3695              option.textContent = candidate.itemName;
3696              option.dataset.optionListPrice = String(Number(candidate.optionListPrice) || 0);
3697              select.appendChild(option);
3698            });
3699            selectWrap.appendChild(select);
3700            otherSection.grid.appendChild(selectWrap);
3701            otherCount += 1;
3702          });
3703        }
3704        if (otherCount) {
3705          var otherRows = Array.from(otherSection.grid.children);
3706          otherRows.reverse().forEach(function (row) { additionalSection.grid.insertBefore(row, additionalSection.grid.firstChild); });
3707          additionalCount += otherCount;
3708          otherCount = 0;
3709        }
3710        additionalSection.section.classList.toggle('bo-config-section--one-option', additionalCount === 1);
3711        otherSection.section.classList.toggle('bo-config-section--one-option', otherCount === 1);
3712        var singleModifierSideStack = hasSalads !== hasSauces && portions.length > 1;
3713        var useSideStack = (hasSalads && hasSauces || singleModifierSideStack) &&
3714          (!sizeSection.section.hidden || otherCount > 0 || additionalCount > 0 || hasMakeMeal);
3715
3716        if (useSideStack) {
3717          var sideStack = document.createElement('div');
3718          sideStack.className = 'bo-config-side-stack';
3719          if (singleModifierSideStack) view.classList.add('bo-single-modifier-stack');
3720          productSideStack = sideStack;
3721          sizeSection.section.classList.remove('bo-config-section--single');
3722          sideStack.appendChild(sizeSection.section);
3723          if (otherCount) sideStack.appendChild(otherSection.section);
3724          if (additionalCount) sideStack.appendChild(additionalSection.section);
3725          view.appendChild(sideStack);
3726        } else if (hasSalads !== hasSauces && implicitSinglePortion && (otherCount || additionalCount)) {
3727          view.classList.add('has-inline-modifiers');
3728          sizeSection.section.classList.remove('bo-config-section--single');
3729          view.appendChild(sizeSection.section);
3730          if (otherCount) view.appendChild(otherSection.section);
3731          if (additionalCount) {
3732            if (!otherCount) view.classList.add('has-inline-extras-wide');
3733            view.appendChild(additionalSection.section);
3734          }
3735        } else if (!hasSalads && !hasSauces && additionalCount) {
3736          var optionStack = document.createElement('div');
3737          optionStack.className = 'bo-config-option-stack';
3738          view.classList.add('has-option-stack');
3739          sizeSection.section.classList.remove('bo-config-section--single');
3740          if (otherCount) {
3741            optionStack.append(otherSection.section, additionalSection.section);
3742            view.append(sizeSection.section, optionStack);
3743          } else if (implicitSinglePortion) {
3744            optionStack.classList.add('bo-config-option-stack--extras-only');
3745            optionStack.appendChild(additionalSection.section);
3746            view.append(sizeSection.section, optionStack);
3747          } else {
3748            optionStack.append(sizeSection.section, additionalSection.section);
3749            view.appendChild(optionStack);
3750          }
3751        } else {
3752          if (otherCount || additionalCount) {
3753            sizeSection.section.classList.remove('bo-config-section--single');
3754          }
3755          if (otherCount) view.appendChild(otherSection.section);
3756          view.appendChild(sizeSection.section);
3757          if (additionalCount) view.appendChild(additionalSection.section);
3758        }
3759      }
3760
3761      if (hasMakeMeal) {
3762        var mealPrice = Number(info.makeMeal) || 0;
3763        var mealSection = ProductDialog.section('Make it a Meal', 'bo-make-meal-section');
3764        var mealToggle = ProductDialog.option(
3765          'checkbox',
3766          'bo-make-meal',
3767          'Make it a Meal',
3768          'Add Meal · £' + mealPrice.toFixed(2),
3769          false,
3770          0,
3771          0
3772        );
3773        mealSection.grid.appendChild(mealToggle);
3774
3775        function addMealSelect(headingText, values) {
3776          var choices = values.filter(Boolean);
3777          if (!choices.length) return;
3778          var wrap = document.createElement('label');
3779          wrap.className = 'bo-dynamic-select bo-meal-option';
3780          wrap.hidden = true;
3781          var heading = document.createElement('span');
3782          heading.textContent = headingText;
3783          var select = document.createElement('select');
3784          select.dataset.mealOption = 'true';
3785          choices.forEach(function (choice) {
3786            var option = document.createElement('option');
3787            option.value = choice;
3788            option.textContent = choice;
3789            select.appendChild(option);
3790          });
3791          wrap.append(heading, select);
3792          mealSection.grid.appendChild(wrap);
3793        }
3794
3795        addMealSelect('Choose Drink', Utils.values(info.cans));
3796        Utils.values(info.mmList).filter(Boolean).forEach(function (definition, index) {
3797          addMealSelect(index === 0 ? 'Choose Side' : 'Meal Choice ' + (index + 1), String(definition).split('-').map(function (choice) {
3798            return choice.trim();
3799          }));
3800        });
3801
3802        if (productSideStack) {
3803          productSideStack.appendChild(mealSection.section);
3804        } else {
3805          mealSection.section.classList.add('bo-config-section--single');
3806          view.appendChild(mealSection.section);
3807        }
3808      }
3809
3810      if (!isPizzaItem && portions.length > 1 && sizeSection.section.parentNode === view) {
3811        sizeSection.section.classList.add('bo-portion-wide');
3812        view.insertBefore(sizeSection.section, view.querySelector('h2').nextSibling);
3813      }
3814
3815      var layoutUnits = Array.from(view.children).filter(function (element) {
3816        return !element.hidden && (element.classList.contains('bo-config-section') ||
3817          element.classList.contains('bo-config-option-stack') ||
3818          element.classList.contains('bo-config-side-stack'));
3819      });
3820      var visualUnitCount = layoutUnits.reduce(function (count, element) {
3821        if (!element.classList.contains('bo-config-option-stack')) return count + 1;
3822        return count + Array.from(element.children).filter(function (child) {
3823          return !child.hidden && child.classList.contains('bo-config-section');
3824        }).length;
3825      }, 0);
3826      if (visualUnitCount === 1) {
3827        view.classList.add('bo-layout-units-1');
3828      } else if (visualUnitCount === 2) {
3829        view.classList.add('bo-layout-units-2');
3830      }
3831
3832      var footer = document.createElement('div');
3833      footer.className = 'bo-product-actions';
3834      var quantityLocked = Boolean(ProductDialog.context && ProductDialog.context.lockPortion) || Utils.quantityLocked(item);
3835      var quantity = document.createElement('div');
3836      quantity.className = 'bo-quantity';
3837      quantity.innerHTML = '<button type="button" data-quantity-change="-1" aria-label="Decrease quantity">−</button><input id="boProductQuantity" value="1" inputmode="numeric" readonly><button type="button" data-quantity-change="1" aria-label="Increase quantity">+</button>';
3838      var addButton = document.createElement('button');
3839      addButton.type = 'button';
3840      addButton.className = 'bo-primary-button bo-add-product';
3841      addButton.innerHTML = '<span>' + Utils.escapeHtml((ProductDialog.context || {}).actionLabel || 'Add to cart') + '</span><strong id="boProductTotal"></strong>';
3842      addButton.addEventListener('click', ProductDialog.addToCart);
3843      if (quantityLocked) {
3844        footer.classList.add('bo-product-actions--fixed-quantity');
3845        footer.appendChild(addButton);
3846      } else {
3847        footer.append(quantity, addButton);
3848      }
3849      view.appendChild(footer);
3850
3851      Dom.productDialogBody.appendChild(view);
3852      Dom.productDialogBody.onchange = ProductDialog.handleChange;
3853      ProductDialog.bindOptionalRadios();
3854      ProductDialog.refreshAvailability(false);
3855      Dom.productDialogBody.querySelectorAll('[data-quantity-change]').forEach(function (button) {
3856        button.addEventListener('click', function () {
3857          var input = Dom.productDialogBody.querySelector('#boProductQuantity');
3858          input.value = String(Math.max(1, Number(input.value) + Number(button.dataset.quantityChange)));
3859          ProductDialog.updateTotal();
3860        });
3861      });
3862      ProductDialog.updateTotal();
3863      Dom.productDialog.classList.toggle('is-compact', isComboItem || simpleSingleItem || (!isPizzaItem && !ProductDialog.hasModifiers(item)));
3864      Utils.showDialog(Dom.productDialog);
3865      Utils.resetProductScroll();
3866      ModifierLayout.attach(view);
3867    }
3868  };
3869
3870  var AppHome = {
3871    init: function () {
3872      var openButton = document.getElementById('boAppLogout');
3873      var confirmButton = document.getElementById('boAppLogoutConfirm');
3874      if (openButton) openButton.addEventListener('click', function () { Utils.showDialog(document.getElementById('boAppLogoutDialog')); });
3875      if (confirmButton) confirmButton.addEventListener('click', function () {
3876        confirmButton.disabled = true;
3877        Auth.request('/iosLogout', null, 'GET').then(function (response) {
3878          if (response.text === 'error') throw new Error('Logout could not be completed.');
3879          window.location.reload();
3880        }).catch(function (error) { Utils.toast(error.message, 'error'); confirmButton.disabled = false; });
3881      });
3882    }
3883  };
3884
3885  var Profile = {
3886    open: function (name) {
3887      var names = { details: 'boProfileDetailsDialog', address: 'boProfileAddressDialog', password: 'boProfilePasswordDialog', delete: 'boProfileDeleteDialog' };
3888      var dialog = document.getElementById(names[name]);
3889      if (dialog) Utils.showDialog(dialog);
3890    },
3891
3892    setBusy: function (form, busy) {
3893      if (!form) return;
3894      form.querySelectorAll('button,input').forEach(function (control) { control.disabled = Boolean(busy); });
3895      form.setAttribute('aria-busy', busy ? 'true' : 'false');
3896    },
3897
3898    saveDetails: function (event) {
3899      event.preventDefault();
3900      var form = event.currentTarget;
3901      var telephoneInput = document.getElementById('boProfileTelephone');
3902      var telephone = Auth.validTelephone(telephoneInput.value);
3903      if (!telephone) { Utils.toast('Please enter a valid telephone number.', 'error'); telephoneInput.focus(); return; }
3904      var data = {
3905        name: String(document.getElementById('boProfileName').value || '').trim(),
3906        email: String(document.getElementById('boProfileEmail').value || '').trim().toLowerCase(),
3907        telephone: telephone,
3908        emailPreferences: document.getElementById('boProfileEmailOrder').checked ? 1 : 0
3909      };
3910      if (data.name.length < 2 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) { Utils.toast('Please check your name and email.', 'error'); return; }
3911      Profile.setBusy(form, true);
3912      Auth.request('/profileUpdate', data).then(function (response) {
3913        if (response.text !== 'Successfully Updated') throw new Error('That email may already be registered to another account.');
3914        Utils.toast('Your account details were updated.');
3915        window.setTimeout(function () { window.location.reload(); }, 600);
3916      }).catch(function (error) { Utils.toast(error.message, 'error'); Profile.setBusy(form, false); });
3917    },
3918
3919    saveAddress: function (event) {
3920      event.preventDefault();
3921      var form = event.currentTarget;
3922      var data = { address: String(document.getElementById('boProfileAddress').value || '').trim(), postcode: String(document.getElementById('boProfilePostcode').value || '').trim() };
3923      if (data.address.length < 2) { Utils.toast('Please enter your address.', 'error'); return; }
3924      Profile.setBusy(form, true);
3925      Auth.validateRegistrationAddress(data).then(function (validated) {
3926        return Auth.request('/addressChange', { address: validated.address, postcode: validated.postcode });
3927      }).then(function (response) {
3928        if (response.text !== 'Successfully Updated') throw new Error('Your address could not be updated.');
3929        Utils.toast('Your delivery address was updated.');
3930        window.setTimeout(function () { window.location.reload(); }, 600);
3931      }).catch(function (error) { if (error.message !== 'edit-address') Utils.toast(error.message || 'Your address could not be updated.', 'error'); Profile.setBusy(form, false); });
3932    },
3933
3934    savePassword: function (event) {
3935      event.preventDefault();
3936      var form = event.currentTarget;
3937      var password = document.getElementById('boProfilePassword').value;
3938      var confirmation = document.getElementById('boProfilePasswordAgain').value;
3939      if (password.length < 5) { Utils.toast('Your password must contain at least 5 characters.', 'error'); return; }
3940      if (password !== confirmation) { Utils.toast("Passwords don't match.", 'error'); return; }
3941      Profile.setBusy(form, true);
3942      Auth.request('/passwordChange', { password: password, verifypassword: confirmation }).then(function (response) {
3943        if (response.text !== 'Successfully Updated') throw new Error('Your password could not be updated.');
3944        Utils.toast('Your password was updated.'); Utils.closeDialog(document.getElementById('boProfilePasswordDialog')); form.reset();
3945      }).catch(function (error) { Utils.toast(error.message, 'error'); }).finally(function () { Profile.setBusy(form, false); });
3946    },
3947
3948    repeatOrder: function (button) {
3949      if (button.disabled) return;
3950      button.disabled = true;
3951      Auth.request('/repeatOrder?id=' + encodeURIComponent(button.dataset.repeatOrder) + '&rtype=' + encodeURIComponent(button.dataset.repeatType), null, 'GET').then(function (response) {
3952        var items; try { items = JSON.parse(response.text); } catch (error) { items = null; }
3953        if (!Array.isArray(items)) throw new Error('This order could not be repeated.');
3954        var skipped = 0;
3955        items.forEach(function (item) {
3956          if (!item || Number(item.availability) === 0) { skipped += 1; return; }
3957          item.basePrice = Number(item.basePrice) || 0; item.fullPrice = Number(item.fullPrice) || 0;
3958          var key = 1; while (window.localStorage.getItem(String(key)) !== null) key += 1;
3959          window.localStorage.setItem(String(key), JSON.stringify(item));
3960        });
3961        Cart.refresh();
3962        Utils.toast(skipped ? 'Available items were added. Some items are no longer available.' : 'Order added to your basket.');
3963        Cart.open();
3964      }).catch(function (error) { Utils.toast(error.message, 'error'); }).finally(function () { button.disabled = false; });
3965    },
3966
3967    deleteAccount: function () {
3968      var button = document.getElementById('boProfileDeleteConfirm');
3969      if (button) button.disabled = true;
3970      Auth.request('/deleteAccount', null, 'GET').then(function (response) {
3971        if (response.text === 'neworder') throw new Error('You have a live order. Your account can be deleted after the shop completes it.');
3972        if (response.text === 'error') throw new Error('Your account could not be deleted.');
3973        Utils.toast('Your account has been deleted.');
3974        window.setTimeout(function () { window.location.href = '/'; }, 900);
3975      }).catch(function (error) { Utils.toast(error.message, 'error'); if (button) button.disabled = false; });
3976    },
3977
3978    init: function () {
3979      document.querySelectorAll('[data-profile-open]').forEach(function (button) { button.addEventListener('click', function () { Profile.open(button.dataset.profileOpen); }); });
3980      document.querySelectorAll('[data-repeat-order]').forEach(function (button) { button.addEventListener('click', function () { Profile.repeatOrder(button); }); });
3981      var details = document.getElementById('boProfileDetailsForm'); if (details) details.addEventListener('submit', Profile.saveDetails);
3982      var address = document.getElementById('boProfileAddressForm'); if (address) address.addEventListener('submit', Profile.saveAddress);
3983      var password = document.getElementById('boProfilePasswordForm'); if (password) password.addEventListener('submit', Profile.savePassword);
3984      var remove = document.getElementById('boProfileDeleteConfirm'); if (remove) remove.addEventListener('click', Profile.deleteAccount);
3985    }
3986  };
3987
3988  var Legal = {
3989    cache: {},
3990
3991    fragmentUrl: function (url) {
3992      return url + (url.indexOf('?') === -1 ? '?' : '&') + 'q=pop';
3993    },
3994
3995    cleanContent: function (html) {
3996      var parsed = new DOMParser().parseFromString(String(html || ''), 'text/html');
3997      parsed.querySelectorAll('script,style,link,meta,base,noscript').forEach(function (element) { element.remove(); });
3998      var legalCopy = parsed.querySelector('.bo-legal-copy');
3999      if (legalCopy) return legalCopy.innerHTML;
4000      return parsed.body ? parsed.body.innerHTML : String(html || '');
4001    },
4002
4003    open: function (link) {
4004      var dialog = document.getElementById('boLegalDialog');
4005      var title = document.getElementById('boLegalDialogTitle');
4006      var content = document.getElementById('boLegalDialogContent');
4007      var fullPage = document.getElementById('boLegalDialogFull');
4008      var url = link.getAttribute('href');
4009      if (!dialog || !title || !content || !fullPage || !url) {
4010        window.location.href = url || '/terms';
4011        return;
4012      }
4013
4014      title.textContent = link.dataset.legalTitle || link.textContent.trim() || 'Legal information';
4015      fullPage.href = url;
4016      dialog.dataset.legalUrl = url;
4017      content.innerHTML = Legal.cache[url] || '<p class="bo-legal-dialog__loading">Loading…</p>';
4018      Utils.showDialog(dialog);
4019
4020      if (Legal.cache[url]) return;
4021      fetch(Legal.fragmentUrl(url), { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
4022        .then(function (response) {
4023          if (!response.ok) throw new Error('Legal information could not be loaded.');
4024          return response.text();
4025        })
4026        .then(function (html) {
4027          var cleaned = Legal.cleanContent(html);
4028          if (!cleaned.trim()) throw new Error('Legal information could not be loaded.');
4029          Legal.cache[url] = cleaned;
4030          if (dialog.open && dialog.dataset.legalUrl === url) content.innerHTML = cleaned;
4031        })
4032        .catch(function () {
4033          content.innerHTML = '<p>This information could not be loaded here. Please use the full-page link below.</p>';
4034        });
4035    },
4036
4037    init: function () {
4038      document.querySelectorAll('[data-legal-modal]').forEach(function (link) {
4039        link.addEventListener('click', function (event) {
4040          if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
4041          event.preventDefault();
4042          Legal.open(link);
4043        });
4044      });
4045    }
4046  };
4047
4048  var App = {
4049    cacheDom: function () {
4050      Dom.categories = document.getElementById('boCategories');
4051      Dom.products = document.getElementById('boProducts');
4052      Dom.status = document.getElementById('boStatus');
4053      Dom.menuContent = document.getElementById('boMenuContent');
4054      Dom.cartButton = document.getElementById('boCartButton');
4055      Dom.cartSummary = document.getElementById('boCartSummary');
4056      Dom.cartDialog = document.getElementById('boCartDialog');
4057      Dom.cartDialogSummary = document.getElementById('boCartDialogSummary');
4058      Dom.cartList = document.getElementById('boCartList');
4059      Dom.productDialog = document.getElementById('boProductDialog');
4060      Dom.productDialogBody = document.getElementById('boProductDialogBody');
4061      Dom.infoDialog = document.getElementById('boInfoDialog');
4062      Dom.infoDialogTitle = document.getElementById('boInfoDialogTitle');
4063      Dom.infoDialogText = document.getElementById('boInfoDialogText');
4064      Dom.toast = document.getElementById('boToast');
4065      Dom.menuButton = document.getElementById('boMenuButton');
4066      Dom.menuCloseButton = document.getElementById('boMenuCloseButton');
4067      Dom.drawer = document.getElementById('boWebMenu');
4068      Dom.drawerBackdrop = document.getElementById('boDrawerBackdrop');
4069    },
4070
4071    bindEvents: function () {
4072      if (Dom.cartButton) Dom.cartButton.addEventListener('click', Cart.open);
4073      if (Dom.menuButton) Dom.menuButton.addEventListener('click', Navigation.openDrawer);
4074      if (Dom.menuCloseButton) Dom.menuCloseButton.addEventListener('click', Navigation.closeDrawer);
4075      if (Dom.drawerBackdrop) Dom.drawerBackdrop.addEventListener('click', Navigation.closeDrawer);
4076
4077      document.querySelectorAll('[data-dialog-close]').forEach(function (button) {
4078        button.addEventListener('click', function () {
4079          Utils.closeDialog(document.getElementById(button.dataset.dialogClose));
4080        });
4081      });
4082
4083      document.querySelectorAll('.bo-dialog').forEach(function (dialog) {
4084        dialog.addEventListener('click', function (event) {
4085          if (event.target === dialog) Utils.closeDialog(dialog);
4086        });
4087      });
4088
4089      window.addEventListener('storage', Cart.refresh);
4090      window.addEventListener('resize', FontProfile.apply);
4091    },
4092
4093    decorateBrandButtons: function () {
4094      var icons = {"google": "<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path fill=\"#4285F4\" d=\"M21.6 12.23c0-.71-.06-1.39-.18-2.05H12v3.88h5.38a4.6 4.6 0 0 1-2 3.02v2.51h3.24c1.9-1.75 2.98-4.33 2.98-7.36z\"/><path fill=\"#34A853\" d=\"M12 22c2.7 0 4.96-.9 6.62-2.41l-3.24-2.51c-.9.6-2.05.96-3.38.96-2.6 0-4.8-1.75-5.59-4.1H3.07v2.59A10 10 0 0 0 12 22z\"/><path fill=\"#FBBC05\" d=\"M6.41 13.94a6 6 0 0 1 0-3.88V7.47H3.07a10 10 0 0 0 0 9.06z\"/><path fill=\"#EA4335\" d=\"M12 5.96c1.47 0 2.79.51 3.83 1.51l2.87-2.87A9.62 9.62 0 0 0 12 2a10 10 0 0 0-8.93 5.47l3.34 2.59A5.99 5.99 0 0 1 12 5.96z\"/></svg>", "facebook": "<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path fill=\"currentColor\" d=\"M14 22v-9h3l.5-4H14V7c0-1.16.34-2 2-2h2V1.4A26 26 0 0 0 15 1c-3 0-5 1.82-5 5v3H7v4h3v9z\"/></svg>", "apple": "<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path fill=\"currentColor\" d=\"M17.05 12.54c.03 3.25 2.85 4.33 2.88 4.35-.02.08-.45 1.54-1.48 3.05-.9 1.3-1.83 2.6-3.3 2.63-1.44.03-1.91-.85-3.56-.85-1.65 0-2.17.82-3.54.88-1.42.05-2.5-1.41-3.4-2.7-1.85-2.66-3.26-7.52-1.36-10.81a5.27 5.27 0 0 1 4.46-2.7c1.39-.03 2.7.94 3.55.94.85 0 2.44-1.16 4.11-.99.7.03 2.66.28 3.92 2.12-.1.06-2.34 1.37-2.31 4.08zM14.34 4.55c.75-.91 1.25-2.17 1.11-3.43-1.08.04-2.39.72-3.17 1.63-.7.8-1.31 2.08-1.15 3.31 1.21.1 2.45-.61 3.21-1.51z\"/></svg>"};
4095      document.querySelectorAll('[data-auth-provider]').forEach(function (button) {
4096        var icon = icons[button.dataset.authProvider];
4097        if (!icon || button.querySelector('svg')) return;
4098        button.insertAdjacentHTML('afterbegin', icon);
4099      });
4100      var wallet = document.getElementById('boNativeWallet');
4101      if (wallet) {
4102        wallet.setAttribute('aria-label', 'Pay with Google Pay');
4103        var logo = document.createElement('img');
4104        logo.alt = '';
4105        logo.hidden = true;
4106        logo.onload = function () { wallet.textContent = ''; logo.hidden = false; wallet.appendChild(logo); };
4107        logo.src = 'https://www.gstatic.com/instantbuy/svg/dark_gpay.svg';
4108      }
4109    },
4110
4111    initNativeBridge: function () {
4112      if (Config.platform !== 'android') return;
4113      try {
4114        if (window.Android && typeof window.Android.returnVersion === 'function') {
4115          window.Android.returnVersion();
4116          document.body.dataset.nativeBridge = 'available';
4117        } else {
4118          document.body.dataset.nativeBridge = 'unavailable';
4119        }
4120      } catch (error) {
4121        document.body.dataset.nativeBridge = 'unavailable';
4122      }
4123    },
4124
4125    init: function () {
4126      App.cacheDom();
4127      DialogLayout.init();
4128      FontProfile.init();
4129      App.bindEvents();
4130      App.decorateBrandButtons();
4131      App.initNativeBridge();
4132      window.alertUser = function (message) { Utils.toast(message); };
4133      window.enableCart = Cart.open;
4134      Auth.init();
4135      Legal.init();
4136      if (document.body.dataset.page === 'checkout' && !Config.user && !document.getElementById('boContinueGuest')) {
4137        window.setTimeout(function () { Auth.open('login'); }, 0);
4138      }
4139      if (Dom.cartSummary && Dom.cartDialogSummary && Dom.cartList) Cart.refresh();
4140      if (document.body.dataset.page === 'menu') Menu.load();
4141      if (document.body.dataset.page === 'home') Home.init();
4142      if (document.body.dataset.page === 'checkout') Checkout.init();
4143      if (document.body.dataset.page === 'profile') Profile.init();
4144      if (document.body.dataset.page === 'apphome') AppHome.init();
4145    }
4146  };
4147
4148  document.addEventListener('DOMContentLoaded', App.init);
4149})();

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.