From 45f65d3a770b99b25c7a366c2bf9a36bceefc8c5 Mon Sep 17 00:00:00 2001 From: Lars Hoffbeck Date: Wed, 22 May 2024 11:54:57 -0400 Subject: [PATCH] (#3289) This PR adds a product wrapper class by repurposing the existing product-info class and migrating product-update specific logic out of the VariantSelects class. A product wrapper enables children to more trivially perform global updates by providing a heirarchical "namespace"--i.e. child publishes event, parent captures and declaratively updates other children VS child updates siblings. By extracting the VariantSelects onChange logic to use this pattern, VariantSelects is able to be a single-purpose class, it is easier to understand why siblings are updated, and we were able to eliminate some really gross logic that handled variant change updates differently depending on the wrapping context. --- assets/base.css | 2 +- assets/constants.js | 1 + assets/global.js | 370 +++---------------------- assets/pickup-availability.js | 11 +- assets/product-form.js | 17 +- assets/product-info.js | 358 +++++++++++++++++++++--- assets/quick-add.js | 9 +- assets/quick-order-list.js | 10 - sections/featured-product.liquid | 18 +- sections/main-product.liquid | 8 +- snippets/product-variant-picker.liquid | 6 - 11 files changed, 396 insertions(+), 414 deletions(-) diff --git a/assets/base.css b/assets/base.css index 0687885711c..2689207d730 100644 --- a/assets/base.css +++ b/assets/base.css @@ -1989,7 +1989,7 @@ input[type='checkbox'] { position: relative; } -product-info .loading__spinner:not(.hidden) ~ *, +.product__info-container .loading__spinner:not(.hidden) ~ *, .quantity__rules-cart .loading__spinner:not(.hidden) ~ * { visibility: hidden; } diff --git a/assets/constants.js b/assets/constants.js index 1b016f6fc0f..6af3f9ba4d6 100644 --- a/assets/constants.js +++ b/assets/constants.js @@ -3,6 +3,7 @@ const ON_CHANGE_DEBOUNCE_TIMER = 300; const PUB_SUB_EVENTS = { cartUpdate: 'cart-update', quantityUpdate: 'quantity-update', + variantChangeStart: 'variant-change-start', variantChange: 'variant-change', cartError: 'cart-error', sectionRefreshed: 'section-refreshed', diff --git a/assets/global.js b/assets/global.js index 16a51cbd756..cfb9b561e5c 100644 --- a/assets/global.js +++ b/assets/global.js @@ -6,6 +6,25 @@ function getFocusableElements(container) { ); } +class SectionId { + static #separator = '__'; + + // for a qualified section id (e.g. 'template--22224696705326__main'), return just the section id (e.g. 'template--22224696705326') + static parseId(qualifiedSectionId) { + return qualifiedSectionId.split(SectionId.#separator)[0]; + } + + // for a qualified section id (e.g. 'template--22224696705326__main'), return just the section name (e.g. 'main') + static parseSectionName(qualifiedSectionId) { + return qualifiedSectionId.split(SectionId.#separator)[1]; + } + + // for a section id (e.g. 'template--22224696705326') and a section name (e.g. 'recommended-products'), return a qualified section id (e.g. 'template--22224696705326__recommended-products') + static getIdForSection(sectionId, sectionName) { + return `${sectionId}${SectionId.#separator}${sectionName}`; + } +} + class HTMLUpdateUtility { #preProcessCallbacks = []; #postProcessCallbacks = []; @@ -1008,68 +1027,24 @@ customElements.define('slideshow-component', SlideshowComponent); class VariantSelects extends HTMLElement { constructor() { super(); - this.addEventListener('change', this.handleProductUpdate); - this.initializeProductSwapUtility(); } - initializeProductSwapUtility() { - this.swapProductUtility = new HTMLUpdateUtility(); - this.swapProductUtility.addPreProcessCallback((html) => { - html.querySelectorAll('.scroll-trigger').forEach((element) => element.classList.add('scroll-trigger--cancel')); - return html; - }); - this.swapProductUtility.addPostProcessCallback((newNode) => { - window?.Shopify?.PaymentButton?.init(); - window?.ProductModel?.loadShopifyXR(); - publish(PUB_SUB_EVENTS.sectionRefreshed, { + connectedCallback() { + this.addEventListener('change', (event) => { + const target = this.getInputForEventTarget(event.target); + this.currentVariant = this.getVariantData(target.id); + this.updateSelectedSwatchValue(event); + + publish(PUB_SUB_EVENTS.variantChangeStart, { data: { - sectionId: this.dataset.section, - resource: { - type: SECTION_REFRESH_RESOURCE_TYPE.product, - id: newNode.querySelector('variant-selects').dataset.productId, - }, + event, + target, + variant: this.currentVariant, }, }); }); } - handleProductUpdate(event) { - const input = this.getInputForEventTarget(event.target); - const targetId = input.id; - const targetUrl = input.dataset.productUrl || this.dataset.url; - this.currentVariant = this.getVariantData(targetId); - const sectionId = this.dataset.originalSection || this.dataset.section; - this.updateSelectedSwatchValue(event); - this.toggleAddButton(true, '', false); - this.removeErrorMessage(); - - let callback = () => {}; - if (this.dataset.url !== targetUrl) { - this.updateURL(targetUrl); - this.updateShareUrl(targetUrl); - callback = this.handleSwapProduct(sectionId); - } else if (!this.currentVariant) { - this.setUnavailable(); - callback = (html) => { - this.updatePickupAvailability(); - this.updateOptionValues(html); - }; - } else { - this.updateURL(targetUrl); - this.updateVariantInput(); - this.updateShareUrl(targetUrl); - callback = this.handleUpdateProductInfo(sectionId); - } - - this.renderProductInfo(sectionId, targetUrl, targetId, callback); - } - - getSelectedOptionValues() { - return Array.from(this.querySelectorAll('select, fieldset input:checked')).map( - (element) => element.dataset.optionValueId - ); - } - updateSelectedSwatchValue({ target }) { const { value, tagName } = target; @@ -1097,107 +1072,6 @@ class VariantSelects extends HTMLElement { } } - updateMedia(html) { - const sectionId = this.dataset.originalSection ? this.dataset.originalSection : this.dataset.section; - const mediaGallerySource = document.querySelector(`[id^="MediaGallery-${this.dataset.section}"] ul`); - const mediaGalleryDestination = html.querySelector(`[id^="MediaGallery-${sectionId}"] ul`); - - const refreshSourceData = () => { - const mediaGallerySourceItems = Array.from(mediaGallerySource.querySelectorAll('li[data-media-id]')); - const sourceSet = new Set(mediaGallerySourceItems.map((item) => item.dataset.mediaId)); - const sourceMap = new Map(mediaGallerySourceItems.map((item, index) => [item.dataset.mediaId, { item, index }])); - return [mediaGallerySourceItems, sourceSet, sourceMap]; - }; - - if (mediaGallerySource && mediaGalleryDestination) { - let [mediaGallerySourceItems, sourceSet, sourceMap] = refreshSourceData(); - const mediaGalleryDestinationItems = Array.from(mediaGalleryDestination.querySelectorAll('li[data-media-id]')); - const destinationSet = new Set(mediaGalleryDestinationItems.map(({ dataset }) => dataset.mediaId)); - let shouldRefresh = false; - - // add items from new data not present in DOM - for (let i = mediaGalleryDestinationItems.length - 1; i >= 0; i--) { - if (!sourceSet.has(mediaGalleryDestinationItems[i].dataset.mediaId)) { - mediaGallerySource.prepend(mediaGalleryDestinationItems[i]); - shouldRefresh = true; - } - } - - // remove items from DOM not present in new data - for (let i = 0; i < mediaGallerySourceItems.length; i++) { - if (!destinationSet.has(mediaGallerySourceItems[i].dataset.mediaId)) { - mediaGallerySourceItems[i].remove(); - shouldRefresh = true; - } - } - - // refresh - if (shouldRefresh) [mediaGallerySourceItems, sourceSet, sourceMap] = refreshSourceData(); - - // if media galleries don't match, sort to match new data order - mediaGalleryDestinationItems.forEach((destinationItem, destinationIndex) => { - const sourceData = sourceMap.get(destinationItem.dataset.mediaId); - - if (sourceData && sourceData.index !== destinationIndex) { - mediaGallerySource.insertBefore( - sourceData.item, - mediaGallerySource.querySelector(`li:nth-of-type(${destinationIndex + 1})`) - ); - - // refresh source now that it has been modified - [mediaGallerySourceItems, sourceSet, sourceMap] = refreshSourceData(); - } - }); - } - - if (this.currentVariant.featured_media) { - document - .querySelector(`[id^="MediaGallery-${this.dataset.section}"]`) - ?.setActiveMedia?.(`${this.dataset.section}-${this.currentVariant.featured_media.id}`); - } - - // update media modal - const modalContent = document.querySelector(`#ProductModal-${sectionId} .product-media-modal__content`); - const newModalContent = html.querySelector(`#ProductModal-${sectionId} .product-media-modal__content`); - if (modalContent && newModalContent) modalContent.innerHTML = newModalContent.innerHTML; - } - - updateURL(url) { - if (this.dataset.updateUrl === 'false') return; - window.history.replaceState({}, '', `${url}${this.currentVariant?.id ? `?variant=${this.currentVariant.id}` : ''}`); - } - - updateShareUrl(url) { - const shareButton = document.getElementById(`Share-${this.dataset.section}`); - if (!shareButton || !shareButton.updateUrl) return; - shareButton.updateUrl( - `${window.shopUrl}${url}${this.currentVariant?.id ? `?variant=${this.currentVariant.id}` : ''}` - ); - } - - updateVariantInput() { - const productForms = document.querySelectorAll( - `#product-form-${this.dataset.section}, #product-form-installment-${this.dataset.section}` - ); - productForms.forEach((productForm) => { - const input = productForm.querySelector('input[name="id"]'); - input.value = this.currentVariant.id; - input.dispatchEvent(new Event('change', { bubbles: true })); - }); - } - - updatePickupAvailability() { - const pickUpAvailability = document.querySelector('pickup-availability'); - if (!pickUpAvailability) return; - - if (this.currentVariant && this.currentVariant.available) { - pickUpAvailability.fetchAvailability(this.currentVariant.id); - } else { - pickUpAvailability.removeAttribute('available'); - pickUpAvailability.innerHTML = ''; - } - } - getInputForEventTarget(target) { return target.tagName === 'SELECT' ? target.selectedOptions[0] : target; } @@ -1210,170 +1084,11 @@ class VariantSelects extends HTMLElement { return this.querySelector(`script[type="application/json"][data-resource="${inputId}"]`); } - removeErrorMessage() { - const section = this.closest('section'); - if (!section) return; - - const productForm = section.querySelector('product-form'); - if (productForm) productForm.handleErrorMessage(); - } - - getWrappingSection(sectionId) { - return ( - this.closest(`section[data-section="${sectionId}"]`) || // main-product - this.closest(`quick-add-modal`)?.modalContent || // quick-add - this.closest(`#shopify-section-${sectionId}`) || // featured-product - null + get selectedOptionValues() { + return Array.from(this.querySelectorAll('select, fieldset input:checked')).map( + ({ dataset }) => dataset.optionValueId ); } - - handleSwapProduct(sectionId) { - return (html) => { - const oldContent = this.getWrappingSection(sectionId); - if (!oldContent) { - return; - } - - document.getElementById(`ProductModal-${sectionId}`)?.remove(); - - const response = - html.querySelector(`section[data-section="${sectionId}"]`) /* main/quick-add */ || - html.getElementById(`shopify-section-${sectionId}`); /* featured product*/ - - this.swapProductUtility.viewTransition(oldContent, response); - }; - } - - handleUpdateProductInfo(sectionId) { - return (html) => { - this.updatePickupAvailability(); - this.updateMedia(html); - const priceDestination = document.getElementById(`price-${this.dataset.section}`); - const priceSource = html.getElementById(`price-${sectionId}`); - const skuSource = html.getElementById(`Sku-${sectionId}`); - const skuDestination = document.getElementById(`Sku-${this.dataset.section}`); - const inventorySource = html.getElementById(`Inventory-${sectionId}`); - const inventoryDestination = document.getElementById(`Inventory-${this.dataset.section}`); - - const volumePricingSource = html.getElementById(`Volume-${sectionId}`); - - const pricePerItemDestination = document.getElementById(`Price-Per-Item-${this.dataset.section}`); - const pricePerItemSource = html.getElementById(`Price-Per-Item-${sectionId}`); - - const volumePricingDestination = document.getElementById(`Volume-${this.dataset.section}`); - const qtyRules = document.getElementById(`Quantity-Rules-${this.dataset.section}`); - const volumeNote = document.getElementById(`Volume-Note-${this.dataset.section}`); - - if (volumeNote) volumeNote.classList.remove('hidden'); - if (volumePricingDestination) volumePricingDestination.classList.remove('hidden'); - if (qtyRules) qtyRules.classList.remove('hidden'); - if (priceSource && priceDestination) priceDestination.innerHTML = priceSource.innerHTML; - if (inventorySource && inventoryDestination) inventoryDestination.innerHTML = inventorySource.innerHTML; - if (skuSource && skuDestination) { - skuDestination.innerHTML = skuSource.innerHTML; - skuDestination.classList.toggle('hidden', skuSource.classList.contains('hidden')); - } - if (volumePricingSource && volumePricingDestination) { - volumePricingDestination.innerHTML = volumePricingSource.innerHTML; - } - if (pricePerItemSource && pricePerItemDestination) { - pricePerItemDestination.innerHTML = pricePerItemSource.innerHTML; - pricePerItemDestination.classList.toggle('hidden', pricePerItemSource.classList.contains('hidden')); - } - - const price = document.getElementById(`price-${this.dataset.section}`); - if (price) price.classList.remove('hidden'); - - if (inventoryDestination) inventoryDestination.classList.toggle('hidden', inventorySource.innerText === ''); - - const addButtonUpdated = html.getElementById(`ProductSubmitButton-${sectionId}`); - this.toggleAddButton( - addButtonUpdated ? addButtonUpdated.hasAttribute('disabled') : true, - window.variantStrings.soldOut - ); - - this.updateOptionValues(html); - - publish(PUB_SUB_EVENTS.variantChange, { - data: { - sectionId, - html, - variant: this.currentVariant, - }, - }); - }; - } - - updateOptionValues(html) { - const variantSelects = html.querySelector('variant-selects'); - if (variantSelects) this.innerHTML = variantSelects.innerHTML; - } - - renderProductInfo(sectionId, url, targetId, callback) { - const params = this.currentVariant - ? `variant=${this.currentVariant?.id}` - : `option_values=${this.getSelectedOptionValues().join(',')}`; - - this.abortController?.abort(); - this.abortController = new AbortController(); - - fetch(`${url}?section_id=${sectionId}&${params}`, { - signal: this.abortController.signal, - }) - .then((response) => response.text()) - .then((responseText) => { - const html = new DOMParser().parseFromString(responseText, 'text/html'); - callback(html); - }) - .then(() => { - // set focus to last clicked option value - document.getElementById(targetId).focus(); - }); - } - - toggleAddButton(disable = true, text, modifyClass = true) { - const productForm = document.getElementById(`product-form-${this.dataset.section}`); - if (!productForm) return; - const addButton = productForm.querySelector('[name="add"]'); - const addButtonText = productForm.querySelector('[name="add"] > span'); - if (!addButton) return; - - if (disable) { - addButton.setAttribute('disabled', 'disabled'); - if (text) addButtonText.textContent = text; - } else { - addButton.removeAttribute('disabled'); - addButtonText.textContent = window.variantStrings.addToCart; - } - } - - setUnavailable() { - this.toggleAddButton(true, '', true); - const button = document.getElementById(`product-form-${this.dataset.section}`); - const addButton = button.querySelector('[name="add"]'); - const addButtonText = button.querySelector('[name="add"] > span'); - const price = document.getElementById(`price-${this.dataset.section}`); - const inventory = document.getElementById(`Inventory-${this.dataset.section}`); - const sku = document.getElementById(`Sku-${this.dataset.section}`); - const pricePerItem = document.getElementById(`Price-Per-Item-${this.dataset.section}`); - const volumeNote = document.getElementById(`Volume-Note-${this.dataset.section}`); - const volumeTable = document.getElementById(`Volume-${this.dataset.section}`); - const qtyRules = document.getElementById(`Quantity-Rules-${this.dataset.section}`); - - if (!addButton) return; - addButtonText.textContent = window.variantStrings.unavailable; - if (price) price.classList.add('hidden'); - if (inventory) inventory.classList.add('hidden'); - if (sku) sku.classList.add('hidden'); - if (pricePerItem) pricePerItem.classList.add('hidden'); - if (volumeNote) volumeNote.classList.add('hidden'); - if (volumeTable) volumeTable.classList.add('hidden'); - if (qtyRules) qtyRules.classList.add('hidden'); - } - - getInputSelector() { - return 'variant-selects fieldset input[type="radio"], variant-selects select option'; - } } customElements.define('variant-selects', VariantSelects); @@ -1386,39 +1101,28 @@ class ProductRecommendations extends HTMLElement { } connectedCallback() { - this.initializeRecommendations(); - - this.unsubscribeFromSectionRefresh = subscribe(PUB_SUB_EVENTS.sectionRefreshed, (event) => { - const sectionId = this.dataset.sectionId; - const isRelatedProduct = this.classList.contains('related-products'); - const isParentSectionUpdated = sectionId && (event.data?.sectionId ?? '') === `${sectionId.split('__')[0]}__main`; - - if (isRelatedProduct && isParentSectionUpdated) { - this.dataset.productId = event.data.resource.id; - this.initializeRecommendations(); - } - }); + this.initializeRecommendations(this.dataset.productId); } disconnectedCallback() { this.unsubscribeFromSectionRefresh(); } - initializeRecommendations() { + initializeRecommendations(productId) { this.observer?.unobserve(this); this.observer = new IntersectionObserver( (entries, observer) => { if (!entries[0].isIntersecting) return; observer.unobserve(this); - this.loadRecommendations(); + this.loadRecommendations(productId); }, { rootMargin: '0px 0px 400px 0px' } ); this.observer.observe(this); } - loadRecommendations() { - fetch(`${this.dataset.url}&product_id=${this.dataset.productId}§ion_id=${this.dataset.sectionId}`) + loadRecommendations(productId) { + fetch(`${this.dataset.url}&product_id=${productId}§ion_id=${this.dataset.sectionId}`) .then((response) => response.text()) .then((text) => { const html = document.createElement('div'); diff --git a/assets/pickup-availability.js b/assets/pickup-availability.js index c272253628c..1b5ebd63579 100644 --- a/assets/pickup-availability.js +++ b/assets/pickup-availability.js @@ -36,10 +36,19 @@ if (!customElements.get('pickup-availability')) { }); } - onClickRefreshList(evt) { + onClickRefreshList() { this.fetchAvailability(this.dataset.variantId); } + update(variant) { + if (variant?.available) { + this.fetchAvailability(variant.id); + } else { + this.removeAttribute('available'); + this.innerHTML = ''; + } + } + renderError() { this.innerHTML = ''; this.appendChild(this.errorHtml); diff --git a/assets/product-form.js b/assets/product-form.js index da186473160..3fafed85276 100644 --- a/assets/product-form.js +++ b/assets/product-form.js @@ -6,10 +6,11 @@ if (!customElements.get('product-form')) { super(); this.form = this.querySelector('form'); - this.form.querySelector('[name=id]').disabled = false; + this.variantIdInput.disabled = false; this.form.addEventListener('submit', this.onSubmitHandler.bind(this)); this.cart = document.querySelector('cart-notification') || document.querySelector('cart-drawer'); this.submitButton = this.querySelector('[type="submit"]'); + this.submitButtonText = this.querySelector('[type="submit"] > span'); if (document.querySelector('cart-drawer')) this.submitButton.setAttribute('aria-haspopup', 'dialog'); @@ -113,6 +114,20 @@ if (!customElements.get('product-form')) { this.errorMessage.textContent = errorMessage; } } + + toggleSubmitButton(disable = true, text) { + if (disable) { + this.submitButton.setAttribute('disabled', 'disabled'); + if (text) this.submitButtonText.textContent = text; + } else { + this.submitButton.removeAttribute('disabled'); + this.submitButtonText.textContent = window.variantStrings.addToCart; + } + } + + get variantIdInput() { + return this.form.querySelector('[name=id]'); + } } ); } diff --git a/assets/product-info.js b/assets/product-info.js index 1fa35239125..924ae279932 100644 --- a/assets/product-info.js +++ b/assets/product-info.js @@ -2,85 +2,327 @@ if (!customElements.get('product-info')) { customElements.define( 'product-info', class ProductInfo extends HTMLElement { + quantityInput = undefined; + quantityForm = undefined; + onVariantChangeUnsubscriber = undefined; + cartUpdateUnsubscriber = undefined; + swapProductUtility = undefined; + abortController = undefined; + constructor() { super(); - this.input = this.querySelector('.quantity__input'); - this.currentVariant = this.querySelector('.product-variant-id'); - this.submitButton = this.querySelector('[type="submit"]'); - } - cartUpdateUnsubscriber = undefined; - variantChangeUnsubscriber = undefined; + this.quantityInput = this.querySelector('.quantity__input'); + } connectedCallback() { - if (!this.input) return; + this.#initializeProductSwapUtility(); + + this.onVariantChangeUnsubscriber = subscribe( + PUB_SUB_EVENTS.variantChangeStart, + this.#handleOptionValueChange.bind(this) + ); + + this.#initQuantityHandlers(); + } + + #initQuantityHandlers() { + if (!this.quantityInput) return; + this.quantityForm = this.querySelector('.product-form__quantity'); if (!this.quantityForm) return; - this.setQuantityBoundries(); + + this.#setQuantityBoundries(); if (!this.dataset.originalSection) { - this.cartUpdateUnsubscriber = subscribe(PUB_SUB_EVENTS.cartUpdate, this.fetchQuantityRules.bind(this)); + this.cartUpdateUnsubscriber = subscribe(PUB_SUB_EVENTS.cartUpdate, this.#fetchQuantityRules.bind(this)); } - this.variantChangeUnsubscriber = subscribe(PUB_SUB_EVENTS.variantChange, (event) => { - const sectionId = this.dataset.originalSection ? this.dataset.originalSection : this.dataset.section; - if (event.data.sectionId !== sectionId) return; - this.updateQuantityRules(event.data.sectionId, event.data.html); - this.setQuantityBoundries(); - }); } disconnectedCallback() { - if (this.cartUpdateUnsubscriber) { - this.cartUpdateUnsubscriber(); + this.onVariantChangeUnsubscriber(); + this.cartUpdateUnsubscriber?.(); + } + + #initializeProductSwapUtility() { + this.swapProductUtility = new HTMLUpdateUtility(); + this.swapProductUtility.addPreProcessCallback((html) => + html.querySelectorAll('.scroll-trigger').forEach((element) => element.classList.add('scroll-trigger--cancel')) + ); + this.swapProductUtility.addPostProcessCallback((newNode) => { + window?.Shopify?.PaymentButton?.init(); + window?.ProductModel?.loadShopifyXR(); + publish(PUB_SUB_EVENTS.sectionRefreshed, { + data: { + sectionId: this.sectionId, + resource: { + type: SECTION_REFRESH_RESOURCE_TYPE.product, + id: newNode.dataset.productId, + }, + }, + }); + }); + } + + #handleOptionValueChange({ data: { event, target, variant } }) { + if (!this.contains(event.target)) return; + + const targetUrl = target.dataset.productUrl || this.dataset.url; + + const productForm = this.productForm; + productForm?.toggleSubmitButton(true); + productForm?.handleErrorMessage(); + + let callback = () => {}; + if (this.dataset.url !== targetUrl) { + this.#updateURL(targetUrl, variant?.id); + this.#updateShareUrl(targetUrl, variant?.id); + callback = this.#handleSwapProduct(); + } else if (!variant) { + this.#setUnavailable(); + callback = (html) => { + this.pickupAvailability?.update(variant); + this.#updateOptionValues(html); + }; + } else { + this.#updateURL(targetUrl, variant.id); + this.#updateShareUrl(targetUrl, variant.id); + this.#updateVariantInputs(variant.id); + callback = this.#handleUpdateProductInfo(variant); + } + + this.#renderProductInfo(targetUrl, variant?.id, target.id, callback); + } + + #handleSwapProduct() { + return (html) => { + this.productModal?.remove(); + const newProduct = html.querySelector('product-info'); + this.swapProductUtility.viewTransition(this, newProduct); + this.relatedProducts?.initializeRecommendations(newProduct.dataset.productId); + this.quickOrderList?.refresh(); + }; + } + + #renderProductInfo(url, variantId, targetId, callback) { + this.abortController?.abort(); + this.abortController = new AbortController(); + + fetch(this.#getProductInfoUrl(url, variantId), { + signal: this.abortController.signal, + }) + .then((response) => response.text()) + .then((responseText) => { + const html = new DOMParser().parseFromString(responseText, 'text/html'); + callback(html); + }) + .then(() => { + // set focus to last clicked option value + document.querySelector(`#${targetId}`)?.focus(); + }); + } + + #getProductInfoUrl(url, variantId) { + let params; + if (variantId) { + params = `variant=${variantId}`; + } else { + const optionValues = this.variantSelectors.selectedOptionValues; + if (optionValues.length) { + params = `option_values=${optionValues.join(',')}`; + } + } + + return `${url}?section_id=${this.sectionId}&${params}`; + } + + #updateOptionValues(html) { + const variantSelects = html.querySelector('variant-selects'); + if (variantSelects) this.variantSelectors.innerHTML = variantSelects.innerHTML; + } + + #handleUpdateProductInfo(variant) { + return (html) => { + this.pickupAvailability?.update(variant); + this.#updateMedia(html, variant?.featured_media?.id); + this.#updateOptionValues(html); + + const updateSourceFromDestination = (id, shouldHide = (source) => false) => { + const source = html.getElementById(`${id}-${this.sectionId}`); + const destination = this.querySelector(`#${id}-${this.dataset.section}`); + if (source && destination) { + destination.innerHTML = source.innerHTML; + destination.classList.toggle('hidden', shouldHide(source)); + } + }; + + updateSourceFromDestination('price'); + updateSourceFromDestination('Sku', ({ classList }) => classList.contains('hidden')); + updateSourceFromDestination('Inventory', ({ innerText }) => innerText === ''); + updateSourceFromDestination('Volume'); + updateSourceFromDestination('Price-Per-Item', ({ classList }) => classList.contains('hidden')); + + this.#updateQuantityRules(this.sectionId, html); + this.querySelector(`#Quantity-Rules-${this.dataset.section}`)?.classList.remove('hidden'); + this.querySelector(`#Volume-Note-${this.dataset.section}`)?.classList.remove('hidden'); + + this.productForm?.toggleSubmitButton( + html.getElementById(`ProductSubmitButton-${this.sectionId}`)?.hasAttribute('disabled') ?? true, + window.variantStrings.soldOut + ); + + publish(PUB_SUB_EVENTS.variantChange, { + data: { + sectionId: this.sectionId, + html, + variant, + }, + }); + }; + } + + #updateVariantInputs(variantId) { + document + .querySelectorAll(`#product-form-${this.dataset.section}, #product-form-installment-${this.dataset.section}`) + .forEach((productForm) => { + const input = productForm.querySelector('input[name="id"]'); + input.value = variantId; + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + } + + #updateURL(url, variantId) { + if (this.dataset.updateUrl === 'false') return; + window.history.replaceState({}, '', `${url}${variantId ? `?variant=${variantId}` : ''}`); + } + + #updateShareUrl(url, variantId) { + this.querySelector('share-url')?.updateUrl( + `${window.shopUrl}${url}${variantId ? `?variant=${variantId}` : ''}` + ); + } + + #setUnavailable() { + this.productForm?.toggleSubmitButton(true, window.variantStrings.unavailable); + + const selectors = ['price', 'Inventory', 'Sku', 'Price-Per-Item', 'Volume-Note', 'Volume', 'Quantity-Rules'] + .map((id) => `#${id}-${this.dataset.section}`) + .join(', '); + document.querySelectorAll(selectors).forEach(({ classList }) => classList.add('hidden')); + } + + #updateMedia(html, variantFeaturedMediaId) { + const mediaGallerySource = this.querySelector('media-gallery ul'); + const mediaGalleryDestination = html.querySelector(`media-gallery ul`); + + const refreshSourceData = () => { + const mediaGallerySourceItems = Array.from(mediaGallerySource.querySelectorAll('li[data-media-id]')); + const sourceSet = new Set(mediaGallerySourceItems.map((item) => item.dataset.mediaId)); + const sourceMap = new Map( + mediaGallerySourceItems.map((item, index) => [item.dataset.mediaId, { item, index }]) + ); + return [mediaGallerySourceItems, sourceSet, sourceMap]; + }; + + if (mediaGallerySource && mediaGalleryDestination) { + let [mediaGallerySourceItems, sourceSet, sourceMap] = refreshSourceData(); + const mediaGalleryDestinationItems = Array.from( + mediaGalleryDestination.querySelectorAll('li[data-media-id]') + ); + const destinationSet = new Set(mediaGalleryDestinationItems.map(({ dataset }) => dataset.mediaId)); + let shouldRefresh = false; + + // add items from new data not present in DOM + for (let i = mediaGalleryDestinationItems.length - 1; i >= 0; i--) { + if (!sourceSet.has(mediaGalleryDestinationItems[i].dataset.mediaId)) { + mediaGallerySource.prepend(mediaGalleryDestinationItems[i]); + shouldRefresh = true; + } + } + + // remove items from DOM not present in new data + for (let i = 0; i < mediaGallerySourceItems.length; i++) { + if (!destinationSet.has(mediaGallerySourceItems[i].dataset.mediaId)) { + mediaGallerySourceItems[i].remove(); + shouldRefresh = true; + } + } + + // refresh + if (shouldRefresh) [mediaGallerySourceItems, sourceSet, sourceMap] = refreshSourceData(); + + // if media galleries don't match, sort to match new data order + mediaGalleryDestinationItems.forEach((destinationItem, destinationIndex) => { + const sourceData = sourceMap.get(destinationItem.dataset.mediaId); + + if (sourceData && sourceData.index !== destinationIndex) { + mediaGallerySource.insertBefore( + sourceData.item, + mediaGallerySource.querySelector(`li:nth-of-type(${destinationIndex + 1})`) + ); + + // refresh source now that it has been modified + [mediaGallerySourceItems, sourceSet, sourceMap] = refreshSourceData(); + } + }); } - if (this.variantChangeUnsubscriber) { - this.variantChangeUnsubscriber(); + + if (variantFeaturedMediaId) { + // set featured media as active in the media gallery + this.querySelector(`media-gallery`)?.setActiveMedia?.( + `${this.dataset.section}-${variantFeaturedMediaId}`, + false + ); + + // update media modal + const modalContent = this.productModal?.querySelector(`.product-media-modal__content`); + const newModalContent = html.querySelector(`product-modal .product-media-modal__content`); + if (modalContent && newModalContent) modalContent.innerHTML = newModalContent.innerHTML; } } - setQuantityBoundries() { + #setQuantityBoundries() { const data = { - cartQuantity: this.input.dataset.cartQuantity ? parseInt(this.input.dataset.cartQuantity) : 0, - min: this.input.dataset.min ? parseInt(this.input.dataset.min) : 1, - max: this.input.dataset.max ? parseInt(this.input.dataset.max) : null, - step: this.input.step ? parseInt(this.input.step) : 1, + cartQuantity: this.quantityInput.dataset.cartQuantity ? parseInt(this.quantityInput.dataset.cartQuantity) : 0, + min: this.quantityInput.dataset.min ? parseInt(this.quantityInput.dataset.min) : 1, + max: this.quantityInput.dataset.max ? parseInt(this.quantityInput.dataset.max) : null, + step: this.quantityInput.step ? parseInt(this.quantityInput.step) : 1, }; let min = data.min; const max = data.max === null ? data.max : data.max - data.cartQuantity; if (max !== null) min = Math.min(min, max); if (data.cartQuantity >= data.min) min = Math.min(min, data.step); - this.input.min = min; + + this.quantityInput.min = min; if (max) { - this.input.max = max; + this.quantityInput.max = max; } else { - this.input.removeAttribute('max'); + this.quantityInput.removeAttribute('max'); } - this.input.value = min; + this.quantityInput.value = min; + publish(PUB_SUB_EVENTS.quantityUpdate, undefined); } - fetchQuantityRules() { - if (!this.currentVariant || !this.currentVariant.value) return; + #fetchQuantityRules() { + const currentVariantId = this.productForm?.variantIdInput?.value; + if (!currentVariantId) return; + this.querySelector('.quantity__rules-cart .loading__spinner').classList.remove('hidden'); - fetch(`${this.dataset.url}?variant=${this.currentVariant.value}§ion_id=${this.dataset.section}`) - .then((response) => { - return response.text(); - }) + fetch(`${this.dataset.url}?variant=${currentVariantId}§ion_id=${this.dataset.section}`) + .then((response) => response.text()) .then((responseText) => { const html = new DOMParser().parseFromString(responseText, 'text/html'); - this.updateQuantityRules(this.dataset.section, html); - this.setQuantityBoundries(); - }) - .catch((e) => { - console.error(e); + this.#updateQuantityRules(this.dataset.section, html); }) - .finally(() => { - this.querySelector('.quantity__rules-cart .loading__spinner').classList.add('hidden'); - }); + .catch((e) => console.error(e)) + .finally(() => this.querySelector('.quantity__rules-cart .loading__spinner').classList.add('hidden')); } - updateQuantityRules(sectionId, html) { + #updateQuantityRules(sectionId, html) { + this.#setQuantityBoundries(); + const quantityFormUpdated = html.getElementById(`Quantity-Form-${sectionId}`); const selectors = ['.quantity__input', '.quantity__rules', '.quantity__label']; for (let selector of selectors) { @@ -102,6 +344,36 @@ if (!customElements.get('product-info')) { } } } + + get productForm() { + return this.querySelector(`product-form`); + } + + get productModal() { + return document.querySelector(`#ProductModal-${this.dataset.section}`); + } + + get pickupAvailability() { + return this.querySelector(`pickup-availability`); + } + + get variantSelectors() { + return this.querySelector('variant-selects'); + } + + get relatedProducts() { + const relatedProductsSectionId = SectionId.getIdForSection(SectionId.parseId(this.sectionId), 'related-products'); + return document.querySelector(`product-recommendations[data-section-id^="${relatedProductsSectionId}"]`); + } + + get quickOrderList() { + const quickOrderListSectionId = SectionId.getIdForSection(SectionId.parseId(this.sectionId), 'quick_order_list'); + return document.querySelector(`quick-order-list[data-id^="${quickOrderListSectionId}"]`); + } + + get sectionId() { + return this.dataset.originalSection || this.dataset.section; + } } ); } diff --git a/assets/quick-add.js b/assets/quick-add.js index 5365b4011fd..b0a05ecd642 100644 --- a/assets/quick-add.js +++ b/assets/quick-add.js @@ -69,10 +69,7 @@ if (!customElements.get('quick-add-modal')) { } preventVariantURLSwitching(productElement) { - const variantPicker = productElement.querySelector('variant-selects'); - if (!variantPicker) return; - - variantPicker.setAttribute('data-update-url', 'false'); + productElement.querySelector('product-info')?.setAttribute('data-update-url', 'false'); } removeDOMElements(productElement) { @@ -89,9 +86,7 @@ if (!customElements.get('quick-add-modal')) { preventDuplicatedIDs(productElement) { const sectionId = productElement.dataset.section; productElement.innerHTML = productElement.innerHTML.replaceAll(sectionId, `quickadd-${sectionId}`); - productElement.querySelectorAll('variant-selects, product-info').forEach((element) => { - element.dataset.originalSection = sectionId; - }); + productElement.querySelector('product-info').dataset.originalSection = sectionId; } removeGalleryListSemantic(productElement) { diff --git a/assets/quick-order-list.js b/assets/quick-order-list.js index 48a035e98ed..433fb6972e8 100644 --- a/assets/quick-order-list.js +++ b/assets/quick-order-list.js @@ -106,7 +106,6 @@ if (!customElements.get('quick-order-list')) { } cartUpdateUnsubscriber = undefined; - sectionRefreshUnsubscriber = undefined; onSubmit(event) { event.preventDefault(); @@ -123,20 +122,11 @@ if (!customElements.get('quick-order-list')) { this.addMultipleDebounce(); }); }); - this.sectionRefreshUnsubscriber = subscribe(PUB_SUB_EVENTS.sectionRefreshed, (event) => { - const isParentSectionUpdated = - this.sectionId && (event.data?.sectionId ?? '') === `${this.sectionId.split('__')[0]}__main`; - - if (isParentSectionUpdated) { - this.refresh(); - } - }); this.sectionId = this.dataset.id; } disconnectedCallback() { this.cartUpdateUnsubscriber?.(); - this.sectionRefreshUnsubscriber?.(); } defineInputsAndQuickOrderTable() { diff --git a/sections/featured-product.liquid b/sections/featured-product.liquid index 3c31b962d02..b6a378dddf0 100644 --- a/sections/featured-product.liquid +++ b/sections/featured-product.liquid @@ -1,3 +1,9 @@ +{%- liquid + assign product = section.settings.product +-%} + + + {{ 'section-main-product.css' | asset_url | stylesheet_tag }} {{ 'section-featured-product.css' | asset_url | stylesheet_tag }} {{ 'component-accordion.css' | asset_url | stylesheet_tag }} @@ -29,10 +35,6 @@ -{%- liquid - assign product = section.settings.product --%} - {% comment %} TODO: assign `product.selected_or_first_available_variant` to variable and replace usage to reduce verbosity {% endcomment %} {%- assign first_3d_model = product.media | where: 'media_type', 'model' | first -%} @@ -74,7 +76,7 @@ {%- endif -%}
- - +
{% render 'product-media-modal', product: product, variant_images: variant_images %} @@ -506,6 +507,7 @@ {% endif %} +
{% schema %} { diff --git a/sections/main-product.liquid b/sections/main-product.liquid index 5b2becf93c5..aa8b6b6a02a 100644 --- a/sections/main-product.liquid +++ b/sections/main-product.liquid @@ -3,6 +3,7 @@ class="section-{{ section.id }}-padding gradient color-{{ section.settings.color_scheme }}" data-section="{{ section.id }}" > + {{ 'section-main-product.css' | asset_url | stylesheet_tag }} {{ 'component-accordion.css' | asset_url | stylesheet_tag }} {{ 'component-price.css' | asset_url | stylesheet_tag }} @@ -73,10 +74,8 @@ {% render 'product-media-gallery', variant_images: variant_images %}
- {%- assign product_form_id = 'product-form-' | append: section.id -%} @@ -649,7 +648,7 @@ {{ 'products.product.view_full_details' | t }} {% render 'icon-arrow' %} - +
@@ -746,6 +745,7 @@ } +
{% schema %} diff --git a/snippets/product-variant-picker.liquid b/snippets/product-variant-picker.liquid index 9e7851c6f47..cc16f27e7fa 100644 --- a/snippets/product-variant-picker.liquid +++ b/snippets/product-variant-picker.liquid @@ -5,7 +5,6 @@ - product: {Object} product object. - block: {Object} passing the block information. - product_form_id: {String} Id of the product form to which the variant picker is associated. - - update_url: {Boolean} whether or not to update url when changing variants. If false, the url isn't updated. Default: true (optional). Usage: {% render 'product-variant-picker', product: product, block: block, product_form_id: product_form_id %} {% endcomment %} @@ -13,11 +12,6 @@ {%- for option in product.options_with_values -%}