From 41f7c7bc6428050d4c7956edca26b7c1bd2d8e04 Mon Sep 17 00:00:00 2001 From: Kamen Zhekov Date: Mon, 2 Aug 2021 13:39:55 +0000 Subject: [PATCH] [IMP] website(_sale): filter products by price with custom range input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When browsing the eCommerce part of the website, customers should be able to filter shop products based on base sale price. Desired behavior after PR is merged: The filter slider can be enabled through the customize menu and it only filters using the product's base price (for performance reason) and takes into account currency conversions, not pricelists and discounts. It uses a custom-built library for the range input with 2 cursors (min. price and max. price). The slider is hidden when all products shown on the current page (category, etc.) have the same price (or there is only one product). A more advanced version of this implementation would enable the filtering to take more factors into account. task-2603014 closes odoo/odoo#74575 Signed-off-by: Jérémy Kersten (jke) Co-authored-by: fja-odoo Co-authored-by: Kamen Zhekov Co-authored-by: Romain Derie --- addons/website/static/lib/multirange/LICENSE | 21 ++ .../lib/multirange/multirange_custom.js | 345 ++++++++++++++++++ .../lib/multirange/multirange_custom.scss | 85 +++++ addons/website_sale/__manifest__.py | 2 + addons/website_sale/controllers/main.py | 80 +++- .../static/src/js/website_sale.js | 35 ++ addons/website_sale/views/templates.xml | 16 + 7 files changed, 579 insertions(+), 5 deletions(-) create mode 100644 addons/website/static/lib/multirange/LICENSE create mode 100644 addons/website/static/lib/multirange/multirange_custom.js create mode 100644 addons/website/static/lib/multirange/multirange_custom.scss diff --git a/addons/website/static/lib/multirange/LICENSE b/addons/website/static/lib/multirange/LICENSE new file mode 100644 index 00000000000..1943bc613da --- /dev/null +++ b/addons/website/static/lib/multirange/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Lea Verou + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/addons/website/static/lib/multirange/multirange_custom.js b/addons/website/static/lib/multirange/multirange_custom.js new file mode 100644 index 00000000000..aaf3628e337 --- /dev/null +++ b/addons/website/static/lib/multirange/multirange_custom.js @@ -0,0 +1,345 @@ +/** + * This code has been more that widely inspired by the multirange library + * which can be found on https://github.com/LeaVerou/multirange. + * + * The license file can be found in the same folder as this file. + */ + +odoo.define('website_sale.multirange', function () { +'use strict'; + +/** + * The multirange library will display the two values as one range input with + * two cursors linked by a background. This is to be used with Bootstrap + * custom-range inputs. + * + * There is 2 number inputs on the right and left of the multirange to + * display and allow quick and precise value modifications. They are + * initialized with the same value provided to the input (min, max, step). + * + * There is 2 events that are added to the input: + * - oldRangeValue: Triggered when the user clicks on a cursor or on focus + * of the right or left number input. + * - newRangeValue: Triggered when the user release a cursor or on focus + * out of the right or left number input. + * + * The options available for the multirange are : + * - On range input or as multirange method options: + * - min: minimal value of the range. Default: 0. + * - max: maximal value of the range. Default: 100. + * - step: precision of the range. Default: 1. + * - currency: symbol preceding the displayed values. Default: Empty. + * - currencyPosition: currency before/after value. Default: "after". + * - value: the current value of the range. Default: "0,100". + * + * - As multirange method options only: + * - displayCounterInput: if we display the value. Default: true. + * + * Initialization of a multiple range input can be done in two ways: + * + * Having the inputs with the options as properties and the multiple + * property set will let the library initialize it just after DOM loaded. + * + * + * + * Providing a HTMLElement and an Object with the desired options. + * + * + * + * multirange(document.querySelector('#multi'), { + * min: 2, + * max: 10, + * step: 0.5, + * currency: "€", + * currencyPosition: "before", + * value: "4,8" + * rangeWithInput: true, + * }); + */ + +const HTMLInputElement = window.HTMLInputElement; +const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value"); + +class Multirange { + constructor(input, options = {}) { + const self = this; + + /* Set default and optionnal values */ + this.input = input; + this.rangeWithInput = options.rangeWithInput === true || this.input.classList.contains('range-with-input'); + const value = options.value || this.input.getAttribute("value"); + const values = value === null ? [] : value.split(","); + this.input.min = this.min = options.min || this.input.min || 0; + this.input.max = this.max = options.max || this.input.max || 100; + this.input.step = this.step = options.step || this.input.step || 1; + this.currency = options.currency || this.input.dataset.currency || ''; + this.currencyPosition = options.currencyPosition || this.input.dataset.currencyPosition || 'after'; + + /* Wrap the input and add its ghost */ + this.rangeDiv = document.createElement("div"); + this.rangeDiv.classList = "multirange-wrapper"; + this.input.parentNode.insertBefore(this.rangeDiv, this.input.nextSibling); + this.rangeDiv.appendChild(this.input); + this.ghost = this.input.cloneNode(); + this.rangeDiv.appendChild(this.ghost); + + this.input.classList.add("multirange", "original"); + this.ghost.classList.add("multirange", "ghost"); + this.input.value = values[0] || this.min; + this.ghost.value = values[1] || this.max; + + this.inputTipLocator = document.createElement("div"); + this.inputTipLocator.classList = "tip-locator"; + this.ghostTipLocator = document.createElement("div"); + this.ghostTipLocator.classList = "tip-locator"; + this.rangeDiv.insertBefore(this.ghostTipLocator, this.input.nextSibling); + this.rangeDiv.insertBefore(this.inputTipLocator, this.ghost.nextSibling); + this.leftCounter = document.createElement("span"); + this.leftCounter.classList = "multirange-min"; + this.rightCounter = document.createElement("span"); + this.rightCounter.classList = "multirange-max"; + this.tipLocatorOptions = { + container: this.rangeDiv, + html: true, + }; + $(this.inputTipLocator).popover(Object.assign( + this.tipLocatorOptions, + { + placement: 'top', + content: this.leftCounter + }) + ); + $(this.ghostTipLocator).popover(Object.assign( + this.tipLocatorOptions, + { + placement: 'bottom', + content: this.rightCounter + }) + ); + + $(this.inputTipLocator).add($(this.ghostTipLocator)).popover('show'); + /* Add the counterInput */ + if (this.rangeWithInput) { + this.leftInput = document.createElement("input"); + this.leftInput.type = "number"; + this.leftInput.style.display = "none"; + this.leftInput.min = this.min; + this.leftInput.max = this.max; + this.leftInput.step = this.step; + this.rightInput = this.leftInput.cloneNode(); + + this.leftInput.classList = "multirange-min"; + this.rightInput.classList = "multirange-max"; + + this.leftCounter.parentNode.appendChild(this.leftInput); + this.rightCounter.parentNode.appendChild(this.rightInput); + } + + /* Define new properties on range input to link it with ghost, especially for Safari compatibility*/ + Object.defineProperty(this.input, "originalValue", descriptor.get ? descriptor : { + get: function () { + return this.value; + }, + set: function (v) { + this.value = v; + } + }); + + Object.defineProperties(this.input, { + valueLow: { + get: function () { + return Math.min(this.originalValue, self.ghost.value); + }, + set: function (v) { + this.originalValue = v; + }, + enumerable: true + }, + valueHigh: { + get: function () { + return Math.max(this.originalValue, self.ghost.value); + }, + set: function (v) { + self.ghost.value = v; + }, + enumerable: true + } + }); + + if (descriptor.get) { + Object.defineProperty(this.input, "value", { + get: function () { + return this.valueLow + "," + this.valueHigh; + }, + set: function (v) { + const values = v.split(","); + this.valueLow = values[0]; + this.valueHigh = values[1]; + this.update(); + }, + enumerable: true + }); + } + + if (typeof this.input.oninput === "function") { + this.ghost.oninput = this.input.oninput.bind(this.input); + } + + this.input.addEventListener("input", this.update.bind(this)); + this.ghost.addEventListener("input", this.update.bind(this)); + + this.input.addEventListener("touchstart", this.saveOldValues.bind(this)); + this.ghost.addEventListener("touchstart", this.saveOldValues.bind(this)); + this.input.addEventListener("mousedown", this.saveOldValues.bind(this)); + this.ghost.addEventListener("mousedown", this.saveOldValues.bind(this)); + + this.input.addEventListener("touchend", this.dispatchNewValueEvent.bind(this)); + this.ghost.addEventListener("touchend", this.dispatchNewValueEvent.bind(this)); + this.input.addEventListener("mouseup", this.dispatchNewValueEvent.bind(this)); + this.ghost.addEventListener("mouseup", this.dispatchNewValueEvent.bind(this)); + + if (this.rangeWithInput) { + this.leftCounter.addEventListener("click", this.counterInputSwitch.bind(this)); + this.rightCounter.addEventListener("click", this.counterInputSwitch.bind(this)); + + this.leftInput.addEventListener("blur", this.counterInputSwitch.bind(this)); + this.rightInput.addEventListener("blur", this.counterInputSwitch.bind(this)); + + this.leftInput.addEventListener("keypress", this.elementBlurOnEnter.bind(this)); + this.rightInput.addEventListener("keypress", this.elementBlurOnEnter.bind(this)); + + this.leftInput.addEventListener("focus", this.selectAllFocus.bind(this)); + this.rightInput.addEventListener("focus", this.selectAllFocus.bind(this)); + } + this.update(); + $(this.rangeDiv).addClass('visible'); + + } + + update() { + const low = 100 * (this.input.valueLow - this.min) / (this.max - this.min); + const high = 100 * (this.input.valueHigh - this.min) / (this.max - this.min); + const tipOffsetLow = 8 - (low * 0.15); + const tipOffsetHigh = 8 - (high * 0.15); + this.rangeDiv.style.setProperty("--low", low + '%'); + this.rangeDiv.style.setProperty("--high", high + '%'); + $(this.inputTipLocator).css({ + 'left': `calc(${low}% + (${tipOffsetLow}px))`, + 'top': '3px' + }); + $(this.ghostTipLocator).css({ + 'left': `calc(${high}% + (${tipOffsetHigh}px))`, + 'top': '18px' + }); + $(this.inputTipLocator).add($(this.ghostTipLocator)).popover('update'); + this.counterInputUpdate(); + } + + counterInputUpdate() { + if (this.rangeWithInput) { + this.leftCounter.innerText = this.formatNumber(this.input.valueLow); + this.rightCounter.innerText = this.formatNumber(this.input.valueHigh); + this.leftInput.value = this.input.valueLow; + this.rightInput.value = this.input.valueHigh; + } + } + + counterInputSwitch(ev) { + let counter = this.rightCounter; + let input = this.rightInput; + if (ev.currentTarget.classList.contains('multirange-min')) { + counter = this.leftCounter; + input = this.leftInput; + } + if (counter.style.display === "none") { + this.input.valueLow = this.leftInput.value; + this.input.valueHigh = this.rightInput.value; + this.dispatchNewValueEvent(); + this.update(); + counter.style.display = ""; + input.style.display = "none"; + } else { + counter.style.display = "none"; + input.style.display = ""; + this.saveOldValues(); + // Hack because firefox: https://bugzilla.mozilla.org/show_bug.cgi?id=1057858 + window.setTimeout(function () { + input.focus(); + }, 1); + } + } + + elementBlurOnEnter(ev) { + if (ev.key === "Enter") { + ev.currentTarget.blur(); + } + } + + selectAllFocus(ev) { + ev.currentTarget.select(); + } + + dispatchNewValueEvent() { + if (this._previousMaxPrice !== this.input.valueHigh || this._previousMinPrice !== this.input.valueLow) { + this.input.dispatchEvent(new CustomEvent("newRangeValue", { + bubbles: true, + })); + } + } + + saveOldValues() { + this._previousMinPrice = this.input.valueLow; + this._previousMaxPrice = this.input.valueHigh; + } + + formatNumber(number) { + number = String(number).split('.'); + if (number[1] && number[1].length === 1) { + number[1] += '0'; + } + let formatedNumber = number[0].replace(/(?=(?:\d{3})+$)(?!\b)/g, ',') + (number[1] ? '.' + number[1] : '.00'); + if (this.currency.length) { + if (this.currencyPosition === 'after') { + formatedNumber = formatedNumber + ' ' + this.currency; + } else { + formatedNumber = this.currency + ' ' + formatedNumber; + } + } + return formatedNumber; + } +} + +function multirange(input, options) { + if (input.classList.contains('multirange')) { + return; + } + new Multirange(input, options); +} + +return { + Multirange: Multirange, + init: multirange, +}; +}); + +odoo.define('website_sale.multirange.instance', function (require) { +'use strict'; + +const publicWidget = require('web.public.widget'); +const multirange = require('website_sale.multirange'); + +publicWidget.registry.WebsiteMultirangeInputs = publicWidget.Widget.extend({ + selector: 'input[type=range][multiple]:not(.multirange)', + + /** + * @override + */ + start() { + return this._super.apply(this, arguments).then(() => { + multirange.init(this.el); + }); + }, +}); +}); diff --git a/addons/website/static/lib/multirange/multirange_custom.scss b/addons/website/static/lib/multirange/multirange_custom.scss new file mode 100644 index 00000000000..c170b0f2724 --- /dev/null +++ b/addons/website/static/lib/multirange/multirange_custom.scss @@ -0,0 +1,85 @@ +// This code has been more that widely inspired by the multirange library +// which can be found on https://github.com/LeaVerou/multirange. +// The license file can be found in the same folder as this file. + + input[type="range"][multiple] { + pointer-events: none; + visibility: hidden; + + &::-webkit-slider-thumb { + pointer-events: auto; + } + + &::-moz-range-thumb { + pointer-events: auto; + } + + &.reverse { + direction: rtl; + } + + &.multirange { + width: 100%; + padding: 0; + margin: 0; + display: inline-block; + vertical-align: top; + + + &::-webkit-slider-thumb { + height: 16px; + width: 16px; + } + + &.original { + position: absolute; + + &::-webkit-slider-thumb { + position: relative; + z-index: 2; + } + + &::-moz-range-thumb { + transform: scale(1); /* Firefox doesn't apply position it seems */ + z-index: 1; + } + } + + &::-moz-range-track { + border-color: transparent; /* needed to switch Firefox to "styleable" control */ + } + + &.ghost { + border-radius: 45%; + position: relative; + background: var(--track-background); + --track-background: linear-gradient(to right, transparent var(--low), #{theme-color('primary')} var(--low) var(--high), transparent 0) no-repeat 50% / 100% 35%; + + &::-webkit-slider-runnable-track { + background: var(--track-background); + } + + &::-moz-range-track { + background: var(--track-background); + } + } + } +} + +.multirange-wrapper { + position: relative; + margin: 3rem 0; + visibility: hidden; + + input[type="range"][multiple] { + visibility: initial; + } + + [x-out-of-boundaries] { + display: none; + } +} + +.tip-locator { + position: absolute; +} diff --git a/addons/website_sale/__manifest__.py b/addons/website_sale/__manifest__.py index c68035ee7e8..74e2021d53d 100644 --- a/addons/website_sale/__manifest__.py +++ b/addons/website_sale/__manifest__.py @@ -43,6 +43,7 @@ 'website_sale/static/src/scss/website_sale.scss', 'website_sale/static/src/scss/website_mail.scss', 'website_sale/static/src/scss/website_sale_frontend.scss', + 'website/static/lib/multirange/multirange_custom.scss', 'sale/static/src/scss/sale_portal.scss', 'sale/static/src/scss/product_configurator.scss', 'sale/static/src/js/variant_mixin.js', @@ -53,6 +54,7 @@ 'website_sale/static/src/js/website_sale_validate.js', 'website_sale/static/src/js/website_sale_recently_viewed.js', 'website_sale/static/src/js/website_sale_tracking.js', + 'website/static/lib/multirange/multirange_custom.js', ], 'web._assets_primary_variables': [ 'website_sale/static/src/scss/primary_variables.scss', diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index 53c584c4e78..2c87750f7a2 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -3,6 +3,7 @@ import json import logging from werkzeug.exceptions import Forbidden, NotFound +from werkzeug.urls import url_decode, url_encode, url_parse from odoo import fields, http, SUPERUSER_ID, tools, _ from odoo.fields import Command @@ -207,8 +208,17 @@ class WebsiteSale(http.Controller): '''/shop/category/''', '''/shop/category//page/''' ], type='http', auth="public", website=True, sitemap=sitemap_shop) - def shop(self, page=0, category=None, search='', ppg=False, **post): + def shop(self, page=0, category=None, search='', min_price=0.0, max_price=0.0, ppg=False, **post): add_qty = int(post.get('add_qty', 1)) + try: + min_price = float(min_price) + except ValueError: + min_price = 0 + try: + max_price = float(max_price) + except ValueError: + max_price = 0 + Category = request.env['product.public.category'] if category: category = Category.search([('id', '=', int(category))], limit=1) @@ -232,10 +242,9 @@ class WebsiteSale(http.Controller): attrib_values = [[int(x) for x in v.split("-")] for v in attrib_list if v] attributes_ids = {v[0] for v in attrib_values} attrib_set = {v[1] for v in attrib_values} - domain = self._get_search_domain(search, category, attrib_values) - keep = QueryURL('/shop', category=category and int(category), search=search, attrib=attrib_list, order=post.get('order')) + keep = QueryURL('/shop', category=category and int(category), search=search, attrib=attrib_list, min_price=min_price, max_price=max_price, order=post.get('order')) pricelist_context, pricelist = self._get_pricelist_context() @@ -249,6 +258,37 @@ class WebsiteSale(http.Controller): Product = request.env['product.template'].with_context(bin_size=True) + filter_by_price_enabled = request.website.is_view_active('website_sale.filter_products_price') + if filter_by_price_enabled: + company_currency = request.website.company_id.currency_id + conversion_rate = request.env['res.currency']._get_conversion_rate(company_currency, pricelist.currency_id, request.website.company_id, fields.Date.today()) + + # This is ~4 times more efficient than a search for the cheapest and most expensive products + from_clause, where_clause, where_params = Product._where_calc(domain).get_sql() + query = f""" + SELECT COALESCE(MIN(list_price), 0) * {conversion_rate}, COALESCE(MAX(list_price), 0) * {conversion_rate} + FROM {from_clause} + WHERE {where_clause} + """ + request.env.cr.execute(query, where_params) + available_min_price, available_max_price = request.env.cr.fetchone() + + if min_price or max_price: + # The if/else condition in the min_price / max_price value assignment + # tackles the case where we switch to a list of products with different + # available min / max prices than the ones set in the previous page. + # In order to have logical results and not yield empty product lists, the + # price filter is set to their respective available prices when the specified + # min exceeds the max, and / or the specified max is lower than the available min. + if min_price: + min_price = min_price if min_price <= available_max_price else available_min_price + post['min_price'] = min_price + domain = expression.AND([domain, [('list_price', '>=', min_price / conversion_rate)]]) + if max_price: + max_price = max_price if max_price >= available_min_price else available_max_price + post['max_price'] = max_price + domain = expression.AND([domain, [('list_price', '<=', max_price / conversion_rate)]]) + search_product = Product.search(domain, order=self._get_search_order(post)) website_domain = request.website.website_domain() categs_domain = [('parent_id', '=', False)] + website_domain @@ -300,6 +340,11 @@ class WebsiteSale(http.Controller): 'search_categories_ids': search_categories.ids, 'layout_mode': layout_mode, } + if filter_by_price_enabled: + values['min_price'] = min_price or available_min_price + values['max_price'] = max_price or available_max_price + values['available_min_price'] = tools.float_round(available_min_price, 2) + values['available_max_price'] = tools.float_round(available_max_price, 2) if category: values['main_object'] = category return request.render("website_sale.products", values) @@ -325,10 +370,12 @@ class WebsiteSale(http.Controller): category = ProductCategory.browse(int(category)).exists() attrib_list = request.httprequest.args.getlist('attrib') + min_price = request.params.get('min_price') + max_price = request.params.get('max_price') attrib_values = [[int(x) for x in v.split("-")] for v in attrib_list if v] attrib_set = {v[1] for v in attrib_values} - keep = QueryURL('/shop', category=category and category.id, search=search, attrib=attrib_list) + keep = QueryURL('/shop', category=category and category.id, search=search, attrib=attrib_list, min_price=min_price, max_price=max_price) categs = ProductCategory.search([('parent_id', '=', False)]) @@ -359,9 +406,32 @@ class WebsiteSale(http.Controller): def pricelist_change(self, pl_id, **post): if (pl_id.selectable or pl_id == request.env.user.partner_id.property_product_pricelist) \ and request.website.is_pricelist_available(pl_id.id): + redirect_url = request.httprequest.referrer + if redirect_url and request.website.is_view_active('website_sale.filter_products_price'): + decoded_url = url_parse(redirect_url) + args = url_decode(decoded_url.query) + min_price = args.get('min_price') + max_price = args.get('max_price') + if min_price or max_price: + previous_price_list = request.website.get_current_pricelist() + try: + min_price = float(min_price) + args['min_price'] = min_price and str( + previous_price_list.currency_id._convert(min_price, pl_id.currency_id, request.website.company_id, fields.Date.today(), round=False) + ) + except (ValueError, TypeError): + pass + try: + max_price = float(max_price) + args['max_price'] = max_price and str( + previous_price_list.currency_id._convert(max_price, pl_id.currency_id, request.website.company_id, fields.Date.today(), round=False) + ) + except (ValueError, TypeError): + pass + redirect_url = decoded_url.replace(query=url_encode(args)).to_url() request.session['website_sale_current_pl'] = pl_id.id request.website.sale_get_order(force_pricelist=pl_id.id) - return request.redirect(request.httprequest.referrer or '/shop') + return request.redirect(redirect_url or '/shop') @http.route(['/shop/pricelist'], type='http', auth="public", website=True, sitemap=False) def pricelist(self, promo, **post): diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js index 7ae80105d97..da1e435f41d 100644 --- a/addons/website_sale/static/src/js/website_sale.js +++ b/addons/website_sale/static/src/js/website_sale.js @@ -962,3 +962,38 @@ publicWidget.registry.websiteSaleProductPageReviews = publicWidget.Widget.extend }, }); }); + +odoo.define('website_sale.price_range_option', function (require) { +'use strict'; + +const publicWidget = require('web.public.widget'); + +publicWidget.registry.multirangePriceSelector = publicWidget.Widget.extend({ + selector: '#o_wsale_price_range_option', + events: { + 'newRangeValue input[type="range"]': '_onPriceRangeSelected', + }, + + //---------------------------------------------------------------------- + // Handlers + //---------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onPriceRangeSelected(ev) { + const range = ev.currentTarget; + const search = $.deparam(window.location.search.substring(1)); + delete search.min_price; + delete search.max_price; + if (parseFloat(range.min) !== range.valueLow) { + search['min_price'] = range.valueLow; + } + if (parseFloat(range.max) !== range.valueHigh) { + search['max_price'] = range.valueHigh; + } + window.location.search = $.param(search); + }, +}); +}); diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 9277029f138..3b68bf5fde4 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -541,6 +541,22 @@ + +