diff --git a/CHANGELOG.md b/CHANGELOG.md index 97bb8da13b..1d55238923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Draft +- Fixed an issue with dispaying options that are out of stock for product on Cart. [#1911](https://github.com/bigcommerce/cornerstone/pull/1911) - Selecting product options doesn't update image on PDP in Internet Explorer. [#1913](https://github.com/bigcommerce/cornerstone/pull/1913) - HTML Entity displayed as is via system/error message on a Storefront. [#1888](https://github.com/bigcommerce/cornerstone/pull/1888) - Shoppers are not anchor-linked to reviews on PDPs if product description tabs are enabled. [#1883](https://github.com/bigcommerce/cornerstone/pull/1883) diff --git a/assets/js/theme/cart.js b/assets/js/theme/cart.js index 8454e2f6f2..6373a73032 100644 --- a/assets/js/theme/cart.js +++ b/assets/js/theme/cart.js @@ -1,13 +1,15 @@ import PageManager from './page-manager'; -import _ from 'lodash'; +import { bind, debounce } from 'lodash'; import giftCertCheck from './common/gift-certificate-validator'; import utils from '@bigcommerce/stencil-utils'; import ShippingEstimator from './cart/shipping-estimator'; import { defaultModal, modalTypes } from './global/modal'; import swal from './global/sweet-alert'; +import CartItemDetails from './common/cart-item-details'; export default class Cart extends PageManager { onReady() { + this.$modal = null; this.$cartContent = $('[data-cart-content]'); this.$cartMessages = $('[data-cart-status]'); this.$cartTotals = $('[data-cart-totals]'); @@ -125,16 +127,28 @@ export default class Cart extends PageManager { }); } - cartEditOptions(itemId) { + cartEditOptions(itemId, productId) { + const context = { productForChangeId: productId, ...this.context }; const modal = defaultModal(); + + if (this.$modal === null) { + this.$modal = $('#modal'); + } + const options = { template: 'cart/modals/configure-product', }; modal.open(); + this.$modal.find('.modal-content').addClass('hide-content'); utils.api.productAttributes.configureInCart(itemId, options, (err, response) => { modal.updateContent(response.content); + const $productOptionsContainer = $('[data-product-attributes-wrapper]', this.$modal); + const modalBodyReservedHeight = $productOptionsContainer.outerHeight(); + $productOptionsContainer.css('height', modalBodyReservedHeight); + + this.productDetails = new CartItemDetails(this.$modal, context); this.bindGiftWrappingForm(); @@ -142,13 +156,11 @@ export default class Cart extends PageManager { }); utils.hooks.on('product-option-change', (event, currentTarget) => { - const $changedOption = $(currentTarget); - const $form = $changedOption.parents('form'); + const $form = $(currentTarget).find('form'); const $submit = $('input.button', $form); const $messageBox = $('.alertMessageBox'); - const item = $('[name="item_id"]', $form).attr('value'); - utils.api.productAttributes.optionChange(item, $form.serialize(), (err, result) => { + utils.api.productAttributes.optionChange(productId, $form.serialize(), (err, result) => { const data = result.data || {}; if (err) { @@ -213,9 +225,9 @@ export default class Cart extends PageManager { bindCartEvents() { const debounceTimeout = 400; - const cartUpdate = _.bind(_.debounce(this.cartUpdate, debounceTimeout), this); - const cartUpdateQtyTextChange = _.bind(_.debounce(this.cartUpdateQtyTextChange, debounceTimeout), this); - const cartRemoveItem = _.bind(_.debounce(this.cartRemoveItem, debounceTimeout), this); + const cartUpdate = bind(debounce(this.cartUpdate, debounceTimeout), this); + const cartUpdateQtyTextChange = bind(debounce(this.cartUpdateQtyTextChange, debounceTimeout), this); + const cartRemoveItem = bind(debounce(this.cartRemoveItem, debounceTimeout), this); let preVal; // cart update @@ -257,10 +269,10 @@ export default class Cart extends PageManager { $('[data-item-edit]', this.$cartContent).on('click', event => { const itemId = $(event.currentTarget).data('itemEdit'); - + const productId = $(event.currentTarget).data('productId'); event.preventDefault(); // edit item in cart - this.cartEditOptions(itemId); + this.cartEditOptions(itemId, productId); }); } diff --git a/assets/js/theme/common/cart-item-details.js b/assets/js/theme/common/cart-item-details.js new file mode 100644 index 0000000000..73003a24cf --- /dev/null +++ b/assets/js/theme/common/cart-item-details.js @@ -0,0 +1,143 @@ +import utils from '@bigcommerce/stencil-utils'; +import ProductDetailsBase, { optionChangeDecorator } from './product-details-base'; +import { isEmpty } from 'lodash'; +import { isBrowserIE, convertIntoArray } from './utils/ie-helpers'; + +export default class CartItemDetails extends ProductDetailsBase { + constructor($scope, context, productAttributesData = {}) { + super($scope, context); + + const $form = $('#CartEditProductFieldsForm', this.$scope); + const $productOptionsElement = $('[data-product-attributes-wrapper]', $form); + const hasOptions = $productOptionsElement.html().trim().length; + const hasDefaultOptions = $productOptionsElement.find('[data-default]').length; + + $productOptionsElement.on('change', e => { + this.setProductVariant(); + }); + + const optionChangeCallback = optionChangeDecorator.call(this, hasDefaultOptions); + + // Update product attributes. Also update the initial view in case items are oos + // or have default variant properties that change the view + if ((isEmpty(productAttributesData) || hasDefaultOptions) && hasOptions) { + const productId = this.context.productForChangeId; + + utils.api.productAttributes.optionChange(productId, $form.serialize(), 'products/bulk-discount-rates', optionChangeCallback); + } else { + this.updateProductAttributes(productAttributesData); + } + } + + setProductVariant() { + const unsatisfiedRequiredFields = []; + const options = []; + + $.each($('[data-product-attribute]'), (index, value) => { + const optionLabel = value.children[0].innerText; + const optionTitle = optionLabel.split(':')[0].trim(); + const required = optionLabel.toLowerCase().includes('required'); + const type = value.getAttribute('data-product-attribute'); + + if ((type === 'input-file' || type === 'input-text' || type === 'input-number') && value.querySelector('input').value === '' && required) { + unsatisfiedRequiredFields.push(value); + } + + if (type === 'textarea' && value.querySelector('textarea').value === '' && required) { + unsatisfiedRequiredFields.push(value); + } + + if (type === 'date') { + const isSatisfied = Array.from(value.querySelectorAll('select')).every((select) => select.selectedIndex !== 0); + + if (isSatisfied) { + const dateString = Array.from(value.querySelectorAll('select')).map((x) => x.value).join('-'); + options.push(`${optionTitle}:${dateString}`); + + return; + } + + if (required) { + unsatisfiedRequiredFields.push(value); + } + } + + if (type === 'set-select') { + const select = value.querySelector('select'); + const selectedIndex = select.selectedIndex; + + if (selectedIndex !== 0) { + options.push(`${optionTitle}:${select.options[selectedIndex].innerText}`); + + return; + } + + if (required) { + unsatisfiedRequiredFields.push(value); + } + } + + if (type === 'set-rectangle' || type === 'set-radio' || type === 'swatch' || type === 'input-checkbox' || type === 'product-list') { + const checked = value.querySelector(':checked'); + if (checked) { + const getSelectedOptionLabel = () => { + const productVariantslist = convertIntoArray(value.children); + const matchLabelForCheckedInput = inpt => inpt.dataset.productAttributeValue === checked.value; + return productVariantslist.filter(matchLabelForCheckedInput)[0]; + }; + if (type === 'set-rectangle' || type === 'set-radio' || type === 'product-list') { + const label = isBrowserIE ? getSelectedOptionLabel().innerText.trim() : checked.labels[0].innerText; + if (label) { + options.push(`${optionTitle}:${label}`); + } + } + + if (type === 'swatch') { + const label = isBrowserIE ? getSelectedOptionLabel().children[0] : checked.labels[0].children[0]; + if (label) { + options.push(`${optionTitle}:${label.title}`); + } + } + + if (type === 'input-checkbox') { + options.push(`${optionTitle}:Yes`); + } + + return; + } + + if (type === 'input-checkbox') { + options.push(`${optionTitle}:No`); + } + + if (required) { + unsatisfiedRequiredFields.push(value); + } + } + }); + + let productVariant = unsatisfiedRequiredFields.length === 0 ? options.sort().join(', ') : 'unsatisfied'; + const view = $('.modal-header-title'); + + if (productVariant) { + productVariant = productVariant === 'unsatisfied' ? '' : productVariant; + if (view.attr('data-event-type')) { + view.attr('data-product-variant', productVariant); + } else { + const productName = view.html().match(/'(.*?)'/)[1]; + const card = $(`[data-name="${productName}"]`); + card.attr('data-product-variant', productVariant); + } + } + } + + /** + * Hide or mark as unavailable out of stock attributes if enabled + * @param {Object} data Product attribute data + */ + updateProductAttributes(data) { + super.updateProductAttributes(data); + + this.$scope.find('.modal-content').removeClass('hide-content'); + } +} diff --git a/assets/js/theme/common/product-details-base.js b/assets/js/theme/common/product-details-base.js new file mode 100644 index 0000000000..9cbc5e2d96 --- /dev/null +++ b/assets/js/theme/common/product-details-base.js @@ -0,0 +1,396 @@ +import Wishlist from '../wishlist'; +import { initRadioOptions } from './aria'; +import { isObject, isNumber } from 'lodash'; + +const optionsTypesMap = { + INPUT_FILE: 'input-file', + INPUT_TEXT: 'input-text', + INPUT_NUMBER: 'input-number', + INPUT_CHECKBOX: 'input-checkbox', + TEXTAREA: 'textarea', + DATE: 'date', + SET_SELECT: 'set-select', + SET_RECTANGLE: 'set-rectangle', + SET_RADIO: 'set-radio', + SWATCH: 'swatch', + PRODUCT_LIST: 'product-list', +}; + +export function optionChangeDecorator(areDefaultOtionsSet) { + return (err, response) => { + const attributesData = response.data || {}; + const attributesContent = response.content || {}; + + this.updateProductAttributes(attributesData); + if (areDefaultOtionsSet) { + this.updateView(attributesData, attributesContent); + } else { + this.updateDefaultAttributesForOOS(attributesData); + } + }; +} + +export default class ProductDetailsBase { + constructor($scope, context) { + this.$scope = $scope; + this.context = context; + this.initRadioAttributes(); + Wishlist.load(this.context); + this.getTabRequests(); + + $('[data-product-attribute]').each((__, value) => { + const type = value.getAttribute('data-product-attribute'); + + this._makeProductVariantAccessible(value, type); + }); + } + + _makeProductVariantAccessible(variantDomNode, variantType) { + switch (variantType) { + case optionsTypesMap.SET_RADIO: + case optionsTypesMap.SWATCH: { + initRadioOptions($(variantDomNode), '[type=radio]'); + break; + } + + default: break; + } + } + + /** + * Allow radio buttons to get deselected + */ + initRadioAttributes() { + $('[data-product-attribute] input[type="radio"]', this.$scope).each((i, radio) => { + const $radio = $(radio); + + // Only bind to click once + if ($radio.attr('data-state') !== undefined) { + $radio.on('click', () => { + if ($radio.data('state') === true) { + $radio.prop('checked', false); + $radio.data('state', false); + + $radio.trigger('change'); + } else { + $radio.data('state', true); + } + + this.initRadioAttributes(); + }); + } + + $radio.attr('data-state', $radio.prop('checked')); + }); + } + + /** + * Hide or mark as unavailable out of stock attributes if enabled + * @param {Object} data Product attribute data + */ + updateProductAttributes(data) { + const behavior = data.out_of_stock_behavior; + const inStockIds = data.in_stock_attributes; + const outOfStockMessage = ` (${data.out_of_stock_message})`; + + if (behavior !== 'hide_option' && behavior !== 'label_option') { + return; + } + + $('[data-product-attribute-value]', this.$scope).each((i, attribute) => { + const $attribute = $(attribute); + const attrId = parseInt($attribute.data('productAttributeValue'), 10); + + + if (inStockIds.indexOf(attrId) !== -1) { + this.enableAttribute($attribute, behavior, outOfStockMessage); + } else { + this.disableAttribute($attribute, behavior, outOfStockMessage); + } + }); + } + + /** + * Check for fragment identifier in URL requesting a specific tab + */ + getTabRequests() { + if (window.location.hash && window.location.hash.indexOf('#tab-') === 0) { + const $activeTab = $('.tabs').has(`[href='${window.location.hash}']`); + const $tabContent = $(`${window.location.hash}`); + + if ($activeTab.length > 0) { + $activeTab.find('.tab') + .removeClass('is-active') + .has(`[href='${window.location.hash}']`) + .addClass('is-active'); + + $tabContent.addClass('is-active') + .siblings() + .removeClass('is-active'); + } + } + } + + /** + * Since $productView can be dynamically inserted using render_with, + * We have to retrieve the respective elements + * + * @param $scope + */ + getViewModel($scope) { + return { + $priceWithTax: $('[data-product-price-with-tax]', $scope), + $priceWithoutTax: $('[data-product-price-without-tax]', $scope), + rrpWithTax: { + $div: $('.rrp-price--withTax', $scope), + $span: $('[data-product-rrp-with-tax]', $scope), + }, + rrpWithoutTax: { + $div: $('.rrp-price--withoutTax', $scope), + $span: $('[data-product-rrp-price-without-tax]', $scope), + }, + nonSaleWithTax: { + $div: $('.non-sale-price--withTax', $scope), + $span: $('[data-product-non-sale-price-with-tax]', $scope), + }, + nonSaleWithoutTax: { + $div: $('.non-sale-price--withoutTax', $scope), + $span: $('[data-product-non-sale-price-without-tax]', $scope), + }, + priceSaved: { + $div: $('.price-section--saving', $scope), + $span: $('[data-product-price-saved]', $scope), + }, + priceNowLabel: { + $span: $('.price-now-label', $scope), + }, + priceLabel: { + $span: $('.price-label', $scope), + }, + $weight: $('.productView-info [data-product-weight]', $scope), + $increments: $('.form-field--increments :input', $scope), + $addToCart: $('#form-action-addToCart', $scope), + $wishlistVariation: $('[data-wishlist-add] [name="variation_id"]', $scope), + stock: { + $container: $('.form-field--stock', $scope), + $input: $('[data-product-stock]', $scope), + }, + sku: { + $label: $('dt.sku-label', $scope), + $value: $('[data-product-sku]', $scope), + }, + upc: { + $label: $('dt.upc-label', $scope), + $value: $('[data-product-upc]', $scope), + }, + quantity: { + $text: $('.incrementTotal', $scope), + $input: $('[name=qty\\[\\]]', $scope), + }, + $bulkPricing: $('.productView-info-bulkPricing', $scope), + }; + } + + /** + * Hide the pricing elements that will show up only when the price exists in API + * @param viewModel + */ + clearPricingNotFound(viewModel) { + viewModel.rrpWithTax.$div.hide(); + viewModel.rrpWithoutTax.$div.hide(); + viewModel.nonSaleWithTax.$div.hide(); + viewModel.nonSaleWithoutTax.$div.hide(); + viewModel.priceSaved.$div.hide(); + viewModel.priceNowLabel.$span.hide(); + viewModel.priceLabel.$span.hide(); + } + + /** + * Update the view of price, messages, SKU and stock options when a product option changes + * @param {Object} data Product attribute data + */ + updateView(data, content = null) { + const viewModel = this.getViewModel(this.$scope); + + this.showMessageBox(data.stock_message || data.purchasing_message); + + if (isObject(data.price)) { + this.updatePriceView(viewModel, data.price); + } + + if (isObject(data.weight)) { + viewModel.$weight.html(data.weight.formatted); + } + + // Set variation_id if it exists for adding to wishlist + if (data.variantId) { + viewModel.$wishlistVariation.val(data.variantId); + } + + // If SKU is available + if (data.sku) { + viewModel.sku.$value.text(data.sku); + viewModel.sku.$label.show(); + } else { + viewModel.sku.$label.hide(); + viewModel.sku.$value.text(''); + } + + // If UPC is available + if (data.upc) { + viewModel.upc.$value.text(data.upc); + viewModel.upc.$label.show(); + } else { + viewModel.upc.$label.hide(); + viewModel.upc.$value.text(''); + } + + // if stock view is on (CP settings) + if (viewModel.stock.$container.length && isNumber(data.stock)) { + // if the stock container is hidden, show + viewModel.stock.$container.removeClass('u-hiddenVisually'); + + viewModel.stock.$input.text(data.stock); + } else { + viewModel.stock.$container.addClass('u-hiddenVisually'); + viewModel.stock.$input.text(data.stock); + } + + this.updateDefaultAttributesForOOS(data); + + // If Bulk Pricing rendered HTML is available + if (data.bulk_discount_rates && content) { + viewModel.$bulkPricing.html(content); + } else if (typeof (data.bulk_discount_rates) !== 'undefined') { + viewModel.$bulkPricing.html(''); + } + } + + /** + * Update the view of price, messages, SKU and stock options when a product option changes + * @param {Object} data Product attribute data + */ + updatePriceView(viewModel, price) { + this.clearPricingNotFound(viewModel); + + if (price.with_tax) { + viewModel.priceLabel.$span.show(); + viewModel.$priceWithTax.html(price.with_tax.formatted); + } + + if (price.without_tax) { + viewModel.priceLabel.$span.show(); + viewModel.$priceWithoutTax.html(price.without_tax.formatted); + } + + if (price.rrp_with_tax) { + viewModel.rrpWithTax.$div.show(); + viewModel.rrpWithTax.$span.html(price.rrp_with_tax.formatted); + } + + if (price.rrp_without_tax) { + viewModel.rrpWithoutTax.$div.show(); + viewModel.rrpWithoutTax.$span.html(price.rrp_without_tax.formatted); + } + + if (price.saved) { + viewModel.priceSaved.$div.show(); + viewModel.priceSaved.$span.html(price.saved.formatted); + } + + if (price.non_sale_price_with_tax) { + viewModel.priceLabel.$span.hide(); + viewModel.nonSaleWithTax.$div.show(); + viewModel.priceNowLabel.$span.show(); + viewModel.nonSaleWithTax.$span.html(price.non_sale_price_with_tax.formatted); + } + + if (price.non_sale_price_without_tax) { + viewModel.priceLabel.$span.hide(); + viewModel.nonSaleWithoutTax.$div.show(); + viewModel.priceNowLabel.$span.show(); + viewModel.nonSaleWithoutTax.$span.html(price.non_sale_price_without_tax.formatted); + } + } + + /** + * Show an message box if a message is passed + * Hide the box if the message is empty + * @param {String} message + */ + showMessageBox(message) { + const $messageBox = $('.productAttributes-message'); + + if (message) { + $('.alertBox-message', $messageBox).text(message); + $messageBox.show(); + } else { + $messageBox.hide(); + } + } + + updateDefaultAttributesForOOS(data) { + const viewModel = this.getViewModel(this.$scope); + if (!data.purchasable || !data.instock) { + viewModel.$addToCart.prop('disabled', true); + viewModel.$increments.prop('disabled', true); + } else { + viewModel.$addToCart.prop('disabled', false); + viewModel.$increments.prop('disabled', false); + } + } + + enableAttribute($attribute, behavior, outOfStockMessage) { + if (this.getAttributeType($attribute) === 'set-select') { + return this.enableSelectOptionAttribute($attribute, behavior, outOfStockMessage); + } + + if (behavior === 'hide_option') { + $attribute.show(); + } else { + $attribute.removeClass('unavailable'); + } + } + + disableAttribute($attribute, behavior, outOfStockMessage) { + if (this.getAttributeType($attribute) === 'set-select') { + return this.disableSelectOptionAttribute($attribute, behavior, outOfStockMessage); + } + + if (behavior === 'hide_option') { + $attribute.hide(0); + } else { + $attribute.addClass('unavailable'); + } + } + + getAttributeType($attribute) { + const $parent = $attribute.closest('[data-product-attribute]'); + + return $parent ? $parent.data('productAttribute') : null; + } + + disableSelectOptionAttribute($attribute, behavior, outOfStockMessage) { + const $select = $attribute.parent(); + + if (behavior === 'hide_option') { + $attribute.toggleOption(false); + // If the attribute is the selected option in a select dropdown, select the first option (MERC-639) + if ($select.val() === $attribute.attr('value')) { + $select[0].selectedIndex = 0; + } + } else { + $attribute.attr('disabled', 'disabled'); + $attribute.html($attribute.html().replace(outOfStockMessage, '') + outOfStockMessage); + } + } + + enableSelectOptionAttribute($attribute, behavior, outOfStockMessage) { + if (behavior === 'hide_option') { + $attribute.toggleOption(true); + } else { + $attribute.prop('disabled', false); + $attribute.html($attribute.html().replace(outOfStockMessage, '')); + } + } +} diff --git a/assets/js/theme/common/product-details.js b/assets/js/theme/common/product-details.js index 433a187317..15382dfd53 100644 --- a/assets/js/theme/common/product-details.js +++ b/assets/js/theme/common/product-details.js @@ -1,51 +1,27 @@ import utils from '@bigcommerce/stencil-utils'; +import ProductDetailsBase, { optionChangeDecorator } from './product-details-base'; import 'foundation-sites/js/foundation/foundation'; import 'foundation-sites/js/foundation/foundation.reveal'; import ImageGallery from '../product/image-gallery'; import modalFactory, { showAlertModal, modalTypes } from '../global/modal'; -import _ from 'lodash'; -import Wishlist from '../wishlist'; +import { isEmpty, isPlainObject } from 'lodash'; import { normalizeFormData } from './utils/api'; -import { initRadioOptions } from './aria'; import { isBrowserIE, convertIntoArray } from './utils/ie-helpers'; -const optionsTypesMap = { - INPUT_FILE: 'input-file', - INPUT_TEXT: 'input-text', - INPUT_NUMBER: 'input-number', - INPUT_CHECKBOX: 'input-checkbox', - TEXTAREA: 'textarea', - DATE: 'date', - SET_SELECT: 'set-select', - SET_RECTANGLE: 'set-rectangle', - SET_RADIO: 'set-radio', - SWATCH: 'swatch', - PRODUCT_LIST: 'product-list', -}; - -export default class ProductDetails { +export default class ProductDetails extends ProductDetailsBase { constructor($scope, context, productAttributesData = {}) { + super($scope, context); + this.$overlay = $('[data-cart-item-add] .loadingOverlay'); - this.$scope = $scope; - this.context = context; this.imageGallery = new ImageGallery($('[data-image-gallery]', this.$scope)); this.imageGallery.init(); this.listenQuantityChange(); - this.initRadioAttributes(); - Wishlist.load(this.context); - this.getTabRequests(); const $form = $('form[data-cart-item-add]', $scope); const $productOptionsElement = $('[data-product-option-change]', $form); const hasOptions = $productOptionsElement.html().trim().length; const hasDefaultOptions = $productOptionsElement.find('[data-default]').length; - $('[data-product-attribute]').each((__, value) => { - const type = value.getAttribute('data-product-attribute'); - - this._makeProductVariantAccessible(value, type); - }); - $productOptionsElement.on('change', event => { this.productOptionsChanged(event); this.setProductVariant(); @@ -57,19 +33,11 @@ export default class ProductDetails { // Update product attributes. Also update the initial view in case items are oos // or have default variant properties that change the view - if ((_.isEmpty(productAttributesData) || hasDefaultOptions) && hasOptions) { + if ((isEmpty(productAttributesData) || hasDefaultOptions) && hasOptions) { const $productId = $('[name="product_id"]', $form).val(); + const optionChangeCallback = optionChangeDecorator.call(this, hasDefaultOptions); - utils.api.productAttributes.optionChange($productId, $form.serialize(), 'products/bulk-discount-rates', (err, response) => { - const attributesData = response.data || {}; - const attributesContent = response.content || {}; - this.updateProductAttributes(attributesData); - if (hasDefaultOptions) { - this.updateView(attributesData, attributesContent); - } else { - this.updateDefaultAttributesForOOS(attributesData); - } - }); + utils.api.productAttributes.optionChange($productId, $form.serialize(), 'products/bulk-discount-rates', optionChangeCallback); } else { this.updateProductAttributes(productAttributesData); } @@ -79,18 +47,6 @@ export default class ProductDetails { this.previewModal = modalFactory('#previewModal')[0]; } - _makeProductVariantAccessible(variantDomNode, variantType) { - switch (variantType) { - case optionsTypesMap.SET_RADIO: - case optionsTypesMap.SWATCH: { - initRadioOptions($(variantDomNode), '[type=radio]'); - break; - } - - default: break; - } - } - setProductVariant() { const unsatisfiedRequiredFields = []; const options = []; @@ -193,66 +149,6 @@ export default class ProductDetails { } } - /** - * Since $productView can be dynamically inserted using render_with, - * We have to retrieve the respective elements - * - * @param $scope - */ - getViewModel($scope) { - return { - $priceWithTax: $('[data-product-price-with-tax]', $scope), - $priceWithoutTax: $('[data-product-price-without-tax]', $scope), - rrpWithTax: { - $div: $('.rrp-price--withTax', $scope), - $span: $('[data-product-rrp-with-tax]', $scope), - }, - rrpWithoutTax: { - $div: $('.rrp-price--withoutTax', $scope), - $span: $('[data-product-rrp-price-without-tax]', $scope), - }, - nonSaleWithTax: { - $div: $('.non-sale-price--withTax', $scope), - $span: $('[data-product-non-sale-price-with-tax]', $scope), - }, - nonSaleWithoutTax: { - $div: $('.non-sale-price--withoutTax', $scope), - $span: $('[data-product-non-sale-price-without-tax]', $scope), - }, - priceSaved: { - $div: $('.price-section--saving', $scope), - $span: $('[data-product-price-saved]', $scope), - }, - priceNowLabel: { - $span: $('.price-now-label', $scope), - }, - priceLabel: { - $span: $('.price-label', $scope), - }, - $weight: $('.productView-info [data-product-weight]', $scope), - $increments: $('.form-field--increments :input', $scope), - $addToCart: $('#form-action-addToCart', $scope), - $wishlistVariation: $('[data-wishlist-add] [name="variation_id"]', $scope), - stock: { - $container: $('.form-field--stock', $scope), - $input: $('[data-product-stock]', $scope), - }, - sku: { - $label: $('dt.sku-label', $scope), - $value: $('[data-product-sku]', $scope), - }, - upc: { - $label: $('dt.upc-label', $scope), - $value: $('[data-product-upc]', $scope), - }, - quantity: { - $text: $('.incrementTotal', $scope), - $input: $('[name=qty\\[\\]]', $scope), - }, - $bulkPricing: $('.productView-info-bulkPricing', $scope), - }; - } - /** * Checks if the current window is being run inside an iframe * @returns {boolean} @@ -289,7 +185,7 @@ export default class ProductDetails { } showProductImage(image) { - if (_.isPlainObject(image)) { + if (isPlainObject(image)) { const zoomImageUrl = utils.tools.imageSrcset.getSrcset( image.data, { '1x': this.context.zoomSize }, @@ -507,282 +403,12 @@ export default class ProductDetails { }); } - /** - * Show an message box if a message is passed - * Hide the box if the message is empty - * @param {String} message - */ - showMessageBox(message) { - const $messageBox = $('.productAttributes-message'); - - if (message) { - $('.alertBox-message', $messageBox).text(message); - $messageBox.show(); - } else { - $messageBox.hide(); - } - } - - /** - * Hide the pricing elements that will show up only when the price exists in API - * @param viewModel - */ - clearPricingNotFound(viewModel) { - viewModel.rrpWithTax.$div.hide(); - viewModel.rrpWithoutTax.$div.hide(); - viewModel.nonSaleWithTax.$div.hide(); - viewModel.nonSaleWithoutTax.$div.hide(); - viewModel.priceSaved.$div.hide(); - viewModel.priceNowLabel.$span.hide(); - viewModel.priceLabel.$span.hide(); - } - - /** - * Update the view of price, messages, SKU and stock options when a product option changes - * @param {Object} data Product attribute data - */ - updatePriceView(viewModel, price) { - this.clearPricingNotFound(viewModel); - - if (price.with_tax) { - viewModel.priceLabel.$span.show(); - viewModel.$priceWithTax.html(price.with_tax.formatted); - } - - if (price.without_tax) { - viewModel.priceLabel.$span.show(); - viewModel.$priceWithoutTax.html(price.without_tax.formatted); - } - - if (price.rrp_with_tax) { - viewModel.rrpWithTax.$div.show(); - viewModel.rrpWithTax.$span.html(price.rrp_with_tax.formatted); - } - - if (price.rrp_without_tax) { - viewModel.rrpWithoutTax.$div.show(); - viewModel.rrpWithoutTax.$span.html(price.rrp_without_tax.formatted); - } - - if (price.saved) { - viewModel.priceSaved.$div.show(); - viewModel.priceSaved.$span.html(price.saved.formatted); - } - - if (price.non_sale_price_with_tax) { - viewModel.priceLabel.$span.hide(); - viewModel.nonSaleWithTax.$div.show(); - viewModel.priceNowLabel.$span.show(); - viewModel.nonSaleWithTax.$span.html(price.non_sale_price_with_tax.formatted); - } - - if (price.non_sale_price_without_tax) { - viewModel.priceLabel.$span.hide(); - viewModel.nonSaleWithoutTax.$div.show(); - viewModel.priceNowLabel.$span.show(); - viewModel.nonSaleWithoutTax.$span.html(price.non_sale_price_without_tax.formatted); - } - } - - /** - * Update the view of price, messages, SKU and stock options when a product option changes - * @param {Object} data Product attribute data - */ - updateView(data, content = null) { - const viewModel = this.getViewModel(this.$scope); - - this.showMessageBox(data.stock_message || data.purchasing_message); - - if (_.isObject(data.price)) { - this.updatePriceView(viewModel, data.price); - } - - if (_.isObject(data.weight)) { - viewModel.$weight.html(data.weight.formatted); - } - - // Set variation_id if it exists for adding to wishlist - if (data.variantId) { - viewModel.$wishlistVariation.val(data.variantId); - } - - // If SKU is available - if (data.sku) { - viewModel.sku.$value.text(data.sku); - viewModel.sku.$label.show(); - } else { - viewModel.sku.$label.hide(); - viewModel.sku.$value.text(''); - } - - // If UPC is available - if (data.upc) { - viewModel.upc.$value.text(data.upc); - viewModel.upc.$label.show(); - } else { - viewModel.upc.$label.hide(); - viewModel.upc.$value.text(''); - } - - // if stock view is on (CP settings) - if (viewModel.stock.$container.length && _.isNumber(data.stock)) { - // if the stock container is hidden, show - viewModel.stock.$container.removeClass('u-hiddenVisually'); - - viewModel.stock.$input.text(data.stock); - } else { - viewModel.stock.$container.addClass('u-hiddenVisually'); - viewModel.stock.$input.text(data.stock); - } - - this.updateDefaultAttributesForOOS(data); - - // If Bulk Pricing rendered HTML is available - if (data.bulk_discount_rates && content) { - viewModel.$bulkPricing.html(content); - } else if (typeof (data.bulk_discount_rates) !== 'undefined') { - viewModel.$bulkPricing.html(''); - } - } - - updateDefaultAttributesForOOS(data) { - const viewModel = this.getViewModel(this.$scope); - if (!data.purchasable || !data.instock) { - viewModel.$addToCart.prop('disabled', true); - viewModel.$increments.prop('disabled', true); - } else { - viewModel.$addToCart.prop('disabled', false); - viewModel.$increments.prop('disabled', false); - } - } - /** * Hide or mark as unavailable out of stock attributes if enabled * @param {Object} data Product attribute data */ updateProductAttributes(data) { - const behavior = data.out_of_stock_behavior; - const inStockIds = data.in_stock_attributes; - const outOfStockMessage = ` (${data.out_of_stock_message})`; - + super.updateProductAttributes(data); this.showProductImage(data.image); - - if (behavior !== 'hide_option' && behavior !== 'label_option') { - return; - } - - $('[data-product-attribute-value]', this.$scope).each((i, attribute) => { - const $attribute = $(attribute); - const attrId = parseInt($attribute.data('productAttributeValue'), 10); - - - if (inStockIds.indexOf(attrId) !== -1) { - this.enableAttribute($attribute, behavior, outOfStockMessage); - } else { - this.disableAttribute($attribute, behavior, outOfStockMessage); - } - }); - } - - disableAttribute($attribute, behavior, outOfStockMessage) { - if (this.getAttributeType($attribute) === 'set-select') { - return this.disableSelectOptionAttribute($attribute, behavior, outOfStockMessage); - } - - if (behavior === 'hide_option') { - $attribute.hide(); - } else { - $attribute.addClass('unavailable'); - } - } - - disableSelectOptionAttribute($attribute, behavior, outOfStockMessage) { - const $select = $attribute.parent(); - - if (behavior === 'hide_option') { - $attribute.toggleOption(false); - // If the attribute is the selected option in a select dropdown, select the first option (MERC-639) - if ($select.val() === $attribute.attr('value')) { - $select[0].selectedIndex = 0; - } - } else { - $attribute.attr('disabled', 'disabled'); - $attribute.html($attribute.html().replace(outOfStockMessage, '') + outOfStockMessage); - } - } - - enableAttribute($attribute, behavior, outOfStockMessage) { - if (this.getAttributeType($attribute) === 'set-select') { - return this.enableSelectOptionAttribute($attribute, behavior, outOfStockMessage); - } - - if (behavior === 'hide_option') { - $attribute.show(); - } else { - $attribute.removeClass('unavailable'); - } - } - - enableSelectOptionAttribute($attribute, behavior, outOfStockMessage) { - if (behavior === 'hide_option') { - $attribute.toggleOption(true); - } else { - $attribute.prop('disabled', false); - $attribute.html($attribute.html().replace(outOfStockMessage, '')); - } - } - - getAttributeType($attribute) { - const $parent = $attribute.closest('[data-product-attribute]'); - - return $parent ? $parent.data('productAttribute') : null; - } - - /** - * Allow radio buttons to get deselected - */ - initRadioAttributes() { - $('[data-product-attribute] input[type="radio"]', this.$scope).each((i, radio) => { - const $radio = $(radio); - - // Only bind to click once - if ($radio.attr('data-state') !== undefined) { - $radio.on('click', () => { - if ($radio.data('state') === true) { - $radio.prop('checked', false); - $radio.data('state', false); - - $radio.trigger('change'); - } else { - $radio.data('state', true); - } - - this.initRadioAttributes(); - }); - } - - $radio.attr('data-state', $radio.prop('checked')); - }); - } - - /** - * Check for fragment identifier in URL requesting a specific tab - */ - getTabRequests() { - if (window.location.hash && window.location.hash.indexOf('#tab-') === 0) { - const $activeTab = $('.tabs').has(`[href='${window.location.hash}']`); - const $tabContent = $(`${window.location.hash}`); - - if ($activeTab.length > 0) { - $activeTab.find('.tab') - .removeClass('is-active') - .has(`[href='${window.location.hash}']`) - .addClass('is-active'); - - $tabContent.addClass('is-active') - .siblings() - .removeClass('is-active'); - } - } } } diff --git a/assets/scss/components/foundation/modal/_modal.scss b/assets/scss/components/foundation/modal/_modal.scss index 4612c7e1d4..bcc2f893ee 100644 --- a/assets/scss/components/foundation/modal/_modal.scss +++ b/assets/scss/components/foundation/modal/_modal.scss @@ -131,3 +131,7 @@ } } } + +.hide-content { + opacity: 0; +} diff --git a/templates/components/cart/content.html b/templates/components/cart/content.html index d3b7b2d50b..bc390c2420 100644 --- a/templates/components/cart/content.html +++ b/templates/components/cart/content.html @@ -53,7 +53,7 @@