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 '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' 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.