UNPKG

@salla.sa/twilight-components

Version:
980 lines (970 loc) 119 kB
/*! * Crafted with ❤ by Salla */ import { r as registerInstance, c as createEvent, h, H as Host, a as getElement, e as axios } from './index-Dbv0I4re.js'; import { P as PendingOrdersIcon } from './cart-DY4LZmNP.js'; import { H as Helper } from './Helper-DFMXF2_h.js'; import { B as BellRing } from './bell-ring-D3mWkc-3.js'; import { i as iconCheck } from './check-BsXh13x8.js'; import { C as CameraIcon } from './camera-C6jIkM-X.js'; import './anime.es-CgtvEd63.js'; var WalletIcon = `<!-- 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>full-wallet</title> <path d="M29 12h-26c-0.668-0.008-1.284-0.226-1.787-0.59l0.009 0.006c-0.744-0.552-1.222-1.428-1.222-2.416 0-1.657 1.343-3 2.999-3h6c0.552 0 1 0.448 1 1s-0.448 1-1 1v0h-6c-0.552 0-1 0.448-1 1 0 0.326 0.156 0.616 0.397 0.798l0.002 0.002c0.167 0.12 0.374 0.194 0.599 0.2l0.001 0h26c0.552 0 1 0.448 1 1s-0.448 1-1 1v0zM27 12c-0.552 0-1-0.448-1-1v0-3h-3c-0.552 0-1-0.448-1-1s0.448-1 1-1v0h4c0.552 0 1 0.448 1 1v0 4c0 0.552-0.448 1-1 1v0zM29 30h-26c-1.657 0-3-1.343-3-3v0-18c0-0.552 0.448-1 1-1s1 0.448 1 1v0 18c0 0.552 0.448 1 1 1v0h25v-5c0-0.552 0.448-1 1-1s1 0.448 1 1v0 6c0 0.552-0.448 1-1 1v0zM29 18c-0.552 0-1-0.448-1-1v0-6c0-0.552 0.448-1 1-1s1 0.448 1 1v0 6c0 0.552-0.448 1-1 1v0zM31 24h-7c-2.209 0-4-1.791-4-4s1.791-4 4-4v0h7c0.552 0 1 0.448 1 1v0 6c0 0.552-0.448 1-1 1v0zM24 18c-1.105 0-2 0.895-2 2s0.895 2 2 2v0h6v-4zM25 12c-0.001 0-0.001 0-0.002 0-0.389 0-0.726-0.222-0.891-0.546l-0.003-0.006-3.552-7.106-2.306 1.152c-0.13 0.066-0.284 0.105-0.447 0.105-0.552 0-1-0.448-1-1 0-0.39 0.223-0.727 0.548-0.892l0.006-0.003 3.2-1.6c0.13-0.067 0.284-0.106 0.447-0.106 0.39 0 0.727 0.223 0.892 0.548l0.003 0.006 4 8c0.067 0.13 0.106 0.285 0.106 0.448 0 0.552-0.448 1-1 1v0zM21 12c-0.001 0-0.001 0-0.002 0-0.389 0-0.726-0.222-0.891-0.546l-0.003-0.006-3.552-7.106-15.104 7.552c-0.13 0.066-0.284 0.105-0.447 0.105-0.552 0-1-0.448-1-1 0-0.39 0.223-0.727 0.548-0.892l0.006-0.003 16-8c0.13-0.067 0.284-0.106 0.447-0.106 0.39 0 0.727 0.223 0.892 0.548l0.003 0.006 4 8c0.067 0.13 0.106 0.285 0.106 0.448 0 0.552-0.448 1-1 1-0.001 0-0.001 0-0.002 0h0z"></path> </svg> `; const sallaAddProductButtonCss = ":host{display:block}salla-add-product-button[width=wide]{width:100%}"; const SallaAddProductButton = class { constructor(hostRef) { registerInstance(this, hostRef); this.success = createEvent(this, "success"); this.failed = createEvent(this, "failed"); this.hostAttributes = {}; /** * Has Pre Order */ this.hasPreOrder = false; /** * Product Status.Defaults to `sale` */ this.productStatus = 'sale'; /** * Product type. Defaults to `product` */ this.productType = 'product'; this.selectedOptions = []; this.buyNowText = salla.lang.get('pages.products.buy_now'); /** Use matchMedia instead of window.innerWidth to avoid forced reflow (Lighthouse/PageSpeed) */ this.isDesktopViewport = typeof window !== 'undefined' ? window.matchMedia('(min-width: 768px)').matches : true; this.viewportMediaQuery = null; this.onViewportChange = (e) => { this.isDesktopViewport = e.matches; this.btn?.setText((e.matches && !!this.passedLabel) ? this.passedLabel : this.getLabel()); }; salla.lang.onLoaded(() => { this.buyNowText = salla.lang.get('pages.products.buy_now'); }); } getLabel() { if (this.productStatus === 'sale' && this.supportStickyBar && !this.isDesktopViewport && this.showQuickBuy && this.isApplePayActive) { return PendingOrdersIcon; } if (this.hasPreOrder) { return salla.lang.get('pages.products.pre_order_now'); } if (this.productStatus === 'sale' && this.productType === 'booking') { return salla.lang.get('pages.cart.book_now'); } if (this.productStatus === 'sale') { return salla.lang.get('pages.cart.add_to_cart'); } if (this.productType !== 'donating') { return salla.lang.get('pages.products.out_of_stock'); } // donating return salla.lang.get('pages.products.donation_exceed'); } addProductToCart(event) { if (this.productType === 'booking') { event.preventDefault(); return this.addBookingProduct(); } // we want to ignore the click action when the type of button is submit a form if (this.hostAttributes.type === 'submit') { return false; } event.preventDefault(); this.btn?.disable(); // Validate product options explicitly on Add to Cart click const optionsEl = document.querySelector(`salla-product-options[product-id="${this.productId}"]`); if (optionsEl && typeof optionsEl.validateAndScroll === 'function') { return optionsEl.validateAndScroll() .then(async (isValid) => { // Call validate hook const ctx = { isValid, component: this }; await salla.hooks.call('salla-add-product-button', 'validate', ctx); if (!ctx.isValid) { this.btn?.enable(); return; } return this._proceedAddToCart(); }) .catch(() => { this.btn?.enable(); }); } // Fallback if options component not found return this._proceedAddToCart(); } async _proceedAddToCart() { /** * by default the quick add is just an alias for add item function * but its work only when the id is the only value is passed via the object * so we will filter the object entities to remove null and zero values in case we don't want the normal add item action */ const parent = this.host.closest('salla-products-list, salla-products-slider'); const data = Object.entries({ id: this.productId, donation_amount: this.donatingAmount, quantity: this.quantity, endpoint: 'quickAdd', source: Helper.getProductsSource(parent?.getAttribute('source')) }).reduce((a, [k, v]) => (v ? (a[k] = v, a) : a), {}); // Use the potentially modified data return salla.cart.addItem(data) .then(async (response) => { this.selectedOptions = []; this.btn?.enable(); this.success.emit(response); return response; }) .catch(error => { this.failed.emit(error); this.btn?.enable(); }); } addBookingProduct() { if (salla.config.isGuest()) { salla.auth.api.setAfterLoginEvent('booking::add', this.productId); salla.event.dispatch('login::open'); return; } return salla.booking.add(this.productId) .then(resp => this.success.emit(resp)) .catch(error => this.failed.emit(error)); } getBtnAttributes() { for (let i = 0; i < this.host.attributes.length; i++) { if (!['id', 'class'].includes(this.host.attributes[i].name)) { this.hostAttributes[this.host.attributes[i].name] = this.host.attributes[i].value; } } return this.hostAttributes; } getQuickBuyBtnAttributes() { return { ...this.getBtnAttributes(), type: this.supportStickyBar && !this.isDesktopViewport ? 'plain' : this.productType == 'donating' ? 'donate' : 'buy' }; } miniCheckoutWidget() { let storeId = salla.config.get('store.id'); if (!storeId) { return; } return h("salla-mini-checkout-widget", { language: salla.lang.locale, "store-id": storeId, config: { user: salla.config.get('user'), failure_url: window.location.href }, products: [this.productId], api: salla.config.get('store.api'), outline: true, "form-selector": "form.product-form", class: "s-add-product-button-mini-checkout" }, h("div", { slot: "widget-label", class: "s-add-product-button-mini-checkout-content" }, h("span", { innerHTML: WalletIcon }), this.buyNowText)); } componentWillLoad() { return salla.onReady() .then(() => { // this to fix not added hydrated class to html element after components loaded document.documentElement.classList.add('hydrated'); this.showQuickBuy = this.quickBuy && salla.config.get('store.settings.buy_now') && this.productStatus == 'sale' && this.productType !== 'booking'; this.isApplePayActive = window.ApplePaySession?.canMakePayments() && salla.config.get('store.settings.payments')?.includes('apple_pay') && salla.config.get('store.settings.is_salla_gateway', false); this.passedLabel = this.host.innerHTML.replace('<!---->', '').trim(); if (!!this.passedLabel && this.isDesktopViewport) { return this.btn?.setText(this.passedLabel); } if (this.host.getAttribute('type') === 'submit' && this.supportStickyBar) { this.viewportMediaQuery = window.matchMedia('(min-width: 768px)'); this.viewportMediaQuery.addEventListener('change', this.onViewportChange); } }); } render() { //TODO:: find a better fix, this is a patch for issue that duplicates the buttons more than twice @see the screenshot inside this folder if (this.host.closest('.swiper-slide')?.classList.contains('swiper-slide-duplicate')) { return ''; } if (this.hasSubscribedOptions) { return h(Host, null, h("salla-product-availability", { ...this.getBtnAttributes(), "is-subscribed": true }, h("span", { class: "s-hidden" }, h("slot", null)))); } if ((this.productStatus === 'out-and-notify' && this.channels) || this.hasOutOfStockOption) { return h(Host, null, h("salla-product-availability", { ...this.getBtnAttributes() }, h("span", { class: "s-hidden" }, h("slot", null)))); } return h(Host, { class: { 's-add-product-button-with-quick-buy': this.showQuickBuy, 's-add-product-button-with-sticky-bar': this.supportStickyBar, 's-add-product-button-with-apple-pay': this.showQuickBuy && this.isApplePayActive } }, h("div", { class: { 's-add-product-button-main': this.showQuickBuy, 'w-full': !document.getElementById('fast-checkout-js') || ['financial_support', 'donating'].includes(this.productType) // This is a temporary fix until all themes fully support the fast checkout -- To be removed later } }, h("salla-button", { color: this.productStatus === 'sale' ? 'primary' : 'light', type: "button", fill: this.productStatus === 'sale' ? 'solid' : 'outline', ref: el => this.btn = el, onClick: event => this.addProductToCart(event), disabled: this.productStatus !== 'sale', ...this.getBtnAttributes(), "loader-position": "center" }, h("slot", null)), this.showQuickBuy && !!document.getElementById('fast-checkout-js') && !['financial_support', 'donating'].includes(this.productType) ? this.miniCheckoutWidget() : ''), this.showQuickBuy && this.isApplePayActive ? h("salla-quick-buy", { ...this.getQuickBuyBtnAttributes() }) : ''); } async componentDidLoad() { // Register this component for hooks system, already on it logic to run componentDidLoad trigger await Salla.hooks.registerComponent('salla-add-product-button', this); if (!this.notifyOptionsAvailability) { return; } salla.event.on('product-options::change', async (data) => { if (!['thumbnail', 'color', 'single-option'].includes(data.option.type)) { return; } this.hasSubscribedOptions = false; this.selectedOptions = await document.querySelector(`salla-product-options[product-id="${this.productId}"]`)?.getSelectedOptions(); this.hasOutOfStockOption = await document.querySelector(`salla-product-options[product-id="${this.productId}"]`)?.hasOutOfStockOption(); let subscribedDetails = salla.storage.get(`product-${this.productId}-subscribed-options`); if (!subscribedDetails && !this.subscribedOptions || !this.hasOutOfStockOption) { return; } if (salla.config.isGuest()) { const parsedSubscribedDetails = subscribedDetails ? subscribedDetails.map(ids => ids.split(',').map(id => parseInt(id))) : []; this.hasSubscribedOptions = parsedSubscribedDetails.length > 0 && parsedSubscribedDetails.some(ids => ids.every(id => this.selectedOptions.some(option => option.id === id))); } else { this.hasSubscribedOptions = this.subscribedOptions && this.subscribedOptions !== 'null' && this.subscribedOptions !== '[]' ? JSON.parse(this.subscribedOptions).some(ids => ids.every(id => this.selectedOptions.some(option => option.id === id))) : false; } }); } componentDidRender() { //if label not passed, get label if (!!this.passedLabel && (!this.supportStickyBar || this.isDesktopViewport)) { // if passed label, set it this.btn?.setText(this.passedLabel); return; } this.btn?.setText(this.getLabel()); salla.lang.onLoaded(() => this.btn?.setText(this.getLabel())); } disconnectedCallback() { this.viewportMediaQuery?.removeEventListener('change', this.onViewportChange); } get host() { return getElement(this); } }; SallaAddProductButton.style = sallaAddProductButtonCss; const sallaProductAvailabilityCss = ""; const SallaProductAvailability = class { constructor(hostRef) { registerInstance(this, hostRef); this.isUser = salla.config.isUser(); this.translationLoaded = false; /** * Listen to product options availability. */ this.notifyOptionsAvailability = false; /** * is current user already subscribed */ this.isSubscribed = false; this.handleSubmitOptions = async () => { let payload = { id: this.productId }; if (!this.notifyOptionsAvailability) { return payload; } let optionsElement = document.querySelector(`salla-product-options[product-id="${this.productId}"]`); let options = Object.values(await optionsElement?.getSelectedOptionsData() || {}); //if all options not selected, show message && throw exception if (options.length && !await optionsElement?.reportValidity()) { let errorMessage = salla.lang.get('common.messages.required_fields'); salla.error(errorMessage); throw errorMessage; } payload.options = []; options.forEach(option => { //inject numbers only, without zeros if (option && !isNaN(option)) { payload.options.push(Number(option)); } }); return payload; }; // helpers this.typing = (e) => { const error = e.target.nextElementSibling; e.target.classList.remove('s-has-error'); error?.classList.contains('s-product-availability-error-msg') && (error.innerText = ''); e.keyCode === 13 && this.submit(); }; salla.lang.onLoaded(() => { this.translationLoaded = true; this.title_ = this.host.title || salla.lang.get('pages.products.notify_availability_title'); this.modal?.setTitle(this.title_); }); if (!this.productId) { this.productId = salla.config.get('page.id'); } if (this.isUser) return; this.channelsWatcher(this.channels); this.title_ = this.host.title || salla.lang.get('pages.products.notify_availability_title'); this.host.removeAttribute('title'); //todo:: fix this to cover options too this.isVisitorSubscribed = !this.notifyOptionsAvailability ? salla.storage.get(`product-${this.productId}-subscribed`) : ''; } channelsWatcher(newValue) { this.channels_ = !!newValue ? newValue.split(',') : []; } openModel() { this.handleSubmitOptions().then(isSuccess => isSuccess ? this.modal.open() : null); } async submit() { let payload = await this.handleSubmitOptions(); if (this.isUser) { return salla.api.product.availabilitySubscribe(payload) .then(() => this.isSubscribed = true); } if (this.channels_.includes('sms')) { let { phone, countryCode } = await this.mobileInput.getValues(); payload['country_code'] = countryCode; payload['phone'] = phone; } if (this.channels_.includes('email')) { this.email.value !== '' && (payload['email'] = this.email.value); } await this.validateform(); return this.btn.load() .then(() => this.btn.disable()) .then(() => salla.api.product.availabilitySubscribe(payload)) .then(() => { if (!this.notifyOptionsAvailability) { salla.storage.set(`product-${this.productId}-subscribed`, true); this.isSubscribed = true; return; } if (payload.options.length) { let options = salla.storage.get(`product-${this.productId}-subscribed-options`) || []; let selectedOptionsString = payload.options.join(','); if (!options.includes(selectedOptionsString)) { options.push(selectedOptionsString); salla.storage.set(`product-${this.productId}-subscribed-options`, options); this.isSubscribed = true; } else { salla.log('already subscribed to this options'); } } }) .then(() => this.btn.stop()) .then(() => this.modal.close()) .catch(() => this.btn.stop() && this.btn.enable()); } async validateform() { try { if (this.channels_.includes('email')) { const isEmailValid = Helper.isValidEmail(this.email.value); if (isEmailValid) return; !isEmailValid && this.validateField(this.email, salla.lang.get('common.elements.email_is_valid')); } if (this.channels_.includes('sms')) { const isPhoneValid = await this.mobileInput.isValid(); if (isPhoneValid) return; } } catch (error) { throw ('Please insert required fields'); } } validateField(field, errorMsg) { field.classList.add('s-has-error'); field.nextElementSibling['innerText'] = '* ' + errorMsg; } render() { return (h(Host, { key: 'caab03a9cadb614db15d47cf54d0fe72cc7be482', class: "s-product-availability-wrap" }, this.isSubscribed || this.isVisitorSubscribed ? h("div", { class: "s-product-availability-subscribed" }, h("span", { innerHTML: BellRing, class: "s-product-availability-subs-icon" }), salla.lang.get('pages.products.notify_availability_success')) : h("salla-button", { width: "wide", onClick: () => this.isUser ? this.submit() : this.openModel() }, salla.lang.get('pages.products.notify_availability')), this.isUser || this.isSubscribed || this.isVisitorSubscribed ? '' : this.renderModal())); } renderModal() { return (h("salla-modal", { ref: modal => this.modal = modal, "modal-title": this.title_, subTitle: salla.lang.get('pages.products.notify_availability_subtitle'), width: "sm" }, h("span", { slot: 'icon', class: "s-product-availability-header-icon", innerHTML: BellRing }), h("div", { class: "s-product-availability-body" }, this.channels_.includes('email') ? [ h("label", { class: "s-product-availability-label" }, salla.lang.get('common.elements.email')), h("input", { class: "s-product-availability-input", onKeyDown: e => this.typing(e), placeholder: salla.lang.get('common.elements.email_placeholder') || 'your@email.com', ref: el => this.email = el, type: "email" }), h("span", { class: "s-product-availability-error-msg" }) ] : '', this.channels_.includes('sms') ? [ h("label", { class: "s-product-availability-label" }, salla.lang.get('common.elements.mobile')), h("salla-tel-input", { ref: el => this.mobileInput = el, onKeyDown: e => this.typing(e) }) ] : ''), h("div", { slot: "footer", class: "s-product-availability-footer" }, h("salla-button", { class: "modal-cancel-btn", width: "wide", color: "light", fill: "outline", onClick: () => this.modal.close() }, salla.lang.get('common.elements.cancel')), h("salla-button", { class: "submit-btn", "loader-position": 'center', width: "wide", ref: btn => this.btn = btn, onClick: () => this.submit() }, salla.lang.get('common.elements.submit'))))); } get host() { return getElement(this); } static get watchers() { return { "channels": ["channelsWatcher"] }; } }; SallaProductAvailability.style = sallaProductAvailabilityCss; 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 = class { constructor(hostRef) { registerInstance(this, hostRef); this.changed = createEvent(this, "changed"); 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;