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 @@ + +