PageSourceSearch

https://www.taylorguitars.com/modules/custom/tg_search/js/algolia-common.js

js taylorguitars.com collected 2026-09-24 17:13:05 UTC 75,826 bytes, 1,904 lines download raw bytes

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.