PageSourceSearch

https://cinnamon-brockley.co.uk/assets/js/cart.js?v=1.2.4

js cinnamon-brockley.co.uk collected 2026-10-08 20:40:31 UTC 38,650 bytes, 986 lines download raw bytes

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">&nbsp;&nbsp;+ ${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.