PageSourceSearch

https://shop.runningcompany.com/assets/scripts/search-overlay.js?v=1.2.0

js runningcompany.com collected 2026-09-26 05:07:08 UTC 102,094 bytes, 2,768 lines download raw bytes

1/**
2 * RunFree Search Overlay Module
3 * Modular, reusable search component with type-ahead and product previews
4 */
5
6// Product utilities for color swatches
7var searchOverlayProducts = {
8  getBucket: function(url) {
9    var items = url.split('/');
10    let fileName = items[items.length-1];
11    let bucket = "";
12    if (url.indexOf("/products/")>0) {
13      var items = fileName.split('_');
14      if (items.length>2) {
15        bucket = items[0];
16      } else {
17        bucket = "sync";
18      }
19    } else if (url.indexOf("/meta/")>0) {
20      bucket = "meta"; 
21    } else { 
22      bucket = "sync";
23    }
24    return bucket;
25  },
26  getSize: function(size, url) {
27    var items = url.split('/');
28    var multiplier = 1;
29    if (window.devicePixelRatio) {
30      multiplier = window.devicePixelRatio;
31    }
32    if (url.indexOf("http")==0) return url;
33    else {
34      if (typeof cdnOn !== 'undefined' && cdnOn) {
35        return searchOverlayProducts.getSized(size, url);
36      } else {
37        return "/images/"+(size*multiplier).toFixed(0)+"/"+items[items.length-1];
38      }
39    }
40  },
41  getSized: function(size, url) {
42    var items = url.split('/');
43    var multiplier = 1;
44    if (window.devicePixelRatio) {
45      multiplier = window.devicePixelRatio;
46    }
47    if (url.indexOf("http")==0) return url;
48    else {
49      let cdnUrl = "https://cdn.runfree.run/images/products/"+searchOverlayProducts.getBucket(url)+"/"+(size*multiplier).toFixed(0)+"/"+items[items.length-1];
50      return cdnUrl;
51    }
52  },
53  getCleaned: function(value) {
54    var toClear = [",", "'"];
55    value = value.trim();
56    value = value.toLowerCase();
57    for (var i=0; i<toClear.length; i++) {
58      value = value.split(toClear[i]).join('');
59    }
60    value = value.replace(/\W/g, ' ');
61    while (value.indexOf("  ")>=0) { 
62      value = value.split('  ').join(' ');
63    } 
64    value = value.split(' ').join('-');
65    return value;
66  },
67  getCanonical: function(product) {
68    var canonical = "/product/"+product.productId+"/";
69    if (product.brandName) {
70      canonical += searchOverlayProducts.getCleaned(product.brandName)+"/";
71    }
72    if (product.name) {
73      canonical += searchOverlayProducts.getCleaned(product.name);
74    }
75    return canonical;
76  }
77};
78
79var SearchOverlay = (function() {
80  'use strict';
81
82  // Configuration
83  const CONFIG = {
84    debounceDelay: 500,
85    minSearchLength: 2,
86    maxProducts: 12,
87    maxSuggestions: 9,
88    maxRecentSearches: 5,
89    maxPopularSearches: 3,
90    maxTopBrands: 3,
91    maxTopCategories: 3,
92    cacheDuration: 5 * 60 * 1000, // 5 minutes
93    maxEditDistance: 2 // Maximum Levenshtein distance for autocorrect
94  };
95
96  // Memoization cache for Levenshtein distance (static terms don't change)
97  let levenshteinCache = new Map();
98  const KNOWN_BRANDS = [
99    // Running shoe brands
100    'brooks', 'nike', 'adidas', 'asics', 'saucony', 'hoka', 'new balance',
101    'mizuno', 'altra', 'on', 'salomon', 'merrell', 'inov-8', 'topo athletic',
102    'newton', 'karhu', 'craft', 'under armour', 'reebok', 'puma', 'skechers',
103    'la sportiva', 'diadora', 'zoot', '361 degrees', 'joma', 'k-swiss',
104    'norda', 'tracksmith', 'saucony', 'ons', 'veja', 'oofos',
105    // Socks & compression brands
106    'balega', 'feetures', 'darn tough', 'smartwool', 'swiftwick', 'bombas',
107    'thorlo', 'injinji', 'stance', 'rockay', 'pro compression', 'cep',
108    '2xu', 'compressport', 'zensah',
109    // Watch & tech brands
110    'garmin', 'polar', 'coros', 'suunto', 'fitbit', 'apple watch', 'whoop',
111    'wahoo', 'timex', 'casio',
112    // Nutrition brands
113    'gu', 'maurten', 'tailwind', 'nuun', 'skratch', 'hammer', 'clif',
114    'honey stinger', 'spring energy', 'ucan', 'huma', 'gatorade', 'sword',
115    'sis', 'precision hydration',
116    // Hydration & accessories brands
117    'nathan', 'amphipod', 'camelbak', 'ultimate direction', 'salomon',
118    'osprey', 'orange mud', 'flipbelt', 'spibelt', 'naked', 'raidlight',
119    // Apparel brands
120    'lululemon', 'athleta', 'patagonia', 'rabbit', 'saysky', 'oiselle',
121    'janji', 'runderwear', 'ten thousand', 'vuori', 'rhone', 'path projects',
122    // Eyewear brands
123    'goodr', 'oakley', 'tifosi', 'julbo', 'smith', 'rudy project',
124    '100 percent', 'roka', 'poc'
125  ];
126
127  // Common product terms for autocorrect
128  const KNOWN_TERMS = [
129    // Brooks shoes
130    'ghost', 'adrenaline', 'glycerin', 'launch', 'levitate', 'cascadia', 'caldera',
131    'ricochet', 'hyperion', 'trace', 'revel', 'beast', 'dyad', 'transcend',
132    // Nike shoes
133    'pegasus', 'vomero', 'structure', 'zoom', 'react', 'infinity', 'alphafly',
134    'vaporfly', 'invincible', 'zoomx', 'tempo', 'winflo', 'flex', 'free',
135    // Adidas shoes
136    'ultraboost', 'solarboost', 'supernova', 'adizero', 'boston', 'sl20',
137    'pulseboost', 'senseboost', 'terrex',
138    // ASICS shoes
139    'gel kayano', 'gel nimbus', 'gel cumulus', 'novablast', 'metaspeed',
140    'gt-2000', 'trabuco', 'fujitrabuco', 'glideride', 'dynablast',
141    // Saucony shoes
142    'kinvara', 'triumph', 'ride', 'guide', 'endorphin', 'freedom', 'peregrine',
143    'xodus', 'hurricane', 'omni', 'axon', 'cohesion',
144    // Hoka shoes
145    'bondi', 'clifton', 'speedgoat', 'challenger', 'rincon', 'arahi', 'mach',
146    'carbon x', 'torrent', 'kawana', 'gaviota', 'anacapa', 'tecton',
147    // New Balance shoes
148    '1080', '880', '860', 'fresh foam', 'fuelcell', 'rebel', 'more',
149    'hierro', 'vongo', 'beacon',
150    // Altra shoes
151    'escalante', 'torin', 'lone peak', 'timp', 'provision', 'paradigm',
152    'superior', 'olympus', 'rivera',
153    // On shoes
154    'cloudrunner', 'cloudflow', 'cloudswift', 'cloudsurfer', 'cloudventure',
155    'cloudstratus', 'cloudflyer', 'cloudnova', 'cloudmonster',
156    // Salomon shoes
157    'sense', 'ultra', 'wildcross', 'sonic', 'speedcross', 'xa pro',
158    's/lab', 'pulsar', 'genesis',
159    // Mizuno shoes
160    'wave rider', 'wave inspire', 'wave sky', 'wave prophecy', 'wave creation',
161    'wave horizon', 'wave rebellion'
162  ];
163
164  // Search Synonyms - Expand queries for better results
165  const SEARCH_SYNONYMS = {
166    // Footwear types
167    'shoe': ['shoes', 'sneaker', 'sneakers'],
168    'shoes': ['shoe', 'sneaker', 'sneakers'], 
169    'sneaker': ['sneakers', 'shoe', 'shoes'],
170    'sneakers': ['sneaker', 'shoe', 'shoes'],
171    'racing flat': ['racing flats', 'flat', 'flats', 'racer', 'racers'],
172    'spike': ['spikes', 'track spike', 'track spikes'],
173    
174    // Shoe characteristics (from your categories)
175    'neutral': ['cushioned', 'cushion', 'mens neutral', 'wmns neutral'],
176    'stability': ['support', 'supportive', 'motion control', 'mens stability', 'wmns stability'],
177    'motion control': ['stability', 'support', 'mens motion control', 'wmns motion control'],
178    'cushion': ['cushioned', 'neutral', 'plush', 'soft'],
179    'cushioned': ['cushion', 'neutral', 'plush', 'soft'],
180    'minimal': ['minimalist', 'barefoot', 'zero drop'],
181    'minimalist': ['minimal', 'barefoot', 'zero drop'],
182    'maximal': ['maximalist', 'max cushion', 'ultra cushion'],
183    'maximalist': ['maximal', 'max cushion', 'ultra cushion'],
184    'responsive': ['bouncy', 'springy', 'energetic'],
185    'lightweight': ['light', 'fast', 'racing'],
186    'waterproof': ['water resistant', 'gore-tex', 'gtx'],
187    'racing': ['race', 'fast', 'mens racing', 'wmns racing'],
188    'trail': ['trails', 'hiking', 'mens trail', 'wmns trail'],
189    'sandals': ['sandal', 'mens sandals', 'wmns sandals'],
190    
191    // Gender terms (from your categories)
192    'mens': ["men's", 'men', 'male', 'mns', 'm'],
193    'men': ["men's", 'mens', 'male', 'mns', 'm'],
194    'wmns': ["women's", 'womens', 'women', 'female', 'w'],
195    'womens': ["women's", 'wmns', 'women', 'female', 'ladies', 'w'],
196    'women': ["women's", 'womens', 'wmns', 'female', 'ladies', 'w'],
197    'ladies': ["women's", 'women', 'womens', 'wmns', 'female', 'w'],
198    'mns': ["men's", 'mens', 'men', 'male', 'm'],
199    'm': ["men's", 'mens', 'men', 'male', 'mns'],
200    'w': ["women's", 'womens', 'women', 'female', 'wmns'],
201    'kids': ['youth', 'children', 'junior', 'boys', 'girls'],
202    'youth': ['kids', 'children', 'junior'],
203    
204    // Apparel types (from your categories)
205    'sock': ['socks', 'hosiery'],
206    'socks': ['sock', 'hosiery'],
207    'short': ['shorts', '4" short', '5" short', '7" short', '9" short', 'split short'],
208    'shorts': ['short', 'mens short', 'wmns short', 'long short'],
209    'shirt': ['shirts', 'top', 'tops', 'tee', 'tees'],
210    'shirts': ['shirt', 'top', 'tops', 'tee', 'tees'],
211    'top': ['tops', 'shirt', 'shirts', 'tee', 'tees'],
212    'tops': ['top', 'shirt', 'shirts', 'tee', 'tees', 'ss tops', 'ls tops'],
213    'tee': ['tees', 'shirt', 'shirts', 't-shirt'],
214    'tees': ['tee', 'shirt', 'shirts', 't-shirts'],
215    'pant': ['pants', 'trouser', 'trousers', 'tight', 'tights', 'mens pant', 'wmns pant'],
216    'pants': ['pant', 'trouser', 'trousers', 'tight', 'tights'],
217    'tight': ['tights', 'legging', 'leggings', 'pant', 'pants', 'mens t
217ight', 'wmns tight'],
218    'tights': ['tight', 'legging', 'leggings', 'pant', 'pants'],
219    'legging': ['leggings', 'tight', 'tights'],
220    'leggings': ['legging', 'tight', 'tights'],
221    'capri': ['capris', 'mens capri', 'wmns capri'],
222    'jacket': ['jackets', 'coat', 'coats', 'outerwear'],
223    'jackets': ['jacket', 'coat', 'coats', 'outerwear'],
224    'outerwear': ['jacket', 'jackets', 'coat', 'coats', 'mens outerwear', 'wmns outerwear'],
225    'zip': ['zipline', '1/2 zip', 'half zip', 'mens 1/2 zip', 'wmns 1/2 zip'],
226    'bra': ['bras', 'sports bra', 'run bra', 'wmns bra'],
227    'tank': ['tanks', 'singlet', 'mens tank', 'wmns tank'],
228    'vest': ['vests', 'hydration vest', 'running vest'],
229    'base layer': ['base layers', 'baselayer', 'thermal'],
230    'arm sleeve': ['arm sleeves', 'sleeves'],
231    'calf sleeve': ['calf sleeves', 'compression sleeve'],
232    'long sleeve': ['ls', 'long sleeves', 'mens ls tops', 'wmns ls tops'],
233    'short sleeve': ['ss', 'short sleeves', 'mens ss tops', 'wmns ss tops'],
234    'skirt': ['skirts', 'wmns skirt', 'running skirt'],
235    'dress': ['dresses'],
236    'underwear': ['undergarments', 'mens underwear', 'wmns underwear'],
237    '2 in 1': ['2-n-1', 'mens 2 in 1', 'wmns 2-n-1'],
238    
239    // Accessories (from your categories)
240    'glove': ['gloves', 'mitt', 'mitts'],
241    'gloves': ['glove', 'mitt', 'mitts'],
242    'hat': ['hats', 'cap', 'caps', 'beanie', 'headwear'],
243    'cap': ['caps', 'hat', 'hats'],
244    'visor': ['visors'],
245    'headband': ['headbands', 'sweatband'],
246    'buff': ['buffs', 'neck gaiter', 'gaiter'],
247    'watch': ['watches', 'gps', 'fitness tracker', 'smartwatch', 'electronics'],
248    'belt': ['belts', 'running belt', 'waist pack'],
249    'pack': ['packs', 'hydration pack', 'vest', 'backpack', 'bags'],
250    'bag': ['bags', 'backpack', 'duffel', 'pack'],
251    'bottle': ['bottles', 'water bottle', 'flask', 'hydration'],
252    'light': ['lights', 'headlamp', 'flashlight'],
253    'reflective': ['reflective gear', 'visibility', 'hi-vis'],
254    'sunglasses': ['glasses', 'shades', 'eyewear'],
255    'eyewear': ['sunglasses', 'glasses', 'shades'],
256    'insole': ['insoles', 'insert', 'inserts', 'footbed', 'shoe accessories'],
257    'insoles': ['insole', 'insert', 'inserts', 'footbed'],
258    'collar': ['collars', 'dog collar'],
259    'stroller': ['strollers', 'jogging stroller'],
260    'book': ['books', 'magazines', 'books/magazines'],
261    'injury prevention': ['recovery', 'massage', 'foam roller'],
262    
263    // Activity types (running specific)
264    'run': ['running', 'runner', 'runners', 'jog', 'jogging'],
265    'running': ['run', 'runner', 'runners', 'jog', 'jogging'],
266    'jog': ['jogging', 'jogger', 'run', 'running'],
267    'jogging': ['jog', 'jogger', 'run', 'running'],
268    'walk': ['walking', 'walker'],
269    'walking': ['walk', 'walker'],
270    'hike': ['hiking', 'hiker', 'trail'],
271    'hiking': ['hike', 'hiker', 'trail'],
272    'trail': ['trails', 'hiking', 'hike', 'off-road', 'mountain', 'mens trail', 'wmns trail'],
273    'ultra': ['ultramarathon', 'ultrarunning', 'ultra running'],
274    'marathon': ['marathons', 'long distance', 'distance'],
275    'half marathon': ['half', 'half marathons', '13.1'],
276    '5k': ['5km', 'five k'],
277    '10k': ['10km', 'ten k'],
278    'track': ['racing', 'race', 'tempo'],
279    'race': ['racing', 'track', 'tempo', 'speed'],
280    'tempo': ['threshold', 'tempo run'],
281    'interval': ['intervals', 'speed work', 'track work'],
282    'recovery': ['recover', 'rest', 'easy', 'massage'],
283    'training': ['train', 'trainer', 'workout', 'mens training', 'wmns training'],
284    'lifestyle': ['casual', 'everyday', 'wmns lifestyle'],
285    'tri': ['triathlon', 'wmns tri top'],
286    
287    // Common terms
288    'sport': ['sports', 'athletic'],
289    'sports': ['sport', 'athletic'],
290    'athletic': ['athletics', 'sport', 'sports'],
291    'training': ['train', 'trainer', 'workout'],
292    'workout': ['training', 'exercise', 'fitness'],
293    'gear': ['equipment', 'accessories'],
294    'compression': ['compress', 'tight', 'support', 'wmns compression'],
295    'breathable': ['moisture wicking', 'wicking', 'ventilated'],
296    'nutrition': ['fuel', 'energy', 'hydration', 'gu', 'gels'],
297    'hydration': ['water', 'fluid', 'drink', 'nutrition', 'bottle'],
298    'gel': ['gels', 'energy gel', 'gu'],
299    'chew': ['chews', 'energy chew', 'blocks'],
300    'clearance': ['sale', 'discount', 'closeout'],
301    'apparel': ['clothing', 'clothes', 'mens apparel', 'womens apparel', 'kids apparel'],
302    'footwear': ['shoes', 'shoe', 'mens footwear', 'womens footwear', 'kids footwear', 'unisex footwear'],
303    
304    // Brand names (from your database)
305    'hoka': ['hoka one one'],
306    'nb': ['new balance'],
307    'newbalance': ['new balance', 'nb'],
308    'on': ['on running', 'on cloud'],
309    
310    // Common brand misspellings - Running Shoe Brands
311    'nike': ['nke', 'nikey', 'nyke', 'nikee'],
312    'adidas': ['addidas', 'adiddas', 'addias', 'adidias', 'adiddis'],
313    'balance': ['balence', 'ballance', 'balanse'],
314    'new balance': ['newbalance', 'new ballance', 'new balence', 'newbalence'],
315    'brooks': ['brook', 'broks', 'brookes', 'brookz', 'brroks'],
316    'asics': ['asic', 'asix', 'asicks', 'acics', 'assics', 'asiks'],
317    'saucony': ['sauconey', 'sacony', 'sauconny', 'saucany', 'socony', 'saucon'],
318    'mizuno': ['mizino', 'mizumo', 'mizzuno', 'mizunno', 'misuno'],
319    'altra': ['altrah', 'altera', 'alltra', 'alttra'],
320    'salomon': ['salmon', 'salamon', 'salamon', 'soloman', 'solamon'],
321    'hoka': ['hooka', 'hokka', 'hocka', 'hokah', 'hoko'],
322    'merrell': ['merell', 'merrel', 'merel', 'merrill', 'merill'],
323    'skechers': ['sketchers', 'skecher', 'sketcher', 'skeechers', 'skethers'],
324    'puma': ['pumma', 'pooma', 'pumah'],
325    'reebok': ['rebok', 'reebok', 'rebock', 'reebook'],
326    'under armour': ['under armor', 'underarmour', 'underarmor', 'ua'],
327    'oofos': ['oofoos', 'oofo', 'oofus', 'ooofos'],
328    'topo athletic': ['topo', 'toppo', 'topo atheltic'],
329    'inov-8': ['inov8', 'inov', 'innovate', 'innov8'],
330    'karhu': ['karho', 'karuh', 'karoo'],
331    'newton': ['newten', 'neuton'],
332    'diadora': ['diadoria', 'diodora', 'diadorra'],
333    'la sportiva': ['lasportiva', 'la sportive', 'sportiva'],
334    'norda': ['norda', 'nordah'],
335    
336    // Common product misspellings - Brooks
337    'ghost': ['ghot', 'gost', 'ghoast', 'goast'],
338    'adrenaline': ['adrenalin', 'adreneline', 'addrenaline', 'adrenalene', 'andenaline'],
339    'glycerin': ['glycerine', 'glicerin', 'glyceran', 'glyserin', 'glycren'],
340    'cascadia': ['cascada', 'cascadea', 'cascaddia', 'cascaida'],
341    'levitate': ['levitae', 'levitat', 'levatate', 'levitte'],
342    'hyperion': ['hypereon', 'hyperian', 'hyerion', 'hyperyon'],
343    
344    // Common product misspellings - Nike
345    'pegasus': ['pegusus', 'pegeses', 'pegasas', 'pegsus', 'pegisis', 'pegusas'],
346    'vomero': ['vomaro', 'vommero', 'vomorro', 'vomera'],
347    'vaporfly': ['vapourfly', 'vaporflye', 'vaperfly', 'vaporflie'],
348    'alphafly': ['alphaflye', 'alfafly', 'alphaflie', 'alpahfly'],
349    'invincible': ['invincable', 'invicible', 'invinsible', 'invincble'],
350    'infinity': ['infiniti', 'infnity', 'infinty', 'infitity'],
351    
352    // Common product misspellings - Hoka
353    'bondi': ['bondie', 'bondy', 'bondii', 'bandi', 'bondee'],
354    'clifton': ['cliffton', 'cliften', 'clifften', 'cliftton', 'cliftonn'],
355    'speedgoat': ['speed goat', 'speedgot', 'speedgoaat', 'speegoat'],
356    'rincon': ['rinconn', 'ricon', 'rinkin', 'rickon'],
357    'arahi': ['arhi', 'arhai', 'arhi', 'ahrai'],
358    'mach': ['mac', 'match', 'maach'],
359    'gaviota': ['gaviotta', 'gavioat', 'gaviots', 'gavioota'],
360    'kawana': ['kawanna', 'kowona', 'kawona', 'kawanna'],
361    
362    // Common product misspellings - ASICS
363    'kayano': ['kayanno', 'kayanna', 'kyano', 'kayno', 'kaiano'],
364    'nimbus': ['nimbes', 'nimbis', 'nimbuus', 'nimbes'],
365    'cumulus': ['cumulis', 'cumulous', 'cumulas', 'cummulus'],
366    'novablast': ['nova blast', 'novablst', 'novvablast'],
367    'metaspeed': ['meta speed', 'metaspede', 'metasped'],
368    
369    // Common product misspellings - Saucony
370    'kinvara': ['kinvarra', 'kinvera', 'kinvarra', 'kinvar', 'kinvra'],
371    'triumph': ['truimph', 'triump', 'triumf', 'triuph', 'trumph'],
372    'endorphin': ['endorfin', 'endorphan', 'endophin', 'endorphine'],
373    'peregrine': ['peragrine', 'perregrine', 'perergrine', 'perigrine'],
374    'hurricane': ['hurrican', 'huricane', 'hurricaine', 'huricain'],
375    
376    // Common product misspellings - New Balance
377    'fuelcell': ['fuel cell', 'fuelcel', 'fuellcell', 'fuelsel'],
378    'fresh foam': ['freshfoam', 'fresh fome', 'freshfome'],
379    
380    // Common product misspellings - Altra
381    'escalante': ['escalant', 'escallante', 'escalente', 'escalanty'],
382    'torin': ['torrin', 'toren', 'torinn', 'torren'],
383    'lone peak': ['lonepeak', 'lone peek', 'lonepeek'],
384    'timp': ['timmp', 'tymp', 'tem'],
385    'paradigm': ['paradigim', 'peradigm', 'paradime', 'paradgm'],
386    'olympus': ['olypmus', 'olympis', 'olumpus', 'olympas'],
387    
388    // Common product misspellings - On Running
389    'cloudrunner': ['cloud runner', 'cloudruner', 'cloudrunnr'],
390    'cloudflow': ['cloud flow', 'cloudfloe', 'cloudflo'],
391    'cloudmonster': ['cloud monster', 'cloudmonstr', 'cloudmoster'],
392    'cloudsurfer': ['cloud surfer', 'cloudsurfer', 'cloudsurffer'],
393    
394    // Common product misspellings - Mizuno
395    'wave rider': ['waverider', 'wave ridder', 'waveridder'],
396    'wave inspire': ['waveinspire', 'wave insipre', 'wave inspir'],
397    
398    // Common product misspellings - Salomon
399    'speedcross': ['speed cross', 'speedcros', 'speedcross'],
400    
401    // Sock brand misspellings
402    'feetures': ['features', 'feeture', 'featurs', 'feeturs', 'feechers'],
403    'balega': ['balaga', 'balego', 'baleca', 'belega', 'bellega'],
404    'darn tough': ['darntough', 'darn tuff', 'darntuff', 'darn tuf'],
405    'smartwool': ['smart wool', 'smartwhool', 'smartwol', 'smartwoool'],
406    'swiftwick': ['swiftwhick', 'swiftwik', 'swiftwhik', 'swift wick'],
407    'injinji': ['injini', 'injinji', 'injiji', 'injenji', 'injinjii'],
408    'thorlo': ['thorlow', 'thorloo', 'tharlo', 'thorlos'],
409    'bombas': ['bomba', 'bombass', 'bombis', 'bomas'],
410    
411    // Watch/tech brand misspellings
412    'garmin': ['garmen', 'garmim', 'garman', 'gramin', 'garmon'],
413    'polar': ['poler', 'pollar', 'polor'],
414    'coros': ['corros', 'coras', 'corros', 'koros'],
415    'suunto': ['sunto', 'suunto', 'sunnto', 'suunton', 'sunnto'],
416    'whoop': ['woop', 'whooop', 'whopp'],
417    'wahoo': ['wahho', 'waho', 'waahoo', 'wahoe'],
418    
419    // Nutrition brand misspellings
420    'maurten': ['morten', 'maurteen', 'marten', 'maurtenn', 'maurtun'],
421    'nuun': ['nun', 'noon', 'nunn', 'nune'],
422    'tailwind': ['tailwing', 'tailwinnd', 'tail wind', 'talwind'],
423    'skratch': ['scratch', 'skrach', 'scrach', 'skratch'],
424    'gu': ['guu', 'goo'],
425    'clif': ['cliff', 'clif', 'clifff'],
426    'honey stinger': ['honeystinger', 'honey stiner', 'hony stinger'],
427    'huma': ['humma', 'humas', 'huuma'],
428    'ucan': ['youcan', 'u can', 'uucan'],
429    
430    // Hydration/accessory brand misspellings
431    'nathan': ['nathen', 'nathann', 'nathon'],
432    'amphipod': ['amphipod', 'amfipod', 'amphapod', 'amphypod'],
433    'camelbak': ['camelback', 'camelbac', 'cammelback', 'camelbakk'],
434    'ultimate direction': ['ultimatedirection', 'ultimate directon', 'ud'],
435    'flipbelt': ['flip belt', 'flipblt', 'flibelt'],
436    'spibelt': ['spy belt', 'spi belt', 'spybelt'],
437    'osprey': ['ospry', 'osprery', 'ossprey'],
438    
439    // Apparel brand misspellings
440    'lululemon': ['lulu lemon', 'lulemon', 'lulullemon', 'lulumon'],
441    'athleta': ['athletia', 'atheleta', 'athlete', 'atheleta'],
442    'patagonia': ['patogonia', 'patigonia', 'patagona', 'patgonia'],
443    'oiselle': ['oisele', 'oisselle', 'oisell', 'oisele'],
444    'janji': ['janji', 'janjii', 'janjy', 'janji'],
445    'vuori': ['vuoori', 'vouri', 'vuorii', 'voori'],
446    'rhone': ['rhon', 'rone', 'rhonne'],
447    
448    // Eyewear brand misspellings
449    'goodr': ['gooder', 'goodrr', 'good r', 'goodar'],
450    'oakley': ['oakly', 'okley', 'oakely', 'oaklee'],
451    'tifosi': ['tifossi', 'tiffosi', 'tifos', 'tifosy'],
452  };
453
454  /**
455   * Build reverse lookup map for bidirectional synonym support
456   * This allows misspellings to find correct terms (e.g., "broks" → "brooks")
457   */
458  const SYNONYM_REVERSE_MAP = {};
459  Object.keys(SEARCH_SYNONYMS).forEach(correctTerm => {
460    SEARCH_SYNONYMS[correctTerm].forEach(synonym => {
461      const normalizedSynonym = synonym.toLowerCase();
462      if (!SYNONYM_REVERSE_MAP[normalizedSynonym]) {
463        SYNONYM_REVERSE_MAP[normalizedSynonym] = [];
464      }
465      if (!SYNONYM_REVERSE_MAP[normalizedSynonym].includes(correctTerm)) {
466        SYNONYM_REVERSE_MAP[normalizedSynonym].push(correctTerm);
467      }
468    });
469  });
470
471  // Color extraction patterns - matches shop.html for consistency
472  const COLOR_PATTERNS = {
473    'red': /\b(red|crimson|scarlet|maroon|burgundy)\b/i,
474    'orange': /\b(orange|tangerine|coral)\b/i,
475    'yellow': /\b(yellow|gold|golden|mustard|lemon)\b/i,
476    'green': /\b(green|olive|lime|mint|teal|emerald|forest)\b/i,
477    'blue': /\b(blue|navy|aqua|cyan|cobalt|royal|indigo|turquoise)\b/i,
478    'purple': /\b(purple|violet|lavender|plum|magenta)\b/i,
479    'pink': /\b(pink|rose|salmon|fuchsia|blush)\b/i,
480    'beige': /\b(beige|tan|khaki|cream|nude|sand)\b/i,
481    'brown': /\b(brown|chocolate|coffee|mocha|caramel|chestnut)\b/i,
482    'black': /\b(black|onyx|charcoal|ebony)\b/i,
483    'gray': /\b(gray|grey|silver|slate|graphite|ash)\b/i,
484    'white': /\b(white|ivory|pearl|snow)\b/i
485  };
486
487  /**
488   * Extract color keywords from a search query
489   * @param {string} query - The search query
490   * @returns {object} - { colors: string[], cleanedQuery: string }
491   */
492  function extractColorFromQuery(query) {
493    if (!query) return { colors: [], cleanedQuery: query };
494    
495    const foundColors = [];
496    let cleanedQuery = query;
497    
498    Object.keys(COLOR_PATTERNS).forEach(colorName => {
499      const pattern = COLOR_PATTERNS[colorName];
500      if (pattern.test(query)) {
501        foundColors.push(colorName);
502        // Remove color from query to get clean search terms
503        cleanedQuery = cleanedQuery.replace(pattern, ' ').trim().replace(/\s+/g, ' ');
504      }
505    });
506    
507    return { colors: foundColors, cleanedQuery };
508  }
509
510  /**
511   * Check if a product's color field matches any of the selected colors
512   * @param {string} productColor - The product's color field
513   * @param {string[]} selectedColors - Array of selected color names (lowercase)
514   * @returns {boolean} - True if product matches any selected color
515   */
516  function productMatchesColorFilter(productColor, selectedColors) {
517    if (!selectedColors || selectedColors.length === 0) return true;
518    if (!productColor) return false;
519    
520    const productColorLower = productColor.toLowerCase();
521    
522    for (const color of selectedColors) {
523      const pattern = COLOR_PATTERNS[color];
524      if (pattern && pattern.test(productColorLower)) {
525        return true;
526      }
527      // Fallback: simple substring match
528      if (productColorLower.includes(color)) {
529        return true;
530      }
531    }
532    return false;
533  }
534
535  /**
536   * Calculate Levenshtein distance between two strings with memoization
537   * Caches results for static brand/term lists to avoid recomputation
538   */
539  function levenshteinDistance(str1, str2) {
540    const s1 = str1.toLowerCase();
541    const s2 = str2.toLowerCase();
542    const cacheKey = `${s1}|${s2}`;
543    
544    // Check cache first
545    if (levenshteinCache && levenshteinCache.has(cacheKey)) {
546      return levenshteinCache.get(cacheKey);
547    }
548    
549    const len1 = s1.length;
550    const len2 = s2.length;
551    
552    // Create 2D array for dynamic programming
553    const matrix = Array(len1 + 1).fill(null).map(() => Array(len2 + 1).fill(0));
554    
555    // Initialize first row and column
556    for (let i = 0; i <= len1; i++) matrix[i][0] = i;
557    for (let j = 0; j <= len2; j++) matrix[0][j] = j;
558    
559    // Fill matrix
560    for (let i = 1; i <= len1; i++) {
561      for (let j = 1; j <= len2; j++) {
562        const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
563        matrix[i][j] = Math.min(
564          matrix[i - 1][j] + 1,      // deletion
565          matrix[i][j - 1] + 1,      // insertion
566          matrix[i - 1][j - 1] + cost // substitution
567        );
568      }
569    }
570    
571    const distance = matrix[len1][len2];
572    if (levenshteinCache) {
573      levenshteinCache.set(cacheKey, distance);
574    }
575    return distance;
576  }
577
578  /**
579   * Find closest match for a term using Levenshtein distance
580   * Returns { match: string, distance: number } or null
581   */
582  function findClosestMatch(query, knownTerms, maxDistance = CONFIG.maxEditDistance) {
583    const queryLower = query.toLowerCase().trim();
584    let bestMatch = null;
585    let bestDistance = Infinity;
586    
587    // Don't autocorrect very short queries (likely intentional)
588    if (queryLower.length < 3) return null;
589    
590    for (const term of knownTerms) {
591      const distance = levenshteinDistance(queryLower, term);
592      
593      // Exact match - no correction needed
594      if (distance === 0) return null;
595      
596      // Found a closer match
597      if (distance <= maxDistance && distance < bestDistance) {
598        bestDistance = distance;
599        bestMatch = term;
600      }
601    }
602    
603    return bestMatch ? { match: bestMatch, distance: bestDistance } : null;
604  }
605
606  /**
607   * Attempt to autocorrect a query using fuzzy matching
608   * Returns corrected query or original if no close match found
609   */
610  function autocorrectQuery(query) {
611    // Easter egg check (obfuscated)
612    const q = query.toLowerCase().trim();
613    
614    const k3 = atob('amVyZW15IHRlbGxpZXI=');
615    const k4 = String.fromCharCode(66,114,97,100,32,80,105,116,116,115,32,115,101,120,105,101,114,32,100,111,112,112,108,101,103,97,110,103,101,114);
616    if (q === k3) return k4;
617    
618    const words = query.toLowerCase().trim().split(/\s+/);
619    let corrected = false;
620    const correctedWords = words.map(word => {
621      // Skip very short words
622      if (word.length < 3) return word;
623      
624      // Try brand names first
625      const brandMatch = findClosestMatch(word, KNOWN_BRANDS);
626      if (brandMatch) {
627        corrected = true;
628        return brandMatch.match;
629      }
630      
631      // Try product terms
632      const termMatch = findClosestMatch(word, KNOWN_TERMS);
633      if (termMatch) {
634        corrected = true;
635        return termMatch.match;
636      }
637      
638      return word;
639    });
640    
641    return corrected ? correctedWords.join(' ') : null;
642  }
643
644  /**
645   * Extract brand names from search query
646   * Returns object with detected brands and cleaned query
647   * @param {string} query - The search query
648   * @param {Object} brandMap - Map of brand names (lowercase) to brand IDs
649   * @returns {Object} - { detectedBrands: [{ id, name }], cleanedQuery: string }
650   */
651  function extractBrandsFromQuery(query, brandMap) {
652    const normalized = query.toLowerCase().trim();
653    const words = normalized.split(/\s+/);
654    const detectedBrands = [];
655    const wordsToKeep = [];
656    
657    // Check each word (and combinations) against known brands
658    // Try multi-word brands first (e.g., "new balance", "under armour")
659    let i = 0;
660    while (i < words.length) {
661      let matchFound = false;
662      let skipWords = 0;
663      
664      // Try 3-word combinations
665      if (i + 2 < words.length) {
666        const threeWord = `${words[i]} ${words[i+1]} ${words[i+2]}`;
667        if (KNOWN_BRANDS.includes(threeWord) || brandMap[threeWord]) {
668          const brandId = brandMap[threeWord];
669          if (brandId) {
670            detectedBrands.push({ id: brandId, name: threeWord });
671            skipWords = 3;
672            matchFound = true;
673          }
674        }
675      }
676      
677      // Try 2-word combinations
678      if (!matchFound && i + 1 < words.length) {
679        const twoWord = `${words[i]} ${words[i+1]}`;
680        if (KNOWN_BRANDS.includes(twoWord) || brandMap[twoWord]) {
681          const brandId = brandMap[twoWord];
682          if (brandId) {
683            detectedBrands.push({ id: brandId, name: twoWord });
684            skipWords = 2;
685            matchFound = true;
686          }
687        }
688      }
689      
690      // Try single word
691      if (!matchFound) {
692        const word = words[i];
693        if (KNOWN_BRANDS.includes(word) || brandMap[word]) {
694          const brandId = brandMap[word];
695          if (brandId) {
696            detectedBrands.push({ id: brandId, name: word });
697            skipWords = 1;
698            matchFound = true;
699          }
700        }
701      }
702      
703      if (matchFound) {
704        i += skipWords;
705      } else {
706        wordsToKeep.push(words[i]);
707        i++;
708      }
709    }
710    
711    return {
712      detectedBrands,
713      cleanedQuery: wordsToKeep.join(' ')
714    };
715  }
716
717  /**
718   * Extract quoted strings from search query
719   * Returns object with quoted phrases and cleaned query
720   * Example: 'nike "wave inspire 16" shoes' => { quotedPhrases: ['wave inspire 16'], cleanedQuery: 'nike shoes' }
721   * @param {string} query - The search query
722   * @returns {Object} - { quotedPhrases: string[], cleanedQuery: string }
723   */
724  function extractQuotedStrings(query) {
725    const quotedPhrases = []; 
726    let cleanedQuery = query;
727    
728    // Match quoted strings with proper pairing to handle apostrophes inside
729    // Matches: "text with 's apostrophes", 'text', "smart quotes", 'smart quotes'
730    const quotePattern = /"([^"]*)"|'([^']*)'|"([^"]*)"|'([^']*)'/g;
731    let match;
732    
733    while ((match = quotePattern.exec(query)) !== null) {
734      // Extract the matched text from whichever group captured it
735      const quotedText = (match[1] || match[2] || match[3] || match[4] || '').trim();
736      if (quotedText.length > 0) {
737        quotedPhrases.push(quotedText);
738        // Remove the quoted phrase from the query (including quotes)
739        cleanedQuery = cleanedQuery.replace(match[0], ' ').trim();
740      }
741    }
742    
743    // Clean up multiple spaces
744    cleanedQuery = cleanedQuery.replace(/\s+/g, ' ').trim();
745    
746    return {
747      quotedPhrases,
748      cleanedQuery
749    };
750  }
751
752  /**
753   * Expand query with synonyms for better search results
754   * Returns array of search terms to try
755   * Now supports bidirectional lookup - typing misspellings will find correct terms
756   * @param {string} query - The search query to expand
757   * @param {Array<string>} excludeGenders - Gender codes that were extracted (e.g., ['M', 'F']) to prevent re-expansion
758   */
759  function expandQueryWithSynonyms(query, excludeGenders = []) {
760    const normalized = query.toLowerCase().trim();
761    const words = normalized.split(/\s+/);
762    
763    // IMPORTANT: Always include the original full query as the primary search term
764    // This ensures "inspire 15" searches for both words together, not "inspire" OR "15"
765    const searchTerms = [normalized];
766    
767    // Build an array of word variations (each word + its synonyms)
768    const wordVariations = [];
769    
770    // Gender-related synonym groups to skip if genders were extracted
771    const genderTerms = new Set([
772      'mens', 'men', 'male', 'mns', 'm', "men's",
773      'womens', 'women', 'female', 'ladies', 'wmns', 'w', "women's",
774      'unisex', 'u',
775      'kids', 'youth', 'children', 'k'
776    ]);
777    
778    // For each word, collect the word itself + synonyms
779    words.forEach(word => {
780      // Skip gender-related terms if gender was already extracted
781      if (excludeGenders.length > 0 && genderTerms.has(word)) {
782        return;
783      }
784      
785      // Skip standalone numbers to avoid overly broad matches
786      if (/^\d+$/.test(word)) {
787        return;
788      }
789      
790      const variations = new Set([word]); // Include original word
791      
792      // Skip adding misspellings when the user already typed the term correctly
793      const isCorrectTerm = KNOWN_BRANDS.includes(word) || KNOWN_TERMS.includes(word);
794      
795      // Check forward lookup (correct term → synonyms)
796      // Skip adding misspellings for correctly-spelled brands
797      if (SEARCH_SYNONYMS[word] && !isCorrectTerm) {
798        // Add top 2 synonyms for this word
799        SEARCH_SYNONYMS[word].slice(0, 2).forEach(syn => variations.add(syn));
800      } 
801      
802      // Check reverse lookup (misspelling/synonym → correct terms)
803      // Always do this - if someone types a misspelling, correct it
804      if (SYNONYM_REVERSE_MAP[word]) {
805        SYNONYM_REVERSE_MAP[word].forEach(correctTerm => {
806          variations.add(correctTerm);
807          
808          // Also add synonyms of the correct term (but not if it's a brand)
809          const isCorrectTargetTerm = KNOWN_BRANDS.includes(correctTerm) || KNOWN_TERMS.includes(correctTerm);
810          if (SEARCH_SYNONYMS[correctTerm] && !isCorrectTargetTerm) {
811            SEARCH_SYNONYMS[correctTerm].slice(0, 1).forEach(syn => variations.add(syn));
812          }
813        });
814      }
815      
816      wordVariations.push(Array.from(variations));
817    });
818    
819    // Add individual word terms only as fallback options (lower priority)
820    // The database should prioritize the full phrase match
821    const individualTerms = wordVariations.flat();
822    searchTerms.push(...individualTerms);
823    
824    // Remove duplicates and return
825    return Array.from(new Set(searchTerms));
826  }
827
828  // State
829  let state = {
830    isOpen: false,
831    searchTimeout: null,
832    currentQuery: '',
833    products: [],
834    suggestions: [],
835    recentSearches: [],
836    popularSearches: [],
837    topBrands: [
838      'Mizuno',
839      'SALOMON',
840      'New Balance',
841      'Brooks',
842      'Hoka',
843      'ASICS'
844    ],
845    topCategories: [
846      'Mens Footwear',
847      'Womens Footwear',
848      'Mens Apparel',
849      'Womens Apparel',
850      'Nutrition',
851      'Accessories',
852      'Trail',
853      'Socks'
854    ],
855    isLoggedIn: false,
856    suggestionsCache: null,
857    cacheTimestamp: null,
858    abortController: null,
859    focusedIndex: -1,
860    focusableElements: [],
861    brands: [], // Fetched from API
862    brandMap: {}, // Maps brand display names to brand IDs
863    categories: [], // Fetched from API
864    categoryMap: {}, // Maps display names to category IDs
865    currentCategoryId: null, // Track if current search is category-based
866    isPerformingCategorySearch: false // Flag to prevent input handler interference
867  };
868 
869  // DOM Elements
870  let elements = {};
871
872  /**
873   * Initialize the search overlay
874   */
875  function init() {
876    cacheElements();
877    bindEvents();
878    setupLogo();
879    loadRecentSearches();
880    fetchBrands(); // Load brands dynamically for current site
881    fetchCategories(); // Load categories dynamically for current site
882  }
883
884  /**
885   * Cache DOM elements
886   */
887  function cacheElements() {
888    elements = {
889      overlay: document.getElementById('SearchOverlay'),
890      backdrop: null,
891      input: document.getElementById('SearchOverlayInput'),
892      clearBtn: document.getElementById('ClearSearchInput'),
893      cancelBtn: document.getElementById('SearchOverlayCancel'),
894      logo: document.getElementById('SearchStoreLogo'),
895      loadingBar: document.getElementById('SearchLoadingBar'),
896      searching: document.getElementById('SearchSearching'),
897      recentSearches: document.getElementById('RecentSearches'),
898      recentSearchesList: document.getElementById('RecentSearchesList'),
899      popularSearches: document.getElementById('PopularSearches'),
900      popularSearchesList: document.getElementById('PopularSearchesList'),
901      topBrands: document.getElementById('TopBrands'),
902      topBrandsList: document.getElementById('TopBrandsList'),
903      topCategories: document.getElementById('TopCategories'),
904      topCategoriesList: document.getElementById('TopCategoriesList'),
905      searchSuggestions: document.getElementById('SearchSuggestions'),
906      suggestionsList: document.getElementById('SuggestionsList'),
907      productPreviews: document.getElementById('SearchProductPreviews'),
908      emptyState: document.getElementById('SearchEmptyState'),
909      noResults: document.getElementById('SearchNoResults'),
910      searchAllBtn: document.getElementById('SearchAllButton'),
911      productTemplate: document.getElementById('SearchProductTemplate'),
912      footer: null // Will be set after searchAllBtn is available
913    };
914
915    // Create backdrop
916    elements.backdrop = document.createElement('div');
917    elements.backdrop.className = 'search-overlay-backdrop';
918    document.body.appendChild(elements.backdrop);
919    
920    // Cache footer element
921    if (elements.searchAllBtn) {
922      elements.footer = elements.searchAllBtn.closest('.search-overlay-footer');
923    }
924  }
925
926  /**
927   * Bind event listeners
928   */
929  function bindEvents() {
930    // Input events
931    elements.input.addEventListener('input', handleInput);
932    elements.input.addEventListener('keydown', handleKeydown);
933
934    // Button events
935    elements.clearBtn.addEventListener('click', clearSearch);
936    elements.cancelBtn.addEventListener('click', close);
937    elements.searchAllBtn.addEventListener('click', searchAll);
938
939    // Backdrop click - only close, don't propagate to overlay content
940    elements.backdrop.addEventListener('click', close);
941    if (elements.overlay) {
942      elements.overlay.addEventListener('click', function(e) {
943        // Prevent clicks inside overlay from closing it
944        e.stopPropagation();
945      });
946    }
947
948    // Global escape key handler
949    document.addEventListener('keydown', function(e) {
950      if (e.key === 'Escape' && state.isOpen) {
951        e.preventDefault();
952        close();
953      }
954    });
955    
956    // Setup keyboard handler for mobile
957    setupKeyboardHandler();
958  }
959
960  /**
961   * Setup keyboard visibility handler for mobile devices
962   * Adjusts footer position when keyboard opens/closes
963   */
964  function setupKeyboardHandler() {
965    // Only run on mobile devices
966    if (!window.visualViewport || window.innerWidth > 768) {
967      return;
968    }
969
970    let lastHeight = window.visualViewport.height;
971    
972    function handleViewportResize() {
973      if (!state.isOpen || !elements.footer) {
974        return;
975      }
976
977      const currentHeight = window.visualViewport.height;
978      const windowHeight = window.innerHeight;
979      
980      // Calculate keyboard height (difference between window and viewport)
981      const keyboardHeight = windowHeight - currentHeight;
982      
983      // Keyboard is open if viewport height is significantly smaller than window height
984      if (keyboardHeight > 150) {
985        // Keyboard is open - move footer up
986        elements.footer.style.position = 'fixed';
987        elements.footer.style.bottom = `${keyboardHeight}px`;
988        elements.footer.style.left = '0';
989        elements.footer.style.right = '0';
990        elements.footer.style.zIndex = '10001';
991        elements.footer.style.transition = 'bottom 0.2s ease-out';
992      } else {
993        // Keyboard is closed - reset footer position
994        elements.footer.style.position = '';
995        elements.footer.style.bottom = '';
996        elements.footer.style.left = '';
997        elements.footer.style.right = '';
998        elements.footer.style.zIndex = '';
999        elements.footer.style.transition = '';
1000      }
1001      
1002      lastHeight = currentHeight;
1003    }
1004
1005    // Listen to viewport resize events
1006    window.visualViewport.addEventListener('resize', handleViewportResize);
1007    window.visualViewport.addEventListener('scroll', handleViewportResize);
1008    
1009    // Also listen to input focus events as a fallback
1010    if (elements.input) {
1011      elements.input.addEventListener('focus', function() {
1012        setTimeout(handleViewportResize, 300);
1013      });
1014      
1015      elements.input.addEventListener('blur', function() {
1016        setTimeout(handleViewportResize, 300);
1017      });
1018    }
1019  }
1020
1021  /**
1022   * Setup logo from site data
1023   */
1024  function setupLogo() {
1025    // Set alt text if available
1026    if (typeof siteName !== 'undefined' && siteName) {
1027      elements.logo.alt = siteName;
1028    }
1029    
1030    // Set logo URL with fallbacks - check for valid URLs (not just "/")
1031    if (typeof logoUrl !== 'undefined' && logoUrl && logoUrl.length > 1 && logoUrl !== '/') {
1032      elements.logo.src = logoUrl;
1033    } else if (typeof logoCard !== 'undefined' && logoCard && logoCard.length > 1) {
1034      elements.logo.src = logoCard;
1035    } else {
1036      // Final fallback - try to construct from window location
1037      const domain = window.location.hostname;
1038      elements.logo.src = `https://cdn.runfree.run/images/branding/${domain}/logo.png`;
1039    }
1040  }
1041
1042  /**
1043   * Open the search overlay
1044   */
1045  function open() {
1046    state.isOpen = true;
1047    state.focusedIndex = -1;
1048    elements.overlay.classList.add('active');
1049    elements.backdrop.classList.add('active');
1050    document.body.style.overflow = 'hidden';
1051    document.body.classList.add('search-overlay-open');
1052    
1053    // Set ARIA attributes
1054    elements.overlay.setAttribute('aria-hidden', 'false');
1055    elements.input.setAttribute('aria-expanded', 'true');
1056    
1057    // Focus input with focus trap
1058    setTimeout(() => {
1059      elements.input.focus();
1060      setupFocusTrap();
1061    }, 100);
1062
1063    // Show initial state
1064    showInitialState();
1065    
1066    // Track analytics
1067    trackEvent('search_overlay_opened');
1068  }
1069
1070  /**
1071   * Setup focus trap to keep focus within overlay
1072   */
1073  function setupFocusTrap() {
1074    const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
1075    const focusableElements = elements.overlay.querySelectorAll(focusableSelectors);
1076    const firstFocusable = focusableElements[0];
1077    const lastFocusable = focusableElements[focusableElements.length - 1];
1078
1079    elements.overlay.addEventListener('keydown', function trapFocus(e) {
1080      if (e.key !== 'Tab') return;
1081
1082      if (e.shiftKey) {
1083        if (document.activeElement === firstFocusable) {
1084          e.preventDefault();
1085          lastFocusable.focus();
1086        }
1087      } else {
1088        if (document.activeElement === lastFocusable) {
1089          e.preventDefault();
1090          firstFocusable.focus();
1091        }
1092      }
1093    });
1094  }
1095
1096  /**
1097   * Close the search overlay
1098   */
1099  function close() {
1100    state.isOpen = false;
1101    state.focusedIndex = -1;
1102    
1103    // Blur any focused element inside overlay before setting aria-hidden
1104    const focusedElement = document.activeElement;
1105    if (focusedElement && elements.overlay.contains(focusedElement)) {
1106      focusedElement.blur();
1107    }
1108    
1109    elements.overlay.classList.remove('active');
1110    elements.backdrop.classList.remove('active');
1111    document.body.style.overflow = '';
1112    document.body.classList.remove('search-overlay-open');
1113    
1114    // Reset ARIA attributes
1115    elements.overlay.setAttribute('aria-hidden', 'true');
1116    elements.input.setAttribute('aria-expanded', 'false');
1117    
1118    // Cancel any pending requests
1119    if (state.abortController) {
1120      state.abortController.abort();
1121      state.abortController = null;
1122    }
1123    
1124    // Clear search after animation
1125    setTimeout(() => {
1126      clearSearch();
1127    }, 400);
1128  }
1129
1130  /**
1131   * Toggle overlay
1132   */
1133  function toggle() {
1134    if (state.isOpen) {
1135      close();
1136    } else {
1137      open();
1138    }
1139  }
1140
1141  /**
1142   * Handle input changes with debouncing
1143   */
1144  function handleInput(e) {
1145    // Skip if we're performing a category search to avoid interference
1146    if (state.isPerformingCategorySearch) {
1147      return;
1148    }
1149    
1150    const query = e.target.value.trim();
1151    
1152    // Show/hide clear button
1153    if (query.length > 0) {
1154      elements.clearBtn.style.display = 'block';
1155    } else {
1156      elements.clearBtn.style.display = 'none';
1157      showInitialState();
1158      return;
1159    }
1160
1161    // Filter suggestions even for short queries
1162    if (query.length >= 1 && query.length < CONFIG.minSearchLength) {
1163      filterInitialSuggestions(query);
1164    }
1165
1166    // Debounce search
1167    clearTimeout(state.searchTimeout);
1168    
1169    if (query.length >= CONFIG.minSearchLength) {
1170      state.searchTimeout = setTimeout(() => {
1171        performSearch(query);
1172      }, CONFIG.debounceDelay);
1173    }
1174  }
1175
1176  /**
1177   * Handle keyboard navigation
1178   */
1179  function handleKeydown(e) {
1180    switch(e.key) {
1181      case 'Enter':
1182        e.preventDefault();
1183        if (state.focusedIndex >= 0 && state.focusableElements[state.focusedIndex]) {
1184          state.focusableElements[state.focusedIndex].click();
1185        } else {
1186          searchAll();
1187        }
1188        break;
1189      
1190      case 'ArrowDown':
1191        e.preventDefault();
1192        navigateResults(1);
1193        break;
1194      
1195      case 'ArrowUp':
1196        e.preventDefault();
1197        navigateResults(-1);
1198        break;
1199      
1200      case 'Tab':
1201        // Allow natural tab navigation but track it
1202        updateFocusableElements();
1203        break;
1204    }
1205  }
1206
1207  /**
1208   * Navigate through search results with arrow keys
1209   */
1210  function navigateResults(direction) {
1211    updateFocusableElements();
1212    
1213    if (state.focusableElements.length === 0) return;
1214    
1215    // Remove current highlight
1216    if (state.focusedIndex >= 0 && state.focusableElements[state.focusedIndex]) {
1217      state.focusableElements[state.focusedIndex].classList.remove('keyboard-focused');
1218    }
1219    
1220    // Update index
1221    state.focusedIndex += direction;
1222    
1223    // Wrap around
1224    if (state.focusedIndex < 0) {
1225      state.focusedIndex = state.focusableElements.length - 1;
1226    } else if (state.focusedIndex >= state.focusableElements.length) {
1227      state.focusedIndex = 0;
1228    }
1229    
1230    // Highlight new element
1231    const focusedElement = state.focusableElements[state.focusedIndex];
1232    if (focusedElement) {
1233      focusedElement.classList.add('keyboard-focused');
1234      focusedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
1235    }
1236  }
1237
1238  /**
1239   * Update list of focusable elements
1240   */
1241  function updateFocusableElements() {
1242    state.focusableElements = Array.from(
1243      elements.overlay.querySelectorAll(
1244        '.suggestion-item, .search-product-tile, .search-all-button'
1245      )
1246    ).filter(el => el.offsetParent !== null); // Only visible elements
1247  }
1248
1249  /**
1250   * Clear search input and results
1251   */
1252  function clearSearch() {
1253    elements.input.value = '';
1254    elements.clearBtn.style.display = 'none';
1255    state.currentQuery = '';
1256    showInitialState();
1257    elements.input.focus();
1258  }
1259
1260  /**
1261   * Show initial state with dynamic suggestions
1262   */
1263  function showInitialState() {
1264    // Show initial sections with skeleton loaders, hide search suggestions
1265    elements.searchSuggestions.style.display = 'none';
1266    
1267    // Always show sections initially (skeleton loaders will be visible until data loads)
1268    elements.popularSearches.style.display = 'block';
1269    elements.topBrands.style.display = 'block';
1270    elements.topCategories.style.display = 'block';
1271    
1272    elements.productPreviews.innerHTML = '';
1273    elements.emptyState.style.display = 'flex';
1274    elements.noResults.style.display = 'none';
1275    elements.searchAllBtn.style.display = 'none';
1276    if (elements.searchAllBtn.closest('.search-overlay-footer')) {
1277      elements.searchAllBtn.closest('.search-overlay-footer').style.display = 'none';
1278    }
1279    
1280    // Load suggestions if not already loaded
1281    if (state.popularSearches.length === 0) {
1282      loadSuggestions();
1283    } else {
1284      renderInitialSuggestions();
1285    }
1286  }
1287  
1288  /**
1289   * Load suggestions from API with caching
1290   */
1291  function loadSuggestions() {
1292    // Check cache first
1293    const now = Date.now();
1294    if (state.suggestionsCache && state.cacheTimestamp && (now - state.cacheTimestamp < CONFIG.cacheDuration)) {
1295      renderInitialSuggestions();
1296      return;
1297    }
1298    
1299    // Check if user is logged in
1300    if (typeof user !== 'undefined' && user.isLoggedIn && user.isLoggedIn()) {
1301      state.isLoggedIn = true;
1302    }
1303    
1304    // Calculate date range (last 90 days for better performance)
1305    const endDate = new Date();
1306    const startDate = new Date();
1307    startDate.setDate(startDate.getDate() - 90);
1308    
1309    service.call('search/suggestions', {
1310      dateStart: startDate.toISOString(),
1311      dateEnd: endDate.toISOString()
1312    }, function(response) {
1313      try {
1314        if (response && response.data) {
1315          const payload = response.data;
1316          const normalizeList = (list) => Array.isArray(list) ? list.map(item => item.name || item) : [];
1317
1318          state.popularSearches = payload.popularSearches || [];
1319
1320          const dynamicBrands = normalizeList(payload.topBrands);
1321          if (dynamicBrands.length > 0) {
1322            state.topBrands = dynamicBrands;
1323            if (state.brands.length > 0) buildBrandMap();
1324          }
1325
1326          const dynamicCategories = normalizeList(payload.topCategories);
1327          if (dynamicCategories.length > 0) {
1328            state.topCategories = dynamicCategories;
1329            filterTopCategoriesByVisibility();
1330            if (state.categories.length > 0) buildCategoryMap();
1331          }
1332
1333          if (Array.isArray(payload.recentSearches) && payload.recentSearches.length > 0) {
1334            // Merge server-provided recents with local cache, preserving recency and avoiding duplicates
1335            const merged = [...state.recentSearches];
1336            payload.recentSearches.forEach(term => {
1337              const normalizedTerm = (term || '').toString();
1338              if (normalizedTerm.length === 0) return;
1339              if (!merged.some(existing => existing.toLowerCase() === normalizedTerm.toLowerCase())) {
1340                merged.push(normalizedTerm);
1341              }
1342            });
1343            state.recentSearches = merged.slice(0, CONFIG.maxRecentSearches);
1344          }
1345
1346          state.suggestionsCache = payload;
1347          state.cacheTimestamp = now;
1348          renderInitialSuggestions();
1349        }
1350      } catch (e) {
1351        console.warn('Failed to load suggestions:', e);
1352      }
1353    });
1354  }
1355  
1356  /**
1357   * Fetch brands from API for brand ID lookup
1358   */
1359  function fetchBrands() {
1360    service.call('brands', {
1361      page: 1,
1362      size: 500
1363    }, function(response) {
1364      try {
1365        if (response && response.data && Array.isArray(response.data)) {
1366          state.brands = response.data;
1367          buildBrandMap();
1368          // Re-render suggestions if overlay is already open
1369          if (state.isOpen && elements.topBrandsList) {
1370            renderInitialSuggestions();
1371          }
1372        }
1373      } catch (e) {
1374        console.warn('Failed to load brands:', e);
1375      }
1376    });
1377  }
1378
1379  /**
1380   * Fetch categories from API for category ID lookup
1381   */
1382  function fetchCategories() {
1383    service.call('categories', {
1384      page: 1,
1385      size: 500
1386    }, function(response) {
1387      try {
1388        if (response && response.data && Array.isArray(response.data)) {
1389          const isHidden = (cat) => cat.hidden === true || cat.hidden === 1 || cat.isHidden === true || cat.isHidden === 1;
1390          state.categories = response.data.filter(cat => !isHidden(cat));
1391          filterTopCategoriesByVisibility();
1392          buildCategoryMap();
1393          // Re-render suggestions if overlay is already open
1394          if (state.isOpen) {
1395            renderInitialSuggestions();
1396          }
1397        }
1398      } catch (e) {
1399        console.warn('Failed to load categories:', e);
1400      }
1401    });
1402  }
1403
1404  /**
1405   * Auto-map topBrands display names to actual brand IDs
1406   * Uses fuzzy keyword matching to handle brand name variations
1407   */
1408  function buildBrandMap() {
1409    state.brandMap = {};
1410    
1411    state.topBrands.forEach(displayName => {
1412      const normalized = displayName.toLowerCase().trim();
1413      
1414      // Find brand that matches the display name
1415      const matchedBrand = state.brands.find(brand => {
1416        const brandName = (brand.name || '').toLowerCase().trim();
1417        const brandLabel = (brand.label || '').toLowerCase().trim();
1418        
1419        // Check for exact match or close match
1420        return brandName === normalized || brandLabel === normalized || 
1421               brandName.includes(normalized) || normalized.includes(brandName);
1422      });
1423      
1424      if (matchedBrand) {
1425        state.brandMap[displayName] = matchedBrand.brandId;
1426      }
1427    });
1428  }
1429
1430  /**
1431   * Remove hidden categories from the suggestion list using the fetched category set
1432   */
1433  function filterTopCategoriesByVisibility() {
1434    if (!state.categories || state.categories.length === 0 || !state.topCategories) return;
1435    const visibleNames = new Set(
1436      state.categories
1437        .map(cat => (cat.label || cat.name || '').toLowerCase().trim())
1438        .filter(name => name.length > 0)
1439    );
1440    state.topCategories = state.topCategories.filter(displayName => visibleNames.has(displayName.toLowerCase().trim()));
1441  }
1442
1443  /**
1444   * Auto-map topCategories display names to actual category IDs
1445   * Uses fuzzy keyword matching to handle variations across stores
1446   */
1447  function buildCategoryMap() {
1448    state.categoryMap = {};
1449    
1450    state.topCategories.forEach(displayName => {
1451      const keywords = displayName.toLowerCase().split(/\s+/);
1452      
1453      // Find category that matches all keywords
1454      const matchedCategory = state.categories.find(cat => {
1455        const categoryName = cat.name.toLowerCase();
1456        const categoryLabel = (cat.label || '').toLowerCase();
1457        
1458        // Check if all keywords match in either name or label
1459        const nameMatch = keywords.every(keyword => categoryName.includes(keyword));
1460        const labelMatch = keywords.every(keyword => categoryLabel.includes(keyword));
1461        
1462        return nameMatch || labelMatch;
1463      });
1464      
1465      if (matchedCategory) {
1466        state.categoryMap[displayName] = matchedCategory.categoryId;
1467      } else {
1468        console.warn(`No category found for "${displayName}"`);
1469      }
1470    });
1471  }
1472
1473  /**
1474   * Check if query matches text with word boundary awareness
1475   */
1476  function matchesQuery(text, query) {
1477    const normalizedText = normalizeQuery(text).toLowerCase();
1478    const normalizedQuery = normalizeQuery(query).toLowerCase();
1479
1480    if (!normalizedQuery) return false;
1481
1482    // Fast path: full string prefix or substring match (handles multi-word, numbers)
1483    if (normalizedText.startsWith(normalizedQuery)) return true;
1484    if (normalizedText.indexOf(normalizedQuery) !== -1) return true;
1485
1486    // Fallback: word-boundary prefix match for partials
1487    const words = normalizedText.split(/[\s\-_]+/);
1488    return words.some(word => word.startsWith(normalizedQuery));
1489  }
1490
1491  /**
1492   * Filter initial suggestions based on query with normalization
1493   */
1494  function filterInitialSuggestions(query) {
1495    // Filter and render popular searches
1496    const matchingPopular = state.popularSearches
1497      .filter(item => {
1498        const text = item.name || item;
1499        return matchesQuery(text, query);
1500      })
1501      .slice(0, CONFIG.maxPopularSearches);
1502    
1503    if (matchingPopular.length > 0) {
1504      elements.popularSearchesList.innerHTML = '';
1505      matchingPopular.forEach(item => {
1506        const text = item.name || item;
1507        const li = document.createElement('li');
1508        li.className = 'suggestion-item';
1509        li.innerHTML = highlightMatch(text, query);
1510        li.addEventListener('click', () => {
1511          elements.input.value = text;
1512          performSearch(text);
1513        });
1514        elements.popularSearchesList.appendChild(li);
1515      });
1516      elements.popularSearches.style.display = 'block';
1517    } else {
1518      elements.popularSearches.style.display = 'none';
1519    }
1520    
1521    // Filter and render top brands
1522    const matchingBrands = state.topBrands
1523      .filter(item => {
1524        const text = item.name || item;
1525        return matchesQuery(text, query);
1526      })
1527      .slice(0, CONFIG.maxTopBrands);
1528    
1529    if (matchingBrands.length > 0) {
1530      elements.topBrandsList.innerHTML = '';
1531      matchingBrands.forEach(item => {
1532        const li = createBrandSuggestionItem(item);
1533        elements.topBrandsList.appendChild(li);
1534      });
1535      elements.topBrands.style.display = 'block';
1536    } else {
1537      elements.topBrands.style.display = 'none';
1538    }
1539    
1540    // Filter and render categories
1541    const matchingCategories = state.topCategories
1542      .filter(category => matchesQuery(category, query))
1543      .slice(0, CONFIG.maxTopCategories);
1544    
1545    if (matchingCategories.length > 0) {
1546      elements.topCategoriesList.innerHTML = '';
1547      matchingCategories.forEach(category => {
1548        const li = document.createElement('li');
1549        li.className = 'suggestion-item';
1550        li.innerHTML = highlightMatch(category, query);
1551        li.addEventListener('click', () => {
1552          elements.input.value = category;
1553          performSearch(category);
1554        });
1555        elements.topCategoriesList.appendChild(li);
1556      });
1557      elements.topCategories.style.display = 'block';
1558    } else {
1559      elements.topCategories.style.display = 'none';
1560    }
1561    
1562    // Hide recent searches during filtering
1563    elements.recentSearches.style.display = 'none';
1564    
1565    // Hide products during initial typing
1566    elements.productPreviews.innerHTML = '';
1567    elements.emptyState.style.display = 'flex';
1568    elements.noResults.style.display = 'none';
1569    elements.searchSuggestions.style.display = 'none';
1570  }
1571  
1572  /**
1573   * Render initial suggestions (Popular, Brands, Categories, Recent)
1574   */
1575  function renderInitialSuggestions() {
1576    // Render recent searches inline (if any exist)
1577    if (state.recentSearches.length > 0) {
1578      const searches = state.recentSearches.slice(0, CONFIG.maxRecentSearches);
1579      const linksHtml = searches.map((term, index) => {
1580        const separator = index < searches.length - 1 ? ', ' : '';
1581        return `<a href="#" class="recent-search-link" data-term="${term}">${term}</a>${separator}`;
1582      }).join('');
1583      
1584      elements.recentSearchesList.innerHTML = linksHtml;
1585      
1586      // Add click handlers
1587      elements.recentSearchesList.querySelectorAll('.recent-search-link').forEach(link => {
1588        link.addEventListener('click', (e) => {
1589          e.preventDefault();
1590          const term = e.target.dataset.term;
1591          elements.input.value = term;
1592          performSearch(term);
1593        });
1594      });
1595      
1596      elements.recentSearches.style.display = 'block';
1597    } else {
1598      elements.recentSearches.style.display = 'none';
1599    }
1600    
1601    // Render popular searches
1602    if (state.popularSearches.length > 0) {
1603      elements.popularSearchesList.innerHTML = '';
1604      state.popularSearches.slice(0, CONFIG.maxPopularSearches).forEac
1604h(item => {
1605        const li = createSuggestionItem(item.name || item);
1606        elements.popularSearchesList.appendChild(li);
1607      });
1608      elements.popularSearches.style.display = 'block';
1609    } else {
1610      elements.popularSearches.style.display = 'none';
1611    }
1612    
1613    // Render top brands
1614    if (state.topBrands.length > 0) {
1615      elements.topBrandsList.innerHTML = '';
1616      state.topBrands.slice(0, CONFIG.maxTopBrands).forEach(brandName => {
1617        const li = createBrandSuggestionItem(brandName);
1618        elements.topBrandsList.appendChild(li);
1619      });
1620      elements.topBrands.style.display = 'block';
1621    } else {
1622      elements.topBrands.style.display = 'none';
1623    }
1624    
1625    // Render top categories
1626    if (state.topCategories.length > 0) {
1627      elements.topCategoriesList.innerHTML = '';
1628      state.topCategories.slice(0, CONFIG.maxTopCategories).forEach(category => {
1629        const li = createCategorySuggestionItem(category);
1630        elements.topCategoriesList.appendChild(li);
1631      });
1632      elements.topCategories.style.display = 'block';
1633    } else {
1634      elements.topCategories.style.display = 'none';
1635    }
1636  }
1637  
1638  /**
1639   * Create a suggestion list item
1640   */
1641  function createSuggestionItem(text) {
1642    const li = document.createElement('li');
1643    li.className = 'suggestion-item';
1644    li.textContent = text;
1645    li.addEventListener('click', () => {
1646      elements.input.value = text;
1647      performSearch(text);
1648    });
1649    return li;
1650  }
1651
1652  /**
1653   * Create a category suggestion item with category ID filtering
1654   */
1655  function createCategorySuggestionItem(category) {
1656    const li = document.createElement('li');
1657    li.className = 'suggestion-item';
1658    li.textContent = category;
1659    li.addEventListener('click', () => {
1660      const categoryId = state.categoryMap[category];
1661      if (categoryId) {
1662        // Navigate to category page with ID-based URL
1663        performCategoryNavigation(category, categoryId);
1664      } else {
1665        // Fall back to text search if category not mapped
1666        elements.input.value = category;
1667        performSearch(category);
1668      }
1669    });
1670    return li;
1671  }
1672
1673  /**
1674   * Normalize search query to handle apostrophes and special characters
1675   * Handles gender-specific terms specially to avoid cross-matching
1676   */
1677  function normalizeQuery(query) {
1678    const trimmed = query.trim();
1679    const lower = trimmed.toLowerCase();
1680    
1681    // Check if this is a standalone gender term (men, mens, men's, women, womens, women's)
1682    const isGenderTerm = /^(wo)?men'?s?$/i.test(trimmed);
1683    
1684    if (isGenderTerm) {
1685      // Don't normalize gender terms - keep the apostrophe if present
1686      // This ensures database can differentiate Men's from Womens
1687      return trimmed;
1688    }
1689    
1690    // For non-gender terms, remove apostrophes
1691    return trimmed.replace(/['']/g, '');
1692  }
1693
1694  /** 
1695   * Extract gender keywords from query and return query with gender codes
1696   * NOTE: We don't remove gender keywords from the query - they still help with matching
1697   * @param {string} query - The search query
1698   * @returns {Object} - { cleanedQuery: string, genders: string[] }
1699   */ 
1700  function extractGenderFromQuery(query) {
1701    // Gender keyword patterns mapped to gender codes
1702    const genderPatterns = {
1703      'M': [
1704        /\b(men'?s?|male|mns?|boys?)\b/gi
1705      ],
1706      'W': [
1707        /\b(women'?s?|womens?|wmns|female|ladies|girls?)\b/gi
1708      ],
1709      'U': [
1710        /\bunisex\b/gi
1711      ],
1712      'K': [
1713        /\b(kids?|youth|children|child)\b/gi
1714      ]
1715    }; 
1716
1717    const detectedGenders = new Set();
1718    let cleanedQuery = query;
1719
1720    // Check each gender pattern and remove from query if found
1721    for (const [genderCode, patterns] of Object.entries(genderPatterns)) {
1722      for (const pattern of patterns) {
1723        if (pattern.test(cleanedQuery)) {
1724          detectedGenders.add(genderCode);
1725          // Remove the gender keyword from the query
1726          cleanedQuery = cleanedQuery.replace(pattern, '').trim();
1727        }
1728      }
1729    }
1730
1731    // Clean up multiple spaces
1732    cleanedQuery = cleanedQuery.replace(/\s+/g, ' ').trim();
1733
1734    return {
1735      cleanedQuery: cleanedQuery,
1736      genders: Array.from(detectedGenders)
1737    };
1738  }
1739
1740  /**
1741   * Perform search with API call
1742   */
1743  function performSearch(query) {
1744    state.currentQuery = query;
1745    state.currentCategoryId = null; // Reset category ID for text searches
1746    
1747    // Hide initial sections when searching
1748    elements.recentSearches.style.display = 'none';
1749    elements.popularSearches.style.display = 'none';
1750    elements.topBrands.style.display = 'none';
1751    elements.topCategories.style.display = 'none';
1752    
1753    // Show loading state 
1754    elements.productPreviews.parentElement.classList.add('loading');
1755    elements.productPreviews.style.display = 'none';  // Hide product grid during search
1756    elements.emptyState.style.display = 'none';
1757    elements.noResults.style.display = 'none';
1758    
1759    // Hide footer until we have results
1760    elements.searchAllBtn.style.display = 'none';
1761    if (elements.searchAllBtn.closest('.search-overlay-footer')) {
1762      elements.searchAllBtn.closest('.search-overlay-footer').style.display = 'none';
1763    }
1764    
1765    // Show loading bar and searching state
1766    if (elements.loadingBar) {
1767      elements.loadingBar.classList.add('active');
1768    }
1769    elements.searching.style.display = 'flex';
1770
1771    // FIRST: Extract quoted strings before any other processing
1772    // Example: 'nike "wave inspire 16" shoes' => quotedPhrases: ['wave inspire 16'], cleanedQuery: 'nike shoes'
1773    const quoteResult = extractQuotedStrings(query);
1774    const quotedPhrases = quoteResult.quotedPhrases;
1775    let cleanedQuery = quoteResult.cleanedQuery;
1776    
1777    console.log('Quote extraction:', { originalQuery: query, quotedPhrases, cleanedQuery });
1778
1779    // Normalize remaining query to handle apostrophes (Men's vs Mens)
1780    const normalizedQuery = normalizeQuery(cleanedQuery);
1781    
1782    // Extract gender keywords and convert to gender filter
1783    const genderResult = extractGenderFromQuery(normalizedQuery);
1784    cleanedQuery = genderResult.cleanedQuery; // Re-assign, don't re-declare
1785    const genders = genderResult.genders;
1786    
1787    // Extract brand names and convert to brand filter
1788    const brandResult = extractBrandsFromQuery(cleanedQuery, state.brandMap);
1789    cleanedQuery = brandResult.cleanedQuery;
1790    const detectedBrands = brandResult.detectedBrands;
1791    
1792    // If we have quoted phrases and NO remaining text, skip brand/gender extraction
1793    // This means the entire search is quoted - use ONLY the quoted phrase(s)
1794    const isFullyQuoted = quotedPhrases && quotedPhrases.length > 0 && (!cleanedQuery || cleanedQuery.trim().length === 0);
1795    
1796    let searchVariations = [];
1797    
1798    if (isFullyQuoted) {
1799      // Everything was quoted - use ONLY the quoted phrase(s), no brand/gender extraction
1800      quotedPhrases.forEach(phrase => {
1801        if (phrase && phrase.trim().length > 0) {
1802          searchVariations.push(phrase.trim().toLowerCase());
1803        }
1804      });
1805      
1806      console.log('Fully quoted search - using exact phrases only:', searchVariations);
1807    } else {
1808      // Normal search or mixed quoted+unquoted
1809      console.log('Brand extraction:', { brands: detectedBrands, cleanedQuery });
1810      console.log('Gender extraction:', { normalizedQuery, cleanedQuery, genders });
1811      
1812      // Add quoted phrases first (highest priority)
1813      if (quotedPhrases && quotedPhrases.length > 0) {
1814        quotedPhrases.forEach(phrase => {
1815          if (phrase && phrase.trim().length > 0) {
1816            searchVariations.push(phrase.trim().toLowerCase());
1817          }
1818        });
1819      }
1820      
1821      // Then add variations from remaining query text
1822      if (cleanedQuery && cleanedQuery.trim().length > 0) {
1823        const variations = expandQueryWithSynonyms(cleanedQuery, genders);
1824        const validVariations = variations.filter(v => v && v.trim().length > 0);
1825        searchVariations.push(...validVariations);
1826      }
1827    }
1828    
1829    // If we have no variations at all, set to null
1830    if (searchVariations.length === 0) {
1831      searchVariations = null;
1832    }
1833    
1834    console.log('Search variations:', searchVariations);
1835
1836    // Check if this is a color search - need more results since we filter client-side
1837    const colorCheck = extractColorFromQuery(normalizedQuery);
1838    const isColorSearch = colorCheck.colors && colorCheck.colors.length > 0;
1839
1840    // Call API endpoint with search variations
1841    const filter = { 
1842      page: 1,
1843      // Request more products for color searches since we filter client-side
1844      pageSize: isColorSearch ? 36 : CONFIG.maxProducts,
1845      sort: 'relevance', 
1846      sortType: 'ASC'
1847    };
1848    
1849    // Add search variations if we have them
1850    if (searchVariations && searchVariations.length > 0) {
1851      filter.searchVariations = searchVariations;
1852    }
1853    
1854    // Only add gender/brand filters if NOT fully quoted
1855    if (!isFullyQuoted) {
1856      // Add gender filter if detected
1857      if (genders.length > 0) {
1858        filter.genders = genders;
1859      }
1860      
1861      // Add brand filter if detected
1862      if (detectedBrands.length > 0) {
1863        filter.brands = detectedBrands.map(b => b.id);
1864      }
1865    }
1866
1867    const hideLoading = () => {
1868      if (elements.loadingBar) {
1869        elements.loadingBar.classList.remove('active');
1870      }
1871      elements.searching.style.display = 'none';
1872    };
1873
1874    // Execute the search with single query (no fallback needed)
1875    service.call('products-search', filter, function(response) {
1876      hideLoading();
1877      handleSearchResults(response || { data: [] });
1878    });
1879  }
1880
1881  /**
1882   * Perform category-based search using categoryId filter
1883   */
1884  function performCategorySearch(categoryName, categoryId) {
1885    state.currentQuery = categoryName;
1886    state.currentCategoryId = categoryId;
1887    
1888    // Set flag to prevent input handler from interfering
1889    state.isPerformingCategorySearch = true;
1890    
1891    // Set input value
1892    elements.input.value = categoryName;
1893    elements.input.setAttribute('aria-expanded', 'true');
1894    
1895    // Hide initial sections
1896    elements.recentSearches.style.display = 'none';
1897    elements.popularSearches.style.display = 'none';
1898    elements.topBrands.style.display = 'none';
1899    elements.topCategories.style.display = 'none';
1900    elements.searchSuggestions.style.display = 'none';
1901    
1902    // Clear products and show loading
1903    elements.productPreviews.innerHTML = '';
1904    elements.emptyState.style.display = 'none';
1905    elements.noResults.style.display = 'none';
1906    
1907    // Hide footer until we have results
1908    elements.searchAllBtn.style.display = 'none';
1909    if (elements.searchAllBtn.closest('.search-overlay-footer')) {
1910      elements.searchAllBtn.closest('.search-overlay-footer').style.display = 'none';
1911    }
1912    
1913    // Show loading bar and searching state
1914    if (elements.loadingBar) {
1915      elements.loadingBar.classList.add('active');
1916    }
1917    elements.searching.style.display = 'flex';
1918
1919    // Call API with category filter
1920    const filter = {
1921      category: categoryId,  // Backend expects 'category', not 'categoryId'
1922      page: 1,
1923      pageSize: CONFIG.maxProducts,
1924      sort: 'name',
1925      sortType: 'ASC'
1926    };
1927
1928    service.call('products', filter, function(response) {
1929      // Hide loading bar and searching state
1930      if (elements.loadingBar) {
1931        elements.loadingBar.classList.remove('active');
1932      }
1933      elements.searching.style.display = 'none';
1934      
1935      // Reset flag after search completes
1936      state.isPerformingCategorySearch = false;
1937      
1938      handleSearchResults(response);
1939    });
1940  }
1941
1942  /**
1943   * Navigate to brand page with proper ID and slug
1944   */
1945  function performBrandNavigation(brandName, brandId) {
1946    // Create URL-friendly slug from brand name
1947    const slug = brandName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1948    
1949    if (!brandId) {
1950      console.error('Brand ID not found for:', brandName);
1951      return;
1952    }
1953    
1954    // Navigate to brand page with proper URL format: /brand/:id/:slug
1955    window.location.href = `/brand/${brandId}/${slug}`;
1956  }
1957
1958  /**
1959   * Perform brand-filtered search
1960   */
1961  function performBrandSearch(brandName, brandId) {
1962    // Redirect to shop page with brand filter
1963    performBrandNavigation(brandName, brandId);
1964  }
1965
1966  /**
1967   * Navigate to category page with proper ID and slug
1968   */
1969  function performCategoryNavigation(categoryName, categoryId) {
1970    // Create URL-friendly slug from category name
1971    const slug = categoryName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1972    
1973    if (!categoryId) {
1974      console.error('Category ID not found for:', categoryName);
1975      return;
1976    }
1977    
1978    // Navigate to category page with proper URL format: /category/:id/:slug
1979    window.location.href = `/category/${categoryId}/${slug}`;
1980  }
1981
1982  /**
1983   * Create brand suggestion item with click handler
1984   * Looks up brandId from brandMap similar to how categories work
1985   */
1986  function createBrandSuggestionItem(brandName) {
1987    const li = document.createElement('li');
1988    li.className = 'suggestion-item';
1989    li.textContent = brandName;
1990    
1991    li.addEventListener('click', () => {
1992      // Look up brandId from brandMap (same pattern as categories)
1993      const brandId = state.brandMap[brandName];
1994      
1995      if (brandId) {
1996        performBrandNavigation(brandName, brandId);
1997      } else {
1998        console.error('Brand ID not found in brandMap for:', brandName);
1999      }
2000    });
2001    
2002    return li;
2003  }
2004
2005  /**
2006   * Handle search results from API
2007   */
2008  function handleSearchResults(response) {
2009    elements.productPreviews.parentElement.classList.remove('loading');
2010    
2011    if (!response || !response.data) {
2012      showNoResults();
2013      return;
2014    }
2015
2016    // Apply client-side color filtering if query contains color keywords
2017    // This ensures instant search dropdown matches shop page behavior
2018    let filteredProducts = response.data;
2019    const colorExtraction = extractColorFromQuery(state.currentQuery);
2020    
2021    if (colorExtraction.colors && colorExtraction.colors.length > 0) {
2022      filteredProducts = response.data.filter(product => 
2023        productMatchesColorFilter(product.color, colorExtraction.colors)
2024      );
2025    }
2026    
2027    state.products = filteredProducts;
2028    
2029    if (state.products.length === 0) {
2030      showNoResults();
2031      return;
2032    }
2033
2034    // Update UI
2035    renderProducts(state.products);
2036    generateSuggestions(state.currentQuery, state.products);
2037    elements.searchAllBtn.style.display = 'block';
2038    if (elements.searchAllBtn.closest('.search-overlay-footer')) {
2039      elements.searchAllBtn.closest('.search-overlay-footer').style.display = 'flex';
2040    }
2041  }
2042
2043  /**
2044   * Render product previews using DocumentFragment for batch DOM operations
2045   */
2046  function renderProducts(products) {
2047    elements.productPreviews.innerHTML = '';
2048    elements.productPreviews.style.display = '';  // Let CSS handle display
2049    elements.emptyState.style.display = 'none';
2050    elements.noResults.style.display = 'none';
2051
2052    const maxProducts = Math.min(products.length, CONFIG.maxProducts);
2053    
2054    // Use DocumentFragment for batch appends - avoids multiple reflows
2055    const fragment = document.createDocumentFragment();
2056    
2057    for (let i = 0; i < maxProducts; i++) {
2058      const product = products[i];
2059      const element = createProductTile(product);
2060      fragment.appendChild(element);
2061    }
2062    
2063    // Single DOM append after all products added
2064    elements.productPreviews.appendChild(fragment);
2065    
2066    // Add "View All Results" card if there are results
2067    if (maxProducts > 0) {
2068      const viewAllCard = createViewAllCard();
2069      elements.productPreviews.appendChild(viewAllCard);
2070    }
2071    
2072    // Initialize color swatches after rendering products
2073    if (typeof colorSwatches !== 'undefined') {
2074      colorSwatches.init('#SearchProductPreviews');
2075      colorSwatches.load();
2076    }
2077    
2078    // Add click handlers for product tiles
2079    elements.productPreviews.querySelectorAll('.product.clickable').forEach(productElement => {
2080      productElement.addEventListener('click', event => {
2081        // Don't navigate if clicking on color swatches
2082        if (event.target.closest('.colorswatch')) {
2083          event.preventDefault();
2084          event.stopPropagation();
2085          return;
2086        }
2087        
2088        const url = productElement.getAttribute('data-url');
2089        if (url) {
2090          window.location.href = url;
2091        }
2092      });
2093    });
2094  }
2095
2096  /**
2097   * Create "View All Results" card
2098   */
2099  function createViewAllCard() {
2100    const card = document.createElement('div');
2101    card.className = 'product view-all-card clickable';
2102    card.innerHTML = `
2103      <div class="view-all-content">
2104        <span class="view-all-text">View All Results</span>
2105      </div>
2106    `;
2107    
2108    // Add click handler
2109    card.addEventListener('click', () => {
2110      searchAll();
2111    });
2112    
2113    return card;
2114  }
2115
2116  /**
2117   * Create a product tile from template
2118   */
2119  function createProductTile(product) {
2120    const element = elements.productTemplate.cloneNode(true);
2121    element.style.display = 'block';
2122    element.classList.remove('template');
2123    element.removeAttribute('id');
2124
2125    // Get product URL
2126    const brandLabel = product.label && product.label.length > 0 ? product.label : product.brandName;
2127    const url = `/product/${product.productId}/${brandLabel.trim().toLowerCase().replace(/\W/g, '-')}/${product.name.trim().toLowerCase().replace(/'/g, '').replace(/\W/g, '-')}`;
2128
2129    // Get image URL
2130    const imageUrl = getProductImageUrl(product);
2131
2132    // Sale badge
2133    const saleClass = product.isOnSale ? ' show' : '';
2134    
2135    // Price formatting
2136    let priceHtml = `$${product.cost.toFixed(2)}`;
2137    if (product.isOnSale && product.retail > product.cost) {
2138      priceHtml = `<span class="struck">$${product.retail.toFixed(2)}</span> $${product.cost.toFixed(2)}`;
2139    }
2140
2141    // Replace template variables in innerHTML
2142    let html = element.innerHTML;
2143    html = html.replace(/{{product\.url}}/g, imageUrl);
2144    html = html.replace(/{{onsale}}/g, saleClass);
2145    html = html.replace(/{{product\.brandName}}/g, brandLabel);
2146    html = html.replace(/{{product\.name}}/g, product.name);
2147    html = html.replace(/{{product\.price}}/g, priceHtml);
2148    html = html.replace(/{{url}}/g, url);
2149    html = html.replace(/{{siteId}}/g, typeof site !== 'undefined' && site.data ? site.data.siteId : '');
2150    html = html.replace(/{{totalColors}}/g, product.totalColors || 1);
2151    
2152    element.innerHTML = html;
2153    
2154    // Replace template variables in attributes
2155    const dataUrlAttr = element.getAttribute('data-url');
2156    if (dataUrlAttr) {
2157      element.setAttribute('data-url', dataUrlAttr.replace(/{{url}}/g, url));
2158    }
2159
2160    return element;
2161  }
2162
2163  /**
2164   * Get product image URL with CDN support
2165   */
2166  function getProductImageUrl(product) {
2167    if (!product.url) return '';
2168    
2169    const parts = product.url.split('/');
2170    const fileName = parts[parts.length - 1];
2171    
2172    // Determine bucket
2173    let bucket = '';
2174    if (product.url.indexOf('/products/') > 0) {
2175      const filenameParts = fileName.split('_');
2176      bucket = filenameParts.length > 2 ? filenameParts[0] : 'sync';
2177    } else if (product.url.indexOf('/meta/') > 0) {
2178      bucket = 'meta';
2179    }
2180
2181    // Return CDN URL or fallback
2182    if (product.url.indexOf('http') === 0) {
2183      return product.url;
2184    }
2185    
2186    // Check if CDN is enabled (assuming global variable or can check site settings)
2187    const useCdn = true; // Default to true, can be configured
2188    if (useCdn) {
2189      return `https://cdn.runfree.run/images/products/${bucket}/760/${fileName}`;
2190    }
2191    
2192    return `/images/760/${fileName}`;
2193  }
2194
2195  /**
2196   * Generate search suggestions based on products with relevance-based ordering
2197   * Relevance: Exact brand/category matches > Close brand/category matches > Products
2198   */
2199  function generateSuggestions(query, products) {
2200    const normalizeApostrophes = (str) => (str || '').toLowerCase().replace(/[’']/g, '');
2201    const queryLower = query.toLowerCase();
2202    const queryNormalized = normalizeApostrophes(query);
2203    
2204    // Expand query with synonyms for smarter category matching
2205    const expandedTerms = new Set([query]);
2206    const queryWords = query.toLowerCase().split(/\s+/);
2207    
2208    // Add synonym expansions
2209    queryWords.forEach(word => {
2210      if (SEARCH_SYNONYMS[word]) {
2211        SEARCH_SYNONYMS[word].forEach(synonym => expandedTerms.add(synonym));
2212      }
2213    });
2214    
2215    // Add category-specific terms
2216    if (queryLower.includes('men') || queryLower.includes('mens') || queryLower.includes("men's")) {
2217      expandedTerms.add('mens');
2218      expandedTerms.add('mens footwear');
2219      expandedTerms.add('mens apparel');
2220    }
2221    if (queryLower.includes('women') || queryLower.includes('womens') || queryLower.includes("women's") || queryLower.includes('wmns')) {
2222      expandedTerms.add('womens');
2223      expandedTerms.add('womens footwear');
2224      expandedTerms.add('womens apparel');
2225    }
2226    if (queryLower.includes('running') || queryLower.includes('run') || queryLower.includes('shoe')) {
2227      expandedTerms.add('footwear');
2228    }
2229
2230    const scoreCategory = (name) => {
2231      const catLower = (name || '').toLowerCase();
2232      const catNormalized = normalizeApostrophes(name);
2233      if (!catLower) return 0;
2234      
2235      // Exact match - highest priority
2236      if (catLower === queryLower || catNormalized === queryNormalized) return 3;
2237      
2238      // Starts with query
2239      if (catLower.startsWith(queryLower) || catNormalized.startsWith(queryNormalized)) return 2.6;
2240      
2241      // Contains query
2242      if (
2243        catLower.includes(queryLower) ||
2244        queryLower.includes(catLower) ||
2245        catNormalized.includes(queryNormalized) ||
2246        queryNormalized.includes(catNormalized)
2247      ) {
2248        return 2.4;
2249      }
2250      
2251      // Check against expanded terms (synonyms and category keywords)
2252      // Sort by length descending to check longer, more specific terms first
2253      const sortedTerms = Array.from(expandedTerms).sort((a, b) => b.length - a.length);
2254      for (const term of sortedTerms) {
2255        const termLower = term.toLowerCase();
2256        const termNormalized = normalizeApostrophes(term);
2257        
2258        // Exact multi-word match (e.g., "mens footwear" matches "Mens Footwear")
2259        if (catLower === termLower || catNormalized === termNormalized) {
2260          return 2.5;
2261        }
2262        
2263        // Category name matches expanded term
2264        if (catLower.includes(termLower) || catNormalized.includes(termNormalized)) {
2265          // "Mens Footwear" matches expanded "mens footwear" from "men's running shoes"
2266          return 2.3;
2267        }
2268        
2269        // Expanded term matches category name (less specific)
2270        if (termLower.includes(catLower) || termNormalized.includes(catNormalized)) {
2271          return 2.2;
2272        }
2273      }
2274      
2275      return 0;
2276    };
2277
2278    const addCategorySuggestion = (name, baseRelevance = 2) => {
2279      const relevance = Math.max(baseRelevance, scoreCategory(name));
2280      if (relevance === 0) return;
2281      if (!suggestionMap.has(name) || suggestionMap.get(name).relevance < relevance) {
2282        suggestionMap.set(name, { text: name, relevance });
2283      }
2284    };
2285    
2286    // Create objects with relevance scoring
2287    // Relevance levels: 3 = exact brand match, 2 = exact category match, 1 = product name
2288    const suggestionMap = new Map(); // Map<suggestion, {text, relevance}>
2289    
2290    // Collect all products' brand names - these are brand suggestions
2291    const brandsSeen = new Set();
2292    products.forEach(product => {
2293      if (product.brandName && !brandsSeen.has(product.brandName)) {
2294        brandsSeen.add(product.brandName);
2295        const brandLower = product.brandName.toLowerCase();
2296        const brandNormalized = normalizeApostrophes(product.brandName);
2297        
2298        // Check if brand is an exact match or close match
2299        if (brandLower === queryLower || brandNormalized === queryNormalized) {
2300          // Exact match: highest relevance
2301          suggestionMap.set(product.brandName, { text: product.brandName, relevance: 3 });
2302        } else if (
2303          brandLower.includes(queryLower) ||
2304          queryLower.includes(brandLower) ||
2305          brandNormalized.includes(queryNormalized) ||
2306          queryNormalized.includes(brandNormalized)
2307        ) {
2308          // Partial match: high relevance (if not already added as exact)
2309          if (!suggestionMap.has(product.brandName) || suggestionMap.get(product.brandName).relevance < 3) {
2310            suggestionMap.set(product.brandName, { text: product.brandName, relevance: 3 });
2311          }
2312        }
2313      }
2314    });
2315
2316    // Collect category matches from topCategories (prioritized list)
2317    if (state.topCategories && Array.isArray(state.topCategories)) {
2318      state.topCategories.forEach(category => addCategorySuggestion(category, 2.2));
2319    }
2320
2321    // Fallback: match against full category list so base categories like "Mens" surface even if not in topCategories
2322    if (state.categories && Array.isArray(state.categories)) {
2323      state.categories.forEach(cat => {
2324        const name = cat.label || cat.name;
2325        addCategorySuggestion(name, 1.8);
2326      });
2327    }
2328
2329    // Add product names with relevance scoring based on match quality
2330    // Products should be ranked higher when they contain the search term
2331    const productsSeen = new Set();
2332    products.forEach(product => {
2333      if (product.name && !productsSeen.has(product.name)) {
2334        productsSeen.add(product.name);
2335        const productLower = product.name.toLowerCase();
2336        const productNormalized = normalizeApostrophes(product.name);
2337        
2338        // Only add if not already added as brand or category
2339        if (!suggestionMap.has(product.name)) {
2340          let relevance = 0;
2341          
2342          // Exact match: very high relevance
2343          if (productLower === queryLower || productNormalized === queryNormalized) {
2344            relevance = 2.8;
2345          }
2346          // Starts with query: high relevance (e.g., "Ghost 12" when searching "Ghost")
2347          else if (productLower.startsWith(queryLower) || productNormalized.startsWith(queryNormalized)) {
2348            relevance = 2.5;
2349          }
2350          // Contains query: medium-high relevance (e.g., "Running Ghost" when searching "Ghost")
2351          else if (
2352            productLower.includes(queryLower) ||
2353            queryLower.includes(productLower) ||
2354            productNormalized.includes(queryNormalized) ||
2355            queryNormalized.includes(productNormalized)
2356          ) {
2357            relevance = 2.0;
2358          }
2359          
2360          if (relevance > 0) {
2361            suggestionMap.set(product.name, { text: product.name, relevance });
2362          }
2363        }
2364      }
2365    });
2366
2367    // Sort by relevance (descending) and convert to array
2368    const suggestionArray = Array.from(suggestionMap.values())
2369      .sort((a, b) => b.relevance - a.relevance) // Higher relevance first
2370      .map(item => item.text)
2371      .slice(0, CONFIG.maxSuggestions);
2372    
2373    if (suggestionArray.length > 0) {
2374      renderSuggestions(suggestionArray, query);
2375    } else {
2376      elements.searchSuggestions.style.display = 'none';
2377    }
2378  }
2379
2380  /**
2381   * Show autocorrect suggestion and optionally auto-search
2382   */
2383  function showAutocorrectSuggestion(correctedQuery) {
2384    // Update the no results message with autocorrect suggestion
2385    const noResultsEl = elements.noResults;
2386    
2387    if (noResultsEl) {
2388      // Remove any existing autocorrect suggestions
2389      const existingSuggestion = noResultsEl.querySelector('.autocorrect-suggestion');
2390      if (existingSuggestion) {
2391        existingSuggestion.remove();
2392      }
2393      
2394      // Create autocorrect suggestion
2395      const suggestionDiv = document.createElement('div');
2396      suggestionDiv.className = 'autocorrect-suggestion';
2397      suggestionDiv.innerHTML = `
2398        Did you mean <a href="#" class="autocorrect-link">${correctedQuery}</a>?
2399      `;
2400      
2401      // Append to no results element
2402      noResultsEl.appendChild(suggestionDiv);
2403      
2404      // Add click handler
2405      const link = suggestionDiv.querySelector('.autocorrect-link');
2406      link.addEventListener('click', (e) => {
2407        e.preventDefault();
2408        elements.input.value = correctedQuery;
2409        performSearch(correctedQuery);
2410      });
2411      
2412      // Track autocorrect suggestion shown
2413      trackEvent('autocorrect_suggested', {
2414        original: state.currentQuery,
2415        suggestion: correctedQuery
2416      });
2417    }
2418  }
2419
2420  /**
2421   * Render suggestions with highlighted matches using DocumentFragment
2422   */
2423  /**
2424   * Find if a suggestion matches a brand or category exactly
2425   * Returns { type: 'brand'|'category', id: <id>, url: <url> } or null
2426   */
2427  function findBrandOrCategoryMatch(suggestion) {
2428    if (!suggestion) return null;
2429    
2430    const suggestionLower = suggestion.toLowerCase().trim();
2431    
2432    // Check brands first
2433    if (state.brands && Array.isArray(state.brands)) {
2434      const matchedBrand = state.brands.find(brand => {
2435        const brandName = (brand.name || '').toLowerCase().trim();
2436        const brandLabel = (brand.label || '').toLowerCase().trim();
2437        return brandName === suggestionLower || brandLabel === suggestionLower;
2438      });
2439      
2440      if (matchedBrand) {
2441        const brandSlug = (matchedBrand.label || matchedBrand.name || '').toLowerCase().replace(/\W/g, '-');
2442        return {
2443          type: 'brand',
2444          id: matchedBrand.brandId,
2445          url: `/brand/${matchedBrand.brandId}/${brandSlug}`,
2446          name: suggestion
2447        };
2448      }
2449    }
2450    
2451    // Check topCategories (these are the searchable categories)
2452    if (state.topCategories && Array.isArray(state.topCategories)) {
2453      const matchedCategory = state.topCategories.find(cat => {
2454        const catName = (cat || '').toLowerCase().trim();
2455        return catName === suggestionLower;
2456      });
2457      
2458      if (matchedCategory) {
2459        // For topCategories, we need to find the actual category ID from the categoryMap
2460        // The categoryMap is built by buildCategoryMap() function
2461        const categoryId = state.categoryMap && state.categoryMap[matchedCategory];
2462        if (categoryId) {
2463          const categorySlug = matchedCategory.toLowerCase().replace(/\W/g, '-');
2464          return {
2465            type: 'category',
2466            id: categoryId,
2467            url: `/category/${categoryId}/${categorySlug}`,
2468            name: suggestion
2469          };
2470        }
2471      }
2472    }
2473    
2474    return null;
2475  }
2476
2477  function renderSuggestions(suggestions, query) {
2478    elements.recentSearches.style.display = 'none';
2479    elements.popularSearches.style.display = 'none';
2480    elements.topBrands.style.display = 'none';
2481    elements.topCategories.style.display = 'none';
2482    elements.searchSuggestions.style.display = 'block';
2483    elements.suggestionsList.innerHTML = '';
2484
2485    // Use DocumentFragment for batch appends - avoids multiple reflows
2486    const fragment = document.createDocumentFragment();
2487
2488    suggestions.forEach(suggestion => {
2489      const li = document.createElement('li');
2490      li.className = 'suggestion-item';
2491      
2492      // Check if suggestion matches a brand or category
2493      const match = findBrandOrCategoryMatch(suggestion);
2494      
2495      if (match) {
2496        // Create link for exact brand/category matches
2497        const link = document.createElement('a');
2498        link.href = match.url;
2499        link.innerHTML = highlightMatch(suggestion, query);
2500        link.className = `suggestion-link suggestion-${match.type}`;
2501        li.appendChild(link);
2502        li.classList.add('suggestion-direct-link');
2503      } else {
2504        // Regular suggestion that performs search
2505        li.innerHTML = highlightMatch(suggestion, query);
2506        li.addEventListener('click', () => {
2507          elements.input.value = suggestion;
2508          performSearch(suggestion);
2509        });
2510      }
2511      
2512      fragment.appendChild(li);
2513    });
2514    
2515    // Single DOM append after all suggestions added
2516    elements.suggestionsList.appendChild(fragment);
2517  }
2518
2519  /**
2520   * Highlight matching text in suggestions
2521   */
2522  function highlightMatch(text, query) {
2523    if (!query) return text;
2524    
2525    // Try exact match first
2526    let regex = new RegExp(`(${escapeRegex(query)})`, 'gi');
2527    let result = text.replace(regex, '<span class="match">$1</span>');
2528    
2529    // If no match and query has no apostrophe, try matching with apostrophe variants
2530    if (!result.includes('<span class="match">')) {
2531      const normalizedQuery = normalizeQuery(query);
2532      const normalizedText = normalizeQuery(text);
2533      
2534      // Find where the match occurs in normalized text
2535      const normalizedIndex = normalizedText.toLowerCase().indexOf(normalizedQuery.toLowerCase());
2536      if (normalizedIndex !== -1) {
2537        // Reconstruct the match from original text
2538        regex = new RegExp(`(${escapeRegex(normalizedQuery)})`, 'gi');
2539        result = text.replace(regex, '<span class="match">$1</span>');
2540      }
2541    }
2542    
2543    return result;
2544  }
2545
2546  /**
2547   * Escape regex special characters
2548   */
2549  function escapeRegex(string) {
2550    return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2551  }
2552
2553  /**
2554   * Show no results state
2555   */
2556  function showNoResults() {
2557    elements.productPreviews.innerHTML = '';
2558    elements.emptyState.style.display = 'none';
2559    elements.noResults.style.display = 'flex';
2560    elements.recentSearches.style.display = 'none';
2561    elements.popularSearches.style.display = 'none';
2562    elements.topBrands.style.display = 'none';
2563    elements.topCategories.style.display = 'none';
2564    elements.searchSuggestions.style.display = 'none';
2565    elements.searchAllBtn.style.display = 'none';
2566    
2567    // Hide loading bar and searching state
2568    if (elements.loadingBar) {
2569      elements.loadingBar.classList.remove('active');
2570    }
2571    elements.searching.style.display = 'none';
2572    
2573    // Try autocorrect
2574    const correctedQuery = autocorrectQuery(state.currentQuery);
2575    if (correctedQuery && correctedQuery !== state.currentQuery.toLowerCase()) {
2576      showAutocorrectSuggestion(correctedQuery);
2577    }
2578  }
2579
2580  /**
2581   * Navigate to full search results page
2582   */
2583  function searchAll() {
2584    const query = elements.input.value.trim();
2585    
2586    // Track analytics
2587    trackEvent('search_all_clicked', { query });
2588    
2589    // Save to recent searches
2590    if (query.length > 0) {
2591      saveRecentSearch(query);
2592    }
2593    
2594    close();
2595    
2596    // Close mobile menu if open
2597    const navBar = document.querySelector('.nav-bar');
2598    const mobileToggle = document.querySelector('.mobile-toggle');
2599    if (navBar && navBar.classList.contains('nav-open')) {
2600      navBar.classList.remove('nav-open');
2601      if (mobileToggle) {
2602        mobileToggle.classList.remove('active');
2603        const icon = mobileToggle.querySelector('i');
2604        if (icon) {
2605          icon.classList.remove('icon-x');
2606          icon.classList.add('icon-Main-Menu');
2607        }
2608      }
2609    }
2610    
2611    // Check if this is a category search
2612    if (state.currentCategoryId) {
2613      // Navigate to category page with proper URL format
2614      const category = state.categories.find(cat => cat.categoryId === state.currentCategoryId);
2615      if (category) {
2616        // Create URL-friendly slug from category name
2617        const slug = category.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
2618        window.location.href = `/category/${category.categoryId}/${slug}`;
2619      } else {
2620        window.location.href = '/shop';
2621      }
2622      return;
2623    }
2624    
2625    // Clear stored filter state and set new search term
2626    // This ensures the search starts fresh without old filters from context/localStorage
2627    if (typeof context !== 'undefined') {
2628      context.set("storeTerms", { 
2629        brand: { label: "", id: -1 }, 
2630        brands: [],
2631        collections: [],
2632        categories: { label: "", id: -1 }, 
2633        filter: query, 
2634        filters: query.length > 0 ? [query] : [], 
2635        sizes: [], 
2636        widths: [],
2637        prices: [],
2638        colors: [],
2639        genders: [],
2640        instore: 0,
2641        shipable: 0
2642      });
2643    }
2644    
2645    if (query.length > 0) {
2646      window.location.href = `/shop?search=${encodeURIComponent(query)}&sort=relevance%20ASC`;
2647    } else {
2648      window.location.href = '/shop';
2649    }
2650  }
2651
2652  /**
2653   * Load recent searches from localStorage
2654   */
2655  function loadRecentSearches() {
2656    try {
2657      const saved = localStorage.getItem('runfree_recent_searches');
2658      if (saved) {
2659        state.recentSearches = JSON.parse(saved);
2660      }
2661      
2662      // If logged in, also load from context (synced from server)
2663      if (typeof context !== 'undefined' && typeof user !== 'undefined' && user.data && user.data.memberId) {
2664        const contextSearches = context.get('recentSearches');
2665        if (contextSearches && Array.isArray(contextSearches)) {
2666          // Merge: prioritize localStorage (more recent), then fill in from server
2667          const merged = [...state.recentSearches];
2668          contextSearches.forEach(search => {
2669            if (!merged.some(s => s.toLowerCase() === search.toLowerCase())) {
2670              merged.push(search);
2671            }
2672          });
2673          state.recentSearches = merged.slice(0, CONFIG.maxRecentSearches);
2674          // Update localStorage with merged results
2675          localStorage.setItem('runfree_recent_searches', JSON.stringify(state.recentSearches));
2676        }
2677      }
2678    } catch (e) {
2679      console.warn('Failed to load recent searches:', e);
2680    }
2681  }
2682
2683  /**
2684   * Save search to recent searches (deduplicated case-insensitively)
2685   */
2686  function saveRecentSearch(query) {
2687    if (!query || query.length < 2) return;
2688    
2689    try {
2690      // Remove if already exists (case-insensitive dedup)
2691      state.recentSearches = state.recentSearches.filter(s => s.toLowerCase() !== query.toLowerCase());
2692      
2693      // Add to front
2694      state.recentSearches.unshift(query);
2695      
2696      // Keep only last N searches
2697      state.recentSearches = state.recentSearches.slice(0, CONFIG.maxRecentSearches);
2698      
2699      // Save to localStorage (instant)
2700      localStorage.setItem('runfree_recent_searches', JSON.stringify(state.recentSearches));
2701      
2702      // If logged in, also save to context (for cross-device sync)
2703      if (typeof context !== 'undefined' && typeof user !== 'undefined' && user.data && user.data.memberId) {
2704        context.set('recentSearches', state.recentSearches);
2705      }
2706    } catch (e) {
2707      console.warn('Failed to save recent search:', e);
2708    }
2709  }
2710
2711  /**
2712   * Track analytics event
2713   */
2714  function trackEvent(eventName, data = {}) {
2715    try {
2716      // Google Analytics 4
2717      if (typeof gtag !== 'undefined') {
2718        gtag('event', eventName, data);
2719      }
2720      
2721      // Facebook Pixel
2722      if (typeof fbq !== 'undefined' && eventName === 'search_all_clicked') {
2723        fbq('track', 'Search', { search_string: data.query });
2724      }
2725      
2726      // Custom analytics
2727      if (typeof analytics !== 'undefined') {
2728        analytics.track(eventName, data);
2729      }
2730    } catch (e) {
2731      console.warn('Analytics tracking failed:', e);
2732    }
2733  }
2734
2735  /**
2736   * Announce message to screen readers
2737   */
2738  function announceToScreenReader(message) {
2739    const announcement = document.createElement('div');
2740    announcement.setAttribute('role', 'status');
2741    announcement.setAttribute('aria-live', 'polite');
2742    announcement.setAttribute('aria-atomic', 'true');
2743    announcement.className = 'sr-only';
2744    announcement.textContent = message;
2745    
2746    document.body.appendChild(announcement);
2747    
2748    // Remove after announcement
2749    setTimeout(() => {
2750      document.body.removeChild(announcement);
2751    }, 1000);
2752  }
2753
2754  // Public API
2755  return {
2756    init: init,
2757    open: open,
2758    close: close,
2759    toggle: toggle
2760  };
2761})();
2762
2763// Auto-initialize when DOM is ready
2764if (document.readyState === 'loading') {
2765  document.addEventListener('DOMContentLoaded', SearchOverlay.init);
2766} else {
2767  SearchOverlay.init();
2768}

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.