PageSourceSearch

https://codigodenim.com/assets/store/controllers/meilisearch_controller

js codigodenim.com collected 2026-10-08 05:39:52 UTC 48,514 bytes, 1,202 lines download raw bytes

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("&", "&amp;")
637            .replaceAll("<", "&lt;")
638            .replaceAll(">", "&gt;")
639            .replaceAll('"', "&quot;")
640            .replaceAll("'", "&#39;")
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.