diff --git a/_dev/js/theme/components/http/useDefaultHttpRequest.js b/_dev/js/theme/components/http/useDefaultHttpRequest.js index 3bf3f859..5f5c6184 100644 --- a/_dev/js/theme/components/http/useDefaultHttpRequest.js +++ b/_dev/js/theme/components/http/useDefaultHttpRequest.js @@ -5,10 +5,11 @@ import useHttpRequest from './useHttpRequest'; * Default http request accepting payload as object and returning promise with response * @param {string} url - url to send request * @param {object} payload - payload to send + * @param {object} options - request options for example different headers * @returns {Promise} */ -const useDefaultHttpRequest = (url, payload) => { - const { request } = useHttpRequest(url); +const useDefaultHttpRequest = (url, payload, options = {}) => { + const { request } = useHttpRequest(url, options); return new Promise((resolve, reject) => { request diff --git a/_dev/js/theme/components/http/useHttpPayloadDefinition.js b/_dev/js/theme/components/http/useHttpPayloadDefinition.js index e2508ba2..fbecbae4 100644 --- a/_dev/js/theme/components/http/useHttpPayloadDefinition.js +++ b/_dev/js/theme/components/http/useHttpPayloadDefinition.js @@ -83,7 +83,7 @@ const useHttpPayloadDefinition = (payload, definition) => { regex, } = fieldDefinition; - if (required && !value) { + if (required && typeof value === 'undefined') { validateErrors.push(`'${fieldName}' ${ERROR_MESSAGES.REQUIRED}`); } diff --git a/_dev/js/theme/components/product.js b/_dev/js/theme/components/product.js index 05875a4b..a611c987 100644 --- a/_dev/js/theme/components/product.js +++ b/_dev/js/theme/components/product.js @@ -1,5 +1,7 @@ import $ from 'jquery'; import prestashop from 'prestashop'; +import useCustomQuantityInput from './useCustomQuantityInput'; +import { each } from '../utils/DOMHelpers'; $(() => { const createInputFile = () => { @@ -13,35 +15,24 @@ $(() => { }); }; - const createProductSpin = () => { - const $quantityInput = $('#quantity_wanted'); + const initProductQuantityInput = () => { + const initQtySpinner = (spinner) => { + const { init } = useCustomQuantityInput(spinner, { + onQuantityChange: (event) => { + prestashop.emit('updateProduct', { + eventType: 'updatedProductQuantity', + event, + }); + }, + }); - $quantityInput.TouchSpin({ - verticalupclass: 'material-icons touchspin-up', - verticaldownclass: 'material-icons touchspin-down', - buttondown_class: 'btn btn-touchspin js-touchspin', - buttonup_class: 'btn btn-touchspin js-touchspin', - min: parseInt($quantityInput.attr('min'), 10), - max: 1000000, - }); + init(); + }; - $quantityInput.on('focusout', () => { - if ($quantityInput.val() === '' || $quantityInput.val() < $quantityInput.attr('min')) { - $quantityInput.val($quantityInput.attr('min')); - $quantityInput.trigger('change'); - } - }); - - $('body').on('change keyup', '#quantity_wanted', (event) => { - $(event.currentTarget).trigger('touchspin.stopspin'); - prestashop.emit('updateProduct', { - eventType: 'updatedProductQuantity', - event, - }); - }); + each('.js-product-qty-spinner', initQtySpinner); }; - createProductSpin(); + initProductQuantityInput(); createInputFile(); let updateEvenType = false; @@ -66,17 +57,6 @@ $(() => { prestashop.on('updatedProduct', (event) => { createInputFile(); - if (event && event.product_minimal_quantity) { - const minimalProductQuantity = parseInt(event.product_minimal_quantity, 10); - const quantityInputSelector = '#quantity_wanted'; - const quantityInput = $(quantityInputSelector); - - // @see http://www.virtuosoft.eu/code/bootstrap-touchspin/ about Bootstrap TouchSpin - quantityInput.trigger('touchspin.updatesettings', { - min: minimalProductQuantity, - }); - } - if (updateEvenType === 'updatedProductCombination') { $('.js-product-images').replaceWith(event.product_cover_thumbnails); $('.js-product-images-modal').replaceWith(event.product_images_modal); @@ -86,5 +66,6 @@ $(() => { updateEvenType = false; prestashop.pageLazyLoad.update(); + initProductQuantityInput(); }); }); diff --git a/_dev/js/theme/components/quickview.js b/_dev/js/theme/components/quickview.js index 41c037cd..f9d5baa6 100644 --- a/_dev/js/theme/components/quickview.js +++ b/_dev/js/theme/components/quickview.js @@ -1,32 +1,31 @@ import $ from 'jquery'; import prestashop from 'prestashop'; +import DOMReady from '../utils/DOMReady'; +import parseToHtml from '../utils/parseToHtml'; -$(() => { - prestashop.on('clickQuickView', (elm) => { - const data = { - action: 'quickview', - id_product: elm.dataset.idProduct, - id_product_attribute: elm.dataset.idProductAttribute, - }; - $.post(prestashop.urls.pages.product, data, null, 'json') - .then((resp) => { - const $body = $('body'); - $('body').append(resp.quickview_html); - const productModal = $( - `#quickview-modal-${resp.product.id}-${resp.product.id_product_attribute}`, - ); - productModal.modal('show'); - $body.addClass('js-quickview-open'); - productModal.on('hidden.bs.modal', () => { - productModal.remove(); - $body.removeClass('js-quickview-open'); - }); - }) - .fail((resp) => { - prestashop.emit('handleError', { - eventType: 'clickQuickView', - resp, - }); - }); +/** + * Handle open quick view + */ +const handleQuickViewOpen = ({ + resp, +}) => { + const body = document.querySelector('body'); + body.append(parseToHtml(resp.quickview_html)); + + // TO DO REMOVE JQUERY + const productModal = $( + `#quickview-modal-${resp.product.id}-${resp.product.id_product_attribute}`, + ); + productModal.modal('show'); + + body.classList.add('js-quickview-open'); + + productModal.on('hidden.bs.modal', () => { + productModal.remove(); + body.classList.remove('js-quickview-open'); }); +}; + +DOMReady(() => { + prestashop.on('clickViewOpen', handleQuickViewOpen); }); diff --git a/_dev/js/theme/components/useCustomQuantityInput.js b/_dev/js/theme/components/useCustomQuantityInput.js index 5fb49144..38355a85 100644 --- a/_dev/js/theme/components/useCustomQuantityInput.js +++ b/_dev/js/theme/components/useCustomQuantityInput.js @@ -112,6 +112,40 @@ const useCustomQuantityInput = (spinnerElement, { maxQty = input.getAttribute('max') ? parseInt(input.getAttribute('max'), 10) : defaultMaxQty; }; + /** + * Update minimum quantity + * @method updateMinQty + * @public + * @param newMinQty + * @returns {void} + */ + const updateMinQty = (newMinQty) => { + const qtyValue = Number.parseInt(newMinQty, 10); + + if (Number.isNaN(qtyValue)) { + throw new Error('Invalid minimum quantity'); + } + + minQty = qtyValue; + }; + + /** + * Update maximum quantity + * @method updateMaxQty + * @public + * @param newMaxQty + * @returns {void} + */ + const updateMaxQty = (newMaxQty) => { + const qtyValue = Number.parseInt(newMaxQty, 10); + + if (Number.isNaN(qtyValue)) { + throw new Error('Invalid maximum quantity'); + } + + maxQty = qtyValue; + }; + /** * Should dispatch change event * @method shouldDispatchChange @@ -299,7 +333,7 @@ const useCustomQuantityInput = (spinnerElement, { /** * Destroy spinner instance and detach events * @method destroy - * @static + * @public * @returns {void} */ const destroy = () => { @@ -310,7 +344,7 @@ const useCustomQuantityInput = (spinnerElement, { /** * Initialize spinner instance and attach events * @method init - * @static + * @public * @returns {void} */ const init = () => { @@ -324,6 +358,8 @@ const useCustomQuantityInput = (spinnerElement, { init, destroy, getDOMElements, + updateMinQty, + updateMaxQty, }; }; diff --git a/_dev/js/theme/core/cart/handler/cart/addToCartHandler.js b/_dev/js/theme/core/cart/handler/cart/addToCartHandler.js index 76e5c98a..1407294f 100644 --- a/_dev/js/theme/core/cart/handler/cart/addToCartHandler.js +++ b/_dev/js/theme/core/cart/handler/cart/addToCartHandler.js @@ -47,8 +47,8 @@ const addToCartHandler = async (event) => { const payload = { id_product: idProduct, qty, - id_product_attribute: idProductAttribute, - id_customization: idCustomization, + id_product_attribute: Number.parseInt(idProductAttribute, 10), + id_customization: Number.parseInt(idCustomization, 10), }; const { getRequest } = addToCartRequest(payload); diff --git a/_dev/js/theme/core/product/handler/product/productFormChangeHandler.js b/_dev/js/theme/core/product/handler/product/productFormChangeHandler.js new file mode 100644 index 00000000..d68041fe --- /dev/null +++ b/_dev/js/theme/core/product/handler/product/productFormChangeHandler.js @@ -0,0 +1,20 @@ +import prestashop from 'prestashop'; +import productStateStore from '../../store/productStateStore'; + +const { setFormChanged } = productStateStore(); + +/** + * Sets the form changed state + * Side effect: emits 'updateProduct' event + * @param event {Event} + */ +const productFormChangeHandler = (event) => { + setFormChanged(true); + + prestashop.emit('updateProduct', { + eventType: 'updatedProductCombination', + event, + }); +}; + +export default productFormChangeHandler; diff --git a/_dev/js/theme/core/product/handler/product/productPopStateHandler.js b/_dev/js/theme/core/product/handler/product/productPopStateHandler.js new file mode 100644 index 00000000..12c2d69c --- /dev/null +++ b/_dev/js/theme/core/product/handler/product/productPopStateHandler.js @@ -0,0 +1,41 @@ +import prestashop from 'prestashop'; +import productFormDataPersister from '../../persister/productFormDataPersister'; +import productStateStore from '../../store/productStateStore'; + +const { setOnPopState, isFormChanged } = productStateStore(); + +const { get } = productFormDataPersister(); + +/** + * Handle popstate event for product page + * Side effect: emits 'updateProduct' event, sets popState in productStateStore + * @param {Event} event + */ +const productPopStateHandler = (event) => { + setOnPopState(true); + + const formData = event?.state?.form || get(); + + if ((!formData || formData?.length === 0) && !isFormChanged()) { + return; + } + + const form = document.querySelector(`${prestashop.selectors.product.actions} .js-product-form`); + + const handleFormElementState = (data) => { + const element = form.querySelector(`[name="${data.name}"]`); + + if (element) { + element.value = data.value; + } + }; + + formData.forEach(handleFormElementState); + + prestashop.emit('updateProduct', { + eventType: 'updatedProductCombination', + event, + }); +}; + +export default productPopStateHandler; diff --git a/_dev/js/theme/core/product/handler/product/productUpdateErrorHandler.js b/_dev/js/theme/core/product/handler/product/productUpdateErrorHandler.js new file mode 100644 index 00000000..e09b1b46 --- /dev/null +++ b/_dev/js/theme/core/product/handler/product/productUpdateErrorHandler.js @@ -0,0 +1,15 @@ +import useAlertToast from '../../../../components/useAlertToast'; + +const { danger } = useAlertToast(); + +/** + * Handle product update error + * @param event + */ +const productUpdateErrorHandler = (event) => { + if (event?.errorMessage) { + danger(event.errorMessage); + } +}; + +export default productUpdateErrorHandler; diff --git a/_dev/js/theme/core/product/handler/product/updateProductCustomizationHandler.js b/_dev/js/theme/core/product/handler/product/updateProductCustomizationHandler.js new file mode 100644 index 00000000..48d3d692 --- /dev/null +++ b/_dev/js/theme/core/product/handler/product/updateProductCustomizationHandler.js @@ -0,0 +1,27 @@ +import prestashop from 'prestashop'; + +/** + * Update product customization input value + * Side effect: update product customization input value + * @param eventType {string} - event type + * @param eventData {object} - event data + * @param eventData.id_customization {number} - customization id + * @return {void} + */ +const updateProductCustomizationHandler = (eventType, { id_customization: idCustomization }) => { + const customizationIdInput = document.querySelector(prestashop.selectors.cart.productCustomizationId); + + // refill customizationId input value when updating quantity or combination + if ( + (eventType === 'updatedProductQuantity' || eventType === 'updatedProductCombination') + && idCustomization + ) { + if (customizationIdInput) { + customizationIdInput.value = idCustomization; + } + } else if (customizationIdInput) { + customizationIdInput.value = 0; + } +}; + +export default updateProductCustomizationHandler; diff --git a/_dev/js/theme/core/product/handler/product/updateProductDOMElementsHandler.js b/_dev/js/theme/core/product/handler/product/updateProductDOMElementsHandler.js new file mode 100644 index 00000000..160468a9 --- /dev/null +++ b/_dev/js/theme/core/product/handler/product/updateProductDOMElementsHandler.js @@ -0,0 +1,55 @@ +import prestashop from 'prestashop'; +import parseToHtml from '../../../../utils/parseToHtml'; +import { each } from '../../../../utils/DOMHelpers'; + +/** + * Replace element with new html string + * @param element {HTMLElement} - element to replace + * @param htmlString {string} - html string to replace with + */ +const replaceElement = (element, htmlString) => { + const newElement = parseToHtml(htmlString); + + element.replaceWith(newElement); +}; + +/** + * Update DOM elements of the product page + * Side effect: update DOM elements of the product page + * @param eventData {object} - event data + * @param eventData.product_cover_thumbnails {string} - product cover thumbnails html string + * @param eventData.product_prices {string} - product prices html string + * @param eventData.product_customization {string} - product customization html string + * @param eventData.product_variants {string} - product variants html string + * @param eventData.product_discounts {string} - product discounts html string + * @param eventData.product_additional_info {string} - product additional info html string + * @param eventData.product_details {string} - product details html string + * @param eventData.product_flags {string} - product flags html string + * @param eventData.product_add_to_cart {string} - product add to cart html string + * @return {void} + */ +const updateProductDOMElementsHandler = ({ + /* eslint-disable */ + product_cover_thumbnails, + product_prices, + product_customization, + product_variants, + product_discounts, + product_additional_info, + product_details, + product_flags, + product_add_to_cart, + /* eslint-enable */ +}) => { + each(prestashop.selectors.product.imageContainer, (el) => replaceElement(el, product_cover_thumbnails)); + each(prestashop.selectors.product.prices, (el) => replaceElement(el, product_prices)); + each(prestashop.selectors.product.customization, (el) => replaceElement(el, product_customization)); + each(prestashop.selectors.product.variantsUpdate, (el) => replaceElement(el, product_variants)); + each(prestashop.selectors.product.discounts, (el) => replaceElement(el, product_discounts)); + each(prestashop.selectors.product.additionalInfos, (el) => replaceElement(el, product_additional_info)); + each(prestashop.selectors.product.details, (el) => replaceElement(el, product_details)); + each(prestashop.selectors.product.flags, (el) => replaceElement(el, product_flags)); + each(prestashop.selectors.product.addToCart, (el) => replaceElement(el, product_add_to_cart)); +}; + +export default updateProductDOMElementsHandler; diff --git a/_dev/js/theme/core/product/handler/product/updateProductHandler.js b/_dev/js/theme/core/product/handler/product/updateProductHandler.js new file mode 100644 index 00000000..231f44c0 --- /dev/null +++ b/_dev/js/theme/core/product/handler/product/updateProductHandler.js @@ -0,0 +1,74 @@ +import prestashop from 'prestashop'; +import isQuickViewOpen from '../../utils/isQuickViewOpen'; +import isProductPreview from '../../utils/isProductPreview'; +import updateProductRequest from '../../request/product/updateProductRequest'; +import productFormDataPersister from '../../persister/productFormDataPersister'; +import productStateStore from '../../store/productStateStore'; +import updateQuantityInputHandler from './updateQuantityInputHandler'; +import updateProductCustomizationHandler from './updateProductCustomizationHandler'; +import updateProductDOMElementsHandler from './updateProductDOMElementsHandler'; +import { fromSerializeObject } from '../../../../utils/formSerialize'; +import useAlertToast from '../../../../components/useAlertToast'; + +const { danger } = useAlertToast(); +const { getCurrentRequestDelayedId, setCurrentRequestDelayedId } = productStateStore(); + +/** + * Handle 'updateProduct' event + * @param params - event object + * @param params.eventType {string} - event type + * @return {Promise} + */ +const updateProductHandler = async ({ eventType }) => { + const productActions = document.querySelector(prestashop.selectors.product.actions); + const quantityWantedInput = productActions.querySelector(prestashop.selectors.quantityWanted); + + const form = productActions.querySelector('.js-product-form'); + const formSerialized = fromSerializeObject(form); + const updateRatingEvent = new Event('updateRating'); + + if (getCurrentRequestDelayedId()) { + clearTimeout(getCurrentRequestDelayedId()); + } + + const updateDelay = 50; + + const timeoutId = setTimeout(async () => { + const idProductAttribute = formSerialized?.id_product_attribute || 0; + const idCustomization = formSerialized?.id_customization || 0; + + const payload = { + quantity_wanted: Number.parseInt(quantityWantedInput.value, 10), + preview: isProductPreview() ? 1 : 0, + quickview: isQuickViewOpen() ? 1 : 0, + ...formSerialized, + id_product: Number.parseInt(formSerialized.id_product, 10), + id_product_attribute: Number.parseInt(idProductAttribute, 10), + id_customization: Number.parseInt(idCustomization, 10), + }; + const { getRequest } = updateProductRequest(payload); + + try { + const data = await getRequest(); + + updateProductDOMElementsHandler(data); + updateProductCustomizationHandler(eventType, data); + updateQuantityInputHandler(eventType, data); + + document.dispatchEvent(updateRatingEvent); + + const { persist, get: getPersistedData } = productFormDataPersister(); + persist(form); + + prestashop.emit('updatedProduct', data, getPersistedData()); + } catch (e) { + danger(prestashop.t.alert.genericHttpError); + } + + setCurrentRequestDelayedId(null); + }, updateDelay); + + setCurrentRequestDelayedId(timeoutId); +}; + +export default updateProductHandler; diff --git a/_dev/js/theme/core/product/handler/product/updateQuantityInputHandler.js b/_dev/js/theme/core/product/handler/product/updateQuantityInputHandler.js new file mode 100644 index 00000000..9339378d --- /dev/null +++ b/_dev/js/theme/core/product/handler/product/updateQuantityInputHandler.js @@ -0,0 +1,21 @@ +import prestashop from 'prestashop'; + +/** + * Update quantity input value + * @param eventType {string} - event type + * @param event {object} - event data + * @param event.product_minimal_quantity {number} - product minimal quantity + */ +const updateQuantityInputHandler = (eventType, { product_minimal_quantity: productMinimalQuantity }) => { + const minimalProductQuantity = parseInt( + productMinimalQuantity, + 10, + ); + + if (!Number.isNaN(minimalProductQuantity) && eventType !== 'updatedProductQuantity') { + const newQtyInput = document.querySelector(`${prestashop.selectors.product.actions} ${prestashop.selectors.quantityWanted}`); + newQtyInput.value = minimalProductQuantity; + } +}; + +export default updateQuantityInputHandler; diff --git a/_dev/js/theme/core/product/handler/product/updatedProductHandler.js b/_dev/js/theme/core/product/handler/product/updatedProductHandler.js new file mode 100644 index 00000000..335709c3 --- /dev/null +++ b/_dev/js/theme/core/product/handler/product/updatedProductHandler.js @@ -0,0 +1,49 @@ +import isQuickViewOpen from '../../utils/isQuickViewOpen'; +import productStateStore from '../../store/productStateStore'; + +const { isOnPopState, setOnPopState } = productStateStore(); + +/** + * Handle updated product on 'updatedProduct' event + * Side effect: changes the url and title, sets popState in productStateStore + * @param event - event object with response data + * @param formData - form data + */ +const updatedProductHandler = ({ + product_url: responseProductUrl = null, + id_product_attribute: responseIdProductAttribute = null, + product_title: responseProductTitle = '', +}, formData) => { + if (!responseProductUrl || !responseIdProductAttribute) { + return; + } + + /* + * If quickview modal is present we are not on product page, so + * we don't change the url nor title + */ + if (isQuickViewOpen()) { + return; + } + + const pageTitle = document.title; + + if (responseProductTitle) { + document.title = responseProductTitle; + } + + if (!isOnPopState()) { + window.history.pushState( + { + id_product_attribute: responseIdProductAttribute, + form: formData, + }, + pageTitle, + responseProductUrl, + ); + } + + setOnPopState(false); +}; + +export default updatedProductHandler; diff --git a/_dev/js/theme/core/product/handler/quickView/quickViewClickHandler.js b/_dev/js/theme/core/product/handler/quickView/quickViewClickHandler.js new file mode 100644 index 00000000..1ed2dc34 --- /dev/null +++ b/_dev/js/theme/core/product/handler/quickView/quickViewClickHandler.js @@ -0,0 +1,22 @@ +import prestashop from 'prestashop'; + +/** + * Quick view btn click handler + * SideEffect: emit event on prestashop object clickViewOpen + * @param event {Event} - click event + */ +const quickViewClickHandler = async (event) => { + event.preventDefault(); + const miniature = event.target.closest(prestashop.selectors.product.miniature); + const dataset = miniature?.dataset || {}; + const idProduct = dataset?.idProduct || 0; + const idProductAttribute = dataset?.idProductAttribute || 0; + + prestashop.emit('clickQuickView', { + dataset, + idProduct, + idProductAttribute, + }); +}; + +export default quickViewClickHandler; diff --git a/_dev/js/theme/core/product/handler/quickView/quickViewHandler.js b/_dev/js/theme/core/product/handler/quickView/quickViewHandler.js new file mode 100644 index 00000000..64215e97 --- /dev/null +++ b/_dev/js/theme/core/product/handler/quickView/quickViewHandler.js @@ -0,0 +1,33 @@ +import prestashop from 'prestashop'; +import quickViewRequest from '../../request/quickView/quickViewRequest'; + +/** + * Quick view handler + * SideEffect: emit event on prestashop object clickViewOpen + * @param idProduct + * @param idProductAttribute + */ +const quickViewHandler = async (idProduct, idProductAttribute) => { + const payload = { + id_product: Number.parseInt(idProduct, 10), + id_product_attribute: Number.parseInt(idProductAttribute, 10), + }; + + const { getRequest } = quickViewRequest(payload); + + try { + const resp = await getRequest(); + + prestashop.emit('clickViewOpen', { + reason: 'openQuickView', + resp, + }); + } catch (e) { + prestashop.emit('handleError', { + eventType: 'clickQuickView', + resp: {}, + }); + } +}; + +export default quickViewHandler; diff --git a/_dev/js/theme/core/product/index.js b/_dev/js/theme/core/product/index.js index 90db081e..1a8dfa23 100644 --- a/_dev/js/theme/core/product/index.js +++ b/_dev/js/theme/core/product/index.js @@ -1,7 +1,8 @@ -import updateProduct from '@js/theme/core/product/updateProduct'; -import productQuickView from '@js/theme/core/product/productQuickView'; +import productController from './productController'; +import DOMReady from '../../utils/DOMReady'; -$(() => { - updateProduct(); - productQuickView(); +const { init } = productController(); + +DOMReady(() => { + init(); }); diff --git a/_dev/js/theme/core/product/persister/productFormDataPersister.js b/_dev/js/theme/core/product/persister/productFormDataPersister.js new file mode 100644 index 00000000..ad5b2c50 --- /dev/null +++ b/_dev/js/theme/core/product/persister/productFormDataPersister.js @@ -0,0 +1,38 @@ +import { formSerializeArray } from '../../../utils/formSerialize'; + +let formData = []; + +/** + * Persists product form data + * @module + */ +const productFormDataPersister = () => { + /** + * Persists form data from the form element + * @method + * @param {HTMLFormElement} formElement - form element to persist + * @throws {Error} - if formElement is not a form element + * @return {void} + */ + const persist = (formElement) => { + if (formElement?.tagName !== 'FORM') { + throw new Error('formElement is not a form element'); + } + + formData = formSerializeArray(formElement); + }; + + /** + * Returns persisted data + * @method + * @return {*[]} + */ + const get = () => formData; + + return { + persist, + get, + }; +}; + +export default productFormDataPersister; diff --git a/_dev/js/theme/core/product/productController.js b/_dev/js/theme/core/product/productController.js new file mode 100644 index 00000000..4ea81aed --- /dev/null +++ b/_dev/js/theme/core/product/productController.js @@ -0,0 +1,46 @@ +import prestashop from 'prestashop'; +import useEvent from '../../components/event/useEvent'; +import quickViewClickHandler from './handler/quickView/quickViewClickHandler'; +import quickViewHandler from './handler/quickView/quickViewHandler'; +import productUpdateErrorHandler from './handler/product/productUpdateErrorHandler'; +import productFormDataPersister from './persister/productFormDataPersister'; +import productPopStateHandler from './handler/product/productPopStateHandler'; +import updatedProductHandler from './handler/product/updatedProductHandler'; +import updateProductHandler from './handler/product/updateProductHandler'; +import productFormChangeHandler from './handler/product/productFormChangeHandler'; + +const { on } = useEvent(); +const { persist } = productFormDataPersister(); + +/** + * Persists form data on init + * Side effect: set formData in productFormDataPersister + * @return {void} + */ +const persistFormDataOnInit = () => { + const form = document.querySelector(`${prestashop.selectors.product.actions} .js-product-form`); + + if (form) { + persist(form); + } +}; + +const productController = () => { + const init = () => { + persistFormDataOnInit(); + on(document, 'click', prestashop.selectors.listing.quickview, quickViewClickHandler); + on(document, 'change', `${prestashop.selectors.product.variants} *[name]`, productFormChangeHandler); + + window.addEventListener('popstate', productPopStateHandler); + prestashop.on('updateProduct', updateProductHandler); + prestashop.on('updatedProduct', updatedProductHandler); + prestashop.on('showErrorNextToAddtoCartButton', productUpdateErrorHandler); + prestashop.on('clickQuickView', ({ idProduct, idProductAttribute }) => quickViewHandler(idProduct, idProductAttribute)); + }; + + return { + init, + }; +}; + +export default productController; diff --git a/_dev/js/theme/core/product/productQuickView.js b/_dev/js/theme/core/product/productQuickView.js deleted file mode 100644 index e309dbae..00000000 --- a/_dev/js/theme/core/product/productQuickView.js +++ /dev/null @@ -1,20 +0,0 @@ -import prestashop from 'prestashop'; -import useEvent from '@js/theme/components/event/useEvent'; - -const { on } = useEvent(); - -const handleQuickView = (event) => { - event.preventDefault(); - const miniature = event.target.closest(prestashop.selectors.product.miniature); - const dataset = miniature?.dataset || {}; - - prestashop.emit('clickQuickView', { - dataset, - }); -}; - -const productQuickView = () => { - on(document, 'click', prestashop.selectors.listing.quickview, handleQuickView); -}; - -export default productQuickView; diff --git a/_dev/js/theme/core/product/request/product/updateProductRequest.js b/_dev/js/theme/core/product/request/product/updateProductRequest.js new file mode 100644 index 00000000..62da2d93 --- /dev/null +++ b/_dev/js/theme/core/product/request/product/updateProductRequest.js @@ -0,0 +1,136 @@ +import prestashop from 'prestashop'; +import useHttpRequest from '../../../../components/http/useHttpRequest'; +import useHttpController from '../../../../components/http/useHttpController'; +import useHttpPayloadDefinition from '../../../../components/http/useHttpPayloadDefinition'; + +const { dispatch, abortAll } = useHttpController(); + +/** + * @typedef ServerResponse + * @type {object} + * @property {number} id_customization - saved customization id + * @property {number} id_product_attribute - saved product attribute id + * @property {number} product_minimal_quantity - product minimal quantity + * @property {boolean} is_quick_view - is quick view + * @property {boolean} product_has_combinations - product has combinations + * @property {string} product_url - product url address + * @property {string} product_title - product meta title + * @property {string} product_add_to_cart - product add to cart html content + * @property {string} product_additional_info - product additional info html content + * @property {string} product_cover_thumbnails - product cover thumbnails html content + * @property {string} product_customization - product customization html content + * @property {string} product_details - product details html content + * @property {string} product_discounts - product discounts html content + * @property {string} product_flags - product flags html content + * @property {string} product_prices - product prices html content + * @property {string} product_images_modal - product images modal html content + * @property {string} product_variants - product variants html content + */ + +/** + * Update listing facets request + * @param payload {object} - payload for request + * @param payload.preview {number} - is preview 1 or 0 + * @param payload.quickview {number} - is quick view 1 or 0 + * @param payload.quantity_wanted {number} - quantity wanted + * @param payload.id_product {number} - product id + * @param payload.id_product_attribute {number} - product attribute id + * @param payload.id_customization {number} - customization id - optional, default 0 + * @param payload.ajax {number} - optional, default 1 + * @param payload.action {string} - optional, default refresh + * @param payload.group[] {array} - array of attributes groups - optional + * @example + * const payload = { + * id_product: 1, + * id_product_attribute: 1, + * quantity_wanted: 1, + * preview: 0, + * quickview: 0, + * } + * const { getRequest } = updateProductRequest(payload); + * + * try { + * const resp = await getRequest(); + * } catch (error) { + * console.error(error); + * } + * @returns {{getRequest: (function(): Promise)}} + */ +const updateProductRequest = (payload) => { + const { request, controller } = useHttpRequest(prestashop.urls.pages.product); + const payloadToSend = { + ajax: 1, + action: 'refresh', + ...payload, + }; + + const payloadDefinition = { + action: { + type: 'string', + required: true, + }, + ajax: { + type: 'int', + required: true, + }, + preview: { + type: 'int', + required: true, + }, + quickview: { + type: 'int', + required: true, + }, + quantity_wanted: { + type: 'int', + required: true, + }, + id_product: { + type: 'int', + required: true, + }, + id_product_attribute: { + type: 'int', + required: true, + }, + id_customization: { + type: 'int', + required: false, + }, + }; + + const { validatePayload } = useHttpPayloadDefinition(payloadToSend, payloadDefinition); + + const validationErrors = validatePayload(); + + if (validationErrors.length) { + throw Error(validationErrors.join(',\n')); + } + + const getRequest = () => { + abortAll(); + + return new Promise((resolve, reject) => { + dispatch(request, controller)(() => request + .query(payloadToSend) + .post() + .json((resp) => { + resolve(resp); + }) + .catch((e) => { + // IF ABORTED + if (e instanceof DOMException) { + return; + } + + reject(); + })); + }); + }; + + return { + getRequest, + }; +}; + +export default updateProductRequest; diff --git a/_dev/js/theme/core/product/request/quickView/quickViewRequest.js b/_dev/js/theme/core/product/request/quickView/quickViewRequest.js new file mode 100644 index 00000000..119d7d85 --- /dev/null +++ b/_dev/js/theme/core/product/request/quickView/quickViewRequest.js @@ -0,0 +1,76 @@ +import prestashop from 'prestashop'; +import useDefaultHttpRequest from '../../../../components/http/useDefaultHttpRequest'; +import useHttpPayloadDefinition from '../../../../components/http/useHttpPayloadDefinition'; + +/** + * @typedef ServerResponse + * @type {object} + * @property {string} quickview_html - html content of quick view + * @property {object} product - product front object + */ + +/** + * Add voucher to cart request + * @param payload {Object} - payload object to send + * @param payload.id_product {number} - id_product to show - Required + * @param payload.id_product_attribute {number} - id_product to show - optional, default 0 + * @param payload.ajax {number} - optional, default 1 + * @param payload.action {string} - optional + * @example + * const payload = { + * id_product: 1, // Required + * id_product_attribute: 1, // Optional - default 0 + * }; + * + * const { getRequest } = quickViewRequest(payload); + * + * try { + * const resp = await getRequest(); + * } catch (error) { + * console.error(error); + * } + * @returns {{getRequest: (function(): Promise)}} + */ +const quickViewRequest = (payload) => { + const payloadToSend = { + action: 'quickview', + ajax: 1, + id_product_attribute: 0, + ...payload, + }; + + const payloadDefinition = { + action: { + type: 'string', + required: true, + }, + ajax: { + type: 'int', + required: true, + }, + id_product: { + type: 'int', + required: true, + }, + id_product_attribute: { + type: 'int', + required: true, + }, + }; + + const { validatePayload } = useHttpPayloadDefinition(payloadToSend, payloadDefinition); + + const validationErrors = validatePayload(); + + if (validationErrors.length) { + throw Error(validationErrors.join(',\n')); + } + + const getRequest = () => useDefaultHttpRequest(prestashop.urls.pages.product, payloadToSend); + + return { + getRequest, + }; +}; + +export default quickViewRequest; diff --git a/_dev/js/theme/core/product/store/productStateStore.js b/_dev/js/theme/core/product/store/productStateStore.js new file mode 100644 index 00000000..a6bb554c --- /dev/null +++ b/_dev/js/theme/core/product/store/productStateStore.js @@ -0,0 +1,70 @@ +const state = { + isOnPopStateEvent: false, + formChanged: false, + currentRequestDelayedId: null, +}; + +/** + * Store for product state + * @module + */ +const productStateStore = () => { + /** + * Returns true if the current event is a popstate event + * @method + * @return {boolean} + */ + const isOnPopState = () => state.isOnPopStateEvent; + + /** + * Sets the current event as a popstate event + * @method + * @param value {boolean} + */ + const setOnPopState = (value) => { + state.isOnPopStateEvent = value; + }; + + /** + * Sets the form changed state + * @method + * @param value {boolean} + */ + const setFormChanged = (value) => { + state.formChanged = value; + }; + + /** + * Returns true if the form has changed + * @method + * @return {boolean} + */ + const isFormChanged = () => state.formChanged; + + /** + * Returns the current request delayed id + * @method + * @return {null|number} + */ + const getCurrentRequestDelayedId = () => state.currentRequestDelayedId; + + /** + * Sets the current request delayed id + * @method + * @param value {null|number} + */ + const setCurrentRequestDelayedId = (value) => { + state.currentRequestDelayedId = value; + }; + + return { + isOnPopState, + setOnPopState, + setFormChanged, + isFormChanged, + getCurrentRequestDelayedId, + setCurrentRequestDelayedId, + }; +}; + +export default productStateStore; diff --git a/_dev/js/theme/core/product/updateProduct.js b/_dev/js/theme/core/product/updateProduct.js deleted file mode 100644 index 08aef27b..00000000 --- a/_dev/js/theme/core/product/updateProduct.js +++ /dev/null @@ -1,378 +0,0 @@ -import prestashop from 'prestashop'; -import { fromSerializeObject } from '@js/theme/utils/formSerialize'; -import parseToHtml from '@js/theme/utils/parseToHtml'; -import useAlertToast from '@js/theme/components/useAlertToast'; -import useEvent from '@js/theme/components/event/useEvent'; - -const { on } = useEvent(); - -const { danger } = useAlertToast(); - -// Used to clearTimeout if user flood the product quantity input -let currentRequestDelayedId = null; - -// Check for popState event -let isOnPopStateEvent = false; - -// Register form of first update -const firstFormData = []; - -// Detect if the form has changed one time -let formChanged = false; - -const isQuickViewOpen = () => !!document.querySelector('.modal.quickview.in'); - -const replaceDOMElements = ({ - /* eslint-disable */ - product_cover_thumbnails, - product_prices, - product_customization, - product_variants, - product_discounts, - product_additional_info, - product_details, - product_flags, - /* eslint-enable */ -}) => { - document.querySelector(prestashop.selectors.product.imageContainer) - ?.replaceWith(parseToHtml(product_cover_thumbnails)); - - document.querySelector(prestashop.selectors.product.prices) - ?.replaceWith(parseToHtml(product_prices)); - - document.querySelector(prestashop.selectors.product.customization) - ?.replaceWith(parseToHtml(product_customization)); - - document.querySelector(prestashop.selectors.product.variantsUpdate) - ?.replaceWith(parseToHtml(product_variants)); - - document.querySelector(prestashop.selectors.product.discounts) - ?.replaceWith(parseToHtml(product_discounts)); - - document.querySelector(prestashop.selectors.product.additionalInfos) - ?.replaceWith(parseToHtml(product_additional_info)); - - document.querySelector(prestashop.selectors.product.details) - ?.replaceWith(parseToHtml(product_details)); - - document.querySelector(prestashop.selectors.product.flags) - ?.replaceWith(parseToHtml(product_flags)); -}; - -const isPreview = () => { - const urlParams = new URLSearchParams(window.location.search); - - return urlParams.has('preview'); -}; - -/** - * Find DOM elements and replace their content - * - * @param {object} replacement Data to be replaced on the current page - */ -const replaceAddToCartSection = ({ addToCartSnippet, targetParent, targetSelector }) => { - const destinationObject = targetParent.querySelector(targetSelector); - - if (destinationObject === null) { - return; - } - - const replace = addToCartSnippet.querySelector(targetSelector); - - if (replace) { - destinationObject.replaceWith(parseToHtml(replace.outerHTML)); - } else { - destinationObject.textContent = ''; - } -}; - -/** - * Replace all "add to cart" sections but the quantity input - * in order to keep quantity field intact i.e. - * - * @param {object} data of updated product and cat - */ -const replaceAddToCartSections = ({ - // eslint-disable-next-line camelcase - product_add_to_cart, -}) => { - let productAddToCart = null; - const addToCartHtml = parseToHtml(product_add_to_cart); - const addToCartElements = document.querySelectorAll('.js-product-add-to-cart'); - - for (let i = 0; i < addToCartElements.length; i += 1) { - if (addToCartElements[i].classList.contains('product-add-to-cart')) { - productAddToCart = addToCartElements[i]; - } - } - - if (productAddToCart === null) { - danger(prestashop.t.alert.genericHttpError); - - return; - } - - let currentAddToCartBlockSelector = prestashop.selectors.product.addToCart; - - if (isQuickViewOpen()) { - currentAddToCartBlockSelector = `.js-quickview ${currentAddToCartBlockSelector}`; - } - - const currentAddToCartBlock = document.querySelector(currentAddToCartBlockSelector); - const productAvailabilitySelector = '.js-add-to-cart-btn-wrapper'; - const productAvailabilityMessageSelector = '.js-product-availability'; - const productMinimalQuantitySelector = '.js-product-minimal-quantity'; - - replaceAddToCartSection({ - addToCartSnippet: addToCartHtml, - targetParent: currentAddToCartBlock, - targetSelector: productAvailabilitySelector, - }); - - replaceAddToCartSection({ - addToCartSnippet: addToCartHtml, - targetParent: currentAddToCartBlock, - targetSelector: productAvailabilityMessageSelector, - }); - - replaceAddToCartSection({ - addToCartSnippet: addToCartHtml, - targetParent: currentAddToCartBlock, - targetSelector: productMinimalQuantitySelector, - }); -}; - -/** - * Update the product html - * - * @param {string} event - * @param {string} eventType - * @param {string} updateUrl - */ -const updateProductData = async (event, eventType) => { - const productActions = document.querySelector(prestashop.selectors.product.actions); - const quantityWantedInput = productActions.querySelector(prestashop.selectors.quantityWanted); - - const form = productActions.querySelector('form'); - const formSerialized = fromSerializeObject(form); - let updateRatingEvent; - - if (typeof Event === 'function') { - updateRatingEvent = new Event('updateRating'); - } else { - updateRatingEvent = document.createEvent('Event'); - updateRatingEvent.initEvent('updateRating', true, true); - } - - // New request only if new value - if ( - event - && event.type === 'keyup' - && quantityWantedInput?.value === quantityWantedInput?.dataset.oldValue - ) { - return; - } - - quantityWantedInput.dataset.oldValue = quantityWantedInput.value ? quantityWantedInput.value : 1; - - if (currentRequestDelayedId) { - clearTimeout(currentRequestDelayedId); - } - - // Most update need to occur (almost) instantly, but in some cases (like keyboard actions) - // we need to delay the update a bit more - let updateDelay = 30; - - if (eventType === 'updatedProductQuantity') { - updateDelay = 750; - } - - currentRequestDelayedId = setTimeout(async () => { - try { - const data = await prestashop.frontAPI.updateProduct(formSerialized, quantityWantedInput.value, isQuickViewOpen(), isPreview()); - - // Used to avoid image blinking if same image = epileptic friendly - - replaceDOMElements(data); - - const customizationIdInput = document.querySelector(prestashop.selectors.cart.productCustomizationId); - // refill customizationId input value when updating quantity or combination - if ( - (eventType === 'updatedProductQuantity' || eventType === 'updatedProductCombination') - && data.id_customization - ) { - if (customizationIdInput) { - customizationIdInput.value = data.id_customization; - } - } else if (customizationIdInput) { - customizationIdInput.value = 0; - } - - replaceAddToCartSections(data); - const minimalProductQuantity = parseInt( - data.product_minimal_quantity, - 10, - ); - - document.dispatchEvent(updateRatingEvent); - - // Prevent quantity input from blinking with classic theme. - if ( - !Number.isNaN(minimalProductQuantity) - && eventType !== 'updatedProductQuantity' - ) { - quantityWantedInput.setAttribute('min', minimalProductQuantity); - quantityWantedInput.value = minimalProductQuantity; - } - - prestashop.emit('updatedProduct', data, formSerialized); - } catch (e) { - danger(prestashop.t.alert.genericHttpError); - } - - currentRequestDelayedId = null; - }, updateDelay); -}; - -const handleProductFormChange = (event) => { - formChanged = true; - - prestashop.emit('updateProduct', { - eventType: 'updatedProductCombination', - event, - // Following variables are not used anymore, but kept for backward compatibility - resp: {}, - reason: { - productUrl: prestashop.urls.pages.product || '', - }, - }); -}; - -const handleUpdateCart = (event) => { - if (!event || !event.reason || event.reason.linkAction !== 'add-to-cart') { - return; - } - - const quantityInput = document.querySelector('#quantity_wanted'); - // Force value to 1, it will automatically trigger updateProduct and reset the appropriate min value if needed - - if (quantityInput) { - quantityInput.value = 1; - } -}; - -const handleError = (event) => { - if (event?.errorMessage) { - danger(event.errorMessage); - } -}; - -const handleUpdateProduct = ({ event, eventType }) => { - updateProductData(event, eventType); -}; - -const handleUpdatedProduct = (args, formData) => { - if (!args.product_url || !args.id_product_attribute) { - return; - } - - /* - * If quickview modal is present we are not on product page, so - * we don't change the url nor title - */ - if (isQuickViewOpen()) { - return; - } - - let pageTitle = document.title; - - if (args.product_title) { - pageTitle = args.product_title; - $(document).attr('title', pageTitle); - } - - if (!isOnPopStateEvent) { - window.history.pushState( - { - id_product_attribute: args.id_product_attribute, - form: formData, - }, - pageTitle, - args.product_url, - ); - } - - isOnPopStateEvent = false; -}; - -const handlePopState = (event) => { - isOnPopStateEvent = true; - - if ( - (!event.state - || (event.state && event.state.form && event.state.form.length === 0)) - && !formChanged - ) { - return; - } - - const form = document.querySelector(`${prestashop.selectors.product.actions} form`); - const handleState = (data) => { - const element = form.querySelector(`[name="${data.name}"]`); - - if (element) { - element.value = data.value; - } - }; - - if (event.state && event.state.form) { - event.state.form.forEach(handleState); - } else { - firstFormData.forEach(handleState); - } - - prestashop.emit('updateProduct', { - eventType: 'updatedProductCombination', - event, - // Following variables are not used anymore, but kept for backward compatibility - resp: {}, - reason: { - productUrl: prestashop.urls.pages.product || '', - }, - }); -}; - -const attachEventListeners = () => { - on(document, 'change', `${prestashop.selectors.product.variants} *[name]`, handleProductFormChange); - - prestashop.on('updateCart', handleUpdateCart); - prestashop.on('showErrorNextToAddtoCartButton', handleError); - // Refresh all the product content - prestashop.on('updateProduct', handleUpdateProduct); - prestashop.on('updatedProduct', handleUpdatedProduct); - - window.addEventListener('popstate', handlePopState); -}; - -const initProductPage = () => { - const productActions = document.querySelector(prestashop.selectors.product.actions); - const formElement = productActions?.querySelector('form'); - const formSerialized = formElement ? fromSerializeObject(formElement) : null; - - if (!formSerialized) { - return; - } - - for (const prop in formSerialized) { - if (Object.hasOwn(formSerialized, prop)) { - firstFormData.push({ name: prop, value: formSerialized[prop] }); - } - } -}; - -const updateProduct = () => { - attachEventListeners(); - initProductPage(); -}; - -export default updateProduct; diff --git a/_dev/js/theme/core/product/utils/isProductPreview.js b/_dev/js/theme/core/product/utils/isProductPreview.js new file mode 100644 index 00000000..8c431915 --- /dev/null +++ b/_dev/js/theme/core/product/utils/isProductPreview.js @@ -0,0 +1,11 @@ +/** + * Check if the product is in preview mode + * @returns {boolean} + */ +const isProductPreview = () => { + const urlParams = new URLSearchParams(window.location.search); + + return urlParams.has('preview'); +}; + +export default isProductPreview; diff --git a/_dev/js/theme/core/product/utils/isQuickViewOpen.js b/_dev/js/theme/core/product/utils/isQuickViewOpen.js new file mode 100644 index 00000000..5645a18f --- /dev/null +++ b/_dev/js/theme/core/product/utils/isQuickViewOpen.js @@ -0,0 +1,7 @@ +/** + * Check if quick view is open + * @returns {boolean} + */ +const isQuickViewOpen = () => !!document.querySelector('.modal.quickview.in'); + +export default isQuickViewOpen; diff --git a/_dev/js/theme/core/selectors.js b/_dev/js/theme/core/selectors.js index fdea55cd..548ef21f 100644 --- a/_dev/js/theme/core/selectors.js +++ b/_dev/js/theme/core/selectors.js @@ -14,8 +14,8 @@ prestashop.selectors = { customizationModal: '.js-customization-modal', imageContainer: '.js-quickview js-images-container, .page-product:not(.js-quickview-open) .js-product-container .images-container', container: '.js-product-container', - availability: '#product-availability, .js-product-availability', - actions: '.product-actions, .js-product-actions', + availability: '.js-product-availability', + actions: '.js-product-actions', variants: '.js-product-variants', refresh: '.js-product-refresh', miniature: '.js-product-miniature', diff --git a/_dev/js/theme/frontAPI/apiAction.js b/_dev/js/theme/frontAPI/apiAction.js deleted file mode 100644 index 21f10987..00000000 --- a/_dev/js/theme/frontAPI/apiAction.js +++ /dev/null @@ -1,13 +0,0 @@ -import updateProductAction from '@js/theme/frontAPI/product/updateProductAction'; - -prestashop.frontAPI = {}; - -prestashop.addAction = (actionName, actionFunction) => { - if (typeof prestashop.frontAPI[actionName] !== 'undefined') { - throw new Error(`Action ${actionName} already exists`); - } - - prestashop.frontAPI[actionName] = actionFunction; -}; - -prestashop.addAction('updateProduct', updateProductAction); diff --git a/_dev/js/theme/frontAPI/product/updateProductAction.js b/_dev/js/theme/frontAPI/product/updateProductAction.js deleted file mode 100644 index 8250fd19..00000000 --- a/_dev/js/theme/frontAPI/product/updateProductAction.js +++ /dev/null @@ -1,37 +0,0 @@ -import useHttpRequest from '@js/theme/components/http/useHttpRequest'; -import prestashop from 'prestashop'; -import useHttpController from '@js/theme/components/http/useHttpController'; - -const { dispatch, abortAll } = useHttpController(); - -const updateProductAction = (productFormData, quantityWanted, quickview = false, isPreview = false) => new Promise((resolve, reject) => { - abortAll(); - - const { request, controller } = useHttpRequest(prestashop.urls.pages.product); - - const payload = { - ajax: 1, - action: 'refresh', - quantity_wanted: quantityWanted, - preview: isPreview ? 1 : 0, - quickview: quickview ? 1 : 0, - ...productFormData, - }; - - dispatch(request, controller)(() => request - .query(payload) - .post() - .json((resp) => { - resolve(resp); - }) - .catch((e) => { - // IF ABORTED - if (e instanceof DOMException) { - return; - } - - reject(); - })); -}); - -export default updateProductAction; diff --git a/_dev/js/theme/index.js b/_dev/js/theme/index.js index 7879d9c2..252d8cc9 100644 --- a/_dev/js/theme/index.js +++ b/_dev/js/theme/index.js @@ -1,9 +1,7 @@ import $ from 'jquery'; import EventEmitter from 'events'; -/* eslint-enable */ -import '@js/theme/frontAPI/apiAction'; import '@js/theme/core'; import '@js/theme/vendors/bootstrap/bootstrap-imports'; @@ -17,14 +15,15 @@ import '@js/theme/components/customer'; import '@js/theme/components/quickview'; import '@js/theme/components/product'; import '@js/theme/components/cart/block-cart'; +/* eslint-enable */ + +import prestashop from 'prestashop'; /* eslint-disable */ // "inherit" EventEmitter for (const i in EventEmitter.prototype) { prestashop[i] = EventEmitter.prototype[i]; } - -import prestashop from 'prestashop'; import usePasswordPolicy from '@js/theme/components/password/usePasswordPolicy'; import Form from '@js/theme/components/form'; import TopMenu from '@js/theme/components/TopMenu'; diff --git a/templates/catalog/_partials/product-add-to-cart.tpl b/templates/catalog/_partials/product-add-to-cart.tpl index 2bd6c6ac..acadc8b8 100644 --- a/templates/catalog/_partials/product-add-to-cart.tpl +++ b/templates/catalog/_partials/product-add-to-cart.tpl @@ -28,24 +28,42 @@ {block name='product_quantity'}
- +
+ +
+ +
+ +
+ +
-
-
{/if}