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 { '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[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.