PageSourceSearch

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

js cinnamon-brockley.co.uk collected 2026-10-08 20:40:35 UTC 12,952 bytes, 309 lines download raw bytes

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.