1/** 2 * Offers Manager - free strip, BOGOF pairing, buy-x rewards 3 * 4 * Free items (free-on-spend and buy-x rewards that are a different product) 5 * go through the normal product flow: Cart.add is wrapped, and if the product 6 * is an earned, unclaimed reward the line goes in free (base £0, extras charged). 7 * BOGOF adds an exact free copy. Buy-x with the reward among the bought products 8 * adds a free copy of the cheapest qualifying line automatically. 9 */ 10 11const OffersManager = { 12 13 init() { 14 if (typeof SITE_FEATURES === 'undefined' || !SITE_FEATURES.offers) return; 15 if (typeof Cart === 'undefined') return; 16 17 this.setupCartListener(); 18 this.checkUnlocks(); 19 20 const originalAdd = Cart.add.bind(Cart); 21 Cart.add = (productId, productName, basePrice, options = [], quantity = 1, cpn = 1) => { 22 const pid = parseInt(productId, 10); 23 let qty = Math.max(1, parseInt(quantity, 10) || 1); 24 25 // Earned free reward: the earned units go in free, the rest at full price 26 const reward = OffersManager.earnedRewardFor(pid); 27 if (reward) { 28 const freeUnits = Math.min(qty, reward.remaining); 29 for (let i = 0; i < freeUnits; i++) { 30 Cart.addFreeItem(pid, productName, parseFloat(basePrice) || 0, options || [], reward.offer.id); 31 } 32 OffersManager.showAddedFeedback(pid); 33 Cart.showNotification(`Added FREE ${productName} to cart!`); 34 qty -= freeUnits; 35 if (qty <= 0) return true; 36 } 37 38 const bogofOffer = OffersManager.getBogofOffer(pid); 39 if (bogofOffer) { 40 return Cart.addBogof(productId, productName, basePrice, options, qty, cpn, bogofOffer.id); 41 } 42 43 return originalAdd(productId, productName, basePrice, options, qty, cpn); 44 }; 45 }, 46 47 setupCartListener() { 48 const originalSet = Cart.set.bind(Cart); 49 Cart.set = (cart) => { 50 originalSet(cart); 51 Cart.enforceOfferRules();
52 this.checkBuyXGetFree(); 53 this.checkUnlocks(); 54 this.updateProgress(); 55 }; 56 }, 57 58 /* -------- helpers -------- */ 59 60 offers() { 61 return (typeof ACTIVE_OFFERS !== 'undefined' && Array.isArray(ACTIVE_OFFERS)) ? ACTIVE_OFFERS : []; 62 }, 63 64 inList(list, id) { 65 return (Array.isArray(list) ? list : []).map(Number).includes(Number(id)); 66 }, 67 68 claimedFor(offer, cart) { 69 return cart 70 .filter(i => i.is_free && i.offer_id === offer.id) 71 .reduce((s, i) => s + (parseInt(i.quantity, 10) || 1), 0); 72 }, 73 74 triggerQtyFor(offer, cart) { 75 const triggers = offer.triggerProducts || []; 76 return cart 77 .filter(i => !i.is_free && this.inList(triggers, i.product_id)) 78 .reduce((s, i) => s + (parseInt(i.quantity, 10) || 1), 0); 79 }, 80 81 perTrigger(offer) { 82 return Math.max(1, parseInt(offer.triggerQuantity, 10) || 1); 83 }, 84 85 buyXEarned(offer, cart) { 86 return Math.floor(this.triggerQtyFor(offer, cart) / this.perTrigger(offer)); 87 }, 88 89 /** 90 * Is this product an earned, unclaimed free reward right now? 91 * Returns { offer, remaining } or null. Used by the Cart.add wrapper and the modal. 92 */ 93 earnedRewardFor(productId) { 94 const pid = parseInt(productId, 10); 95 if (!pid) return null; 96 const cart = Cart.get(); 97 const subtotal = Cart.getPromoSubtotal(); 98 99 for (const offer of this.offers()) { 100 const claimed = this.claimedFor(offer, cart); 101 102 if (offer.type === 'free_on_spend') { 103 if (!this.inList(offer.eligibleProducts, pid)) continue; 104 if (subtotal < (parseFloat(offer.minSpend) || 0)) continue; 105 const remaining = (parseInt(offer.maxQuantity, 10) || 1) - claimed; 106 if (remaining > 0) return { offer, remaining }; 107 } else if (offer.type === 'buy_x_get_free') { 108 const triggers = offer.triggerProducts || []; 109 const rewards = (offer.rewardProducts || []).filter(id => !this.inList(triggers, id)); 110 if (!this.inList(rewards, pid)) continue; 111 const remaining = this.buyXEarned(offer, cart) - claimed; 112 if (remaining > 0) return { offer, remaining }; 113 } 114 } 115 return null; 116 }, 117 118 /** 119 * Badge and one-line note for the product modal when the product is in a 120 * BOGOF or is a buy-x trigger. Earned free rewards are handled separately. 121 */ 122 offerNoteFor(productId) { 123 const pid = parseInt(productId, 10); 124 if (!pid) return null; 125 126 const bogof = this.getBogofOffer(pid); 127 if (bogof) { 128 return { kind: 'bogof', badge: bogof.badge || '2 for 1', text: 'A second one is added free when you add this' }; 129 } 130 131 const cart = Cart.get(); 132 for (const offer of this.offers()) { 133 if (offer.type !== 'buy_x_get_free' || !this.inList(offer.triggerProducts, pid)) continue; 134 const per = this.perTrigger(offer); 135 const needed = per - (this.triggerQtyFor(offer, cart) % per); 136 const triggers = offer.triggerProducts || []; 137 const rewards = (offer.rewardProducts || []).filter(id => !this.inList(triggers, id)); 138 let what = 'the cheapest one free'; 139 if (rewards.length === 1) { 140 const card = document.getElementById(`offerCard_${rewards[0]}`); 141 what = `a ${(card && card.dataset.productName) || 'free item'} free`; 142 } else if (rewards.length > 1) { 143 what = 'a free item'; 144 } 145 return { kind: 'deal', badge: `Buy ${per} get 1 free`, text: `Buy ${needed} more to get ${what}` }; 146 } 147 return null; 148 }, 149 150 /* -------- free strip cards -------- */ 151 152 checkUnlocks() { 153 const cart = Cart.get(); 154 const subtotal = Cart.getPromoSubtotal(); 155
156 this.offers().forEach(offer => { 157 const claimed = this.claimedFor(offer, cart); 158 159 if (offer.type === 'free_on_spend') { 160 const minSpend = parseFloat(offer.minSpend) || 0; 161 const isUnlocked = subtotal >= minSpend; 162 const allClaimed = claimed >= (parseInt(offer.maxQuantity, 10) || 1); 163 const lockedText = `Spend £${Math.max(0, minSpend - subtotal).toFixed(2)} more to unlock`; 164 (offer.eligibleProducts || []).forEach(pid => { 165 this.updateCardState(pid, isUnlocked, allClaimed, lockedText); 166 }); 167 } else if (offer.type === 'buy_x_get_free') { 168 const triggers = offer.triggerProducts || []; 169 const rewards = (offer.rewardProducts || []).filter(id => !this.inList(triggers, id)); 170 if (!rewards.length) return; 171 const earned = this.buyXEarned(offer, cart); 172 const per = this.perTrigger(offer); 173 const needed = per - (this.triggerQtyFor(offer, cart) % per); 174 rewards.forEach(pid => { 175 const card = document.getElementById(`offerCard_${pid}`); 176 const name = (card && card.dataset.triggerName) ? ` ${card.dataset.triggerName}` : ''; 177 this.updateCardState(pid, earned > 0, claimed >= earned, `Buy ${needed} more${name} to unlock`); 178 }); 179 } 180 }); 181 }, 182 183 updateCardState(productId, isUnlocked, claimed, lockedText) { 184 const card = document.getElementById(`offerCard_${productId}`); 185 if (!card) return; 186 187 const requirement = card.querySelector('.offer-requirement'); 188 card.classList.remove('locked', 'unlocked', 'claimed'); 189 190 if (isUnlocked && claimed) { 191 card.classList.add('claimed'); 192 if (requirement) requirement.textContent = 'Free item in your cart'; 193 } else if (isUnlocked) { 194 card.classList.add('unlocked'); 195 if (requirement) requirement.textContent = 'Unlocked - add it free'; 196 } else { 197 card.classList.add('locked'); 198 if (requirement) requirement.textContent = lockedText || card.dataset.unlockSub || ''; 199 } 200 }, 201 202 updateProgress() { 203 Cart.updateOfferProgress(); 204 }, 205 206 /** 207 * Free strip card tapped (or its plus button). Products with options open the 208 * normal modal; the Cart.add wrapper turns the resulting line into a free one. 209 */ 210 addFreeItem(productId, offerId) { 211 const pid = parseInt(productId, 10); 212 const card = document.getElementById(`offerCard_${pid}`); 213 if (!card) return; 214 215 if (card.classList.contains('locked')) { 216 Cart.showNotification(card.dataset.unlockSub || 'Not unlocked yet', 'info'); 217 return; 218 } 219 if (card.classList.contains('claimed')) { 220 Cart.showNotification('Free item already in your cart', 'info'); 221 return; 222 } 223 if (!this.earnedRewardFor(pid)) { 224 this.checkUnlocks(); 225 return; 226 } 227 228 let product = null; 229 try { product = JSON.parse(card.dataset.product || 'null'); } catch (e) { product = null; } 230 if (!product) { 231 product = { id: pid, name: card.dataset.productName || '', price: card.dataset.productPrice || 0, options: [] }; 232 } 233 234 if (product.options && product.options.length && typeof ProductModal !== 'undefined') { 235 ProductModal.open(card); 236 return; 237 } 238 239 Cart.add(pid, product.name, parseFloat(product.price) || 0, [], 1, product.cpn ?? 1); 240 }, 241 242 showAddedFeedback(productId) { 243 const card = document.getElementById(`offerCard_${productId}`); 244 if (!card) return; 245 card.classList.add('added-flash'); 246 setTimeout(() => card.classList.remove('added-flash'), 600); 247 }, 248 249 /* -------- BOGOF -------- */ 250 251 getBogofOffer(productId) { 252 return this.offers().find(o => 253 o.type === 'bogof' && o.active && this.inList(o.products, productId) 254 ) || null; 255 }, 256 257 isBogofProduct(productId) { 258 return this.getBogofOffer(productId); 259 }, 260 261 isVariationBogof(variationId) { 262 return this.getBogofOffer(variationId); 263 }, 264 265 /* -------- buy-x automatic rewards -------- */ 266 267 checkBuyXGetFree() { 268 const cart = Cart.get(); 269 270 this.offers().filter(o => o.type === 'buy_x_get_free').forEach(offer => { 271 const triggers = offer.triggerProducts || []; 272 const rewardsAll = (offer.rewardProducts && offer.rewardProducts.length) ? offer.rewardProducts : triggers; 273 const earned = this.buyXEarned(offer, cart);
274 const claimed = this.claimedFor(offer, cart); 275 if (claimed >= earned) return; 276 277 const triggerRewards = rewardsAll.filter(id => this.inList(triggers, id)); 278 const otherRewards = rewardsAll.filter(id => !this.inList(triggers, id)); 279 280 // Reward is one of the bought products: free copy of the cheapest qualifying line 281 if (triggerRewards.length) { 282 if (!offer.cheapestFree) return; 283 const qualifying = cart.filter(i => !i.is_free && this.inList(triggerRewards, i.product_id)); 284 if (!qualifying.length) return; 285 const cheapest = qualifying.reduce((min, i) => i.total_price < min.total_price ? i : min); 286 Cart.addFreeItem(cheapest.product_id, cheapest.product_name, cheapest.base_price, cheapest.options || [], offer.id); 287 Cart.showNotification(`You got a FREE ${cheapest.product_name}!`); 288 return; 289 } 290 291 // Exactly one reward product with no choices to make: add it automatically. 292 // Anything else waits in the Free strip for the customer to choose. 293 if (otherRewards.length !== 1) return; 294 const card = document.getElementById(`offerCard_${otherRewards[0]}`); 295 if (!card) return; 296 let p = null; 297 try { p = JSON.parse(card.dataset.product || 'null'); } catch (e) { p = null; } 298 if (!p || (p.options && p.options.length)) return; 299 300 Cart.addFreeItem(p.id, p.name, parseFloat(p.price) || 0, [], offer.id); 301 this.showAddedFeedback(p.id); 302 Cart.showNotification(`You got a FREE ${p.name}!`); 303 }); 304 } 305}; 306 307document.addEventListener('DOMContentLoaded', () => { 308 OffersManager.init(); 309});
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.