[ADD] website_sale_lazy_loading: load the /shop listing on demand

The shop pages products: every page is a full reload that throws away the
grid the visitor was reading. This appends the next page to the grid
instead, on scroll or on a "Load more products" click, per website.

The controller does not duplicate /shop. The new /shop/lazy_products calls
shop() and renders the cards out of the qcontext it prepared, so search,
categories, attributes, tags, price filter, sort order and pricelists are
supported by construction -- and so are the values other modules add to the
listing, the wishlist state among them. Out of range pages answer empty
rather than the last page again, which portal.pager would otherwise clamp
to and the frontend would append as duplicates.

The product loop of website_sale.products is replaced by a call to a shared
template, so the first page and the appended ones are the same markup: a
ribbon, a price or a button another module adds to products_item shows up
on every card, not only on the ones the initial render produced.

Progressive enhancement throughout: the first page and the pager are still
what the standard controller renders, and the pager is only hidden once the
widget is running. Without JavaScript -- and for crawlers -- the shop is
exactly what it is without this module. The page size is the shop layout's
"Products per page", the value the pager already uses, so there is nothing
to keep in sync.

The frontend takes no decision about what to show: the server sends the
mode, the URL of the listing on screen and each page of cards. It observes
a block below the grid rather than listening to scroll, restarts the public
widgets on the appended cards, and keeps a button as the fallback for a
failed request or a browser without IntersectionObserver.

Tests cover the block rendering per mode, the filters travelling in the
AJAX URL, and the endpoint on a next page, the last page, an out of range
page, a broken page number and an unknown category.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
GitHub Copilot 2026-08-24 12:33:09 +02:00
parent 5766406b6f
commit a9d6f52ca6
22 changed files with 1039 additions and 0 deletions

View file

@ -183,6 +183,7 @@ addons-cm/
├── # --- Ventas y web --- ├── # --- Ventas y web ---
├── website_sale_aplicoop/ # Sistema eskaera (compras grupo) ├── website_sale_aplicoop/ # Sistema eskaera (compras grupo)
├── website_sale_disable_cart/ # Tienda solo catálogo: desactiva el carrito estándar ├── 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 ├── portal_event_registration/ # Portal: ver registros de eventos + adjuntos
├── # --- Membresías --- ├── # --- Membresías ---
├── membership_monthly_invoicing/ # Factura mensual de membresía por socio (cron) ├── 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_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_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 - [portal_event_registration](../portal_event_registration/README.rst) - Portal: registros de eventos + adjuntos al chatter
**Membresías** **Membresías**

View file

@ -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 <http://www.gnu.org/licenses/>.
---
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.

View file

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

View file

@ -0,0 +1,4 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from . import controllers
from . import models

View file

@ -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,
}

View file

@ -0,0 +1,3 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from . import website_sale

View file

@ -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",
}

View file

@ -0,0 +1,4 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from . import website
from . import res_config_settings

View file

@ -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,
)

View file

@ -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.",
)

View file

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

View file

@ -0,0 +1,3 @@
* `Criptomart <https://criptomart.net>`_:
* Development and maintenance

View file

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

View file

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

View file

@ -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``.

View file

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

View file

@ -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;
}

View file

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

View file

@ -0,0 +1,3 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from . import test_lazy_loading

View file

@ -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")

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.lazy.loading</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="website_sale.res_config_settings_view_form" />
<field name="arch" type="xml">
<block id="sale_product_catalog_settings" position="inside">
<setting id="shop_lazy_loading_setting">
<label for="shop_lazy_loading" />
<span
class="fa fa-lg fa-globe"
title="Values set here are website-specific."
groups="website.group_multi_website"
/>
<div class="text-muted">
How the shop listing loads the pages after the first one
</div>
<div class="content-group">
<div class="row mt16">
<field
name="shop_lazy_loading"
class="o_light_label"
widget="radio"
/>
</div>
</div>
</setting>
</block>
</field>
</record>
</odoo>

View file

@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
The product cards of the grid, extracted from `website_sale.products`
so that the first page (rendered by /shop) and the pages appended over
AJAX (rendered by /shop/lazy_products) always share the same markup.
Everything it reads comes from the standard shop values.
-->
<template id="products_grid_items" name="Shop Products Grid Items">
<t t-foreach="bins" t-as="tr_product">
<t t-foreach="tr_product" t-as="td_product">
<t t-if="td_product">
<t t-set="col_height" t-value="td_product['y']" />
<t t-set="col_width" t-value="12 // ppr * td_product['x']" />
<t t-set="col_class_lg" t-value="'g-col-lg-' + str(col_width)" />
<t
t-set="col_class_md"
t-value="grid_md_allow_custom_cols and ('g-col-md-' + str(col_width)) or grid_md_use_3col and 'g-col-md-4' or 'g-col-md-6'"
/>
<t
t-set="col_is_stretched"
t-value="(td_product['x'] &gt;= td_product['y'] * 2)"
/>
<t
t-set="col_is_custom_portrait"
t-value="not col_is_stretched and (col_height &gt; td_product['x'])"
/>
<div
t-attf-class="oe_product {{col_is_custom_portrait and 'oe_product_custom_portrait'}} g-col-6 {{col_class_md}} {{col_class_lg}} {{col_is_stretched and 'oe_product_size_stretch'}}"
t-attf-style="--o-wsale-products-grid-product-col-height: {{col_height}};"
t-att-data-ribbon-id="td_product['ribbon'].id"
t-att-data-colspan="td_product['x'] != 1 and td_product['x']"
t-att-data-rowspan="td_product['y'] != 1 and td_product['y']"
t-att-data-name="product_block_name"
>
<div
t-attf-class="o_wsale_product_grid_wrapper position-relative h-100 o_wsale_product_grid_wrapper_#{td_product['x']}_#{td_product['y']}"
>
<t t-call="website_sale.products_item">
<t t-set="product" t-value="td_product['product']" />
</t>
</div>
</div>
</t>
</t>
</t>
</template>
<!--
Shop listing: render the cards through the shared template and add the
block driving the lazy loading. The standard pager is left in place and
only hidden by the frontend, so a visitor without JavaScript keeps it.
-->
<template
id="products_lazy_loading"
inherit_id="website_sale.products"
name="Shop Lazy Loading"
>
<xpath
expr="//section[@id='o_wsale_products_grid']/t[@t-foreach='bins']"
position="replace"
>
<t t-call="website_sale_lazy_loading.products_grid_items" />
</xpath>
<xpath expr="//div[hasclass('products_pager')]" position="before">
<div
t-if="lazy_loading_active"
class="o_wsale_lazy_loading text-center pt-4"
t-att-data-mode="lazy_loading_mode"
t-att-data-url="lazy_loading_url"
t-att-data-page="lazy_loading_page"
t-att-data-page-count="lazy_loading_page_count"
>
<div class="o_wsale_lazy_loading_status" role="status" aria-live="polite">
<div class="o_wsale_lazy_loading_spinner d-none text-muted">
<span
class="spinner-border spinner-border-sm me-2"
aria-hidden="true"
/>
<span>Loading more products…</span>
</div>
<div class="o_wsale_lazy_loading_done visually-hidden d-none">
More products loaded.
</div>
</div>
<button
type="button"
class="o_wsale_lazy_loading_btn btn btn-primary d-none"
>
Load more products
</button>
<div
class="o_wsale_lazy_loading_error alert alert-warning d-none mt-3 mb-0"
role="alert"
>
The next products could not be loaded. Please try again.
</div>
</div>
</xpath>
</template>
</odoo>