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
+
+
+
+
+
+
+
+ How the shop listing loads the pages after the first one
+