odoo.define('sale.VariantMixin', function (require) { 'use strict'; var concurrency = require('web.concurrency'); var core = require('web.core'); var utils = require('web.utils'); var ajax = require('web.ajax'); var _t = core._t; var VariantMixin = { events: { 'change .css_attribute_color input': '_onChangeColorAttribute', 'change .main_product:not(.in_cart) input.js_quantity': 'onChangeAddQuantity', 'change [data-attribute_exclusions]': 'onChangeVariant' }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * When a variant is changed, this will check: * - If the selected combination is available or not * - The extra price if applicable * - The display name of the product ("Customizable desk (White, Steel)") * - The new total price * - The need of adding a "custom value" input * If the custom value is the only available value * (defined by its data 'is_single_and_custom'), * the custom value will have it's own input & label * * 'change' events triggered by the user entered custom values are ignored since they * are not relevant * * @param {MouseEvent} ev */ onChangeVariant: function (ev) { var $parent = $(ev.target).closest('.js_product'); if (!$parent.data('uniqueId')) { $parent.data('uniqueId', _.uniqueId()); } this._throttledGetCombinationInfo($parent.data('uniqueId'))(ev); }, /** * @see onChangeVariant * * @private * @param {Event} ev * @returns {Deferred} */ _getCombinationInfo: function (ev) { var self = this; if ($(ev.target).hasClass('variant_custom_value')) { return Promise.resolve(); } var $parent = $(ev.target).closest('.js_product'); var qty = $parent.find('input[name="add_qty"]').val(); var combination = this.getSelectedVariantValues($parent); var parentCombination = $parent.find('ul[data-attribute_exclusions]').data('attribute_exclusions').parent_combination; var productTemplateId = parseInt($parent.find('.product_template_id').val()); self._checkExclusions($parent, combination); return ajax.jsonRpc(this._getUri('/sale/get_combination_info'), 'call', { 'product_template_id': productTemplateId, 'product_id': this._getProductId($parent), 'combination': combination, 'add_qty': parseInt(qty), 'pricelist_id': this.pricelistId || false, 'parent_combination': parentCombination, }).then(function (combinationData) { self._onChangeCombination(ev, $parent, combinationData); }); }, /** * Will add the "custom value" input for this attribute value if * the attribute value is configured as "custom" (see product_attribute_value.is_custom) * * @private * @param {MouseEvent} ev */ handleCustomValues: function ($target) { var $variantContainer; var $customInput = false; if ($target.is('input[type=radio]') && $target.is(':checked')) { $variantContainer = $target.closest('ul').closest('li'); $customInput = $target; } else if ($target.is('select')) { $variantContainer = $target.closest('li'); $customInput = $target .find('option[value="' + $target.val() + '"]'); } if ($variantContainer) { if ($customInput && $customInput.data('is_custom') === 'True') { var attributeValueId = $customInput.data('value_id'); var attributeValueName = $customInput.data('value_name'); if ($variantContainer.find('.variant_custom_value').length === 0 || $variantContainer .find('.variant_custom_value') .data('custom_product_template_attribute_value_id') !== parseInt(attributeValueId)) { $variantContainer.find('.variant_custom_value').remove(); var $input = $('', { type: 'text', 'data-custom_product_template_attribute_value_id': attributeValueId, 'data-attribute_value_name': attributeValueName, class: 'variant_custom_value form-control' }); var isRadioInput = $target.is('input[type=radio]') && $target.closest('label.css_attribute_color').length === 0; if (isRadioInput && $customInput.data('is_single_and_custom') !== 'True') { $input.addClass('custom_value_radio'); $target.closest('div').after($input); } else { $input.attr('placeholder', attributeValueName); $input.addClass('custom_value_own_line'); $variantContainer.append($input); } } } else { $variantContainer.find('.variant_custom_value').remove(); } } }, /** * Hack to add and remove from cart with json * * @param {MouseEvent} ev */ onClickAddCartJSON: function (ev) { ev.preventDefault(); var $link = $(ev.currentTarget); var $input = $link.closest('.input-group').find("input"); var min = parseFloat($input.data("min") || 0); var max = parseFloat($input.data("max") || Infinity); var previousQty = parseFloat($input.val() || 0, 10); var quantity = ($link.has(".fa-minus").length ? -1 : 1) + previousQty; var newQty = quantity > min ? (quantity < max ? quantity : max) : min; if (newQty !== previousQty) { $input.val(newQty).trigger('change'); } return false; }, /** * When the quantity is changed, we need to query the new price of the product. * Based on the price list, the price might change when quantity exceeds X * * @param {MouseEvent} ev */ onChangeAddQuantity: function (ev) { var $parent; if ($(ev.currentTarget).closest('.oe_optional_products_modal').length > 0){ $parent = $(ev.currentTarget).closest('.oe_optional_products_modal'); } else if ($(ev.currentTarget).closest('form').length > 0){ $parent = $(ev.currentTarget).closest('form'); } else { $parent = $(ev.currentTarget).closest('.o_product_configurator'); } this.triggerVariantChange($parent); }, /** * Triggers the price computation and other variant specific changes * * @param {$.Element} $container */ triggerVariantChange: function ($container) { var self = this; $container.find('ul[data-attribute_exclusions]').trigger('change'); $container.find('input.js_variant_change:checked, select.js_variant_change').each(function () { self.handleCustomValues($(this)); }); }, /** * Will look for user custom attribute values * in the provided container * * @param {$.Element} $container * @returns {Array} array of custom values with the following format * {integer} custom_product_template_attribute_value_id * {string} attribute_value_name * {string} custom_value */ getCustomVariantValues: function ($container) { var variantCustomValues = []; $container.find('.variant_custom_value').each(function (){ var $variantCustomValueInput = $(this); if ($variantCustomValueInput.length !== 0){ variantCustomValues.push({ 'custom_product_template_attribute_value_id': $variantCustomValueInput.data('custom_product_template_attribute_value_id'), 'attribute_value_name': $variantCustomValueInput.data('attribute_value_name'), 'custom_value': $variantCustomValueInput.val(), }); } }); return variantCustomValues; }, /** * Will look for attribute values that do not create product variant * (see product_attribute.create_variant "dynamic") * * @param {$.Element} $container * @returns {Array} array of attribute values with the following format * {integer} custom_product_template_attribute_value_id * {string} attribute_value_name * {integer} value * {string} attribute_name * {boolean} is_custom */ getNoVariantAttributeValues: function ($container) { var noVariantAttributeValues = []; var variantsValuesSelectors = [ 'input.no_variant.js_variant_change:checked', 'select.no_variant.js_variant_change' ]; $container.find(variantsValuesSelectors.join(',')).each(function (){ var $variantValueInput = $(this); var singleNoCustom = $variantValueInput.data('is_single') && !$variantValueInput.data('is_custom'); if ($variantValueInput.is('select')){ $variantValueInput = $variantValueInput.find('option[value=' + $variantValueInput.val() + ']'); } if ($variantValueInput.length !== 0 && !singleNoCustom){ noVariantAttributeValues.push({ 'custom_product_template_attribute_value_id': $variantValueInput.data('value_id'), 'attribute_value_name': $variantValueInput.data('value_name'), 'value': $variantValueInput.val(), 'attribute_name': $variantValueInput.data('attribute_name'), 'is_custom': $variantValueInput.data('is_custom') }); } }); return noVariantAttributeValues; }, /** * Will return the list of selected product.template.attribute.value ids * For the modal, the "main product"'s attribute values are stored in the * "unchanged_value_ids" data * * @param {$.Element} $container the container to look into */ getSelectedVariantValues: function ($container) { var values = []; var unchangedValues = $container .find('div.oe_unchanged_value_ids') .data('unchanged_value_ids') || []; var variantsValuesSelectors = [ 'input.js_variant_change:checked', 'select.js_variant_change' ]; _.each($container.find(variantsValuesSelectors.join(', ')), function (el) { values.push(+$(el).val()); }); return values.concat(unchangedValues); }, /** * Will return a promise: * * - If the product already exists, immediately resolves it with the product_id * - If the product does not exist yet ("dynamic" variant creation), this method will * create the product first and then resolve the promise with the created product's id * * @param {$.Element} $container the container to look into * @param {integer} productId the product id * @param {integer} productTemplateId the corresponding product template id * @param {boolean} useAjax wether the rpc call should be done using ajax.jsonRpc or using _rpc * @returns {Promise} the promise that will be resolved with a {integer} productId */ selectOrCreateProduct: function ($container, productId, productTemplateId, useAjax) { var self = this; productId = parseInt(productId); productTemplateId = parseInt(productTemplateId); var productReady = Promise.resolve(); if (productId) { productReady = Promise.resolve(productId); } else { var params = { product_template_id: productTemplateId, product_template_attribute_value_ids: JSON.stringify(self.getSelectedVariantValues($container)), }; var route = '/sale/create_product_variant'; if (useAjax) { productReady = ajax.jsonRpc(route, 'call', params); } else { productReady = this._rpc({route: route, params: params}); } } return productReady; }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Will disable attribute value's inputs based on combination exclusions * and will disable the "add" button if the selected combination * is not available * * This will check both the exclusions within the product itself and * the exclusions coming from the parent product (meaning that this product * is an option of the parent product) * * It will also check that the selected combination does not exactly * match a manually archived product * * @private * @param {$.Element} $parent the parent container to apply exclusions * @param {Array} combination the selected combination of product attribute values */ _checkExclusions: function ($parent, combination) { var self = this; var combinationData = $parent .find('ul[data-attribute_exclusions]') .data('attribute_exclusions'); $parent .find('option, input, label') .removeClass('css_not_available') .attr('title', function () { return $(this).data('value_name') || ''; }) .data('excluded-by', ''); // exclusion rules: array of ptav // for each of them, contains array with the other ptav they exclude if (combinationData.exclusions) { // browse all the currently selected attributes _.each(combination, function (current_ptav) { if (combinationData.exclusions.hasOwnProperty(current_ptav)) { // for each exclusion of the current attribute: _.each(combinationData.exclusions[current_ptav], function (excluded_ptav) { // disable the excluded input (even when not already selected) // to give a visual feedback before click self._disableInput( $parent, excluded_ptav, current_ptav, combinationData.mapped_attribute_names ); }); } }); } // parent exclusions (tell which attributes are excluded from parent) _.each(combinationData.parent_exclusions, function (exclusions, excluded_by){ // check that the selected combination is in the parent exclusions _.each(exclusions, function (ptav) { // disable the excluded input (even when not already selected) // to give a visual feedback before click self._disableInput( $parent, ptav, excluded_by, combinationData.mapped_attribute_names, combinationData.parent_product_name ); }); }); }, /** * Extracted to a method to be extendable by other modules * * @param {$.Element} $parent */ _getProductId: function ($parent) { return parseInt($parent.find('.product_id').val()); }, /** * Will disable the input/option that refers to the passed attributeValueId. * This is used for showing the user that some combinations are not available. * * It will also display a message explaining why the input is not selectable. * Based on the "excludedBy" and the "productName" params. * e.g: Not available with Color: Black * * @private * @param {$.Element} $parent * @param {integer} attributeValueId * @param {integer} excludedBy The attribute value that excludes this input * @param {Object} attributeNames A dict containing all the names of the attribute values * to show a human readable message explaining why the input is disabled. * @param {string} [productName] The parent product. If provided, it will be appended before * the name of the attribute value that excludes this input * e.g: Not available with Customizable Desk (Color: Black) */ _disableInput: function ($parent, attributeValueId, excludedBy, attributeNames, productName) { var $input = $parent .find('option[value=' + attributeValueId + '], input[value=' + attributeValueId + ']'); $input.addClass('css_not_available'); $input.closest('label').addClass('css_not_available'); if (excludedBy && attributeNames) { var $target = $input.is('option') ? $input : $input.closest('label').add($input); var excludedByData = []; if ($target.data('excluded-by')) { excludedByData = JSON.parse($target.data('excluded-by')); } var excludedByName = attributeNames[excludedBy]; if (productName) { excludedByName = productName + ' (' + excludedByName + ')'; } excludedByData.push(excludedByName); $target.attr('title', _.str.sprintf(_t('Not available with %s'), excludedByData.join(', '))); $target.data('excluded-by', JSON.stringify(excludedByData)); } }, /** * @see onChangeVariant * * @private * @param {MouseEvent} ev * @param {$.Element} $parent * @param {Array} combination */ _onChangeCombination: function (ev, $parent, combination) { var self = this; var $price = $parent.find(".oe_price:first .oe_currency_value"); var $default_price = $parent.find(".oe_default_price:first .oe_currency_value"); var $optional_price = $parent.find(".oe_optional:first .oe_currency_value"); $price.text(self._priceToStr(combination.price)); $default_price.text(self._priceToStr(combination.list_price)); var isCombinationPossible = true; if (!_.isUndefined(combination.is_combination_possible)) { isCombinationPossible = combination.is_combination_possible; } this._toggleDisable($parent, isCombinationPossible); if (combination.has_discounted_price) { $default_price .closest('.oe_website_sale') .addClass("discount"); $optional_price .closest('.oe_optional') .removeClass('d-none') .css('text-decoration', 'line-through'); $default_price.parent().removeClass('d-none'); } else { $default_price .closest('.oe_website_sale') .removeClass("discount"); $optional_price.closest('.oe_optional').addClass('d-none'); $default_price.parent().addClass('d-none'); } var rootComponentSelectors = [ 'tr.js_product', '.oe_website_sale', '.o_product_configurator' ]; // update images only when changing product // or when either ids are 'false', meaning dynamic products. // Dynamic products don't have images BUT they may have invalid // combinations that need to disable the image. if (!combination.product_id || !this.last_product_id || combination.product_id !== this.last_product_id) { this.last_product_id = combination.product_id; self._updateProductImage( $parent.closest(rootComponentSelectors.join(', ')), combination.display_image, combination.product_id, combination.product_template_id, combination.carousel, isCombinationPossible ); } $parent .find('.product_id') .first() .val(combination.product_id || 0) .trigger('change'); $parent .find('.product_display_name') .first() .text(combination.display_name); $parent .find('.js_raw_price') .first() .text(combination.price) .trigger('change'); this.handleCustomValues($(ev.target)); }, /** * returns the formatted price * * @private * @param {float} price */ _priceToStr: function (price) { var l10n = _t.database.parameters; var precision = 2; if ($('.decimal_precision').length) { precision = parseInt($('.decimal_precision').last().data('precision')); } var formatted = _.str.sprintf('%.' + precision + 'f', price).split('.'); formatted[0] = utils.insert_thousand_seps(formatted[0]); return formatted.join(l10n.decimal_point); }, /** * Returns a throttled `_getCombinationInfo` with a leading and a trailing * call, which is memoized per `uniqueId`, and for which previous results * are dropped. * * The uniqueId is needed because on the configurator modal there might be * multiple elements triggering the rpc at the same time, and we need each * individual product rpc to be executed, but only once per individual * product. * * The leading execution is to keep good reactivity on the first call, for * a better user experience. The trailing is because ultimately only the * information about the last selected combination is useful. All * intermediary rpc can be ignored and are therefore best not done at all. * * The DropMisordered is to make sure slower rpc are ignored if the result * of a newer rpc has already been received. * * @private * @param {string} uniqueId * @returns {function} */ _throttledGetCombinationInfo: _.memoize(function (uniqueId) { var dropMisordered = new concurrency.DropMisordered(); var _getCombinationInfo = _.throttle(this._getCombinationInfo.bind(this), 500); return function (ev, params) { return dropMisordered.add(_getCombinationInfo(ev, params)); }; }), /** * Toggles the disabled class depending on the $parent element * and the possibility of the current combination. * * @private * @param {$.Element} $parent * @param {boolean} isCombinationPossible */ _toggleDisable: function ($parent, isCombinationPossible) { $parent.toggleClass('css_not_available', !isCombinationPossible); }, /** * Updates the product image. * This will use the productId if available or will fallback to the productTemplateId. * * @private * @param {$.Element} $productContainer * @param {boolean} displayImage will hide the image if true. It will use the 'invisible' class * instead of d-none to prevent layout change * @param {integer} product_id * @param {integer} productTemplateId */ _updateProductImage: function ($productContainer, displayImage, productId, productTemplateId) { var model = productId ? 'product.product' : 'product.template'; var modelId = productId || productTemplateId; var imageUrl = '/web/image/{0}/{1}/' + (this._productImageField ? this._productImageField : 'image_1024'); var imageSrc = imageUrl .replace("{0}", model) .replace("{1}", modelId); var imagesSelectors = [ 'span[data-oe-model^="product."][data-oe-type="image"] img:first', 'img.product_detail_img', 'span.variant_image img', 'img.variant_image', ]; var $img = $productContainer.find(imagesSelectors.join(', ')); if (displayImage) { $img.removeClass('invisible').attr('src', imageSrc); } else { $img.addClass('invisible'); } }, /** * Highlight selected color * * @private * @param {MouseEvent} ev */ _onChangeColorAttribute: function (ev) { var $parent = $(ev.target).closest('.js_product'); $parent.find('.css_attribute_color') .removeClass("active") .filter(':has(input:checked)') .addClass("active"); }, /** * Extension point for website_sale * * @private * @param {string} uri The uri to adapt */ _getUri: function (uri) { return uri; } }; return VariantMixin; });