446 lines
17 KiB
JavaScript
446 lines
17 KiB
JavaScript
if (!customElements.get('product-info')) {
|
|
customElements.define(
|
|
'product-info',
|
|
class ProductInfo extends HTMLElement {
|
|
quantityInput = undefined;
|
|
quantityForm = undefined;
|
|
onVariantChangeUnsubscriber = undefined;
|
|
cartUpdateUnsubscriber = undefined;
|
|
abortController = undefined;
|
|
pendingRequestUrl = null;
|
|
preProcessHtmlCallbacks = [];
|
|
postProcessHtmlCallbacks = [];
|
|
|
|
constructor() {
|
|
super();
|
|
|
|
this.quantityInput = this.querySelector('.quantity__input');
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.initializeProductSwapUtility();
|
|
|
|
this.onVariantChangeUnsubscriber = subscribe(
|
|
PUB_SUB_EVENTS.optionValueSelectionChange,
|
|
this.handleOptionValueChange.bind(this)
|
|
);
|
|
|
|
this.initQuantityHandlers();
|
|
this.dispatchEvent(new CustomEvent('product-info:loaded', { bubbles: true }));
|
|
}
|
|
|
|
addPreProcessCallback(callback) {
|
|
this.preProcessHtmlCallbacks.push(callback);
|
|
}
|
|
|
|
initQuantityHandlers() {
|
|
if (!this.quantityInput) return;
|
|
|
|
this.quantityForm = this.querySelector('.product-form__quantity');
|
|
if (!this.quantityForm) return;
|
|
|
|
this.setQuantityBoundries();
|
|
if (!this.dataset.originalSection) {
|
|
this.cartUpdateUnsubscriber = subscribe(PUB_SUB_EVENTS.cartUpdate, this.fetchQuantityRules.bind(this));
|
|
}
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
this.onVariantChangeUnsubscriber();
|
|
this.cartUpdateUnsubscriber?.();
|
|
}
|
|
|
|
initializeProductSwapUtility() {
|
|
this.preProcessHtmlCallbacks.push((html) =>
|
|
html.querySelectorAll('.scroll-trigger').forEach((element) => element.classList.add('scroll-trigger--cancel'))
|
|
);
|
|
this.postProcessHtmlCallbacks.push((newNode) => {
|
|
window?.Shopify?.PaymentButton?.init();
|
|
window?.ProductModel?.loadShopifyXR();
|
|
});
|
|
}
|
|
|
|
handleOptionValueChange({ data: { event, target, selectedOptionValues } }) {
|
|
if (!this.contains(event.target)) return;
|
|
|
|
this.resetProductFormState();
|
|
|
|
const productUrl = target.dataset.productUrl || this.pendingRequestUrl || this.dataset.url;
|
|
this.pendingRequestUrl = productUrl;
|
|
const shouldSwapProduct = this.dataset.url !== productUrl;
|
|
const shouldFetchFullPage = this.dataset.updateUrl === 'true' && shouldSwapProduct;
|
|
|
|
this.renderProductInfo({
|
|
requestUrl: this.buildRequestUrlWithParams(productUrl, selectedOptionValues, shouldFetchFullPage),
|
|
targetId: target.id,
|
|
callback: shouldSwapProduct
|
|
? this.handleSwapProduct(productUrl, shouldFetchFullPage)
|
|
: this.handleUpdateProductInfo(productUrl),
|
|
});
|
|
}
|
|
|
|
resetProductFormState() {
|
|
const productForm = this.productForm;
|
|
productForm?.toggleSubmitButton(true);
|
|
productForm?.handleErrorMessage();
|
|
}
|
|
|
|
handleSwapProduct(productUrl, updateFullPage) {
|
|
return (html) => {
|
|
this.productModal?.remove();
|
|
|
|
const selector = updateFullPage ? "product-info[id^='MainProduct']" : 'product-info';
|
|
const sourceProductInfo = html.querySelector(selector);
|
|
const variant = this.getSelectedVariant(sourceProductInfo);
|
|
this.variantSelectors?.resolvePendingSelectPromise(variant, this.getVariantSelects(sourceProductInfo));
|
|
this.updateURL(productUrl, variant?.id);
|
|
|
|
if (updateFullPage) {
|
|
document.querySelector('head title').innerHTML = html.querySelector('head title').innerHTML;
|
|
|
|
HTMLUpdateUtility.viewTransition(
|
|
document.querySelector('main'),
|
|
html.querySelector('main'),
|
|
this.preProcessHtmlCallbacks,
|
|
this.postProcessHtmlCallbacks
|
|
);
|
|
} else {
|
|
HTMLUpdateUtility.viewTransition(
|
|
this,
|
|
html.querySelector('product-info'),
|
|
this.preProcessHtmlCallbacks,
|
|
this.postProcessHtmlCallbacks
|
|
);
|
|
}
|
|
};
|
|
}
|
|
|
|
renderProductInfo({ requestUrl, targetId, callback }) {
|
|
this.abortController?.abort();
|
|
this.abortController = new AbortController();
|
|
|
|
fetch(requestUrl, { signal: this.abortController.signal })
|
|
.then((response) => response.text())
|
|
.then((responseText) => {
|
|
this.pendingRequestUrl = null;
|
|
const html = new DOMParser().parseFromString(responseText, 'text/html');
|
|
callback(html);
|
|
// set focus to last clicked option value
|
|
document.querySelector(`#${targetId}`)?.focus();
|
|
})
|
|
.catch((error) => {
|
|
if (error.name === 'AbortError') {
|
|
console.log('Fetch aborted by user');
|
|
} else {
|
|
console.error(error);
|
|
}
|
|
this.variantSelectors?.rejectPendingSelectPromise(error);
|
|
});
|
|
}
|
|
|
|
parseJsonScript(parent, selector) {
|
|
try {
|
|
return JSON.parse(parent?.querySelector(selector)?.textContent);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
getVariantSelects(queryRoot) {
|
|
return queryRoot?.querySelector('variant-selects');
|
|
}
|
|
|
|
getSelectedVariant(productInfoNode) {
|
|
return this.parseJsonScript(this.getVariantSelects(productInfoNode), '[data-selected-variant]');
|
|
}
|
|
|
|
buildRequestUrlWithParams(url, optionValues, shouldFetchFullPage = false) {
|
|
const params = [];
|
|
|
|
!shouldFetchFullPage && params.push(`section_id=${this.sectionId}`);
|
|
|
|
if (optionValues.length) {
|
|
params.push(`option_values=${optionValues.join(',')}`);
|
|
}
|
|
|
|
return `${url}?${params.join('&')}`;
|
|
}
|
|
|
|
updateOptionValues(html) {
|
|
const variantSelects = html.querySelector('variant-selects');
|
|
if (variantSelects) {
|
|
HTMLUpdateUtility.viewTransition(this.variantSelectors, variantSelects, this.preProcessHtmlCallbacks);
|
|
}
|
|
}
|
|
|
|
handleUpdateProductInfo(productUrl) {
|
|
return (html) => {
|
|
const sourceVariantSelects = this.getVariantSelects(html);
|
|
const variant = this.getSelectedVariant(html);
|
|
|
|
// Resolve product:select promise before updateOptionValues replaces the variant-selects DOM element
|
|
this.variantSelectors?.resolvePendingSelectPromise(variant, sourceVariantSelects);
|
|
|
|
this.pickupAvailability?.update(variant);
|
|
this.updateOptionValues(html);
|
|
this.updateURL(productUrl, variant?.id);
|
|
this.updateVariantInputs(variant?.id);
|
|
|
|
if (!variant) {
|
|
this.setUnavailable();
|
|
return;
|
|
}
|
|
|
|
this.updateMedia(html, variant?.featured_media?.id);
|
|
|
|
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) {
|
|
this.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) {
|
|
this.querySelector('share-button')?.updateUrl(
|
|
`${window.shopUrl}${url}${variantId ? `?variant=${variantId}` : ''}`
|
|
);
|
|
|
|
if (this.dataset.updateUrl === 'false') return;
|
|
window.history.replaceState({}, '', `${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) {
|
|
if (!variantFeaturedMediaId) return;
|
|
|
|
const mediaGallerySource = this.querySelector('media-gallery ul');
|
|
const mediaGalleryDestination = html.querySelector(`media-gallery ul`);
|
|
|
|
const refreshSourceData = () => {
|
|
if (this.hasAttribute('data-zoom-on-hover')) enableZoomOnHover(2);
|
|
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();
|
|
}
|
|
});
|
|
}
|
|
|
|
// set featured media as active in the media gallery
|
|
this.querySelector(`media-gallery`)?.setActiveMedia?.(
|
|
`${this.dataset.section}-${variantFeaturedMediaId}`,
|
|
true
|
|
);
|
|
|
|
// 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() {
|
|
const data = {
|
|
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.quantityInput.min = min;
|
|
|
|
if (max) {
|
|
this.quantityInput.max = max;
|
|
} else {
|
|
this.quantityInput.removeAttribute('max');
|
|
}
|
|
this.quantityInput.value = min;
|
|
|
|
publish(PUB_SUB_EVENTS.quantityUpdate, undefined);
|
|
}
|
|
|
|
fetchQuantityRules() {
|
|
const currentVariantId = this.productForm?.variantIdInput?.value;
|
|
if (!currentVariantId) return;
|
|
|
|
this.querySelector('.quantity__rules-cart .loading__spinner').classList.remove('hidden');
|
|
return 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);
|
|
})
|
|
.catch((e) => console.error(e))
|
|
.finally(() => this.querySelector('.quantity__rules-cart .loading__spinner').classList.add('hidden'));
|
|
}
|
|
|
|
updateQuantityRules(sectionId, html) {
|
|
if (!this.quantityInput) return;
|
|
this.setQuantityBoundries();
|
|
|
|
const quantityFormUpdated = html.getElementById(`Quantity-Form-${sectionId}`);
|
|
const selectors = ['.quantity__input', '.quantity__rules', '.quantity__label'];
|
|
for (let selector of selectors) {
|
|
const current = this.quantityForm.querySelector(selector);
|
|
const updated = quantityFormUpdated.querySelector(selector);
|
|
if (!current || !updated) continue;
|
|
if (selector === '.quantity__input') {
|
|
const attributes = ['data-cart-quantity', 'data-min', 'data-max', 'step'];
|
|
for (let attribute of attributes) {
|
|
const valueUpdated = updated.getAttribute(attribute);
|
|
if (valueUpdated !== null) {
|
|
current.setAttribute(attribute, valueUpdated);
|
|
} else {
|
|
current.removeAttribute(attribute);
|
|
}
|
|
}
|
|
} else {
|
|
current.innerHTML = updated.innerHTML;
|
|
if (selector === '.quantity__label') {
|
|
const updatedAriaLabelledBy = updated.getAttribute('aria-labelledby');
|
|
if (updatedAriaLabelledBy) {
|
|
current.setAttribute('aria-labelledby', updatedAriaLabelledBy);
|
|
// Update the referenced visually hidden element
|
|
const labelId = updatedAriaLabelledBy;
|
|
const currentHiddenLabel = document.getElementById(labelId);
|
|
const updatedHiddenLabel = html.getElementById(labelId);
|
|
if (currentHiddenLabel && updatedHiddenLabel) {
|
|
currentHiddenLabel.textContent = updatedHiddenLabel.textContent;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
);
|
|
}
|