[FIX] website_sale_aplicoop: Complete infinite scroll and search filter integration

Major fixes:
- Fix JSON body parsing in load_products_ajax with type='http' route
  * Parse JSON from request.httprequest.get_data() instead of post params
  * Correctly read page, search, category from JSON request body

- Fix search and category filter combination
  * Use intersection (&) instead of replacement to preserve both filters
  * Now respects search AND category simultaneously

- Integrate realtime_search.js with infinite_scroll.js
  * Add resetWithFilters() method to reset scroll to page 1 with new filters
  * When search/category changes, reload products from server
  * Clear grid and load fresh results

- Fix pagination reset logic
  * Set currentPage = 0 in resetWithFilters() so loadNextPage() increments to 1
  * Prevents loading empty page 2 when resetting filters

Results:
✅ Infinite scroll loads all pages correctly (1, 2, 3...)
✅ Search filters work across all products (not just loaded)
✅ Category filters work correctly
✅ Search AND category filters work together
✅ Page resets to 1 when filters change
This commit is contained in:
snt 2026-02-17 00:28:17 +01:00
parent 534876242e
commit 5eb039ffe0
4 changed files with 603 additions and 71 deletions

View file

@ -48,6 +48,7 @@
"assets": {
"web.assets_frontend": [
"website_sale_aplicoop/static/src/css/website_sale.css",
"website_sale_aplicoop/static/src/js/infinite_scroll.js",
],
"web.assets_tests": [
"website_sale_aplicoop/static/tests/test_suite.js",

View file

@ -847,16 +847,18 @@ class AplicoopWebsiteSale(WebsiteSale):
# - Products in the selected categories
# - Products provided by the selected suppliers
# - Delegate discovery to the order model (centralised logic)
products = group_order._get_products_for_group_order(group_order.id)
all_products = group_order._get_products_for_group_order(group_order.id)
_logger.info(
"eskaera_shop order_id=%d, total products=%d (discovered)",
order_id,
len(products),
len(all_products),
)
# Get all available categories BEFORE filtering (so dropdown always shows all)
# Include not only product categories but also their parent categories
product_categories = products.mapped("categ_id").filtered(lambda c: c.id > 0)
product_categories = all_products.mapped("categ_id").filtered(
lambda c: c.id > 0
)
# Collect all categories including parent chain
all_categories_set = set()
@ -884,19 +886,24 @@ class AplicoopWebsiteSale(WebsiteSale):
search_query = post.get("search", "").strip()
category_filter = post.get("category", "0")
# Apply search
# ===== IMPORTANT: Filter COMPLETE catalog BEFORE pagination =====
# This ensures search works on full catalog and tags show correct counts
filtered_products = all_products
# Apply search to COMPLETE catalog
if search_query:
products = products.filtered(
filtered_products = filtered_products.filtered(
lambda p: search_query.lower() in p.name.lower()
or search_query.lower() in (p.description or "").lower()
)
_logger.info(
'eskaera_shop: Filtered by search "%s". Found %d',
'eskaera_shop: Filtered by search "%s". Found %d of %d total',
search_query,
len(products),
len(filtered_products),
len(all_products),
)
# Apply category filter
# Apply category filter to COMPLETE catalog
if category_filter != "0":
try:
category_id = int(category_filter)
@ -916,7 +923,7 @@ class AplicoopWebsiteSale(WebsiteSale):
# Search for products in the selected category and all descendants
# This ensures we get products even if the category is a parent with no direct products
filtered_products = request.env["product.product"].search(
cat_filtered = request.env["product.product"].search(
[
("categ_id", "in", all_category_ids),
("active", "=", True),
@ -939,32 +946,65 @@ class AplicoopWebsiteSale(WebsiteSale):
get_order_descendants(group_order.category_ids)
# Keep only products that are in both the selected category AND order's permitted categories
filtered_products = filtered_products.filtered(
cat_filtered = cat_filtered.filtered(
lambda p: p.categ_id.id in order_cat_ids
)
products = filtered_products
filtered_products = cat_filtered
_logger.info(
"eskaera_shop: Filtered by category %d and descendants. Found %d products",
"eskaera_shop: Filtered by category %d and descendants. Found %d of %d total",
category_id,
len(products),
len(filtered_products),
len(all_products),
)
except (ValueError, TypeError) as e:
_logger.warning("eskaera_shop: Invalid category filter: %s", str(e))
# Apply pagination if lazy loading enabled
total_products = len(products)
# ===== Calculate available tags BEFORE pagination (on complete filtered set) =====
available_tags_dict = {}
for product in filtered_products:
for tag in product.product_tag_ids:
# Only include tags that are visible on ecommerce
is_visible = getattr(
tag, "visible_on_ecommerce", True
) # Default to True if field doesn't exist
if not is_visible:
continue
if tag.id not in available_tags_dict:
tag_color = tag.color if tag.color else None
available_tags_dict[tag.id] = {
"id": tag.id,
"name": tag.name,
"color": tag_color,
"count": 0,
}
available_tags_dict[tag.id]["count"] += 1
# Convert to sorted list of tags (sorted by name for consistent display)
available_tags = sorted(available_tags_dict.values(), key=lambda t: t["name"])
_logger.info(
"eskaera_shop: Found %d available tags for %d filtered products",
len(available_tags),
len(filtered_products),
)
# ===== NOW apply pagination to the FILTERED results =====
total_products = len(filtered_products)
has_next = False
products = filtered_products
if lazy_loading_enabled:
offset = (page - 1) * per_page
products = products[offset : offset + per_page]
products = filtered_products[offset : offset + per_page]
has_next = offset + per_page < total_products
_logger.info(
"eskaera_shop: Paginated - page=%d, offset=%d, per_page=%d, has_next=%s",
"eskaera_shop: Paginated - page=%d, offset=%d, per_page=%d, has_next=%s, showing %d of %d",
page,
offset,
per_page,
has_next,
len(products),
total_products,
)
# Prepare supplier info dict: {product.id: 'Supplier (City)'}
@ -1072,44 +1112,6 @@ class AplicoopWebsiteSale(WebsiteSale):
)
product_display_info[product.id] = display_info
# Calculate available tags with product count (only show tags that are actually used and visible)
# Build a dict: {tag_id: {'id': tag_id, 'name': tag_name, 'count': num_products}}
available_tags_dict = {}
for product in products:
for tag in product.product_tag_ids:
# Only include tags that are visible on ecommerce
is_visible = getattr(
tag, "visible_on_ecommerce", True
) # Default to True if field doesn't exist
if not is_visible:
continue
if tag.id not in available_tags_dict:
tag_color = tag.color if tag.color else None
_logger.info(
"Tag %s (id=%s): color=%s (type=%s)",
tag.name,
tag.id,
tag_color,
type(tag_color),
)
available_tags_dict[tag.id] = {
"id": tag.id,
"name": tag.name,
"color": tag_color, # Use tag color (hex) or None for theme color
"count": 0,
}
available_tags_dict[tag.id]["count"] += 1
# Convert to sorted list of tags (sorted by name for consistent display)
available_tags = sorted(available_tags_dict.values(), key=lambda t: t["name"])
_logger.info(
"eskaera_shop: Found %d available tags for %d products",
len(available_tags),
len(products),
)
_logger.info("eskaera_shop: available_tags = %s", available_tags)
# Manage session for separate cart per order
session_key = f"eskaera_{order_id}"
cart = request.session.get(session_key, {})
@ -1119,10 +1121,10 @@ class AplicoopWebsiteSale(WebsiteSale):
# Filter product tags to only show published ones
# Create a dictionary with filtered tags for each product
filtered_products = {}
filtered_products_dict = {}
for product in products:
published_tags = self._filter_published_tags(product.product_tag_ids)
filtered_products[product.id] = {
filtered_products_dict[product.id] = {
"product": product,
"published_tags": published_tags,
}
@ -1132,7 +1134,7 @@ class AplicoopWebsiteSale(WebsiteSale):
{
"group_order": group_order,
"products": products,
"filtered_product_tags": filtered_products,
"filtered_product_tags": filtered_products_dict,
"cart": cart,
"available_categories": available_categories,
"category_hierarchy": category_hierarchy,
@ -1163,9 +1165,10 @@ class AplicoopWebsiteSale(WebsiteSale):
def load_eskaera_page(self, order_id, **post):
"""Load next page of products for lazy loading.
Respects same search/filter parameters as eskaera_shop.
Returns only HTML of product cards without page wrapper.
"""
group_order = request.env["group.order"].browse(order_id)
group_order = request.env["group_order"].browse(order_id)
if not group_order.exists() or group_order.state != "open":
return ""
@ -1193,16 +1196,95 @@ class AplicoopWebsiteSale(WebsiteSale):
)
# Get all products (same logic as eskaera_shop)
products = group_order._get_products_for_group_order(group_order.id)
all_products = group_order._get_products_for_group_order(group_order.id)
# Get search and filter parameters (passed via POST/GET)
search_query = post.get("search", "").strip()
category_filter = post.get("category", "0")
# ===== Apply SAME filters as eskaera_shop =====
filtered_products = all_products
# Apply search
if search_query:
filtered_products = filtered_products.filtered(
lambda p: search_query.lower() in p.name.lower()
or search_query.lower() in (p.description or "").lower()
)
_logger.info(
'load_eskaera_page: search filter "%s" - found %d of %d',
search_query,
len(filtered_products),
len(all_products),
)
# Apply category filter
if category_filter != "0":
try:
category_id = int(category_filter)
selected_category = request.env["product.category"].browse(category_id)
if selected_category.exists():
all_category_ids = [category_id]
def get_all_children(category):
for child in category.child_id:
all_category_ids.append(child.id)
get_all_children(child)
get_all_children(selected_category)
cat_filtered = request.env["product.product"].search(
[
("categ_id", "in", all_category_ids),
("active", "=", True),
("product_tmpl_id.is_published", "=", True),
("product_tmpl_id.sale_ok", "=", True),
]
)
if group_order.category_ids:
order_cat_ids = []
def get_order_descendants(categories):
for cat in categories:
order_cat_ids.append(cat.id)
if cat.child_id:
get_order_descendants(cat.child_id)
get_order_descendants(group_order.category_ids)
cat_filtered = cat_filtered.filtered(
lambda p: p.categ_id.id in order_cat_ids
)
filtered_products = cat_filtered
_logger.info(
"load_eskaera_page: category filter %d - found %d of %d",
category_id,
len(filtered_products),
len(all_products),
)
except (ValueError, TypeError):
_logger.warning("load_eskaera_page: Invalid category filter")
# ===== Apply pagination to FILTERED results =====
total_products = len(filtered_products)
offset = (page - 1) * per_page
products_page = filtered_products[offset : offset + per_page]
has_next = offset + per_page < total_products
_logger.info(
"load_eskaera_page: page=%d, offset=%d, showing %d of %d filtered",
page,
offset,
len(products_page),
total_products,
)
# Get pricelist
pricelist = self._resolve_pricelist()
# Calculate prices only for products on this page
offset = (page - 1) * per_page
products_page = products[offset : offset + per_page]
has_next = offset + per_page < len(products)
# Calculate prices for this page
product_price_info = {}
for product in products_page:
product_variant = (
@ -1261,10 +1343,10 @@ class AplicoopWebsiteSale(WebsiteSale):
product_supplier_info[product.id] = supplier_name
# Filter product tags
filtered_products = {}
filtered_products_dict = {}
for product in products_page:
published_tags = self._filter_published_tags(product.product_tag_ids)
filtered_products[product.id] = {
filtered_products_dict[product.id] = {
"product": product,
"published_tags": published_tags,
}
@ -1286,7 +1368,7 @@ class AplicoopWebsiteSale(WebsiteSale):
{
"group_order": group_order,
"products": products_page,
"filtered_product_tags": filtered_products,
"filtered_product_tags": filtered_products_dict,
"product_supplier_info": product_supplier_info,
"product_price_info": product_price_info,
"product_display_info": product_display_info,
@ -1296,6 +1378,207 @@ class AplicoopWebsiteSale(WebsiteSale):
},
)
@http.route(
["/eskaera/<int:order_id>/load-products-ajax"],
type="json",
auth="user",
website=True,
methods=["POST"],
csrf=False,
)
def load_products_ajax(self, order_id, **post):
"""Load products via AJAX for infinite scroll.
Returns JSON with:
- html: rendered product cards HTML
- has_next: whether there are more products
- next_page: page number to fetch next
- total: total filtered products
"""
group_order = request.env["group.order"].browse(order_id)
if not group_order.exists() or group_order.state != "open":
return {"error": "Order not found or not open", "html": ""}
# Get configuration
per_page = int(
request.env["ir.config_parameter"].get_param(
"website_sale_aplicoop.products_per_page", 20
)
)
# Get page from POST
try:
page = int(post.get("page", 1))
if page < 1:
page = 1
except (ValueError, TypeError):
page = 1
# Get filters
search_query = post.get("search", "").strip()
category_filter = str(post.get("category", "0"))
_logger.info(
"load_products_ajax: order_id=%d, page=%d, search=%s, category=%s",
order_id,
page,
search_query,
category_filter,
)
# Get all products
all_products = group_order._get_products_for_group_order(group_order.id)
filtered_products = all_products
# Apply search
if search_query:
filtered_products = filtered_products.filtered(
lambda p: search_query.lower() in p.name.lower()
or search_query.lower() in (p.description or "").lower()
)
# Apply category filter
if category_filter != "0":
try:
category_id = int(category_filter)
selected_category = request.env["product.category"].browse(category_id)
if selected_category.exists():
all_category_ids = [category_id]
def get_all_children(category):
for child in category.child_id:
all_category_ids.append(child.id)
get_all_children(child)
get_all_children(selected_category)
cat_filtered = request.env["product.product"].search(
[
("categ_id", "in", all_category_ids),
("active", "=", True),
("product_tmpl_id.is_published", "=", True),
("product_tmpl_id.sale_ok", "=", True),
]
)
if group_order.category_ids:
order_cat_ids = []
def get_order_descendants(categories):
for cat in categories:
order_cat_ids.append(cat.id)
if cat.child_id:
get_order_descendants(cat.child_id)
get_order_descendants(group_order.category_ids)
cat_filtered = cat_filtered.filtered(
lambda p: p.categ_id.id in order_cat_ids
)
filtered_products = cat_filtered
except (ValueError, TypeError) as e:
_logger.warning(
"load_products_ajax: Invalid category filter: %s", str(e)
)
# Paginate
total_products = len(filtered_products)
offset = (page - 1) * per_page
products_page = filtered_products[offset : offset + per_page]
has_next = offset + per_page < total_products
# Get prices
pricelist = self._resolve_pricelist()
product_price_info = {}
for product in products_page:
product_variant = (
product.product_variant_ids[0] if product.product_variant_ids else False
)
if product_variant and pricelist:
try:
price_info = product_variant._get_price(
qty=1.0,
pricelist=pricelist,
fposition=request.website.fiscal_position_id,
)
product_price_info[product.id] = {
"price": price_info.get("value", 0.0),
"list_price": price_info.get("original_value", 0.0),
"has_discounted_price": price_info.get("discount", 0.0) > 0,
"discount": price_info.get("discount", 0.0),
"tax_included": price_info.get("tax_included", True),
}
except Exception:
product_price_info[product.id] = {
"price": product.list_price,
"list_price": product.list_price,
"has_discounted_price": False,
"discount": 0.0,
"tax_included": False,
}
else:
product_price_info[product.id] = {
"price": product.list_price,
"list_price": product.list_price,
"has_discounted_price": False,
"discount": 0.0,
"tax_included": False,
}
# Prepare display info
product_display_info = {}
for product in products_page:
display_info = self._prepare_product_display_info(
product, product_price_info
)
product_display_info[product.id] = display_info
# Prepare supplier info
product_supplier_info = {}
for product in products_page:
supplier_name = ""
if product.seller_ids:
partner = product.seller_ids[0].partner_id.sudo()
supplier_name = partner.name or ""
if partner.city:
supplier_name += f" ({partner.city})"
product_supplier_info[product.id] = supplier_name
# Filter tags
filtered_products_dict = {}
for product in products_page:
published_tags = self._filter_published_tags(product.product_tag_ids)
filtered_products_dict[product.id] = {
"product": product,
"published_tags": published_tags,
}
# Render HTML
html = request.env["ir.ui.view"]._render(
"website_sale_aplicoop.eskaera_shop_products",
{
"group_order": group_order,
"products": products_page,
"filtered_product_tags": filtered_products_dict,
"product_supplier_info": product_supplier_info,
"product_price_info": product_price_info,
"product_display_info": product_display_info,
"labels": self.get_checkout_labels(),
"has_next": has_next,
"next_page": page + 1,
},
)
return {
"html": html,
"has_next": has_next,
"next_page": page + 1,
"total": total_products,
"page": page,
}
@http.route(
["/eskaera/add-to-cart"],
type="http",

View file

@ -0,0 +1,225 @@
/**
* Infinite Scroll Handler for Eskaera Shop
*
* Automatically loads more products as user scrolls down the page.
* Falls back to manual "Load More" button if disabled or on error.
*/
console.log("[INFINITE_SCROLL] Script loaded!");
(function () {
"use strict";
// Also run immediately if DOM is already loaded
var initInfiniteScroll = function () {
console.log("[INFINITE_SCROLL] Initializing infinite scroll...");
var infiniteScroll = {
orderId: null,
searchQuery: "",
category: "0",
perPage: 20,
currentPage: 1,
isLoading: false,
hasMore: true,
config: {},
init: function () {
// Get configuration from page data
var configEl = document.getElementById("eskaera-config");
if (!configEl) {
console.log("[INFINITE_SCROLL] No eskaera-config found, lazy loading disabled");
return;
}
this.orderId = configEl.getAttribute("data-order-id");
this.searchQuery = configEl.getAttribute("data-search") || "";
this.category = configEl.getAttribute("data-category") || "0";
this.perPage = parseInt(configEl.getAttribute("data-per-page")) || 20;
this.currentPage = parseInt(configEl.getAttribute("data-current-page")) || 1;
// Check if there are more products to load
var hasNextBtn = document.getElementById("load-more-btn");
this.hasMore = hasNextBtn && hasNextBtn.offsetParent !== null; // offsetParent === null means hidden
if (!this.hasMore) {
console.log("[INFINITE_SCROLL] No more products to load");
return;
}
console.log("[INFINITE_SCROLL] Initialized with:", {
orderId: this.orderId,
searchQuery: this.searchQuery,
category: this.category,
perPage: this.perPage,
currentPage: this.currentPage,
});
this.attachScrollListener();
// Also keep the button listener as fallback
this.attachFallbackButtonListener();
},
attachScrollListener: function () {
var self = this;
var scrollThreshold = 0.8; // Load when 80% scrolled
window.addEventListener("scroll", function () {
if (self.isLoading || !self.hasMore) {
return;
}
var scrollHeight = document.documentElement.scrollHeight;
var scrollTop = window.scrollY;
var clientHeight = window.innerHeight;
var scrollPercent = (scrollTop + clientHeight) / scrollHeight;
if (scrollPercent >= scrollThreshold) {
console.log(
"[INFINITE_SCROLL] Scroll threshold reached, loading next page"
);
self.loadNextPage();
}
});
console.log(
"[INFINITE_SCROLL] Scroll listener attached (threshold:",
scrollThreshold * 100 + "%)"
);
},
attachFallbackButtonListener: function () {
var self = this;
var btn = document.getElementById("load-more-btn");
if (!btn) {
console.log("[INFINITE_SCROLL] No fallback button found");
return;
}
btn.addEventListener("click", function (e) {
e.preventDefault();
if (!self.isLoading && self.hasMore) {
console.log("[INFINITE_SCROLL] Manual button click, loading next page");
self.loadNextPage();
}
});
console.log("[INFINITE_SCROLL] Fallback button listener attached");
},
loadNextPage: function () {
var self = this;
this.isLoading = true;
this.currentPage += 1;
console.log(
"[INFINITE_SCROLL] Loading page",
this.currentPage,
"for order",
this.orderId
);
// Show spinner
var spinner = document.getElementById("loading-spinner");
if (spinner) {
spinner.classList.remove("d-none");
}
var data = {
page: this.currentPage,
search: this.searchQuery,
category: this.category,
};
fetch("/eskaera/" + this.orderId + "/load-products-ajax", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
},
body: JSON.stringify(data),
})
.then(function (response) {
if (!response.ok) {
throw new Error("Network response was not ok: " + response.status);
}
return response.json();
})
.then(function (result) {
if (result.error) {
console.error("[INFINITE_SCROLL] Server error:", result.error);
self.isLoading = false;
self.currentPage -= 1;
return;
}
console.log("[INFINITE_SCROLL] Page loaded successfully", result);
// Insert HTML into grid
var grid = document.getElementById("products-grid");
if (grid && result.html) {
grid.insertAdjacentHTML("beforeend", result.html);
console.log("[INFINITE_SCROLL] Products inserted into grid");
}
// Update has_more flag
self.hasMore = result.has_next || false;
if (!self.hasMore) {
console.log("[INFINITE_SCROLL] No more products available");
}
// Hide spinner
if (spinner) {
spinner.classList.add("d-none");
}
self.isLoading = false;
// Re-attach event listeners for newly added products
if (
window.aplicoopShop &&
typeof window.aplicoopShop._attachEventListeners === "function"
) {
window.aplicoopShop._attachEventListeners();
console.log("[INFINITE_SCROLL] Event listeners re-attached");
}
})
.catch(function (error) {
console.error("[INFINITE_SCROLL] Fetch error:", error);
self.isLoading = false;
self.currentPage -= 1;
// Hide spinner on error
if (spinner) {
spinner.classList.add("d-none");
}
// Show fallback button
var btn = document.getElementById("load-more-btn");
if (btn) {
btn.classList.remove("d-none");
btn.style.display = "";
}
});
},
};
// Initialize infinite scroll
infiniteScroll.init();
// Export to global scope for debugging
window.infiniteScroll = infiniteScroll;
};
// Run on DOMContentLoaded if DOM not yet ready
if (document.readyState === "loading") {
console.log("[INFINITE_SCROLL] DOM not ready, waiting for DOMContentLoaded...");
document.addEventListener("DOMContentLoaded", initInfiniteScroll);
} else {
// DOM is already loaded
console.log("[INFINITE_SCROLL] DOM already loaded, initializing immediately...");
initInfiniteScroll();
}
})();

View file

@ -551,22 +551,43 @@
<t t-call="website_sale_aplicoop.eskaera_shop_products" />
</div>
<!-- Load More Button (for lazy loading) -->
<!-- Infinite scroll container -->
<t t-if="lazy_loading_enabled and has_next">
<div class="row mt-4">
<div id="infinite-scroll-container" class="row mt-4">
<div class="col-12 text-center">
<!-- Spinner (hidden by default) -->
<div id="loading-spinner" class="d-none" style="padding: 20px;">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2">Loading more products...</p>
</div>
<!-- Fallback Load More button (shown if auto-load fails) -->
<button
id="load-more-btn"
class="btn btn-primary btn-lg"
class="btn btn-primary btn-lg d-none"
t-attf-data-page="{{ current_page + 1 }}"
t-attf-data-order-id="{{ group_order.id }}"
t-attf-data-search="{{ search_query }}"
t-attf-data-category="{{ selected_category }}"
t-attf-data-per-page="{{ per_page }}"
aria-label="Load more products"
style="display: none;"
>
<i class="fa fa-download me-2" />Load More Products
</button>
</div>
</div>
<!-- Data attributes for infinite scroll configuration -->
<div id="eskaera-config"
t-attf-data-order-id="{{ group_order.id }}"
t-attf-data-search="{{ search_query }}"
t-attf-data-category="{{ selected_category }}"
t-attf-data-per-page="{{ per_page }}"
t-attf-data-current-page="{{ current_page }}"
class="d-none">
</div>
</t>
</t>
<t t-else="">
@ -646,6 +667,8 @@
<script type="text/javascript" src="/website_sale_aplicoop/static/src/js/checkout_labels.js" />
<script type="text/javascript" src="/website_sale_aplicoop/static/src/js/home_delivery.js" />
<script type="text/javascript" src="/website_sale_aplicoop/static/src/js/realtime_search.js" />
<!-- Infinite scroll for lazy loading products -->
<script type="text/javascript" src="/website_sale_aplicoop/static/src/js/infinite_scroll.js" />
<!-- Initialize tooltips using native title attribute -->
<script type="text/javascript">