diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e54e4d8..cb37e4b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -183,6 +183,7 @@ addons-cm/ ├── # --- Ventas y web --- ├── website_sale_aplicoop/ # Sistema eskaera (compras grupo) ├── website_sale_disable_cart/ # Tienda solo catálogo: desactiva el carrito estándar +├── website_sale_lazy_loading/ # /shop bajo demanda: scroll infinito o botón "Cargar más" ├── portal_event_registration/ # Portal: ver registros de eventos + adjuntos ├── # --- Membresías --- ├── membership_monthly_invoicing/ # Factura mensual de membresía por socio (cron) @@ -222,6 +223,7 @@ addons-cm/ - [website_sale_aplicoop](../website_sale_aplicoop/README.rst) - Sistema eskaera completo - [website_sale_disable_cart](../website_sale_disable_cart/README.rst) - Tienda solo catálogo: oculta el carrito estándar y redirige `/shop/cart*` +- [website_sale_lazy_loading](../website_sale_lazy_loading/README.rst) - `/shop` bajo demanda: scroll infinito o botón "Cargar más" en vez del paginador - [portal_event_registration](../portal_event_registration/README.rst) - Portal: registros de eventos + adjuntos al chatter **Membresías** diff --git a/website_sale_lazy_loading/LICENSE.txt b/website_sale_lazy_loading/LICENSE.txt new file mode 100644 index 0000000..61a65ee --- /dev/null +++ b/website_sale_lazy_loading/LICENSE.txt @@ -0,0 +1,56 @@ +GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 2007 + +Website Sale - Aplicoop +Copyright 2025 Criptomart SL + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +--- + +FULL LICENSE TEXT +================= + +For the complete AGPL-3 license text, see: +https://www.gnu.org/licenses/agpl-3.0.html + +--- + +SUMMARY OF RIGHTS +================= + +When you distribute a modified version under AGPL-3, you must: + +1. Keep the same license (AGPL-3) +2. Provide a copy of the license with your distribution +3. State what changes you made +4. Include the original copyright notices +5. If distributed over a network, provide source code access + +Detailed information: https://www.gnu.org/licenses/agpl-3.0-standalone.html + +--- + +ATTRIBUTION +=========== + +This module was developed by: Criptomart SL +Website: https://criptomart.net + +Original inspiration: Aplicoop project +https://sourceforge.net/projects/aplicoop/ + +--- + +This file is part of the Website Sale - Aplicoop module for Odoo. diff --git a/website_sale_lazy_loading/README.rst b/website_sale_lazy_loading/README.rst new file mode 100644 index 0000000..131c1ea --- /dev/null +++ b/website_sale_lazy_loading/README.rst @@ -0,0 +1,115 @@ +================================ +Website Sale - Lazy Loading +================================ + +.. image:: https://img.shields.io/badge/license-AGPL--3-blue.svg + :target: https://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. image:: https://img.shields.io/badge/Odoo-18.0-blue + :alt: Odoo: 18.0 + +Loads the products of the eCommerce shop (``/shop``) **on demand** instead of +paginating them: the visitor keeps the products already on screen and the next +page is appended to the grid, either when they scroll to the bottom of the +listing (infinite scroll) or when they click a *Load more products* button. + +It is the same idea as the lazy loading of ``website_sale_aplicoop`` +(``/eskaera``), rebuilt for the standard shop. + +What the module does +==================== + +- Adds a per-website setting choosing how the listing loads its pages: + infinite scroll, a *Load more* button, or the standard pager. +- Adds ``/shop/lazy_products``, which renders one page of the listing as an + HTML fragment (the product cards only) plus whether a further page exists. +- Replaces the product loop of ``website_sale.products`` with a call to a + shared template, so the first page and the appended ones are rendered by the + very same markup. +- Hides the standard pager from the frontend once the widget takes over. + +The page size is the **Products per page** of the shop layout, the same value +the standard pager uses, so nothing has to be configured twice. + +Design notes +============ + +- The ``/shop`` controller is not duplicated: ``/shop/lazy_products`` calls + ``shop()`` and renders the grid out of the values it prepared. Everything + the standard listing supports — search, categories, attributes, tags, price + filter, sort order, pricelists — is therefore supported as is, and so are + the values other modules add to the shop (the wishlist state, for example). +- The product cards come from ``website_sale.products_item``, so ribbons, + prices, add-to-cart, wishlist or comparison buttons added by other modules + are rendered exactly as on the first page. Public widgets are restarted on + the appended cards through ``widgets_start_request``. +- Progressive enhancement: the first page and the pager are still rendered by + the standard controller. Without JavaScript, the pager stays visible and the + shop behaves exactly as it does without this module — which is also what + search engine crawlers get. +- The frontend takes no decision about *what* to show: the server sends the + mode, the URL of the current listing and each page of cards. + +Configuration +============= + +Go to *Website > Configuration > Settings > Shop - Products > Shop Product +Loading* and pick how the listing loads its pages. The setting is per website: + +- **Infinite scroll** (default): the next page is appended when the visitor + reaches the bottom of the listing. A *Load more products* button shows up as + a fallback if a request fails or if the browser has no + ``IntersectionObserver``. +- **Load more button**: the next page is appended when the visitor clicks the + button. This is the friendlier option for keyboard and screen reader users, + and it keeps the footer reachable. +- **Pager (standard)**: turns the module off and leaves the standard + ``website_sale`` pager alone. + +The number of products per page is the *Products per page* of the shop layout +(open ``/shop`` as editor, *Edit*, then the *Products Page* options). + +Usage +===== + +There is nothing to do at runtime. Worth knowing: + +- Product links keep the page they belong to (``?page=3``), so coming back + from a product page lands on the right part of the listing. +- Changing a filter, a category or the sort order is still a normal page load; + lazy loading restarts from the first page of the new listing. +- Reloading the page starts again from the first page: the appended pages are + not part of the URL. +- The endpoint answers with the same data ``/shop`` would render for that + page, under the rights of the visitor making the request. + +Known limitations +================= + +- The listing is only appended, never reduced: it grows until the visitor + leaves or reloads the page. +- Infinite scroll pushes the footer away as long as pages remain; use the + button mode if the footer matters on your site. + +Technical +========= + +:: + + controllers/website_sale.py /shop values + /shop/lazy_products endpoint + models/website.py shop_lazy_loading (per website) + views/..._templates.xml shared grid template + lazy loading block + static/src/js/... public widget appending the pages + +Tests: ``docker-compose run --rm odoo odoo $ODOO_ADDONS -d odoo --test-enable +--stop-after-init -u website_sale_lazy_loading`` + +Credits +======= + +**Authors:** Criptomart + +The approach (a server rendered fragment appended to the grid, an observer +close to the bottom of the listing and a button as a fallback) comes from the +lazy loading of the Eskaera shop in ``website_sale_aplicoop``, reimplemented +here on top of the standard ``website_sale`` controller and templates. diff --git a/website_sale_lazy_loading/__init__.py b/website_sale_lazy_loading/__init__.py new file mode 100644 index 0000000..c3246aa --- /dev/null +++ b/website_sale_lazy_loading/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from . import controllers +from . import models diff --git a/website_sale_lazy_loading/__manifest__.py b/website_sale_lazy_loading/__manifest__.py new file mode 100644 index 0000000..fdfe3c9 --- /dev/null +++ b/website_sale_lazy_loading/__manifest__.py @@ -0,0 +1,28 @@ +# Copyright 2026 - Today Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +{ # noqa: B018 + "name": "Website Sale - Lazy Loading", + "version": "18.0.1.0.0", + "category": "Website/Sale", + "summary": "Load the /shop product listing on demand: infinite scroll or a " + "'Load more' button instead of the pager", + "author": "Criptomart", + "maintainers": ["Criptomart"], + "website": "https://git.criptomart.net/criptomart/addons-cm", + "license": "AGPL-3", + "depends": [ + "website_sale", + ], + "data": [ + "views/res_config_settings_views.xml", + "views/website_sale_lazy_loading_templates.xml", + ], + "assets": { + "web.assets_frontend": [ + "website_sale_lazy_loading/static/src/css/website_sale_lazy_loading.css", + "website_sale_lazy_loading/static/src/js/website_sale_lazy_loading.js", + ], + }, + "installable": True, +} diff --git a/website_sale_lazy_loading/controllers/__init__.py b/website_sale_lazy_loading/controllers/__init__.py new file mode 100644 index 0000000..f1d4a0e --- /dev/null +++ b/website_sale_lazy_loading/controllers/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from . import website_sale diff --git a/website_sale_lazy_loading/controllers/website_sale.py b/website_sale_lazy_loading/controllers/website_sale.py new file mode 100644 index 0000000..a113421 --- /dev/null +++ b/website_sale_lazy_loading/controllers/website_sale.py @@ -0,0 +1,166 @@ +# Copyright 2026 - Today Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +import logging +from urllib.parse import urlencode + +from werkzeug.exceptions import NotFound + +from odoo import http +from odoo.http import request + +from odoo.addons.website_sale.controllers.main import WebsiteSale + +_logger = logging.getLogger(__name__) + +LAZY_LOADING_ROUTE = "/shop/lazy_products" +GRID_ITEMS_TEMPLATE = "website_sale_lazy_loading.products_grid_items" + + +class WebsiteSaleLazyLoading(WebsiteSale): + """Serve the /shop listing page by page over AJAX. + + The standard ``/shop`` rendering is untouched: it still answers with the + first page and with its pager, so visitors without JavaScript (and search + engine crawlers) keep a fully navigable listing. What this controller adds + is a second entry point returning the very same product cards as an HTML + fragment, which the frontend appends to the grid already on screen. + """ + + # ------------------------------------------------------------------ + # Values for the /shop page + # ------------------------------------------------------------------ + + def _get_additional_extra_shop_values(self, values, **post): + """Feed the lazy loading block of the ``website_sale.products`` page.""" + res = super()._get_additional_extra_shop_values(values, **post) + res.update(self._prepare_lazy_loading_values(values)) + return res + + def _prepare_lazy_loading_values(self, values): + """Return what the template needs to hand over to the frontend. + + Everything is precomputed here (including the ``data-*`` payload) so + the template only reads plain values and the JavaScript side only + appends what the server sends back. + """ + pager = values.get("pager") or {} + page = pager.get("page", {}).get("num", 1) + page_count = pager.get("page_count", 1) + mode = request.website.shop_lazy_loading or "off" + # Nothing to lazy load on the last page: the block would only add an + # observer and a button that can never fetch anything. + if mode == "off" or page >= page_count: + return {"lazy_loading_active": False} + return { + "lazy_loading_active": True, + "lazy_loading_mode": mode, + "lazy_loading_url": self._get_lazy_loading_url(values), + "lazy_loading_page": page, + "lazy_loading_page_count": page_count, + } + + def _get_lazy_loading_url(self, values): + """Return the AJAX URL carrying the filters of the current listing. + + The query string of the current request already holds the search + terms, the attribute values, the tags, the price range and the sort + order; only the category travels in the path, so it is added back as a + parameter. The page number is dropped: the frontend sets it. + """ + params = request.httprequest.args.to_dict(flat=False) + params.pop("page", None) + category = values.get("category") + if category: + params["category"] = [str(category.id)] + query = urlencode(params, doseq=True) + if not query: + return LAZY_LOADING_ROUTE + return "%s?%s" % (LAZY_LOADING_ROUTE, query) + + # ------------------------------------------------------------------ + # AJAX endpoint + # ------------------------------------------------------------------ + + @http.route( + [LAZY_LOADING_ROUTE], + type="http", + auth="public", + website=True, + methods=["GET"], + sitemap=False, + ) + def shop_lazy_products(self, page=1, category=None, **post): + """Render one page of the shop listing as an HTML fragment. + + :return: JSON with the ``html`` of the product cards, the ``page`` it + belongs to and whether a further page (``has_next``) exists. + """ + page = self._get_lazy_loading_page(page) + if category is not None and not str(category).isdigit(): + # The category travels as a bare id; anything else is a broken URL + # rather than a category the visitor could have picked. + category = None + try: + response = self.shop(page=page, category=category, **post) + except NotFound: + return request.make_json_response( + {"error": "not_found", "html": "", "has_next": False}, status=404 + ) + values = getattr(response, "qcontext", None) or {} + if not getattr(response, "is_qweb", False) or "pager" not in values: + # /shop answered something else than the listing, e.g. the login + # redirect of a website whose eCommerce is restricted. + _logger.info("[LAZY_LOADING] /shop did not render the product listing") + return request.make_json_response( + {"error": "unavailable", "html": "", "has_next": False}, status=403 + ) + pager = values["pager"] + page_count = pager["page_count"] + if pager["page"]["num"] != page: + # Out of range: the pager clamps to the last page, and sending it + # again would duplicate the cards already on screen. + return request.make_json_response( + {"html": "", "page": page, "page_count": page_count, "has_next": False} + ) + values = dict(values, **self._prepare_lazy_grid_values(values)) + html = request.env["ir.ui.view"]._render_template(GRID_ITEMS_TEMPLATE, values) + _logger.debug( + "[LAZY_LOADING] page %s/%s rendered with %s products", + page, + page_count, + len(values["products"]), + ) + return request.make_json_response( + { + "html": html, + "page": page, + "page_count": page_count, + "has_next": page < page_count, + } + ) + + def _get_lazy_loading_page(self, page): + """Return ``page`` as a page number, defaulting to the first page.""" + try: + page = int(page) + except (TypeError, ValueError): + return 1 + return max(page, 1) + + def _prepare_lazy_grid_values(self, values): + """Complete the shop values with what the grid layout needs. + + ``website_sale.products`` computes these on the fly right before the + grid; the fragment is rendered on its own, so they are rebuilt here + from the same shop options. + """ + website = request.website + has_left_column = website.is_view_active( + "website_sale.products_categories" + ) or website.is_view_active("website_sale.products_attributes") + return { + "grid_md_allow_custom_cols": has_left_column, + "grid_md_use_3col": not has_left_column and values.get("ppr") == 4, + "product_block_name": "Product", + } diff --git a/website_sale_lazy_loading/models/__init__.py b/website_sale_lazy_loading/models/__init__.py new file mode 100644 index 0000000..f76839d --- /dev/null +++ b/website_sale_lazy_loading/models/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from . import website +from . import res_config_settings diff --git a/website_sale_lazy_loading/models/res_config_settings.py b/website_sale_lazy_loading/models/res_config_settings.py new file mode 100644 index 0000000..1572ecc --- /dev/null +++ b/website_sale_lazy_loading/models/res_config_settings.py @@ -0,0 +1,16 @@ +# Copyright 2026 - Today Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import fields +from odoo import models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + # Per website: a shop with a handful of products may keep the pager while + # a catalog with thousands of them loads on demand. + shop_lazy_loading = fields.Selection( + related="website_id.shop_lazy_loading", + readonly=False, + ) diff --git a/website_sale_lazy_loading/models/website.py b/website_sale_lazy_loading/models/website.py new file mode 100644 index 0000000..e0022b8 --- /dev/null +++ b/website_sale_lazy_loading/models/website.py @@ -0,0 +1,30 @@ +# Copyright 2026 - Today Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import fields +from odoo import models + + +class Website(models.Model): + _inherit = "website" + + shop_lazy_loading = fields.Selection( + selection=[ + ("off", "Pager (standard)"), + ("button", "Load more button"), + ("scroll", "Infinite scroll"), + ], + string="Shop Product Loading", + default="scroll", + required=True, + help="How the /shop listing loads the pages after the first one.\n" + "- Pager (standard): the visitor clicks the page numbers, each page " + "is a full reload.\n" + "- Load more button: the next page is appended when the visitor " + "clicks the button.\n" + "- Infinite scroll: the next page is appended when the visitor " + "reaches the bottom of the listing.\n" + "The page size is the 'Products per page' of the shop layout " + "(website editor), so it is shared with the standard pager. Visitors " + "without JavaScript always get the standard pager.", + ) diff --git a/website_sale_lazy_loading/readme/CONFIGURE.rst b/website_sale_lazy_loading/readme/CONFIGURE.rst new file mode 100644 index 0000000..1b480df --- /dev/null +++ b/website_sale_lazy_loading/readme/CONFIGURE.rst @@ -0,0 +1,17 @@ +Go to *Website > Configuration > Settings > Shop - Products > Shop Product +Loading* and pick how the listing loads its pages. The setting is **per +website**: + +* **Infinite scroll** (default): the next page is appended when the visitor + reaches the bottom of the listing. A *Load more products* button shows up as + a fallback if a request fails or if the browser has no + ``IntersectionObserver``. +* **Load more button**: the next page is appended when the visitor clicks the + button. This is the friendlier option for keyboard and screen reader users, + and it keeps the footer reachable. +* **Pager (standard)**: turns the module off and leaves the standard + ``website_sale`` pager alone. + +The number of products of each page is **not** configured here: it is the +*Products per page* of the shop layout (open ``/shop`` as editor, *Edit* and +use the *Products Page* options), the same value the standard pager uses. diff --git a/website_sale_lazy_loading/readme/CONTRIBUTORS.rst b/website_sale_lazy_loading/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..84284ab --- /dev/null +++ b/website_sale_lazy_loading/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Criptomart `_: + + * Development and maintenance diff --git a/website_sale_lazy_loading/readme/CREDITS.rst b/website_sale_lazy_loading/readme/CREDITS.rst new file mode 100644 index 0000000..c034aac --- /dev/null +++ b/website_sale_lazy_loading/readme/CREDITS.rst @@ -0,0 +1,10 @@ +**Authors:** + +* Criptomart + +**Other credits:** + +The approach (a server rendered fragment appended to the grid, an observer +close to the bottom of the listing and a button as a fallback) comes from the +lazy loading of the Eskaera shop in ``website_sale_aplicoop``, reimplemented +here on top of the standard ``website_sale`` controller and templates. diff --git a/website_sale_lazy_loading/readme/DESCRIPTION.rst b/website_sale_lazy_loading/readme/DESCRIPTION.rst new file mode 100644 index 0000000..0869dbc --- /dev/null +++ b/website_sale_lazy_loading/readme/DESCRIPTION.rst @@ -0,0 +1,20 @@ +This module loads the products of the eCommerce shop (``/shop``) **on demand** +instead of paginating them: the visitor keeps the products already on screen +and the next page is appended to the grid, either when they scroll to the +bottom of the listing (infinite scroll) or when they click a *Load more +products* button. + +It is the same idea as the lazy loading of ``website_sale_aplicoop`` +(``/eskaera``), rebuilt for the standard shop: + +* The page size is the **Products per page** of the shop layout, the very same + value the standard pager uses, so nothing has to be configured twice. +* The product cards come from the standard ``website_sale.products_item`` + template, so ribbons, prices, wishlist or comparison buttons added by other + modules are rendered exactly as on the first page. +* Search, categories, attributes, tags, price filter and sort order are kept: + the appended pages are the pages of the listing currently on screen. + +The first page and the pager are still rendered by the standard controller, so +visitors without JavaScript — and search engine crawlers — keep a listing they +can navigate page by page. diff --git a/website_sale_lazy_loading/readme/INSTALL.rst b/website_sale_lazy_loading/readme/INSTALL.rst new file mode 100644 index 0000000..6adc49e --- /dev/null +++ b/website_sale_lazy_loading/readme/INSTALL.rst @@ -0,0 +1,6 @@ +Install it like any other addon:: + + docker-compose run --rm odoo odoo -d odoo -i website_sale_lazy_loading --stop-after-init + docker-compose up -d + +It only depends on ``website_sale``. diff --git a/website_sale_lazy_loading/readme/USAGE.rst b/website_sale_lazy_loading/readme/USAGE.rst new file mode 100644 index 0000000..90a52e5 --- /dev/null +++ b/website_sale_lazy_loading/readme/USAGE.rst @@ -0,0 +1,24 @@ +There is nothing to do at runtime: once the mode is set, ``/shop`` loads its +pages on demand. + +How it works +~~~~~~~~~~~~ + +``/shop`` is rendered by the standard controller, with its pager. When a next +page exists, this module adds a block below the grid carrying the loading mode +and the URL of the current listing (search, category, attributes, tags, price +range and sort order included). The frontend widget then hides the pager and +requests ``/shop/lazy_products?...&page=N``, which answers with the product +cards of that page as an HTML fragment plus whether a further page exists, and +appends them to the grid. + +Consequences worth knowing: + +* Product links keep the page they belong to (``?page=3``), so coming back from + a product page lands on the right part of the listing. +* Changing a filter, a category or the sort order is still a normal page load; + lazy loading restarts from the first page of the new listing. +* Reloading the page starts again from the first page: the appended pages are + not part of the URL. +* The endpoint answers with the same data ``/shop`` would render for that page, + under the rights of the visitor making the request. diff --git a/website_sale_lazy_loading/static/src/css/website_sale_lazy_loading.css b/website_sale_lazy_loading/static/src/css/website_sale_lazy_loading.css new file mode 100644 index 0000000..e5837bf --- /dev/null +++ b/website_sale_lazy_loading/static/src/css/website_sale_lazy_loading.css @@ -0,0 +1,14 @@ +/* Lazy loading block of the shop listing (/shop). + Sits between the product grid and the standard pager. */ + +.o_wsale_lazy_loading { + /* Keeps something for the scroll observer to intersect with once the + button and the spinner are both hidden. */ + min-height: 3rem; +} + +.o_wsale_lazy_loading_spinner { + display: flex; + align-items: center; + justify-content: center; +} diff --git a/website_sale_lazy_loading/static/src/js/website_sale_lazy_loading.js b/website_sale_lazy_loading/static/src/js/website_sale_lazy_loading.js new file mode 100644 index 0000000..5c7a3ec --- /dev/null +++ b/website_sale_lazy_loading/static/src/js/website_sale_lazy_loading.js @@ -0,0 +1,237 @@ +/** @odoo-module **/ + +import publicWidget from "@web/legacy/js/public/public_widget"; +import { get } from "@web/core/network/http_service"; + +// How close to the loading block the visitor has to scroll before the next +// page is requested, so the products are there by the time they arrive. +const SCROLL_ROOT_MARGIN = "400px"; + +/** + * Appends the next pages of the /shop listing to the grid already on screen. + * + * The block this widget is attached to is only rendered when a next page + * exists, and it carries everything the server decided: the mode, the URL with + * the active filters and the page numbers. Nothing here decides *what* to + * show, it only asks for the next page and inserts the HTML it gets back. + */ +publicWidget.registry.WebsiteSaleLazyLoading = publicWidget.Widget.extend({ + selector: ".o_wsale_lazy_loading", + disabledInEditableMode: true, + events: { + "click .o_wsale_lazy_loading_btn": "_onClickLoadMore", + }, + + /** + * @override + */ + start() { + this.gridEl = document.querySelector("#o_wsale_products_grid"); + this.buttonEl = this.el.querySelector(".o_wsale_lazy_loading_btn"); + this.spinnerEl = this.el.querySelector(".o_wsale_lazy_loading_spinner"); + this.doneEl = this.el.querySelector(".o_wsale_lazy_loading_done"); + this.errorEl = this.el.querySelector(".o_wsale_lazy_loading_error"); + this.page = parseInt(this.el.dataset.page, 10) || 1; + this.pageCount = parseInt(this.el.dataset.pageCount, 10) || 1; + this.isLoading = false; + this.focusOnLoad = false; + if (this.gridEl && this.el.dataset.url) { + // The standard pager is the fallback for visitors without + // JavaScript, so it only goes away once this widget takes over. + this.pagerEl = document.querySelector(".products_pager"); + if (this.pagerEl) { + this.pagerEl.classList.add("d-none"); + } + this._activate(); + } + return this._super(...arguments); + }, + + /** + * @override + */ + destroy() { + this._disconnectObserver(); + if (this.pagerEl) { + this.pagerEl.classList.remove("d-none"); + } + this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Starts watching for the moment the next page has to be requested. + * + * @private + */ + _activate() { + if (this.el.dataset.mode === "scroll" && window.IntersectionObserver) { + this.observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + this._loadNextPage(); + } + }, + { rootMargin: SCROLL_ROOT_MARGIN } + ); + this.observer.observe(this.el); + } else { + // Manual mode, and fallback for browsers without observers. + this._showButton(); + } + }, + + /** + * @private + */ + _disconnectObserver() { + if (this.observer) { + this.observer.disconnect(); + this.observer = null; + } + }, + + /** + * Requests the next page and appends its products to the grid. + * + * @private + * @returns {Promise} + */ + async _loadNextPage() { + if (this.isLoading || this.page >= this.pageCount) { + return; + } + this.isLoading = true; + this._setLoading(true); + const url = new URL(this.el.dataset.url, window.location.origin); + url.searchParams.set("page", this.page + 1); + let result; + try { + result = await get(url.toString()); + } catch { + this._onLoadFailed(); + return; + } + if (this.isDestroyed()) { + // The page went into edit mode while the request was in flight. + return; + } + this.isLoading = false; + if (!result || result.error) { + this._onLoadFailed(); + return; + } + this.page = result.page || this.page + 1; + this.pageCount = result.page_count || this.pageCount; + const newEls = result.html ? this._insertProducts(result.html) : []; + this._setLoading(false); + this.doneEl.classList.remove("d-none"); + if (!result.has_next) { + this._onLastPageLoaded(newEls); + } else if (this.observer) { + // Observing again triggers a new callback when the block is still + // in view once the products are in, which happens on viewports + // taller than the page that was just loaded. + this.observer.unobserve(this.el); + this.observer.observe(this.el); + } + }, + + /** + * Appends the product cards and lets the public widgets bind to them. + * + * @private + * @param {string} html product cards rendered by the server + * @returns {HTMLElement[]} the cards that were added + */ + _insertProducts(html) { + const previousCount = this.gridEl.children.length; + this.gridEl.insertAdjacentHTML("beforeend", html); + const newEls = [...this.gridEl.children].slice(previousCount); + if (newEls.length) { + this.trigger_up("widgets_start_request", { $target: $(newEls) }); + } + return newEls; + }, + + /** + * Nothing left to load: stop watching and give the keyboard a landing + * spot, as the button the visitor just used is about to disappear. + * + * @private + * @param {HTMLElement[]} newEls + */ + _onLastPageLoaded(newEls) { + this._disconnectObserver(); + this._hideButton(); + if (this.focusOnLoad && newEls.length) { + const linkEl = newEls[0].querySelector("a"); + if (linkEl) { + linkEl.focus(); + } + } + this.focusOnLoad = false; + }, + + /** + * Leaves the visitor in control after a failed request: no more automatic + * requests, and a button to try again. + * + * @private + */ + _onLoadFailed() { + this.isLoading = false; + this._disconnectObserver(); + this._setLoading(false); + this.errorEl.classList.remove("d-none"); + this._showButton(); + }, + + /** + * @private + * @param {boolean} isLoading + */ + _setLoading(isLoading) { + this.spinnerEl.classList.toggle("d-none", !isLoading); + this.buttonEl.disabled = isLoading; + if (isLoading) { + this.errorEl.classList.add("d-none"); + // Taking the message out while loading makes the live region + // announce every load, not only the first one. + this.doneEl.classList.add("d-none"); + } + }, + + /** + * @private + */ + _showButton() { + this.buttonEl.classList.remove("d-none"); + }, + + /** + * @private + */ + _hideButton() { + this.buttonEl.classList.add("d-none"); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onClickLoadMore(ev) { + ev.preventDefault(); + this.focusOnLoad = true; + this._loadNextPage(); + }, +}); + +export default publicWidget.registry.WebsiteSaleLazyLoading; diff --git a/website_sale_lazy_loading/tests/__init__.py b/website_sale_lazy_loading/tests/__init__.py new file mode 100644 index 0000000..716996e --- /dev/null +++ b/website_sale_lazy_loading/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from . import test_lazy_loading diff --git a/website_sale_lazy_loading/tests/test_lazy_loading.py b/website_sale_lazy_loading/tests/test_lazy_loading.py new file mode 100644 index 0000000..e6f4b6f --- /dev/null +++ b/website_sale_lazy_loading/tests/test_lazy_loading.py @@ -0,0 +1,144 @@ +# Copyright 2026 - Today Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo.tests import tagged +from odoo.tests.common import HttpCase + +PER_PAGE = 3 +PRODUCT_COUNT = 7 +PAGE_COUNT = 3 # ceil(7 / 3) + + +@tagged("post_install", "-at_install") +class TestLazyLoading(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.website = cls.env["website"].browse(1) + cls.website.shop_ppg = PER_PAGE + # The listing has to be predictable: only the products created here may + # show up in the shop. + cls.env["product.template"].search([("is_published", "=", True)]).write( + {"is_published": False} + ) + cls.category = cls.env["product.public.category"].create({"name": "Lazy Cat"}) + cls.other_category = cls.env["product.public.category"].create( + {"name": "Other Cat"} + ) + cls.products = cls.env["product.template"].create( + [ + { + "name": "Lazy Product %02d" % index, + "list_price": 10.0 + index, + "is_published": True, + "public_categ_ids": [(6, 0, cls.category.ids)], + } + for index in range(PRODUCT_COUNT) + ] + ) + cls.other_product = cls.env["product.template"].create( + { + "name": "Other Product", + "list_price": 5.0, + "is_published": True, + "public_categ_ids": [(6, 0, cls.other_category.ids)], + } + ) + + def _get_lazy_page(self, page, **params): + """Call the AJAX endpoint and return its decoded payload.""" + query = "&".join("%s=%s" % (key, value) for key, value in params.items()) + url = "/shop/lazy_products?page=%s" % page + if query: + url = "%s&%s" % (url, query) + response = self.url_open(url) + self.assertEqual(response.status_code, 200) + return response.json() + + # ------------------------------------------------------------------ + # /shop rendering + # ------------------------------------------------------------------ + + def test_shop_renders_lazy_loading_block(self): + """The listing carries the block driving the lazy loading.""" + self.website.shop_lazy_loading = "scroll" + body = self.url_open("/shop").text + self.assertIn("o_wsale_lazy_loading", body) + self.assertIn('data-mode="scroll"', body) + self.assertIn('data-page-count="%s"' % PAGE_COUNT, body) + # The pager is still served: it is the fallback without JavaScript. + self.assertIn("products_pager", body) + + def test_shop_without_lazy_loading(self): + """The standard pager is left alone when the mode is off.""" + self.website.shop_lazy_loading = "off" + body = self.url_open("/shop").text + self.assertNotIn("o_wsale_lazy_loading", body) + self.assertIn("products_pager", body) + + def test_shop_last_page_has_no_block(self): + """Nothing can be lazy loaded from the last page.""" + self.website.shop_lazy_loading = "scroll" + body = self.url_open("/shop/page/%s" % PAGE_COUNT).text + self.assertNotIn("o_wsale_lazy_loading", body) + + def test_lazy_loading_url_keeps_the_filters(self): + """The AJAX URL carries the filters of the listing on screen.""" + self.website.shop_lazy_loading = "scroll" + body = self.url_open("/shop?search=Lazy&order=name+desc").text + self.assertIn("/shop/lazy_products?", body) + self.assertIn("search=Lazy", body) + self.assertIn("order=name+desc", body) + + # ------------------------------------------------------------------ + # /shop/lazy_products + # ------------------------------------------------------------------ + + def test_lazy_page_returns_the_next_products(self): + """A page holds the products of that page, and only those.""" + result = self._get_lazy_page(2) + self.assertEqual(result["page"], 2) + self.assertEqual(result["page_count"], PAGE_COUNT) + self.assertTrue(result["has_next"]) + self.assertIn("oe_product", result["html"]) + for product in self.products[PER_PAGE : PER_PAGE * 2]: + self.assertIn(product.name, result["html"]) + for product in self.products[:PER_PAGE]: + self.assertNotIn(product.name, result["html"]) + + def test_lazy_page_last_page(self): + """The last page reports that there is nothing left to load.""" + result = self._get_lazy_page(PAGE_COUNT) + self.assertFalse(result["has_next"]) + self.assertIn(self.products[-1].name, result["html"]) + + def test_lazy_page_out_of_range(self): + """An out of range page answers empty instead of repeating the last.""" + result = self._get_lazy_page(PAGE_COUNT + 5) + self.assertEqual(result["html"], "") + self.assertFalse(result["has_next"]) + + def test_lazy_page_invalid_page(self): + """A broken page number falls back to the first page.""" + result = self._get_lazy_page("not-a-page") + self.assertEqual(result["page"], 1) + self.assertIn(self.products[0].name, result["html"]) + + def test_lazy_page_keeps_the_category(self): + """Filtering by category is kept across the appended pages.""" + result = self._get_lazy_page(2, category=self.category.id) + self.assertIn(self.products[PER_PAGE].name, result["html"]) + self.assertNotIn(self.other_product.name, result["html"]) + + def test_lazy_page_ignores_a_broken_category(self): + """A category that is not an id is dropped instead of crashing.""" + result = self._get_lazy_page(1, category="pwned") + self.assertEqual(result["page"], 1) + self.assertIn(self.products[0].name, result["html"]) + + def test_lazy_page_unknown_category(self): + """An unknown category answers a clean 404, not a traceback.""" + unknown_id = self.other_category.id + 1000 + response = self.url_open("/shop/lazy_products?page=1&category=%s" % unknown_id) + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["error"], "not_found") diff --git a/website_sale_lazy_loading/views/res_config_settings_views.xml b/website_sale_lazy_loading/views/res_config_settings_views.xml new file mode 100644 index 0000000..003a134 --- /dev/null +++ b/website_sale_lazy_loading/views/res_config_settings_views.xml @@ -0,0 +1,34 @@ + + + + + res.config.settings.view.form.inherit.lazy.loading + res.config.settings + + + + + + + + + + diff --git a/website_sale_lazy_loading/views/website_sale_lazy_loading_templates.xml b/website_sale_lazy_loading/views/website_sale_lazy_loading_templates.xml new file mode 100644 index 0000000..1dd1fe4 --- /dev/null +++ b/website_sale_lazy_loading/views/website_sale_lazy_loading_templates.xml @@ -0,0 +1,103 @@ + + + + + + + + + +