diff --git a/setup/website_sale_infinite_scroll/odoo/addons/website_sale_infinite_scroll b/setup/website_sale_infinite_scroll/odoo/addons/website_sale_infinite_scroll new file mode 120000 index 0000000000..1d9453b4a1 --- /dev/null +++ b/setup/website_sale_infinite_scroll/odoo/addons/website_sale_infinite_scroll @@ -0,0 +1 @@ +../../../../website_sale_infinite_scroll \ No newline at end of file diff --git a/setup/website_sale_infinite_scroll/setup.py b/setup/website_sale_infinite_scroll/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/website_sale_infinite_scroll/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/website_sale_infinite_scroll/README.rst b/website_sale_infinite_scroll/README.rst new file mode 100644 index 0000000000..e69de29bb2 diff --git a/website_sale_infinite_scroll/__init__.py b/website_sale_infinite_scroll/__init__.py new file mode 100644 index 0000000000..0b5f48ad0a --- /dev/null +++ b/website_sale_infinite_scroll/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2020 Advitus MB +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from . import models +from . import controllers diff --git a/website_sale_infinite_scroll/__manifest__.py b/website_sale_infinite_scroll/__manifest__.py new file mode 100644 index 0000000000..f1b99565dd --- /dev/null +++ b/website_sale_infinite_scroll/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2020 Advitus MB +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +{ + "name": "eCommerce Infinite Scroll", + "category": "Website", + "version": "14.0.1.0.0", + "author": "Advitus MB, Ooops, Cetmix, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/e-commerce", + "license": "LGPL-3", + "depends": ["website_sale"], + "data": [ + "views/assets.xml", + "views/templates.xml", + "views/res_config_settings.xml", + ], + "demo": [ + "demo/demo_products.xml", + ], + "maintainers": ["dessanhemrayev", "CetmixGitDrone"], + "application": False, + "installable": True, +} diff --git a/website_sale_infinite_scroll/controllers/__init__.py b/website_sale_infinite_scroll/controllers/__init__.py new file mode 100644 index 0000000000..374d9a0cc2 --- /dev/null +++ b/website_sale_infinite_scroll/controllers/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2020 Advitus MB +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). + +from . import main diff --git a/website_sale_infinite_scroll/controllers/main.py b/website_sale_infinite_scroll/controllers/main.py new file mode 100644 index 0000000000..f70c4da4f3 --- /dev/null +++ b/website_sale_infinite_scroll/controllers/main.py @@ -0,0 +1,94 @@ +import math + +from odoo import http +from odoo.http import request + +from odoo.addons.website_sale.controllers.main import WebsiteSale + + +class WebsiteSaleInfinityScroll(WebsiteSale): + @http.route() + def shop(self, page=0, category=None, ppg=False, search="", **post): + res = super().shop( + page=page, + category=category, + search=search, + ppg=self._get_shop_ppg(ppg), + **post + ) + return request.render("website_sale.products", res.qcontext) + + def _get_shop_ppg(self, ppg): + return ( + request.website.shop_ppg + 1 + if request.website.viewref("website_sale_infinite_scroll.scroll_products") + .sudo() + .active + else ppg + ) + + @http.route( + [ + """/website_sale_infinite_scroll""", + """/website_sale_infinite_scroll/""" """page/""", + """/website_sale_infinite_scroll/""" + """category/""", + """/website_sale_infinite_scroll/category/""" + """/page/""", + ], + type="http", + auth="public", + methods=["POST"], + website=True, + ) + def website_sale_infinite_scroll_get_page( + self, page=0, category=None, search="", ppg=False, **post + ): + if ppg: + try: + ppg = int(ppg) + post["ppg"] = ppg + except ValueError: + ppg = False + if not ppg: + ppg = request.env["website"].get_current_website().shop_ppg or 20 + old_ppg = ppg + old_page = page + if page > 0: + ppg = ppg * page + page = 1 + res = super().shop(page=page, category=category, search=search, ppg=ppg, **post) + page_count = int(math.ceil(float(len(res.qcontext["products"])) / old_ppg)) + if old_page > page_count: + return request.render("website_sale_infinite_scroll.empty_page") + res.qcontext.update( + { + "ppg": old_ppg, + "page": old_page, + } + ) + return request.render( + "website_sale_infinite_scroll.infinite_products", + res.qcontext, + ) + + @http.route( + ["/infinite_scroll_preloader"], + type="http", + auth="public", + website=True, + multilang=False, + sitemap=False, + ) + def get_website_sale_infinite_scroll_preloader(self, **post): + website = request.website + response = request.redirect( + website.image_url(website, "infinite_scroll_preloader"), code=301 + ) + response.headers["Cache-Control"] = ( + "public, max-age=%s" % http.STATIC_CACHE_LONG + ) + return response diff --git a/website_sale_infinite_scroll/demo/demo_products.xml b/website_sale_infinite_scroll/demo/demo_products.xml new file mode 100644 index 0000000000..f28ee021da --- /dev/null +++ b/website_sale_infinite_scroll/demo/demo_products.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/website_sale_infinite_scroll/models/__init__.py b/website_sale_infinite_scroll/models/__init__.py new file mode 100644 index 0000000000..74b81abfff --- /dev/null +++ b/website_sale_infinite_scroll/models/__init__.py @@ -0,0 +1,2 @@ +from . import website +from . import res_config_settings diff --git a/website_sale_infinite_scroll/models/res_config_settings.py b/website_sale_infinite_scroll/models/res_config_settings.py new file mode 100644 index 0000000000..a0949e44bb --- /dev/null +++ b/website_sale_infinite_scroll/models/res_config_settings.py @@ -0,0 +1,10 @@ +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + website_sale_infinite_scroll_preloader = fields.Image( + related="website_id.infinite_scroll_preloader", + readonly=False, + ) diff --git a/website_sale_infinite_scroll/models/website.py b/website_sale_infinite_scroll/models/website.py new file mode 100644 index 0000000000..124161462f --- /dev/null +++ b/website_sale_infinite_scroll/models/website.py @@ -0,0 +1,21 @@ +import base64 + +from odoo import fields, models, tools +from odoo.modules.module import get_resource_path + + +class Website(models.Model): + _inherit = "website" + + def _default_preloader(self): + img_path = get_resource_path("web", "static/src/img/throbber-large.gif") + with tools.file_open(img_path, "rb") as f: + return base64.b64encode(f.read()) + + infinite_scroll_preloader = fields.Image( + max_width=170, + max_height=170, + string="eCommerce Infinite Scroll", + default=_default_preloader, + ) + infinite_scroll_ppg = fields.Integer(default=24) diff --git a/website_sale_infinite_scroll/readme/CONFIGURE.rst b/website_sale_infinite_scroll/readme/CONFIGURE.rst new file mode 100644 index 0000000000..1f7dcc4bae --- /dev/null +++ b/website_sale_infinite_scroll/readme/CONFIGURE.rst @@ -0,0 +1 @@ +To customize preloading icon, go to * General Settings > Website > Website sale infinite scroll preloader" diff --git a/website_sale_infinite_scroll/readme/CONTRIBUTORS.rst b/website_sale_infinite_scroll/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..d855f2707d --- /dev/null +++ b/website_sale_infinite_scroll/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ + * Cetmix + * Dessan Hemrayev diff --git a/website_sale_infinite_scroll/readme/DESCRIPTION.rst b/website_sale_infinite_scroll/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..d873ba8521 --- /dev/null +++ b/website_sale_infinite_scroll/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +This module removes pagination in /shop page and replaces it with infinite scrolling of products. +In general settings > website, it's possible to customize the image to display while loading products. diff --git a/website_sale_infinite_scroll/readme/USAGE.rst b/website_sale_infinite_scroll/readme/USAGE.rst new file mode 100644 index 0000000000..46e6edeb04 --- /dev/null +++ b/website_sale_infinite_scroll/readme/USAGE.rst @@ -0,0 +1 @@ +Go to /shop page > Customize > activate "Infinite Products Scroll" diff --git a/website_sale_infinite_scroll/static/description/icon.png b/website_sale_infinite_scroll/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/website_sale_infinite_scroll/static/description/icon.png differ diff --git a/website_sale_infinite_scroll/static/src/js/bus_longpolling.js b/website_sale_infinite_scroll/static/src/js/bus_longpolling.js new file mode 100644 index 0000000000..86fe4a0f8c --- /dev/null +++ b/website_sale_infinite_scroll/static/src/js/bus_longpolling.js @@ -0,0 +1,21 @@ +odoo.define("website_sale_infinite_scroll.Longpolling", function (require) { + "use strict"; + + var LongpollingBus = require("bus.Longpolling"); + + // Enable fast backward/forward from bfcache + LongpollingBus.include({ + init: function () { + this._super.apply(this, arguments); + $(window).unbind("unload"); + $(window).on( + "pagehige." + this._longPollingBusId, + this._onFocusChange.bind(this, {focus: false}) + ); + }, + destroy: function () { + this._super.apply(this, arguments); + $(window).off("pagehige." + this.bus_id); + }, + }); +}); diff --git a/website_sale_infinite_scroll/static/src/js/main.js b/website_sale_infinite_scroll/static/src/js/main.js new file mode 100644 index 0000000000..ed29130d6f --- /dev/null +++ b/website_sale_infinite_scroll/static/src/js/main.js @@ -0,0 +1,120 @@ +odoo.define("website_sale_infinite_scroll.main", function (require) { + "use strict"; + + var sAnimations = require("website.content.snippets.animation"); + var core = require("web.core"); + var _t = core._t; + + sAnimations.registry.infinite_scroll = sAnimations.Class.extend({ + selector: "#wrapwrap", + allow_load: true, + flag: true, + init: function () { + var self = this; + this._super.apply(this, arguments); + // Parse current page number and compute next one + var current_url = window.location.pathname; + + if (current_url.indexOf("/shop") !== -1 && self._check_pagination()) { + var current_arguments = window.location.search; + self.current_page = 1; + if (current_url.indexOf("/page") === -1) { + current_url = current_url.replace( + "/shop", + "/shop/page/" + self.current_page + ); + } + var match = current_url.match(/\/page\/(\d*)/); + self.current_page = match[1]; + current_url = current_url.replace("/page/" + self.current_page, ""); + self.next_page = self.current_page; + // Build fetch endpoint url + self.fetch_url = + current_url.replace("/shop", "/website_sale_infinite_scroll") + + "/page/" + + self.next_page + + current_arguments; + } + }, + + start: function () { + var current_url = window.location.pathname; + if (current_url.indexOf("/shop") !== -1) { + var self = this; + const container = this.el; + container.addEventListener("scroll", () => { + if (self._check_pagination()) { + self._onScroll(); + } + }); + } + }, + _check_pagination: function () { + var pagination = document.querySelector(".products_pager .pagination"); + if (!pagination) return false; + var style = window.getComputedStyle(pagination).display; + return style === "none"; + }, + + load_next_page: function () { + var self = this; + if (self.flag) { + self.next_page++; + // Set page in fetch url + var url = self.fetch_url.replace( + "/page/" + self.current_page, + "/page/" + self.next_page + ); + + // Add spinner + var $spinner = $("", { + class: "website_sale_infinite_scroll-spinner", + text: _t("Loading more products..."), + }); + + const postData = { + csrf_token: core.csrf_token, + }; + $.ajax({ + url, + success: function (table) { + if (table && table !== "") { + // Self.$("tbody").append(table); + self.$("tbody").html(table); + } else { + self.flag = false; + } + }, + // Error: edialog, + // shows the loader element before sending. + beforeSend: function () { + self.allow_load = false; + const date = new Date(); + $spinner.css( + "background", + `url(/infinite_scroll_preloader?new=${date.getTime()}) center top no-repeat` + ); + self.$el + .find(".o_wsale_products_grid_table_wrapper") + .append($spinner); + }, + // Hides the loader after completion of request, whether successfull or failor. + complete: function () { + self.allow_load = true; + self.$el.find(".website_sale_infinite_scroll-spinner").remove(); + }, + type: "POST", + dataType: "html", + data: postData, + }); + } + }, + + _onScroll: function () { + var self = this; + if (self.allow_load) { + self.load_next_page(); + } + }, + }); +}); diff --git a/website_sale_infinite_scroll/static/src/js/toors/toor.js b/website_sale_infinite_scroll/static/src/js/toors/toor.js new file mode 100644 index 0000000000..80326792a9 --- /dev/null +++ b/website_sale_infinite_scroll/static/src/js/toors/toor.js @@ -0,0 +1,39 @@ +odoo.define("website_sale_infinite_scroll.tour", function (require) { + "use strict"; + + var tour = require("web_tour.tour"); + var ajax = require("web.ajax"); + + var steps = [ + { + trigger: "#wrapwrap", + run: function () { + const url = "/website_sale_infinite_scroll/page/2"; + ajax.post(url, {async: true}).then(function (table) { + console.log(table); + }); + }, + }, + { + trigger: "#wrapwrap", + run: function () { + window.location.href = "/shop"; + const url = "/website_sale_infinite_scroll/page/1000?ppg=False"; + ajax.post(url, {async: true}).then(function (table) { + console.log(table); + }); + }, + }, + ]; + tour.register( + "website_sale_infinite_scroll", + { + url: "/shop", + test: true, + }, + steps + ); + return { + steps: steps, + }; +}); diff --git a/website_sale_infinite_scroll/static/src/scss/main.scss b/website_sale_infinite_scroll/static/src/scss/main.scss new file mode 100644 index 0000000000..84dadac5e0 --- /dev/null +++ b/website_sale_infinite_scroll/static/src/scss/main.scss @@ -0,0 +1,44 @@ +.website_sale_infinite_scroll-spinner { + text-align: center; + margin: 0 auto; + display: block; + background: url(/infinite_scroll_preloader) center top no-repeat; + padding-top: 115px; + margin-top: 30px; +} + +#scrollUp { + color: rgba(0, 0, 0, 0.7) !important; + mix-blend-mode: unset !important; +} + +.website_sale_infinite_scroll_custom { + > form { + height: 100%; + height: -moz-available; /* WebKit-based browsers will ignore this. */ + height: -webkit-fill-available; /* Mozilla-based browsers will ignore this. */ + height: fill-available; + } +} + +/* Safari only */ + +@media not all and (min-resolution: 0.001dpcm) { + /* Styles only for Safari 6 and earlier versions */ + .only-safari { + padding-bottom: 0.75rem !important; + } +} + +/* Styles only for Safari 7 and later */ +_::-webkit-full-page-media, +_:future, +:root .only-safari { + padding-bottom: 0.75rem !important; +} + +@media not all and (min-resolution: 0.001dpcm) and (-webkit-min-device-pixel-ratio: 0) and (min-color-index: 0) { + .only-safari { + padding-bottom: 0.75rem !important; + } +} diff --git a/website_sale_infinite_scroll/tests/__init__.py b/website_sale_infinite_scroll/tests/__init__.py new file mode 100644 index 0000000000..e3bdbe73d4 --- /dev/null +++ b/website_sale_infinite_scroll/tests/__init__.py @@ -0,0 +1 @@ +from . import test_website_sale_infinite_scroll diff --git a/website_sale_infinite_scroll/tests/test_website_sale_infinite_scroll.py b/website_sale_infinite_scroll/tests/test_website_sale_infinite_scroll.py new file mode 100644 index 0000000000..cc9e3050c6 --- /dev/null +++ b/website_sale_infinite_scroll/tests/test_website_sale_infinite_scroll.py @@ -0,0 +1,57 @@ +from odoo.tests.common import HttpCase, SavepointCase, TransactionCase +from odoo.tools import base64_to_image + +from odoo.addons.website.tools import MockRequest +from odoo.addons.website_sale_infinite_scroll.controllers.main import ( + WebsiteSaleInfinityScroll, +) + + +class TestWebsiteSaleHttpCase(HttpCase): + def test_ui_website_portal(self): + """Test frontend tour.""" + self.start_tour("/shop", "website_sale_infinite_scroll", login="portal") + + def test_check_page(self): + req = self.url_open("/shop") + self.assertEqual(req.status_code, 200) + + +class TestWebsiteSaleInfiniteScrollHttpCase(SavepointCase): + @classmethod + def setUpClass(cls): + super(TestWebsiteSaleInfiniteScrollHttpCase, cls).setUpClass() + cls.website = cls.env["website"].browse(1) + cls.WebsiteSaleController = WebsiteSaleInfinityScroll() + cls.public_user = cls.env.ref("base.public_user") + + def test_get_preload_url(self): + self.assertTrue(self.website._default_preloader(), msg="Must be equal") + with MockRequest(self.env, website=self.website.with_user(self.public_user)): + result = ( + self.WebsiteSaleController.get_website_sale_infinite_scroll_preloader() + ) + self.assertIn( + result.location.split("=")[0], + "/web/image/website/1/infinite_scroll_preloader?unique", + msg="Must be equal", + ) + + def test_check_page(self): + with MockRequest(self.env, website=self.website.with_user(self.public_user)): + product_count = self.WebsiteSaleController._get_shop_ppg(20) + self.assertEqual(product_count, 21, msg="Must be equal") + + +class TestWebsiteSaleInfiniteScroll(TransactionCase): + def test_website_preloader(self): + Website = self.env["website"] + website = Website.create( + { + "name": "Test Website", + "infinite_scroll_preloader": Website._default_preloader(), + } + ) + + image = base64_to_image(website.infinite_scroll_preloader) + self.assertEqual(image.format, "GIF") diff --git a/website_sale_infinite_scroll/views/assets.xml b/website_sale_infinite_scroll/views/assets.xml new file mode 100644 index 0000000000..12de849053 --- /dev/null +++ b/website_sale_infinite_scroll/views/assets.xml @@ -0,0 +1,28 @@ + + +