[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

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