PageSourceSearch

https://cdn.simplystamps.com/skin/frontend/zephyr/default/js/components/search-modal.js?v=1114

js simplystamps.com collected 2026-09-24 20:30:12 UTC 20,917 bytes, 543 lines download raw bytes

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.