PageSourceSearch

https://www.springerpub.com/static/version1789146322/frontend/Mage…/Algolia_AlgoliaSearch/js/autocomplete.js

js springerpub.com collected 2026-09-24 08:47:42 UTC 42,562 bytes, 1,053 lines download raw bytes

1define([
2    'uiComponent',
3    'jquery',
4
5    // Algolia core UI libs
6    'algoliaSearchLib',
7    'algoliaAutocompleteLib',
8    'algoliaQuerySuggestionsPluginLib',
9    'algoliaRedirectUrlPluginLib',
10
11    // Algolia integration dependencies
12    'algoliaCommon',
13    'algoliaBase64',
14
15    // HTML templates
16    'algoliaAutocompleteProductsHtml',
17    'algoliaAutocompleteCategoriesHtml',
18    'algoliaAutocompletePagesHtml',
19    'algoliaAutocompleteSuggestionsHtml',
20    'algoliaAutocompleteAdditionalHtml',
21
22    'algoliaInsights',
23    'algoliaHooks',
24    'domReady!'
25], function (
26    Component,
27    $,
28    algoliasearch,
29    autocomplete,
30    querySuggestionsPlugin,
31    redirectUrlPlugin,
32    algoliaCommon,
33    algoliaBase64,
34    productsHtml,
35    categoriesHtml,
36    pagesHtml,
37    suggestionsHtml,
38    additionalHtml,
39    algoliaInsights
40) {
41    const DEFAULT_HITS_PER_SECTION = 2;
42    const DEBOUNCE_MS = algoliaConfig.autocomplete.debounceMilliseconds;
43    const MIN_SEARCH_LENGTH_CHARS = algoliaConfig.autocomplete.minimumCharacters;
44
45    return Component.extend({
46        DEFAULT_HITS_PER_SECTION,
47        DEBOUNCE_MS,
48        MIN_SEARCH_LENGTH_CHARS,
49
50        state: {
51            hasRendered: false,
52            hasSuggestionSection: false,
53            hasRedirect: false
54        },
55
56        navigator: {
57            navigate({itemUrl}) {
58                window.location.assign(itemUrl);
59            },
60            navigateNewTab({itemUrl}) {
61                const windowReference = window.open(itemUrl, '_blank', 'noopener');
62
63                if (windowReference) {
64                    windowReference.focus();
65                }
66            },
67            navigateNewWindow({itemUrl}) {
68                window.open(itemUrl, '_blank', 'noopener');
69            }
70        },
71
72        initialize(config, element) {
73            this.buildAutocomplete();
74        },
75
76        /**
77         * Setup the autocomplete search input
78         * For autocomplete feature is used Algolia's autocomplete.js library
79         * Docs: https://github.com/algolia/autocomplete.js
80         **/
81        buildAutocomplete() {
82            /** We have nothing to do here if autocomplete is disabled **/
83            if (typeof algoliaConfig === 'undefined' || !algoliaConfig.autocomplete.enabled) return;
84
85            const searchClient = this.getSearchClient();
86
87            const sources = this.buildAutocompleteSources(searchClient);
88
89            const plugins = this.buildAutocompletePlugins(searchClient);
90
91            let options = this.buildAutocompleteOptions(searchClient, sources, plugins);
92
93            this.startAutocomplete(options);
94
95            this.trackClicks();
96
97            this.addKeyboardNavigation();
98        },
99
100        getSearchClient() {
101            /**
102             * Initialise Algolia client
103             * Docs: https://www.algolia.com/doc/api-client/getting-started/instantiate-client-index/
104             **/
105            const searchClient = algoliasearch(
106                algoliaConfig.applicationId,
107                algoliaConfig.apiKey
108            );
109            searchClient.addAlgoliaAgent(
110                'Magento2 integration (' + algoliaConfig.extensionVersion + ')'
111            );
112            return searchClient;
113        },
114
115        getSearchResultsUrl(query) {
116            return `${algoliaConfig.resultPageUrl}?q=${encodeURIComponent(query)}`;
117        },
118
119        handleAutocompleteSubmit({ state: { query } }) {
120            if (query && !this.state.hasRedirect) {
121                this.navigator.navigate({ itemUrl: this.getSearchResultsUrl(query) });
122            }
123        },
124
125        buildAutocompleteOptions(searchClient, sources, plugins) {
126            const debounced = this.debounce(items => Promise.resolve(items), this.DEBOUNCE_MS);
127
128            let options = algoliaCommon.triggerHooks('beforeAutocompleteOptions', {});
129
130            options = {
131                ...options,
132
133                container         : algoliaConfig.autocomplete.selector,
134                placeholder       : algoliaConfig.translations.placeholder,
135                debug             : algoliaConfig.autocomplete.isDebugEnabled,
136                detachedMediaQuery: 'none',
137
138                // Set debug to true, to be able to remove keyboard and be able to scroll in autocomplete menu
139                debug: algoliaCommon.isMobile(),
140                plugins,
141                navigator: this.navigator,
142
143                onSubmit: (params) => {
144                    this.handleAutocompleteSubmit(params);
145                },
146
147                onStateChange: ({ state }) => {
148                    this.handleAutocompleteStateChange(state);
149                },
150
151                render: (params, root) => {
152                    this.renderAutocomplete(params, root);
153                },
154
155                getSources: ({query}) => {
156                    return this.filterMinChars(query, debounced(this.transformSources(searchClient, sources)));
157                },
158
159                shouldPanelOpen: ({state}) => {
160                    return state.query.length >= this.MIN_SEARCH_LENGTH_CHARS;
161                }
162            };
163
164            options = algoliaCommon.triggerHooks('afterAutocompleteOptions', options);
165
166            return options;
167        },
168
169        /**
170         * Handle render callback
171         * Docs: https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-js/autocomplete/#param-render
172         *
173         * @param params
174         * @param root
175         */
176        renderAutocomplete({ sections, render, html }, root) {
177            const classes = [
178                'aa-PanelLayout',
179                'aa-Panel--scrollable'
180            ]
181            if (sections.length > 1) {
182                classes.push('with-grid');
183            }
184
185            if (algoliaConfig.autocomplete.redirects.showHitsWithRedirect) {
186                classes.push('show-hits-with-redirect');
187            }
188
189            if (algoliaConfig.autocomplete.redirects.showSelectableRedirect) {
190                classes.push('show-selectable-redirect');
191            }
192
193            render(
194                html`<div class="${classes.join(' ')}">${sections}</div>`,
195                root
196            );
197        },
198
199        /**
200         * Validate and merge behaviors for custom sources
201         *
202         * @param searchClient
203         * @param sources Magento sources
204         * @returns Algolia sources
205         */
206        transformSources(searchClient, sources) {
207            return sources
208                .filter(data => {
209                    if (!data.sourceId) {
210                        console.error(
211                            'Algolia Autocomplete: sourceId is required for custom sources'
212                        );
213                        return false;
214                    }
215                    return true;
216                })
217                .map((data) => {
218                    const getItems = ({query}) => {
219                        return autocomplete.getAlgoliaResults({
220                            searchClient,
221                            queries: [
222                                {
223                                    query,
224                                    indexName: data.indexName,
225                                    params   : data.options,
226                                },
227                            ],
228                            // only set transformResponse if defined (necessary check for custom sources)
229                            ...(data.transformResponse && {
230                                transformResponse: data.transformResponse,
231                            }),
232                        });
233                    };
234                    const fallbackTemplates = {
235                        noResults: () => 'No results',
236                        header   : () => data.sourceId,
237                        item     : ({item}) => {
238                            console.error(
239                                `Algolia Autocomplete: No template defined for source "${data.sourceId}"`
240                            );
241                            return '[ITEM TEMPLATE MISSING]';
242                        },
243                    };
244                    return {
245                        sourceId : data.sourceId,
246                        getItems,
247                        templates: {...fallbackTemplates, ...(data.templates || {})},
248                        // only set getItemUrl if defined (necessary check for custom sources)
249                        ...(data.getItemUrl && {getItemUrl: data.getItemUrl}),
250                    };
251                });
252        },
253
254        /**
255         * Build all of the extension's federated sources for Autocomplete
256         * @param searchClient
257         * @returns array of source objects
258         */
259        buildAutocompleteSources(searchClient) {
260             /**
261             * Load suggestions, products and categories as configured
262             * NOTE: Sequence matters!
263             * **/
264             if (algoliaConfig.autocomplete.nbOfCategoriesSuggestions > 0) {
265                algoliaConfig.autocomplete.sections.unshift({
266                    hitsPerPage: algoliaConfig.autocomplete.nbOfCategoriesSuggestions,
267                    label      : algoliaConfig.translations.categories,
268                    name       : 'categories',
269                });
270            }
271
272            if (algoliaConfig.autocomplete.nbOfProductsSuggestions > 0) {
273                algoliaConfig.autocomplete.sections.unshift({
274                    hitsPerPage: algoliaConfig.autocomplete.nbOfProductsSuggestions,
275                    label      : algoliaConfig.translations.products,
276                    name       : 'products',
277                });
278            }
279
280            /** Setup autocomplete data sources **/
281            let sources = algoliaConfig.autocomplete.sections.map((section) =>
282                this.buildAutocompleteSource(section, searchClient)
283            );
284
285            // DEPRECATED - retaining for backward compatibility but `beforeAutcompleteSources` may be removed or relocated in a future version
286            sources = algoliaCommon.triggerHooks(
287                'beforeAutocompleteSources',
288                sources,
289                searchClient
290            );
291
292            sources = algoliaCommon.triggerHooks(
293                'afterAutocompleteSources',
294                sources,
295                searchClient
296            );
297
298            return sources;
299        },
300
301        /**
302         * Build pre-baked sources
303         * @param section - object containing data for federated section in the autocomplete menu
304         * @param searchClient
305         * @returns object representing a single source
306         */
307        buildAutocompleteSource(section, searchClient) {
308            const defaultSourceConfig = this.buildAutocompleteSourceDefault(section);
309
310            switch (section.name) {
311                case 'products':
312                    return this.buildAutocompleteSourceProducts(section, defaultSourceConfig);
313                case 'categories':
314                    return this.buildAutocompleteSourceCategories(section, defaultSourceConfig);
315                case 'pages':
316                    return this.buildAutocompleteSourcePages(section, defaultSourceConfig);
317                default:
318                    /** If is not products, categories, or pages, it's an additional section **/
319                    return this.buildAutocompleteSourceAdditional(section, defaultSourceConfig);
320            }
321        },
322
323        /**
324         * Build a default source configuration for all pre baked federated autocomplete sections
325         * @param section - object containing data for this section
326         * @returns
327         */
328        buildAutocompleteSourceDefault(section) {
329            const options = {
330                hitsPerPage   : section.hitsPerPage || this.DEFAULT_HITS_PER_SECTION,
331                analyticsTags : 'autocomplete',
332                clickAnalytics: true,
333                distinct      : true,
334            };
335
336            const getItemUrl = ({item}) => {
337                return this.getNavigatorUrl(item.url);
338            };
339
340            const transformResponse = ({results, hits}) => {
341                const resDetail = results[0];
342
343                return hits.map((res) => {
344                    return res.map((hit, i) => {
345                        return {
346                            ...hit,
347                            query   : resDetail.query,
348                            position: i + 1,
349                        };
350                    });
351                });
352            };
353
354            const defaultSectionIndex = `${algoliaConfig.indexName}_${section.name}`;
355
356            return {
357                sourceId : section.name,
358                options,
359                getItemUrl,
360                transformResponse,
361                indexName: defaultSectionIndex,
362            };
363        },
364
365        /**
366         * Build the source to be used for federated section showing product results
367         * @param section - object containing data for this section
368         * @param source - default values for the source object
369         * @returns source object
370         */
371        buildAutocompleteSourceProducts(section, source) {
372            source.options = this.buildProductSourceOptions(section, source.options);
373            source.templates = {
374                noResults: ({html}) => {
375                    return productsHtml.getNoResultHtml({html});
376                },
377                header: ({items, html}) => {
378                    return productsHtml.getHeaderHtml({items, html});
379                },
380                item: ({item, components, html}) => {
381                    const _data = this.transformAutocompleteHit(item, algoliaConfig.priceKey);
382                    return productsHtml.getItemHtml({item: _data, components, html});
383                },
384                footer: ({items, html}) => {
385                    const resultDetails = { nbHits: items.length };
386                    if (items.length) {
387                        const firstItem = items[0];
388                        resultDetails.allDepartmentsUrl =
389                            algoliaConfig.resultPageUrl +
390                            '?q=' +
391                            encodeURIComponent(firstItem.query);
392                        resultDetails.nbHits = firstItem.nbHits;
393
394                        if (
395                            algoliaConfig.facets.find(
396                                (facet) => facet.attribute === 'categories'
397                            )
398                        ) {
399                            let allCategories = [];
400                            if (typeof firstItem.allCategories !== 'undefined') {
401                                allCategories = Object.keys(firstItem.allCategories).map(
402                                    (key) => {
403                                        const url =
404                                            resultDetails.allDepartmentsUrl +
405                                            '&categories=' +
406                                            encodeURIComponent(key);
407                                        return {
408                                            name : key,
409                                            value: firstItem.allCategories[key],
410                                            url,
411                                        };
412                                    }
413                                );
414                            }
415                            //reverse value sort apparently...
416                            allCategories.sort((a, b) => b.value - a.value);
417                            resultDetails.allCategories = allCategories.slice(0, 2);
418                        }
419                    }
420                    return productsHtml.getFooterHtml({html, ...resultDetails});
421                },
422            };
423            source.transformResponse = ({results, hits}) => {
424                const resDetail = results[0];
425                const redirectUrl = resDetail?.renderingContent?.redirect?.url;
426                this.state.hasRedirect = !!redirectUrl;
427
428                return hits.map((res) => {
429                    return res.map((hit, i) => {
430                        return {
431                            ...hit,
432                            nbHits       : resDetail.nbHits,
433                            allCategories: resDetail.facets['categories.level0'],
434                            query        : resDetail.query,
435                            position     : i + 1,
436                        };
437                    });
438                });
439            };
440            return source;
441        },
442
443        /**
444         * Build the source options for the products search results
445         * (Provides an alternate approach to customizing via mixin in addition to front end custom event hooks)
446         * @param section - object containing data for the product section (although not used in default implementation retained for accessibility through overrides)
447         * @param options - default values for the options object
448         * @returns options object
449         */
450        buildProductSourceOptions(section, options) {
451            // DEPRECATED - retaining for backward compatibility but `beforeAutocompleteProductSourceOptions` may be removed in a future version
452            options = algoliaCommon.triggerHooks(
453                'beforeAutocompleteProductSourceOptions',
454                options
455            );
456
457            options.facets = ['categories.level0'];
458            options.numericFilters = 'visibility_search=1';
459            options.ruleContexts = [algoliaConfig.request.ruleContexts.facetFilters, '']; // Empty context to keep backward compatibility for already created rules in dashboard
460
461            options = algoliaCommon.triggerHooks(
462                'afterAutocompleteProductSourceOptions',
463                options
464            );
465            return options;
466        },
467
468        /**
469         * Build the source to be used for federated section showing category results
470         * @param section - object containing data for this section
471         * @param source - default values for the source object
472         * @returns source object
473         */
474        buildAutocompleteSourceCategories(section, source) {
475            if (
476                section.name === 'categories' &&
477                algoliaConfig.showCatsNotIncludedInNavigation === false
478            ) {
479                source.options.numericFilters = 'include_in_menu=1';
480            }
481            source.templates = {
482                noResults: ({html}) => {
483                    return categoriesHtml.getNoResultHtml({html});
484                },
485                header: ({html, items}) => {
486                    return categoriesHtml.getHeaderHtml({section, html, items});
487                },
488                item: ({item, components, html}) => {
489                    return categoriesHtml.getItemHtml({item, components, html});
490                },
491                footer: ({html, items}) => {
492                    return categoriesHtml.getFooterHtml({section, html, items});
493                },
494            };
495            return source;
496        },
497
498         /**
499         * Build the source to be used for federated section showing CMS page results
500         * @param section - object containing data for this section
501         * @param source - default values for the source object
502         * @returns source object
503         */
504        buildAutocompleteSourcePages(section, source) {
505            source.templates = {
506                noResults: ({html}) => {
507                    return pagesHtml.getNoResultHtml({html});
508                },
509                header: ({html, items}) => {
510                    return pagesHtml.getHeaderHtml({section, html, items});
511                },
512                item: ({item, components, html}) => {
513                    return pagesHtml.getItemHtml({item, components, html});
514                },
515                footer: ({html, items}) => {
516                    return pagesHtml.getFooterHtml({section, html, items});
517                },
518            };
519            return source;
520        },
521
522        /**
523         * Build the source to be used for federated sections based on product attributes
524         * @param section - object containing data for this section
525         * @param source - default values for the source object
526         * @returns source object
527         */
528        buildAutocompleteSourceAdditional(section, source) {
529            source.indexName = `${algoliaConfig.indexName}_section_${section.name}`;
530            source.templates = {
531                noResults: ({html}) => {
532                    return additionalHtml.getNoResultHtml({html});
533                },
534                header: ({html, items}) => {
535                    return additionalHtml.getHeaderHtml({section, html, items});
536                },
537                item: ({item, components, html}) => {
538                    return additionalHtml.getItemHtml({
539                        item,
540                        components,
541                        html,
542                        section,
543                    });
544                },
545                footer: ({html, items}) => {
546                    return additionalHtml.getFooterHtml({section, html, items});
547                },
548            };
549            return source;
550        },
551
552        buildAutocompletePlugins(searchClient) {
553            const plugins = [];
554
555            if (algoliaConfig.autocomplete.areSuggestionsEnabled) {
556                this.state.hasSuggestionSection = true;
557                plugins.push(this.buildSuggestionsPlugin(searchClient));
558            }
559
560            if (algoliaConfig.autocomplete.redirects.enabled) {
561                plugins.push(this.buildRedirectPlugin());
562            }
563
564            return algoliaCommon.triggerHooks(
565                'afterAutocompletePlugins',
566                plugins,
567                searchClient
568            );
569        },
570
571        /**
572         *
573         * @param options
574         * @returns the Algolia Autocomplete instance
575         */
576        startAutocomplete(options) {
577            /** Bind autocomplete feature to the input */
578            const algoliaAutocompleteInstance = autocomplete.autocomplete(options);
579            return algoliaCommon.triggerHooks(
580                'afterAutocompleteStart',
581                algoliaAutocompleteInstance
582            );
583        },
584
585        transformAutocompleteHit(hit, price_key, helper) {
586            if (Array.isArray(hit.categories))
587                hit.categories = hit.categories.join(', ');
588
589            if (
590                hit._highlightResult.categories_without_path &&
591                Array.isArray(hit.categories_without_path)
592            ) {
593                hit.categories_without_path = $.map(
594                    hit._highlightResult.categories_without_path,
595                    function (category) {
596                        return category.value;
597                    }
598                );
599
600                hit.categories_without_path = hit.categories_without_path.join(', ');
601            }
602
603            let matchedColors = [];
604
605            // TODO: Adapt this migrated code from common.js - helper not utilized
606            if (helper && algoliaConfig.useAdaptiveImage === true) {
607                if (hit.images_data && helper.state.facetsRefinements.color) {
608                    matchedColors = helper.state.facetsRefinements.color.slice(0); // slice to clone
609                }
610
611                if (hit.images_data && helper.state.disjunctiveFacetsRefinements.color) {
612                    matchedColors =
613                        helper.state.disjunctiveFacetsRefinements.color.slice(0); // slice to clone
614                }
615            }
616
617            if (Array.isArray(hit.color)) {
618                let colors = [];
619
620                $.each(hit._highlightResult.color, function (i, color) {
621                    if (color.matchLevel === undefined || color.matchLevel === 'none') {
622                        return;
623                    }
624
625                    colors.push(color.value);
626
627                    if (algoliaConfig.useAdaptiveImage === true) {
628                        const matchedColor = color.matchedWords.join(' ');
629                        if (
630                            hit.images_data &&
631                            color.fullyHighlighted &&
632                            color.fullyHighlighted === true
633                        ) {
634                            matchedColors.push(matchedColor);
635                        }
636                    }
637                });
638
639                colors = colors.join(', ');
640                hit._highlightResult.color = {value: colors};
641            } else {
642                if (
643                    hit._highlightResult.color &&
644                    hit._highlightResult.color.matchLevel === 'none'
645                ) {
646                    hit._highlightResult.color = {value: ''};
647                }
648            }
649
650            if (algoliaConfig.useAdaptiveImage === true) {
651                $.each(matchedColors, function (i, color) {
652                    color = color.toLowerCase();
653
654                    if (hit.images_data[color]) {
655                        hit.image_url = hit.images_data[color];
656                        hit.thumbnail_url = hit.images_data[color];
657
658                        return false;
659                    }
660                });
661            }
662
663            if (
664                hit._highlightResult.color &&
665                hit._highlightResult.color.value &&
666                hit.categories_without_path
667            ) {
668                if (
669                    hit.categories_without_path.indexOf('<em>') === -1 &&
670                    hit._highlightResult.color.value.indexOf('<em>') !== -1
671                ) {
672                    hit.categories_without_path = '';
673                }
674            }
675
676            if (Array.isArray(hit._highlightResult.name))
677                hit._highlightResult.name = hit._highlightResult.name[0];
678
679            if (Array.isArray(hit.price)) {
680                hit.price = hit.price[0];
681                if (
682                    hit['price'] !== undefined &&
683                    price_key !== '.' + algoliaConfig.currencyCode + '.default' &&
684                    hit['price'][algoliaConfig.currencyCode][
685                    price_key.substr(1) + '_formated'
686                        ] !== hit['price'][algoliaConfig.currencyCode]['default_formated']
687                ) {
688                    hit['price'][algoliaConfig.currencyCode][
689                    price_key.substr(1) + '_original_formated'
690                        ] = hit['price'][algoliaConfig.currencyCode]['default_formated'];
691                }
692
693                if (
694                    hit['price'][algoliaConfig.currencyCode]['default_original_formated'] &&
695                    hit['price'][algoliaConfig.currencyCode]['special_to_date']
696                ) {
697                    const priceExpiration =
698                        hit['price'][algoliaConfig.currencyCode]['special_to_date'];
699
700                    if (algoliaConfig.now > priceExpiration + 1) {
701                        hit['price'][algoliaConfig.currencyCode]['default_formated'] =
702                            hit['price'][algoliaConfig.currencyCode][
703                                'default_original_formated'
704                                ];
705                        hit['price'][algoliaConfig.currencyCode][
706                            'default_original_formated'
707                            ] = false;
708                    }
709                }
710            }
711
712            // Add to cart parameters
713            const action =
714                algoliaConfig.instant.addToCartParams.action +
715                'product/' +
716                hit.objectID +
717                '/';
718
719            const correctFKey = algoliaCommon.getCookie('form_key');
720
721            if (
722                correctFKey != '' &&
723                algoliaConfig.instant.addToCartParams.formKey != correctFKey
724            ) {
725                algoliaConfig.instant.addToCartParams.formKey = correctFKey;
726            }
727
728            hit.addToCart = {
729                action : action,
730                uenc   : algoliaBase64.mageEncode(action),
731                formKey: algoliaConfig.instant.addToCartParams.formKey,
732            };
733
734            if (hit.__autocomplete_queryID) {
735                hit.urlForInsights = hit.url;
736
737                if (
738                    algoliaConfig.ccAnalytics.enabled &&
739                    algoliaConfig.ccAnalytics.conversionAnalyticsMode !== 'disabled'
740                ) {
741                    const insightsDataUrlString = $.param({
742                        queryID  : hit.__autocomplete_queryID,
743                        objectID : hit.objectID,
744                        indexName: hit.__autocomplete_indexName,
745                    });
746                    if (hit.url.indexOf('?') > -1) {
747                        hit.urlForInsights += '&' + insightsDataUrlString;
748                    } else {
749                        hit.urlForInsights += '?' + insightsDataUrlString;
750                    }
751                }
752            }
753
754            return hit;
755        },
756
757        filterMinChars(query, result) {
758            return query.length >= MIN_SEARCH_LENGTH_CHARS ? result : [];
759        },
760
761        /**
762         * Tells Autocomplete to “wait” for a set time after typing stops before returning results
763         * See https://www.algolia.com/doc/ui-libraries/autocomplete/guides/debouncing-sources/#select-a-debounce-delay
764         * @param fn Function to debounce
765         * @param time Delay in ms before function executes
766         * @returns
767         */
768        debounce(fn, time) {
769            let timerId = undefined;
770
771            return (...args) => {
772                if (timerId) {
773                    clearTimeout(timerId);
774                }
775
776                return new Promise((resolve) => {
777                    timerId = setTimeout(() => resolve(fn(...args)), time);
778                });
779            };
780        },
781
782        getNavigatorUrl(url) {
783            if (algoliaConfig.autocomplete.isNavigatorEnabled) {
784                return url;
785            }
786        },
787
788        /**
789         * Only clickable links can open in a new window - else popup blockers may be triggered
790         * @param event
791         * @returns {boolean}
792         */
793        canRedirectToNewWindow(event) {
794            return algoliaConfig.autocomplete.redirects.openInNewWindow
795                && !(event instanceof SubmitEvent)
796                && !(event instanceof KeyboardEvent);
797        },
798
799        /**
800         * Controls the render of the selectable redirect Autocomplete menu item
801         * @param html Tagged template function
802         * @param state
803         * @returns {*}
804         */
805        getRedirectItemTemplate({html, state}) {
806            return html`
807                <div className="aa-ItemWrapper">
808                    <div className="aa-ItemContent">
809                        <div className="aa-ItemIcon aa-ItemIcon--noBorder">
810                            <svg viewBox="0 0 24 24" fill="currentColor">
811                                <path
812                                    d="M16.041 15.856c-0.034 0.026-0.067 0.055-0.099 0.087s-0.060 0.064-0.087 0.099c-1.258 1.213-2.969 1.958-4.855 1.958-1.933 0-3.682-0.782-4.95-2.050s-2.050-3.017-2.050-4.95 0.782-3.682 2.050-4.95 3.017-2.050 4.95-2.050 3.682 0.782 4.95 2.050 2.050 3.017 2.050 4.95c0 1.886-0.745 3.597-1.959 4.856zM21.707 20.293l-3.675-3.675c1.231-1.54 1.968-3.493 1.968-5.618 0-2.485-1.008-4.736-2.636-6.364s-3.879-2.636-6.364-2.636-4.736 1.008-6.364 2.636-2.636 3.879-2.636 6.364 1.008 4.736 2.636 6.364 3.879 2.636 6.364 2.636c2.125 0 4.078-0.737 5.618-1.968l3.675 3.675c0.391 0.391 1.024 0.391 1.414 0s0.391-1.024 0-1.414z"></path>
813                            </svg>
814                        </div>
815                        <div className="aa-ItemContentBody">
816                            <div className="aa-ItemContentTitle"><a className="aa-ItemLink">${state.query}</a>
817                            </div>
818                        </div>
819                    </div>
820                    <div className="aa-ItemActions">
821                        <div className="aa-ItemActionButton">
822                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
823                                 strokeLinecap="round" strokeLinejoin="round">
824                                <line x1="5" y1="12" x2="19" y2="12"></line>
825                                <polyline points="12 5 19 12 12 19"></polyline>
826                            </svg>
827                        </div>
828                    </div>
829                </div>`;
830        },
831
832        buildRedirectPlugin() {
833            const onRedirect = (redirects, { event, navigator, state }) => {
834                const item = redirects.find((r) => r.sourceId === 'products');
835                const itemUrl = item?.urls?.[0];
836                if (!itemUrl) return;
837
838                if (event.metaKey || event.ctrlKey) {
839                    navigator.navigateNewTab({ itemUrl, item, state });
840                } else if (event.shiftKey || this.canRedirectToNewWindow(event)) {
841                    navigator.navigateNewWindow({ itemUrl, item, state });
842                } else {
843                    navigator.navigate({ itemUrl, item, state });
844                }
845            };
846
847            const params = {
848                onRedirect,
849                templates: {
850                    item: ({html, state}) => {
851                        return (algoliaConfig.autocomplete.redirects.showSelectableRedirect)
852                            ? this.getRedirectItemTemplate({html, state})
853                            : html``;
854                    }
855                }
856            };
857
858            return redirectUrlPlugin.createRedirectUrlPlugin(params);
859        },
860
861        /**
862         * Create a wrapped searchClient that gracefully handles missing indices
863         */
864        createSuggestionsSearchClient(searchClient, suggestionsIndexName) {
865            return {
866                ...searchClient,
867                search: (requests, ...rest) => {
868                    return searchClient.search(requests, ...rest).catch((error) => {
869                        // If the suggestions index doesn't exist, return empty results
870                        if (error.status === 404 && error.message?.includes(suggestionsIndexName)) {
871                            console.warn(`Algolia: Suggestions index "${suggestionsIndexName}" does not exist. Skipping suggestions.`);
872                            return {
873                                results: requests.map((req) => ({
874                                    // Return sensible defaults for zero result scenario
875                                    hits: [],
876                                    nbHits: 0,
877                                    page: 0,
878                                    nbPages: 0,
879                                    hitsPerPage: 0,
880                                    processingTimeMS: 0,
881                                    query: req.query,
882                                    params: ''
883                                }))
884                            };
885                        }
886                        throw error;
887                    });
888                }
889            };
890        },
891
892        buildSuggestionsPlugin(searchClient) {
893            const numberOfSuggestions = this.getNumberOfSuggestions();
894            const suggestionsIndexName = this.getSuggestionsIndexName();
895            const suggestionsSearchClient = this.createSuggestionsSearchClient(searchClient, suggestionsIndexName);
896
897            return querySuggestionsPlugin.createQuerySuggestionsPlugin(
898                {
899                    searchClient: suggestionsSearchClient,
900                    indexName: suggestionsIndexName,
901                    getSearchParams() {
902                        return {
903                            hitsPerPage   : numberOfSuggestions,
904                            clickAnalytics: true,
905                        };
906                    },
907                    transformSource: ({source}) => {
908                        return {
909                            ...source,
910                            getItems: ({query}) => {
911                                const items = this.filterMinChars(query, source.getItems());
912                                const oldTransform = items.transformResponse;
913                                items.transformResponse = (arg) => {
914                                    const hits = oldTransform ? oldTransform(arg) : arg.hits;
915                                    return hits.map((hit, i) => {
916                                        return {
917                                            ...hit,
918                                            position: i + 1,
919                                        };
920                                    });
921                                };
922                                return items;
923                            },
924                            getItemUrl: ({item}) => {
925                                return this.getNavigatorUrl(
926                                    algoliaConfig.resultPageUrl + `?q=${item.query}`
927                                );
928                            },
929                            templates: {
930                                noResults({html}) {
931                                    return suggestionsHtml.getNoResultHtml({html});
932                                },
933                                header({html, items}) {
934                                    return suggestionsHtml.getHeaderHtml({html, items});
935                                },
936                                item({item, components, html}) {
937                                    return suggestionsHtml.getItemHtml({item, components, html});
938                                },
939                                footer({html, items}) {
940                                    return suggestionsHtml.getFooterHtml({html, items});
941                                },
942                            },
943                        };
944                    },
945                }
946            );
947        },
948
949        /**
950         * Autocomplete insight click conversion
951         */
952        trackClicks() {
953            // TODO: Switch to insights plugin
954            if (algoliaConfig.ccAnalytics.enabled) {
955                $(document).on('click', '.algoliasearch-autocomplete-hit', function () {
956                    const $this = $(this);
957                    if ($this.data('clicked')) return;
958
959                    const objectId = $this.attr('data-objectId');
960                    const indexName = $this.attr('data-index');
961                    const queryId = $this.attr('data-queryId');
962                    const position = $this.attr('data-position');
963
964                    let useCookie = algoliaConfig.cookieConfiguration
965                        .cookieRestrictionModeEnabled
966                        ? !!algoliaCommon.getCookie(algoliaConfig.cookieConfiguration.consentCookieName)
967                        : true;
968                    if (useCookie !== false) {
969                        algoliaInsights.initializeAnalytics();
970                        const eventData = algoliaInsights.buildEventData(
971                            'Clicked',
972                            objectId,
973                            indexName,
974                            position,
975                            queryId
976                        );
977                        algoliaInsights.trackClick(eventData);
978                        $this.attr('data-clicked', true);
979                    }
980                });
981            }
982        },
983
984        handleAutocompleteStateChange(autocompleteState) {
985            if (!this.state.hasRendered && autocompleteState.isOpen) {
986                this.addPanelObserver();
987                this.state.hasRendered = true;
988            }
989        },
990
991        addPanelObserver() {
992            const observer = new MutationObserver((mutationsList, observer) => {
993                for (let mutation of mutationsList) {
994                    if (mutation.type === 'childList') {
995                        mutation.addedNodes.forEach(node => {
996                            if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('aa-PanelLayout')) {
997                                this.initAutocompletePanel(node);
998                                //We only care about the first occurrence
999                                observer.disconnect();
1000                            }
1001                        });
1002                    }
1003                }
1004            });
1005
1006            observer.observe(document.body, { childList: true, subtree: true });
1007        },
1008
1009        // Modify the initial panel render DOM as needed
1010        initAutocompletePanel(node) {
1011            this.addFooter(node);
1012            this.handleSuggestionsLayout();
1013        },
1014
1015        addFooter(node) {
1016            if (!algoliaConfig.removeBranding) {
1017                const div = document.createElement('div');
1018                div.id = 'algoliaFooter';
1019                div.classList.add('footer_algolia');
1020                div.innerHTML = `<span class="algolia-search-by-label">${algoliaConfig.translations.searchBy}</span><a href="https://www.algolia.com/?utm_source=magento&utm_medium=link&utm_campaign=magento_autocompletion_menu" title="${algoliaConfig.translations.searchBy} Algolia" target="_blank"><img src="${algoliaConfig.urls.logo}" alt="${algoliaConfig.translations.searchBy} Algolia" /></a>`;
1021                node.appendChild(div);
1022            }
1023        },
1024
1025        /**
1026         * @deprecated Legacy layout handler - deprecated in favor of CSS Grid
1027         */
1028        handleSuggestionsLayout() {
1029            // Do nothing
1030        },
1031
1032        addKeyboardNavigation() {
1033            if (algoliaConfig.autocomplete.isNavigatorEnabled) {
1034                $('body').append(
1035                    '<style>.aa-Item[aria-selected="true"]{background-color: #f2f2f2;}</style>'
1036                );
1037            }
1038        },
1039
1040        getSuggestionsIndexName() {
1041            return algoliaConfig.autocomplete.showAlgoliaSuggestions ?
1042                algoliaConfig.autocomplete.suggestionsIndexName :
1043                `${algoliaConfig.indexName}_suggestions`;
1044        },
1045
1046        getNumberOfSuggestions() {
1047            return algoliaConfig.autocomplete.showAlgoliaSuggestions ?
1048                algoliaConfig.autocomplete.nbOfAlgoliaSuggestions :
1049                algoliaConfig.autocomplete.nbOfQueriesSuggestions;
1050
1051        }
1052    });
1053});

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.