1/** 2 * Cart Management - LocalStorage Based 3 * Generic for all restaurant sites 4 */ 5 6const Cart = { 7 /** 8 * Get storage key - separate cart for table ordering and per store in multi-store mode 9 */ 10 getKey() { 11 const storePrefix = window.STORE_SLUG ? `${window.STORE_SLUG}_` : ''; 12 return window.TABLE_MODE ? `${storePrefix}tableCart` : `${storePrefix}cart`; 13 }, 14 15 /** 16 * Get cart from localStorage 17 */ 18 get() { 19 const cart = localStorage.getItem(this.getKey()); 20 return cart ? JSON.parse(cart) : []; 21 }, 22 23 /** 24 * Save cart to localStorage 25 */ 26 set(cart) { 27 localStorage.setItem(this.getKey(), JSON.stringify(cart)); 28 this.updateUI(); 29 }, 30 31 /** 32 * Add item to cart 33 */ 34 add(productId, productName, basePrice, options = [], quantity = 1, cpn = 1) { 35 let cart = this.get(); 36 37 // Calculate total price with options 38 let totalPrice = parseFloat(basePrice); 39 options.forEach(opt => { 40 totalPrice += parseFloat(opt.option_price || 0); 41 if (opt.child_price) { 42 totalPrice += parseFloat(opt.child_price); 43 } 44 }); 45 46 // Check if exact item already exists (same product + same options) 47 const existingIndex = this.findExactMatch(cart, productId, options); 48 49 if (existingIndex !== -1) { 50 // Update quantity of existing item 51 cart[existingIndex].quantity += quantity; 52 } else { 53 // Add new item 54 cart.push({ 55 product_id: productId, 56 product_name: productName, 57 base_price: basePrice, 58 options: options, 59 quantity: quantity, 60 total_price: totalPrice, 61 cpn: cpn !== undefined && cpn !== null ? parseInt(cpn) : 1, 62 added_at: Date.now() 63 }); 64 } 65 66 this.set(cart); 67 this.showSuccessFeedback(); 68 this.scrollToBottom(); 69 //this.showNotification(`Added ${productName} to cart`); 70 return true; 71 }, 72 73 /** 74 * Add free item to cart 75 */ 76 addFreeItem(productId, productName, basePrice, options = [], offerId, linkedTo = null) { 77 let cart = this.get(); 78 79 // Calculate total price - base is free, options may be charged 80 let totalPrice = 0; 81 options.forEach(opt => { 82 totalPrice += parseFloat(opt.option_price || 0); 83 if (opt.child_price) { 84 totalPrice += parseFloat(opt.child_price); 85 } 86 }); 87 88 // Generate unique key for linking 89 const cartKey = `free_${offerId}_${Date.now()}`; 90 91 cart.push({ 92 cart_key: cartKey, 93 product_id: productId, 94 product_name: productName, 95 base_price: 0, // Base is always free 96 original_price: parseFloat(basePrice), // Store original for display 97 options: options, 98 quantity: 1, 99 total_price: totalPrice, 100 cpn: 0, // Doesn't count toward promotions 101 is_free: true, 102 offer_id: offerId, 103 linked_to: linkedTo, // For BOGOF: cart_key of paid item 104 added_at: Date.now() 105 }); 106 107 this.set(cart); 108 this.scrollToBottom(); 109 return cartKey; 110 }, 111 112 /** 113 * Add BOGOF item - adds paid item + free duplicate 114 */ 115 addBogof(productId, productName, basePrice, options = [], quantity = 1, cpn = 1, offerId) { 116 let cart = this.get(); 117 118 // Calculate total price with options 119 let totalPrice = parseFloat(basePrice); 120 options.forEach(opt => { 121 totalPrice += parseFloat(opt.option_price || 0); 122 if (opt.child_price) { 123 totalPrice += parseFloat(opt.child_price); 124 } 125 }); 126 127 // Check if exact paid item already exists 128 const existingPaidIndex = cart.findIndex(item => 129 !item.is_free && 130 item.product_id == productId && 131 item.offer_id === offerId && 132 JSON.stringify(item.options) === JSON.stringify(options) 133 ); 134 135 if (existingPaidIndex !== -1) { 136 // Increment paid item 137 cart[existingPaidIndex].quantity += quantity; 138 139 // Find and increment linked free item 140 const paidKey = cart[existingPaidIndex].cart_key; 141 const freeIndex = cart.findIndex(item => item.linked_to === paidKey); 142 if (freeIndex !== -1) { 143 cart[freeIndex].quantity += quantity; 144 } 145 146 this.set(cart); 147 } else { 148 // Create new pair 149 const paidKey = 'paid_' + Date.now(); 150 const freeKey = 'free_bogof_' + productId + '_' + Date.now(); 151 152 // Add paid item 153 cart.push({ 154 cart_key: paidKey, 155 product_id: productId, 156 product_name: productName, 157 base_price: basePrice, 158 options: options, 159 quantity: quantity, 160 total_price: totalPrice, 161 cpn: cpn !== undefined && cpn !== null ? parseInt(cpn) : 1, 162 offer_id: offerId, 163 added_at: Date.now() 164 }); 165 166 // Add free item 167 cart.push({ 168 cart_key: freeKey, 169 product_id: productId, 170 product_name: productName, 171 base_price: 0, 172 original_price: basePrice, 173 options: options, 174 quantity: quantity, 175 total_price: 0, 176 cpn: 0, 177 is_free: true, 178 offer_id: offerId, 179 linked_to: paidKey, 180 added_at: Date.now() 181 }); 182 183 this.set(cart); 184 } 185 this.scrollToBottom(); 186 //this.showNotification(`${productName} (2 for 1) added to cart!`); 187 }, 188 189 /** 190 * Find exact match in cart (same product + same options) 191 */ 192 findExactMatch(cart, productId, options) { 193 return cart.findIndex(item => { 194 if (item.product_id !== productId) return false; 195 196 // Compare options 197 if (item.options.length !== options.length) return false; 198 199 // Deep compare options 200 const itemOptionsStr = JSON.stringify(this.sortOptions(item.options)); 201 const newOptionsStr = JSON.stringify(this.sortOptions(options)); 202 203 return itemOptionsStr === newOptionsStr; 204 }); 205 }, 206 207 /**
208 * Sort options for comparison 209 */ 210 sortOptions(options) { 211 return [...options].sort((a, b) => a.option_id - b.option_id); 212 }, 213 214 /** 215 * Update item quantity 216 */ 217 update(index, quantity) { 218 let cart = this.get(); 219 220 if (index < 0 || index >= cart.length) { 221 console.error('Invalid cart index'); 222 return false; 223 } 224 225 quantity = parseInt(quantity); 226 227 if (quantity <= 0) { 228 return this.remove(index); 229 } 230 231 if (quantity > 20) { 232 this.showNotification('Maximum 20 items per product', 'warning'); 233 quantity = 20; 234 } 235 236 cart[index].quantity = quantity; 237 this.set(cart); 238 return true; 239 }, 240 241 /** 242 * Remove item from cart (handles linked BOGOF items) 243 */ 244 remove(index) { 245 let cart = this.get(); 246 247 if (index < 0 || index >= cart.length) { 248 console.error('Invalid cart index'); 249 return false; 250 } 251 252 const item = cart[index]; 253 const itemKey = item.cart_key; 254 255 // Animate out and remove 256 const itemElement = document.querySelector(`[data-cart-index="${index}"]`); 257 if (itemElement) { 258 itemElement.classList.add('cart-item-removing'); 259 setTimeout(() => { 260 cart = this.get(); // Re-fetch in case of changes 261 262 // If this is a paid BOGOF trigger, also remove linked free item 263 if (item.is_bogof_trigger && itemKey) { 264 cart = cart.filter(i => i.linked_to !== itemKey); 265 } 266 267 // Find and remove the item by key or timestamp 268 const currentIndex = cart.findIndex(i => 269 (i.cart_key && i.cart_key === itemKey) || 270 i.added_at === item.added_at 271 ); 272 if (currentIndex !== -1) { 273 cart.splice(currentIndex, 1); 274 } 275 276 this.set(cart); 277 this.enforceOfferRules();
278 }, 400); 279 } else { 280 // If this is a paid BOGOF trigger, also remove linked free item 281 if (item.is_bogof_trigger && itemKey) { 282 cart = cart.filter(i => i.linked_to !== itemKey); 283 } 284 285 cart.splice(index, 1); 286 this.set(cart); 287 this.enforceOfferRules(); 288 } 289 290 return true; 291 }, 292 293 /** 294 * Clear entire cart (with SweetAlert2 confirmation) 295 */ 296 clear() { 297 const cart = this.get(); 298 if (cart.length === 0) return; 299 300 // Use SweetAlert2 if available, otherwise use confirm 301 if (typeof Swal !== 'undefined') { 302 Swal.fire({ 303 title: 'Clear Cart?', 304 text: 'Remove all items from your cart?', 305 icon: 'warning', 306 showCancelButton: true, 307 confirmButtonText: 'Yes, clear it', 308 confirmButtonColor: '#dc3545', 309 cancelButtonText: 'Cancel' 310 }).then((result) => { 311 if (result.isConfirmed) { 312 localStorage.removeItem(this.getKey()); 313 this.updateUI(); 314 Swal.fire({ 315 title: 'Cleared!', 316 text: 'Your cart is now empty', 317 icon: 'success', 318 timer: 1500,
319 showConfirmButton: false 320 }); 321 } 322 }); 323 } else { 324 // Fallback to native confirm 325 if (confirm('Are you sure you want to clear your cart?')) { 326 localStorage.removeItem(this.getKey()); 327 this.updateUI(); 328 this.showNotification('Cart cleared'); 329 } 330 } 331 }, 332 333 /** 334 * Remove multiple items by product IDs 335 */ 336 removeByProductIds(productIds) { 337 if (!Array.isArray(productIds) || productIds.length === 0) return; 338 339 let cart = this.get(); 340 const idsToRemove = productIds.map(id => parseInt(id)); 341 342 cart = cart.filter(item => !idsToRemove.includes(parseInt(item.product_id))); 343 344 localStorage.setItem(this.getKey(), JSON.stringify(cart)); 345 this.updateUI(); 346 }, 347 348 /** 349 * Get items (alias for get) 350 */ 351 getItems() { 352 return this.get(); 353 }, 354 355 /** 356 * Get cart item count 357 */ 358 getCount() { 359 const cart = this.get(); 360 return cart.reduce((total, item) => total + item.quantity, 0); 361 }, 362 363 /** 364 * Get cart subtotal 365 */ 366 getSubtotal() { 367 const cart = this.get(); 368 return cart.reduce((total, item) => { 369 return total + (item.total_price * item.quantity); 370 }, 0); 371 }, 372 373 /** 374 * Get promotional subtotal (excludes free items) 375 * Used for threshold calculations 376 */ 377 getPromoSubtotal() { 378 const cart = this.get(); 379 return cart.reduce((total, item) => { 380 // Skip free items for promo calculations 381 if (item.is_free) return total; 382 return total + (item.total_price * item.quantity); 383 }, 0); 384 }, 385 386 /** 387 * Format price (gets currency symbol from page or defaults to £) 388 */ 389 formatPrice(amount) { 390 const currencySymbol = document.body.dataset.currency || '£'; 391 return currencySymbol + parseFloat(amount).toFixed(2); 392 }, 393 394 /** 395 * Scroll cart sidebar to bottom 396 */ 397 scrollToBottom() { 398 // Desktop 399 const cartBody = document.querySelector('.cart-sidebar__body'); 400 if (cartBody) { 401 cartBody.scrollTo({ top: cartBody.scrollHeight - 300, behavior: 'smooth' }); 402 } 403 404 // Mobile offcanvas 405 const mobileBody = document.querySelector('#mobileCartOffcanvas .offcanvas-body'); 406 if (mobileBody) { 407 mobileBody.scrollTo({ top: mobileBody.scrollHeight, behavior: 'smooth' }); 408 } 409 }, 410 411 /** 412 * Update all cart UI elements 413 */ 414 updateUI() { 415 const cart = this.get(); 416 const count = this.getCount(); 417 const subtotal = this.getSubtotal(); 418 419 // Update cart count badges 420 const badges = document.querySelectorAll('.cart-count'); 421 badges.forEach(badge => { 422 badge.textContent = count; 423 badge.style.display = count > 0 ? 'inline-block' : 'none'; 424 }); 425 426 // Update cart sidebar 427 this.updateCartSidebar(cart, subtotal); 428 429 // Update mobile cart bar visibility 430 const mobileCartBar = document.getElementById('mobileCartBar'); 431 if (mobileCartBar) { 432 mobileCartBar.style.display = count > 0 ? 'block' : 'none'; 433 } 434 435 // Update mobile totals 436 const mobileTotal = document.getElementById('mobile-cart-total'); 437 if (mobileTotal) { 438 mobileTotal.textContent = this.formatPrice(subtotal); 439 } 440 const mobileFooterTotal = document.getElementById('mobile-cart-total-footer'); 441 if (mobileFooterTotal) { 442 mobileFooterTotal.textContent = this.formatPrice(subtotal); 443 } 444 445 // Update offer progress messages 446 this.updateOfferProgress(); 447 448 // Update product card quantity badges 449 this.updateCardQuantities(); 450 }, 451 452 /** 453 * Update quantity badges on product cards 454 */ 455 updateCardQuantities() { 456 const cart = this.get(); 457 458 // Build product_id -> total quantity map
459 const qtyMap = {}; 460 cart.forEach(item => { 461 const pid = parseInt(item.product_id); 462 qtyMap[pid] = (qtyMap[pid] || 0) + item.quantity; 463 }); 464 465 document.querySelectorAll('.menu-product-card').forEach(card => { 466 const btn = card.querySelector('.menu-product-card__quick-add'); 467 if (!btn) return; 468 469 const badge = btn.querySelector('.menu-product-card__qty-badge'); 470 const plusIcon = btn.querySelector('.bi-plus'); 471 if (!badge) return; 472 473 let totalQty = qtyMap[parseInt(card.dataset.productId)] || 0; 474 475 // Sum variation IDs only if base not in cart 476 if (totalQty === 0 && card.dataset.hasVariations === '1') { 477 try { 478 const variations = JSON.parse(card.dataset.variations || '[]'); 479 variations.forEach(v => { 480 if (v.id && qtyMap[parseInt(v.id)]) { 481 totalQty += qtyMap[parseInt(v.id)]; 482 } 483 }); 484 } catch(e) {} 485 } 486 487 if (totalQty > 0) { 488 badge.textContent = totalQty; 489 badge.style.display = ''; 490 if (plusIcon) plusIcon.style.display = 'none'; 491 btn.classList.add('has-items'); 492 } else { 493 badge.textContent = ''; 494 badge.style.display = 'none'; 495 if (plusIcon) plusIcon.style.display = ''; 496 btn.classList.remove('has-items'); 497 } 498 }); 499 }, 500 501 /** 502 * Update cart sidebar HTML 503 */ 504 updateCartSidebar(cart, subtotal) { 505 const cartSidebar = document.getElementById('cart-items'); 506 if (!cartSidebar) return; 507 508 if (cart.length === 0) { 509 const emptyHtml = ` 510 <div class="text-center text-muted py-5"> 511 <i class="bi bi-cart-x" style="font-size: 3rem;"></i> 512 <p class="mt-3">Your cart is empty</p> 513 </div> 514 `; 515 516 cartSidebar.innerHTML = emptyHtml; 517 518 // Also update mobile cart 519 const mobileCartItems = document.getElementById('mobile-cart-items'); 520 if (mobileCartItems) { 521 mobileCartItems.innerHTML = emptyHtml; 522 } 523 524 const totalEl = document.getElementById('cart-total'); 525 if (totalEl) totalEl.textContent = this.formatPrice(0); 526 527 return; 528 } 529 530 let html = ''; 531 532 cart.forEach((item, index) => { 533 // Build options display with proper nesting 534 let optionsHtml = ''; 535 if (item.options && item.options.length > 0) { 536 optionsHtml = '<div class="cart-item-options">'; 537 538 // Group options: render parent + its children immediately 539 const renderedIndices = new Set(); 540 541 item.options.forEach((opt, optIndex) => { 542 if (renderedIndices.has(optIndex)) return; // Already rendered as child 543 544 const isChild = !!opt.parent_option_id; 545 if (isChild) return; // Will be rendered with parent 546 547 // Render parent option 548 const qtyStr = opt.quantity && opt.quantity > 1 ? ` x${opt.quantity}` : ''; 549 const quantity = opt.quantity || 1; 550 const unitPrice = (parseFloat(opt.option_price) || 0) + (parseFloat(opt.child_price) || 0); 551 const totalPrice = unitPrice * quantity; 552 const priceStr = totalPrice > 0 ? ` +${Cart.formatPrice(totalPrice)}` : ''; 553 554 optionsHtml += `<small class="text-muted d-block">+ ${opt.option_name}${qtyStr}${priceStr}</small>`; 555 renderedIndices.add(optIndex); 556 557 // Immediately render children of this parent 558 item.options.forEach((childOpt, childIndex) => { 559 if (parseInt(childOpt.parent_option_id) === parseInt(opt.option_id) && parseInt(childOpt.parent_group_id) === parseInt(opt.group_id)) { 560 const childQtyStr = childOpt.quantity && childOpt.quantity > 1 ? ` x${childOpt.quantity}` : ''; 561 const childQuantity = childOpt.quantity || 1; 562 const childUnitPrice = (parseFloat(childOpt.option_price) || 0) + (parseFloat(childOpt.child_price) || 0); 563 const childTotalPrice = childUnitPrice * childQuantity; 564 const childPriceStr = childTotalPrice > 0 ? ` +${Cart.formatPrice(childTotalPrice)}` : ''; 565 566 optionsHtml += `<small class="text-muted d-block ps-3"> + ${childOpt.option_name}${childQtyStr}${childPriceStr}</small>`; 567 renderedIndices.add(childIndex); 568 } 569 }); 570 }); 571 572 optionsHtml += '</div>'; 573 } 574 575 const itemTotal = item.total_price * item.quantity; 576 577 // Check if this is a free item 578 const isFree = item.is_free === true; 579 const freeClass = isFree ? ' free-item' : ''; 580 const freeBadge = isFree ? '<span class="free-badge">FREE</span>' : ''; 581 const originalPrice = isFree && item.original_price ? 582 `<span class="original-price">${this.formatPrice(item.original_price)}</span> ` : ''; 583 584 // Free items can't have quantity changed 585 const minusIcon = item.quantity === 1 ? 'bi-trash' : 'bi-dash'; 586 const minusClass = item.quantity === 1 ? ' qty-trash' : '';
587 const quantityControls = isFree ? 588 `<span class="quantity-note">Qty: ${item.quantity}</span>` : 589 `<div class="cart-qty-selector"> 590 <button type="button" class="cart-qty-btn${minusClass}" onclick="Cart.update(${index}, ${item.quantity - 1})"> 591 <i class="bi ${minusIcon}"></i> 592 </button> 593 <span class="cart-qty-value">${item.quantity}</span> 594 <button type="button" class="cart-qty-btn" onclick="Cart.update(${index}, ${item.quantity + 1})"> 595 <i class="bi bi-plus"></i> 596 </button> 597 </div>`; 598 599 // Price display - strike + discounted for promo-eligible lines 600 let paidPriceHtml = `<strong>${this.formatPrice(itemTotal)}</strong>`; 601 if (typeof PromoManager !== 'undefined') { 602 const discountedTotal = PromoManager.getDiscountedPrice(itemTotal, item.cpn ?? 1); 603 if (discountedTotal !== null) { 604 paidPriceHtml = `<span><s class="text-muted me-1">${this.formatPrice(itemTotal)}</s><strong class="text-success">${this.formatPrice(discountedTotal)}</strong></span>`; 605 } 606 } 607 const priceDisplay = isFree ? 608 (itemTotal > 0 ? `<strong>${this.formatPrice(itemTotal)}</strong>` : '<strong class="text-success">FREE</strong>') : 609 paidPriceHtml; 610 611 // Each price display 612 const eachPrice = isFree ? 613 (item.total_price > 0 ? `<span class="text-muted">${originalPrice}+${this.formatPrice(item.total_price)} extras</span>` : 614 `<span class="text-success">${originalPrice}FREE</span>`) : 615 `<span class="text-muted">${this.formatPrice(item.total_price)} each</span>`; 616 if (item.is_free && item.linked_to) { 617 // Compact free item display 618 const truncatedName = item.product_name.length > 30 619 ? item.product_name.substring(0, 30) + '...' 620 : item.product_name; 621 622 html += ` 623 <div class="cart-item cart-item-free-compact mb-2 ps-2 border-start border-success border-3" data-cart-index="${index}"> 624 <div class="d-flex justify-content-between align-items-center"> 625 <span class="text-truncate me-2">${truncatedName}</span> 626 <span class="badge bg-success">FREE</span> 627 </div> 628 <small class="text-muted">Qty: ${item.quantity}${itemTotal > 0 ? ` (+${this.formatPrice(itemTotal)} extras)` : ''}</small> 629 </div> 630 `; 631 return; 632 } 633 // Compact free-on-spend item display 634 if (item.is_free && !item.linked_to) { 635 const truncatedName = item.product_name.length > 30 636 ? item.product_name.substring(0, 30) + '...' 637 : item.product_name; 638 639 html += ` 640 <div class="cart-item cart-item-free-compact mb-2 ps-2 border-start border-info border-3" data-cart-index="${index}"> 641 <div class="d-flex justify-content-between align-items-center"> 642 <span class="text-truncate me-2">${truncatedName}</span> 643 <div> 644 <span class="badge bg-info me-1">FREE</span> 645 <button class="btn btn-sm btn-link text-danger p-0" onclick="Cart.remove(${index})" title="Remove"> 646 <i class="bi bi-trash"></i> 647 </button> 648 </div> 649 </div> 650 <small class="text-muted">Qty: ${item.quantity}${itemTotal > 0 ? ` (+${this.formatPrice(itemTotal)} extras)` : ''}</small> 651 </div> 652 `; 653 return; 654 } 655 html += ` 656 <div class="cart-item mb-3 pb-3 border-bottom${freeClass}" data-cart-index="${index}"> 657 <h6 class="mb-1">${item.product_name}${freeBadge}</h6> 658 ${optionsHtml} 659 <div class="d-flex justify-content-between align-items-center mt-2"> 660 ${priceDisplay}
661 ${quantityControls} 662 </div> 663 </div> 664 `; 665 }); 666 667 cartSidebar.innerHTML = html; 668 669 // Also update mobile cart 670 const mobileCartItems = document.getElementById('mobile-cart-items'); 671 if (mobileCartItems) { 672 mobileCartItems.innerHTML = html; 673 } 674 675 // Update total 676 const totalEl = document.getElementById('cart-total'); 677 if (totalEl) { 678 totalEl.textContent = this.formatPrice(subtotal); 679 } 680 }, 681 682 /** 683 * Show success feedback (button flash + badge pulse) 684 */ 685 showSuccessFeedback() { 686 // Flash the add button 687 const lastClickedBtn = document.activeElement; 688 if (lastClickedBtn && lastClickedBtn.tagName === 'BUTTON') { 689 lastClickedBtn.classList.add('btn-success-flash'); 690 setTimeout(() => { 691 lastClickedBtn.classList.remove('btn-success-flash'); 692 }, 600); 693 } 694 695 // Pulse cart badge 696 const badges = document.querySelectorAll('.cart-count'); 697 badges.forEach(badge => { 698 badge.classList.add('cart-badge-pulse'); 699 setTimeout(() => { 700 badge.classList.remove('cart-badge-pulse'); 701 }, 500); 702 }); 703 }, 704 705 /** 706 * Show notification 707 */ 708 showNotification(message, type = 'success') { 709 // Check if notification container exists 710 let container = document.getElementById('cart-notifications'); 711 712 if (!container) { 713 container = document.createElement('div'); 714 container.id = 'cart-notifications'; 715 container.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 9999;'; 716 document.body.appendChild(container); 717 } 718 719 const alert = document.createElement('div'); 720 alert.className = `alert alert-${type} alert-dismissible fade show`; 721 alert.innerHTML = ` 722 ${message} 723 <button type="button" class="btn-close" data-bs-dismiss="alert"></button> 724 `; 725 726 container.appendChild(alert); 727 728 // Auto dismiss after 3 seconds 729 setTimeout(() => { 730 alert.classList.remove('show'); 731 setTimeout(() => alert.remove(), 150); 732 }, 3000); 733 }, 734 735 /** 736 * Check and enforce offer rules after cart changes 737 * Removes free items if conditions no longer met 738 */ 739 enforceOfferRules() { 740 if (typeof SITE_FEATURES === 'undefined' || !SITE_FEATURES.offers) return; 741 if (typeof ACTIVE_OFFERS === 'undefined') return; 742 743 let cart = this.get(); 744 let modified = false; 745 const subtotal = this.getPromoSubtotal(); 746 747 cart = cart.filter(item => { 748 if (!item.is_free || !item.offer_id) return true; 749 750 const offer = ACTIVE_OFFERS.find(o => o.id === item.offer_id); 751 if (!offer) { 752 modified = true; 753 return false; // Offer no longer exists 754 } 755 756 // Check free_on_spend threshold 757 if (offer.type === 'free_on_spend') { 758 if (subtotal < offer.minSpend) { 759 modified = true; 760 //this.showNotification(`Free item removed - spend £${offer.minSpend.toFixed(2)} to qualify`, 'warning'); 761 return false; 762 } 763 } 764 765 // Check BOGOF linked item still exists 766 if (offer.type === 'bogof' && item.linked_to) { 767 const linkedItem = cart.find(i => i.cart_key === item.linked_to); 768 if (!linkedItem) { 769 modified = true; 770 return false; 771 } 772 } 773 774 // Sync free qty to paid qty for BOGOF 775 if (item.is_free && item.linked_to && offer.type === 'bogof') { 776 const paidItem = cart.find(i => i.cart_key === item.linked_to); 777 if (paidItem && item.quantity !== paidItem.quantity) { 778 item.quantity = paidItem.quantity; 779 modified = true; 780 } 781 } 782 783 return true; 784 }); 785 786 // Check buy_x_get_free quantities 787 ACTIVE_OFFERS.filter(o => o.type === 'buy_x_get_free').forEach(offer => { 788 const triggerQty = cart 789 .filter(i => !i.is_free && (offer.triggerProducts || []).includes(i.product_id)) 790 .reduce((sum, i) => sum + i.quantity, 0); 791 792 const freeItems = cart.filter(i => i.is_free && i.offer_id === offer.id); 793 const allowedFree = Math.floor(triggerQty / offer.triggerQuantity); 794 795 // Remove excess free items 796 if (freeItems.length > allowedFree) { 797 const toRemove = freeItems.length - allowedFree; 798 for (let i = 0; i < toRemove; i++) { 799 const removeIdx = cart.findIndex(item => 800 item.is_free && item.offer_id === offer.id 801 ); 802 if (removeIdx !== -1) { 803 cart.splice(removeIdx, 1); 804 modified = true; 805 } 806 } 807 if (toRemove > 0) { 808 //this.showNotification(`Free item removed - need ${offer.triggerQuantity} items to qualify`, 'warning'); 809 } 810 } 811 }); 812 813 if (modified) { 814 localStorage.setItem(this.getKey(), JSON.stringify(cart)); 815 this.updateUI(); 816 } 817 }, 818 819 /** 820 * Get offer progress messages for display 821 */ 822 getOfferProgress() { 823 if (typeof ACTIVE_OFFERS === 'undefined') return []; 824 825 const messages = []; 826 const cart = this.get(); 827 const subtotal = this.getPromoSubtotal(); 828 829 ACTIVE_OFFERS.forEach(offer => { 830 switch (offer.type) { 831 case 'free_on_spend': 832 const maxQty = offer.maxQuantity || 1; 833 const claimed = cart.filter(i => i.is_free && i.offer_id === offer.id).length; 834 const remaining = maxQty - claimed; 835 836 if (subtotal < offer.minSpend) { 837 const toSpend = (offer.minSpend - subtotal).toFixed(2); 838 messages.push({ 839 type: 'progress', 840 icon: 'gift', 841 text: `Spend £${toSpend} more for a free ${offer.name}!`, 842 offer_id: offer.id 843 }); 844 } else if (remaining > 0) { 845 // const itemText = remaining === 1 ? 'item' : 'items';
846 const chooseText = maxQty > 1 ? `Choose ${remaining}` : `Choose your free item`; 847 messages.push({ 848 type: 'unlocked', 849 icon: 'gift-fill', 850 text: `${offer.name} available! ${chooseText}`, 851 offer_id: offer.id 852 }); 853 } 854 break; 855 856 case 'buy_x_get_free': { 857 const triggers = (offer.triggerProducts || []).map(Number); 858 const per = Math.max(1, parseInt(offer.triggerQuantity, 10) || 1); 859 const triggerQty = cart 860 .filter(i => !i.is_free && triggers.includes(Number(i.product_id))) 861 .reduce((sum, i) => sum + (parseInt(i.quantity, 10) || 1), 0); 862 const rewards = (offer.rewardProducts || []).map(Number).filter(id => !triggers.includes(id)); 863 const earned = Math.floor(triggerQty / per); 864 const claimedQty = cart 865 .filter(i => i.is_free && i.offer_id === offer.id) 866 .reduce((sum, i) => sum + (parseInt(i.quantity, 10) || 1), 0); 867 868 if (rewards.length && earned > claimedQty) { 869 messages.push({ 870 type: 'unlocked', 871 icon: 'gift-fill', 872 text: `${offer.name} unlocked! Choose your free item`, 873 offer_id: offer.id 874 }); 875 } else if (triggerQty > 0) { 876 const needed = per - (triggerQty % per); 877 if (needed < per && needed > 0) { 878 messages.push({ 879 type: 'progress', 880 icon: 'plus-circle', 881 text: `Add ${needed} more for a free one (${offer.name})!`, 882 offer_id: offer.id 883 }); 884 } 885 } 886 break; 887 } 888 } 889 }); 890 891 return messages; 892 }, 893 894 /** 895 * Update offer progress display in cart sidebar 896 */ 897 updateOfferProgress() { 898 const container = document.getElementById('offerProgressMessages'); 899 const mobileContainer = document.getElementById('mobileOfferProgressMessages'); 900 901 const section = container?.closest('.offer-progress-section') || container?.parentElement; 902 const mobileSection = document.getElementById('mobileOfferProgressSection'); 903 904 const messages = this.getOfferProgress(); 905 906 if (messages.length === 0) { 907 if (section) section.classList.add('d-none'); 908 if (mobileSection) mobileSection.classList.add('d-none'); 909 return; 910 } 911 912 const freeNav = document.querySelector('.menu-categories-nav a[href="#strip-free"]'); 913 914 const html = messages.map(msg => { 915 const colorClass = msg.type === 'unlocked' ? 'text-success' : 'text-primary'; 916 const inner = `<i class="bi bi-${msg.icon} me-1"></i>${msg.text}`; 917 918 if (msg.type === 'unlocked' && freeNav) { 919 return `<div class="${colorClass} mb-1"><a href="#strip-free" class="offer-progress-link" onclick="Cart.goToFreeItems(event)">${inner}<i class="bi bi-arrow-right-short ms-1"></i></a></div>`; 920 } 921 return `<div class="${colorClass} mb-1">${inner}</div>`; 922 }).join(''); 923 924 if (container) container.innerHTML = html; 925 if (mobileContainer) mobileContainer.innerHTML = html; 926 927 if (section) section.classList.remove('d-none'); 928 if (mobileSection) mobileSection.classList.remove('d-none'); 929 }, 930 931 /** 932 * Jump to the free items strip - reuses the category nav link so offset, 933 * active state and sparkle match a normal nav click. Closes the mobile 934 * cart sheet first if it's open. 935 */ 936 goToFreeItems(event) { 937 if (event) event.preventDefault(); 938 939 const navLink = document.querySelector('.menu-categories-nav a[href="#strip-free"]'); 940 if (!navLink) return; 941 942 const offcanvasEl = document.getElementById('mobileCartOffcanvas'); 943 const offcanvas = (offcanvasEl && typeof bootstrap !== 'undefined') 944 ? bootstrap.Offcanvas.getInstance(offcanvasEl) 945 : null; 946 947 if (offcanvas && offcanvasEl.classList.contains('show')) { 948 offcanvasEl.addEventListener('hidden.bs.offcanvas', () => navLink.click(), { once: true });
949 offcanvas.hide(); 950 } else { 951 navLink.click(); 952 } 953 }, 954 955 /** 956 * Initialize cart on page load 957 */ 958 init() { 959 this.updateUI(); 960 961 // Scroll mobile cart to bottom when offcanvas opens 962 const mobileOffcanvas = document.getElementById('mobileCartOffcanvas'); 963 if (mobileOffcanvas) { 964 mobileOffcanvas.addEventListener('shown.bs.offcanvas', () => { 965 const mobileBody = mobileOffcanvas.querySelector('.offcanvas-body'); 966 if (mobileBody) { 967 setTimeout(() => { 968 mobileBody.scrollTo({ top: mobileBody.scrollHeight, behavior: 'smooth' }); 969 }, 100); 970 } 971 }); 972 } 973 974 // Listen for storage events (cart updated in another tab) 975 window.addEventListener('storage', (e) => { 976 if (e.key === this.getKey()) { 977 this.updateUI(); 978 } 979 }); 980 } 981}; 982 983// Initialize cart when DOM is ready 984document.addEventListener('DOMContentLoaded', () => { 985 Cart.init(); 986});
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.