UNPKG

@salla.sa/twilight-components

Version:
602 lines (598 loc) 29.2 kB
/*! * Crafted with ❤ by Salla */ import { proxyCustomElement, HTMLElement, createEvent, h, Host } from '@stencil/core/internal/client'; import { d as defineCustomElement$d } from './salla-booking-field2.js'; import { d as defineCustomElement$c } from './salla-button2.js'; import { d as defineCustomElement$b } from './salla-color-picker2.js'; import { d as defineCustomElement$a } from './salla-conditional-fields2.js'; import { d as defineCustomElement$9 } from './salla-datetime-picker2.js'; import { d as defineCustomElement$8 } from './salla-file-upload2.js'; import { d as defineCustomElement$7 } from './salla-loading2.js'; import { d as defineCustomElement$6 } from './salla-map2.js'; import { d as defineCustomElement$5 } from './salla-modal2.js'; import { d as defineCustomElement$4 } from './salla-product-options2.js'; import { d as defineCustomElement$3 } from './salla-progress-bar2.js'; import { d as defineCustomElement$2 } from './salla-skeleton2.js'; import { d as defineCustomElement$1 } from './salla-slider2.js'; const sallaMultipleBundleProductOptionsModalCss = ":host{display:block}"; const SallaMultipleBundleProductOptionsModal = /*@__PURE__*/ proxyCustomElement(class SallaMultipleBundleProductOptionsModal extends HTMLElement { constructor() { super(); this.__registerHost(); this.__attachShadow(); this.optionsSaved = createEvent(this, "optionsSaved", 7); this.productSelected = createEvent(this, "productSelected", 7); this.product = null; this.sectionId = null; this.sectionIndex = 0; this.productIndex = 0; this.selectedOptions = {}; this.optionsResetTokens = {}; this.isLoading = false; this.hasUnsavedChanges = false; this.validationErrors = []; } /** * Generate a unique cache key for selected options using section ID, product index, and product ID */ generateCacheKey(sectionId, productIndex, productId) { return `${sectionId || 'unknown'}-${productIndex || 0}-${productId || 'unknown'}`; } handleProductChange(newValue) { // Use setTimeout to ensure modal is ready setTimeout(() => { if (this.modal && newValue) { const title = newValue.name || ''; this.modal.setTitle(title); } }, 100); // Reset validation errors when product changes this.validationErrors = []; this.hasUnsavedChanges = false; } /** Opens the modal manually. */ async open() { if (!this.modal) { requestAnimationFrame(() => this.open()); return; } this.isLoading = true; // Set the title before opening if (this.product?.name) { this.modal.setTitle(this.product.name); } this.modal.open(); // Initialize selectedOptions with current selections from the component setTimeout(async () => { if (this.product?.id) { await this.initializeSelectedOptions(); } // Set title again after modal is fully loaded if (this.product?.name) { this.modal.setTitle(this.product.name); } this.modal.stopLoading(); this.isLoading = false; }, 300); } /** Closes the modal manually. */ async close() { if (this.modal) { this.modal.close(); } } /** Refreshes the internal options tracking state manually. */ async refreshOptionsState() { // Force re-render by updating the component state this.selectedOptions = { ...this.selectedOptions }; } componentDidLoad() { this.modalOpenListener = (data) => { this.product = data.product; this.sectionId = data.sectionId || null; this.sectionIndex = data.sectionIndex || 0; this.productIndex = data.productIndex || 0; this.open(); }; salla.event.on('multiple-bundle-product-modal::open', this.modalOpenListener); // Listen for clear-options event when a product is deselected this.clearOptionsListener = (data) => { if (!data || !data.productId) return; this.clearProductOptions(data.productId, data.sectionId, data.productIndex); }; salla.event.on('multiple-bundle-product-modal::clear-options', this.clearOptionsListener); // Create and store the option change listener for proper cleanup this.optionChangeListener = (e) => { const data = e.detail || e; const { option, detail } = data; // If data is a detail object (has option_id), find the option from product const actualOption = option || (data.option_id && this.product?.options?.find(opt => opt.id === data.option_id || String(opt.id) === String(data.option_id))); const actualDetail = detail || (data.id ? data : null); if (this.product?.id && actualOption) { this.handleOptionChange(Number(this.product.id), actualOption, actualDetail); } }; salla.event.on('product-options::change', this.optionChangeListener); // Create and store the checkbox change listener for proper cleanup this.checkboxChangeListener = (e) => { const target = e.target; // Check if this is a product selection checkbox if (target && target.type === 'checkbox' && target.name && target.name.includes('bundle[') && target.name.includes('][id]')) { // Extract section info from the checkbox name: bundle[sectionId][productIndex][id] const nameMatch = target.name.match(/^bundle\[([^\]]+)\]\[([^\]]+)\]\[id\]$/); if (nameMatch && !target.checked) { const [, sectionId, productIndex] = nameMatch; const productId = target.value; const form = this.host.closest('form'); this.cleanupProductDeselection({ sectionId, productIndex: parseInt(productIndex, 10), productId, form, uncheckedInput: target, }); } } }; // Listen for product checkbox changes to reset options when product is deselected document.addEventListener('change', this.checkboxChangeListener); } disconnectedCallback() { // Clean up event listeners to prevent memory leaks if (this.checkboxChangeListener) { document.removeEventListener('change', this.checkboxChangeListener); } if (this.optionChangeListener) { salla.event.off('product-options::change', this.optionChangeListener); } if (this.modalOpenListener) { salla.event.off('multiple-bundle-product-modal::open', this.modalOpenListener); } if (this.clearOptionsListener) { salla.event.off('multiple-bundle-product-modal::clear-options', this.clearOptionsListener); } } cleanupProductDeselection(params) { const { sectionId, productIndex, productId, form, uncheckedInput } = params; this.clearProductOptions(productId, sectionId, productIndex); if (form) { const productInputPattern = `bundle[${sectionId}][${productIndex}]`; Array.from(form.querySelectorAll(`input[name^="${productInputPattern}"]`)).forEach(input => { if (input === uncheckedInput) { return; } const shouldRemoveHidden = input.type === 'hidden'; const shouldRemoveByDataset = input.getAttribute('data-product-id') === String(productId) && input.name?.startsWith(productInputPattern); if (shouldRemoveHidden || shouldRemoveByDataset) { input.remove(); } }); requestAnimationFrame(() => { const changeEvent = new window.Event('change', { bubbles: true }); form.dispatchEvent(changeEvent); }); } } generateFormInputName(sectionId, productIndex, optionParentId) { return `bundle[${sectionId}][${productIndex}][options][${optionParentId}]`; } async initializeSelectedOptions() { if (!this.product?.id) return; const productId = this.product.id; const cacheKey = this.generateCacheKey(this.sectionId, this.productIndex, productId); const optionsEl = document.querySelector(`salla-product-options[product-id="${productId}"]`); if (optionsEl) { try { const selectedOptions = await optionsEl.getSelectedOptions(); if (selectedOptions && selectedOptions.length > 0) { this.selectedOptions = { ...this.selectedOptions, [cacheKey]: selectedOptions, }; } } catch (e) { console.warn('Could not initialize selected options:', e); } } } // Clear options state for a specific product clearProductOptions(productId, sectionId, productIndex) { const updatedSelectedOptions = { ...this.selectedOptions }; if (sectionId != null && productIndex != null && !Number.isNaN(productIndex)) { const cacheKey = this.generateCacheKey(sectionId, productIndex, productId); delete updatedSelectedOptions[cacheKey]; this.bumpOptionsResetToken(cacheKey); } else { const productSuffix = `-${String(productId)}`; const affectedKeys = []; Object.keys(updatedSelectedOptions).forEach(key => { if (key.endsWith(productSuffix)) { delete updatedSelectedOptions[key]; affectedKeys.push(key); } }); affectedKeys.forEach(key => this.bumpOptionsResetToken(key)); } this.selectedOptions = updatedSelectedOptions; // Reset validation errors and unsaved changes this.validationErrors = []; this.hasUnsavedChanges = false; } bumpOptionsResetToken(cacheKey) { if (!cacheKey) return; this.optionsResetTokens = { ...this.optionsResetTokens, [cacheKey]: (this.optionsResetTokens[cacheKey] || 0) + 1, }; } async handleOptionChange(productId, option, detail) { const cacheKey = this.generateCacheKey(this.sectionId, this.productIndex, productId); // Get the current state from the component to ensure we have the latest selections const optionsEl = document.querySelector(`salla-product-options[product-id="${productId}"]`); let currentComponentSelections = []; if (optionsEl) { try { currentComponentSelections = (await optionsEl.getSelectedOptions()) || []; } catch (e) { console.warn('Could not get current selections from component:', e); } } // If component returns data, use it; otherwise, fall back to manual tracking if (currentComponentSelections.length > 0) { // Component returned data, use it this.selectedOptions = { ...this.selectedOptions, [cacheKey]: currentComponentSelections, }; } else { // If we have existing selections in internal state and component returns empty, // it might be a deselection, so we should use manual tracking if (this.selectedOptions[cacheKey] && this.selectedOptions[cacheKey].length > 0) { // Component didn't return data, use manual tracking const currentSelected = this.selectedOptions[cacheKey] || []; const updatedSelected = [...currentSelected]; // Find existing selection for this specific option (by option_id) const existingIndex = updatedSelected.findIndex(opt => opt.option_id === option.id); if (existingIndex > -1) { // Check if this is a deselection (detail might be null or undefined) if (!detail || detail.id === null || detail.id === undefined) { // Remove the option (deselection) updatedSelected.splice(existingIndex, 1); } else { // Replace existing selection for this option updatedSelected[existingIndex] = { ...detail, option_id: option.id }; } } else { // Only add if detail exists (not a deselection) if (detail && detail.id !== null && detail.id !== undefined) { updatedSelected.push({ ...detail, option_id: option.id }); } } this.selectedOptions = { ...this.selectedOptions, [cacheKey]: updatedSelected, }; } else { // No existing selections, component returned empty, and we're trying to add // This might be the first selection, so add it manually if (detail && detail.id !== null && detail.id !== undefined) { this.selectedOptions = { ...this.selectedOptions, [cacheKey]: [{ ...detail, option_id: option.id }], }; } } } this.hasUnsavedChanges = true; this.validationErrors = []; // Clear validation errors when user makes changes } async validateOptions() { if (!this.product?.options) return true; const errors = []; const productId = this.product.id; const cacheKey = this.generateCacheKey(this.sectionId, this.productIndex, productId); // Get the actual selected options from the component const optionsEl = document.querySelector(`salla-product-options[product-id="${productId}"]`); let currentSelected = []; if (optionsEl) { try { currentSelected = (await optionsEl.getSelectedOptions()) || []; // Also check our internal state as fallback const internalSelected = this.selectedOptions[cacheKey] || []; // Use whichever has more selections, or if component returns empty but internal has data, use internal if (internalSelected.length > currentSelected.length || (currentSelected.length === 0 && internalSelected.length > 0)) { currentSelected = internalSelected; } } catch (e) { // Fallback to internal state currentSelected = this.selectedOptions[cacheKey] || []; } } else { // Fallback to internal state currentSelected = this.selectedOptions[cacheKey] || []; } // Check if any options are selected at all if (currentSelected.length === 0) { errors.push(salla.lang.get('pages.products.no_options_selected')); } // Check required options this.product.options.forEach(option => { if (option.required) { const hasSelection = currentSelected.some(selected => { return selected.option_id == option.id; // Use == instead of === for type flexibility }); if (!hasSelection) { errors.push(salla.lang.get('pages.products.required_option_missing', { option: option.name, })); } } }); this.validationErrors = errors; return errors.length === 0; } async onSave(e) { e.preventDefault(); const productId = this.product?.id; if (!productId) return; const cacheKey = this.generateCacheKey(this.sectionId, this.productIndex, productId); // Small delay to ensure component state is updated await new Promise(resolve => setTimeout(resolve, 100)); // Validate options before saving const isValid = await this.validateOptions(); if (!isValid) { salla.notify.error(this.validationErrors.join(', ')); return; } this.isLoading = true; try { // please don't change this with this.host.querySelector it will return null const optionsEl = document.querySelector(`salla-product-options[product-id="${productId}"]`); let selectedOptions = await optionsEl?.getSelectedOptions(); // If component returns empty but we have internal state, use internal state if ((!selectedOptions || selectedOptions.length === 0) && this.selectedOptions[cacheKey]?.length > 0) { selectedOptions = this.selectedOptions[cacheKey]; } if (!selectedOptions || selectedOptions.length === 0) { this.isLoading = false; return; } // Store the selected options for this product using cache key this.selectedOptions = { ...this.selectedOptions, [cacheKey]: selectedOptions, }; const form = this.host.closest('form'); if (!form) { this.isLoading = false; return; } // remove old inputs for this specific product in this specific section/index only const productInputPattern = `bundle[${this.sectionId}][${this.productIndex}]`; // Remove only hidden inputs and inputs with data-product-id, but preserve visible checkboxes Array.from(form.querySelectorAll(`input[name^="${productInputPattern}"][type="hidden"]`)).forEach(el => el.remove()); // Also remove any inputs with data-product-id that match this specific pattern Array.from(form.querySelectorAll(`[data-product-id="${productId}"][name^="${productInputPattern}"]`)).forEach(el => el.remove()); // Ensure the actual checkbox in the UI is checked to reflect the selection visually const checkboxId = `bundle[${this.sectionId}][${this.productIndex}][id]`; const checkbox = document.getElementById(checkboxId); if (checkbox) { checkbox.checked = true; // Don't dispatch change event here to avoid double API calls } else { // If checkbox doesn't exist, create a hidden input as fallback const productSelectionInput = document.createElement('input'); productSelectionInput.type = 'hidden'; productSelectionInput.name = `bundle[${this.sectionId}][${this.productIndex}][id]`; productSelectionInput.value = String(productId); productSelectionInput.dataset.productId = String(productId); form.appendChild(productSelectionInput); } // append new hidden inputs for options selectedOptions.forEach((option) => { // how to get option parent id? const optionParentId = option.option_id; const hidden = document.createElement('input'); hidden.type = 'hidden'; // Use productIndex for the form input name hidden.name = this.generateFormInputName(this.sectionId, this.productIndex ?? 0, optionParentId); hidden.value = String(option.id); hidden.dataset.productId = String(productId); form.appendChild(hidden); }); // Trigger single form change event with all updates (product selection + options) const changeEvent = new window.Event('change', { bubbles: true }); form.dispatchEvent(changeEvent); // Emit custom event this.optionsSaved.emit({ productId: Number(productId), selectedOptions, sectionId: this.sectionId, productIndex: this.productIndex, }); // Emit product selected event to check the card if (this.sectionId) { this.productSelected.emit({ productId: Number(productId), sectionId: this.sectionId, product: this.product, fromModal: true, }); } // Show success message salla.notify.success(salla.lang.get('pages.products.options_saved')); this.hasUnsavedChanges = false; this.validationErrors = []; // close modal this.modal.close(); } catch (error) { salla.notify.error(salla.lang.get('pages.products.options_save_error')); } finally { this.isLoading = false; } } // Method to get options with selected state preserved getOptionsWithSelectedState() { if (!this.product?.options) return []; const cacheKey = this.generateCacheKey(this.sectionId, this.productIndex, this.product.id); const savedOptions = this.selectedOptions[cacheKey] || []; return this.product.options.map(option => ({ ...option, details: option.details.map(detail => { const isSelected = savedOptions.some(saved => { return saved.id === detail.id; }); return { ...detail, is_selected: isSelected, }; }), })); } render() { const productId = this.product?.id; const optionsWithSelectedState = this.getOptionsWithSelectedState(); const cacheKey = this.generateCacheKey(this.sectionId, this.productIndex, productId); const resetToken = this.optionsResetTokens[cacheKey] || 0; const isDisabled = this.isLoading || optionsWithSelectedState.some(opt => opt.details.some(d => d.is_selected && d.is_out === true)); return (h(Host, { key: '2daff50a76067220f79c498bf756db36aec2996f' }, h("salla-modal", { key: '3ed6ec8860016d10cec0ee5d4bf55125fc3e44f0', isLoading: this.isLoading, ref: el => (this.modal = el), width: "md", centered: false, id: `s-multiple-bundle-product-options-modal-options-${productId}`, class: "s-multiple-bundle-product-options-modal-wrapper" }, h("div", { key: '9896b07b43cf0e8191d3c7e1ba6d0613d31f08f3', slot: "loading" }, h("salla-skeleton", { key: 'cdf032bdbaebc1e54136b5380a10f9077a781ab0', height: "100%", width: "100%" })), this.product?.images && this.product?.images.length > 0 && (h("salla-slider", { key: '5df7c6e1868f3b3ca8983ff1baa43ad3351aeca2', id: `details-slider-${this.product?.id}`, type: "thumbs", loop: false, "auto-height": true, "listen-to-thumbnails-option": true, showThumbsControls: false, controlsOuter: false, showControls: false, class: "s-multiple-bundle-product-options-modal-slider", verticalThumbs: true, thumbsConfig: { centeredSlides: true, centeredSlidesBounds: true, slidesPerView: Math.min(5, Math.max(1, this.product?.images.length)), watchOverflow: true, watchSlidesProgress: true, direction: 'vertical', spaceBetween: 10, } }, h("div", { key: '190198c7256c9669c25d0cd587cf5193bd75c57e', slot: "items" }, this.product?.images && this.product?.images.map((image, index) => (h("div", { key: index, class: "swiper-slide" }, h("img", { src: image.url, alt: image.alt || `${this.product?.name} - Image ${index + 1}`, loading: "lazy", onError: e => { e.target.style.display = 'none'; } }))))), this.product?.images && this.product?.images.length > 1 && (h("div", { key: '1c8d14ffc416800d19ac3cc87b463da2acede83e', slot: "thumbs" }, this.product?.images && this.product?.images.map((image, index) => (h("div", { key: index, "data-caption": `${this.product?.name} - Image ${index + 1}` }, h("img", { src: image.url, loading: "eager", class: "s-multiple-bundle-product-options-modal-slider-thumb", title: `${this.product?.name} - ${index + 1}`, alt: image.alt || `${this.product?.name} - ${index + 1}`, onError: e => { e.target.style.display = 'none'; } })))))))), h("salla-product-options", { options: JSON.stringify(optionsWithSelectedState), key: `${cacheKey}-reset-${resetToken}`, "product-id": productId, "unique-key": `${cacheKey}-reset-${resetToken}` }), h("div", { key: '52dd62ee4b96b11a10981cccd301ad199adfa61e', slot: "footer" }, h("div", { key: 'c846f4d9099936f41872fe99f3d0b8a789f90c01', class: "s-multiple-bundle-product-options-modal-footer" }, h("salla-button", { key: '40d0742d67f6ab45deace262dbd085feba3d8980', onClick: e => this.onSave(e), loading: this.isLoading, disabled: isDisabled }, this.isLoading ? salla.lang.get('common.elements.saving') : salla.lang.get('common.elements.save'))))))); } get host() { return this; } static get watchers() { return { "product": ["handleProductChange"] }; } static get style() { return sallaMultipleBundleProductOptionsModalCss; } }, [1, "salla-multiple-bundle-product-options-modal", { "product": [32], "sectionId": [32], "sectionIndex": [32], "productIndex": [32], "selectedOptions": [32], "optionsResetTokens": [32], "isLoading": [32], "hasUnsavedChanges": [32], "validationErrors": [32], "open": [64], "close": [64], "refreshOptionsState": [64] }, undefined, { "product": ["handleProductChange"] }]); function defineCustomElement() { if (typeof customElements === "undefined") { return; } const components = ["salla-multiple-bundle-product-options-modal", "salla-booking-field", "salla-button", "salla-color-picker", "salla-conditional-fields", "salla-datetime-picker", "salla-file-upload", "salla-loading", "salla-map", "salla-modal", "salla-product-options", "salla-progress-bar", "salla-skeleton", "salla-slider"]; components.forEach(tagName => { switch (tagName) { case "salla-multiple-bundle-product-options-modal": if (!customElements.get(tagName)) { customElements.define(tagName, SallaMultipleBundleProductOptionsModal); } break; case "salla-booking-field": if (!customElements.get(tagName)) { defineCustomElement$d(); } break; case "salla-button": if (!customElements.get(tagName)) { defineCustomElement$c(); } break; case "salla-color-picker": if (!customElements.get(tagName)) { defineCustomElement$b(); } break; case "salla-conditional-fields": if (!customElements.get(tagName)) { defineCustomElement$a(); } break; case "salla-datetime-picker": if (!customElements.get(tagName)) { defineCustomElement$9(); } break; case "salla-file-upload": if (!customElements.get(tagName)) { defineCustomElement$8(); } break; case "salla-loading": if (!customElements.get(tagName)) { defineCustomElement$7(); } break; case "salla-map": if (!customElements.get(tagName)) { defineCustomElement$6(); } break; case "salla-modal": if (!customElements.get(tagName)) { defineCustomElement$5(); } break; case "salla-product-options": if (!customElements.get(tagName)) { defineCustomElement$4(); } break; case "salla-progress-bar": if (!customElements.get(tagName)) { defineCustomElement$3(); } break; case "salla-skeleton": if (!customElements.get(tagName)) { defineCustomElement$2(); } break; case "salla-slider": if (!customElements.get(tagName)) { defineCustomElement$1(); } break; } }); } defineCustomElement(); export { SallaMultipleBundleProductOptionsModal as S, defineCustomElement as d };