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, '&') 1121 .replace(/</g, '<') 1122 .replace(/>/g, '>') 1123 .replace(/"/g, '"'); 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.