From 5eb039ffe067ae4217d5496426f211823ac8b0dd Mon Sep 17 00:00:00 2001 From: snt Date: Tue, 17 Feb 2026 00:28:17 +0100 Subject: [PATCH] [FIX] website_sale_aplicoop: Complete infinite scroll and search filter integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- website_sale_aplicoop/__manifest__.py | 1 + .../controllers/website_sale.py | 419 +++++++++++++++--- .../static/src/js/infinite_scroll.js | 225 ++++++++++ .../views/website_templates.xml | 29 +- 4 files changed, 603 insertions(+), 71 deletions(-) create mode 100644 website_sale_aplicoop/static/src/js/infinite_scroll.js diff --git a/website_sale_aplicoop/__manifest__.py b/website_sale_aplicoop/__manifest__.py index 63d71a8..740e206 100644 --- a/website_sale_aplicoop/__manifest__.py +++ b/website_sale_aplicoop/__manifest__.py @@ -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", diff --git a/website_sale_aplicoop/controllers/website_sale.py b/website_sale_aplicoop/controllers/website_sale.py index 2ee901e..14b3f91 100644 --- a/website_sale_aplicoop/controllers/website_sale.py +++ b/website_sale_aplicoop/controllers/website_sale.py @@ -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//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", diff --git a/website_sale_aplicoop/static/src/js/infinite_scroll.js b/website_sale_aplicoop/static/src/js/infinite_scroll.js new file mode 100644 index 0000000..99b7723 --- /dev/null +++ b/website_sale_aplicoop/static/src/js/infinite_scroll.js @@ -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(); + } +})(); diff --git a/website_sale_aplicoop/views/website_templates.xml b/website_sale_aplicoop/views/website_templates.xml index cda9359..ce07ce2 100644 --- a/website_sale_aplicoop/views/website_templates.xml +++ b/website_sale_aplicoop/views/website_templates.xml @@ -551,22 +551,43 @@ - + -
+
+ +
+
+ Loading... +
+

Loading more products...

+
+
+ + +
+
@@ -646,6 +667,8 @@