1/** 2 * Zephyr Search Modal Component 3 * 4 * Alpine.js component for search modal with autocomplete. 5 * Configuration is set from PHP template via searchModalConfig. 6 */ 7 8var searchModalConfig = { 9 minChars: 3, 10 searchUrl: '', 11 suggestUrl: '', 12 resultUrl: '', 13 formKey: '', 14 blogFirst: false 15}; 16 17function searchModal() { 18 return { 19 query: '', 20 typedQuery: '', 21 results: { 22 suggestions: [], 23 products: [], 24 categories: [], 25 blog: [] 26 }, 27 selectedIndex: -1, 28 minChars: searchModalConfig.minChars, 29 searchUrl: searchModalConfig.searchUrl, 30 suggestUrl: searchModalConfig.suggestUrl, 31 formKey: searchModalConfig.formKey, 32 33 _suggestTimer: null, 34 _productTimer: null, 35 _suggestAbort: null, 36 _productAbort: null, 37 _skipNextInput: false, 38 _cache: {}, 39 _cacheMaxAge: 5 * 60 * 1000, 40 _popularCacheMaxAge: 30 * 60 * 1000, 41 _popularPrefetched: false, 42 suggestedQueries: searchModalConfig.suggestedQueries || [], 43 // Blog pages list blog matches above the products (and first in keyboard order) 44 blogFirst: !!searchModalConfig.blogFirst, 45 _mobileMaxSuggestions: 3, 46 showSkeleton: false, 47 _skeletonShownAt: 0, 48 _skeletonMinMs: 400, 49 _skeletonHideTimer: null, 50 51 get isMobile() { 52 return window.innerWidth < 768; 53 }, 54 55 get visibleSuggestions() { 56 if (!this.results.suggestions) return []; 57 if (this.isMobile) return this.results.suggestions.slice(0, this._mobileMaxSuggestions); 58 return this.results.suggestions; 59 }, 60 61 get suggestionsCount() { 62 return this.visibleSuggestions.length; 63 }, 64 65 get productsCount() { 66 return this.results.products ? this.results.products.length : 0; 67 }, 68 69 get blogCount() { 70 return this.results.blog ? this.results.blog.length : 0; 71 }, 72 73 get totalItems() { 74 return this.suggestionsCount + this.productsCount + this.blogCount + 75 (this.results.categories ? this.results.categories.length : 0); 76 }, 77 78 // Keyboard order follows the visual order: suggestions, then products and blog 79 // posts in whichever order the page shows them 80 get totalNavigable() { 81 return this.suggestionsCount + this.productsCount + this.blogCount; 82 }, 83 84 get productsOffset() { 85 return this.suggestionsCount + (this.blogFirst ? this.blogCount : 0); 86 }, 87 88 get blogOffset() { 89 return this.suggestionsCount + (this.blogFirst ? 0 : this.productsCount); 90 }, 91 92 init() { 93 this._restoreState(); 94 95 var urlParams = new URLSearchParams(window.location.search); 96 var existingQuery = urlParams.get('q'); 97 if (existingQuery && window.location.pathname.indexOf('catalogsearch/result') !== -1) { 98 this.query = existingQuery; 99 } 100 101 this.$watch('$store.search.isLoading', (loading) => { 102 var self = this; 103 var hasProducts = this.results.products.length > 0; 104 105 if (loading && !hasProducts) { 106 // Show skeleton immediately 107 if (this._skeletonHideTimer) clearTimeout(this._skeletonHideTimer); 108 this.showSkeleton = true; 109 this._skeletonShownAt = Date.now(); 110 } else if (this.showSkeleton) { 111 // Enforce minimum display time before hiding 112 var elapsed = Date.now() - this._skeletonShownAt; 113 var remaining = this._skeletonMinMs - elapsed; 114 if (remaining > 0) { 115 this._skeletonHideTimer = setTimeout(function() { 116 self.showSkeleton = false; 117 }, remaining); 118 } else { 119 this.showSkeleton = false; 120 } 121 } 122 }); 123 124 this.$watch('$store.search.isOpen', (isOpen) => { 125 if (isOpen) { 126 this.$nextTick(() => { 127 if (this.$refs.searchInput) { 128 this.$refs.searchInput.focus(); 129 this.$refs.searchInput.select(); 130 } 131 }); 132 if (!this._popularPrefetched && this.suggestedQueries.length > 0) { 133 this._popularPrefetched = true; 134 var self = this;
135 this.suggestedQueries.forEach(function(q) { 136 self._prefetchPopular(q); 137 }); 138 } 139 } 140 }); 141 }, 142 143 _saveState() { 144 try { 145 sessionStorage.setItem('zephyr_search', JSON.stringify({ 146 query: this.query, 147 typedQuery: this.typedQuery, 148 results: this.results 149 })); 150 } catch (e) {} 151 }, 152 153 _restoreState() { 154 try { 155 var saved = sessionStorage.getItem('zephyr_search'); 156 if (!saved) return; 157 var state = JSON.parse(saved); 158 if (state.query) { 159 this.query = state.query; 160 this.typedQuery = state.typedQuery || state.query; 161 this.results = state.results || { suggestions: [], products: [], categories: [], blog: [] }; 162 if (!this.results.blog) this.results.blog = []; 163 this.$store.search.hasSearched = true; 164 } 165 } catch (e) {} 166 }, 167 168 _getCached(query, type) { 169 var key = type + ':' + query.toLowerCase(); 170 var entry = this._cache[key]; 171 if (entry && (Date.now() - entry.time) < this._cacheMaxAge) { 172 return entry.data; 173 } 174 return null; 175 }, 176 177 _setCache(query, type, data) { 178 var key = type + ':' + query.toLowerCase(); 179 this._cache[key] = { data: data, time: Date.now() }; 180 }, 181 182 onInput() { 183 if (this._skipNextInput) { 184 this._skipNextInput = false; 185 return; 186 } 187 var self = this; 188 var q = this.query; 189 190 if (this._suggestTimer) clearTimeout(this._suggestTimer); 191 if (this._productTimer) clearTimeout(this._productTimer); 192 193 if (q.length < this.minChars) { 194 this.results = { suggestions: [], products: [], categories: [], blog: [] }; 195 this.$store.search.hasSearched = false; 196 this.$store.search.isLoading = false; 197 try { sessionStorage.removeItem('zephyr_search'); } catch (e) {} 198 return; 199 } 200 201 this.typedQuery = q; 202 this.selectedIndex = -1; 203 this.$store.search.hasSearched = true; 204 205 var cachedSuggestions = this._getCached(q, 'suggest'); 206 var cachedProducts = this._getCached(q, 'products'); 207 if (cachedSuggestions && cachedProducts) { 208 this.results.suggestions = cachedSuggestions; 209 this.results.products = cachedProducts.products; 210 this.results.categories = cachedProducts.categories; 211 this.results.blog = cachedProducts.blog || []; 212 this._saveState(); 213 this.$store.search.isLoading = false; 214 return; 215 } 216 217 this.$store.search.isLoading = true; 218 219 if (cachedSuggestions) { 220 this.results.suggestions = cachedSuggestions; 221 } else { 222 this._suggestTimer = setTimeout(function() { 223 self._fetchSuggestions(q); 224 }, 150); 225 } 226 227 if (cachedProducts) { 228 this.results.products = cachedProducts.products; 229 this.results.categories = cachedProducts.categories; 230 this.results.blog = cachedProducts.blog || []; 231 if (cachedSuggestions) { 232 this.$store.search.isLoading = false; 233 } 234 } else { 235 this._productTimer = setTimeout(function() { 236 self._fetchProducts(q); 237 }, 300); 238 } 239 }, 240 241 _fetchSuggestions(q) { 242 var self = this; 243 244 if (this._suggestAbort) this._suggestAbort.abort(); 245 this._suggestAbort = new AbortController(); 246 247 fetch(this.suggestUrl + '?q=' + encodeURIComponent(q), { 248 headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-Form-Key': this.formKey }, 249 signal: this._suggestAbort.signal 250 }).then(function(res) { 251 if (!res.ok) return []; 252 return res.json(); 253 }).then(function(suggestions) { 254 var result = Array.isArray(suggestions) 255 ? suggestions.slice(0, 5) 256 : []; 257 self._setCache(q, 'suggest', result); 258 if (self.typedQuery === q) { 259 self.results.suggestions = result; 260 self._saveState(); 261 if (self.results.products.length > 0 || self._getCached(q, 'products')) { 262 self.$store.search.isLoading = false;
263 } 264 } 265 }).catch(function(err) { 266 if (err.name !== 'AbortError') console.error('Suggest error:', err); 267 }); 268 }, 269 270 _fetchProducts(q) { 271 var self = this; 272 273 if (this._productAbort) this._productAbort.abort(); 274 this._productAbort = new AbortController(); 275 276 fetch(this.searchUrl + '?q=' + encodeURIComponent(q), { 277 headers: { 'X-Requested-With': 'XMLHttpRequest' }, 278 signal: this._productAbort.signal 279 }).then(function(res) { return res.json(); }) 280 .then(function(data) { 281 if (data.success && data.items) { 282 var parsed = self.parseResults(data.items); 283 self._setCache(q, 'products', parsed); 284 if (self.typedQuery === q) { 285 self.results.products = parsed.products; 286 self.results.categories = parsed.categories; 287 self.results.blog = parsed.blog; 288 self._saveState(); 289 self.$store.search.isLoading = false; 290 } 291 } else { 292 if (self.typedQuery === q) { 293 self.$store.search.isLoading = false; 294 } 295 } 296 }).catch(function(err) { 297 if (err.name !== 'AbortError') { 298 console.error('Search error:', err); 299 if (self.typedQuery === q) { 300 self.$store.search.isLoading = false; 301 } 302 } 303 }); 304 }, 305 306 /** 307 * Scrape Mirasvit's autocomplete HTML into typed buckets. 308 * 309 * Zephyr's mst_searchautocomplete/autocomplete/result.phtml wraps each search index in 310 * <ul data-index="..."> so rows can be bucketed by index; rows outside such a group 311 * (vendor markup) fall back to the image/price heuristics. 312 */ 313 parseResults(html) { 314 var parser = new DOMParser(); 315 var doc = parser.parseFromString(html, 'text/html'); 316 var products = []; 317 var categories = []; 318 var blog = []; 319 320 doc.querySelectorAll('li[data-url]').forEach(function(li) { 321 var url = li.dataset.url; 322 var nameEl = li.querySelector('.name'); 323 var imgEl = li.querySelector('.searchautocomlete-image img, .autocomplete-image img, img'); 324 var descEl = li.querySelector('.autocomplete-short-description, .description, div.highlight'); 325 var priceEl = li.querySelector('.price-box, .price'); 326 var group = li.closest('ul[data-index]'); 327 var indexCode = group ? group.dataset.index : null; 328 329 var name = nameEl && nameEl.textContent ? nameEl.textContent.trim() : ''; 330 var image = imgEl && imgEl.src ? imgEl.src : ''; 331 var description = descEl && descEl.textContent ? descEl.textContent.trim() : ''; 332 var price = priceEl && priceEl.innerHTML ? priceEl.innerHTML : ''; 333 334 if (!url || !name) return; 335 336 if (indexCode === 'magpleasure_blog_post') { 337 blog.push({ url: url, name: name, image: image, description: description }); 338 } else if (indexCode === 'mage_catalog_product') { 339 products.push({ url: url, name: name, image: image, description: description, price: price }); 340 } else if (indexCode === 'mage_catalog_category') { 341 categories.push({ url: url, name: name }); 342 } else if (imgEl) { 343 products.push({ url: url, name: name, image: image, description: description, price: price }); 344 } else { 345 categories.push({ url: url, name: name }); 346 } 347 }); 348 349 return { products: products, categories: categories, blog: blog }; 350 }, 351 352 highlightMatch(text, query) { 353 if (!query) return this.escapeHtml(text); 354 var escaped = this.escapeHtml(text); 355 var words = this.escapeHtml(query).trim().split(/\s+/).filter(
355Boolean); 356 var pattern = words.map(function(w) { 357 return w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 358 }).join('|'); 359 var splitRegex = new RegExp('(' + pattern + ')', 'gi'); 360 var testRegex = new RegExp('^(?:' + pattern + ')$', 'i'); 361 var parts = escaped.split(splitRegex); 362 if (parts.length === 1) return '<span class="text-gray-400">' + escaped + '</span>'; 363 return parts.map(function(part) { 364 return testRegex.test(part) ? part : '<span class="text-gray-400">' + part + '</span>'; 365 }).join(''); 366 }, 367 368 escapeHtml(str) { 369 var div = document.createElement('div'); 370 div.textContent = str; 371 return div.innerHTML; 372 }, 373 374 selectNext() { 375 if (this.totalNavigable === 0) return; 376 if (this.selectedIndex < this.totalNavigable - 1) { 377 this.selectedIndex++; 378 } else { 379 this.selectedIndex = 0; 380 } 381 this.copySelectionToInput(); 382 }, 383 384 selectPrev() { 385 if (this.totalNavigable === 0) return; 386 if (this.selectedIndex > 0) { 387 this.selectedIndex--; 388 } else if (this.selectedIndex === 0) { 389 this.selectedIndex = -1; 390 this.query = this.typedQuery; 391 return; 392 } else { 393 this.selectedIndex = this.totalNavigable - 1; 394 } 395 this.copySelectionToInput(); 396 }, 397 398 copySelectionToInput() { 399 if (this.selectedIndex < 0) return; 400 401 this._skipNextInput = true; 402 var item = this.navigableItem(this.selectedIndex); 403 if (item) { 404 this.query = item.text || item.name; 405 } 406 this.scrollSelectionIntoView(); 407 }, 408 409 /** 410 * Keep the keyboard-selected row visible; the results pane scrolls and the blog 411 * rows sit below the products. 412 */ 413 scrollSelectionIntoView() { 414 var pane = this.$refs.resultsPane; 415 if (!pane) return; 416 this.$nextTick(function() { 417 var el = pane.querySelector('.bg-blue-50'); 418 if (el && typeof el.scrollIntoView === 'function') { 419 el.scrollIntoView({ block: 'nearest' }); 420 } 421 }); 422 }, 423 424 /** 425 * Item at a keyboard position across suggestions, products and blog posts. 426 */ 427 navigableItem(position) { 428 if (position < 0) return null; 429 if (position < this.suggestionsCount) { 430 return this.results.suggestions[position] || null; 431 } 432 if (position >= this.productsOffset && position < this.productsOffset + this.productsCount) { 433 return this.results.products[position - this.productsOffset] || null; 434 } 435 if (position >= this.blogOffset && position < this.blogOffset + this.blogCount) { 436 return this.results.blog[position - this.blogOffset] || null; 437 } 438 return null; 439 }, 440 441 selectSuggestion(suggestion) { 442 if (typeof zGA4 === 'function') { 443 zGA4('search', { search_term: suggestion.text }); 444 } 445 window.location.href = searchModalConfig.resultUrl + '?q=' + encodeURIComponent(suggestion.text); 446 }, 447 448 selectCurrent() { 449 if (this.selectedIndex >= 0 && this.selectedIndex < this.suggestionsCount) { 450 this.selectSuggestion(this.results.suggestions[this.selectedIndex]); 451 } else if (this.selectedIndex >= this.suggestionsCount) { 452 var item = this.navigableItem(this.selectedIndex); 453 if (item && item.url) { 454 if (typeof zGA4 === 'function') { 455 zGA4('search', { search_term: this.query }); 456 } 457 window.location.href = item.url; 458 } 459 } else if (this.query.length >= this.minChars) { 460 this.submitSearch(); 461 } 462 }, 463 464 submitSearch() { 465 if (this.query.length >= this.minChars) { 466 if (typeof zGA4 === 'function') { 467 zGA4('search', { search_term: this.query }); 468 } 469 window.location.href = searchModalConfig.resultUrl + '?q=' + encodeURIComponent(this.query); 470 } 471 }, 472 473 selectPopular(text) { 474 this.query = text; 475 this.typedQuery = text; 476 this.selectedIndex = -1; 477 this.$store.search.hasSearched = true; 478 479 var cached = this._getPopularCached(text); 480 if (cached) { 481 this.results.products = cached.products; 482 this.results.categories = cached.categories; 483 this.results.blog = cached.blog || []; 484 this.results.suggestions = []; 485 this.$store.search.isLoading = false;
486 this._saveState(); 487 return; 488 } 489 490 this.onInput(); 491 }, 492 493 _prefetchPopular(q) { 494 var self = this; 495 if (this._getPopularCached(q)) return; 496 497 fetch(this.searchUrl + '?q=' + encodeURIComponent(q), { 498 headers: { 'X-Requested-With': 'XMLHttpRequest' } 499 }).then(function(res) { return res.json(); }) 500 .then(function(data) { 501 if (data.success && data.items) { 502 var parsed = self.parseResults(data.items); 503 var key = 'products:' + q.toLowerCase(); 504 self._cache[key] = { data: parsed, time: Date.now() }; 505 } 506 }).catch(function() {}); 507 }, 508 509 _getPopularCached(query) { 510 var key = 'products:' + query.toLowerCase(); 511 var entry = this._cache[key]; 512 if (entry && (Date.now() - entry.time) < this._popularCacheMaxAge) { 513 return entry.data; 514 } 515 return null; 516 }, 517 518 clearSearch() { 519 this.query = ''; 520 this.typedQuery = ''; 521 this.results = { suggestions: [], products: [], categories: [], blog: [] }; 522 this.selectedIndex = -1; 523 this.$store.search.hasSearched = false; 524 if (this._suggestAbort) this._suggestAbort.abort(); 525 if (this._productAbort) this._productAbort.abort(); 526 if (this._skeletonHideTimer) clearTimeout(this._skeletonHideTimer); 527 this.showSkeleton = false; 528 try { sessionStorage.removeItem('zephyr_search'); } catch (e) {} 529 } 530 }; 531} 532 533window.openSearchModal = function() { 534 if (window.Alpine && Alpine.store('search')) { 535 Alpine.store('search').open(); 536 } 537}; 538 539window.closeSearchModal = function() { 540 if (window.Alpine && Alpine.store('search')) { 541 Alpine.store('search').close(); 542 } 543};
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.