[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:
parent
5766406b6f
commit
a9d6f52ca6
22 changed files with 1039 additions and 0 deletions
166
website_sale_lazy_loading/controllers/website_sale.py
Normal file
166
website_sale_lazy_loading/controllers/website_sale.py
Normal 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",
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue