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.