/* * Copyright 2025 Criptomart * License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) */ (function () { "use strict"; window.realtimeSearch = { searchInput: null, categorySelect: null, allProducts: [], debounceTimer: null, debounceDelay: 0, categoryHierarchy: {}, // Maps parent category IDs to their children selectedTags: new Set(), // Set of selected tag IDs (for OR logic filtering) availableTags: {}, // Maps tag ID to {id, name, count} init: function () { console.log("[realtimeSearch] Initializing..."); // searchInput y categorySelect ya fueron asignados por tryInit() console.log("[realtimeSearch] Search input:", this.searchInput); console.log("[realtimeSearch] Category select:", this.categorySelect); if (!this.searchInput) { console.error("[realtimeSearch] ERROR: Search input not found!"); return false; } if (!this.categorySelect) { console.error("[realtimeSearch] ERROR: Category select not found!"); return false; } this._buildCategoryHierarchyFromDOM(); this._storeAllProducts(); console.log( "[realtimeSearch] After _storeAllProducts(), calling _attachEventListeners()..." ); this._attachEventListeners(); console.log("[realtimeSearch] ✓ Initialized successfully"); return true; }, _buildCategoryHierarchyFromDOM: function () { /** * Construye un mapa de jerarquía de categorías desde las opciones del select. * Ahora todas las opciones son planas pero con indentación visual (↳ arrows). * * La profundidad se determina contando el número de arrows (↳). * Estructura: categoryHierarchy[parentCategoryId] = [childCategoryId1, childCategoryId2, ...] */ var self = this; var allOptions = this.categorySelect.querySelectorAll("option[value]"); var optionStack = []; // Stack para mantener los padres en cada nivel allOptions.forEach(function (option) { var categoryId = option.getAttribute("value"); var text = option.textContent; // Contar arrows para determinar profundidad var arrowCount = (text.match(/↳/g) || []).length; var depth = arrowCount; // Depth: 0 for roots, 1 for children, 2 for grandchildren, etc. // Ajustar el stack al nivel actual // Si la profundidad es menor o igual, sacamos elementos del stack while (optionStack.length > depth) { optionStack.pop(); } // Si hay un padre en el stack (profundidad > 0), agregar como hijo if (depth > 0 && optionStack.length > 0) { var parentId = optionStack[optionStack.length - 1]; if (!self.categoryHierarchy[parentId]) { self.categoryHierarchy[parentId] = []; } if (!self.categoryHierarchy[parentId].includes(categoryId)) { self.categoryHierarchy[parentId].push(categoryId); } } // Agregar este ID al stack como posible padre para los siguientes // Adjust position in stack based on depth if (optionStack.length > depth) { optionStack[depth] = categoryId; } else { optionStack.push(categoryId); } }); console.log( "[realtimeSearch] Complete category hierarchy built:", self.categoryHierarchy ); }, _storeAllProducts: function () { var productCards = document.querySelectorAll(".product-card"); console.log("[realtimeSearch] Found " + productCards.length + " product cards"); var self = this; this.allProducts = []; productCards.forEach(function (card, index) { var name = card.getAttribute("data-product-name") || ""; var categoryId = card.getAttribute("data-category-id") || ""; var tagIdsStr = card.getAttribute("data-product-tags") || ""; // Parse tag IDs from comma-separated string var tagIds = []; if (tagIdsStr) { tagIds = tagIdsStr .split(",") .map(function (id) { return parseInt(id.trim(), 10); }) .filter(function (id) { return !isNaN(id); }); } self.allProducts.push({ element: card, name: name.toLowerCase(), category: categoryId.toString(), originalCategory: categoryId, tags: tagIds, // Array of tag IDs for this product }); }); console.log("[realtimeSearch] Total products stored: " + this.allProducts.length); }, _attachEventListeners: function () { var self = this; // Initialize available tags from DOM self._initializeAvailableTags(); // Store original colors for each tag badge self.originalTagColors = {}; // Maps tag ID to original color // Store last values at instance level so polling can access them self.lastSearchValue = ""; self.lastCategoryValue = ""; // Prevent form submission completely var form = self.searchInput.closest("form"); if (form) { form.addEventListener("submit", function (e) { e.preventDefault(); e.stopPropagation(); console.log("[realtimeSearch] Form submission prevented and stopped"); return false; }); } // Prevent Enter key from submitting self.searchInput.addEventListener("keypress", function (e) { if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); console.log("[realtimeSearch] Enter key prevented on search input"); return false; } }); // Search input: listen to 'input' for real-time filtering self.searchInput.addEventListener("input", function (e) { try { console.log('[realtimeSearch] INPUT event - value: "' + e.target.value + '"'); self._filterProducts(); } catch (error) { console.error("[realtimeSearch] Error in input listener:", error.message); } }); // Also keep 'keyup' for extra compatibility self.searchInput.addEventListener("keyup", function (e) { try { console.log('[realtimeSearch] KEYUP event - value: "' + e.target.value + '"'); self._filterProducts(); } catch (error) { console.error("[realtimeSearch] Error in keyup listener:", error.message); } }); // Category select self.categorySelect.addEventListener("change", function (e) { try { console.log( '[realtimeSearch] CHANGE event - selected: "' + e.target.value + '"' ); self._filterProducts(); } catch (error) { console.error( "[realtimeSearch] Error in category change listener:", error.message ); } }); // Tag filter badges: click to toggle selection (independent state) var tagBadges = document.querySelectorAll('[data-toggle="tag-filter"]'); console.log("[realtimeSearch] Found " + tagBadges.length + " tag filter badges"); // Get theme colors from CSS variables var rootStyles = getComputedStyle(document.documentElement); var primaryColor = rootStyles.getPropertyValue("--bs-primary").trim() || rootStyles.getPropertyValue("--primary").trim() || "#0d6efd"; var secondaryColor = rootStyles.getPropertyValue("--bs-secondary").trim() || rootStyles.getPropertyValue("--secondary").trim() || "#6c757d"; console.log( "[realtimeSearch] Theme colors - Primary:", primaryColor, "Secondary:", secondaryColor ); // Store original colors for each badge BEFORE adding event listeners tagBadges.forEach(function (badge) { var tagId = parseInt(badge.getAttribute("data-tag-id"), 10); var tagColor = badge.getAttribute("data-tag-color"); // Store the original color (either from data-tag-color or use secondary for tags without color) if (tagColor) { self.originalTagColors[tagId] = tagColor; console.log( "[realtimeSearch] Stored original color for tag " + tagId + ": " + tagColor ); } else { self.originalTagColors[tagId] = "var(--bs-secondary, " + secondaryColor + ")"; console.log("[realtimeSearch] Tag " + tagId + " has no color, using secondary"); } }); tagBadges.forEach(function (badge) { badge.addEventListener("click", function (e) { e.preventDefault(); var tagId = parseInt(badge.getAttribute("data-tag-id"), 10); var originalColor = self.originalTagColors[tagId]; console.log( "[realtimeSearch] Tag badge clicked: " + tagId + " (original color: " + originalColor + ")" ); // Toggle tag selection if (self.selectedTags.has(tagId)) { // Deselect self.selectedTags.delete(tagId); console.log("[realtimeSearch] Tag " + tagId + " deselected"); } else { // Select self.selectedTags.add(tagId); console.log("[realtimeSearch] Tag " + tagId + " selected"); } // Update colors for ALL badges based on selection state tagBadges.forEach(function (badge) { var id = parseInt(badge.getAttribute("data-tag-id"), 10); if (self.selectedTags.size === 0) { // No tags selected: restore all to original colors var originalColor = self.originalTagColors[id]; badge.style.setProperty("background-color", originalColor, "important"); badge.style.setProperty("border-color", originalColor, "important"); badge.style.setProperty("color", "#ffffff", "important"); console.log( "[realtimeSearch] Badge " + id + " reset to original color (no selection)" ); } else if (self.selectedTags.has(id)) { // Selected: primary color badge.style.setProperty( "background-color", "var(--bs-primary, " + primaryColor + ")", "important" ); badge.style.setProperty( "border-color", "var(--bs-primary, " + primaryColor + ")", "important" ); badge.style.setProperty("color", "#ffffff", "important"); console.log( "[realtimeSearch] Badge " + id + " set to primary (selected)" ); } else { // Not selected but others are: secondary color badge.style.setProperty( "background-color", "var(--bs-secondary, " + secondaryColor + ")", "important" ); badge.style.setProperty( "border-color", "var(--bs-secondary, " + secondaryColor + ")", "important" ); badge.style.setProperty("color", "#ffffff", "important"); console.log( "[realtimeSearch] Badge " + id + " set to secondary (not selected)" ); } }); // Filter products (independent of search/category state) self._filterProducts(); }); }); // POLLING FALLBACK: Since Odoo components may intercept events, // use polling to detect value changes console.log("[realtimeSearch] 🚀 POLLING INITIALIZATION STARTING"); console.log("[realtimeSearch] Search input element:", self.searchInput); console.log("[realtimeSearch] Category select element:", self.categorySelect); var pollingCounter = 0; var pollInterval = setInterval(function () { try { pollingCounter++; // Try multiple ways to get the search value var currentSearchValue = self.searchInput.value || ""; var currentSearchAttr = self.searchInput.getAttribute("value") || ""; var currentSearchDataValue = self.searchInput.getAttribute("data-value") || ""; var currentSearchInnerText = self.searchInput.innerText || ""; var currentCategoryValue = self.categorySelect ? self.categorySelect.value || "" : ""; // FIRST POLL: Detailed debug if (pollingCounter === 1) { console.log("═══════════════════════════════════════════"); console.log("[realtimeSearch] 🔍 FIRST POLLING DEBUG (POLL #1)"); console.log("═══════════════════════════════════════════"); console.log("Search input .value:", JSON.stringify(currentSearchValue)); console.log( 'Search input getAttribute("value"):', JSON.stringify(currentSearchAttr) ); console.log( 'Search input getAttribute("data-value"):', JSON.stringify(currentSearchDataValue) ); console.log( "Search input innerText:", JSON.stringify(currentSearchInnerText) ); console.log( "Category select .value:", JSON.stringify(currentCategoryValue) ); console.log( 'Last stored values - search:"' + self.lastSearchValue + '" category:"' + self.lastCategoryValue + '"' ); console.log("═══════════════════════════════════════════"); } // Log every 20 polls (reduce spam) if (pollingCounter % 20 === 0) { console.log( "[realtimeSearch] POLLING #" + pollingCounter + ': search="' + currentSearchValue + '" category="' + currentCategoryValue + '"' ); } // Check for ANY change in either field if ( currentSearchValue !== self.lastSearchValue || currentCategoryValue !== self.lastCategoryValue ) { console.log( '[realtimeSearch] ⚡ CHANGE DETECTED: search="' + currentSearchValue + '" (was:"' + self.lastSearchValue + '") | category="' + currentCategoryValue + '" (was:"' + self.lastCategoryValue + '")' ); self.lastSearchValue = currentSearchValue; self.lastCategoryValue = currentCategoryValue; // Reset infinite scroll with new filters (will reload from server) if ( window.infiniteScroll && typeof window.infiniteScroll.resetWithFilters === "function" ) { console.log( "[realtimeSearch] Calling infiniteScroll.resetWithFilters()" ); window.infiniteScroll.resetWithFilters( currentSearchValue, currentCategoryValue ); } else { // Fallback: filter locally (but this only filters loaded products) console.log( "[realtimeSearch] infiniteScroll not available, filtering locally only" ); self._filterProducts(); } } } catch (error) { console.error("[realtimeSearch] ❌ Error in polling:", error.message); } }, 300); // Check every 300ms console.log("[realtimeSearch] ✅ Polling interval started with ID:", pollInterval); console.log("[realtimeSearch] Event listeners attached with polling fallback"); }, _initializeAvailableTags: function () { /** * Initialize availableTags map from the DOM tag filter badges. * Format: availableTags[tagId] = {id, name, count} */ var self = this; var tagBadges = document.querySelectorAll('[data-toggle="tag-filter"]'); tagBadges.forEach(function (badge) { var tagId = parseInt(badge.getAttribute("data-tag-id"), 10); var tagName = badge.getAttribute("data-tag-name") || ""; var countSpan = badge.querySelector(".tag-count"); var count = countSpan ? parseInt(countSpan.textContent, 10) : 0; self.availableTags[tagId] = { id: tagId, name: tagName, count: count, }; }); console.log( "[realtimeSearch] Initialized " + Object.keys(self.availableTags).length + " available tags" ); }, _filterProducts: function () { var self = this; try { var searchQuery = (self.searchInput.value || "").toLowerCase().trim(); var selectedCategoryId = (self.categorySelect.value || "").toString(); console.log( "[realtimeSearch] Filtering: search=" + searchQuery + " category=" + selectedCategoryId + " tags=" + Array.from(self.selectedTags).join(",") ); // Build a set of allowed category IDs (selected category + ALL descendants recursively) var allowedCategories = {}; if (selectedCategoryId) { allowedCategories[selectedCategoryId] = true; // Recursive function to get all descendants var getAllDescendants = function (parentId) { var descendants = []; if (self.categoryHierarchy[parentId]) { self.categoryHierarchy[parentId].forEach(function (childId) { descendants.push(childId); allowedCategories[childId] = true; // Recursivamente obtener descendientes del hijo var grandDescendants = getAllDescendants(childId); descendants = descendants.concat(grandDescendants); }); } return descendants; }; var allDescendants = getAllDescendants(selectedCategoryId); console.log( "[realtimeSearch] Selected category " + selectedCategoryId + " has " + allDescendants.length + " total descendants" ); console.log( "[realtimeSearch] Allowed categories:", Object.keys(allowedCategories) ); } var visibleCount = 0; var hiddenCount = 0; // Track tag counts for dynamic badge updates var tagCounts = {}; for (var tagId in self.availableTags) { tagCounts[tagId] = 0; } self.allProducts.forEach(function (product) { var nameMatches = !searchQuery || product.name.indexOf(searchQuery) !== -1; var categoryMatches = !selectedCategoryId || allowedCategories[product.category]; // Tag filtering: if tags are selected, product must have AT LEAST ONE selected tag (OR logic) var tagMatches = true; if (self.selectedTags.size > 0) { tagMatches = product.tags.some(function (productTagId) { return self.selectedTags.has(productTagId); }); } var shouldShow = nameMatches && categoryMatches && tagMatches; if (shouldShow) { product.element.classList.remove("hidden-product"); visibleCount++; // Count this product's tags toward the dynamic counters product.tags.forEach(function (tagId) { if (tagCounts.hasOwnProperty(tagId)) { tagCounts[tagId]++; } }); } else { product.element.classList.add("hidden-product"); hiddenCount++; } }); // Update badge counts dynamically for (var tagId in tagCounts) { var badge = document.querySelector('[data-tag-id="' + tagId + '"]'); if (badge) { var countSpan = badge.querySelector(".tag-count"); if (countSpan) { countSpan.textContent = tagCounts[tagId]; } } } console.log( "[realtimeSearch] Filter result: visible=" + visibleCount + " hidden=" + hiddenCount ); } catch (error) { console.error("[realtimeSearch] ERROR in _filterProducts():", error.message); console.error("[realtimeSearch] Stack:", error.stack); } }, }; // Initialize when DOM is ready console.log("[realtimeSearch] Script loaded, DOM state: " + document.readyState); function tryInit() { try { console.log("[realtimeSearch] Attempting initialization..."); // Query product cards var productCards = document.querySelectorAll(".product-card"); console.log("[realtimeSearch] Found " + productCards.length + " product cards"); // Use the NEW pure HTML input with ID (not transformed by Odoo) var searchInput = document.getElementById("realtime-search-input"); console.log("[realtimeSearch] Search input found:", !!searchInput); if (searchInput) { console.log("[realtimeSearch] Search input class:", searchInput.className); console.log("[realtimeSearch] Search input type:", searchInput.type); } // Category select with ID (not transformed by Odoo) var categorySelect = document.getElementById("realtime-category-select"); console.log("[realtimeSearch] Category select found:", !!categorySelect); if (productCards.length > 0 && searchInput) { console.log("[realtimeSearch] ✓ All elements found! Initializing..."); // Assign elements to window.realtimeSearch BEFORE calling init() window.realtimeSearch.searchInput = searchInput; window.realtimeSearch.categorySelect = categorySelect; window.realtimeSearch.init(); console.log("[realtimeSearch] ✓ Initialization complete!"); } else { console.log( "[realtimeSearch] Waiting for elements... (products:" + productCards.length + ", search:" + !!searchInput + ")" ); if (productCards.length === 0) { console.log( "[realtimeSearch] No product cards found. Current HTML body length:", document.body.innerHTML.length ); } setTimeout(tryInit, 500); } } catch (error) { console.error("[realtimeSearch] ERROR in tryInit():", error.message); } } if (document.readyState === "loading") { console.log("[realtimeSearch] Adding DOMContentLoaded listener"); document.addEventListener("DOMContentLoaded", function () { console.log("[realtimeSearch] DOMContentLoaded fired"); tryInit(); }); } else { console.log("[realtimeSearch] DOM already loaded, initializing with delay"); setTimeout(tryInit, 500); } })();