PageSourceSearch

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

js cinnamon-brockley.co.uk collected 2026-10-08 20:40:34 UTC 17,765 bytes, 461 lines download raw bytes

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.