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.