UNPKG

@salla.sa/twilight-components

Version:
860 lines (856 loc) 54.1 kB
/*! * Crafted with ❤ by Salla */ import { proxyCustomElement, HTMLElement, createEvent, h, Host } from '@stencil/core/internal/client'; import { I as IconVerified } from './check.js'; import { C as CameraIcon } from './camera.js'; import { d as defineCustomElement$a } from './salla-booking-field2.js'; import { d as defineCustomElement$9 } from './salla-button2.js'; import { d as defineCustomElement$8 } from './salla-color-picker2.js'; import { d as defineCustomElement$7 } from './salla-conditional-fields2.js'; import { d as defineCustomElement$6 } from './salla-datetime-picker2.js'; import { d as defineCustomElement$5 } from './salla-file-upload2.js'; import { d as defineCustomElement$4 } from './salla-loading2.js'; import { d as defineCustomElement$3 } from './salla-map2.js'; import { d as defineCustomElement$2 } from './salla-modal2.js'; import { d as defineCustomElement$1 } from './salla-progress-bar2.js'; var DisplayType; (function (DisplayType) { DisplayType["COLOR"] = "color"; DisplayType["DATE"] = "date"; DisplayType["DATETIME"] = "datetime"; DisplayType["DONATION"] = "donation"; DisplayType["IMAGE"] = "image"; DisplayType["MULTIPLE_OPTIONS"] = "multiple-options"; DisplayType["NUMBER"] = "number"; DisplayType["SINGLE_OPTION"] = "single-option"; DisplayType["DIGITAL_CARD_VALUE"] = "digital-code-value"; DisplayType["COUNTRY"] = "country"; DisplayType["SPLITTER"] = "splitter"; DisplayType["TEXT"] = "text"; DisplayType["TEXTAREA"] = "textarea"; DisplayType["THUMBNAIL"] = "thumbnail"; DisplayType["TIME"] = "time"; DisplayType["RADIO"] = "radio"; DisplayType["CHECKBOX"] = "checkbox"; DisplayType["MAP"] = "map"; DisplayType["FILE"] = "file"; DisplayType["COLOR_PICKER"] = "color_picker"; DisplayType["BOOKING"] = "booking"; })(DisplayType || (DisplayType = {})); var Currency; (function (Currency) { Currency["Sar"] = "SAR"; })(Currency || (Currency = {})); var FileIcon = `<!-- Generated by IcoMoon.io --> <svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"> <title>file-upload</title> <path d="M21.333 24c0.341 0 0.683-0.131 0.943-0.391 0.521-0.521 0.521-1.364 0-1.885l-5.333-5.333c-0.123-0.123-0.271-0.22-0.433-0.288-0.327-0.135-0.693-0.135-1.019 0-0.163 0.068-0.311 0.165-0.433 0.288l-5.333 5.333c-0.521 0.521-0.521 1.364 0 1.885s1.364 0.521 1.885 0l3.057-3.057v10.115c0 0.736 0.597 1.333 1.333 1.333s1.333-0.597 1.333-1.333v-10.115l3.057 3.057c0.26 0.26 0.601 0.391 0.943 0.391zM28.943 9.724l-9.333-9.333c-0.249-0.251-0.589-0.391-0.943-0.391h-12c-2.205 0-4 1.795-4 4v24c0 2.205 1.795 4 4 4h4c0.736 0 1.333-0.597 1.333-1.333s-0.597-1.333-1.333-1.333h-4c-0.735 0-1.333-0.599-1.333-1.333v-24c0-0.735 0.599-1.333 1.333-1.333h11.448l8.552 8.552v16.781c0 0.735-0.599 1.333-1.333 1.333h-4c-0.736 0-1.333 0.597-1.333 1.333s0.597 1.333 1.333 1.333h4c2.205 0 4-1.795 4-4v-17.333c0-0.353-0.14-0.693-0.391-0.943z"></path> </svg> `; const sallaProductOptionsCss = ""; const SallaProductOptions = /*@__PURE__*/ proxyCustomElement(class SallaProductOptions extends HTMLElement { constructor() { super(); this.__registerHost(); this.changed = createEvent(this, "changed", 7); this.fileTypes = { pdf: 'application/pdf', png: 'image/png', jpg: 'image/jpeg', word: 'application/doc,application/ms-doc,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document', exl: 'application/excel,application/vnd.ms-excel,application/x-excel,application/x-msexcel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', txt: 'text/plain', }; this.outOfStockText = ""; this.donationAmount = salla.lang.get('pages.products.donation_amount'); this.selectDonationAmount = salla.lang.getWithDefault('pages.products.select_donation_amount', 'تحديد مبلغ التبرع'); this.selectAmount = salla.lang.getWithDefault('pages.products.select_amount', 'اختر المبلغ'); this.isCustomDonation = false; this.selectedOptions = []; this.canDisabled = false; this.disableCardValue = true; this.availableDigitalCardValues = []; this.userInitiatedValidation = false; this.isCartMode = false; this.outSkus = []; /** * Avoid selection of previous default or selected card value option * when switching between digital card country options for the 1st time */ this.ignoreDefaultCardValue = false; /** * The id of the product to which the options are going to be fetched for. */ this.productId = salla.config.get('page.id'); this.handleDonationOptions = (event, detail, type, _option) => { // Donating-amount input only: onInput updates price and dispatches native change for form price watcher. if (detail === 'custom' && type === 'input') { const inputTarget = event.target; salla.helpers.inputDigitsOnly(inputTarget); salla.event.emit('product-options::donation-changed', { id: this.productId, price: inputTarget.value }); inputTarget.dispatchEvent(new window.Event('change', { bubbles: true })); return; } // Tab (radio) change: get value from the radio that fired. const value = type === 'option' ? String(event.target?.value ?? '') : ''; if (type === 'option' && detail === 'custom') { // "Custom" tab: stop propagation so form does not see change until user types in donating-amount input. event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); } this.isCustomDonation = value === 'custom'; if (this.donationInput) { if (value === 'custom') { this.donationInput.value = ''; this.donationInput.focus(); } else { this.donationInput.value = value; } if (detail === 'custom') { return; } salla.event.emit('product-options::donation-changed', { id: this.productId, price: value }); } }; this.hideLabel = (option) => { if (option.type === DisplayType.DONATION && (option.donation && !option.donation.can_donate)) { return true; } return false; }; this.getExpireDonationMessage = (option) => { if (!option.donation) { return; } const completed = option.donation.target_amount <= option.donation.collected_amount; return h("div", { class: { "s-product-options-donation-message": true, "s-product-options-donation-completed": completed, "s-product-options-donation-expired": !completed } }, h("p", null, option.donation.target_message), h("span", { innerHTML: completed ? salla.money(option.donation.target_amount) : '' })); }; salla.lang.onLoaded(() => { this.outOfStockText = salla.lang.get("pages.products.out_of_stock"); this.donationAmount = salla.lang.get('pages.products.donation_amount'); this.selectDonationAmount = salla.lang.getWithDefault('pages.products.select_donation_amount', 'تحديد مبلغ التبرع'); this.selectAmount = salla.lang.getWithDefault('pages.products.select_amount', 'اختر المبلغ'); }); if (this.options) { try { this.setOptionsData(Array.isArray(this.options) ? this.options : JSON.parse(this.options)); return; } catch (e) { salla.log('Bad json passed via options prop'); } } if (!Array.isArray(this.optionsData)) { salla.log('Options is not an array[] ---> ', this.optionsData); this.setOptionsData([]); } if (this.productId && !salla.url.is_page('cart')) { salla.api.product.getDetails(this.productId, ['options']).then(resp => this.setOptionsData(resp.data.options)); } } /** * Sets the options data for the product * @param optionsData - Array of product options */ async setOptionsData(optionsData) { this.optionsData = optionsData; const that = this; this.optionsData[0]?.details?.forEach(function (detail) { Object.entries(detail.skus_availability || {}) .filter(sku => !sku[1]) .map(sku => that.outSkus.push(Number(sku[0]))); }); } /** * Get the id's of the selected options. * */ async getSelectedOptionsData() { const selectedOptions = {}; const formData = this.host.getElementSallaData(); // Check if bundleContext is defined as a prop on the component before accessing it const contextData = (typeof this.bundleContext !== 'undefined') ? this.bundleContext : null; formData.forEach((value, key) => { if (contextData) { // Handle bundle naming convention: bundle[sectionId][index][options][optionId] if (key.startsWith('bundle[') && key.includes('[options][')) { const optionId = key.split('[options][')[1].replace(']', ''); selectedOptions[optionId] = value; } } else { // Handle standard naming convention: options[optionId] if (key.startsWith('options[')) { selectedOptions[key.replace('options[', '').replace(']', '')] = value; } } }); return selectedOptions; } /** * Report options form validity. * */ async reportValidity() { const requiredElements = this.host.querySelectorAll('[required]'); let pass = true; for (let i = 0; i < requiredElements.length; i++) { //if there is only one invalid option, return false if ('reportValidity' in requiredElements[i] && !requiredElements[i].reportValidity()) { pass = false; } } return pass; } /** * Return true if there is any out of stock options are selected and vise versa. * */ async hasOutOfStockOption() { return this.selectedOptions.some(option => option.is_out) || (this.selectedSkus?.length && this.selectedSkus?.every(sku => this.outSkus.includes(sku))); } /** * Get selected options. * */ async getSelectedOptions() { return this.selectedOptions; } /** * Get a specific option by its id. * */ async getOption(option_id) { return this.optionsData.find(option => option.id === option_id); } // @ts-ignore invalidHandler(event, option) { const closestProductOption = event.target.closest('.s-product-options-option'); if (!closestProductOption.classList.contains('s-product-options-option-error')) { closestProductOption.classList.add('s-product-options-option-error'); } if ((this.userInitiatedValidation || salla.helpers.isIOSDevice()) && !salla.url.is_page('cart')) { const firstInvalidElement = this.host.querySelector('.s-product-options-option-error'); if (firstInvalidElement === closestProductOption) { this.scrollToElement(closestProductOption); } } } scrollToElement(element) { if (!element) return; element.scrollIntoView({ behavior: 'smooth', block: 'center' }); } changedHandler(event, option, fireChangeEvent = true) { const data = { event: event, option: option, detail: null, productId: this.productId }; if (option.details) { const detail = option.details.find((detail) => { return Number(detail.id) === Number(event.target.value); }); data.detail = detail; } if (option.type === 'country') { this.handleCountryOptionChange(event, data.detail); } const optionElement = event.target.closest('.s-product-options-option'); if (event.target.value || ((option.type === DisplayType.FILE || option.type === DisplayType.IMAGE) && event.type === 'added') || (option.type === DisplayType.MAP && event.type === 'selected' && (event.target.lat && event.target.lng))) { setTimeout(() => { optionElement.classList.remove('s-product-options-option-error'); }, 200); } if (option.type === DisplayType.DONATION) { salla.event.emit('product-options::donation-changed', { id: this.productId, price: event.target.value }); } this.setSelectedSkus(); this.handleRequiredMultipleOptions(option); const index = this.selectedOptions.findIndex(opt => opt.option_id === data.option.id); if (data.option.type === DisplayType.MULTIPLE_OPTIONS) { // Handle multiple selections const detailIndex = this.selectedOptions.findIndex(opt => opt.option_id === data.option.id && opt?.id === data.detail?.id); if (detailIndex > -1) { // If the option is already selected, remove it (unselect) this.selectedOptions.splice(detailIndex, 1); } else { // If the option is not selected, add it to the selectedOptions array this.selectedOptions.push({ ...data.detail, option_id: data.option.id }); } } else { // Handle single selection if (!data.detail || Object.keys(data.detail).length === 0) { // If there is no value for the single-select, remove it from the selectedOptions array if (index > -1) { this.selectedOptions.splice(index, 1); } } else { // If a value exists, update or add the selection if (index > -1) { // Replace the existing selection with the new one this.selectedOptions[index] = { ...data.detail, option_id: data.option.id }; } else { // If no selection exists for this input, add the new selection this.selectedOptions.push({ ...data.detail, option_id: data.option.id }); } } } // Update optionsData directly this.optionsData = this.optionsData.map(opt => { if (opt.id === data.option.id) { return { ...opt, details: opt.details.map(detail => ({ ...detail, is_selected: data.option.type === DisplayType.MULTIPLE_OPTIONS ? this.selectedOptions.some(selected => selected.id === detail.id) : Number(detail.id) === Number(data.detail?.id), value: data.detail?.value })) }; } return opt; }); // Emit the event only if fireChangeEvent is true if (fireChangeEvent) { this.changed.emit(data); salla.event.emit('product-options::change', data); } } /** * loop throw all selected details, then get common sku, if it's only one, means we selected all of them; */ setSelectedSkus() { this.selectedSkus = this.selectedOptions.map(detail => Object.keys(detail.skus_availability || {})) .reduce((p, c) => p.filter(e => c.includes(e)), []) // Initialize accumulator as an empty array .map(sku => Number(sku)); } handleRequiredMultipleOptions(option) { if (option.type !== DisplayType.MULTIPLE_OPTIONS || !option.required) { return; } const optionContainer = this.host.querySelector(`[data-option-id="${option.id}"]`); const hasChecked = optionContainer.querySelectorAll('input:checked').length; optionContainer.querySelectorAll('input').forEach(input => input.toggleAttribute('required', !hasChecked)); } getLatLng(value, type) { return value ? value.split(',')[type === 'lat' ? 0 : 1] : ''; } getDisplayForType(option) { if (this[`${option.type}Option`]) { return this[`${option.type}Option`](option); } if (option.type === DisplayType.COLOR_PICKER) { return this.colorPickerOption(option); } if (option.type === DisplayType.MULTIPLE_OPTIONS) { return this.multipleOptions(option); } if (option.type === DisplayType.SINGLE_OPTION) { return this.singleOption(option); } // Handle radio type as single option for bundle products if (option.type === DisplayType.RADIO) { return this.radioOption(option); } if (option.type === DisplayType.DIGITAL_CARD_VALUE) { return this.digitalCardValuesOption(option); } if (option.type === DisplayType.COUNTRY) { return this.countryOption(option); } if (option.type === DisplayType.BOOKING && salla.url.is_page("cart")) { return h("salla-booking-field", { onInvalidInput: (e) => this.invalidHandler(e, option), option: option, productId: option.value }); } salla.log(`Couldn't find options type(${option.type})😢`); return ''; } getOptionShownWhen(option) { return option.visibility_condition ? { "data-show-when": `options[${option.visibility_condition.option}] ${option.visibility_condition.operator} ${option.visibility_condition.value}` } : {}; } getAvailableDigitalCardSKUs(detail) { const digitalCardOption = this.optionsData.find(({ type }) => type === 'digital-code-value'); if (!digitalCardOption) throw new Error('product-options:: No digital card options found'); const outofStockSKUs = Object.keys(detail.skus_availability).filter(key => detail.skus_availability[key] === false); this.availableDigitalCardValues = digitalCardOption.details.filter((op) => { return !Object.keys(op.skus_availability).filter(SKU_key => outofStockSKUs.includes(SKU_key)).length; }); } handleCountryOptionChange(event, detail) { event.stopImmediatePropagation(); this.ignoreDefaultCardValue = true; const currentCardValue = this.host.querySelector("input[data-code-value]:checked"); if (currentCardValue) currentCardValue.checked = false; const digitalCardOption = this.optionsData.find(({ type }) => type === 'digital-code-value'); if (!digitalCardOption) throw new Error('product-options:: No digital card options found'); this.getAvailableDigitalCardSKUs(detail); } getSelectedDigitalCardOptions(option) { const selectedOption = option.details.find(detail => detail.is_selected); const defaultOption = option.details.find(detail => !!detail.is_default) || option.details[0]; /*option.details[0] only applys for counrty options*/ if (!['digital-code-value', 'country'].includes(option.type)) return; return selectedOption || defaultOption; } //we need the cart Id for productOption Image async componentWillLoad() { if (salla.url.is_page("cart")) { this.disableCardValue = false; this.fillSelectedOptions(); } if (this.config) { try { this.optionConfig = typeof this.config === 'string' ? JSON.parse(this.config) : this.config; } catch (error) { console.error('Failed to parse JSON in config prop:', error); } } const shouldSelectDefaultOption = this.optionsData.filter(({ type }) => ["country", "digital-card-value"].includes(type)).length > 0 && salla.url.is_page('cart'); if (shouldSelectDefaultOption) { const countryOption = this.optionsData.find(option => option.type === 'country'); const defaultSelection = countryOption && this.getSelectedDigitalCardOptions(countryOption); if (defaultSelection) { this.getAvailableDigitalCardSKUs(defaultSelection); } } this.outOfStockText = salla.lang.get('pages.products.out_of_stock'); await salla.onReady(); this.canDisabled = !salla.config.get('store.settings.product.notify_options_availability') || salla.url.is_page('cart'); this.pasteHandler = this.handlePaste.bind(this); document.addEventListener("paste", this.pasteHandler); const needsCartId = (!salla.storage.get('cart.id') && this.optionsData.some(option => ['file', 'image'].includes(option.type))); return needsCartId ? salla.api.cart.getCurrentCartId(false, "salla-product-options") : null; } disconnectedCallback() { if (this.pasteHandler) { document.removeEventListener("paste", this.pasteHandler); } } /** * This is a workaround for a bug in iOS 26 Safari, when pasting English text to RTL inputs, it adds extra text!! * To avoid any break changes, we will make it only work on these conditions: * - content_copyright is on * - Apple Pay is enabled (means it's iOS/safari) * - Input is an input or textarea * - Salla form control * - Options array */ handlePaste(event) { const target = event.target; if (!Salla.helpers.isAppleDevice() || !(target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) || !target.classList.contains('s-form-control') || !this.host.contains(target)) { return; } // Prevent default paste (to avoid Safari inserting extra content) event.preventDefault(); // Read only the clipboard data const text = event.clipboardData?.getData("text") || ""; // Insert it manually at cursor if you want const start = target.selectionStart; const end = target.selectionEnd; const newValue = target.value.slice(0, start) + text + target.value.slice(end); target.value = newValue; // Reset cursor position target.setSelectionRange(start + text.length, start + text.length); try { target.dispatchEvent(new window.Event('change', { bubbles: true })); } catch (error) { Salla.log('Error on change'); } } hideDigitalCardsOptions(option) { return (this.disableCardValue && option.type === DisplayType.DIGITAL_CARD_VALUE && !salla.url.is_page("cart")); } render() { if (this.optionsData?.length === 0) { return; } return (h(Host, { class: "s-product-options-wrapper" }, h("salla-conditional-fields", null, this.optionsData.map((option) => h("div", { key: option.id, class: `s-product-options-option-container${option.visibility_condition || this.hideDigitalCardsOptions(option) ? ' hidden' : ''}`, "data-option-id": option.id, ...this.getOptionShownWhen(option) }, option.name === 'splitter' ? this.splitterOption() : h("div", { class: { "s-product-options-option": true, "s-product-options-option-booking": option.type === DisplayType.BOOKING && salla.url.is_page("cart") }, "data-option-type": option.type, "data-option-required": `${option.required}` }, h("label", { htmlFor: this.generateInputId(option.id), class: `s-product-options-option-label ${this.hideLabel(option) ? 's-product-options-option-label-hidden' : ''}` }, h("b", null, option.name, option.required && h("span", null, " * "), " "), h("small", null, option.placeholder)), h("div", { class: `s-product-options-option-content ${this.hideLabel(option) || (option.type === DisplayType.BOOKING && salla.url.is_page("cart")) ? 's-product-options-option-content-full-width' : ''}` }, this.getDisplayForType(option)))))))); } generateUniqueKey(defaultValue) { const contextData = this.bundleContext; let baseKey = this.uniqueKey ? `${defaultValue}-${this.uniqueKey}` : defaultValue; if (contextData) { try { // Handle both string and object types const context = typeof contextData === 'string' ? JSON.parse(contextData) : contextData; const { sectionId, productId } = context; baseKey = `${baseKey}-bundle-${sectionId}-${productId}`; } catch (e) { // If parsing fails, just use the base key } } return baseKey; } /** * Generate a valid HTML id for option inputs. * @param optionId - The option ID * @returns The formatted id string */ generateInputId(optionId) { return this.generateUniqueKey(`option-${optionId}`); } /** * Generate the correct input name based on bundle context * @param optionId - The option ID * @returns The formatted input name */ generateInputName(optionId) { const contextData = this.bundleContext; const baseOptionId = optionId.toString(); if (contextData) { try { // Handle both string and object types const context = typeof contextData === 'string' ? JSON.parse(contextData) : contextData; const { sectionId, productId } = context; return `bundle[${sectionId}][${productId}][options][${baseOptionId}]`; } catch (e) { return `options[${baseOptionId}]`; } } return `options[${baseOptionId}]`; } fillSelectedOptions() { this.selectedOptions = this.optionsData.reduce((acc, opt) => { const selectedDetails = opt.details.filter(detail => detail.is_selected); const mappedDetails = selectedDetails.map(detail => ({ ...detail, option_id: opt.id })); return acc.concat(mappedDetails); }, []); } async componentDidLoad() { if (!this.optionsData?.length) { return; } // Register for hooks — theme can call enterCartMode() here await Salla.hooks.registerComponent('salla-product-options', this); // Handle donation options const selectedDonationOption = this.optionsData.find(option => option.type === DisplayType.DONATION)?.details?.find(detail => detail.is_selected); if (selectedDonationOption) { setTimeout(() => { salla.event.emit('product-options::donation-changed', { id: this.productId, price: selectedDonationOption.additional_price }); }, 1000); } // Handle pre-selected options on product page // Skip if in cart mode (enterCartMode called by hook) or on cart page // Call salla.product.getPrice() directly to avoid triggering form validation which causes unwanted scroll if (!salla.url.is_page("cart") && !this.isCartMode) { const pricingOptionTypes = [ DisplayType.SINGLE_OPTION, DisplayType.MULTIPLE_OPTIONS, DisplayType.COLOR, DisplayType.THUMBNAIL, DisplayType.DONATION, DisplayType.DIGITAL_CARD_VALUE, DisplayType.COUNTRY ]; const hasPreSelectedPricingOption = this.optionsData.some(option => pricingOptionTypes.includes(option.type) && option.details?.some(detail => detail.is_selected)); if (hasPreSelectedPricingOption) { // Use requestAnimationFrame to ensure DOM is fully painted before accessing form requestAnimationFrame(() => { try { const form = this.host.closest('form'); if (!form) return; // Validate form belongs to this product to avoid conflicts on complex pages const formData = new FormData(form); const formProductId = formData.get('id') || formData.get('product_id'); if (formProductId && String(formProductId) !== String(this.productId)) return; salla.product.getPrice(formData); } catch (e) { salla.log('Error updating price for pre-selected options:', e); } }); } } } /** * Switch the component to cart-item mode: fill selected options, * enable disabled-out-of-stock, and skip product price updates. * Used by themes when treating a product page as a cart item editor. */ async enterCartMode() { this.isCartMode = true; this.disableCardValue = false; this.canDisabled = true; this.fillSelectedOptions(); } /** * Enable user-initiated validation mode so invalid fields will scroll into view */ async enableUserInitiatedValidation() { this.userInitiatedValidation = true; } /** * Validate options and trigger scrolling to the first invalid option if any */ async validateAndScroll() { await this.enableUserInitiatedValidation(); return this.reportValidity(); } //@ts-ignore donationOption(option, product) { return h("div", { class: "s-product-options-donation-wrapper" }, option.donation?.can_donate ? [ option.donation ? h("div", { key: option.id, class: "s-product-options-donation-progress" }, h("salla-progress-bar", { donation: option.donation })) : '', option.details.length ? [h("h4", { key: option.id }, this.selectAmount), h("div", { key: option.id, class: "s-product-options-donation-options" }, option.details.map((detail, i) => h("div", { key: option.id, class: "s-product-options-donation-options-item" }, h("input", { id: this.generateUniqueKey(`donation-option-${i}`), type: "radio", name: "donating_option", checked: detail.is_selected, value: detail.additional_price, onChange: e => this.handleDonationOptions(e, detail, 'option', option) }), h("label", { htmlFor: this.generateUniqueKey(`donation-option-${i}`) }, h("span", { innerHTML: salla.money(detail.name) })))), option.donation?.custom_amount_enabled ? h("div", { class: "s-product-options-donation-options-item" }, h("input", { id: this.generateUniqueKey("donation-option-custom"), type: "radio", name: "donating_option", value: "custom", onChange: e => this.handleDonationOptions(e, 'custom', 'option', option) }), h("label", { htmlFor: this.generateUniqueKey("donation-option-custom") }, h("span", null, " ", this.selectDonationAmount, " "))) : '')] : '', h("div", { key: option.id, class: { "s-product-options-donation-input-group": true, "shown": !option.details.length || (option.details.length && this.isCustomDonation) } }, h("input", { type: "text", id: "donating-amount", name: "donation_amount", class: "s-form-control", ref: el => { this.donationInput = el; }, value: option.details.length && option.details.some(detail => detail.is_selected) ? option.details.find(detail => detail.is_selected).additional_price : option.value, // required placeholder: option.placeholder, onInput: e => this.handleDonationOptions(e, 'custom', 'input', option), onBlur: e => this.changedHandler(e, option), onInvalid: (e) => this.invalidHandler(e, option) }), h("span", { class: "s-product-options-donation-amount-currency" }, salla.config.currency(salla.config.get('user.currency_code')).symbol)) ] : this.getExpireDonationMessage(option)); } fileUploader(option, additions = null) { return h("salla-file-upload", { ...(additions || {}), "payload-name": "file", value: option.value, "instant-upload": true, name: this.generateInputName(option.id), required: !option.visibility_condition && option.required, height: "120px", onAdded: (e) => this.changedHandler(e, option), url: salla.cart.api.getUploadImageEndpoint(), "form-data": { cart_item_id: this.productId, product_id: this.productId }, onInvalidInput: (e) => this.invalidHandler(e, option), class: { "s-product-options-image-input": true, required: option.required } }, h("div", { class: "s-product-options-filepond-placeholder" }, h("span", { class: "s-product-options-filepond-placeholder-icon", innerHTML: additions.accept?.split(',').every(type => type.includes('image')) ? CameraIcon : FileIcon }), h("p", { class: "s-product-options-filepond-placeholder-text" }, salla.lang.get('common.uploader.drag_and_drop')), h("span", { class: "filepond--label-action" }, salla.lang.get('common.uploader.browse')))); } //@ts-ignore imageOption(option) { return this.fileUploader(option, { accept: 'image/png,image/jpeg,image/jpg,image/gif' }); } //@ts-ignore fileOption(option) { const types = option.details.map(detail => this.fileTypes[detail.name]).filter(Boolean); return types?.length ? this.fileUploader(option, { accept: types.join(',') }) : 'File types not selected.'; } // TODO: (ONLY FOR TESTING!) find a better way to make it testable, e.g. wrap it with a unique class like textOption //@ts-ignore numberOption(option) { return h("input", { type: "text", value: option.value, class: "s-form-control", required: !option.visibility_condition && option.required, id: this.generateInputId(option.id), name: this.generateInputName(option.id), placeholder: option.placeholder, onBlur: e => this.changedHandler(e, option), onInvalid: (e) => this.invalidHandler(e, option), onInput: e => salla.helpers.inputDigitsOnly(e.target) }); } //@ts-ignore splitterOption() { return h("div", { class: "s-product-options-splitter" }); } //@ts-ignore textOption(option) { return h("div", { class: "s-product-options-text" }, h("input", { type: "text", value: option.value, maxLength: option?.length, class: 's-form-control', required: !option.visibility_condition && option.required, id: this.generateInputId(option.id), name: this.generateInputName(option.id), placeholder: option.placeholder, onInvalid: (e) => this.invalidHandler(e, option), onChange: e => this.changedHandler(e, option) })); } //@ts-ignore textareaOption(option) { //todo::remove mt-1 class, and if it's okay to remove the tag itself will be great return h("div", { class: "s-product-options-textarea" }, h("div", { class: "mt-1" }, h("textarea", { rows: 4, value: option.value, maxLength: option?.length, class: "s-form-control", required: !option.visibility_condition && option.required, id: this.generateInputId(option.id), name: this.generateInputName(option.id), placeholder: option.placeholder, onInvalid: (e) => this.invalidHandler(e, option), onChange: (e) => this.changedHandler(e, option) }))); } //@ts-ignore mapOption(option) { return h("salla-map", { zoom: 15, lat: this.getLatLng(option.value, 'lat'), lng: this.getLatLng(option.value, 'lng'), name: this.generateInputName(option.id), searchable: true, required: option.required, onInvalidInput: (e) => this.invalidHandler(e, option), onSelected: e => this.changedHandler(e, option) }); } colorPickerOption(option) { return h("salla-color-picker", { onSubmitted: e => this.changedHandler(e, option), name: this.generateInputName(option.id), required: !option.visibility_condition && option.required, onInvalidInput: (e) => this.invalidHandler(e, option), color: option.value }); } /** * ============= Date Time options ============= */ //@ts-ignore timeOption(option) { return h("salla-datetime-picker", { noCalendar: true, enableTime: true, dateFormat: "h:i K", value: option.value, placeholder: option.name, required: !option.visibility_condition && option.required, name: this.generateInputName(option.id), class: "s-product-options-time-element", onInvalidInput: (e) => this.invalidHandler(e, option), onPicked: e => this.changedHandler(e, option) }); } //@ts-ignore dateOption(option) { //todo:: consider date-range @see https://github.com/SallaApp/theme-raed/blob/master/src/assets/js/partials/product-options.js#L8-L23 return h("div", { class: "s-product-options-date-element" }, h("salla-datetime-picker", { value: option.value, placeholder: option.name, required: !option.visibility_condition && option.required, minDate: new Date(), name: this.generateInputName(option.id), onInvalidInput: (e) => this.invalidHandler(e, option), onPicked: e => this.changedHandler(e, option) })); } //@ts-ignore datetimeOption(option) { //todo:: consider date-range @see https://github.com/SallaApp/theme-raed/blob/master/src/assets/js/partials/product-options.js#L8-L23 return h("div", { class: "s-product-options-datetime-element" }, h("salla-datetime-picker", { enableTime: true, value: option.value, dateFormat: "Y-m-d G:i:K", placeholder: option.name, required: !option.visibility_condition && option.required, name: this.generateInputName(option.id), maxDate: option.to_date_time, minDate: option.from_date_time, onInvalidInput: (e) => this.invalidHandler(e, option), onPicked: e => this.changedHandler(e, option) })); } /** * ============= Advanced options ============= */ getOptionDetailName(detail, outOfStock = true, optionType) { let detailName; if (optionType && optionType === DisplayType.COLOR) { detailName = detail.name + ((outOfStock && this.isOptionDetailOut(detail) && !salla.url.is_page("cart")) && !this.hideOutLabel ? ` <br/> <p> ${this.outOfStockText} </p>` : '') + (detail.additional_price ? ` <p> (${salla.money(detail.additional_price, false)}) </p>` : ''); } if (!detailName) { detailName = detail.name + ((outOfStock && this.isOptionDetailOut(detail) && !salla.url.is_page("cart")) && !this.hideOutLabel ? ` - ${this.outOfStockText}` : '') + (detail.additional_price ? ` (${salla.money(detail.additional_price, false)})` : ''); } //Some merchants adding price to the names of the options, //and because we are using this inside select option, we need to replace the html currency symbol with the store currency symbol return detailName.replace('<i class=sicon-sar></i>', salla.config.currency()?.symbol || 'ر.س'); } isOptionDetailOut(detail) { if (detail.is_out || !detail.skus_availability || !this.selectedSkus?.length) { return detail.is_out; } const isDetailSelected = this.selectedOptions.filter(option => option.id === detail.id).length; //if the current options is the only selected option, so we are sure that it's not out, because there is no other options selected yet if (isDetailSelected && this.selectedOptions.length === 1) { return false; } //if current details has sku in the possible outSkus it's out for sure if (isDetailSelected) { //here we will get the possible outSkus for current selected options const outSelectableSkus = this.selectedSkus.filter(sku => this.outSkus.includes(sku)); return Object.keys(detail.skus_availability).some(sku => outSelectableSkus.includes(Number(sku))); } return this.selectedOptions.some(option => option.is_out && option.option_id !== detail.option_id); } /** * Renders a single input element (radio or checkbox) for an option detail. * @param type - The type of input element ('radio' or 'checkbox'). * @param detail - The detail object representing an option detail. * @param option - The parent option object containing the details. * @param isRequired - Indicates if the input is required based on the option's rules. * @param name - The name attribute for the input element. * @returns HTMLElement - A labeled input element. */ renderInput(type, detail, option, isRequired, name, buttonStyle) { const id = this.generateUniqueKey(`${type}-${option.id}-${detail.id}`); const isDisabled = this.isOptionDetailOut(detail); return (h("label", { class: { "s-product-options-disabled": isDisabled, } }, h("input", { id: id, type: type, name: name, value: detail.id, disabled: isDisabled, required: isRequired, checked: detail.is_selected, onInvalid: (e) => this.invalidHandler(e, option), onChange: (e) => this.changedHandler(e, option) }), h("div", { class: { "s-product-options-grid-mode-span": buttonStyle, "s-product-options-disabled": isDisabled } }, this.getOptionDetailName(detail)))); } /** * Renders a collection of input elements for all details of an option. * @param type - The type of input elements ('radio' or 'checkbox'). * @param option - The parent option object containing the details. * @param isRequired - Indicates if the inputs are required based on the option's rules. * @returns HTMLElement[] - An array of labeled input elements. */ renderOptionDetails(type, option, isRequired, buttonStyle = false) { const baseName = this.generateInputName(option.id); const name = type === 'radio' ? baseName : `${baseName}[]`; return option?.details.map((detail) => this.renderInput(type, detail, option, isRequired, name, buttonStyle)); } /** * Renders a dropdown (select) element for a single-option selection. * @param option - The parent option object. * @returns HTMLElement - A select dropdown element with all option details. */ renderSelect(option) { return (h("div", null, h("select", { id: this.generateInputId(option.id), name: this.generateInputName(option.id), required: !option.visibility_condition && option.required, class: "s-form-control", onInvalid: (e) => this.invalidHandler(e, option), onChange: (e) => this.changedHandler(e, option) }, h("option", { value: "" }, option.placeholder), option?.details.map((detail) => (h("option", { key: detail.id, value: detail.id, disabled: this.canDisabled && this.isOptionDetailOut(detail), selected: detail.is_selected }, this.getOptionDetailName(detail))))))); } /** * Renders a grid-based layout for option inputs (radio or checkbox). * @param type - The type of input elements ('radio' or 'checkbox'). * @param option - The parent option object containing the details. * @param isRequired - Indicates if the inputs are required based on the option's rules. * @returns HTMLElement - A grid-based container with input elements. */ renderButtonStyle(type, option, isRequired) { return (h("div", { class: "s-product-options-grid-mode" }, this.renderOptionDetails(type, option, isRequired, true))); } /** * Renders a single-option selection, either as a grid or dropdown, based on configuration. * @param option - The parent option object. * @returns HTMLElement - The rendered single-option element. */ singleOption(option) { const buttonStyle = this.optionConfig?.['single-option']?.type === 'button'; const isRequired = !option.visibility_condition && option.required; return buttonStyle ? this.renderButtonStyle('radio', option, isRequired) : this.renderSelect(option); } /** * Renders a multiple-option selection, either as a grid or list, based on configuration. * @param option - The parent option object. * @returns HTMLElement - The rendered multiple-option element. */ multipleOptions(option) { const buttonStyle = this.optionConfig?.['multiple-option']?.type === 'button'; const isRequired = option.required && !option.details.some((detail) => detail.is_selected) && !option.visibility_condition; return buttonStyle ? this.renderButtonStyle('checkbox', option, isRequired) : (h("div", { class: { 's-product-options-multiple-options-wrapper': true, required: option.required, } }, this.renderOptionDetails('checkbox', option, isRequired))); } /** * Renders a radio option selection (used for bundle products). * This is essentially the same as single option but with explicit radio handling. * @param option - The parent option object. * @returns HTMLElement - The rendered radio option element. */ radioOption(option) { // Radio options behave the same as single options return this.singleOption(option); } //@ts-ignore colorOption(option) { return (h("fieldset", { class: "s-product-options-colors-wrapper" }, option?.details.map((detail) => (h("div", { class: "s-product-options-colors-item", key: detail.id }, h("input", { type: "radio", value: detail.id, required: !option.visibility_condition && option.required, checked: detail.is_selected, name: this.generateInputName(option.id), disabled: this.canDisabled && this.isOptionDetailOut(detail), id: this.generateUniqueKey(`color-${this.productId}-${option.id}-${detail.id}`), onInvalid: (e) => this.invalidHandler(e, option), onChange: (e) => this.changedHandler(e, option) }), h("label", { htmlFor: this.generateUniqueKey(`color-${this.productId}-${option.id}-${detail.id}`) }, h("span", { style: { backgroundColor: detail.color } }), h("div", { innerHTML: this.getOptionDetailName(detail, true, option.type) }))))))); } //@ts-ignore thumbnailOption(option) { return h("div", { class: "s-product-options-thumbnails-wrapper" }, option.details.map((detail) => { return h("div", { key: detail.id }, h("input", { type: "radio", value: detail.id, "data-itemid": detail.id, required: !option.visibility_condition && option.required, checked: detail.is_selected, name: this.generateInputName(option.id), "data-img-id": detail.option_value, disabled: this.canDisabled && this.isOptionDetailOut(detail), id: this.generateUniqueKey(`option_${this.productId}-${option.id}_${detail.id}`), onInvalid: (e) => this.invalidHandler(e, option), onChange: (e) => this.changedHandler(e, option) }), h("label", { htmlFor: this.generateUniqueKey(`option_${this.productId}-${option.id}_${detail.id}`), "data-img-id": detail.option_value, class: "go-to-slide" }, h("img", { "data-src": detail.image, src: detail.image, title: detail.name, alt: detail.name }), h("span", { innerHTML: IconVerified, class: "s-product-options-thumbnails-icon" }), this.isOptionDetailOut(detail) ? [ h("small", { key: detail.id, class: "s-product-options-thumbnails-stock-badge" }, this.outOfStockText), this.canDisabled ? h("div", { key: detail.id, class: "s-product-options-thumbnails-badge-overlay" }) : '', ] : ''), h("p", null, this.getOptionDetailName(detail, false), " ")); })); } // Digital card options digitalCardValuesOption(option) { return h("div", { class: "s-product-options-digital-card-wrapper" }, this.availableDigitalCardValues.length > 0 ? this.availableDigitalCardValues.map((detail) => { const id = String(detail.id); return h("label", { htmlFor: this.generateUniqueKey(id.toString()), key: id, class: "s-product-options-digital-card-option" }, h("input", { type: "radio", "data-code-value": true, class: "s-form-control s-product-options-digital-card-input", value: detail.id, name: this.generateInputName(option.id), id: this.generateUniqueKey(id.toString()), required: !option.visibility_condition && option.required, onInvalid: (e) => this.invalidHandler(e, option), ...(!this.ignoreDefaultCardValue ? { defaultChecked: this.getSelectedDigitalCardOptions(option)?.id === detail.id } : {}) }), h("span", null, detail.name, " ", salla.config?.currency()?.symbol)); }) : h("div", { class: "s-product-options-digital-card-out-of-stock" })); } countryOption(option) { return h("div", { class: "s-product-options-digital-card-wrapper" }, option.details.map((detail) => { return h("label", { htmlFor: this.generateUniqueKey(detail.id.toString()), key: detail.id, class: { "s-product-options-digital-card-option": true, "s-product-options-digital-card-option-stock-out": detail.is_out } }, h("input", { id: this.generateUniqueKey(detail.id.toString()), type: "radio", class: "s-form-control s-product-options-digital-card-input", value: detail.id, name: this.generateInputName(option.id), disabled: detail.is_out, required: !option.visibility_condition && option.required, onInvalid: (e) => this.invalidHandler(e,