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.