1/** 2 * @file 3 * Shared Algolia utilities for search and browse functionality. 4 * 5 * This file contains reusable functions for: 6 * - Filter panel creation 7 * - Card fetching and rendering 8 * - Commerce data loading 9 * - Filter UI handlers 10 */ 11 12(function ($, Drupal) { 13 'use strict'; 14 15 // How long Retail Solutions gets to enable an add-to-cart form before we 16 // treat the item as declined and restore the Locally locator. Slightly 17 // longer than tg_pricing's own 4500ms price settle so the price resolves 18 // first and the two do not visibly reflow the card in sequence. 19 var RS_SETTLE_TIMEOUT = 5000; 20 21 // Namespace for shared Algolia utilities 22 Drupal.algoliaCommon = { 23 24 // Counter for unique Locally widget sequences (prevents duplicate IDs) 25 locallyWidgetIndex: 0, 26 27 /** 28 * Create filter panel widgets with consistent styling. 29 * Matches existing search page UI. 30 * 31 * @param {Function} refinementList - instantsearch.widgets.refinementList 32 * @param {Function} rangeSlider - instantsearch.widgets.rangeSlider 33 * @param {Function} panel - instantsearch.widgets.panel 34 * @return {Object} Filter panel objects 35 */ 36 createFilterPanels: function (refinementList, rangeSlider, panel) { 37 var hiddenFn = function (options) { 38 return !options.canRefine; 39 }; 40 41 return { 42 filterShapes: panel({ 43 templates: { header: 'Shape' }, 44 hidden: hiddenFn, 45 })(refinementList), 46 47 filterBodyWood: panel({ 48 templates: { header: 'Body Wood' }, 49 hidden: hiddenFn, 50 })(refinementList), 51 52 filterTopWood: panel({ 53 templates: { header: 'Top Wood' }, 54 hidden: hiddenFn, 55 })(refinementList), 56 57 filterSeries: panel({ 58 templates: { header: 'Series & Collections' }, 59 })(refinementList), 60 61 62 filterBracing: panel({ 63 templates: { header: 'Bracing System' }, 64 hidden: hiddenFn, 65 })(refinementList), 66 67 filterSpecialty: panel({ 68 templates: { header: 'Specialty' }, 69 hidden: hiddenFn, 70 })(refinementList), 71 72 filterCutaway: panel({ 73 templates: { header: 'Cutaway' }, 74 hidden: hiddenFn, 75 })(refinementList), 76 77 filterNeckWidth: panel({ 78 templates: { header: 'Neck Width' }, 79 hidden: hiddenFn, 80 })(refinementList), 81 82 filterElectronics: panel({ 83 templates: { header: 'Electronics' }, 84 hidden: hiddenFn, 85 })(refinementList), 86 87 filterCategory: panel({ 88 templates: { header: 'Category' }, 89 hidden: hiddenFn, 90 })(refinementList), 91 92 // Raw refinementList for embedding without a panel wrapper 93 rawRefinementList: refinementList, 94 }; 95 }, 96 97 /** 98 * Build filter widgets array for a given container prefix. 99 * 100 * @param {Object} filters - Filter panel objects from createFilterPanels() 101 * @param {string} prefix - Container ID prefix (e.g., '' for search, 'browse-' for browse) 102 * @param {Object} settings - drupalSettings with series_weights 103 * @return {Array} Array of filter widgets 104 */ 105 buildFilterWidgets: function (filters, prefix, settings) { 106 prefix = prefix || ''; 107 108 var alphabeticalSort = function (a, b) { 109 return a.label.localeCompare(b.label); 110 }; 111 112 return [ 113 filters.filterShapes({ 114 container: '#' + prefix + 'shape', 115 attribute: 'guitar_shape', 116 limit: 30, 117 transformItems: function (items) { 118 return items.sort(alphabeticalSort); 119 }, 120 }), 121 122 filters.filterBodyWood({ 123 container: '#' + prefix + 'body-wood', 124 attribute: 'guitar_body_wood', 125 }), 126 127 filters.filterTopWood({ 128 container: '#' + prefix + 'top-wood', 129 attribute: 'top_wood', 130 limit: 30, 131 }), 132 133 filters.filterSeries({ 134 container: '#' + prefix + 'series', 135 attribute: 'guitar_series', 136 limit: 30, 137 transformItems: function (items) { 138 var seriesWeights = settings && settings.algolia_search && settings.algolia_search.series_weights 139 ? settings.algolia_search.series_weights 140 : {}; 141 return items 142 .map(function (item) { 143 if (!item.weight) { 144 item.weight = seriesWeights[item.label] || 0; 145 } 146 return item; 147 }) 148 .sort(function (a, b) { 149 return a.weight - b.weight; 150 }); 151 }, 152 }), 153 154 filters.rawRefinementList({ 155 container: '#' + prefix + 'collection-list', 156 attribute: 'guitar_collection', 157 limit: 30, 158 transformItems: function (items) { 159 return items.sort(alphabeticalSort); 160 }, 161 }), 162 163 filters.filterBracing({ 164 container: '#' + prefix + 'bracing', 165 attribute: 'guitar_bracing', 166 limit: 30, 167 transformItems: function (items) { 168 return items.sort(alphabeticalSort); 169 }, 170 }), 171 172 filters.filterSpecialty({ 173 container: '#' + prefix + 'specialty', 174 attribute: 'guitar_specialty', 175 limit: 30, 176 transformItems: function (items) { 177 return items.sort(alphabeticalSort); 178 }, 179 }), 180 181 filters.filterCutaway({ 182 container: '#' + prefix + 'cutaway', 183 attribute: 'guitar_cutaway', 184 limit: 30, 185 transformItems: function (items) { 186 return items.sort(alphabeticalSort); 187 }, 188 }), 189 190 filters.filterNeckWidth({ 191 container: '#' + prefix + 'neck-width', 192 attribute: 'guitar_neck_width', 193 limit: 30, 194 transformItems: function (items) { 195 return items.sort(alphabeticalSort); 196 }, 197 }), 198 199 filters.filterElectronics({ 200 container: '#' + prefix + 'electronics', 201 attribute: 'guitar_electronics', 202 limit: 30, 203 }), 204 205 filters.filterCategory({ 206 container: '#' + prefix + 'category', 207 attribute: 'product_category', 208 transformItems: function (items) { 209 return items.sort(alphabeticalSort); 210 }, 211 }), 212 213 // Price filter (Option A) â custom Min/Max TEXT inputs (via connectRange) 214 // instead of the stock rangeInput. Text inputs let us (a) show thous
214ands 215 // separators, which native number inputs can't, and (b) mirror the slider 216 // position into them live during a drag (see attachPriceSliderLiveSync). 217 // Pairs with the tooltip-less rangeSlider below; both refine the same 218 // 'price_field' attribute and stay in sync. Both live in a hand-built 219 // collapsible .ais-Panel in the templates. 220 instantsearch.connectors.connectRange( 221 Drupal.algoliaCommon.renderPriceRangeInputs 222 )({ 223 container: '#' + prefix + 'price-input', 224 attribute: 'price_field', 225 }), 226 227 instantsearch.widgets.rangeSlider({ 228 container: '#' + prefix + 'price-slider', 229 attribute: 'price_field', 230 pips: false, 231 tooltips: false, 232 }), 233 ]; 234 }, 235 236 /** 237 * Status term ids, from PHP (GuitarStatus::jsSettings) via 238 * tg_search_page_attachments. 239 * 240 * The fallbacks match GuitarStatus and exist only for a page cached from 241 * before that attachment shipped. Anything that filters on status must go 242 * through here rather than writing an id inline, or the definitions drift: 243 * the header search bar hardcoded `status_id:1` for exactly that reason and 244 * would have kept filtering on a stale id after the others had moved on. 245 */ 246 guitarStatusIds: function () { 247 var ids = (window.drupalSettings && drupalSettings.tgGuitarStatus) || {}; 248 return { 249 active: ids.active || 1, 250 outOfProduction: (ids.outOfProduction && ids.outOfProduction.length) 251 ? ids.outOfProduction 252 : [3, 2027], 253 }; 254 }, 255 256 /** 257 * Algolia filter expression matching guitars still in production. 258 * 259 * The default for every grid and for the header search bar. 260 */ 261 inProductionFilter: function () { 262 return 'status_id:' + this.guitarStatusIds().active; 263 }, 264 265 /** 266 * How many years back the Out of Production filter reaches. 267 * 268 * Rolling, evaluated per query rather than baked into the index, so the 269 * window moves with the calendar without a reindex. Merchandising controls 270 * what appears here by editing field_model_year: a guitar with no model 271 * year, or one older than this window, does not show. 272 */ 273 OUT_OF_PRODUCTION_YEARS: 3, 274 275 /** 276 * Earliest model year the Out of Production filter will show. 277 */ 278 outOfProductionCutoff: function () { 279 return new Date().getFullYear() - this.OUT_OF_PRODUCTION_YEARS; 280 }, 281 282 /** 283 * Create the "Out of Production" toggle as a custom InstantSearch widget. 284 * 285 * The widget owns the status refinement for its search instance. Unchecked 286 * (the default) it refines status_id = Active, the same default the pages 287 * used to hard-code in the configure widget's filters string. Checked it 288 * shows published guitars that are: 289 * 290 * - any status other than Active (status_id != 1), and 291 * - photographed, not placeholder art (has_full_images), and 292 * - from within the last OUT_OF_PRODUCTION_YEARS model years. 293 * 294 * Everything keys on status_id rather than the status term's name, because 295 * editors rename these terms: "Discontinued" became "Out of Production" in 296 * September 2026, and a name-based filter would have quietly changed which 297 * guitars the grid returned. Published-only is still enforced by the 298 * node_published:true base filter; unpublished nodes never reach the index. 299 * 300 * A standard toggleRefinement cannot express any of this, which is why it 301 * is hand-rolled. It implements getWidgetUiState / getWidgetSearchParameters 302 * so deep links, back/forward and the routing in createRouting() 303 * (uiState.toggle.out_of_production <-> ?out_of_production) behave like the 304 * stock toggles. 305 * 306 * REQUIRES status_id, model_year and has_full_images to be present on the 307 * Algolia records and registered in attributesForFaceting. TgAlgoliaBackend 308 * registers them on index; a product_index reindex must have run before 309 * this ships, or every grid returns zero results. 310 * 311 * @param {Element} container - DOM element to render the checkbox into 312 * @param {Element} clearButtonContainer - Optional clearRefinements widget 313 * container. The clear connector does not know about numeric refinements, 314 * so without this hook "Clear all filters" would leave the toggle checked 315 * while resetting every other filter. 316 * @return {Object} InstantSearch widget 317 */ 318 createOutOfProductionToggle: function (container, clearButtonContainer) { 319 var STATUS = 'status_id'; 320 var YEAR = 'model_year'; 321 var IMAGES = 'has_full_images'; 322 var ids = this.guitarStatusIds(); 323 var ACTIVE = ids.active; 324 // Deliberately the two retired statuses rather than "anything not 325 // Active": In Development is also not Active, and labelling an 326 // unreleased model "Out of Production" would be wrong. 327 var RETIRED = ids.outOfProduction; 328 var self = this; 329 var changeHandler = null; 330 var boundHelper = null; 331 332 // Identify the state by the model_year refinement, which the 333 // out-of-production view always sets and the default view never does. 334 // Testing status_id for "something other than Active" instead would 335 // report the toggle as on for any third status a future filter refined 336 // to, and write ?out_of_production=true into the URL for a view showing 337 // neither retired nor Active guitars. 338 function isOn(state) {
339 var refined = state.getNumericRefinement(YEAR, '>='); 340 return Boolean(refined && refined.length); 341 } 342 343 // Both branches start from a clean slate so the two states can never 344 // overlap (which would return guitars matching neither view). 345 function clear(state) { 346 return state 347 .removeNumericRefinement(STATUS) 348 .removeNumericRefinement(YEAR) 349 .removeNumericRefinement(IMAGES); 350 } 351 352 function applyOn(state) { 353 // An array value becomes an OR across the retired ids. 354 return clear(state) 355 .addNumericRefinement(STATUS, '=', RETIRED) 356 .addNumericRefinement(IMAGES, '=', 1) 357 .addNumericRefinement(YEAR, '>=', self.outOfProductionCutoff()); 358 } 359 360 function applyOff(state) { 361 return clear(state).addNumericRefinement(STATUS, '=', ACTIVE); 362 } 363 364 return { 365 $$type: 'tg.outOfProduction', 366 $$widgetType: 'tg.outOfProduction', 367 368 init: function (initOptions) { 369 var helper = initOptions.helper; 370 boundHelper = helper; 371 372 // Invariant: status must always be refined one way or the other, or 373 // the default Active-only view is silently lost and the grid shows 374 // every guitar ever made. "Clear all filters" clears refinements 375 // directly on the helper without consulting widgets, so restore the 376 // default here. This runs synchronously on state change, before the 377 // cleared search is sent. 378 changeHandler = function (event) { 379 var state = event.state; 380 var refined = state.getNumericRefinement(STATUS, '='); 381 if (!(refined && refined.length)) { 382 helper.setState(applyOff(state)); 383 } 384 }; 385 helper.on('change', changeHandler); 386 387 // Reset to the default alongside "Clear all filters" (bubbling 388 // listener, so it runs after the clear widget's own handler). 389 if (clearButtonContainer) { 390 clearButtonContainer.addEventListener('click', function (event) { 391 if (event.target.closest('.ais-ClearRefinements-button') && isOn(helper.state)) { 392 helper.setState(applyOff(helper.state).resetPage()).search(); 393 } 394 }); 395 } 396 397 // No container (e.g. a cached page still serving the old template 398 // markup): skip the UI but keep the widget registered, since its 399 // search parameter handling is what enforces the Active-only default. 400 if (!container) { 401 return; 402 } 403 404 // Mimic toggleRefinement markup so the existing .special-filter 405 // styles apply unchanged. 406 container.innerHTML = 407 '<div class="ais-ToggleRefinement">' + 408 '<label class="ais-ToggleRefinement-label">' + 409 '<input class="ais-ToggleRefinement-checkbox" type="checkbox">' + 410 '<span class="ais-ToggleRefinement-labelText">Out of Production</span>' + 411 '</label>' + 412 '</div>'; 413 // Unlike next-gen/new-arrivals this toggle is not count-gated, so 414 // reveal the container (templates ship it display:none to avoid a 415 // flash of empty space before Algolia initializes). 416 container.style.display = ''; 417 418 container.querySelector('input').addEventListener('change', function () { 419 var next = this.checked ? applyOn(helper.state) : applyOff(helper.state); 420 helper.setState(next.resetPage()).search(); 421 }); 422 }, 423 424 render: function (renderOptions) { 425 var checkbox = container && container.querySelector('input'); 426 if (checkbox) { 427 checkbox.checked = isOn(renderOptions.helper.state); 428 } 429 }, 430 431 dispose: function (disposeOptions) { 432 if (boundHelper && changeHandler) { 433 boundHelper.removeListener('change', changeHandler); 434 } 435 if (container) { 436 container.innerHTML = ''; 437 } 438 return clear(disposeOptions.state); 439 }, 440 441 getWidgetUiState: function (uiState, widgetStateOptions) { 442 var toggle = uiState.toggle || {}; 443 if (isOn(widgetStateOptions.searchParameters)) { 444 toggle.out_of_production = true; 445 uiState.toggle = toggle; 446 } 447 else { 448 delete toggle.out_of_production; 449 if (Object.keys(toggle).length === 0) { 450 delete uiState.toggle; 451 } 452 } 453 return uiState; 454 }, 455 456 getWidgetSearchParameters: function (searchParameters, opts) { 457 var on = Boolean(opts.uiState.toggle && opts.uiState.toggle.out_of_production); 458 return on ? applyOn(searchParameters) : applyOff(searchParameters); 459 }, 460 }; 461 }, 462 463 /** 464 * Attribute-to-URL-param mapping for clean URL parameters. 465 */ 466 _refinementParamMap: {
467 guitar_shape: 'shape', 468 guitar_body_wood: 'body_wood', 469 top_wood: 'top_wood', 470 guitar_series: 'series', 471 guitar_collection: 'collection', 472 guitar_bracing: 'bracing', 473 guitar_specialty: 'specialty', 474 guitar_cutaway: 'cutaway', 475 guitar_neck_width: 'neck_width', 476 guitar_electronics: 'electronics', 477 product_category: 'category', 478 }, 479 480 /** 481 * Sort index suffix to short name mapping. 482 */ 483 _sortMap: { 484 'product_index_price_asc': 'price_asc', 485 'product_index_price_desc': 'price_desc', 486 'product_index_a_z': 'a_z', 487 'product_index_z_a': 'z_a', 488 'product_index_new': 'new', 489 }, 490 491 /** 492 * Create InstantSearch routing configuration with clean URL parameters. 493 * 494 * @param {string} indexName - The Algolia index name 495 * @param {Object} options - Configuration options 496 * @param {boolean} options.includeQuery - Map query to 'search' param (for search page) 497 * @return {Object} Routing config for instantsearch() 498 */ 499 createRouting: function (indexName, options) { 500 options = options || {}; 501 var self = this; 502 var indexPrefix = indexName.replace('product_index', ''); 503 504 // Build reverse sort map: short name -> full index name 505 var reverseSortMap = {}; 506 Object.keys(self._sortMap).forEach(function (suffix) { 507 reverseSortMap[self._sortMap[suffix]] = indexPrefix + suffix; 508 }); 509 510 // Build reverse refinement map: URL param -> attribute 511 var reverseRefinementMap = {}; 512 Object.keys(self._refinementParamMap).forEach(function (attr) { 513 reverseRefinementMap[self._refinementParamMap[attr]] = attr; 514 }); 515 516 return { 517 stateMapping: { 518 stateToRoute: function (uiState) { 519 var indexUiState = uiState[indexName] || {}; 520 var route = {}; 521 522 // Query (search page only) 523 if (options.includeQuery && indexUiState.query) { 524 route.search = indexUiState.query; 525 } 526 527 // Refinement lists 528 var refinements = indexUiState.refinementList || {}; 529 Object.keys(self._refinementParamMap).forEach(function (attr) { 530 if (refinements[attr] && refinements[attr].length) { 531 route[self._refinementParamMap[attr]] = refinements[attr].join(','); 532 } 533 }); 534 535 // Price range 536 var range = indexUiState.range || {}; 537 if (range.price_field) { 538 route.price = range.price_field; 539 } 540 541 // Toggle refinements 542 var toggle = indexUiState.toggle || {}; 543 if (toggle.field_next_generation) { 544 route.next_gen = 'true'; 545 } 546 if (toggle['new']) { 547 route.new_arrivals = 'true'; 548 } 549 if (toggle.out_of_production) { 550 route.out_of_production = 'true'; 551 } 552 553 // Sort (omit for default index) 554 if (indexUiState.sortBy && indexUiState.sortBy !== indexName) { 555 var sortSuffix = indexUiState.sortBy.replace(indexPrefix, ''); 556 if (self._sortMap[sortSuffix]) { 557 route.sort = self._sortMap[sortSuffix]; 558 } 559 } 560 561 // Page (omit page 1) 562 if (indexUiState.page && indexUiState.page > 1) { 563 route.page = indexUiState.page; 564 } 565 566 return route; 567 }, 568 569 routeToState: function (routeState) { 570 var state = {}; 571 572 // Query 573 if (options.includeQuery && routeState.search) { 574 state.query = routeState.search; 575 } 576 577 // Refinement lists 578 var refinementList = {}; 579 Object.keys(reverseRefinementMap).forEach(function (param) { 580 if (routeState[param]) { 581 refinementList[reverseRefinementMap[param]] = routeState[param].split(','); 582 } 583 }); 584 if (Object.keys(refinementList).length) { 585 state.refinementList = refinementList; 586 } 587 588 // Price range 589 if (routeState.price) { 590 state.range = { price_field: routeState.price }; 591 } 592 593 // Toggle refinements 594 var toggle = {}; 595 if (routeState.next_gen === 'true') { 596 toggle.field_next_generation = true; 597 } 598 if (routeState.new_arrivals === 'true') { 599 toggle['new'] = true; 600 } 601 if (routeState.out_of_production === 'true') { 602 toggle.out_of_production = true; 603 } 604 if (Object.keys(toggle).length) { 605 state.toggle = toggle; 606 } 607 608 // Sort 609 if (routeState.sort && reverseSortMap[routeState.sort]) { 610 state.sortBy = reverseSortMap[routeState.sort]; 611 } 612 613 // Page 614 if (routeState.page) { 615 state.page = Number(routeState.page); 616 } 617 618 var uiState = {}; 619 uiState[indexName] = state; 620 return uiState; 621 }, 622 }, 623 }; 624 }, 625 626 /** 627 * Fetch and render guitar cards from NIDs. 628 * 629 * @param {jQuery} $container - Container with .guitar-card-wrapper elements 630 * @param {Object} options - Optional settings 631 * @param {boolean} options.groupings - Include groupings (default: true) 632 * @param {boolean} options.finishes - Include finishes (default: true) 633 * @param {boolean} options.commerce - Include commerce data (default: true) 634 * @param {boolean} options.lefty - Show left-handed guitar versions (default: false) 635 * @param {Function} options.callback - Optional callback after rendering 636 */ 637 fetchAndRenderCards: function ($container, options) { 638 var self = this; 639 options = options || {}; 640 var groupingsParam = options.groupings !== false ? 'true' : 'false'; 641 var finishesParam = options.finishes !== false ? 'true' : 'false'; 642 var commerceParam = options.commerce !== false ? 'true' : 'false'; 643 var leftyParam = options.lefty === true; 644 var callback = options.callback; 645 646 var guitarNids = []; 647 var productNids = []; 648 649 $container.find('.guitar-card-wrapper').each(function () { 650 var nid = $(this).data('nid'); 651 var contentType = $(this).data('content-type'); 652 if (nid) { 653 if (contentType === 'product') { 654 productNids.push(nid); 655 } else { 656 guitarNids.push(nid); 657 } 658 } 659 }); 660 661 var requestsCompleted = 0; 662 var totalRequests = (guitarNids.length > 0 ? 1 : 0) + (productNids.length > 0 ? 1 : 0); 663 var allListingCards = {}; 664 665 var renderAllCards = function () { 666 // Model family finish rotation 667 var modelFamilyFinishIndex = {}; 668 669 $container.find('.guitar-card-wrapper').each(function () { 670 var $wrapper = $(this); 671 var nid = $wrapper.data('nid'); 672 var contentType = $wrapper.data('content-type'); 673 674 if (allListingCards[nid]) { 675 $wrapper.html(allListingCards[nid]); 676 677 // Process finishes for guitar cards (not products) 678 if (contentType !== 'product') {
679 self.processFinishes($wrapper, modelFamilyFinishIndex); 680 } 681 682 Drupal.attachBehaviors($wrapper[0]); 683 } 684 else { 685 // Defensive: the batch returned no card for this nid. This happens 686 // when a curated node is unpublished or deleted â the renderer 687 // (OptimizedNodeLoader) filters out unpublished content, so its 688 // entry is absent (or an empty string) in the response. Left alone, 689 // the wrapper stays empty and its grid cell renders as a blank 690 // "missing tile". Hide the whole cell so the grid closes up around 691 // the unavailable item instead of leaving a gap. 692 var $cell = $wrapper.closest('.ais-Hits-item'); 693 ($cell.length ? $cell : $wrapper) 694 .attr('aria-hidden', 'true') 695 .hide(); 696 } 697 }); 698 699 // Load commerce data 700 self.loadCommerceData($container, leftyParam); 701 702 if (callback) { 703 callback(); 704 } 705 }; 706 707 // Fetch guitar cards 708 if (guitarNids.length > 0) { 709 var guitarQueryParts = []; 710 guitarNids.forEach(function (nid) { 711 guitarQueryParts.push('nids[]=' + encodeURIComponent(nid)); 712 }); 713 guitarQueryParts.push('groupings=' + groupingsParam); 714 guitarQueryParts.push('finishes=' + finishesParam); 715 guitarQueryParts.push('commerce=' + commerceParam); 716 if (leftyParam) { 717 guitarQueryParts.push('lefty=true'); 718 } 719 var guitarQueryString = guitarQueryParts.join('&'); 720 721 $.get('/resp/listing-cards-batch?' + guitarQueryString, function (listingCards) {
722 Object.keys(listingCards).forEach(function (nid) { 723 allListingCards[nid] = listingCards[nid]; 724 }); 725 requestsCompleted++; 726 if (requestsCompleted === totalRequests) { 727 renderAllCards(); 728 } 729 }).fail(function (xhr, status, error) { 730 console.error('Error loading guitar listing cards: ' + error); 731 requestsCompleted++; 732 if (requestsCompleted === totalRequests) { 733 renderAllCards(); 734 } 735 }); 736 } 737 738 // Fetch product cards 739 if (productNids.length > 0) { 740 var productQueryParts = []; 741 productNids.forEach(function (nid) { 742 productQueryParts.push('nids[]=' + encodeURIComponent(nid)); 743 }); 744 var productQueryString = productQueryParts.join('&'); 745 746 $.get('/resp/product-listing-cards-batch?' + productQueryString, function (listingCards) { 747 Object.keys(listingCards).forEach(function (nid) { 748 allListingCards[nid] = listingCards[nid]; 749 }); 750 requestsCompleted++; 751 if (requestsCompleted === totalRequests) { 752 renderAllCards(); 753 } 754 }).fail(function (xhr, status, error) { 755 console.error('Error loading product listing cards: ' + error); 756 requestsCompleted++; 757 if (requestsCompleted === totalRequests) { 758 renderAllCards(); 759 } 760 }); 761 } 762 763 // If no requests needed, still call callback 764 if (totalRequests === 0 && callback) { 765 callback(); 766 } 767 }, 768 769 /** 770 * Populate finish/collection swatches for a card wrapper. 771 * 772 * Thin delegate. The implementation lives in tg_theme/product_card, which 773 * also owns the swatch and carousel click handling (delegated from the 774 * document, so nothing needs re-binding after a render). 775 * 776 * @param {jQuery} $wrapper - The guitar card wrapper element 777 * @param {Object} modelFamilyFinishIndex - Tracker for finish rotation 778 */ 779 processFinishes: function ($wrapper, modelFamilyFinishIndex) { 780 Drupal.tgProductCard.populateFinishes($wrapper, modelFamilyFinishIndex); 781 }, 782 783 /** 784 * Load commerce data for rs-placeholder elements. 785 * 786 * @param {jQuery} $container - Container with rs-placeholder elements 787 * @param {boolean} lefty - Whether to load left-handed guitar commerce data 788 */ 789 loadCommerceData: function ($container, lefty) { 790 var self = this; 791 var isLefty = lefty === true; 792 var $rsPlaceholders = $container.find('.rs-placeholder'); 793 794 if ($rsPlaceholders.length === 0) { 795 return; 796 } 797 798 // Collect all entity IDs that need loading 799 var entityIds = []; 800 var placeholderMap = {}; 801 802 $rsPlaceholders.each(function () { 803 var entityId = $(this).data('entity-id'); 804 if (entityId && !$(this).hasClass('loaded')) { 805 entityIds.push(entityId);
806 if (!placeholderMap[entityId]) { 807 placeholderMap[entityId] = []; 808 } 809 placeholderMap[entityId].push($(this)); 810 } 811 }); 812 813 if (entityIds.length === 0) { 814 return; 815 } 816 817 // Build query string for batch request 818 var queryParts = []; 819 entityIds.forEach(function (nid) { 820 queryParts.push('nids[]=' + encodeURIComponent(nid)); 821 }); 822 // Add lefty parameter if in lefty mode 823 if (isLefty) { 824 queryParts.push('lefty=true'); 825 } 826 827 // Make single batch request for all commerce data 828 $.ajax({ 829 url: '/resp/atc-price-templates-batch?' + queryParts.join('&'), 830 method: 'GET', 831 dataType: 'json', 832 success: function (responses) { 833 var locallyPending = []; 834 835 Object.keys(responses).forEach(function (entityId) { 836 var html = responses[entityId]; 837 var $placeholders = placeholderMap[entityId]; 838 839 if ($placeholders && html) { 840 $placeholders.forEach(function ($rsPlaceholder) { 841 var needsLocally = self.processCommercePlaceholder($rsPlaceholder, html, entityId); 842 var $lb = $rsPlaceholder.closest('.text-container').find('.lcly-button'); 843 if (needsLocally) { 844 if ($lb.length > 0) { 845 locallyPending.push({ $button: $lb, nid: entityId }); 846 } 847 } else if ($lb.length > 0) { 848 // ATC markup is present â hide the Locally placeholder for 849 // now. Inline style overrides CSS-reserved skeleton space 850 // without needing !important in the stylesheet. 851 $lb.hide(); 852 // ...but markup presence is only a server-side intent. RS can 853 // still decline the item at runtime (no online offer), in 854 // which case it leaves the form rsHidden forever and the card 855 // would show no CTA at all. Watch for that and put the 856 // locator back. Verified against Gold Label 917e/914e, which
857 // ship an ATC form that RS never enables. 858 self.watchForDeclinedAtc($rsPlaceholder, $lb, entityId); 859 } 860 }); 861 } 862 }); 863 864 // Products absent from the RS response have no online price/ATC. 865 entityIds.forEach(function (entityId) { 866 if (Object.prototype.hasOwnProperty.call(responses, entityId)) { return; } 867 var $placeholders = placeholderMap[entityId]; 868 if (!$placeholders) { return; } 869 $placeholders.forEach(function ($rsPlaceholder) { 870 var $lb = $rsPlaceholder.closest('.text-container').find('.lcly-button'); 871 if ($lb.length > 0) { 872 locallyPending.push({ $button: $lb, nid: entityId }); 873 } 874 }); 875 }); 876 877 self.loadLocallyWidgetsBatch(locallyPending); 878 }, 879 error: function (jqXHR, textStatus, errorThrown) { 880 console.error('Failed to batch load commerce data:', textStatus, errorThrown); 881 self.loadLocallyWidgetsFallback($rsPlaceholders); 882 }, 883 }); 884 }, 885 886 /** 887 * Process a single commerce placeholder with loaded HTML. 888 * 889 * @param {jQuery} $rsPlaceholder - The placeholder element 890 * @param {string} html - The commerce HTML to insert 891 * @param {string} entityId - The entity ID 892 */ 893 processCommercePlaceholder: function ($rsPlaceholder, html, entityId) { 894 $rsPlaceholder.html(html); 895 $rsPlaceholder.addClass('loaded'); 896 897 // Price display/formatting is owned by the unified component 898 // (tg_pricing): RS price always wins, system price only as fallback. 899 Drupal.tgPricing.hydrate($rsPlaceholder[0]); 900 901 // Check if add-to-cart form was loaded 902 var hasAddToCart = $rsPlaceholder.find('.rsAddToCartForm, .rsWithMiniCartModal, .atc-function').length > 0; 903 904 // Return whether Locally widget is needed â caller batches the requests. 905 return !hasAddToCart; 906 }, 907 908 /** 909 * Watch an RS offer container that shipped add-to-cart markup, and restore 910 * the Locally locator if RS settles without ever enabling it. 911 * 912 * The add-to-cart form in the batch response reflects server-side intent 913 * (PriceResolver::resolve()'s show_atc: on an RS domain, not hidden, not 914 * discontinued, has an RS item id). Whether an online offer actually 915 * exists is only known once RS answers. When it declines, it leaves 916 * .rsAddToCartForm carrying rsHidden and renders no control, so a card 917 * whose locator we already hid ends up with no call to action. 918 * 919 * @param {jQuery} $rsPlaceholder - The commerce placeholder 920 * @param {jQuery} $locallyButton - The Locally button container 921 * @param {string} entityId - The entity ID 922 */ 923 watchForDeclinedAtc: function ($rsPlaceholder, $locallyButton, entityId) { 924 var self = this; 925 var scope = $rsPlaceholder.closest('.rsProductOfferContainer')[0] || $rsPlaceholder[0]; 926 if (!scope) { return; } 927 928 // One watcher per offer container. refreshCommerce re-arms this over whole 929 // carousels, which sweeps up originals that are already being watched; 930 // two watchers would race to restore and double-fetch the widget. The flag 931 // is an expando on purpose: cloneNode does not copy it, so a clone is 932 // correctly treated as unwatched, which it is (observers do not clone). 933 if (scope.tgAtcDeclineWatch) { return; } 934 scope.tgAtcDeclineWatch = true; 935 936 var settled = false; 937 var observer = null; 938 var timer = null; 939 940 function atcLive() { 941 var form = scope.querySelector('.rsAddToCartForm'); 942 return !!(form && !form.classList.contains('rsHidden')); 943 } 944 945 function finish() { 946 if (settled) { return; } 947 settled = true; 948 if (observer) { observer.disconnect(); } 949 window.clearTimeout(timer); 950 // RS delivered an online offer: add-to-cart is the CTA, nothing to do. 951 if (atcLive()) { return; } 952 // RS declined. Give the card its locator back. 953 $locallyButton.show(); 954 if (!$locallyButton.hasClass('active')) { 955 self.loadLocallyWidgetsBatch([{ $button: $locallyButton, nid: entityId }]); 956 } 957 } 958 959 // The observer only lets us bail out early once RS enables add-to-cart;
960 // the timeout below is what actually decides a decline. Without 961 // MutationObserver we still settle correctly on the timer, just without 962 // the early exit. Bailing out here instead would leave the locator hidden 963 // forever, which is the very state this function exists to prevent. 964 if (window.MutationObserver) { 965 observer = new MutationObserver(function () { 966 if (atcLive()) { finish(); } 967 }); 968 observer.observe(scope, { 969 childList: true, 970 subtree: true, 971 attributes: true, 972 attributeFilter: ['class'], 973 }); 974 } 975 976 timer = window.setTimeout(finish, RS_SETTLE_TIMEOUT); 977 }, 978 979 /** 980 * Stand a Locally locator down once RS enables add-to-cart. 981 * 982 * The inverse of watchForDeclinedAtc, for callers that render a locator for 983 * every card up front (algolia-browse's initStaticFallback). There, hiding 984 * on markup presence and restoring on decline would blank the CTA for the 985 * whole settle window on every declined item. Waiting instead costs 986 * nothing: while the form is still rsHidden it renders no control, so the 987 * locator is the only visible CTA right up to the moment it is replaced. 988 * 989 * @param {jQuery} $rsPlaceholder - The commerce placeholder 990 * @param {jQuery} $locallyButton - The Locally button container 991 */ 992 watchForLiveAtc: function ($rsPlaceholder, $locallyButton) { 993 var scope = $rsPlaceholder.closest('.rsProductOfferContainer')[0] || $rsPlaceholder[0]; 994 if (!scope) { return; } 995 // Same expando rationale as watchForDeclinedAtc: not an attribute, so a 996 // clone reads as unwatched rather than inheriting a stale marker. 997 if (scope.tgAtcLiveWatch) { return; } 998 scope.tgAtcLiveWatch = true; 999 1000 var observer = null; 1001 var timer = null; 1002 1003 function atcLive() { 1004 var form = scope.querySelector('.rsAddToCartForm'); 1005 return !!(form && !form.classList.contains('rsHidden')); 1006 } 1007 1008 // RS already answered before we got here. 1009 if (atcLive()) { $locallyButton.hide(); return; } 1010 if (!window.MutationObserver) { return; } 1011 1012 observer = new MutationObserver(function () { 1013 if (!atcLive()) { return; } 1014 observer.disconnect(); 1015 window.clearTimeout(timer); 1016 $locallyButton.hide(); 1017 }); 1018 observer.observe(scope, { 1019 childList: true, 1020 subtree: true, 1021 attributes: true, 1022 attributeFilter: ['class'], 1023 }); 1024 1025 // RS declined or never answered: the locator is the correct CTA, so stop 1026 // watching and leave it exactly as it is. 1027 timer = window.setTimeout(function () { 1028 observer.disconnect(); 1029 }, RS_SETTLE_TIMEOUT); 1030 }, 1031 1032 /** 1033 * Re-run commerce hydration over a container whose DOM was restructured 1034 * (Slick init/reinit, tab swap, InstantSearch re-render). 1035 * 1036 * Carousels clone slides, and a clone is a copy of whatever state the 1037 * original happened to be in at that instant â which is a race. This makes 1038 * the outcome independent of who won: 1039 * - clones made BEFORE the batch resolved arrive with empty placeholders, 1040 * so they still need loading; 1041 * - clones made AFTER arrive fully populated but carry no observers, so 1042 * their prices need rebinding. 1043 * 1044 * Locally buttons deliberately are not re-armed here: their config lives on 1045 * attributes the clone already inherited, and any that genuinely lack it 1046 * self-heal on click via recoverLocallyWidget. 1047 * 1048 * @param {jQuery} $container - Container that was just restructured 1049 */ 1050 refreshCommerce: function ($container) { 1051 var self = this; 1052 if (!$container || !$container.length) { return; } 1053 1054 // Populates any placeholder not already marked .loaded; a no-op for the 1055 // ones that were cloned complete. 1056 this.loadCommerceData($container, false); 1057 1058 // Rebinds RS price observers on cloned price elements (the WeakSet guard 1059 // in tg_pricing sees clones as unbound; the copied data attribute does 1060 // not fool it). 1061 if (Drupal.tgPricing) { 1062 Drupal.tgPricing.hydrate($container[0]); 1063 } 1064 1065 // A clone taken after the commerce batch resolved but before the Locally 1066 // batch did carries a .loaded placeholder â so loadCommerceData skips it 1067 // â yet an empty locator button. That is the exact state that shipped a 1068 // blank CTA slot to live. Fill those in directly. 1069 // 1070 // Run twice: the Slick init that calls us can land on either side of the 1071 // in-flight widget batch, and the second pass costs nothing when the 1072 // first already settled it (buttons with content are skipped). 1073 function fillEmptyLocallyButtons() { 1074 var pending = []; 1075 $container.find('.lcly-button').each(function () { 1076 var $b = $(this); 1077 // Already wired, already populated, or deliberately hidden because 1078 // this product sells through RS. 1079 if ($b.hasClass('active') || $b.attr('data-lcly-endpoint')) { return; } 1080 // Native trim, not $.trim â jQuery 4 (this site ships 4.0.0) removed it. 1081 if ((this.innerHTML || '').trim() !== '') { return; } 1082 if ($b.css('display') === 'none') { return; } 1083 1084 var nid = $b.closest('.listing-card, .guitar-card-wrapper').attr('data-nid') 1085 || $b.closest('.text-container').find('.rs-placeholder[data-entity-id]').first().attr('data-entity-id'); 1086 if (nid) { pending.push({ $button: $b, nid: nid }); } 1087 }); 1088 if (pending.length) { self.loadLocallyWidgetsBatch(pending); } 1089 } 1090 1091 // Clones taken while RS was still settling are the one shape neither pass 1092 // above can repair: the placeholder is .loaded (so loadCommerceData skips 1093 // it) and the locator carries inline display:none (so the pass above skips 1094 // it), yet the MutationObserver that would have restored the locator did 1095 // not come along with the clone. Hidden means recoverLocallyWidget cannot 1096 // self-heal on click either, so the slide keeps a blank CTA slot for good. 1097 // Re-arming is safe for genuinely purchasable products: the watcher stands 1098 // down the moment RS enables add-to-cart. 1099 function rearmDeclinedAtcWatch() { 1100 $container.find('.rs-placeholder.loaded').each(function () { 1101 var $ph = $(this); 1102 var scope = $ph.closest('.rsProductOfferContainer')[0] || this; 1103 var form = scope.querySelector('.rsAddToCartForm'); 1104 // Only the pending-decline shape: ATC markup shipped, RS has not 1105 // enabled it, and the locator was hidden on that markup's strength. 1106 if (!form || !form.classList.contains('rsHidden')) { return; } 1107 var $lb = $ph.closest('.text-container').find('.lcly-button'); 1108 if ($lb.length === 0 || $lb.css('display') !== 'none') { return; } 1109 var nid = $ph.attr('data-entity-id'); 1110 if (!nid) { return; } 1111 self.watchForDeclinedAtc($ph, $lb, nid); 1112 }); 1113 } 1114 1115 fillEmptyLocallyButtons(); 1116 rearmDeclinedAtcWatch(); 1117 window.setTimeout(fillEmptyLocallyButtons, 1500); 1118 }, 1119 1120 /** 1121 * Apply Locally widget HTML to a button element. 1122 * Shared by loadLocallyWidget and loadLocallyWidgetsBatch. 1123 * 1124 * @param {jQuery} $locallyButton - The Locally button container 1125 * @param {string} locallyHtml - HTML response from the Locally widget endpoint 1126 */ 1127 applyLocallyWidgetHtml: function ($locallyButton, locallyHtml) { 1128 var self = this; 1129 1130 // Replace the embedded sequence with a fresh unique one so that each card 1131 // instance gets distinct IDs even when the same product appears multiple times. 1132 var seqMatch = locallyHtml.match(/id="lcly-config-([^"]+)"/); 1133 if (seqMatch && seqMatch[1]) { 1134 var originalSeq = seqMatch[1]; 1135 var nidPart = originalSeq.split('_')[0]; 1136 var newSeq = nidPart + '_' + (++self.locallyWidgetIndex); 1137 locallyHtml = locallyHtml.split(originalSeq).join(newSeq); 1138 } 1139 1140 var $temp = $('<div>').html(locallyHtml); 1141 var scripts = []; 1142 1143 $temp.find('script').each(function () { 1144 scripts.push({ 1145 id: $(this).attr('id'), 1146 src: $(this).attr('src'), 1147 content: $(this).html(), 1148 }); 1149 $(this).remove(); 1150 }); 1151 1152 $locallyButton.html($temp.html()); 1153 $locallyButton.addClass('active'); 1154 1155 var locallyEndpoint = null; 1156 var locallyScriptId = null; 1157
1158 scripts.forEach(function (script) { 1159 if (script.id && script.id.indexOf('lcly-config-') !== -1 && script.content) { 1160 try { 1161 var seqId = script.id.replace('lcly-config-', ''); 1162 locallyScriptId = 'lcly-script-' + seqId; 1163 1164 var configMatch = script.content.match(/lcly_config_[^=]+=\s*(\{[\s\S]*?\});/); 1165 if (configMatch && configMatch[1]) { 1166 var config = JSON.parse(configMatch[1]); 1167 var query = Object.keys(config).reduce(function (a, k) { 1168 a.push(encodeURIComponent(k) + '=' + encodeURIComponent(config[k])); 1169 return a; 1170 }, []).join('&'); 1171 locallyEndpoint = 'https://Taylor-Guitars.locally.com/stores/map.js?' + query; 1172 } 1173 } catch (e) { 1174 console.error('Error processing Locally config:', e); 1175 } 1176 } else if (script.id && script.id.indexOf('lcly-script-') !== -1 && !script.src && !script.content) { 1177 // Skip empty lcly-script placeholder 1178 } else { 1179 var scriptElement = document.createElement('script'); 1180 if (script.id) scriptElement.id = script.id; 1181 if (script.src) scriptElement.src = script.src; 1182 if (script.content) scriptElement.textContent = script.content; 1183 $locallyButton[0].appendChild(scriptElement); 1184 } 1185 }); 1186 1187 if (locallyEndpoint && locallyScriptId) { 1188 // Defer loading Locally's map.js until the user opens the locator. 1189 // Each map.js response is ~50KB, uncacheable (Cache-Control: private, 1190 // max-age=5, no CORS), prompts for geolocation, and often reloads 1191 // itself once more, so eager per-card loading multiplies into dozens 1192 // of locally.com requests on grid pages. The fallback anchor injected 1193 // above is already styled as the "Find Locally" button by CSS, so the 1194 // card looks complete without the script. 1195 // 1196 // Stored as DOM ATTRIBUTES, not jQuery data. Slick's infinite mode 1197 // clones slides with native cloning, which copies attributes but drops 1198 // jQuery's data store â so a config kept in .data() leaves every cloned 1199 // card's button dead. Whether the clone happens before or after this 1200 // runs is a race that resolves differently per environment (it held on 1201 // local and test, and lost on live), so the state has to live somewhere 1202 // cloning preserves rather than depend on the ordering. 1203 $locallyButton.attr('data-lcly-endpoint', locallyEndpoint); 1204 $locallyButton.attr('data-lcly-script-id', locallyScriptId); 1205 } 1206 1207 Drupal.attachBehaviors($locallyButton[0]); 1208 }, 1209 1210 /** 1211 * Load the deferred Locally map.js for one card and open its locator. 1212 * 1213 * Invoked by the delegated click handler at the bottom of this file the 1214 * first time a user clicks a card's "Find Locally" button. 1215 * 1216 * @param {jQuery} $locallyButton - The Locally button container 1217 */ 1218 activateLocallyWidget: function ($locallyButton) { 1219 var self = this; 1220 var el = $locallyButton[0]; 1221 if (!el) { return; } 1222 1223 // Attribute-based, so a Slick clone carries its own working config. 1224 var endpoint = el.getAttribute('data-lcly-endpoint'); 1225 var scriptId = el.getAttribute('data-lcly-script-id'); 1226 1227 // Already running: swallow the click rather than stacking loads. 1228 if (el.getAttribute('data-lcly-activating') === '1') { return; } 1229 1230 // No config. Rather than dead-end (the state that sent customers to 1231 // join.locally.com), re-request this card's widget and activate once it 1232 // lands, so the click the user already made still opens the locator. 1233 if (!endpoint || !scriptId) { 1234 self.recoverLocallyWidget($locallyButton); 1235 return; 1236 } 1237 1238 el.setAttribute('data-lcly-activating', '1'); 1239 1240 var locallyScript = document.createElement('script'); 1241 locallyScript.id = scriptId; 1242 locallyScript.src = endpoint; 1243 locallyScript.onload = function () { 1244 el.removeAttribute('data-lcly-endpoint'); 1245 el.removeAttribute('data-lcly-activating'); 1246 // map.js has rendered the real trigger. Wait for Locally's inline 1247 // modal app (main.js, loaded by map.js) before clicking it; clicks 1248 // made earlier are queued by Locally and never visibly open the 1249 // modal. The trigger is re-queried each tick because map.js can 1250 // re-render itself (location reload) in the meantime. The click also 1251 // fires the delegated GA4 find_dealer handler in global.js once. 1252 var attempts = 0; 1253 var opener = setInterval(function () { 1254 attempts++; 1255 var ready = typeof window.lclyInlineModalGlobal === 'function'; 1256 if (ready || attempts >= 40) { 1257 clearInterval(opener); 1258 // Belt-and-braces against duplicate analytics: this synthetic click 1259 // is what fires GA4 find_dealer and the Algolia "Find Locally 1260 // Click" conversion, so it must happen at most once per button no 1261 // matter how many activation paths converge here. 1262 if (el.hasAttribute('data-lcly-opened')) { return; } 1263 var trigger = $locallyButton.find('.lcly-primary-trigger')[0]; 1264 if (trigger) { 1265 el.setAttribute('data-lcly-opened', '1'); 1266 trigger.click(); 1267 } 1268 } 1269 }, 200); 1270 }; 1271 locallyScript.onerror = function () { 1272 // Leave the endpoint attribute in place so the next click can retry. 1273 el.removeAttribute('data-lcly-activating'); 1274 }; 1275 el.appendChild(locallyScript); 1276 }, 1277 1278 /** 1279 * Re-fetch a Locally widget whose activation config is missing, then 1280 * activate it, so a click is never silently dropped. 1281 * 1282 * Reached when a button was rendered without config: a Slick clone made 1283 * before the widget batch resolved, markup restored from bfcache, or a 1284 * failed initial request. 1285 * 1286 * @param {jQuery} $locallyButton - The Locally button container 1287 */ 1288 recoverLocallyWidget: function ($locallyButton) { 1289 var self = this; 1290 var el = $locallyButton[0]; 1291 if (!el) { return; } 1292 1293 // One attempt per button, for the life of the page. Without this, 1294 // recovery recurses: applyLocallyWidgetHtml can return markup with no 1295 // parseable config, leaving activateLocallyWidget to call back in here, 1296 // which re-applies, and so on. Measured at 30 activations and 15 1297 // duplicate find_dealer events from a single user click â which would 1298 // have corrupted GA4 and Algolia conversion data far more quietly than 1299 // it broke the UI. 1300 if (el.hasAttribute('data-lcly-recovered')) { return; } 1301 el.setAttribute('data-lcly-recovered', '1'); 1302 1303 var $card = $locallyButton.closest('.listing-card, .guitar-card-wrapper, .text-container'); 1304 var nid = $card.attr('data-nid') 1305 || $card.find('.rs-placeholder[data-entity-id]').first().attr('data-entity-id') 1306 || $locallyButton.closest('.text-container').find('.rs-placeholder[data-entity-id]').first().attr('data-entity-id'); 1307 if (!nid) { return; } 1308 1309 el.setAttribute('data-lcly-recovering', '1'); 1310 1311 $.ajax({ 1312 url: '/ajax/locally-widgets-batch?nids[]=' + encodeURIComponent(nid), 1313 method: 'GET', 1314 dataType: 'json', 1315 success: function (responses) { 1316 var html = responses && responses[nid]; 1317 if (!html) { return; } 1318 self.applyLocallyWidgetHtml($locallyButton, html); 1319 // Honour the click the user already made â but only if c
1319onfig 1320 // genuinely landed this time. Calling activate unconditionally is 1321 // what allowed the recursion. 1322 if (el.getAttribute('data-lcly-endpoint')) { 1323 self.activateLocallyWidget($locallyButton); 1324 } 1325 }, 1326 error: function () {}, 1327 }); 1328 }, 1329 1330 /** 1331 * Load a Locally widget for a specific entity (single request). 1332 * 1333 * @param {jQuery} $locallyButton - The Locally button container 1334 * @param {string} entityId - The entity ID 1335 */ 1336 loadLocallyWidget: function ($locallyButton, entityId) { 1337 var self = this; 1338 1339 self.locallyWidgetIndex++; 1340 var sequence = entityId + '_' + self.locallyWidgetIndex; 1341 1342 $.ajax({ 1343 url: '/ajax/locally-widget/' + entityId + '/' + sequence, 1344 method: 'GET', 1345 success: function (locallyHtml) { 1346 self.applyLocallyWidgetHtml($locallyButton, locallyHtml); 1347 }, 1348 error: function () { 1349 console.error('Failed to load Locally widget for entity ' + entityId); 1350 $locallyButton.addClass('active'); 1351 }, 1352 }); 1353 }, 1354 1355 /** 1356 * Batch-load Locally widgets for multiple product cards in a single request. 1357 * 1358 * @param {Array} items - Array of { $button: jQuery, nid: string } objects 1359 */ 1360 loadLocallyWidgetsBatch: function (items) { 1361 var self = this; 1362 1363 if (!items || items.length === 0) { return; } 1364 1365 // Group buttons by NID (same product may appear in multiple finish slots). 1366 var buttonsByNid = {}; 1367 items.forEach(function (item) { 1368 if (!item.$button || !item.nid) { return; } 1369 if (!buttonsByNid[item.nid]) { buttonsByNid[item.nid] = []; } 1370 buttonsByNid[item.nid].push(item.$button); 1371 }); 1372 1373 var nids = Object.keys(buttonsByNid); 1374 if (nids.length === 0) { return; } 1375 1376 var queryParts = nids.map(function (nid) { return 'nids[]=' + encodeURIComponent(nid); }); 1377 1378 $.ajax({ 1379 url: '/ajax/locally-widgets-batch?' + queryParts.join('&'), 1380 method: 'GET', 1381 dataType: 'json', 1382 success: function (responses) { 1383 Object.keys(responses).forEach(function (nid) { 1384 var html = responses[nid]; 1385 if (!html) { return; } 1386 (buttonsByNid[nid] || []).forEach(function ($button) { 1387 self.applyLocallyWidgetHtml($button, html); 1388 }); 1389 }); 1390 }, 1391 error: function () { 1392 // Fall back to individual requests so buttons still work. 1393 nids.forEach(function (nid) { 1394 (buttonsByNid[nid] || []).forEach(function ($button) { 1395 self.loadLocallyWidget($button, nid); 1396 }); 1397 }); 1398 }, 1399 }); 1400 }, 1401 1402 /** 1403 * Fallback: Load Locally widgets when commerce data fails. 1404 * 1405 * @param {jQuery} $rsPlaceholders - The placeholder elements 1406 */ 1407 loadLocallyWidgetsFallback: function ($rsPlaceholders) { 1408 var self = this; 1409 var items = []; 1410 1411 $rsPlaceholders.each(function () { 1412 var entityId = $(this).data('entity-id'); 1413 var $locallyButton = $(this).closest('.text-container').find('.lcly-button'); 1414 if ($locallyButton.length > 0) { 1415 items.push({ $button: $locallyButton, nid: entityId }); 1416 } 1417 }); 1418 1419 self.loadLocallyWidgetsBatch(items); 1420 }, 1421 1422 /** 1423 * Setup filter panel open/close handlers. 1424 * 1425 * @param {jQuery} $container - Container with filter elements 1426 */ 1427 setupFilterHandlers: function ($container) { 1428 // Use event delegation so handlers work even if DOM is re-rendered 1429 $container.on('click', '.filters .heading .close', function () { 1430 $(this).closest('.filters').removeClass('open'); 1431 $('body').removeClass('modal-open'); 1432 }); 1433 1434 $container.on('click', '.filters .apply', function () { 1435 $(this).closest('.filters').removeClass('open'); 1436 $('body').removeClass('modal-open'); 1437 }); 1438 1439 $container.on('click', '.all-filters', function () { 1440 // Find the filters panel - could be a sibling or within .results 1441 var $filters = $(this).closest('.browse-content, .results-tab').find('.filters'); 1442 if ($filters.length === 0) { 1443 $filters = $(this).siblings('.results').find('.filters'); 1444 } 1445 $filters.addClass('open'); 1446 $('body').addClass('modal-open'); 1447 }); 1448 1449 // Restore scroll if window resizes past mobile breakpoint while filters are open 1450 $(window).on('resize.filterHandlers', function () { 1451 if (window.innerWidth > 767 && $('body').hasClass('modal-open')) { 1452 $('.filters').removeClass('open'); 1453 $('body').removeClass('modal-open'); 1454 } 1455 }); 1456 }, 1457 1458 /** 1459 * Setup filter panel header toggle handlers. 1460 * Uses event delegation to work with dynamically rendered Algolia panels. 1461 * 1462 * @param {jQuery} $container - Container with filter panels 1463 */ 1464 setupFilterPanelToggles: function ($container) { 1465 // Use event delegation so it works for dynamically added panels 1466 $container.on('click', '.ais-Panel-header', function () { 1467 var $panel = $(this).parent(); 1468 $panel.toggleClass('open'); 1469 1470 // Toggle the collection list when the series panel is toggled 1471 var $panelContainer = $panel.parent(); 1472 var $collectionList = $panelContainer.next('.collection-list-facet'); 1473 if ($collectionList.length) { 1474 $collectionList.toggleClass('open'); 1475 } 1476 }); 1477 1478 // Mirror the price slider's live position into the Min/Max input
1478s while 1479 // dragging (the custom inputs handle their own refine on commit). 1480 Drupal.algoliaCommon.attachPriceSliderLiveSync($container); 1481 }, 1482 1483 /** 1484 * connectRange render function for the price Min/Max inputs. 1485 * 1486 * Renders custom TEXT inputs (reusing the .ais-RangeInput-* classes so the 1487 * existing SCSS applies) so values can carry thousands separators and be 1488 * driven live by the slider. Refines on blur / Enter. 1489 * 1490 * @param {Object} renderOptions - connectRange render options. 1491 * @param {boolean} isFirstRender - True on the initial render. 1492 */ 1493 renderPriceRangeInputs: function (renderOptions, isFirstRender) { 1494 var range = renderOptions.range; 1495 var start = renderOptions.start; 1496 var refine = renderOptions.refine; 1497 var params = renderOptions.widgetParams; 1498 var container = typeof params.container === 'string' 1499 ? document.querySelector(params.container) 1500 : params.container; 1501 if (!container) { 1502 return; 1503 } 1504 1505 var fmt = function (v) { 1506 return (v || v === 0) && isFinite(v) ? Number(v).toLocaleString('en-US') : ''; 1507 }; 1508 var parse = function (s) { 1509 var n = parseInt(String(s).replace(/[^0-9]/g, ''), 10); 1510 return isNaN(n) ? undefined : n; 1511 }; 1512 1513 if (isFirstRender) { 1514 container.innerHTML = 1515 '<div class="ais-RangeInput">' + 1516 '<div class="ais-RangeInput-form">' + 1517 '<label class="ais-RangeInput-label">' + 1518 '<input class="ais-RangeInput-input ais-RangeInput-input--min" type="text" inputmode="numeric" autocomplete="off" aria-label="Minimum price">' + 1519 '</label>' + 1520 '<span class="ais-RangeInput-separator">â</span>' + 1521 '<label class="ais-RangeInput-label">' + 1522 '<input class="ais-RangeInput-input ais-RangeInput-input--max" type="text" inputmode="numeric" autocomplete="off" aria-label="Maximum price">' + 1523 '</label>' + 1524 '</div>' + 1525 '</div>'; 1526 1527 var minEl = container.querySelector('.ais-RangeInput-input--min'); 1528 var maxEl = container.querySelector('.ais-RangeInput-input--max'); 1529 var commit = function () { 1530 refine([parse(minEl.value), parse(maxEl.value)]); 1531 }; 1532 [minEl, maxEl].forEach(function (el) { 1533 // Strip separators for clean editing; reformat happens on re-render. 1534 el.addEventListener('focus', function () { 1535 el.value = el.value.replace(/[^0-9]/g, ''); 1536 }); 1537 el.addEventListener('change', commit); 1538 el.addEventListener('keydown', function (e) { 1539 if (e.key === 'Enter') { 1540 e.preventDefault(); 1541 el.blur(); 1542 } 1543 }); 1544 }); 1545 container._priceRefs = { minEl: minEl, maxEl: maxEl }; 1546 } 1547 1548 var refs = container._priceRefs; 1549 if (!refs) { 1550 return; 1551 } 1552 // Reflect the current refinement (or the data bounds when unset), but never 1553 // clobber a field the user is actively editing. 1554 var curMin = start[0] === -Infinity || start[0] === undefined ? range.min : start[0]; 1555 var curMax = start[1] === Infinity || start[1] === undefined ? range.max : start[1]; 1556 if (document.activeElement !== refs.minEl) { 1557 refs.minEl.value = fmt(curMin); 1558 } 1559 if (document.activeElement !== refs.maxEl) { 1560 refs.maxEl.value = fmt(curMax); 1561 } 1562 refs.minEl.placeholder = fmt(range.min); 1563 refs.maxEl.placeholder = fmt(range.max); 1564 }, 1565 1566 /** 1567 * Mirror the rheostat slider's live handle values into the price inputs 1568 * while dragging, so the values update in real time (not just on release). 1569 * 1570 * The rheostat handles carry aria-valuenow, which updates continuously 1571 * during a drag. We observe the track subtree (handles are re-created on 1572 * each slider render) and write the formatted values into the inputs, 1573 * unless the user is actively editing that field. 1574 * 1575 * @param {jQuery} $container - Container holding the price filter panel(s). 1576 */ 1577 attachPriceSliderLiveSync: function ($container) { 1578 var fmt = function (v) { 1579 var n = parseInt(v, 10);
1580 return isNaN(n) ? '' : n.toLocaleString('en-US'); 1581 }; 1582 $container.find('.price-filter-panel').each(function () { 1583 var panel = this; 1584 var track = panel.querySelector('.price-track'); 1585 var inputWrap = panel.querySelector('.price-input'); 1586 if (!track || !inputWrap || track._tgLiveSync) { 1587 return; 1588 } 1589 track._tgLiveSync = true; 1590 var sync = function () { 1591 var minEl = inputWrap.querySelector('.ais-RangeInput-input--min'); 1592 var maxEl = inputWrap.querySelector('.ais-RangeInput-input--max'); 1593 var handles = track.querySelectorAll('.rheostat-handle'); 1594 if (!minEl || !maxEl || handles.length < 2) { 1595 return; 1596 } 1597 if (document.activeElement !== minEl) { 1598 minEl.value = fmt(handles[0].getAttribute('aria-valuenow')); 1599 } 1600 if (document.activeElement !== maxEl) { 1601 maxEl.value = fmt(handles[1].getAttribute('aria-valuenow')); 1602 } 1603 }; 1604 new MutationObserver(sync).observe(track, { 1605 attributes: true, 1606 attributeFilter: ['aria-valuenow'], 1607 subtree: true, 1608 }); 1609 }); 1610 }, 1611 1612 /** 1613 * Check if current viewport is mobile. 1614 * 1615 * @return {boolean} True if mobile viewport 1616 */ 1617 isMobile: function () { 1618 return window.innerWidth < 768; 1619 }, 1620 1621 /** 1622 * Update pagination visibility based on number of pages. 1623 * Hides pagination when there's only one page of results. 1624 * 1625 * @param {Object} search - InstantSearch instance 1626 * @param {string} indexName - The Algolia index name 1627 * @param {jQuery} $paginationContainer - The pagination container element 1628 */ 1629 updatePaginationVisibility: function (search, indexName, $paginationContainer) { 1630 var paginationState = search.renderState[indexName]?.pagination; 1631 if (paginationState && paginationState.nbPages <= 1) { 1632 $paginationContainer.hide(); 1633 } else { 1634 $paginationContainer.show(); 1635 } 1636 }, 1637 1638 /** 1639 * Update toggle refinement visibility based on facet count. 1640 * Hides the toggle filter when there are no matching results. 1641 * 1642 * @param {Object} search - InstantSearch instance 1643 * @param {string} indexName - The Algolia index name 1644 * @param {string} attribute - The facet attribute name 1645 * @param {jQuery} $container - The toggle container element 1646 * @return {boolean} Whether the toggle is visible (has results) 1647 */ 1648 updateToggleVisibility: function (search, indexName, attribute, $container) { 1649 var toggleState = search.renderState[indexName]?.toggleRefinement; 1650 if (toggleState && toggleState[attribute]) { 1651 // The count is in value.onFacetValue.count for toggleRefinement widgets 1652 var onFacetValue = toggleState[attribute].value?.onFacetValue; 1653 var count = onFacetValue?.count; 1654 if (count === 0 || count === undefined || count === null) { 1655 $container.hide(); 1656 return false; 1657 } else { 1658 $container.show(); 1659 return true; 1660 } 1661 } 1662 return false; 1663 }, 1664 1665 /** 1666 * Update special filters section visibility. 1667 * Hides the divider when all special filters are hidden. 1668 * 1669 * @param {boolean} nextGenVisible - Whether Next Generation filter is visible 1670 * @param {boolean} newArrivalsVisible - Whether New Arrivals filter is visible 1671 * @param {boolean} outOfProductionVisible - Whether Out of Production filter is visible 1672 * @param {jQuery} $divider - The filter divider element 1673 */ 1674 updateSpecialFiltersDivider: function (nextGenVisible, newArrivalsVisible, outOfProductionVisible, $divider) { 1675 if (!nextGenVisible && !newArrivalsVisible && !outOfProductionVisible) { 1676 $divider.hide(); 1677 } else { 1678 $divider.show(); 1679 } 1680 }, 1681 1682 /** 1683 * UA substrings that mark a request as a bot, scraper, headless 1684 * browser, or HTTP-library client we want to keep out of Algolia. 1685 * 1686 * Match is case-insensitive substring. Order doesn't matter. Most 1687 * legitimate crawlers self-identify with `bot`, `crawl`, `spider`, 1688 * or `slurp`; the explicit names below catch (a) AI/LLM crawlers 1689 * that use bare brand tokens (Claude-Web, ChatGPT-User, cohere-ai, 1690 * etc.), (b) automation harnesses (HeadlessChrome, Puppeteer, 1691 * Playwright, Selenium, WebDriver, Cypress, Lighthouse), and 1692 * (c) HTTP libraries that humans never run a browser through 1693 * (curl, Wget, python-requests, Go-http-client, okhttp, etc.). 1694 * 1695 * Keep this aligned with the server-side deny list in 1696 * web/sites/default/settings.php. Note the two lists serve 1697 * different jobs: this one keeps bots out of Algolia at runtime, 1698 * the settings.php one returns 403 before Drupal boots. 1699 */ 1700 BOT_UA_REGEX: /bot|crawl|spider|slurp|AdsBot|AmazonProductDiscovery|Bytespider|GPTBot|ClaudeBot|PerplexityBot|facebookexternalhit|HeadlessChrome|Puppeteer|Playwright|PhantomJS|Selenium|WebDriver|Cypress|Lighthouse|scrapy|Mediapartners-Google|ChatGPT-User|Claude-Web|Claude-User|cohere-ai|MistralAI|Anthropic-AI|meta-externalagent|python-requests|Go-http-client|curl\/|Wget|okhttp|Apache-HttpClient|libwww|httpx|aiohttp|Guzzle|StatusCake|ia_archiver|omgili/i, 1701 1702 /** 1703 * sessionStorage key holding the reason the Algolia gate last decided 1704 * the way it did. The bot fallback is otherwise completely silent, which 1705 * makes field reports ("I got the crawler view once") unfalsifiable â 1706 * this leaves a value the affected visitor can read straight back: 1707 * 1708 * sessionStorage.getItem('tg_algolia_bypass_reason') 1709 * 1710 * The stored value is JSON: {r: reason, p: path, t: epoch ms}. The path 1711 * and timestamp matter because the gate only runs on pages that carry a 1712 * search or browse block â without them, a value left over from an 1713 * earlier page in the same tab reads as if it described the current one. 1714 * 1715 * Also mirrored to Drupal.algoliaCommon.lastBypassReason (the bare reason 1716 * string) for the case where storage is unavailable. 1717 */ 1718 BYPASS_REASON_KEY: 'tg_algolia_bypass_reason', 1719 1720 /** 1721 * Record why the gate decided as it did. 'none' means the visitor was 1722 * treated as human and Algolia initialised normally. 1723 * 1724 * @param {string} reason - Short machine-readable reason token. 1725 */ 1726 recordBypassReason: function (reason) { 1727 Drupal.algoliaCommon.lastBypassReason = reason;
1728 try { 1729 window.sessionStorage.setItem( 1730 Drupal.algoliaCommon.BYPASS_REASON_KEY, 1731 JSON.stringify({ 1732 r: reason, 1733 p: window.location.pathname, 1734 t: Date.now(), 1735 }) 1736 ); 1737 } 1738 catch (e) { 1739 // Private mode / storage blocked. lastBypassReason above still holds 1740 // the value, so the console read-back keeps working. 1741 } 1742 }, 1743 1744 /** 1745 * Returns true when the current browser is human, recent enough, 1746 * and not a known automation tool. This is the gate for Algolia 1747 * initialization â false means we render only the server-side 1748 * fallback and never call Algolia. 1749 * 1750 * Browser version floor (early 2023, well past the 2020 minimum): 1751 * - Chrome / Chromium Edge / CriOS >= 110 (Feb 2023) 1752 * - Firefox >= 110 (Feb 2023) 1753 * - Safari (Version/X.X) >= 16 (Sep 2022) 1754 * Anything older returns false. 1755 * 1756 * Layered checks beyond the version floor: 1757 * - BOT_UA_REGEX substring match 1758 * - navigator.webdriver (Selenium/Puppeteer/Playwright/etc.) 1759 * - empty navigator.languages (typical of headless) 1760 * 1761 * Deliberately NOT checked: window.outerWidth/outerHeight === 0. It read 1762 * as a headless signal, but it is a point-in-time measurement rather than 1763 * a property of the client, and it is 0 for real people whenever the 1764 * renderer runs before the browser window has been realised and sized â 1765 * a cold browser launch from an email client, or a prerendered tab that 1766 * has not been activated. That produced the crawler view for legitimate 1767 * visitors arriving from Braze email links, which then corrected itself 1768 * on refresh once the window existed. Every automation harness it caught 1769 * is already rejected by BOT_UA_REGEX, navigator.webdriver, or the 1770 * version floor, and any UA that matches no known engine is rejected 1771 * outright below, so relocating the check to that path would leave it 1772 * dead code. Do not reinstate it without a non-transient signal. 1773 */ 1774 isModernBrowser: function () { 1775 // Trusted-automation override: the one authorised agent (Claude Code 1776 // driving via the Chrome extension) carries a secret token, which lets it 1777 // drive Algolia pages like a human, past every bot guard below. No other 1778 // bot/crawler has the token, so the public bot protection is unchanged. 1779 // See Drupal.algoliaCommon.hasTrustedAutomationOverride(). 1780 if (Drupal.algoliaCommon.hasTrustedAutomationOverride()) { 1781 Drupal.algoliaCommon.recordBypassReason('none:trusted-automation-token'); 1782 return true; 1783 } 1784 var ua = navigator.userAgent; 1785 if (!ua) { 1786 Drupal.algoliaCommon.recordBypassReason('empty-user-agent'); 1787 return false; 1788 } 1789 if (Drupal.algoliaCommon.BOT_UA_REGEX.test(ua)) { 1790 Drupal.algoliaCommon.recordBypassReason('bot-ua-regex'); 1791 return false; 1792 } 1793 if (navigator.webdriver) { 1794 Drupal.algoliaCommon.recordBypassReason('navigator-webdriver'); 1795 return false; 1796 } 1797 if (!navigator.languages || navigator.languages.length === 0) { 1798 Drupal.algoliaCommon.recordBypassReason('empty-navigator-languages'); 1799 return false; 1800 } 1801 1802 // Resolve the engine and its version floor in one pass so the reason 1803 // marker can name both. 1804 var engines = [ 1805 // Chrome on iOS reports as CriOS, not Chrome. Check it first. 1806 { name: 'crios', pattern: /CriOS\/(\d+)/, floor: 110 }, 1807 // Modern Edge is Chromium-based (Edg/). Old EdgeHTML (Edge/) is not 1808 // matched here on purpose â pre-Chromium Edge (2015â2019) is blocked. 1809 { name: 'edge', pattern: /Edg\/(\d+)/, floor: 110 }, 1810 // Chrome / Chromium / Brave / Opera (must come before Safari since 1811 // Chrome's UA also contains "Safari"). 1812 { name: 'chrome', pattern: /Chrome\/(\d+)/, floor: 110 }, 1813 { name: 'firefox', pattern: /Firefox\/(\d+)/, floor: 110 }, 1814 // Safari without "Chrome" in the UA â desktop and iOS Safari. 1815 { name: 'safari', pattern: /Version\/(\d+).*Safari/, floor: 16 }, 1816 ]; 1817 1818 for (var i = 0; i < engines.length; i++) { 1819 var match = ua.match(engines[i].pattern); 1820 if (!match) { 1821 continue; 1822 } 1823 var version = parseInt(match[1], 10); 1824 if (version >= engines[i].floor) { 1825 Drupal.algoliaCommon.recordBypassReason('none'); 1826 return true; 1827 } 1828 Drupal.algoliaCommon.recordBypassReason( 1829 'below-version-floor:' + engines[i].name + '/' + version 1830 ); 1831 return false;
1832 } 1833 1834 // Unknown browser engines: not given the benefit of the doubt. 1835 Drupal.algoliaCommon.recordBypassReason('unknown-browser-engine'); 1836 return false; 1837 }, 1838 1839 // Secret token that authorises one trusted automated agent (Claude Code via 1840 // the Chrome extension) to drive Algolia pages like a human, bypassing the 1841 // bot guards in isModernBrowser(). Activate once per origin by visiting any 1842 // page with ?tg_algolia_human=<token>; it is then persisted to localStorage 1843 // for the rest of the session. Crawlers/other bots never carry it. 1844 TRUSTED_AUTOMATION_TOKEN: 'claude-algolia-human-9f4b27e1', 1845 1846 /** 1847 * True when the trusted-automation token is present. Reads it from 1848 * localStorage, and accepts a one-time ?tg_algolia_human=<token> query 1849 * param which it persists so the override survives in-session navigation. 1850 * Fails closed (returns false) if storage is unavailable. 1851 */ 1852 hasTrustedAutomationOverride: function () { 1853 var token = Drupal.algoliaCommon.TRUSTED_AUTOMATION_TOKEN; 1854 try { 1855 var qp = new URLSearchParams(window.location.search).get('tg_algolia_human'); 1856 if (qp && qp === token) { 1857 window.localStorage.setItem('tg_algolia_human', token); 1858 } 1859 return window.localStorage.getItem('tg_algolia_human') === token; 1860 } 1861 catch (e) { 1862 return false; 1863 } 1864 }, 1865 1866 /** 1867 * True when the current request should bypass Algolia entirely. 1868 * Combines isModernBrowser() with the ?legacy_browse=1 testing 1869 * override (used by QA to preview the bot view in a real browser). 1870 */ 1871 shouldBypassAlgolia: function () { 1872 var forceLegacy = false; 1873 try { 1874 forceLegacy = new URLSearchParams(window.location.search).get('legacy_browse') === '1'; 1875 } catch (e) { 1876 forceLegacy = false; 1877 } 1878 if (forceLegacy) { 1879 Drupal.algoliaCommon.recordBypassReason('legacy-browse-param'); 1880 return true; 1881 } 1882 // isModernBrowser() records its own reason, including 'none' on pass. 1883 return !Drupal.algoliaCommon.isModernBrowser(); 1884 }, 1885 }; 1886 1887 // First click on a still-deferred Locally button loads map.js for that card 1888 // only, then opens the locator. Delegated so it covers cards rendered later 1889 // by InstantSearch. 1890 $(document).on('click', '.lcly-button', function (e) { 1891 var $locallyButton = $(this); 1892 1893 // ALWAYS suppress the default action, before any other decision. The 1894 // visible button is Locally's fallback anchor, whose href points at 1895 // join.locally.com (their B2B marketing site) with target="_blank". If we 1896 // return early without preventing it â which happens whenever the 1897 // activation config is missing for any reason â a customer clicking 1898 // "Find Locally" is sent off-site. Never let that be reachable. 1899 e.preventDefault(); 1900 1901 Drupal.algoliaCommon.activateLocallyWidget($locallyButton); 1902 }); 1903 1904})(jQuery, Drupal);
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.