PageSourceSearch

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

js cinnamon-brockley.co.uk collected 2026-10-08 20:40:32 UTC 61,477 bytes, 1,501 lines download raw bytes

1/**
2 * Product Modal System v2.0 (Modern Layout)
3 * Handles product detail modal with proper child view navigation
4 * Based on existing OptionsModal patterns
5 */
6
7const ProductModal = {
8    state: {
9        // Product info
10        productId: null,
11        productName: '',
12        basePrice: 0,
13        productData: null,
14        cpn: 1,
15        
16        // Variations
17        hasVariations: false,
18        variations: [],
19        selectedVariation: null,
20        parentProduct: null,
21        
22        // Options
23        productConfig: [],
24        allOptionGroups: [],
25        loadedStoreSlug: null,
26        visibleGroupIds: [],
27        initialGroupIds: [],
28        
29        // Selections (same structure as existing OptionsModal)
30        selections: {
31            main: {},    // { groupId: { optionId: quantity } }
32            child: {}    // { parentOptionId: { groupId: { optionId: quantity } } }
33        },
34        selectionOrder: [],  // Tracks insertion order: [{context, childKey, groupId, optionId}]
35        // UI state
36        quantity: 1,
37        totalPrice: 0,
38        modalInstance: null,
39        descriptionExpanded: false,
40        
41        // View management
42        currentView: 'main',  // 'variations' | 'main' | 'child'
43        childViewParentGroupId: null,
44        childViewParentOptionId: null,
45        childViewGroupIds: [],
46        mainViewScrollTop: 0,
47        scrollTimer: null,
48    },
49
50    /**
51     * Initialize modal instance
52     */
53    init() {
54        const modalEl = document.getElementById('productModal');
55        if (modalEl && !this.state.modalInstance) {
56            this.state.modalInstance = new bootstrap.Modal(modalEl);
57            
58            modalEl.addEventListener('hidden.bs.modal', () => {
59                this.resetState();
60            });
61
62            // Reset scroll once the modal is actually visible. The scrollTop = 0
63            // in updateModalUI() runs while the modal is still display:none, where
64            // the assignment is ignored — so it must also run post-show.
65            modalEl.addEventListener('shown.bs.modal', () => {
66                const optionsEl = document.getElementById('modalOptionsContainer');
67                if (optionsEl) optionsEl.scrollTop = 0;
68            });
69        }
70    },
71
72    /**
73     * Open modal from product card click
74     */
75    async open(cardElement) {
76        this.init();
77        
78        const isAvailable = cardElement.dataset.available === '1';
79        if (!isAvailable) return;
80        
81        const productData = JSON.parse(cardElement.dataset.product);
82        const hasVariations = cardElement.dataset.hasVariations === '1';
83        
84        this.resetState();
85        
86        this.state.productData = productData;
87        this.state.productName = productData.name;
88        this.state.hasVariations = hasVariations;
89        
90        if (hasVariations) {
91            this.state.variations = JSON.parse(cardElement.dataset.variations);
92            this.state.parentProduct = productData;
93            this.state.currentView = 'variations';
94            this.state.productId = null;
95            this.state.basePrice = 0;
96            this.state.productConfig = [];
97        } else {
98            this.state.productId = productData.id;
99            this.state.basePrice = parseFloat(productData.price) || 0;
100            this.state.productConfig = productData.options || [];
101            this.state.currentView = 'main';
102            this.state.cpn = productData.cpn ?? 1;
103            
104            // Set up initially visible groups
105            const initialGroupIds = this.state.productConfig
106                .filter(cfg => cfg.initially_visible)
107                .map(cfg => cfg.group_id);
108            this.state.initialGroupIds = initialGroupIds;
109            this.state.visibleGroupIds = [...initialGroupIds];
110        }
111        
112        await this.loadOptionGroups();
113        this.updateModalUI();
114        this.state.modalInstance.show();
115    },
116
117    /**
118     * Quick add - opens modal for products with options, direct add for simple
119     */
120    quickAdd(cardElement) {
121        const isAvailable = cardElement.dataset.available === '1';
122        if (!isAvailable) return;
123        
124        const productData = JSON.parse(cardElement.dataset.product);
125        const hasVariations = cardElement.dataset.hasVariations === '1';
126        const hasOptions = productData.options && productData.options.length > 0;
127        
128        if (hasVariations || hasOptions) {
129            this.open(cardElement);
130            return;
131        }
132        
133        // Simple product - add directly
134        Cart.add(
135            productData.id,
136            productData.name,
137            productData.price,
138            [],
139            1,
140            productData.cpn ?? 1
141        );
142        
143        // Show checkmark feedback
144        const btn = cardElement.querySelector('.menu-product-card__quick-add');
145        if (btn) {
146            btn.classList.add('added');
147            setTimeout(() => btn.classList.remove('added'), 1500);
148        }
149    },
150
151    /**
152     * Load option groups from API
153     */
154    async loadOptionGroups() {
155        const slug = window.STORE_SLUG || '';
156        // Already fetched for this store — reuse cached groups.
157        if (this.state.allOptionGroups.length > 0 && this.state.loadedStoreSlug === slug) {
158            return;
159        }
160        try {
161            const response = await fetch(`${BASE_PATH}/api/options.php${slug ? '?store=' + slug : ''}`);
162            if (!response.ok) throw new Error('Failed to load options');
163            this.state.allOptionGroups = await response.json();
164            this.state.loadedStoreSlug = slug;
165        } catch (error) {
166            console.error('Error loading options:', error);
167            this.state.allOptionGroups = [];
168        }
169    },
170
171    // ==========================================
172    // UI RENDERING
173    // ==========================================
174
175    updateModalUI() {
176        // Image
177        const imgEl = document.getElementById('modalProductImage');
178        const placeholderEl = document.querySelector('.product-modal__image-placeholder');
179        
180        if (this.state.productData.img) {
181            imgEl.src = `${this.state.productData.img}`;
182            imgEl.alt = this.state.productName;
183            imgEl.classList.remove('d-none');
184            placeholderEl.classList.add('d-none');
185        } else {
186            imgEl.classList.add('d-none');
187            placeholderEl.classList.remove('d-none');
188        }
189        
190        // Title
191        document.getElementById('modalProductTitle').textContent = this.state.productName;
192        
193        // Description
194        this.renderDescription();
195        
196        // Allergens
197        this.renderAllergens();
198        
199        // Render current view
200        this.renderCurrentView();
201        
202        // Update footer visibility
203        this.updateFooterVisibility();
204        
205        // Update price
206        this.updatePrice();
207        
208        // Reset quantity
209        this.state.quantity = 1;
210        document.getElementById('modalQtyValue').value = 1;
211        document.getElementById('modalQtyMinus').disabled = true;
212        const addBtnReset = document.getElementById('modalAddBtn');
213        if (addBtnReset) addBtnReset.disabled = false;
214        // Reset scroll position
215        document.getElementById('modalOptionsContainer').scrollTop = 0;
216    },
217
218    DESCRIPTION_MAX_LINES: 3,
219
220    renderDescription() {
221        const descEl = document.getElementById('modalProductDescription');
222        const description = (this.state.productData.dsc || '').trim();
223
224        if (!description) {
225            descEl.classList.add('d-none');
226            descEl.innerHTML = '';
227            return;
228        }
229
230        this.state.descriptionExpanded = false;
231        descEl.classList.remove('d-none');
232        this.renderDescriptionState();
233    },
234
235    /**
236     * Measure-based truncation. Runs before the modal is shown by forcing the
237     * hidden modal into layout (invisible), so the text is final on first paint.
238     * Only shows "more" if the text genuinely overflows DESCRIPTION_MAX_LINES,
239     * cuts at a word boundary, link stays inline after the ellipsis.
240     */
241    renderDescriptionState() {
242        const descEl = document.getElementById('modalProductDescription');
243        const modalEl = document.getElementById('productModal');
244        const full = (this.state.productData.dsc || '').trim();
245        const link = (label) =>
246            `<span class="product-modal__read-more" role="button" tabindex="0" onclick="ProductModal.toggleDescription()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();ProductModal.toggleDescription();}">${label}</span>`;
247
248        if (this.state.descriptionExpanded) {
249            descEl.innerHTML = `${this.escapeHtml(full)} ${link('less')}`;
250            return;
251        }
252
253        // Force layout on a not-yet-shown modal so we can measure at its real width
254        const wasHidden = !descEl.offsetParent;
255        if (wasHidden) {
256            modalEl.style.display = 'block';
257            modalEl.style.visibility = 'hidden';
258        }
259
260        descEl.textContent = full;
261
262        let lineHeight = parseFloat(getComputedStyle(descEl).lineHeight);
263        if (isNaN(lineHeight)) {
264            descEl.textContent = 'A';
265            lineHeight = descEl.clientHeight;
266            descEl.textContent = full;
267        }
268        const maxHeight = lineHeight * this.DESCRIPTION_MAX_LINES + 1;
269
270        if (descEl.scrollHeight > maxHeight) {
271            const render = (len) =>
272                descEl.innerHTML = `${this.escapeHtml(full.slice(0, len).trimEnd())}... ${link('more')}`;
273
274            // Longest prefix that fits with the inline link
275            let lo = 0, hi = full.length;
276            while (lo < hi) {
277                const mid = Math.ceil((lo + hi) / 2);
278                render(mid);
279                if (descEl.scrollHeight <= maxHeight) lo = mid; else hi = mid - 1;
280            }
281
282            // Back up to a word boundary unless that loses too much
283            let cut = lo;
284            const space = full.lastIndexOf(' ', cut);
285            if (space > cut * 0.6) cut = space;
286            render(cut);
287        }
288
289        if (wasHidden) {
290            modalEl.style.display = '';
291            modalEl.style.visibility = '';
292        }
293    },
294
295    renderAllergens() {
296        const allergensEl = document.getElementById('modalAllergens');
297        const allergenIds = this.state.productData.allergens || [];
298        
299        if (allergenIds.length > 0 && window.ALLERGENS) {
300            const allergenNames = allergenIds
301                .map(id => window.ALLERGENS[id] || id)
302                .filter(Boolean);
303            
304            if (allergenNames.length > 0) {
305                allergensEl.innerHTML = '<span class="allergens-label">Contains: </span>' + 
306                    allergenNames.map(name => 
307                        `<span class="product-modal__allergen">${this.escapeHtml(name)}</span>`
308                    ).join('');
309                allergensEl.classList.remove('d-none');
310                return;
311            }
312        }
313        
314        allergensEl.classList.add('d-none');
315    },
316
317    renderCurrentView() {
318        const container = document.getElementById('modalOptionsContainer');
319        container.innerHTML = '';
320        
321        if (this.state.currentView === 'variations') {
322            this.renderVariationsView(container);
323        } else if (this.state.currentView === 'main') {
324            this.renderMainView(container);
325        } else if (this.state.currentView === 'child') {
326            this.renderChildView(container);
327        }
328    },
329
330    updateFooterVisibility() {
331        const footer = document.querySelector('.product-modal__footer');
332        if (!footer) return;
333        
334        if (this.state.currentView === 'variations' || this.state.currentView === 'child') {
335            footer.classList.add('d-none');
336        } else {
337            footer.classList.remove('d-none');
338        }
339    },
340
341    // ==========================================
342    // VARIATIONS VIEW
343    // ==========================================
344
345    renderVariationsView(container) {
346        const header = document.createElement('div');
347        header.className = 'mb-3';
348        header.innerHTML = `<p class="text-muted">Choose a size to continue</p>`;
349        container.appendChild(header);
350        
351        const list = document.createElement('div');
352        list.className = 'variation-list';
353        
354        this.state.variations.forEach(variation => {
355            const hasOptions = variation.options && variation.options.length > 0;
356            const isOutOfStock = variation.stock === 0;
357            
358            // Check for BOGOF offer on this variation
359            const bogofOffer = typeof OffersManager !== 'undefined' 
360                ? OffersManager.getBogofOffer(variation.id) 
361                : null;
362            const bogofBadge = bogofOffer 
363                ? `<span class="badge bg-danger ms-2">${this.escapeHtml(bogofOffer.badge || '2 FOR 1')}</span>` 
364                : '';
365            
366            const item = document.createElement('div');
367            item.className = 'variation-item d-flex justify-content-between align-items-center py-3 border-bottom';
368            
369            let buttonHtml = '';
370            if (isOutOfStock) {
371                buttonHtml = `<button class="btn btn-secondary btn-sm" disabled>Out of Stock</button>`;
372            } else if (hasOptions) {
373                buttonHtml = `
374                    <button class="btn btn-primary btn-sm btn-select-variation" data-variation-id="${variation.id}">
375                        Choose<i class="bi bi-chevron-right ms-1"></i>
376                    </button>`;
377            } else {
378                buttonHtml = `
379                    <button class="btn btn-success btn-sm btn-add-simple-variation" data-variation-id="${variation.id}">
380                        Add<i class="bi bi-plus-lg ms-1"></i>
381                    </button>`;
382            }
383            
384            item.innerHTML = `
385                <div>
386                    <strong>${this.escapeHtml(variation.name)}</strong>${bogofBadge}
387                    <span class="text-primary ms-2">${this.priceHtml(variation.price, variation.cpn ?? 1)}</span>
388                    ${variation.dsc ? `<br><small class="text-muted">${this.escapeHtml(variation.dsc)}</small>` : ''}
389                </div>
390                ${buttonHtml}
391            `;
392            
393            list.appendChild(item);
394        });
395        
396        container.appendChild(list);
397        
398        // Event listeners
399        container.querySelectorAll('.btn-select-variation').forEach(btn => {
400            btn.addEventListener('click', () => this.selectVariation(parseInt(btn.dataset.variationId)));
401        });
402        
403        container.querySelectorAll('.btn-add-simple-variation').forEach(btn => {
404            btn.addEventListener('click', () => this.addSimpleVariation(parseInt(btn.dataset.variationId)));
405        });
406    },
407
408    selectVariation(variationId) {
409        const variation = this.state.variations.find(v => v.id === variationId);
410        if (!variation) return;
411        
412        this.state.selectedVariation = variation;
413        this.state.productId = variation.id;
414        this.state.productName = `${this.state.parentProduct.name} - ${variation.name}`;
415        this.state.basePrice = parseFloat(variation.price);
416        this.state.cpn = variation.cpn ?? 1;
417        
418        // Parse variation options config
419        let productConfig = [];
420        if (Array.isArray(variation.options)) {
421            productConfig = variation.options;
422        }
423        
424        this.state.productConfig = productConfig;
425        
426        const initialGroupIds = productConfig
427            .filter(cfg => cfg.initially_visible)
428            .map(cfg => cfg.group_id);
429        
430        this.state.initialGroupIds = initialGroupIds;
431        this.state.visibleGroupIds = [...initialGroupIds];
432        this.state.selections = { main: {}, child: {} };
433        
434        // Switch to main view
435        this.state.currentView = 'main';
436        
437        // Update title
438        document.getElementById('modalProductTitle').textContent = this.state.productName;
439        
440        this.renderCurrentView();
441        this.updateFooterVisibility();
442        this.updatePrice();
443        
444        // Scroll to top
445        document.getElementById('modalOptionsContainer').scrollTop = 0;
446    },
447
448    addSimpleVariation(variationId) {
449        const variation = this.state.variations.find(v => v.id === variationId);
450        if (!variation) return;
451        
452        const fullName = `${this.state.parentProduct.name} - ${variation.name}`;
453        
454        Cart.add(variation.id, fullName, variation.price, [], 1, variation.cpn ?? 1);
455        
456        this.state.modalInstance.hide();
457    },
458
459    // ==========================================
460    // MAIN VIEW
461    // ==========================================
462
463    renderMainView(container) {
464        // Back to variations button if applicable
465        if (this.state.hasVariations) {
466            const backBtn = document.createElement('div');
467            backBtn.className = 'mb-3';
468            backBtn.innerHTML = `
469                <button type="button" class="btn btn-outline-secondary btn-sm" id="backToVariationsBtn">
470                    <i class="bi bi-arrow-left me-1"></i>Back to sizes
471                </button>
472            `;
473            container.appendChild(backBtn);
474            document.getElementById('backToVariationsBtn')?.addEventListener('click', () => this.backToVariations());
475        }
476        
477        // Render visible option groups
478        this.state.visibleGroupIds.forEach(groupId => {
479            const group = this.state.allOptionGroups.find(g => g.id === groupId);
480            if (!group) return;
481            
482            const groupEl = this.renderOptionGroup(group, 'main', null);
483            container.appendChild(groupEl);
484        });
485    },
486
487    backToVariations() {
488        this.state.currentView = 'variations';
489        this.state.selectedVariation = null;
490        this.state.productId = null;
491        this.state.basePrice = 0;
492        this.state.productConfig = [];
493        this.state.selections = { main: {}, child: {} };
494        this.state.selectionOrder = [];
495        this.state.visibleGroupIds = [];
496        this.state.productName = this.state.parentProduct.name;
497        
498        document.getElementById('modalProductTitle').textContent = this.state.productName;
499        
500        this.renderCurrentView();
501        this.updateFooterVisibility();
502        this.updatePrice();
503    },
504
505    // ==========================================
506    // CHILD VIEW
507    // ==========================================
508
509    openChildView(parentGroupId, parentOptionId, groupIds) {
510        // Save scroll position before leaving main view
511        this.state.mainViewScrollTop = document.getElementById('modalOptionsContainer').scrollTop;
512        this.state.childViewParentGroupId = parentGroupId;
513        this.state.childViewParentOptionId = parentOptionId;
514        this.state.childViewGroupIds = groupIds;
515        this.state.currentView = 'child';
516        
517        // Animate transition
518        const container = document.getElementById('modalOptionsContainer');
519        container.classList.add('view-transitioning');
520        container.style.opacity = '0';
521        container.style.transform = 'translateX(1rem)';
522        
523        setTimeout(() => {
524            this.renderCurrentView();
525            this.updateFooterVisibility();
526            container.scrollTop = 0;
527            
528            requestAnimationFrame(() => {
529                container.style.transition = 'opacity 200ms ease, transform 200ms ease';
530                container.style.opacity = '1';
531                container.style.transform = 'translateX(0)';
532                
533                setTimeout(() => {
534                    container.classList.remove('view-transitioning');
535                    container.style.transition = '';
536                }, 200);
537            });
538        }, 50);
539    },
540
541    renderChildView(container) {
542        const parentOption = this.getParentOptionInfo();
543        
544        // Header with back button
545        const header = document.createElement('div');
546        header.className = 'mb-3 pb-3 border-bottom';
547        header.innerHTML = `
548            <button type="button" class="btn btn-outline-secondary btn-sm mb-2" id="childViewBackBtn">
549                <i class="bi bi-arrow-left me-1"></i>Back
550            </button>
551            <h5 class="mb-0">Options for ${this.escapeHtml(parentOption.name)}</h5>
552        `;
553        container.appendChild(header);
554        
555        document.getElementById('childViewBackBtn')?.addEventListener('click', () => this.cancelChildView());
556        
557        // Render child option groups
558        this.state.childViewGroupIds.forEach(groupId => {
559            const group = this.state.allOptionGroups.find(g => g.id === groupId);
560            if (!group) return;
561            
562            const groupEl = this.renderOptionGroup(group, 'child', this.state.childViewParentOptionId);
563            container.appendChild(groupEl);
564        });
565        
566        // Done button - sticky
567        const footer = document.createElement('div');
568        footer.className = 'child-view-footer';
569        footer.innerHTML = `
570            <button type="button" class="btn btn-primary w-100" id="childViewDoneBtn">
571                <i class="bi bi-check2 me-1"></i>Confirm
572            </button>
573        `;
574        container.appendChild(footer);
575        
576        document.getElementById('childViewDoneBtn')?.addEventListener('click', () => this.confirmChildView());
577    },
578
579    getParentOptionInfo() {
580        const group = this.state.allOptionGroups.find(g => g.id === this.state.childViewParentGroupId);
581        const option = group?.options.find(o => o.id === this.state.childViewParentOptionId);
582        return option || { name: 'Option' };
583    },
584
585    confirmChildView() {
586        // Validate child selections
587        const errors = this.validateChildSelections();
588        
589        if (errors.length > 0) {
590            this.displayValidationErrors(errors, 'child');
591            return;
592        }
593        
594        const parentGroupId = this.state.childViewParentGroupId;
595        
596        // Animate transition back to main
597        const container = document.getElementById('modalOptionsContainer');
598        container.style.opacity = '0';
599        container.style.transform = 'translateX(-1rem)';
600        
601        setTimeout(() => {
602            this.state.currentView = 'main';
603            this.renderCurrentView();
604            this.updateFooterVisibility();
605            this.updatePrice();
606            // Restore scroll position
607            document.getElementById('modalOptionsContainer').scrollTop = this.state.mainViewScrollTop;
608            
609            requestAnimationFrame(() => {
610                container.style.transition = 'opacity 200ms ease, transform 200ms ease';
611                container.style.opacity = '1';
612                container.style.transform = 'translateX(0)';
613                
614                setTimeout(() => {
615                    container.style.transition = '';
616                    // Scroll to next required section in main view
617                    this.autoScrollToNextRequired(parentGroupId);
618                }, 200);
619            });
620        }, 50);
621    },
622
623    cancelChildView() {
624        // Clear child selections for this parent option
625        const childKey = this.getChildKey(this.state.childViewParentGroupId, this.state.childViewParentOptionId);
626        this._removeChildKeyFromOrder(childKey);
627        delete this.state.selections.child[childKey];
628        
629        // Also deselect the parent option
630        const parentGroupId = this.state.childViewParentGroupId;
631        const parentOptionId = this.state.childViewParentOptionId;
632        
633        if (this.state.selections.main[parentGroupId]) {
634            this._removeFromOrder('main', parentGroupId, parentOptionId, null);
635            delete this.state.selections.main[parentGroupId][parentOptionId];
636        }
637        
638        // Animate transition back to main
639        const container = document.getElementById('modalOptionsContainer');
640        container.style.opacity = '0';
641        container.style.transform = 'translateX(-1rem)';
642        
643        setTimeout(() => {
644            this.state.currentView = 'main';
645            this.renderCurrentView();
646            this.updateFooterVisibility();
647            this.updatePrice();
648            
649            requestAnimationFrame(() => {
650                container.style.transition = 'opacity 200ms ease, transform 200ms ease';
651                container.style.opacity = '1';
652                container.style.transform = 'translateX(0)';
653                
654                setTimeout(() => {
655                    container.style.transition = '';
656                }, 200);
657            });
658        }, 50);
659    },
660
661    validateChildSelections() {
662        const errors = [];
663        const parentOptionId = this.state.childViewParentOptionId;
664        const childKey = this.getChildKey(this.state.childViewParentGroupId, parentOptionId);
665        
666        this.state.childViewGroupIds.forEach(groupId => {
667            const config = this.state.productConfig.find(c => c.group_id === groupId);
668            if (!config || config.min === 0) return;
669            
670            const selections = this.state.selections.child[childKey]?.[groupId] || {};
671            const count = Object.values(selections).reduce((sum, qty) => sum + qty, 0);
672            
673            if (count < config.min) {
674                const group = this.state.allOptionGroups.find(g => g.id === groupId);
675                errors.push({
676                    type: 'option',
677                    groupId: groupId,
678                    message: `Please select ${config.min === 1 ? 'an option' : config.min + ' options'} for ${group?.name || 'this group'}`
679                });
680            }
681        });
682        
683        return errors;
684    },
685
686    // ==========================================
687    // OPTION GROUP RENDERING
688    // ==========================================
689
690    renderOptionGroup(group, context, parentOptionId) {
691        const div = document.createElement('div');
692        div.className = 'option-group mb-4';
693        div.dataset.groupId = group.id;
694        div.dataset.context = context;
695        
696        const config = this.getGroupConfig(group.id);
697        const groupType = config && config.max === 1 ? 'single' : 'multiple';
698        const isRequired = config && config.min > 0;
699        
700        // Header
701        const header = document.createElement('div');
702        header.className = 'option-group__header';
703        header.innerHTML = `
704            <h4 class="option-group__title" id="groupTitle_${group.id}">${this.escapeHtml(group.name)}</h4>
705            <span class="option-group__badge${isRequired ? ' required' : ''}" id="groupBadge_${group.id}">
706                ${isRequired ? 'Required' : 'Optional'}
707            </span>
708        `;
709        div.appendChild(header);
710        
711        // Constraint hint
712        if (config && config.max !== 1) {
713            const hint = document.createElement('div');
714            hint.className = 'option-group__hint';
715            if (config.min > 0 && config.max > 0) {
716                hint.textContent = `Choose ${config.min}${config.max > config.min ? '-' + config.max : ''}`;
717            } else if (config.max > 0) {
718                hint.textContent = `Choose up to ${config.max}`;
719            } else if (config.max === 0) {
720                hint.textContent = 'Choose as many as you like';
721            }
722            div.appendChild(hint);
723        }
724        
725        // Options
726        const optionsContainer = document.createElement('div');
727        optionsContainer.className = 'option-buttons';
728        
729        group.options.forEach(option => {
730            const btn = this.renderOptionButton(group.id, option, context, parentOptionId, groupType);
731            optionsContainer.appendChild(btn);
732        });
733        
734        div.appendChild(optionsContainer);
735        
736        return div;
737    },
738
739    renderOptionButton(groupId, option, context, parentOptionId, groupType) {
740        const selections = this.getSelections(context, parentOptionId);
741        const quantity = selections[groupId]?.[option.id] || 0;
742        const isSelected = quantity > 0;
743        const price = this.getOptionPrice(option, groupId);
744        
745        // For multi-select with quantity > 0, wrap in container with decrement button
746        if (groupType === 'multiple' && isSelected) {
747            const wrapper = document.createElement('div');
748            wrapper.className = 'option-btn-wrapper';
749            
750            // Decrement button
751            const minusBtn = document.createElement('button');
752            minusBtn.type = 'button';
753            minusBtn.className = 'option-btn-minus';
754            minusBtn.innerHTML = '<i class="bi bi-dash"></i>';
755            minusBtn.addEventListener('click', (e) => {
756                e.stopPropagation();
757                this.handleMultipleSelect(groupId, option.id, 'decrement', context, parentOptionId);
758            });
759            
760            // Main button
761            const btn = document.createElement('button');
762            btn.type = 'button';
763            btn.className = 'option-btn selected';
764            btn.dataset.groupId = groupId;
765            btn.dataset.optionId = option.id;
766            
767            let priceHtml = price >
767 0 ? `<span class="option-btn__price">+£${price.toFixed(2)}</span>` : '';
768            btn.innerHTML = `<span class="option-btn__text">${this.escapeHtml(option.name)}</span>${priceHtml}`;
769            
770            btn.addEventListener('click', () => {
771                this.handleMultipleSelect(groupId, option.id, 'increment', context, parentOptionId);
772            });
773            
774            wrapper.appendChild(minusBtn);
775            wrapper.appendChild(btn);
776            
777            // Badge on wrapper (outside button overflow)
778            const qtyBadge = document.createElement('span');
779            qtyBadge.className = 'option-btn__qty';
780            qtyBadge.textContent = quantity;
781            wrapper.appendChild(qtyBadge);
782            
783            return wrapper;
784        }
785        
786        // Standard button (single select or multi-select with quantity 0)
787        const btn = document.createElement('button');
788        btn.type = 'button';
789        btn.className = `option-btn${isSelected ? ' selected' : ''}`;
790        btn.dataset.groupId = groupId;
791        btn.dataset.optionId = option.id;
792        btn.dataset.context = context;
793        btn.dataset.parentOptionId = parentOptionId || '';
794        
795        // Check for child selection count badge (only in main context)
796        let badge = '';
797        if (isSelected && context === 'main') {
798            const revealedGroupIds = this.getRevealedByForOption(groupId, option.id);
799            if (revealedGroupIds.length > 0) {
800                const childCount = this.getChildSelectionCount(groupId, option.id, revealedGroupIds);
801                if (childCount > 0) {
802                    badge = `<span class="option-btn__badge">✓ ${childCount}</span>`;
803                }
804            }
805        }
806        
807        let priceHtml = '';
808        if (price > 0) {
809            priceHtml = `<span class="option-btn__price">+£${price.toFixed(2)}</span>`;
810        }
811        
812        btn.innerHTML = `<span class="option-btn__text">${this.escapeHtml(option.name)}</span>${priceHtml}${badge}`;
813        
814        // Event listener
815        btn.addEventListener('click', () => {
816            if (groupType === 'single') {
817                this.handleSingleSelect(groupId, option.id, context, parentOptionId);
818            } else {
819                this.handleMultipleSelect(groupId, option.id, 'increment', context, parentOptionId);
820            }
821        });
822        
823        return btn;
824    },
825
826    getGroupConfig(groupId) {
827        return this.state.productConfig.find(cfg => cfg.group_id === groupId) || null;
828    },
829
830    getOptionPrice(option, groupId) {
831        const basePrice = parseFloat(option.price) || 0;
832        if (basePrice === 0) return 0;
833        const config = this.getGroupConfig(groupId);
834        const adjustment = config ? (parseFloat(config.price_adjustment) || 0) : 0;
835        return basePrice + adjustment;
836    },
837
838    getRevealedByForOption(groupId, optionId) {
839        const groupsToReveal = [];
840        const compositeKey = `${groupId}:${optionId}`;
841        
842        this.state.productConfig.forEach(cfg => {
843            if (cfg.revealed_by && Array.isArray(cfg.revealed_by)) {
844                // New format: "group_id:option_id" strings
845                if (cfg.revealed_by.includes(compositeKey)) {
846                    groupsToReveal.push(cfg.group_id);
847                }
848            }
849        });
850        return groupsToReveal;
851    },
852
853    getChildSelectionCount(parentGroupId, parentOptionId, groupIds) {
854        let count = 0;
855        const childKey = this.getChildKey(parentGroupId, parentOptionId);
856        if (this.state.selections.child[childKey]) {
857            groupIds.forEach(gId => {
858                if (this.state.selections.child[childKey][gId]) {
859                    count += Object.values(this.state.selections.child[childKey][gId]).reduce((a, b) => a + b, 0);
860                }
861            });
862        }
863        return count;
864    },
865
866    getSelections(context, parentOptionId) {
867        if (context === 'main') {
868            return this.state.selections.main;
869        } else {
870            const childKey = this.getChildKey(this.state.childViewParentGroupId, parentOptionId);
871            return this.state.selections.child[childKey] || {};
872        }
873    },
874
875    getChildKey(parentGroupId, parentOptionId) {
876        return parseInt(parentGroupId) + '_' + parseInt(parentOptionId);
877    },
878
879    escapeHtml(value) {
880        return String(value ?? '').replace(/[&<>"']/g, ch => ({
881            '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
882        }[ch]));
883    },
884
885    setSelection(context, parentOptionId, groupId, optionId, quantity) {
886        if (context === 'main') {
887            if (!this.state.selections.main[groupId]) {
888                this.state.selections.main[groupId] = {};
889            }
890            if (quantity === 0) {
891                delete this.state.selections.main[groupId][optionId];
892                this._removeFromOrder('main', groupId, optionId, null);
893            } else {
894                this.state.selections.main[groupId][optionId] = quantity;
895                this._addToOrder('main', groupId, optionId, null);
896            }
897        } else {
898            const childKey = this.getChildKey(this.state.childViewParentGroupId, parentOptionId);
899            if (!this.state.selections.child[childKey]) {
900                this.state.selections.child[childKey] = {};
901            }
902            if (!this.state.selections.child[childKey][groupId]) {
903                this.state.selections.child[childKey][groupId] = {};
904            }
905            if (quantity === 0) {
906                delete this.state.selections.child[childKey][groupId][optionId];
907                this._removeFromOrder('child', groupId, optionId, childKey);
908            } else {
909                this.state.selections.child[childKey][groupId][optionId] = quantity;
910                this._addToOrder('child', groupId, optionId, childKey);
911            }
912        }
913    },
914
915    _addToOrder(context, groupId, optionId, childKey) {
916        const exists = this.state.selectionOrder.some(e =>
917            e.context === context && e.groupId === groupId && 
918            e.optionId === optionId && e.childKey === (childKey || null)
919        );
920        if (!exists) {
921            this.state.selectionOrder.push({
922                context, groupId: parseInt(groupId), 
923                optionId: parseInt(optionId), childKey: childKey || null
924            });
925        }
926    },
927
928    _removeFromOrder(context, groupId, optionId, childKey) {
929        this.state.selectionOrder = this.state.selectionOrder.filter(e =>
930            !(e.context === context && e.groupId === parseInt(groupId) && 
931              e.optionId === parseInt(optionId) && e.childKey === (childKey || null))
932        );
933    },
934
935    _removeGroupFromOrder(context, groupId, childKey) {
936        this.state.selectionOrder = this.state.selectionOrder.filter(e =>
937            !(e.context === context && e.groupId === parseInt(groupId) && 
938              e.childKey === (childKey || null))
939        );
940    },
941
942    _removeChildKeyFromOrder(childKey) {
943        this.state.selectionOrder = this.state.selectionOrder.filter(e =>
944            !(e.childKey === childKey)
945        );
946    },
947
948    // ==========================================
949    // SELECTION HANDLERS
950    // ==========================================
951
952    handleSingleSelect(groupId, optionId, context, parentOptionId) {
953        const selections = this.getSelections(context, parentOptionId);
954        
955        // Get previously selected option in this group
956        const oldOptionId = Object.keys(selections[groupId] || {})[0];
957        const oldOptionIdInt = oldOptionId ? parseInt(oldOptionId) : null;
958        
959        // Clear existing selection in group
960        if (context === 'main') {
961            // Clear child selections for old option
962            if (oldOptionIdInt && oldOptionIdInt !== optionId) {
963                const oldChildKey = this.getChildKey(groupId, oldOptionIdInt);
964                delete this.state.selections.child[oldChildKey];
965            }
966            if (this.state.selections.main[groupId]) {
967                this._removeGroupFromOrder('main', groupId, null);
968                this.state.selections.main[groupId] = {};
969            }
970        } else {
971            const childKey = this.getChildKey(this.state.childViewParentGroupId, parentOptionId);
972            if (this.state.selections.child[childKey]?.[groupId]) {
973                this._removeGroupFromOrder('child', groupId, childKey);
974                this.state.selections.child[childKey][groupId] = {};
975            }
976        }
977        
978        // Set new selection
979        this.setSelection(context, parentOptionId, groupId, optionId, 1);
980        
981        // Check for revealed groups (child view)
982        if (context === 'main') {
983            const revealedGroupIds = this.getRevealedByForOption(groupId, optionId);
984            
985            if (revealedGroupIds.length > 0) {
986                // Open child view
987                this.openChildView(groupId, optionId, revealedGroupIds);
988                return;
989            }
990        }
991        
992        // Re-render and update
993        this.clearGroupError(groupId);
994        this.renderCurrentView();
995        this.updatePrice();
996        
997        // Auto-scroll to next required (works for both main and child)
998        if (context === 'main') {
999            this.autoScrollToNextRequired(groupId);
1000        } else {
1001            this.autoScrollToNextRequiredChild(groupId);
1002        }
1003    },
1004
1005    handleMultipleSelect(groupId, optionId, action, context, parentOptionId) {
1006        const selections = this.getSelections(context, parentOptionId);
1007        const currentQty = selections[groupId]?.[optionId] || 0;
1008        const config = this.getGroupConfig(groupId);
1009        const maxAllowed = config?.max || 999;
1010        
1011        // Count total selections in group
1012        const totalInGroup = Object.values(selections[groupId] || {}).reduce((sum, qty) => sum + qty, 0);
1013        
1014        let newQty = currentQty;
1015        
1016        if (action === 'increment') {
1017            if (maxAllowed === 0 || totalInGroup < maxAllowed) {
1018                newQty = currentQty + 1;
1019            }
1020        } else if (action === 'decrement') {
1021            newQty = Math.max(0, currentQty - 1);
1022        }
1023        
1024        this.setSelection(context, parentOptionId, groupId, optionId, newQty);
1025        
1026        this.clearGroupError(groupId);
1027        this.renderCurrentView();
1028        this.updatePrice();
1029        
1030        // Auto-scroll to next required
1031        if (action === 'increment') {
1032            if (context === 'main') {
1033                this.autoScrollToNextRequired(groupId);
1034            } else {
1035                this.autoScrollToNextRequiredChild(groupId);
1036            }
1037        }
1038    },
1039
1040    autoScrollToNextRequired(justCompletedGroupId) {
1041        // Only advance once this group is "full" (its max is reached).
1042        // Single-select (max 1) advances on the first pick; a min-2/max-2 group
1043        // waits until 2 are chosen. Unlimited groups (max 0) never auto-advance.
1044        const config = this.getGroupConfig(parseInt(justCompletedGroupId));
1045        const max = config ? (config.max || 0) : 0;
1046        if (max === 0) return;
1047        const groupSel = this.state.selections.main[parseInt(justCompletedGroupId)] || {};
1048        const countInGroup = Object.values(groupSel).reduce((sum, qty) => sum + qty, 0);
1049        if (countInGroup < max) return;
1050
1051        clearTimeout(this.state.scrollTimer);
1052        this.state.scrollTimer = setTimeout(() => {
1053            const container = document.getElementById('modalOptionsContainer');
1054            if (!container) return;
1055            
1056            const allGroups = container.querySelectorAll('.option-group');
1057            let foundCurrent = false;
1058            
1059            for (const groupEl of allGroups) {
1060                const gId = parseInt(groupEl.dataset.groupId);
1061                
1062                if (gId === parseInt(justCompletedGroupId)) {
1063                    foundCurrent = true;
1064                    continue;
1065                }
1066                
1067                if (!foundCurrent) continue;
1068                
1069                // Scroll to next section (required or optional)
1070                const containerRect = container.getBoundingClientRect();
1071                const groupRect = groupEl.getBoundingClientRect();
1072                const scrollTop = container.scrollTop + (groupRect.top - containerRect.top) - 20;
1073                container.scrollTo({ top: scrollTop, behavior: 'smooth' });
1074                break;
1075            }
1076        }, 150);
1077    },
1078
1079    autoScrollToNextRequiredChild(justCompletedGroupId) {
1080        clearTimeout(this.state.scrollTimer);
1081        this.state.scrollTimer = setTimeout(() => {
1082            const config = this.getGroupConfig(parseInt(justCompletedGroupId));
1083            if (!config) return;
1084            
1085            const parentOptionId = this.state.childViewParentOptionId;
1086            const childKey = this.getChildKey(this.state.childViewParentGroupId, parentOptionId);
1087            const currentSelections = Object.values(this.state.selections.child[childKey]?.[justCompletedGroupId] || {}).reduce((s, q) => s + q, 0);
1088            if (config.min > 0 && currentSelections < config.min) return;
1089            
1090            const allGroups = document.querySelectorAll('#modalOptionsContainer .option-group');
1091            let foundCurrent = false;
1092            
1093            for (const groupEl of allGroups) {
1094                const gId = parseInt(groupEl.dataset.groupId);
1095                
1096                if (gId === parseInt(justCompletedGroupId)) {
1097                    foundCurrent = true;
1098                    continue;
1099                }
1100                
1101                if (!foundCurrent) continue;
1102                
1103                // Scroll to next group regardless of required status
1104                groupEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
1105                break;
1106            }
1107        }, 300);
1108    },
1109
1110    // ==========================================
1111    // VALIDATION
1112    // ==========================================
1113
1114    validate() {
1115        const errors = [];
1116        
1117        // Check variation selected
1118        if (this.state.hasVariations && !this.state.selectedVariation) {
1119            errors.push({ type: 'variation', message: 'Please select a size' });
1120        }
1121        
1122        // Check main option groups
1123        this.state.visibleGroupIds.forEach(groupId => {
1124            const config = this.getGroupConfig(groupId);
1125            if (!config || config.min === 0) return;
1126            
1127            const selections = this.state.selections.main[groupId] || {};
1128            const count = Object.values(selections).reduce((sum, qty) => sum + qty, 0);
1129            
1130            if (count < config.min) {
1131                const group = this.state.allOptionGroups.find(g => g.id === groupId);
1132                errors.push({
1133                    type: 'option',
1134                    groupId: groupId,
1135                    message: `Please select ${config.min === 1 ? 'an option' : config.min + ' options'} for ${group?.name || 'this group'}`
1136                });
1137            }
1138        });
1139        
1140        // Check child selections for selected options with revealed_by groups
1141        Object.keys(this.state.selections.main).forEach(groupId => {
1142            Object.keys(this.state.selections.main[groupId]).forEach(optionId => {
1143                const revealedGroupIds = this.getRevealedByForOption(parseInt(groupId), parseInt(optionId))
1144                
1145                revealedGroupIds.forEach(revealedGroupId => {
1146                    const config = this.state.productConfig.find(c => c.group_id === revealedGroupId);
1147                    if (!config || config.min === 0) return;
1148                    
1149                    const childKey = this.getChildKey(groupId, optionId);
1150                    const childSelections = this.state.selections.child[childKey]?.[revealedGroupId] || {};
1151                    const count = Object.values(childSelections).reduce((sum, qty) => sum + qty, 0);
1152                    
1153                    if (count < config.min) {
1154                        const group = this.state.allOptionGroups.find(g => g.id === revealedGroupId);
1155                        const parentGroup = this.state.allOptionGroups.find(g => g.id === parseInt(groupId));
1156                        const parentOption = parentGroup?.options.find(o => o.id === parseInt(optionId));
1157                        
1158                        errors.push({
1159                            type: 'child',
1160                            groupId: revealedGroupId,
1161                            parentOptionId: optionId,
1162                            message: `Please complete options for ${parentOption?.name || 'your selection'}`
1163                        });
1164                    }
1165                });
1166            });
1167        });
1168        
1169        return errors;
1170    },
1171
1172    displayValidationErrors(errors, context = 'main') {
1173        this.clearValidationErrors();
1174        
1175        errors.forEach(error => {
1176            if (error.type === 'variation') {
1177                // Highlight variation section
1178            } else if (error.type === 'option' || error.type === 'child') {
1179                const title = document.getElementById(`groupTitle_${error.groupId}`);
1180                const badge = document.getElementById(`groupBadge_${error.groupId}`);
1181                if (title) title.classList.add('has-error');
1182                if (badge) badge.classList.add('has-error');
1183            }
1184        });
1185        
1186        // Scroll to first error
1187        const firstError = document.querySelector('.has-error');
1188        if (firstError) {
1189            firstError.scrollIntoView({ behavior: 'smooth', block: 'center' });
1190        }
1191    },
1192
1193    clearValidationErrors() {
1194        document.querySelectorAll('.has-error').forEach(el => el.classList.remove('has-error'));
1195    },
1196
1197    clearGroupError(groupId) {
1198        const title = document.getElementById(`groupTitle_${groupId}`);
1199        const badge = document.getElementById(`groupBadge_${groupId}`);
1200        if (title) title.classList.remove('has-error');
1201        if (badge) badge.classList.remove('has-error');
1202    },
1203
1204    // ==========================================
1205    // PRICE & QUANTITY
1206    // ==========================================
1207
1208    updatePrice() {
1209        let total = this.state.basePrice;
1210        
1211        // Main selections
1212        Object.entries(this.state.selections.main).forEach(([groupId, options]) => {
1213            const group = this.state.allOptionGroups.find(g => g.id === parseInt(groupId));
1214            if (!group) return;
1215            
1216            Object.entries(options).forEach(([optionId, quantity]) => {
1217                const option = group.options.find(o => o.id === parseInt(optionId));
1218                if (option) {
1219                    total += this.getOptionPrice(option, parseInt(groupId)) * quantity;
1220                }
1221            });
1222        });
1223        
1224        // Child selections
1225        Object.entries(this.state.selections.child).forEach(([childKey, groups]) => {
1226            Object.entries(groups).forEach(([groupId, options]) => {
1227                const group = this.state.allOptionGroups.find(g => g.id === parseInt(groupId));
1228                if (!group) return;
1229                
1230                Object.entries(options).forEach(([optionId, quantity]) => {
1231                    const option = group.options.find(o => o.id === parseInt(optionId));
1232                    if (option) {
1233                        total += this.getOptionPrice(option, parseInt(groupId)) * quantity;
1234                    }
1235                });
1236            });
1237        });
1238        
1239        this.state.totalPrice = total * this.state.quantity;
1240
1241        const btnText = document.querySelector('#modalAddBtn .product-modal__add-btn-text');
1242        const reward = this.getFreeReward();
1243
1244        if (reward) {
1245            // Earned free reward: base free on the earned units, priced extras still charged
1246            const freeUnits = Math.min(this.state.quantity, reward.remaining);
1247            const paidUnits = this.state.quantity - freeUnits;
1248            const extras = Math.max(0, total - this.state.basePrice);
1249            const shown = (extras * freeUnits) + (total * paidUnits);
1250            document.getElementById('modalTotalPrice').textContent = `£${shown.toFixed(2)}`;
1251            if (btnText) btnText.textContent = paidUnits > 0 ? 'Add to Cart' : 'Add free';
1252            this.renderFreeNote(reward, freeUnits, paidUnits);
1253        } else {
1254            // Button shows the discounted line total when a percent promo is active
1255            const discountedTotal = this.getDiscountedPrice(this.state.totalPrice, this.state.cpn);
1256            document.getElementById('modalTotalPrice').textContent =
1257                `£${(discountedTotal !== null ? discountedTotal : this.state.totalPrice).toFixed(2)}`;
1258            if (btnText) btnText.textContent = 'Add to Cart';
1259            const note = (typeof OffersManager !== 'undefined' && typeof OffersManager.offerNoteFor === 'function'
1260                && this.state.productId && this.state.currentView !== 'variations')
1261                ? OffersManager.offerNoteFor(parseInt(this.state.productId, 10)) : null;
1262            this.renderOfferNote(note);
1263        }
1264
1265        this.renderBasePrice(reward);
1266    },
1267
1268    /**
1269     * Earned, unclaimed free reward for the product on screen (free-on-spend or
1270     * buy-x reward). Evaluated live from the cart, so it switches off once claimed.
1271     */
1272    getFreeReward() {
1273        if (typeof OffersManager === 'undefined' || typeof OffersManager.earnedRewardFor !== 'function') return null;
1274        if (!this.state.productId || this.state.currentView === 'variations') return null;
1275        return OffersManager.earnedRewardFor(parseInt(this.state.productId, 10));
1276    },
1277
1278    renderOfferNote(note) {
1279        let el = document.getElementById('modalFreeNote');
1280        if (!el) {
1281            const title = document.getElementById('modalProductTitle');
1282            if (!title) return;
1283            el = document.createElement('div');
1284            el.id = 'modalFreeNote';
1285            el.className = 'product-modal__free-note d-none';
1286            title.insertAdjacentElement('afterend', el);
1287        }
1288        if (!note) {
1289            el.className = 'product-modal__free-note d-none';
1290            el.innerHTML = '';
1291            return;
1292        }
1293        const badge = note.badge ? `<span class="badge me-2">${this.escapeHtml(note.badge)}</span>` : '<i class="bi bi-gift-fill me-1"></i>';
1294        el.innerHTML = `${badge}${this.escapeHtml(note.text)}`;
1295        el.className = `product-modal__free-note product-modal__free-note--${note.kind || 'free'}`;
1296    },
1297
1298    renderFreeNote(reward, freeUnits = 0, paidUnits = 0) {
1299        if (!reward) { this.renderOfferNote(null); return; }
1300        const text = paidUnits > 0
1301            ? `${freeUnits} free with your order, ${paidUnits} at full price. Priced extras are still charged.`
1302            : 'Free with your order. Priced extras are still charged.';
1303        this.renderOfferNote({ kind: 'free', badge: 'Free', text });
1304    },
1305
1306    /**
1307     * Price line under the title - strike + discounted when a promo applies.
1308     * Hidden on the variations list (each row shows its own price).
1309     */
1310    renderBasePrice(reward = null) {
1311        const el = document.getElementById('modalBasePrice');
1312        if (!el) return;
1313
1314        if (this.state.currentView === 'variations' || !this.state.basePrice) {
1315            el.classList.add('d-none');
1316            el.innerHTML = '';
1317            return;
1318        }
1319
1320        if (reward) {
1321            el.innerHTML = `<s class="text-muted">£${this.state.basePrice.toFixed(2)}</s> <span class="text-success">Free</span>`;
1322            el.classList.remove('d-none');
1323            return;
1324        }
1325
1326        const hasOptions = this.state.productConfig && this.state.productConfig.length > 0;
1327        const fromHtml = hasOptions ? '<span class="menu-product-card__price-from">From</span> ' : '';
1328        el.innerHTML = fromHtml + this.priceHtml(this.state.basePrice, this.state.cpn);
1329        el.classList.remove('d-none');
1330    },
1331
1332    getDiscountedPrice(price, cpn = 1) {
1333        if (typeof PromoManager === 'undefined') return null;
1334        return PromoManager.getDiscountedPrice(price, cpn);
1335    },
1336
1337    priceHtml(price, cpn = 1) {
1338        if (typeof PromoManager === 'undefined') return `£${parseFloat(price).toFixed(2)}`;
1339        return PromoManager.priceHtml(price, cpn);
1340    },
1341
1342    toggleDescription() {
1343        this.state.descriptionExpanded = !this.state.descriptionExpanded;
1344        this.renderDescriptionState();
1345    },
1346
1347    incrementQty() {
1348        this.state.quantity++;
1349        document.getElementById('modalQtyValue').value = this.state.quantity;
1350        document.getElementById('modalQtyMinus').disabled = false;
1351        this.updatePrice();
1352    },
1353
1354    decrementQty() {
1355        if (this.state.quantity > 1) {
1356            this.state.quantity--;
1357            document.getElementById('modalQtyValue').value = this.state.quantity;
1358            document.getElementById('modalQtyMinus').disabled = this.state.quantity === 1;
1359            this.updatePrice();
1360        }
1361    },
1362
1363    // ==========================================
1364    // ADD TO CART
1365    // ==========================================
1366
1367    buildOptionsArray() {
1368        const optionsArray = [];
1369
1370        // Emit in the order shown in the modal: main groups top-to-bottom
1371        // (visibleGroupIds), options in their group's display order, and each
1372        // selected option's child selections nested directly after it.
1373        this.state.visibleGroupIds.forEach(groupId => {
1374            const group = this.state.allOptionGroups.find(g => g.id === groupId);
1375            if (!group) return;
1376
1377            const mainSel = this.state.selections.main[groupId] || {};
1378
1379            group.options.forEach(option => {
1380                const quantity = mainSel[option.id];
1381                if (!quantity) return;
1382
1383                optionsArray.push({
1384                    option_id: option.id,
1385                    option_name: option.name,
1386                    option_price: this.getOptionPrice(option, groupId),
1387                    quantity: quantity,
1388                    group_id: groupId,
1389                    group_name: group.name
1390                });
1391
1392                // Nested child selections for this option, in display order
1393                const revealedGroupIds = this.getRevealedByForOption(groupId, option.id);
1394                if (revealedGroupIds.length === 0) return;
1395
1396                const childKey = this.getChildKey(groupId, option.id);
1397                const childSel = this.state.selections.child[childKey] || {};
1398
1399                revealedGroupIds.forEach(childGroupId => {
1400                    const childGroup = this.state.allOptionGroups.find(g => g.id === childGroupId);
1401                    if (!childGroup) return;
1402
1403                    const childGroupSel = childSel[childGroupId] || {};
1404
1405                    childGroup.options.forEach(childOption => {
1406                        const childQty = childGroupSel[childOption.id];
1407                        if (!childQty) return;
1408
1409                        optionsArray.push({
1410                            option_id: childOption.id,
1411                            option_name: childOption.name,
1412                            option_price: this.getOptionPrice(childOption, childGroupId),
1413                            quantity: childQty,
1414                            group_id: childGroupId,
1415                            group_name: childGroup.name,
1416                            parent_option_id: option.id,
1417                            parent_option_name: option.name,
1418                            parent_group_id: groupId,
1419                            parent_group_name: group.name
1420                        });
1421                    });
1422                });
1423            });
1424        });
1425
1426        return optionsArray;
1427    },
1428
1429    addToCart() {
1430        const errors = this.validate();
1431        
1432        if (errors.length > 0) {
1433            this.displayValidationErrors(errors);
1434            return;
1435        }
1436        
1437        const options = this.buildOptionsArray();
1438
1439        // Guard against a fast double-tap adding twice (hide() is async).
1440        const addBtn = document.getElementById('modalAddBtn');
1441        if (addBtn) addBtn.disabled = true;
1442
1443        Cart.add(
1444            this.state.productId,
1445            this.state.productName,
1446            this.state.basePrice,
1447            options,
1448            this.state.quantity,
1449            this.state.cpn
1450        );
1451        
1452        this.state.modalInstance.hide();
1453    },
1454
1455    // ==========================================
1456    // STATE MANAGEMENT
1457    // ==========================================
1458
1459    resetState() {
1460        this.state.productId = null;
1461        this.state.productName = '';
1462        this.state.basePrice = 0;
1463        this.state.productData = null;
1464        this.state.cpn = 1;
1465        this.state.hasVariations = false;
1466        this.state.variations = [];
1467        this.state.selectedVariation = null;
1468        this.state.parentProduct = null;
1469        this.state.productConfig = [];
1470        this.state.visibleGroupIds = [];
1471        this.state.initialGroupIds = [];
1472        this.state.selections = { main: {}, child: {} };
1473        this.state.selectionOrder = [];
1474        this.state.quantity = 1;
1475        this.state.totalPrice = 0;
1476        this.state.descriptionExpanded = false;
1477        this.state.currentView = 'main';
1478        this.state.childViewParentGroupId = null;
1479        this.state.childViewParentOptionId = null;
1480        this.state.childViewGroupIds = [];
1481        clearTimeout(this.state.scrollTimer);
1482        this.state.scrollTimer = null;
1483        
1484        const optionsEl = document.getElementById('modalOptionsContainer');
1485        optionsEl.innerHTML = '';
1486        // Clear any inline styles left behind mid-transition so a reopened modal
1487        // never shows an invisible or shifted options area.
1488        optionsEl.style.opacity = '';
1489        optionsEl.style.transform = '';
1490        optionsEl.style.transition = '';
1491        optionsEl.classList.remove('view-transitioning');
1492        this.clearValidationErrors();
1493    }
1494};
1495
1496// Initialize when DOM ready
1497document.addEventListener('DOMContentLoaded', () => {
1498    ProductModal.init();
1499});
1500
1501window.ProductModal = ProductModal;

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.