1import { Controller } from "@hotwired/stimulus" 2 3export default class extends Controller { 4 static targets = ["input", "panel", "status", "results", "brandsFilter", "colorsFilter", "gendersFilter", "categoriesFilter", "sort", "priceMin", "priceMax", "priceMinRange", "priceMaxRange", "priceMinLabel", "priceMaxLabel", "priceRangeActive", "sentinel", "resultsContainer", "filtersPanel", "filtersBackdrop", "filtersToggleButton", "aiCell", "aiAssistantButton", "closeButton", "recommendations", "recommendationsList", "emptyState", "emptyStateTitle"] 5 static values = { 6 searchUrl: String, 7 trackUrl: String, 8 recommendationsUrl: String, 9 minChars: Number, 10 debounce: Number, 11 limit: Number, 12 noResultsText: String, 13 emptyTitleText: String, 14 loadingText: String, 15 typeMoreText: String, 16 errorText: String, 17 srResultsText: String, 18 overLimitResultsText: String, 19 filterCollapseText: String, 20 filterExpandText: String, 21 showMoreText: String, 22 showLessText: String, 23 soldOutText: String 24 } 25 26 connect() { 27 this.debounceTimer = null 28 this.abortController = null 29 this.activeIndex = -1 30 this.resultItems = [] 31 this.requestSequence = 0 32 this.lastRenderedState = "" 33 this.activeBrands = new Set() 34 this.activeColors = new Set() 35 this.activeGenders = new Set() 36 this.activeCategories = new Set() 37 this.currentOffset = 0 38 this.pageSize = this.limitValue || 24 39 this.totalHits = 0 40 this.isLoadingMore = false 41 this.intersectionObserver = null 42 this.priceBounds = null 43 this.priceBoundsQuery = null 44 this.mobileFiltersOpen = false 45 this.finalSearchTimer = null 46 this.latestSearchToken = null 47 this.trackedSearchToken = null 48 this.recommendationsCache = new Map() 49 50 this.handleDocumentSubmit = this.handleDocumentSubmit.bind(this) 51 this.handleDocumentAnchorClick = this.handleDocumentAnchorClick.bind(this) 52 this.handleViewportChange = this.handleViewportChange.bind(this) 53 document.addEventListener("submit", this.handleDocumentSubmit) 54 document.addEventListener("click", this.handleDocumentAnchorClick) 55 window.addEventListener("resize", this.handleViewportChange) 56 this._setupIntersectionObserver() 57 this._adoptAiWidget() 58 } 59 60 search() { 61 clearTimeout(this.debounceTimer) 62 this._clearFinalSearchTimer() 63 this.debounceTimer = setTimeout(() => { 64 this._doSearch() 65 }, this.debounceValue || 280) 66 } 67 68 handleFocus() { 69 if (this.inputTarget.value.trim().length === 0) { 70 this._doSearch() 71 } else if (this.inputTarget.value.trim().length >= (this.minCharsValue || 2)) { 72 this.search() 73 } 74 } 75 76 handleKeydown(event) { 77 if (this.panelTarget.classList.contains("d-none")) return 78 79 if (event.key === "Escape") { 80 this.closeButtonTarget.click() 81 return 82 } 83 84 if (event.key === "ArrowDown") { 85 event.preventDefault() 86 this.updateActiveIndex(this.activeIndex + 1) 87 return 88 } 89 90 if (event.key === "ArrowUp") { 91 event.preventDefault() 92 this.updateActiveIndex(this.activeIndex - 1) 93 return 94 } 95 96 if (event.key === "Enter" && this.activeIndex >= 0 && this.resultItems[this.activeIndex]) { 97 event.preventDefault() 98 this._trackClick(this.resultItems[this.activeIndex], this.activeIndex) 99 window.location.assign(this.resultItems[this.activeIndex].url) 100 } 101 } 102 103 async _doSearch() { 104 const query = this.inputTarget.value.trim() 105 const minChars = this.minCharsValue || 2 106 107 if (query.length > 0 && query.length < minChars) { 108 this.showPanelWithStatus(this.typeMoreTextValue) 109 this.clearResults() 110 return 111 } 112 113 const stateKey = this._stateKey() 114 if (this.lastRenderedState === stateKey && this.resultItems.length > 0) {
115 this.openPanel() 116 return 117 } 118 119 this.abortInFlightRequest() 120 this.abortController = new AbortController() 121 const requestId = ++this.requestSequence 122 this.currentOffset = 0 123 this.totalHits = 0 124 this.latestSearchToken = null 125 126 this.showLoadingState() 127 128 try { 129 const url = this.searchUrlValue || "/buscar" 130 const response = await fetch(`${url}?${this._buildParams(0).toString()}`, { 131 headers: { "Accept": "application/json" }, 132 signal: this.abortController.signal 133 }) 134 135 if (!response.ok) { 136 throw new Error(`Search request failed with status ${response.status}`) 137 } 138 139 const results = await response.json() 140 if (requestId !== this.requestSequence) return 141 142 this.lastRenderedState = stateKey 143 this.totalHits = results.total_hits || 0 144 this.latestSearchToken = results.search_token || null 145 const firstPageHits = Array.isArray(results) ? results : (results.hits || []) 146 this.currentOffset = firstPageHits.length 147 this.renderResults(results) 148 } catch (error) { 149 if (error.name === "AbortError") return 150 this.showErrorState() 151 } 152 } 153 154 renderResults(response) { 155 const hits = Array.isArray(response) ? response : (response.hits || []) 156 const facets = response.facets || {} 157 const facetStats = response.facet_stats || {} 158 159 const totalHits = response.total_hits || hits.length 160 161 this.resultItems = hits 162 this.activeIndex = -1 163 164 this.renderFacets(facets, facetStats, response) 165 166 if (this.resultItems.length === 0) { 167 this.showPanelWithStatus(this.noResultsTextValue) 168 this.clearResults() 169 this._scheduleFinalSearchTrack() 170 if (this.inputTarget.value.trim().length > 0) { 171 this._showEmptyState(this.inputTarget.value.trim()) 172 if (!this._hasActiveFilters()) this._loadRecommendations(this.requestSequence) 173 } 174 return 175 } 176 177 this.openPanel() 178 this.statusTarget.textContent = this._resultsStatusText(totalHits) 179 this._replaceProductItems(this.resultItems.map((product, index) => this._renderHit(product, index)).join("")) 180 181 this.bindResultInteractions() 182 this._scheduleFinalSearchTrack() 183 this._recheckSentinel() 184 } 185 186 async _loadMore() { 187 if (this.isLoadingMore) return 188 if (this.currentOffset >= this.totalHits) return 189 const queryLength = this.inputTarget.value.trim().length 190 if (queryLength > 0 && queryLength < (this.minCharsValue || 2)) return 191 192 this.isLoadingMore = true 193 const requestId = this.requestSequence 194 this._showLoadMoreSkeleton() 195 196 try { 197 const url = this.searchUrlValue || "/buscar" 198 const response = await fetch(`${url}?${this._buildParams(this.currentOffset).toString()}`, { 199 headers: { "Accept": "application/json" } 200 }) 201 202 if (!response.ok) throw new Error("Load more failed") 203 204 const results = await response.json() 205 const hits = Array.isArray(results) ? results : (results.hits || []) 206 207 this._removeLoadMoreSkeleton() 208 // A new search (e.g. filter change) started meanwhile: discard this stale page. 209 if (requestId !== this.requestSequence) return 210 211 const existingIds = new Set(this.resultItems.map(p => p.id)) 212 const newHits = hits.filter(p => p.id != null && !existingIds.has(p.id)) 213 214 if (newHits.length > 0) { 215 const startIndex = this.resultItems.length 216 this.resultItems = this.resultItems.concat(newHits) 217 this.resultsTarget.insertAdjacentHTML("beforeend", newHits.map((p, i) => this._renderHit(p, startIndex + i)).join("")) 218 this.bindResultInteractions() 219 } 220 this.currentOffset += hits.length 221 } catch (_error) { 222 this._removeLoadMoreSkeleton() 223 } finally { 224 this.isLoadingMore = false 225 this._recheckSentinel() 226 } 227 } 228 229 // IntersectionObserver only fires on visibility changes; re-observing forces a fresh 230 // check when the sentinel is already visible after the result list was replaced. 231 _recheckSentinel() { 232 if (!this.intersectionObserver || !this.hasSentinelTarget) return 233 this.intersectionObserver.unobserve(this.sentinelTarget) 234 this.intersectionObserver.observe(this.sentinelTarget) 235 } 236 237 _buildParams(offset) { 238 const query = this.inputTarget.value.trim() 239 const params = new URLSearchParams({ q: query, offset: String(offset) }) 240 const locale = this._currentLocale() 241 if (locale) params.set("locale", locale) 242 if (this.activeBrands.size > 0) { 243 this.activeBrands.forEach(brand => params.append("brands[]", brand)) 244 } 245 if (this.activeColors.size > 0) {
246 this.activeColors.forEach(color => params.append("colors[]", color)) 247 } 248 if (this.activeGenders.size > 0) { 249 this.activeGenders.forEach(gender => params.append("genders[]", gender)) 250 } 251 if (this.activeCategories.size > 0) { 252 this.activeCategories.forEach(name => params.append("categories[]", name)) 253 } 254 const hasPriceBounds = this.priceBounds && Number.isFinite(this.priceBounds.min) && Number.isFinite(this.priceBounds.max) 255 const minValue = this.hasPriceMinTarget ? this.priceMinTarget.value.trim() : "" 256 const maxValue = this.hasPriceMaxTarget ? this.priceMaxTarget.value.trim() : "" 257 258 if (minValue) { 259 const minNumber = Number(minValue) 260 if (!hasPriceBounds || minNumber > this.priceBounds.min) { 261 params.set("price_min", minValue) 262 } 263 } 264 265 if (maxValue) { 266 const maxNumber = Number(maxValue) 267 if (!hasPriceBounds || maxNumber < this.priceBounds.max) { 268 params.set("price_max", maxValue) 269 } 270 } 271 if (this.hasSortTarget) { 272 params.set("sort", this.sortTarget.value) 273 } 274 return params 275 } 276 277 _currentLocale() { 278 const pathMatch = window.location.pathname.match(/^\/([a-z]{2})(?:\/|$)/i) 279 if (pathMatch && pathMatch[1]) return pathMatch[1].toLowerCase() 280 281 const htmlLang = (document.documentElement.getAttribute("lang") || "").toLowerCase() 282 const langMatch = htmlLang.match(/^([a-z]{2})(?:[-_][a-z]{2})?$/i) 283 return langMatch && langMatch[1] ? langMatch[1] : "" 284 } 285 286 _renderHit(product, index) { 287 const name = product.formatted_name || this.escapeHtml(product.name || "") 288 const price = this.formatPrice(product.price) 289 const comparisonValue = this.formatPrice(product.comparison_value) 290 const productUrl = product.url || "#" 291 const imageUrl = product.photo_url || "" 292 const brand = product.formatted_brand_name || (product.brand_name ? this.escapeHtml(product.brand_name) : "") 293 const discount = Number(product.discount) 294 const finalPrice = Number(product.price) 295 const oldPrice = Number(product.comparison_value) 296 const showComparisonValue = Number.isFinite(oldPrice) && Number.isFinite(finalPrice) && oldPrice > finalPrice 297 const discountBadge = Number.isFinite(discount) && discount > 0 298 ? `<span class="ms-search__discount-badge">-${Math.round(discount)}%</span>` 299 : "" 300 const soldOutBadge = product.stock == 0 301 ? `<div class="sold-out-tag" style="z-index: 1050;"><span class="sold-out-text">${this.soldOutTextValue}</span></div>` 302 : "" 303 304 const sizes = Array.isArray(product.option_values) ? product.option_values : [] 305 const sizesHtml = sizes.length > 0 306 ? `<div class="ms-search__sizes-container">${sizes.map(s => `<div class="ms-search__size-badge${s.available ? '' : ' out-of-stock'}">${this.escapeHtml(String(s.presentation || ''))}</div>`).join('')}</div>` 307 : "" 308 309 return ` 310 <li class="ms-search__item" role="option" aria-selected="false" data-index="${index}"> 311 <a class="text-decoration-none d-block text-dark h-100 ms-search__item-link" href="${productUrl}" data-index="${index}"> 312 <div class="ms-search__image-wrap mb-3 d-flex align-items-center justify-content-center bg-light rounded position-relative" style="aspect-ratio: 9/12; overflow: hidden;"> 313 ${discountBadge} 314 ${imageUrl ? `<img src="${imageUrl}" alt="${this.escapeHtml(product.name)}" class="img-fluid w-100 h-100 object-fit-cover mix-blend-multiply">` : ``} 315 ${soldOutBadge} 316 ${sizesHtml} 317 </div> 318 <div class="text-uppercase text-muted fw-bold mb-1" style="font-size: 10px; letter-spacing: 0.5px;">${brand}</div> 319 <div class="fs-6 mb-2 lh-sm" style="color: #111827;">${name}</div> 320 <div class="ms-search__price-wrap fs-6" style="color: #0f172a;">
321 <span>${price}</span> 322 ${showComparisonValue ? `<span class="ms-search__comparison-price">${comparisonValue}</span>` : ""} 323 </div> 324 </a> 325 </li> 326 ` 327 } 328 329 _hasActiveFilters() { 330 const params = this._buildParams(0) 331 return ["brands[]", "colors[]", "genders[]", "categories[]", "price_min", "price_max"].some(key => params.has(key)) 332 } 333 334 // Separate request after the empty state renders; never touches search tokens or tracked result items. 335 async _loadRecommendations(requestId) { 336 if (!this.hasRecommendationsUrlValue || !this.hasRecommendationsTarget || !this.hasRecommendationsListTarget) return 337 338 const locale = this._currentLocale() 339 let hits = this.recommendationsCache.get(locale) 340 341 if (!hits) { 342 try { 343 const params = new URLSearchParams() 344 if (locale) params.set("locale", locale) 345 const response = await fetch(`${this.recommendationsUrlValue}?${params.toString()}`, { 346 headers: { "Accept": "application/json" } 347 }) 348 if (!response.ok) return 349 350 const data = await response.json() 351 hits = Array.isArray(data.hits) ? data.hits : [] 352 this.recommendationsCache.set(locale, hits) 353 } catch (_error) { 354 return 355 } 356 } 357 358 if (requestId !== this.requestSequence || this.resultItems.length > 0) return 359 if (hits.length === 0) return 360 361 this.recommendationsListTarget.innerHTML = hits.map((product, index) => this._renderHit(product, index)).join("") 362 this.recommendationsTarget.classList.remove("d-none") 363 } 364 365 _showEmptyState(query) { 366 if (!this.hasEmptyStateTarget) return 367 368 if (this.hasEmptyStateTitleTarget) { 369 const template = this.emptyTitleTextValue || this.noResultsTextValue 370 this.emptyStateTitleTarget.textContent = template.replace("%{query}", query) 371 } 372 this.emptyStateTarget.classList.remove("d-none") 373 } 374 375 _clearRecommendations() { 376 if (this.hasEmptyStateTarget) this.emptyStateTarget.classList.add("d-none") 377 if (!this.hasRecommendationsTarget) return 378 379 this.recommendationsTarget.classList.add("d-none") 380 if (this.hasRecommendationsListTarget) this.recommendationsListTarget.innerHTML = "" 381 } 382 383 _setupIntersectionObserver() { 384 if (!this.hasSentinelTarget) return 385 386 this.intersectionObserver = new IntersectionObserver( 387 (entries) => { 388 if (entries[0].isIntersecting) this._loadMore() 389 }, 390 { 391 root: this.hasResultsContainerTarget ? this.resultsContainerTarget : null, 392 rootMargin: "0px 0px 150px 0px", 393 threshold: 0 394 } 395 ) 396 this.intersectionObserver.observe(this.sentinelTarget) 397 } 398 399 _showLoadMoreSkeleton() { 400 if (!this.hasSentinelTarget) return 401 this.sentinelTarget.innerHTML = ` 402 <div class="ms-search__grid w-100 mt-0"> 403 <div><div class="ms-search__skeleton" style="height: 240px;"></div></div> 404 <div><div class="ms-search__skeleton" style="height: 240px;"></div></div> 405 <div><div class="ms-search__skeleton" style="height: 240px;"></div></div> 406 <div><div class="ms-search__skeleton" style="height: 240px;"></div></div> 407 </div> 408 ` 409 } 410 411 _removeLoadMoreSkeleton() { 412 if (this.hasSentinelTarget) this.sentinelTarget.innerHTML = "" 413 } 414 415 bindResultInteractions() { 416 const links = this.resultsTarget.querySelectorAll(".ms-search__item-link") 417 links.forEach((link) => { 418 if (!link.dataset.trackingBound) { 419 link.dataset.trackingBound = "true" 420 link.addEventListener("click", (event) => { 421 const index = Number(event.currentTarget.dataset.index) 422 const product = this.resultItems[index] 423 this._trackClick(product, index) 424 }) 425 } 426 427 link.addEventListener("mouseenter", (event) => { 428 const index = Number(event.currentTarget.dataset.index) 429 this.updateActiveIndex(index) 430 }) 431 }) 432 } 433 434 updateActiveIndex(nextIndex) { 435 if (this.resultItems.length === 0) return 436 437 const itemCount = this.resultItems.length 438 if (nextIndex < 0) nextIndex = itemCount - 1 439 if (nextIndex >= itemCount) nextIndex = 0 440 this.activeIndex = nextIndex 441 442 const nodes = this.resultsTarget.querySelectorAll(".ms-search__item")
443 nodes.forEach((node, index) => { 444 const isActive = index === this.activeIndex 445 node.classList.toggle("is-active", isActive) 446 node.setAttribute("aria-selected", isActive ? "true" : "false") 447 if (isActive) { 448 node.scrollIntoView({ block: "nearest" }) 449 } 450 }) 451 } 452 453 showLoadingState() { 454 this.openPanel() 455 this._clearRecommendations() 456 this.statusTarget.textContent = this.loadingTextValue 457 this._replaceProductItems(` 458 <li class="ms-search__skeleton-wrapper"><div class="ms-search__skeleton"></div></li> 459 <li class="ms-search__skeleton-wrapper"><div class="ms-search__skeleton"></div></li> 460 <li class="ms-search__skeleton-wrapper"><div class="ms-search__skeleton"></div></li> 461 <li class="ms-search__skeleton-wrapper"><div class="ms-search__skeleton"></div></li> 462 `) 463 } 464 465 showErrorState() { 466 this.showPanelWithStatus(this.errorTextValue) 467 this.clearResults() 468 this.lastRenderedState = "" 469 } 470 471 showPanelWithStatus(message) { 472 this.openPanel() 473 this.statusTarget.textContent = message 474 } 475 476 _resultsStatusText(totalHits) { 477 const numericTotal = Number(totalHits) 478 if (Number.isFinite(numericTotal) && numericTotal >= 1000 && this.hasOverLimitResultsTextValue) { 479 return this.overLimitResultsTextValue 480 } 481 482 return `${totalHits} ${this.srResultsTextValue}` 483 } 484 485 openPanel() { 486 this.panelTarget.classList.remove("d-none") 487 this.inputTarget.setAttribute("aria-expanded", "true") 488 document.body.style.overflow = "hidden" 489 } 490 491 closePanel() { 492 this._clearFinalSearchTimer() 493 this.closeMobileFilters() 494 this.panelTarget.classList.add("d-none") 495 this.statusTarget.textContent = "" 496 this.clearResults() 497 this.inputTarget.setAttribute("aria-expanded", "false") 498 this.activeIndex = -1 499 this.lastRenderedState = "" 500 document.body.style.overflow = "" 501 } 502 503 openMobileFilters() { 504 if (!this.isMobileViewport()) return 505 if (!this.hasFiltersPanelTarget) return 506 507 this.mobileFiltersOpen = true 508 this.filtersPanelTarget.classList.add("is-mobile-open") 509 510 if (this.hasFiltersBackdropTarget) { 511 this.filtersBackdropTarget.classList.add("is-active") 512 } 513 514 if (this.hasFiltersToggleButtonTarget) { 515 this.filtersToggleButtonTarget.setAttribute("aria-expanded", "true") 516 } 517 } 518 519 closeMobileFilters() { 520 this.mobileFiltersOpen = false 521 522 if (this.hasFiltersPanelTarget) { 523 this.filtersPanelTarget.classList.remove("is-mobile-open") 524 } 525 526 if (this.hasFiltersBackdropTarget) { 527 this.filtersBackdropTarget.classList.remove("is-active") 528 } 529 530 if (this.hasFiltersToggleButtonTarget) { 531 this.filtersToggleButtonTarget.setAttribute("aria-expanded", "false") 532 } 533 } 534 535 handleViewportChange() { 536 if (!this.isMobileViewport()) { 537 this.closeMobileFilters() 538 } 539 } 540 541 isMobileViewport() { 542 return window.matchMedia("(max-width: 991.98px)").matches 543 } 544 545 clearResults() { 546 this.resultItems = [] 547 this._removeProductItems() 548 this.currentOffset = 0 549 this.totalHits = 0 550 this._removeLoadMoreSkeleton() 551 this._clearRecommendations() 552 } 553 554 // Removes every grid child (product items and loading skeletons) while 555 // preserving the persistent AI assistant cell so its chat state is kept. 556 _removeProductItems() { 557 Array.from(this.resultsTarget.children).forEach((node) => { 558 if (!node.classList.contains("ms-search__ai-cell")) node.remove() 559 }) 560 } 561 562 // Replaces the product items while keeping the pinned AI assistant cell 563 // as the first child of the grid so its chat state is never wiped. 564 _replaceProductItems(html) { 565 this._removeProductItems() 566 this.resultsTarget.insertAdjacentHTML("beforeend", html) 567 } 568 569 toggleAiAssistant() { 570 if (!this.hasAiCellTarget) return 571 572 const willOpen = this.aiCellTarget.classList.contains("d-none") 573 this.aiCellTarget.classList.toggle("d-none", !willOpen) 574 575 if (this.hasAiAssistantButtonTarget) { 576 this.aiAssistantButtonTarget.setAttribute("aria-expanded", willOpen ? "true" : "false") 577 } 578
579 // The vidiv-agent widget mounts while hidden (display:none) and can end up 580 // with a 0px layout, so it stays blank until it receives a resize. Nudge it 581 // once the cell becomes visible so it recalculates its dimensions. 582 if (willOpen) { 583 requestAnimationFrame(() => { 584 window.dispatchEvent(new Event("resize")) 585 }) 586 } 587 } 588 589 // The vidiv-agent loader always appends its widget as a fixed floating node on 590 // <body>. We relocate that node into the AI cell so it renders inline in the 591 // 2x2 grid block (CSS neutralizes its fixed positioning). 592 _adoptAiWidget() { 593 if (!this.hasAiCellTarget) return 594 595 const slug = "demo-velfix" 596 const adopt = () => { 597 const root = document.querySelector(`.vidiv-widget-root[data-vidiv-widget-key="${slug}"]`) 598 if (!root) return false 599 if (root.parentElement !== this.aiCellTarget) { 600 this.aiCellTarget.appendChild(root) 601 } 602 return true 603 } 604 605 if (adopt()) return 606 607 this.aiWidgetObserver = new MutationObserver(() => { 608 if (adopt()) { 609 this.aiWidgetObserver.disconnect() 610 this.aiWidgetObserver = null 611 } 612 }) 613 this.aiWidgetObserver.observe(document.body, { childList: true }) 614 } 615 616 formatPrice(price) { 617 if (price === null || price === undefined || price === "") { 618 return "-" 619 } 620 621 const numericPrice = Number(price) 622 if (Number.isNaN(numericPrice)) { 623 return `${this.escapeHtml(String(price))} â¬` 624 } 625 626 const formattedPrice = new Intl.NumberFormat("es-ES", { 627 minimumFractionDigits: 2, 628 maximumFractionDigits: 2 629 }).format(numericPrice) 630 631 return `${formattedPrice} â¬` 632 } 633 634 escapeHtml(text) { 635 return String(text) 636 .replaceAll("&", "&") 637 .replaceAll("<", "<") 638 .replaceAll(">", ">") 639 .replaceAll('"', """) 640 .replaceAll("'", "'") 641 } 642 643 handleBrandChange(event) { 644 const brand = event.target.value 645 if (event.target.checked) { 646 this.activeBrands.add(brand) 647 } else { 648 this.activeBrands.delete(brand) 649 } 650 this.search() 651 } 652 653 handleColorChange(event) { 654 const color = event.target.value 655 if (event.target.checked) { 656 this.activeColors.add(color) 657 } else { 658 this.activeColors.delete(color) 659 } 660 this.search() 661 } 662 663 handleCategoryChange(event) { 664 const categoryName = event.target.value 665 if (event.target.checked) { 666 this.activeCategories.add(categoryName) 667 } else { 668 this.activeCategories.delete(categoryName) 669 } 670 this.search() 671 } 672 673 handleGenderChange(event) { 674 const gender = event.target.value 675 if (event.target.checked) { 676 this.activeGenders.add(gender) 677 } else { 678 this.activeGenders.delete(gender) 679 } 680 this.search() 681 } 682 683 handleSortChange() { 684 this.lastRenderedState = "" 685 this.search() 686 } 687 688 toggleFilterSection(event) { 689 const button = event.currentTarget 690 const sectionName = button.dataset.filterSection 691 if (!sectionName) return 692 693 const sectionContent = this.element.querySelector(`[data-filter-section-content="${sectionName}"]`) 694 if (!sectionContent) return 695 696 const isCollapsed = sectionContent.classList.toggle("d-none") 697 button.setAttribute("aria-expanded", String(!isCollapsed)) 698 button.textContent = this.filterSectionToggleText(!isCollapsed) 699 } 700 701 handlePriceChange() { 702 this.lastRenderedState = "" 703 this.search() 704 } 705 706 handlePriceRangeInput() { 707 this.syncPriceRangeUI(false) 708 } 709 710 handlePriceRangeCommit() { 711 this.syncPriceRangeUI(true) 712 } 713 714 syncPriceRangeUI(triggerSearch = false) { 715 if (!this.hasPriceMinRangeTarget || !this.hasPriceMaxRangeTarget) return 716 717 let minValue = Number(this.priceMinRangeTarget.value) 718 let maxValue = Number(this.priceMaxRangeTarget.value) 719 720 if (minValue > maxValue) { 721 const activeElement = document.activeElement 722 if (activeElement === this.priceMinRangeTarget) { 723 maxValue = minValue
724 this.priceMaxRangeTarget.value = String(maxValue) 725 } else { 726 minValue = maxValue 727 this.priceMinRangeTarget.value = String(minValue) 728 } 729 } 730 731 if (this.hasPriceMinTarget) this.priceMinTarget.value = String(minValue) 732 if (this.hasPriceMaxTarget) this.priceMaxTarget.value = String(maxValue) 733 if (this.hasPriceMinLabelTarget) this.priceMinLabelTarget.textContent = `${Math.floor(minValue)} â¬` 734 if (this.hasPriceMaxLabelTarget) this.priceMaxLabelTarget.textContent = `${Math.floor(maxValue)} â¬` 735 736 if (this.hasPriceRangeActiveTarget && this.priceBounds && this.priceBounds.max > this.priceBounds.min) { 737 const total = this.priceBounds.max - this.priceBounds.min 738 const leftPct = ((minValue - this.priceBounds.min) / total) * 100 739 const rightPct = ((this.priceBounds.max - maxValue) / total) * 100 740 741 this.priceRangeActiveTarget.style.left = `${Math.max(0, Math.min(leftPct, 100))}%` 742 this.priceRangeActiveTarget.style.right = `${Math.max(0, Math.min(rightPct, 100))}%` 743 } 744 745 if (triggerSearch) { 746 this.handlePriceChange() 747 } 748 } 749 750 renderFacets(facets, facetStats, response) { 751 if (this.hasBrandsFilterTarget) { 752 const brandCounts = facets.brand_name || {} 753 const sortedBrands = Object.entries(brandCounts) 754 .sort(([, a], [, b]) => b - a) 755 const collapsedLimit = 10 756 757 if (sortedBrands.length === 0) { 758 this.brandsFilterTarget.innerHTML = `<p class="text-muted small">â</p>` 759 } else { 760 const shouldCollapse = sortedBrands.length > collapsedLimit 761 const brandsHtml = sortedBrands.map(([brand, count], index) => { 762 const id = `ms-brand-${brand.toLowerCase().replace(/[^a-z0-9]/g, "-")}` 763 const checked = this.activeBrands.has(brand) ? "checked" : "" 764 const safeLabel = this.escapeHtml(brand) 765 const hiddenClass = shouldCollapse && index >= collapsedLimit ? " d-none" : "" 766 return ` 767 <div class="form-check mb-2${hiddenClass}" data-brand-item> 768 <input class="form-check-input rounded-0 border-secondary border-opacity-25 shadow-none" 769 type="checkbox" id="${id}" value="${safeLabel}" ${checked} 770 data-action="change->meilisearch#handleBrandChange"> 771 <label class="form-check-label text-muted w-100 d-flex justify-content-between small" for="${id}"> 772 <span class="text-truncate" style="max-width: 140px">${safeLabel}</span> 773 <span>(${count})</span> 774 </label> 775 </div>` 776 }).join("") 777 778 const toggleHtml = shouldCollapse 779 ? ` 780 <button type="button" 781 class="btn btn-link p-0 mt-1 text-muted small text-decoration-none" 782 data-action="click->meilisearch#toggleBrandList" 783 data-brands-expanded="false"> 784 ${this.showMoreTextValue} 785 </button>` 786 : "" 787 788 this.brandsFilterTarget.innerHTML = `${brandsHtml}${toggleHtml}` 789 } 790 } 791 792 if (this.hasColorsFilterTarget) { 793 const colorCounts = facets.color || {} 794 const sortedColors = Object.entries(colorCounts).sort(([, a], [, b]) => b - a) 795 const collapsedLimit = 10 796 797 if (sortedColors.length === 0) { 798 this.colorsFilterTarget.innerHTML = `<p class="text-muted small">â</p>` 799 } else { 800 const shouldCollapse = sortedColors.length > collapsedLimit 801 const colorsHtml = sortedColors.map(([color, count], index) => { 802 const id = `ms-color-${color.toLowerCase().replace(/[^a-z0-9]/g, "-")}` 803 const checked = this.activeColors.has(color) ? "checked" : "" 804 const safeLabel = this.escapeHtml(color) 805 const hiddenClass = shouldCollapse && index >= collapsedLimit ? " d-none" : "" 806 return ` 807 <div class="form-check mb-2${hiddenClass}" data-color-item> 808 <input class="form-check-input rounded-0 border-secondary border-opacity-25 shadow-none" 809 type="checkbox" id="${id}" value="${safeLabel}" ${checked} 810 data-action="change->meilisearch#handleColorChange"> 811 <label class="form-check-label text-muted w-100 d-flex justify-content-between small" for="${id}">
812 <span class="text-truncate" style="max-width: 140px">${safeLabel}</span> 813 <span>(${count})</span> 814 </label> 815 </div>` 816 }).join("") 817 818 const toggleHtml = shouldCollapse 819 ? `<button type="button" 820 class="btn btn-link p-0 mt-1 text-muted small text-decoration-none" 821 data-action="click->meilisearch#toggleColorList" 822 data-colors-expanded="false"> 823 ${this.showMoreTextValue} 824 </button>` 825 : "" 826 827 this.colorsFilterTarget.innerHTML = `${colorsHtml}${toggleHtml}` 828 } 829 } 830 831 if (this.hasGendersFilterTarget) { 832 const genderCounts = facets.gender || {} 833 const sortedGenders = Object.entries(genderCounts).sort(([, a], [, b]) => b - a) 834 const collapsedLimit = 10 835 836 if (sortedGenders.length === 0) { 837 this.gendersFilterTarget.innerHTML = `<p class="text-muted small">â</p>` 838 } else { 839 const shouldCollapse = sortedGenders.length > collapsedLimit 840 const gendersHtml = sortedGenders.map(([gender, count], index) => { 841 const id = `ms-gender-${gender.toLowerCase().replace(/[^a-z0-9]/g, "-")}` 842 const checked = this.activeGenders.has(gender) ? "checked" : "" 843 const safeLabel = this.escapeHtml(gender) 844 const hiddenClass = shouldCollapse && index >= collapsedLimit ? " d-none" : "" 845 return ` 846 <div class="form-check mb-2${hiddenClass}" data-gender-item> 847 <input class="form-check-input rounded-0 border-secondary border-opacity-25 shadow-none" 848 type="checkbox" id="${id}" value="${safeLabel}" ${checked} 849 data-action="change->meilisearch#handleGenderChange"> 850 <label class="form-check-label text-muted w-100 d-flex justify-content-between small" for="${id}"> 851 <span class="text-truncate" style="max-width: 140px">${safeLabel}</span> 852 <span>(${count})</span> 853 </label> 854 </div>` 855 }).join("") 856 857 const toggleHtml = shouldCollapse 858 ? `<button type="button" 859 class="btn btn-link p-0 mt-1 text-muted small text-decoration-none" 860 data-action="click->meilisearch#toggleGenderList" 861 data-genders-expanded="false"> 862 ${this.showMoreTextValue} 863 </button>` 864 : "" 865 866 this.gendersFilterTarget.innerHTML = `${gendersHtml}${toggleHtml}` 867 } 868 } 869 870 if (this.hasCategoriesFilterTarget) { 871 const taxonCounts = facets.taxon_names || {} 872 const sortedTaxons = Object.entries(taxonCounts).sort(([, a], [, b]) => b - a) 873 const collapsedLimit = 10 874 875 if (sortedTaxons.length === 0) { 876 this.categoriesFilterTarget.innerHTML = `<p class="text-muted small">â</p>` 877 } else { 878 const shouldCollapse = sortedTaxons.length > collapsedLimit 879 const taxonsHtml = sortedTaxons.map(([name, count], index) => { 880 const id = `ms-taxon-${name.toLowerCase().replace(/[^a-z0-9]/g, "-")}` 881 const checked = this.activeCategories.has(name) ? "checked" : "" 882 const safeLabel = this.escapeHtml(name) 883 const hiddenClass = shouldCollapse && index >= collapsedLimit ? " d-none" : "" 884 return ` 885 <div class="form-check mb-2${hiddenClass}" data-category-item> 886 <input class="form-check-input rounded-0 border-secondary border-opacity-25 shadow-none" 887 type="checkbox" id="${id}" value="${safeLabel}" ${checked} 888 data-action="change->meilisearch#handleCategoryChange"> 889 <label class="form-check-label text-muted w-100 d-flex justify-content-between small" for="${id}">
890 <span class="text-truncate" style="max-width: 140px">${safeLabel}</span> 891 <span>(${count})</span> 892 </label> 893 </div>` 894 }).join("") 895 896 const toggleHtml = shouldCollapse 897 ? `<button type="button" 898 class="btn btn-link p-0 mt-1 text-muted small text-decoration-none" 899 data-action="click->meilisearch#toggleCategoryList" 900 data-categories-expanded="false"> 901 ${this.showMoreTextValue} 902 </button>` 903 : "" 904 905 this.categoriesFilterTarget.innerHTML = `${taxonsHtml}${toggleHtml}` 906 } 907 } 908 909 if (facetStats.price) { 910 const { min, max } = facetStats.price 911 912 if (Number.isFinite(min) && Number.isFinite(max) && min <= max) { 913 const currentQuery = this.inputTarget.value.trim().toLowerCase() 914 const boundsMin = Math.floor(min) 915 const boundsMax = Math.ceil(max) 916 const isSameQuery = this.priceBoundsQuery === currentQuery 917 918 if (!isSameQuery || !this.priceBounds) { 919 this.priceBounds = { min: boundsMin, max: boundsMax } 920 this.priceBoundsQuery = currentQuery 921 if (this.hasPriceMinTarget) this.priceMinTarget.value = String(boundsMin) 922 if (this.hasPriceMaxTarget) this.priceMaxTarget.value = String(boundsMax) 923 } else { 924 // Keep bounds stable for the current search query so applying filters 925 // does not collapse the slider and block expanding the max price again. 926 this.priceBounds = { 927 min: Math.min(this.priceBounds.min, boundsMin), 928 max: Math.max(this.priceBounds.max, boundsMax) 929 } 930 } 931 932 const currentMin = this.hasPriceMinTarget && this.priceMinTarget.value !== "" 933 ? Number(this.priceMinTarget.value) 934 : this.priceBounds.min 935 const currentMax = this.hasPriceMaxTarget && this.priceMaxTarget.value !== "" 936 ? Number(this.priceMaxTarget.value) 937 : this.priceBounds.max 938 939 if (this.hasPriceMinRangeTarget) { 940 this.priceMinRangeTarget.min = String(this.priceBounds.min) 941 this.priceMinRangeTarget.max = String(this.priceBounds.max) 942 this.priceMinRangeTarget.value = String(Math.max(this.priceBounds.min, Math.min(currentMin, this.priceBounds.max))) 943 } 944 945 if (this.hasPriceMaxRangeTarget) { 946 this.priceMaxRangeTarget.min = String(this.priceBounds.min) 947 this.priceMaxRangeTarget.max = String(this.priceBounds.max) 948 this.priceMaxRangeTarget.value = String(Math.max(this.priceBounds.min, Math.min(currentMax, this.priceBounds.max))) 949 } 950 951 this.syncPriceRangeUI(false) 952 } 953 } 954 } 955 956 _stateKey() { 957 const query = this.inputTarget.value.trim() 958 const brands = [...this.activeBrands].sort().join("|") 959 const colors = [...this.activeColors].sort().join("|") 960 const genders = [...this.activeGenders].sort().join("|") 961 const taxons = [...this.activeCategories].sort().join("|") 962 const sort = this.hasSortTarget ? this.sortTarget.value : "" 963 let priceMin = this.hasPriceMinTarget ? this.priceMinTarget.value.trim() : "" 964 let priceMax = this.hasPriceMaxTarget ? this.priceMaxTarget.value.trim() : "" 965 if (this.priceBounds) { 966 if (priceMin !== "" && Number(priceMin) <= this.priceBounds.min) priceMin = "" 967 if (priceMax !== "" && Number(priceMax) >= this.priceBounds.max) priceMax = "" 968 } 969 return `${query}§${brands}§${colors}§${genders}§${taxons}§${priceMin}§${priceMax}§${sort}` 970 } 971 972 toggleBrandList(event) { 973 const button = event.currentTarget 974 const isExpanded = button.dataset.brandsExpanded === "true" 975 const brandItems = this.brandsFilterTarget.querySelectorAll("[data-brand-item]") 976
977 brandItems.forEach((item, index) => { 978 if (index >= 10) { 979 item.classList.toggle("d-none", isExpanded) 980 } 981 }) 982 983 button.dataset.brandsExpanded = isExpanded ? "false" : "true" 984 button.textContent = isExpanded ? this.showMoreTextValue : this.showLessTextValue 985 } 986 987 toggleColorList(event) { 988 const button = event.currentTarget 989 const isExpanded = button.dataset.colorsExpanded === "true" 990 const colorItems = this.colorsFilterTarget.querySelectorAll("[data-color-item]") 991 992 colorItems.forEach((item, index) => { 993 if (index >= 10) { 994 item.classList.toggle("d-none", isExpanded) 995 } 996 }) 997 998 button.dataset.colorsExpanded = isExpanded ? "false" : "true" 999 button.textContent = isExpanded ? this.showMoreTextValue : this.showLessTextValue 1000 } 1001 1002 toggleCategoryList(event) { 1003 const button = event.currentTarget 1004 const isExpanded = button.dataset.categoriesExpanded === "true" 1005 const categoryItems = this.categoriesFilterTarget.querySelectorAll("[data-category-item]") 1006 1007 categoryItems.forEach((item, index) => { 1008 if (index >= 10) { 1009 item.classList.toggle("d-none", isExpanded) 1010 } 1011 }) 1012 1013 button.dataset.categoriesExpanded = isExpanded ? "false" : "true" 1014 button.textContent = isExpanded ? this.showMoreTextValue : this.showLessTextValue 1015 } 1016 1017 toggleGenderList(event) { 1018 const button = event.currentTarget 1019 const isExpanded = button.dataset.gendersExpanded === "true" 1020 const genderItems = this.gendersFilterTarget.querySelectorAll("[data-gender-item]") 1021 1022 genderItems.forEach((item, index) => { 1023 if (index >= 10) { 1024 item.classList.toggle("d-none", isExpanded) 1025 } 1026 }) 1027 1028 button.dataset.gendersExpanded = isExpanded ? "false" : "true" 1029 button.textContent = isExpanded ? this.showMoreTextValue : this.showLessTextValue 1030 } 1031 1032 filterSectionToggleText(isExpanded) { 1033 const text = isExpanded ? this.filterCollapseTextValue : this.filterExpandTextValue 1034 const arrow = isExpanded ? "â" : "â" 1035 return `${text} ${arrow}` 1036 } 1037 1038 handleDocumentSubmit(event) { 1039 const form = event.target 1040 if (!(form instanceof HTMLFormElement)) return 1041 if (form.id !== "form-add-to-cart") return 1042 1043 this._attachSearchTokenToCartForm(form) 1044 this._trackConversionFromStorage() 1045 } 1046 1047 handleDocumentAnchorClick(event) { 1048 const link = event.target.closest("a") 1049 if (!link) return 1050 if (link.id !== "direct-to-checkout") return 1051 1052 this._trackConversionFromStorage() 1053 } 1054 1055 _scheduleFinalSearchTrack() { 1056 this._clearFinalSearchTimer() 1057 1058 const query = this.inputTarget.value.trim() 1059 const minChars = this.minCharsValue || 2 1060 1061 if (!this.latestSearchToken) return 1062 if (query.length > 0 && query.length < minChars) return 1063 1064 this.finalSearchTimer = setTimeout(() => { 1065 if (!this.latestSearchToken) return 1066 if (this.trackedSearchToken === this.latestSearchToken) return 1067 1068 this._trackEvent("search", { token: this.latestSearchToken }) 1069 this.trackedSearchToken = this.latestSearchToken 1070 }, 1200) 1071 } 1072 1073 _clearFinalSearchTimer() { 1074 if (!this.finalSearchTimer) return 1075 1076 clearTimeout(this.finalSearchTimer) 1077 this.finalSearchTimer = null 1078 } 1079 1080 _flushPendingSearchTrack() { 1081 if (!this.finalSearchTimer) return 1082 1083 clearTimeout(this.finalSearchTimer) 1084 this.finalSearchTimer = null 1085 1086 if (!this.latestSearchToken) return 1087 if (this.trackedSearchToken === this.latestSearchToken) return 1088 1089 this._trackEvent("search", { token: this.latestSearchToken }, true) 1090 this.trackedSearchToken = this.latestSearchToken 1091 } 1092 1093 _trackClick(product, index) { 1094 if (!this.latestSearchToken || !product || !product.id) return 1095 1096 const payload = { 1097 token: this.latestSearchToken, 1098 product_id: product.id, 1099 position: Number.isFinite(index) ? index : null 1100 } 1101 1102 this._trackEvent("click", payload, true) 1103 1104 const clickContext = { 1105 token: this.latestSearchToken, 1106 product_id: product.id, 1107 tracked_at: Date.now() 1108 } 1109 sessionStorage.setItem("vweb.meilisearch.last_click", JSON.stringify(clickContext)) 1110 } 1111 1112 _trackConversionFromStorage() { 1113 const raw = sessionStorage.getItem("vweb.meilisearch.last_click") 1114 if (!raw) return 1115 1116 try { 1117 const data = JSON.parse(raw) 1118 if (!data || !data.token || !data.product_id) return 1119 1120 const maxAgeMs = 30 * 60 * 1000 1121 const isStale = !data.tracked_at || (Date.now() - Number(data.tracked_at) > maxAgeMs) 1122 if (isStale) { 1123 sessionStorage.removeItem("vweb.meilisearch.last_click") 1124 return 1125 } 1126 1127 this._trackEvent("conversion", { 1128 token: data.token, 1129 product_id: data.product_id 1130 }, true) 1131 } catch (_error) { 1132 sessionStorage.removeItem("vweb.meilisearch.last_click") 1133 } 1134 } 1135 1136 _attachSearchTokenToCartForm(form) { 1137 const raw = sessionStorage.getItem("vweb.meilisearch.last_click") 1138 if (!raw) return 1139 1140 try { 1141 const data = JSON.parse(raw) 1142 if (!data || !data.token) return 1143 1144 let input = form.querySelector('input[name="search_token"]') 1145 if (!input) { 1146 input = document.createElement("input") 1147 input.type = "hidden" 1148 input.name = "search_token" 1149 form.appendChild(input) 1150 } 1151 input.value = data.token 1152 } catch (_error) { 1153 sessionStorage.removeItem("vweb.meilisearch.last_click") 1154 } 1155 } 1156 1157 _trackEvent(type, payload, keepalive = false) { 1158 if (!this.hasTrackUrlValue || !payload.token) return 1159 1160 const body = JSON.stringify({ type, ...payload }) 1161 const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content 1162 const headers = { 1163 "Accept": "application/json", 1164 "Content-Type": "application/json" 1165 } 1166 1167 if (csrfToken) { 1168 headers["X-CSRF-Token"] = csrfToken 1169 } 1170 1171 fetch(this.trackUrlValue, { 1172 method: "POST", 1173 headers, 1174 body, 1175 keepalive 1176 }).catch(() => { }) 1177 } 1178 1179 abortInFlightRequest() { 1180 if (this.abortController) { 1181 this.abortController.abort() 1182 this.abortController = null 1183 } 1184 } 1185 1186 disconnect() { 1187 clearTimeout(this.debounceTimer) 1188 this._flushPendingSearchTrack() 1189 this.abortInFlightRequest() 1190 document.removeEventListener("submit", this.handleDocumentSubmit) 1191 document.removeEventListener("click", this.handleDocumentAnchorClick) 1192 window.removeEventListener("resize", this.handleViewportChange) 1193 if (this.intersectionObserver) { 1194 this.intersectionObserver.disconnect() 1195 this.intersectionObserver = null 1196 } 1197 if (this.aiWidgetObserver) { 1198 this.aiWidgetObserver.disconnect() 1199 this.aiWidgetObserver = null 1200 } 1201 } 1202};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.