PageSourceSearch

https://www.ekinsport.com/static/version1790179775/frontend/Ekinsp…/js/empathy-search.min.js

js ekinsport.com collected 2026-09-24 10:43:39 UTC 49,139 bytes, 1,163 lines download raw bytes

1/**
2 * Ekinsport_EmpathySearch - Alpine.js component for the Empathy.co search overlay.
3 *
4 * Extracted from search/form.phtml: this used to be ~40 KiB of inline script repeated in the
5 * HTML of every page, re-downloaded on each navigation and never cached. As a static asset it
6 * is fetched once and served from the browser cache afterwards.
7 *
8 * Registered with Alpine.data() on "alpine:init", the way Alpine expects reusable components to
9 * be declared, and wrapped in an IIFE so none of the helpers below leak into the global scope.
10 * The markup keeps using x-data="empathySearch({...})": Alpine resolves the name against the
11 * registry, so the template is unchanged.
12 *
13 * Loaded with <script defer> from search/form.phtml. Deferred classic scripts run before module
14 * scripts declared later in the document, and Hyva loads Alpine as a deferred module from
15 * before.body.end, so the registration always happens before Alpine starts.
16 */
17(function () {
18    'use strict';
19
20    // ── LRU cache shared across all overlay openings ────────
21    // Key = serialization of parameters; value = Empathy JSON response.
22    // Persists as long as the page is not reloaded.
23    const EMPATHY_CACHE_MAX = 50;
24    const EMPATHY_BROWSER_CACHE_TTL = 15 * 60 * 1000; // 15 minutes
25    const empathyCache = new Map();
26
27    // ── AI carousels (zero / low-results fallback) ──────────
28    // Empathy AI Carousel API surfaces semantically related product
29    // carousels when a search returns no or few results.
30    const AI_CAROUSEL_MAX = 4;    // max number of carousels rendered
31
32    const browserCacheKey = (key) => `empathy_search_${key}`;
33
34    const browserCacheGet = (key) => {
35        try {
36            const raw = localStorage.getItem(browserCacheKey(key));
37            if (!raw) return null;
38
39            const item = JSON.parse(raw);
40            if (!item || !item.expiresAt || Date.now() > item.expiresAt) {
41                localStorage.removeItem(browserCacheKey(key));
42                return null;
43            }
44
45            return item.value ?? null;
46        } catch (e) {
47            return null;
48        }
49    };
50
51    const browserCacheSet = (key, value, ttl = EMPATHY_BROWSER_CACHE_TTL) => {
52        try {
53            localStorage.setItem(browserCacheKey(key), JSON.stringify({
54                value,
55                expiresAt: Date.now() + ttl,
56            }));
57        } catch (e) {
58            // localStorage may be unavailable or full: ignore.
59        }
60    };
61
62    const cacheGet = (key) => {
63        if (!empathyCache.has(key)) return null;
64        const value = empathyCache.get(key);
65        empathyCache.delete(key);
66        empathyCache.set(key, value); // LRU : remet en fin
67        return value;
68    };
69
70    const cacheSet = (key, value) => {
71        if (empathyCache.has(key)) empathyCache.delete(key);
72        empathyCache.set(key, value);
73        if (empathyCache.size > EMPATHY_CACHE_MAX) {
74            const firstKey = empathyCache.keys().next().value;
75            empathyCache.delete(firstKey);
76        }
77    };
78
79    function empathySearch(cfg) {
80        // Facet labels/filter fields, produced by the ViewModel and passed through x-data.
81        const FACET_MAP = cfg.facetMap || {};
82
83        const getScope = () => {
84            if (window.matchMedia('(max-width: 1023px)').matches) {
85                return 'mobile';
86            }
87
88            return 'desktop';
89        };
90
91        const buildEmpathyUrl = (type, extraParams = {}) => {
92            const params = new URLSearchParams({
93                lang:     cfg.lang,
94                store:    cfg.storeCode,
95                currency: cfg.currency,
96                rate:     1,
97                portal:   1,
98                scope:    getScope(),
99                ...extraParams,
100            });
101            return `${cfg.apiBaseUrl}/search/v1/query/${cfg.instanceId}/${type}?${params}`;
102        };
103
104        const buildEmpathizeUrl = (searchQuery) => {
105            const params = new URLSearchParams({
106                query: searchQuery,
107                lang:  cfg.lang,
108                store: cfg.storeCode,
109            });
110
111            return `${cfg.apiBaseUrl}/search/v1/query/${cfg.instanceId}/empathize?${params}`;
112        };
113
114        const buildRelatedTagsUrl = (searchQuery) => {
115            const params = new URLSearchParams({
116                internal: 'true',
117                query:    searchQuery,
118                instance: cfg.instanceId,
119                scope:    getScope(),
120                lang:     cfg.lang,
121                currency: cfg.currency,
122                portal:   1,
123                store:    cfg.storeCode,
124                rate:     1,
125            });
126
127            return `${cfg.apiBaseUrl}/relatedtags/${cfg.instanceId}?${params}`;
128        };
129
130        const buildNextQueriesUrl = (searchQuery) => {
131            const params = new URLSearchParams({
132                internal:              'true',
133                query:                 searchQuery,
134                instance:              cfg.instanceId,
135                scope:                 getScope(),
136                lang:                  cfg.lang,
137                currency:              cfg.currency,
138                portal:                1,
139                store:                 cfg.storeCode,
140                rate:                  1,
141                relatedPromptsEnabled: 'true',
142            });
143
144            return `${cfg.apiBaseUrl}/nextqueries/${cfg.instanceId}?${params}`;
145        };
146
147        const cachedFetch = async (cacheKey, url, { signal } = {}) => {
148            const cached = cacheGet(cacheKey);
149            if (cached !== null) return cached;
150            const response = await fetch(url, { credentials: 'omit', signal });
151            if (!response.ok) throw new Error(`HTTP ${response.status}`);
152            const data = await response.json();
153            cacheSet(cacheKey, data);
154            return data;
155        };
156
157        return {
158            // ── State ──────────────────────────────────────────────────────
159            facetMap: FACET_MAP,
160            query: cfg.currentQuery || '',
161            isOpen: false,
162            isLoading: false,
163            isBlocked: false,
164            isNoResults: false,
165            noResultsMessage: cfg.noResultsMessage || '',
166            blockedMessage: cfg.blockedMessage || '',
167            banner: null,
168            suggestions: [],   // empathize autocomplete → dropdown below input
169            relatedTags: [],   // related tags from dedicated endpoint → pills
170            nextQueries: [],   // next queries from search response → suggestions post-results
171            aiCarousels: [],   // no-results: one product carousel per AI next query
172            aiCarouselsLoading: false, // true while the AI carousels are being fetched (skeleton)
173            products: [],
174            totalResults: 0,
175            activeIndex: -1,
176            sessionId: null,
177            hasQuery: false,
178            facets: [],
179            selectedFacets: {},
180            openFacets: {},
181            isMobileFiltersOpen: false,
182            chipLimit: 6,              // chips shown per block before the "see more" toggle, set in open()
183            relatedTagsExpanded: false,
184            nextQueriesExpanded: false,
185            isTyping: false,   // overlay input in use → mobile shows the suggestion list instead of the secondary blocks
186            _searchAbort: null,
187            _empathizeAbort: null,
188            _relatedTagsAbort: null,
189            _nextQueriesAbort: null,
190            _aiCarouselAbort: null,
191            _preheated: false,
192            _aiCarouselToken: 0,
193
194            init() {
195                this.sessionId = this.generateSessionId();
196                if (this.query) this.hasQuery = true;
197
198                // Clears error messages on the first keypress,
199                // without waiting for the 600ms debounce.
200                this.$watch('query', () => {
201                    if (this.isBlocked || this.isNoResults) {
202                        this.isBlocked = false;
203                        this.isNoResults = false;
204                    }
205                });
206
207                // Deferred preheating of the topclicked cache:
208                // wait until the page is fully loaded AND the
209                // browser is idle (outside the critical path), then
210                // trigger the fetch as a background task. The overlay will then
211                // have the trends already cached on first open.
212                const schedule = () => {
213                    if ('requestIdleCallback' in window) {
214                        requestIdleCallback(() => this.preheat(), { timeout: 4000 });
215                    } else {
216                        // Safari < 16.4: fallback via setTimeout after load
217                        setTimeout(() => this.preheat(), 1500);
218                    }
219                };
220                if (document.readyState === 'complete') {
221                    schedule();
222                } else {
223                    window.addEventListener('load', schedule, { once: true });
224                }
225            },
226
227            // Preheating: loads topclicked in the background so that
228            // the overlay has it cached when opened.
229            // Idempotent — can be called multiple times without duplicates.
230            preheat() {
231                if (this._preheated || !cfg.instanceId) return;
232                this._preheated = true;
233
234                const scope = getScope();
235                const key = `topclicked|${scope}|${cfg.storeCode}|${cfg.lang}|${cfg.currency}|${cfg.rows}`;
236
237                if (browserCacheGet(key) !== null) {
238                    return;
239                }
240
241                fetch(buildEmpathyUrl('topclicked', { rows: cfg.rows }), { credentials: 'omit' })
242                    .then(r => r.ok ? r.json() : null)
243                    .then(d => {
244                        if (d) {
245                            browserCacheSet(key, d);
246                        }
247                    })
248                    .catch(() => {});
249            },
250
251            open() {
252                this.isOpen = true;
253                // About two rows of chips on either layout.
254                this.chipLimit = getScope() === 'mobile' ? 6 : 12;
255                document.body.classList.add('overflow-hidden');
256
257                this.$nextTick(() => {
258                    if (this.$refs.overlayInput) {
259                        this.$refs.overlayInput.focus();
260
261                        if (this.query.length > cfg.minSearchLength) {
262                            this.fetchEmpathize();
263                            this.fetchSearch();
264                        } else {
265                            this.fetchTopClicked();
266                        }
267                    }
268                });
269            },
270
271            close() {
272                if (this._searchAbort) {
273                    this._searchAbort.abort();
274                    this._searchAbort = null;
275                }
276
277                if (this._empathizeAbort) {
278                    this._empathizeAbort.abort();
279                    this._empathizeAbort = null;
280                }
281
282                if (this._relatedTagsAbort) {
283                    this._relatedTagsAbort.abort();
284                    this._relatedTagsAbort = null;
285                }
286
287                if (this._nextQueriesAbort) {
288                    this._nextQueriesAbort.abort();
289                    this._nextQueriesAbort = null;
290                }
291
292                if (this._aiCarouselAbort) {
293                    this._aiCarouselAbort.abort();
294                    this._aiCarouselAbort = null;
295                }
296
297                this.isOpen = false;
298                document.body.classList.remove('overflow-hidden');
299                this.activeIndex = -1;
300                this.query = '';
301                this.hasQuery = false;
302                this.isBlocked = false;
303                this.isNoResults = false;
304                this.banner = null;
305                this.suggestions = [];
306                this.relatedTags = [];
307                this.nextQueries = [];
308                this.aiCarousels = [];
309                this.aiCarouselsLoading = false;
310                this.facets = [];
311                this.totalResults = 0;
312                this.isLoading = false;
313                this.isMobileFiltersOpen = false;
314                this.isTyping = false;
315            },
316
317            onInput() {
318                const query = this.normalizedQuery();
319
320                this.activeIndex = -1;
321                this.hasQuery = query.length > 0;
322                this.selectedFacets = {};
323
324                if (query.length <= cfg.minSearchLength) {
325                    this.suggestions = [];
326                    this.relatedTags = [];
327                    this.nextQueries = [];
328                    this.aiCarousels = [];
329                    this.aiCarouselsLoading = false;
330                    this.totalResults = 0;
331                    this.facets = [];
332                    this.banner = null;
333                    this.isBlocked = false;
334                    this.isNoResults = false;
335
336                    if (this._searchAbort) {
337                        this._searchAbort.abort();
338                        this._searchAbort = null;
339                    }
340
341                    if (this._empathizeAbort) {
342                        this._empathizeAbort.abort();
343                        this._empathizeAbort = null;
344                    }
345
346                    if (this._relatedTagsAbort) {
347                        this._relatedTagsAbort.abort();
348                        this._relatedTagsAbort = null;
349                    }
350
351                    if (this._nextQueriesAbort) {
352                        this._nextQueriesAbort.abort();
353                        this._nextQueriesAbort = null;
354                    }
355
356                    if (this._aiCarouselAbort) {
357                        this._aiCarouselAbort.abort();
358                        this._aiCarouselAbort = null;
359                    }
360
361                    this.isLoading = false;
362
363                    if (this.products.length === 0) {
364                        this.fetchTopClicked();
365                    }
366
367                    return;
368                }
369
370                // Check blocked words before any API call
371                if (this.isQueryBlocked(query)) {
372                    this.isBlocked = true;
373                    this.isNoResults = false;
374                    this.suggestions = [];
375                    this.products = [];
376                    this.aiCarousels = [];
377                    this.aiCarouselsLoading = false;
378                    this.facets = [];
379                    this.banner = null;
380                    this.totalResults = 0;
381                    this.isLoading = false;
382
383                    if (this._searchAbort) { this._searchAbort.abort(); this._searchAbort = null; }
384                    if (this._empathizeAbort) { this._empathizeAbort.abort(); this._empathizeAbort = null; }
385                    if (this._relatedTagsAbort) { this._relatedTagsAbort.abort(); this._relatedTagsAbort = null; }
386                    if (this._nextQueriesAbort) { this._nextQueriesAbort.abort(); this._nextQueriesAbort = null; }
387                    if (this._aiCarouselAbort) { this._aiCarouselAbort.abort(); this._aiCarouselAbort = null; }
388
389                    this.fetchTopClicked();
390                    return;
391                }
392
393                this.isBlocked = false;
394                this.isNoResults = false;
395                this.aiCarousels = [];
396                this.aiCarouselsLoading = false;
397                this.fetchEmpathize(query);
398                this.fetchRelatedTags(query);
399                this.fetchNextQueries(query);
400                this.fetchSearch(query);
401            },
402
403            navigateDown() {
404                const total = this.suggestions.length;
405                this.activeIndex = (this.activeIndex + 1) % (total || 1);
406            },
407            navigateUp() {
408                const total = this.suggestions.length;
409                this.activeIndex = this.activeIndex <= 0 ? total - 1 : this.activeIndex - 1;
410            },
411            selectActive() {
412                if (this.activeIndex >= 0 && this.suggestions[this.activeIndex]) {
413                    const title = this.suggestions[this.activeIndex].title_raw;
414                    this.suggestions = [];
415                    this.goToSuggestion(title);
416                    return;
417                }
418
419                this.commitSearch();
420            },
421
422            // Mobile only: true while the in-flow suggestion list replaces the secondary blocks.
423            get showMobileSuggestions() {
424                return this.isTyping && this.suggestions.length > 0;
425            },
426
427            // A block is collapsible when it would hide at least two chips,
428            // so the toggle never takes the place of the single chip it hides.
429            isChipListCollapsible(total) {
430                return total > this.chipLimit + 1;
431            },
432            isChipCollapsed(total, index, expanded) {
433                return !expanded && this.isChipListCollapsible(total) && index >= this.chipLimit;
434            },
435
436            // Leaves the "typing" state: the user is now browsing the results.
437            // On mobile the input is blurred so the keyboard stops covering them.
438            commitSearch() {
439                if (!this.isTyping) return;
440
441                this.isTyping = false;
442                this.activeIndex = -1;
443
444                if (getScope() === 'mobile') {
445                    // Suggestions are kept: focusing the input again brings the list back.
446                    if (this.$refs.overlayInput) {
447                        this.$refs.overlayInput.blur();
448                    }
449                } else {
450                    // Desktop: Enter closes the floating dropdown.
451                    this.suggestions = [];
452                }
453            },
454
455            // ── Empathy.co API (DIRECT CALLS, with cache) ──────────────
456            async fetchEmpathize(searchQuery = this.normalizedQuery()) {
457                if (!cfg.instanceId || searchQuery.length <= cfg.minSearchLength) return;
458                if (this.isQueryBlocked(searchQuery)) return;
459
460                if (this._empathizeAbort) {
461                    this._empathizeAbort.abort();
462                }
463
464                const controller = new AbortController();
465                this._empathizeAbort = controller;
466                const url = buildEmpathizeUrl(searchQuery);
467
468                try {
469                    const response = await fetch(url, {
470                        credentials: 'omit',
471                        signal: controller.signal,
472                    });
473
474                    if (!response.ok) {
475                        throw new Error(`HTTP ${response.status}`);
476                    }
477
478                    const data = await response.json();
479
480                    if (this.normalizedQuery() !== searchQuery) {
481                        return;
482                    }
483
484                    this.suggestions = data?.topTrends?.content || [];
485                } catch (e) {
486                    if (e.name !== 'AbortError') {
487                        this.suggestions = [];
488                    }
489                }
490
491                if (this._empathizeAbort === controller) {
492                    this._empathizeAbort = null;
493                }
494            },
495
496            async fetchRelatedTags(searchQuery = this.normalizedQuery()) {
497                if (!cfg.instanceId || searchQuery.length <= cfg.minSearchLength) return;
498                if (this.isQueryBlocked(searchQuery)) return;
499
500                if (this._relatedTagsAbort) {
501                    this._relatedTagsAbort.abort();
502                }
503
504                const controller = new AbortController();
505                this._relatedTagsAbort = controller;
506                const url = buildRelatedTagsUrl(searchQuery);
507
508                try {
509                    const response = await fetch(url, {
510                        credentials: 'omit',
511                        signal: controller.signal,
512                    });
513
514                    if (!response.ok) {
515                        throw new Error(`HTTP ${response.status}`);
516                    }
517
518                    const data = await response.json();
519
520                    if (this.normalizedQuery() !== searchQuery) {
521                        return;
522                    }
523
524                    this.relatedTags = data?.data?.relatedtags || [];
525                    this.relatedTagsExpanded = false;
526                } catch (e) {
527                    if (e.name !== 'AbortError') {
528                        this.relatedTags = [];
529                    }
530                }
531
532                if (this._relatedTagsAbort === controller) {
533                    this._relatedTagsAbort = null;
534                }
535            },
536
537            async fetchNextQueries(searchQuery = this.normalizedQuery()) {
538                if (!cfg.instanceId || searchQuery.length <= cfg.minSearchLength) return;
539                if (this.isQueryBlocked(searchQuery)) return;
540
541                if (this._nextQueriesAbort) {
542                    this._nextQueriesAbort.abort();
543                }
544
545                const controller = new AbortController();
546                this._nextQueriesAbort = controller;
547                const url = buildNextQueriesUrl(searchQuery);
548
549                try {
550                    const response = await fetch(url, {
551                        credentials: 'omit',
552                        signal: controller.signal,
553                    });
554
555                    if (!response.ok) {
556                        throw new Error(`HTTP ${response.status}`);
557                    }
558
559                    const data = await response.json();
560
561                    if (this.normalizedQuery() !== searchQuery) {
562                        return;
563                    }
564
565                    this.nextQueries = data?.data?.nextqueries || [];
566                    this.nextQueriesExpanded = false;
567                } catch (e) {
568                    if (e.name !== 'AbortError') {
569                        this.nextQueries = [];
570                    }
571                }
572
573                if (this._nextQueriesAbort === controller) {
574                    this._nextQueriesAbort = null;
575                }
576            },
577
578            async fetchSearch(searchQuery = this.normalizedQuery()) {
579                if (!cfg.instanceId || searchQuery.length <= cfg.minSearchLength) return;
580                if (this.isQueryBlocked(searchQuery)) return;
581
582                // Cancel the previous request if still in flight
583                if (this._searchAbort) this._searchAbort.abort();
584                const controller = new AbortController();
585                this._searchAbort = controller;
586
587                const scope = getScope();
588                const filters = this.buildFilters();
589                const key = `search|${scope}|${searchQuery}|${cfg.rows}|${filters.join(',')}`;
590                const url = new URL(buildEmpathyUrl('search', {
591                    query: searchQuery,
592                    start: 0,
593                    rows:  cfg.rows,
594                }));
595                filters.forEach(f => url.searchParams.append('filter', f));
596
597                // Cache hit → synchronous update, no loader
598                const cached = cacheGet(key);
599                if (cached) {
600                    if (this.normalizedQuery() !== searchQuery) {
601                        if (this._searchAbort === controller) {
602                            this._searchAbort = null;
603                        }
604                        return;
605                    }
606
607                    this.applySearchData(cached, searchQuery);
608
609                    if (this._searchAbort === controller) {
610                        this._searchAbort = null;
611                    }
612
613                    return;
614                }
615
616                this.isLoading = true;
617                try {
618                    const data = await cachedFetch(key, url.toString(), { signal: controller.signal });
619
620                    if (this.normalizedQuery() !== searchQuery) {
621                        return;
622                    }
623
624                    this.applySearchData(data, searchQuery);
625                } catch (e) {
626                    if (e.name !== 'AbortError') {
627                        this.products = [];
628                        this.totalResults = 0;
629                    }
630                }
631
632                if (this._searchAbort === controller) {
633                    this._searchAbort = null;
634                    this.isLoading = false;
635                }
636            },
637
638            // Applies a 'search' response and switches to topclicked if 0 results
639            // (instead of showing the empty state 'No results') to always
640            // offer products — useful when the user clears their query
641            // down to a too-short or ambiguous prefix (e.g. 'nik').
642            applySearchData(data, searchQuery = this.normalizedQuery()) {
643                if (this.normalizedQuery() !== searchQuery || searchQuery.length <= cfg.minSearchLength) {
644                    return;
645                }
646
647                const products = data?.catalog?.content || [];
648                const total = data?.catalog?.numFound || 0;
649                this.facets = data?.catalog?.facets || [];
650                this.totalResults = total;
651
652                // Banner — data.banner.content est un tableau d'objets banner
653                const bannerContent = data?.banner?.content;
654                const b = Array.isArray(bannerContent) && bannerContent.length > 0 ? bannerContent[0] : null;
655                this.banner = (b && b.image_url) ? b : null;
656
657                this.trackQuery(searchQuery, total);
658
659                if (total === 0) {
660                    this.isNoResults = true;
661                    this.facets = [];
662                    this.banner = null;
663                    this.products = [];
664                } else {
665                    this.isNoResults = false;
666                    this.products = products;
667                }
668
669                // AI Carousels (Empathy AI Carousel API): zero-results OR
670                // low-results fallback. When the total number of matching
671                // products is below the configured threshold, surface
672                // semantically related product carousels.
673                if (total < cfg.aiCarouselThreshold) {
674                    this.fetchAiCarousels(searchQuery, total);
675                    // For a strict zero-results, also preload trends so the
676                    // overlay is never empty if the AI carousels come back blank.
677                    if (total === 0) {
678                        this.fetchTopClicked();
679                    }
680                } else {
681                    this.aiCarousels = [];
682                    this.aiCarouselsLoading = false;
683                    if (this._aiCarouselAbort) {
684                        this._aiCarouselAbort.abort();
685                        this._aiCarouselAbort = null;
686                    }
687                }
688            },
689
690            async fetchTopClicked() {
691                if (!cfg.instanceId) return;
692
693                const scope = getScope();
694                const key = `topclicked|${scope}|${cfg.storeCode}|${cfg.lang}|${cfg.currency}|${cfg.rows}`;
695                const cached = browserCacheGet(key);
696
697                if (cached !== null) {
698                    this.products = cached?.topclicked?.content || [];
699                    this.totalResults = 0;
700                    return;
701                }
702
703                const url = buildEmpathyUrl('topclicked', { rows: cfg.rows });
704
705                try {
706                    const response = await fetch(url, { credentials: 'omit' });
707
708                    if (!response.ok) {
709                        throw new Error(`HTTP ${response.status}`);
710                    }
711
712                    const data = await response.json();
713
714                    browserCacheSet(key, data);
715
716                    this.products = data?.topclicked?.content || [];
717                    this.totalResults = 0;
718                } catch (e) {
719                    this.products = [];
720                }
721            },
722
723            // ── AI Carousels (Empathy AI Carousel API) ─────────────────
724            // Zero / low-results fallback. Two-step flow:
725            //   1. POST /v1/suggestions/{instance} (SSE) → AI-generated
726            //      related queries (queries_data event).
727            //   2. POST /v1/suggestions/{instance}/search → one carousel per
728            //      related query, each with its own grouped product results.
729            // Race-safe via a monotonic token + a shared AbortController.
730            async fetchAiCarousels(searchQuery = this.normalizedQuery(), total = this.totalResults) {
731                if (!cfg.instanceId || !cfg.apiBaseUrl) return;
732
733                if (this._aiCarouselAbort) {
734                    this._aiCarouselAbort.abort();
735                }
736                const controller = new AbortController();
737                this._aiCarouselAbort = controller;
738                const token = ++this._aiCarouselToken;
739                this.aiCarouselsLoading = true;
740
741                const origin = total === 0 ? 'ai_carousel:no_results' : 'ai_carousel:low_results';
742                const filters = {
743                    scope:  getScope(),
744                    store:  cfg.storeCode,
745                    rate:   '1',
746                    portal: '1',
747                    origin,
748                };
749
750                try {
751                    // Step 1 — AI-generated related queries (SSE stream)
752                    const sugResponse = await fetch(`${cfg.apiBaseUrl}/v1/suggestions/${cfg.instanceId}`, {
753                        method:      'POST',
754                        credentials: 'omit',
755                        signal:      controller.signal,
756                        headers: {
757                            'content-type': 'application/json',
758                            'accept':       'text/event-stream',
759                        },
760                        body: JSON.stringify({
761                            internal: true,
762                            context: {
763                                query:    searchQuery,
764                                lang:     cfg.lang,
765                                instance: cfg.instanceId,
766                                filters,
767                            },
768                        }),
769                    });
770
771                    if (!sugResponse.ok || !sugResponse.body) {
772                        throw new Error(`HTTP ${sugResponse.status}`);
773                    }
774
775                    const queries = await this.readSuggestionQueries(sugResponse);
776
777                    if (token !== this._aiCarouselToken || this.normalizedQuery() !== searchQuery) return;
778
779                    if (!Array.isArray(queries) || queries.length === 0) {
780                        this.aiCarousels = [];
781                        return;
782                    }
783
784                    // Step 2 — product results grouped per related query
785                    const excludeIds = (this.products || [])
786                        .map(p => p && p.id)
787                        .filter(Boolean);
788
789                    const searchResponse = await fetch(`${cfg.apiBaseUrl}/v1/suggestions/${cfg.instanceId}/search`, {
790                        method:      'POST',
791                        credentials: 'omit',
792                        signal:      controller.signal,
793                        headers: { 'content-type': 'application/json' },
794                        body: JSON.stringify({
795                            internal: true,
796                            context: {
797                                lang:     cfg.lang,
798                                instance: cfg.instanceId,
799                                filters,
800                            },
801                            queries,
802                            excludeOptions: { resultIds: excludeIds },
803                        }),
804                    });
805
806                    if (!searchResponse.ok) {
807                        throw new Error(`HTTP ${searchResponse.status}`);
808                    }
809
810                    const data = await searchResponse.json();
811
812                    if (token !== this._aiCarouselToken || this.normalizedQuery() !== searchQuery) return;
813
814                    this.aiCarousels = (data?.items || [])
815                        .filter(item => item && Array.isArray(item.results) && item.results.length > 0)
816                        .slice(0, AI_CAROUSEL_MAX)
817                        .map(item => ({ query: item.query, products: item.results }));
818                } catch (e) {
819                    if (e.name !== 'AbortError') {
820                        this.aiCarousels = [];
821                    }
822                } finally {
823                    // Only the most recent request clears the loading state, so a
824                    // newer in-flight fetch keeps its skeleton visible.
825                    if (token === this._aiCarouselToken) {
826                        this.aiCarouselsLoading = false;
827                    }
828                    if (this._aiCarouselAbort === controller) {
829                        this._aiCarouselAbort = null;
830                    }
831                }
832            },
833
834            // Reads the suggestions SSE stream and resolves with the queries
835            // carried by the first `queries_data` event, then closes the stream.
836            async readSuggestionQueries(response) {
837                const reader = response.body.getReader();
838                const decoder = new TextDecoder();
839                let buffer = '';
840
841                try {
842                    while (true) {
843                        const { done, value } = await reader.read();
844                        if (done) break;
845
846                        buffer += decoder.decode(value, { stream: true });
847                        const chunks = buffer.split('\n\n');
848                        buffer = chunks.pop();
849
850                        for (const chunk of chunks) {
851                            let event = null;
852                            let dataStr = '';
853                            for (const line of chunk.split('\n')) {
854                                if (line.startsWith('event:')) {
855                                    event = line.slice(6).trim();
856                                } else if (line.startsWith('data:')) {
857                                    dataStr += line.slice(5).trim();
858                                }
859                            }
860
861                            if (event === 'queries_data' && dataStr) {
862                                reader.cancel().catch(() => {});
863                                try {
864                                    return JSON.parse(dataStr).queries || [];
865                                } catch (e) {
866                                    return [];
867                                }
868                            }
869                        }
870                    }
871                } catch (e) {
872                    // Stream aborted or network error → no queries.
873                }
874
875                return [];
876            },
877
878            // ── Tracking (always direct, no cache) ─────────────────
879            trackQuery(query, totalHits) {
880                if (!cfg.instanceId || query.length <= cfg.minSearchLength) return;
881
882                const params = new URLSearchParams({
883                    instanceId: cfg.instanceId,
884                    q:         query,
885                    lang:      cfg.lang,
886                    store:     cfg.storeCode,
887                    totalHits: totalHits,
888                    page:      1,
889                    session:   this.sessionId,
890                    scope:     getScope(),
891                    follow:    'false',
892                });
893                fetch(
894                    `${cfg.apiBaseUrl}/tagging/v1/track/${cfg.instanceId}/query?${params}`,
895                    { keepalive: true, credentials: 'omit' }
896                ).catch(() => {});
897            },
898
899            trackClick(product, position) {
900                if (!cfg.instanceId || !product?.id) return;
901
902                // Priority 1: use the pre-built tagging.click URL provided by Empathy
903                // (works for both search results and topclicked products)
904                if (product?.tagging?.click) {
905                    const clickUrl = new URL(product.tagging.click);
906                    // Add title if missing (required by Empathy but absent from pre-built URL)
907                    if (product.name && !clickUrl.searchParams.get('title')) {
908                        clickUrl.searchParams.set('title', product.name);
909                    }
910                    if (this.sessionId && !clickUrl.searchParams.get('session')) {
911                        clickUrl.searchParams.set('session', this.sessionId);
912                    }
913                    // store = axe de segmentation par marché, absent des URLs pré-construites
914                    if (cfg.storeCode && !clickUrl.searchParams.get('store')) {
915                        clickUrl.searchParams.set('store', cfg.storeCode);
916                    }
917                    fetch(clickUrl.toString(), { keepalive: true, credentials: 'omit' }).catch(() => {});
918                    return;
919                }
920
921                // Fallback: build the URL manually (only if there is an active search query)
922                if (!this.query || this.query.trim().length <= cfg.minSearchLength) return;
923
924                const params = new URLSearchParams({
925                    instanceId: cfg.instanceId,
926                    q:          this.query,
927                    lang:       cfg.lang,
928                    store:      cfg.storeCode,
929                    title:      product.name || '',
930                    url:        product.url  || '',
931                    productId:  product.id,
932                    position:   position + 1,
933                    page:      1,
934                    session:   this.sessionId,
935                    scope:     getScope(),
936                    follow:    'false',
937                });
938                fetch(
939                    `${cfg.apiBaseUrl}/tagging/v1/track/${cfg.instanceId}/click?${params}`,
940                    { keepalive: true, credentials: 'omit' }
941                ).catch(() => {});
942            },
943
944            // ── Banner tracking ────────────────────────────────────────
945            trackBannerDisplay(banner) {
946                if (!banner?.tagging?.displayClick) return;
947                try {
948                    const url = new URL(banner.tagging.displayClick);
949                    if (this.sessionId && !url.searchParams.get('session')) {
950                        url.searchParams.set('session', this.sessionId);
951                    }
952                    // displayId requis par l'API Empathy Tagging (UUID unique par impression)
953                    if (!url.searchParams.get('displayId')) {
954                        url.searchParams.set('displayId', this.generateSessionId());
955                    }
956                    // displayFamily requis par l'API Empathy Tagging (type de l'élément affiché)
957                    if (!url.searchParams.get('displayFamily')) {
958                        url.searchParams.set('displayFamily', banner.type || 'BANNER');
959                    }
960                    if (cfg.storeCode && !url.searchParams.get('store')) {
961                        url.searchParams.set('store', cfg.storeCode);
962                    }
963                    fetch(url.toString(), { keepalive: true, credentials: 'omit' }).catch(() => {});
964                } catch (e) {}
965            },
966
967            trackBannerClick(banner) {
968                if (!banner?.tagging?.click) return;
969                try {
970                    const url = new URL(banner.tagging.click);
971                    if (this.sessionId && !url.searchParams.get('session')) {
972                        url.searchParams.set('session', this.sessionId);
973                    }
974                    if (cfg.storeCode && !url.searchParams.get('store')) {
975                        url.searchParams.set('store', cfg.storeCode);
976                    }
977                    fetch(url.toString(), { keepalive: true, credentials: 'omit' }).catch(() => {});
978                } catch (e) {}
979            },
980
981            // ── Navigation ────────────────────────────────────────────────
982            goToProduct(product, position) {
983                this.trackClick(product, position);
984
985                // Stocke l'URL add2cart dans un dictionnaire itemGroupId → URLs
986                // afin de couvrir le cas où l'utilisateur consulte plusieurs
987                // produits avant d'en ajouter un au panier.
988                if (product?.tagging?.add2cart && product?.itemGroupId) {
989                    try {
990                        const raw = localStorage.getItem('empathy_tagging_map');
991                        const stored = raw ? JSON.parse(raw) : {};
992                        const now = Date.now();
993
994                        // Purge les entrées expirées (TTL 30 min)
995                        for (const key of Object.keys(stored)) {
996                            if (stored[key].expiresAt && now > stored[key].expiresAt) {
997                                delete stored[key];
998                            }
999                        }
1000
1001                        // Clé = itemGroupId (correspond à $block->getProduct()->getId() côté Magento)
1002                        stored[product.itemGroupId] = {
1003                            add2cartUrl: product.tagging.add2cart,
1004                            checkoutUrl: product.tagging.checkout || null,
1005                            title:       product.name || '',
1006                            sessionId:   this.sessionId,
1007                            storeCode:   cfg.storeCode,
1008                            expiresAt:   now + 30 * 60 * 1000, // 30 minutes
1009                        };
1010
1011                        // Limite à 20 entrées
1012                        const keys = Object.keys(stored);
1013                        if (keys.length > 20) {
1014                            delete stored[keys[0]];
1015                        }
1016
1017                        localStorage.setItem('empathy_tagging_map', JSON.stringify(stored));
1018                    } catch (e) {}
1019                }
1020
1021                window.location.href = product.url;
1022            },
1023
1024            goToSuggestion(term) {
1025                this.query = term;
1026                this.activeIndex = -1;
1027                this.selectedFacets = {};
1028                // No fetchEmpathize() here: it reopened the suggestions over the results just picked.
1029                this.suggestions = [];
1030                this.commitSearch();
1031                this.fetchSearch(term);
1032                this.fetchRelatedTags(term);
1033                this.fetchNextQueries(term);
1034            },
1035
1036            submitSearch(term) {
1037                const q = term || this.query;
1038                if (q.length > cfg.minSearchLength) {
1039                    window.location.href = cfg.searchUrl + '?' + new URLSearchParams({ q });
1040                }
1041            },
1042
1043            formatPrice(price) {
1044                if (price == null) return '';
1045                return new Intl.NumberFormat(cfg.locale || 'fr-FR', {
1046                    style:    'currency',
1047                    currency: cfg.currency || 'EUR',
1048                }).format(price);
1049            },
1050
1051            isOnSale(product) {
1052                return product.price && product.price > product.salePrice;
1053            },
1054
1055            // ── Facets ──────────────────────────────────────────────────
1056            getFacetLabel(facetField) {
1057                return FACET_MAP[facetField]?.label || facetField;
1058            },
1059            getFacetFilterField(facetField) {
1060                return FACET_MAP[facetField]?.filterField || facetField;
1061            },
1062            buildFilters() {
1063                const parts = [];
1064                for (const [filterField, values] of Object.entries(this.selectedFacets)) {
1065                    (values || []).forEach(v => parts.push(`${filterField}:${v}`));
1066                }
1067                return parts;
1068            },
1069            toggleFacet(facetField, value) {
1070                const key = this.getFacetFilterField(facetField);
1071                if (!this.selectedFacets[key]) {
1072                    this.selectedFacets[key] = [];
1073                }
1074                const idx = this.selectedFacets[key].indexOf(value);
1075                if (idx >= 0) {
1076                    this.selectedFacets[key].splice(idx, 1);
1077                    if (this.selectedFacets[key].length === 0) {
1078                        delete this.selectedFacets[key];
1079                    }
1080                } else {
1081                    this.selectedFacets[key].push(value);
1082                }
1083                this.fetchSearch();
1084            },
1085            isFacetSelected(facetField, value) {
1086                const key = this.getFacetFilterField(facetField);
1087                return (this.selectedFacets[key] || []).includes(value);
1088            },
1089            clearFacets() {
1090                this.selectedFacets = {};
1091                this.fetchSearch();
1092            },
1093            activeFiltersCount() {
1094                return Object.values(this.selectedFacets).reduce((sum, v) => sum + v.length, 0);
1095            },
1096            toggleFacetOpen(facetId) {
1097                this.openFacets = { ...this.openFacets, [facetId]: !this.isFacetOpen(facetId) };
1098            },
1099            isFacetOpen(facetId) {
1100                if (this.openFacets[facetId] !== undefined) {
1101                    return this.openFacets[facetId];
1102                }
1103                return window.innerWidth >= 1024;
1104            },
1105
1106            // ── Utilities ───────────────────────────────────────────────
1107            normalizedQuery() {
1108                // Lowercase the query to match Empathy's normalized (lowercased) keyword fields.
1109                // This ensures references like "SE0047-001" match the stored "se0047-001" keyword,
1110                // especially when useCompleteWord:true triggers term (non-analyzed) queries.
1111                return this.query.trim().toLowerCase();
1112            },
1113
1114            /**
1115             * Replaces %1 in a message template with the term in bold.
1116             * The query is escaped to prevent any HTML injection.
1117             */
1118            formatMessage(template, term = this.query) {
1119                const escaped = term
1120                    .replace(/&/g, '&amp;')
1121                    .replace(/</g, '&lt;')
1122                    .replace(/>/g, '&gt;')
1123                    .replace(/"/g, '&quot;');
1124                return template.replace('%1', `<strong>${escaped}</strong>`);
1125            },
1126
1127            /**
1128             * Checks whether the query contains a word/expression from the block list.
1129             * Case-insensitive comparison, on whole words only:
1130             * 'as' does NOT block 'basket' or 'classe', but blocks 'as cannes'.
1131             * Supports French accented characters (é, è, à, ç…).
1132             */
1133            isQueryBlocked(query = this.normalizedQuery()) {
1134                if (!cfg.blockedWords || cfg.blockedWords.length === 0) return false;
1135                const lowerQuery = query.toLowerCase();
1136                // Letters: a-z + Unicode range covering Latin accents (U+00C0–U+00FF)
1137                const L = 'a-zA-Z\u00C0-\u00FF0-9';
1138                return cfg.blockedWords.some(word => {
1139                    if (!word) return false;
1140                    const escaped = word.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1141                    // The term must be preceded and followed by a non-alphanumeric character
1142                    // (or be at the start/end of the string)
1143                    const regex = new RegExp('(^|[^' + L + '])' + escaped + '($|[^' + L + '])', 'i');
1144                    return regex.test(lowerQuery);
1145                });
1146            },
1147
1148            generateSessionId() {
1149                if (typeof crypto !== 'undefined' && crypto.randomUUID) {
1150                    return crypto.randomUUID();
1151                }
1152                return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
1153                    const r = (Math.random() * 16) | 0;
1154                    return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
1155                });
1156            },
1157        };
1158    }
1159
1160    document.addEventListener('alpine:init', () => {
1161        Alpine.data('empathySearch', empathySearch);
1162    });
1163})();

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.