1/** 2 * Promo Management - Live discount preview on order-online page 3 */ 4 5const PromoManager = { 6 state: { 7 autoPromos: [], 8 appliedPromos: [], 9 discountableSubtotal: 0, 10 selectedMethod: null, 11 currentDiscount: 0 12 }, 13 14 init() { 15 this.state.autoPromos = window.autoPromos || []; 16 17 // Hook into cart updates 18 const originalUpdateUI = Cart.updateUI; 19 Cart.updateUI = () => { 20 originalUpdateUI.call(Cart); 21 PromoManager.updateDiscountPreview(); 22 }; 23 24 // Check if table mode (DeliveryManager won't exist) 25 if (window.TABLE_MODE) { 26 // Table mode - method comes from window.ORDER_METHOD 27 this.state.selectedMethod = window.ORDER_METHOD || 'table'; 28 } else if (typeof DeliveryManager !== 'undefined') { 29 // Normal mode - hook into delivery method changes 30 const originalSelectMethod = DeliveryManager.selectMethod; 31 DeliveryManager.selectMethod = (button, isRestoring) => { 32 originalSelectMethod.call(DeliveryManager, button, isRestoring); 33 PromoManager.state.selectedMethod = DeliveryManager.state.selectedMethod; 34 Cart.updateUI(); 35 PromoManager.updatePromoBadges(); 36 }; 37 38 // Initial state from DeliveryManager 39 this.state.selectedMethod = DeliveryManager.state.selectedMethod; 40 } 41 42 // Re-render cart lines now that the rate is known (cart.js painted before we loaded) 43 Cart.updateUI(); 44 this.updatePromoBadges(); 45 }, 46 47 /** 48 * Calculate subtotal of discountable items only (cpn: 1) 49 */ 50 getDiscountableSubtotal() { 51 const cart = Cart.get(); 52 let subtotal = 0; 53 54 cart.forEach(item => { 55 // Default to discountable if cpn not set 56 if (item.cpn !== 0) { 57 subtotal += item.total_price * item.quantity; 58 } 59 }); 60 61 return subtotal; 62 }, 63 64 /** 65 * Get full subtotal (all items) 66 */ 67 getFullSubtotal() { 68 return Cart.getSubtotal(); 69 }, 70 71 /** 72 * Check if promo applies to current method 73 */ 74 appliesToMethod(promo) { 75 // null or empty = applies to all 76 if (!promo.methods || promo.methods.length === 0) { 77 return true; 78 } 79 80 // No method selected yet - show all 81 if (!this.state.selectedMethod) { 82 return true; 83 } 84 85 return promo.methods.includes(this.state.selectedMethod); 86 }, 87 88 /** 89 * Calculate discount for a promo 90 */ 91 calculateDiscount(promo, discountableSubtotal, deliveryFee = 0) { 92 if (discountableSubtotal < promo.minOrder) { 93 return 0; 94 } 95 96 let discount = 0; 97 98 if (promo.type === 'percent') { 99 discount = discountableSubtotal * promo.value / 100; 100 if (promo.maxDiscount) { 101 discount = Math.min(discount, promo.maxDiscount); 102 } 103 } else if (promo.type === 'fixed') { 104 discount = promo.value; 105 } else if (promo.type === 'delivery_fee') { 106 discount = deliveryFee * promo.value / 100; 107 } 108 109 return Math.round(discount * 100) / 100; 110 }, 111 112 /** 113 * Update the discount preview in cart sidebar (desktop + mobile) 114 */ 115 updateDiscountPreview() { 116 const previewContainer = document.getElementById('discountPreview'); 117 const progressContainer = document.getElementById('discountProgress'); 118 const appliedContainer = document.getElementById('appliedDiscounts'); 119 120 // Mobile elements 121 const mobilePreviewContainer = document.getElementById('mobileDiscountPreview'); 122 const mobileProgressContainer = document.getElementById('mobileDiscountProgress'); 123 const mobileAppliedContainer = document.getElementById('mobileAppliedDiscounts'); 124 125 if (!previewContainer) return; 126 127 const discountableSubtotal = this.getDiscountableSubtotal(); 128 const fullSubtotal = this.getFullSubtotal(); 129 const deliveryFee = window.TABLE_MODE ? 0 : (DeliveryManager.state.postcodeData?.deliveryFee || 0); 130 131 let progressMessages = []; 132 let appliedMessages = []; 133 let totalDiscount = 0; 134 135 // Separate stackable and non-stackable 136 let stackablePromos = []; 137 let nonStackablePromos = []; 138 139 this.state.autoPromos.forEach(promo => { 140 if (!this.appliesToMethod(promo)) return; 141 142 const discount = this.calculateDiscount(promo, discountableSubtotal, deliveryFee); 143 144 if (discount > 0) { 145 const promoData = { 146 code: promo.code, 147 description: promo.description, 148 discount: discount, 149 type: promo.type, 150 stackable: promo.stackable 151 }; 152 153 if (promo.stackable) { 154 stackablePromos.push(promoData); 155 } else { 156 nonStackablePromos.push(promoData); 157 } 158 } else if (promo.minOrder > discountableSubtotal) { 159 // Show progress towards promo 160 const remaining = promo.minOrder - discountableSubtotal; 161 let savingText = ''; 162 163 if (promo.type === 'percent') { 164 savingText = `${promo.value}% off`; 165 } else if (promo.type === 'fixed') { 166 savingText = `£${promo.value.toFixed(2)} off`; 167 } else if (promo.type === 'delivery_fee') { 168 savingText = 'free delivery'; 169 } 170 171 let methodHint = ''; 172 if (promo.methods && promo.methods.length === 1) { 173 methodHint = ` (${promo.methods[0]})`; 174 } 175 176 progressMessages.push(`Spend £${remaining.toFixed(2)} more for ${savingText}${methodHint}`); 177 } 178 }); 179 180 // Apply stacking logic 181 if (nonStackablePromos.length > 0) { 182 // Sort by discount descending, pick best one 183 nonStackablePromos.sort((a, b) => b.discount - a.discount); 184 const bestPromo = nonStackablePromos[0]; 185 186 if (bestPromo.type !== 'delivery_fee') { 187 totalDiscount = bestPromo.discount; 188 } 189 appliedMessages.push({ 190 code: bestPromo.code, 191 label: bestPromo.type === 'delivery_fee' ? 'Free Delivery' : bestPromo.code, 192 discount: bestPromo.discount, 193 type: bestPromo.type 194 }); 195 196 // Non-stackable means alone - ignore all stackable 197 } else { 198 // No non-stackable, apply all stackable 199 stackablePromos.forEach(promo => { 200 if (promo.type !== 'delivery_fee') { 201 totalDiscount += promo.discount; 202 } 203 appliedMessages.push({ 204 code: promo.code, 205 label: promo.type === 'delivery_fee' ? 'Free Delivery' : promo.code, 206 discount: promo.discount, 207 type: promo.type 208 }); 209 }); 210 } 211 // Store for external access 212 this.state.currentDiscount = totalDiscount; 213 214 // Update UI 215 if (progressMessages.length === 0 && appliedMessages.length === 0) { 216 this.state.currentDiscount = 0; 217 previewContainer.classList.add('d-none'); 218 if (mobilePreviewContainer) mobilePreviewContainer.classList.add('d-none'); 219 this.updateCardPrices(); 220 return; 221 } 222 223 previewContainer.classList.remove('d-none'); 224 if (mobilePreviewContainer) mobilePreviewContainer.classList.remove('d-none'); 225 226 // Render progress messages 227 if (progressMessages.length > 0) { 228 const progressHtml = progressMessages.map(msg => 229 `<div class="text-primary"><i class="bi bi-info-circle me-1"></i>${msg}</div>` 230 ).join(''); 231 progressContainer.innerHTML = progressHtml; 232 progressContainer.classList.remove('d-none'); 233 if (mobileProgressContainer) { 234 mobileProgressContainer.innerHTML = progressHtml; 235 mobileProgressContainer.classList.remove('d-none'); 236 } 237 } else { 238 progressContainer.classList.add('d-none'); 239 if (mobileProgressContainer) mobileProgressContainer.classList.add('d-none'); 240 } 241 242 // Render applied discounts 243 if (appliedMessages.length > 0) { 244 const appliedHtml = appliedMessages.map(item => 245 item.type === 'delivery_fee' 246 ? `<div class="text-success small"><i class="bi bi-truck me-1"></i>Free Delivery Applied</div>` 247 : `<div class="d-flex justify-content-between align-items-center mb-1">
248 <span class="badge bg-success"><i class="bi bi-tag-fill me-1"></i>${item.label}</span> 249 <span class="text-success fw-bold">-£${item.discount.toFixed(2)}</span> 250 </div>` 251 ).join(''); 252 appliedContainer.innerHTML = appliedHtml; 253 appliedContainer.classList.remove('d-none'); 254 if (mobileAppliedContainer) { 255 mobileAppliedContainer.innerHTML = appliedHtml; 256 mobileAppliedContainer.classList.remove('d-none'); 257 } 258 } else { 259 appliedContainer.classList.add('d-none'); 260 if (mobileAppliedContainer) mobileAppliedContainer.classList.add('d-none'); 261 } 262 263 // Update subtotal display with strikethrough if discount applied 264 const cartTotalOriginal = document.getElementById('cart-total-original'); 265 if (cartTotalOriginal) { 266 const cartTotal = document.getElementById('cart-total'); 267 if (cartTotal && totalDiscount > 0) { 268 cartTotalOriginal.textContent = `£${fullSubtotal.toFixed(2)}`; 269 cartTotalOriginal.classList.remove('d-none'); 270 cartTotal.textContent = `£${(fullSubtotal - totalDiscount).toFixed(2)}`; 271 cartTotal.classList.add('text-success'); 272 } else { 273 cartTotalOriginal.classList.add('d-none'); 274 if (cartTotal) cartTotal.classList.remove('text-success'); 275 } 276 } 277 278 // Mobile strikethrough 279 const mobileTotalOriginal = document.getElementById('mobile-cart-total-original'); 280 if (mobileTotalOriginal) { 281 const mobileTotal = document.getElementById('mobile-cart-total-footer'); 282 if (mobileTotal && totalDiscount > 0) { 283 mobileTotalOriginal.textContent = `£${fullSubtotal.toFixed(2)}`; 284 mobileTotalOriginal.classList.remove('d-none'); 285 mobileTotal.textContent = `£${(fullSubtotal - totalDiscount).toFixed(2)}`; 286 mobileTotal.classList.add('text-success'); 287 } else { 288 mobileTotalOriginal.classList.add('d-none'); 289 if (mobileTotal) mobileTotal.classList.remove('text-success'); 290 } 291 } 292 293 // Mobile sticky bar total (pre-open "View Cart" button) 294 const mobileBarTotal = document.getElementById('mobile-cart-total'); 295 if (mobileBarTotal) { 296 mobileBarTotal.textContent = `£${(fullSubtotal - totalDiscount).toFixed(2)}`; 297 } 298 299 // Update min order warning to use post-discount total 300 this.updateMinOrderCheck(fullSubtotal - totalDiscount); 301 this.updateCardPrices(); 302 }, 303 304 /** 305 * Update min order check with post-discount total 306 */ 307 updateMinOrderCheck(postDiscountTotal) { 308 // Skip if in table mode or DeliveryManager doesn't exist 309 if (window.TABLE_MODE || typeof DeliveryManager === 'undefined') return; 310 311 if (!DeliveryManager.state.selectedMethod) return; 312 313 const minOrder = DeliveryManager.state.minOrder; 314 const warningDiv = document.getElementById('minOrderWarning'); 315 const warningText = document.getElementById('minOrderText'); 316 317 if (warningDiv && warningText) { 318 if (minOrder > 0 && postDiscountTotal < minOrder) { 319 const remaining = minOrder - postDiscountTotal; 320 warningText.textContent = `£${remaining.toFixed(2)} more required for ${DeliveryManager.state.selectedMethod}`; 321 warningDiv.classList.remove('d-none'); 322 } else { 323 warningDiv.classList.add('d-none'); 324 } 325 } 326 }, 327 328 /** 329 * Update promo badge visibility based on selected method 330 */ 331 updatePromoBadges() { 332 const badges = document.querySelectorAll('[data-promo-code]'); 333 334 badges.forEach(badge => { 335 const methodsAttr = badge.dataset.promoMethods; 336 let methods = null; 337 338 try { 339 methods = JSON.parse(methodsAttr); 340 } catch (e) { 341 methods = null; 342 } 343 344 // null = show always 345 if (!methods || methods.length === 0) { 346 badge.classList.remove('opacity-50'); 347 return; 348 } 349 350 // No method selected - show all 351 if (!this.state.selectedMethod) { 352 badge.classList.remove('opacity-50'); 353 return; 354 } 355 356 // Check if current method matches 357 if (methods.includes(this.state.selectedMethod)) { 358 badge.classList.remove('opacity-50'); 359 } else { 360 badge.classList.add('opacity-50'); 361 } 362 }); 363 }, 364 365 /** 366 * Get current discount for external access 367 */ 368 getCurrentDiscount() { 369 return this.state.currentDiscount || 0; 370 }, 371 372 /** 373 * Effective percent-off rate currently active (0 if none). 374 * Percent promos only - mirrors the stacking logic in updateDiscountPreview. 375 * Shared by cards, pick cards, product modal and cart lines. 376 */ 377 getEffectiveRate() { 378 const discountableSubtotal = this.getDiscountableSubtotal(); 379 380 const percentPromos = this.state.autoPromos.filter(p => 381 p.type === 'percent' && this.appliesToMethod(p) 382 ); 383 if (percentPromos.length === 0) return 0; 384 385 const activeNonStackable = percentPromos 386 .filter(p => !p.stackable && discountableSubtotal >= p.minOrder) 387 .sort((a, b) => b.value - a.value)[0]; 388 389 const activeStackable = percentPromos 390 .filter(p => p.stackable && discountableSubtotal >= p.minOrder); 391 392 if (activeNonStackable) return activeNonStackable.value; 393 if (activeStackable.length > 0) { 394 // Rates apply to same base subtotal (not compounding), so sum them 395 return activeStackable.reduce((sum, p) => sum + p.value, 0); 396 } 397 return 0; 398 }, 399 400 /** 401 * Discounted price for a given price/cpn, or null if no discount applies. 402 */ 403 getDiscountedPrice(price, cpn = 1) { 404 const rate = this.getEffectiveRate(); 405 if (rate <= 0 || cpn === 0 || !price) return null; 406 return Math.round(price * (1 - rate / 100) * 100) / 100; 407 }, 408 409 /** 410 * Strike/discount markup - same classes everywhere. 411 */ 412 priceHtml(price, cpn = 1, symbol = '£') { 413 const discounted = this.getDiscountedPrice(price, cpn); 414 if (discounted === null) return `${symbol}${parseFloat(price).toFixed(2)}`; 415 return `<s class="text-muted me-1">${symbol}${parseFloat(price).toFixed(2)}</s><span class="text-success fw-semibold">${symbol}${discounted.toFixed(2)}</span>`;
416 }, 417 418 updateCardPrices() { 419 const effectiveRate = this.getEffectiveRate(); 420 421 if (effectiveRate <= 0) { 422 this.revertCardPrices(); 423 return; 424 } 425 426 document.querySelectorAll('.menu-product-card').forEach(card => { 427 let productData = {}; 428 try { productData = JSON.parse(card.dataset.product || '{}'); } catch (e) {} 429 430 const cpn = productData.cpn ?? (card.dataset.cpn !== undefined ? parseInt(card.dataset.cpn) : 1); 431 const priceEl = card.querySelector('.menu-product-card__price'); 432 if (!priceEl || cpn === 0) return; 433 434 const basePrice = parseFloat(priceEl.dataset.basePrice || 0); 435 if (!basePrice) return; 436 437 const showFrom = priceEl.dataset.showFrom === '1'; 438 const fromHtml = showFrom ? '<span class="menu-product-card__price-from">From</span>' : ''; 439 440 priceEl.innerHTML = `${fromHtml}${this.priceHtml(basePrice, cpn)}`; 441 priceEl.dataset.discountApplied = '1'; 442 }); 443 }, 444 445 revertCardPrices() { 446 document.querySelectorAll('.menu-product-card__price[data-discount-applied="1"]').forEach(priceEl => { 447 const basePrice = parseFloat(priceEl.dataset.basePrice || 0); 448 if (!basePrice) return; 449 450 const showFrom = priceEl.dataset.showFrom === '1'; 451 const fromHtml = showFrom ? '<span class="menu-product-card__price-from">From</span>' : ''; 452 453 priceEl.innerHTML = `${fromHtml}£${basePrice.toFixed(2)}`; 454 delete priceEl.dataset.discountApplied; 455 }); 456 }, 457}; 458 459document.addEventListener('DOMContentLoaded', () => { 460 PromoManager.init(); 461});
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.