PageSourceSearch

https://cinnamon-brockley.co.uk/assets/js/upsells.js

js cinnamon-brockley.co.uk collected 2026-10-08 20:40:37 UTC 8,371 bytes, 256 lines download raw bytes

1/**
2 * Upsells Manager - "Customers also love" carousel with threshold intelligence
3 * Reads product data from data attributes (server-rendered)
4 * Only handles filtering, threshold calculation, and visibility
5 */
6
7const UpsellsManager = {
8    config: {
9        enabled: false,
10        title: 'Customers also love'
11    },
12
13    /**
14     * Initialize upsells manager
15     */
16    init(config) {
17        if (!config || !config.enabled) return;
18        
19        this.config = config;
20        
21        // Hook into Cart.updateUI to refresh upsells
22        const originalUpdateUI = Cart.updateUI.bind(Cart);
23        Cart.updateUI = () => {
24            originalUpdateUI();
25            UpsellsManager.update();
26        };
27        
28        // Initial update
29        this.update();
30    },
31
32    /**
33     * Get cart product IDs
34     */
35    getCartProductIds() {
36        const cart = Cart.get();
37        return cart.map(item => parseInt(item.product_id));
38    },
39
40    /**
41     * Find the next promo threshold above current subtotal
42     */
43    getNextThreshold(currentSubtotal) {
44        if (typeof window.autoPromos === 'undefined') return null;
45        
46        const applicablePromos = window.autoPromos
47            .filter(promo => {
48                if (!promo.minOrder || promo.minOrder <= 0) return false;
49                if (promo.minOrder <= currentSubtotal) return false;
50                if (promo.type === 'delivery_fee') return false;
51                return true;
52            })
53            .sort((a, b) => a.minOrder - b.minOrder);
54        
55        if (applicablePromos.length === 0) return null;
56        
57        const nextPromo = applicablePromos[0];
58        
59        return {
60            threshold: nextPromo.minOrder,
61            type: nextPromo.type,
62            value: nextPromo.value,
63            maxDiscount: nextPromo.maxDiscount || null
64        };
65    },
66
67    /**
68     * Calculate the benefit of adding an upsell
69     */
70    calculateBenefit(productPrice, currentSubtotal) {
71        const nextThreshold = this.getNextThreshold(currentSubtotal);
72        if (!nextThreshold) return null;
73        
74        const newSubtotal = currentSubtotal + productPrice;
75        
76        if (newSubtotal < nextThreshold.threshold) return null;
77        
78        let discount = 0;
79        if (nextThreshold.type === 'percent') {
80            discount = newSubtotal * (nextThreshold.value / 100);
81            if (nextThreshold.maxDiscount) {
82                discount = Math.min(discount, nextThreshold.maxDiscount);
83            }
84        } else if (nextThreshold.type === 'fixed') {
85            discount = nextThreshold.value;
86        }
87        
88        const netCost = productPrice - discount;
89        
90        return {
91            discountType: nextThreshold.type,
92            discountValue: nextThreshold.value,
93            discountAmount: discount,
94            netCost: netCost
95        };
96    },
97
98    /**
99     * Format currency
100     */
101    formatPrice(amount) {
102        const symbol = document.body.dataset.currency || '£';
103        return symbol + Math.abs(amount).toFixed(2);
104    },
105
106    /**
107     * Update upsells visibility and badges
108     */
109    update() {
110        const desktopSection = document.getElementById('upsellsSection');
111        const mobileSection = document.getElementById('mobileUpsellsSection');
112        
113        if (!desktopSection && !mobileSection) return;
114        
115        const cart = Cart.get();
116        
117        // Hide if cart is empty
118        if (cart.length === 0) {
119            if (desktopSection) desktopSection.classList.add('d-none');
120            if (mobileSection) mobileSection.classList.add('d-none');
121            return;
122        }
123        
124        const cartProductIds = this.getCartProductIds();
125        const currentSubtotal = Cart.getPromoSubtotal();
126        
127        // Process desktop cards
128        let visibleCount = 0;
129        visibleCount += this.processCarousel('upsellsCarousel', cartProductIds, currentSubtotal);
130        
131        // Process mobile cards (separate set)
132        this.processCarousel('mobileUpsellsCarousel', cartProductIds, currentSubtotal);
133        
134        // Show/hide sections
135        if (visibleCount === 0) {
136            if (desktopSection) desktopSection.classList.add('d-none');
137            if (mobileSection) mobileSection.classList.add('d-none');
138        } else {
139            if (desktopSection) desktopSection.classList.remove('d-none');
140            if (mobileSection) mobileSection.classList.remove('d-none');
141        }
142    },
143
144    /**
145     * Process a carousel - filter, badge, sort
146     */
147    processCarousel(carouselId, cartProductIds, currentSubtotal) {
148        const carousel = document.getElementById(carouselId);
149        if (!carousel) return 0;
150        
151        const allCards = carousel.querySelectorAll('.upsell-card');
152        const cardData = [];
153        
154        allCards.forEach(card => {
155            const productId = parseInt(card.dataset.productId);
156            const productPrice = parseFloat(card.dataset.productPrice);
157            
158            // Hide if already in cart
159            if (cartProductIds.includes(productId)) {
160                card.classList.add('d-none');
161                return;
162            }
163            
164            // Calculate benefit
165            const benefit = this.calculateBenefit(productPrice, currentSubtotal);
166            
167            // Update benefit badge
168            this.updateBenefitBadge(card, benefit, productPrice);
169            
170            card.classList.remove('d-none');
171            
172            cardData.push({
173                card: card,
174                benefit: benefit,
175                price: productPrice
176            });
177        });
178        
179        // Sort cards: threshold-unlockers first (best deal first), then by price
180        cardData.sort((a, b) => {
181            if (a.benefit && !b.benefit) return -1;
182            if (!a.benefit && b.benefit) return 1;
183            if (a.benefit && b.benefit) {
184                return a.benefit.netCost - b.benefit.netCost;
185            }
186            return a.price - b.price;
187        });
188        
189        // Reorder cards in DOM
190        cardData.forEach(({ card }) => {
191            carousel.appendChild(card);
192        });
193        
194        return cardData.length;
195    },
196
197    /**
198     * Update benefit badge on a card
199     */
200    updateBenefitBadge(card, benefit, productPrice) {
201        let badgeContainer = card.querySelector('.upsell-card__benefit');
202        
203        // Remove existing badge
204        if (badgeContainer) {
205            badgeContainer.remove();
206        }
207        
208        if (!benefit) return;
209        
210        // Create new badge
211        const badge = document.createElement('div');
212        badge.className = 'upsell-card__benefit';
213        
214        if (benefit.netCost <= 0) {
215            badge.classList.add('upsell-card__benefit--amazing');
216            badge.innerHTML = `<i class="bi bi-stars"></i><span>FREE + save ${this.formatPrice(-benefit.netCost)}!</span>`;
217        } else if (benefit.netCost < productPrice * 0.5) {
218            badge.classList.add('upsell-card__benefit--great');
219            badge.innerHTML = `<i class="bi bi-lightning-fill"></i><span>Only ${this.formatPrice(benefit.netCost)} net!</span>`;
220        } else {
221            badge.innerHTML = `<i class="bi bi-unlock-fill"></i><span>Unlocks ${benefit.discountValue}% off!</span>`;
222        }
223        
224        // Insert before the price
225        const footer = card.querySelector('.upsell-card__footer');
226        if (footer) {
227            footer.insertBefore(badge, footer.firstChild);
228        }
229    },
230
231    /**
232     * Add upsell product to cart (called from onclick)
233     */
234    addToCart(button, event) {
235        event.stopPropagation();
236        
237        const card = button.closest('.upsell-card');
238        if (!card) return;
239        
240        const productId = card.dataset.productId;
241        const name = card.dataset.productName;
242        const price = parseFloat(card.dataset.productPrice);
243        const cpn = parseInt(card.dataset.productCpn) || 1;
244        
245        // Visual feedback - swap icon
246        const icon = button.querySelector('i');
247        if (icon) {
248            icon.classList.remove('bi-plus');
249            icon.classList.add('bi-check');
250            button.classList.add('added');
251        }
252        
253        // Add to cart
254        Cart.add(productId, name, price, [], 1, cpn);
255    }
256};

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.