1/*! 2 * Seekmodo Search â typeahead dropdown UI for WordPress. 3 * 4 * Vanilla JS (no jQuery, no React) drop-in that auto-attaches to the 5 * theme's search inputs and renders a Seekmodo-branded results 6 * dropdown beneath each one. Mirrors the Zen Cart connector's 7 * `jscript_seekmodo_typeahead.js` so the cross-platform UX is 8 * consistent for an operator running both connectors on a hybrid 9 * publisher / Woo storefront. 10 * 11 * Surfaces it talks to: 12 * 13 * - GET `/wp-json/seekmodo/v1/typeahead?q=...&max=...&vertical=...` 14 * (registered by `Content/Typeahead.php`; the `commerce` vertical 15 * swaps to product-card row shapes via the 16 * `seekmodo_typeahead_shape_row` filter in `Commerce/Typeahead.php`). 17 * 18 * - The bundled click beacon at `assets/typeahead/seekmodo-beacon.js` 19 * picks up `data-seekmodo-id` / `data-seekmodo-pos` / 20 * `data-seekmodo-surface="typeahead"` attributes that this script 21 * stamps on every dropdown row, so click-attribution flows 22 * through the existing pipeline without a second beacon. 23 * 24 * Boot contract (set by `Frontend/TypeaheadUI.php` via 25 * `wp_localize_script`): 26 * 27 * window.SEEKMODO_TYPEAHEAD = { 28 * endpoint: 'https://example.com/wp-json/seekmodo/v1/typeahead', 29 * vertical: 'content' | 'commerce', 30 * brand: false | true, // show the "Powered by" footer 31 * placeholder: 'Searchâ¦', // forwarded to inputs missing one 32 * min_chars: 2, 33 * max_rows: 8, 34 * debounce_ms: 150, 35 * selectors: ['input[type="search"]', 'input[name="s"]', 'input[data-seekmodo-typeahead]'], 36 * i18n: { 37 * no_results: 'No results', 38 * powered_by: 'Powered by', 39 * brand: 'Seekmodo', 40 * on_sale: 'Sale', 41 * out_of_stock: 'Out of stock', 42 * backorder: 'On backorder', 43 * }, 44 * }; 45 * 46 * Themes with their own typeahead implementation can suppress this 47 * UI either by: 48 * - returning `false` from the PHP `seekmodo_enqueue_typeahead` 49 * filter (preferred; bypasses enqueue entirely), OR 50 * - flipping the per-site "Enable Seekmodo typeahead" toggle off 51 * in wp-admin Settings â Seekmodo, OR 52 * - decorating their custom search input with 53 * `data-seekmodo-typeahead-skip` so this script ignores it. 54 * 55 * Trek.ca's theme integration uses option (B) â its custom 56 * `inc/seekmodo-search.php` keeps the connector's typeahead UI off 57 * site-wide and renders a bespoke header popover instead. POCO.com 58 * relies on option (A) being on with `brand=false` for a white-label 59 * experience. 60 */ 61(function () { 62 'use strict'; 63 64 if (typeof window === 'undefined' || typeof document === 'undefined') { 65 return; 66 } 67 if (typeof window.fetch !== 'function') { 68 // IE11 / very old Safari â bail silently. The theme's existing 69 // search submission still works; only the dropdown is skipped. 70 return; 71 } 72 73 var CFG = window.SEEKMODO_TYPEAHEAD || {}; 74 if (!CFG.endpoint) { 75 return; 76 } 77 78 var DEBOUNCE_MS = (typeof CFG.debounce_ms === 'number' && CFG.debounce_ms > 0) 79 ? CFG.debounce_ms : 150; 80 var MIN_CHARS = (typeof CFG.min_chars === 'number' && CFG.min_chars > 0) 81 ? CFG.min_chars : 2; 82 var MAX_ROWS = (typeof CFG.max_rows === 'number' && CFG.max_rows > 0) 83 ? CFG.max_rows : 8; 84 var VERTICAL = (CFG.vertical === 'commerce') ? 'commerce' : 'content'; 85 var BRAND = !!CFG.brand; 86 var CACHE_SIZE = (typeof CFG.cache_size === 'number' && CFG.cache_size > 0) 87 ? Math.min(128, CFG.cache_size | 0) : 32; 88 // SM-602 v0.5.0 â gateway-direct config (token + URL + tenant). 89 // Present iff the connector minted a browser token at page render. 90 // Absent for unpaired sites, sites whose plan disables the 91 // mint, or anything else that fell open at server-side enqueue 92 // time. Empty / partially-populated objects are treated as absent. 93 var GW = (CFG.gateway && typeof CFG.gateway === 'object' 94 && typeof CFG.gateway.url === 'string' && CFG.gateway.url 95 && typeof CFG.gateway.tenant === 'string' && CFG.gateway.tenant 96 && typeof CFG.gateway.token === 'string' && CFG.gateway.token) 97 ? CFG.gateway : null; 98 // Token-expiry-as-absolute-epoch-seconds. The mint comes back with 99 // ttl=300 and we re-mint server-side when we get within 60s of the 100 // floor; this bound is a defensive floor in the JS for a user who 101 // sat on a tab past the cached transient's TTL. When we trip it 102 // we drop to the WP REST endpoint for the next keystroke instead 103 // of POSTing an expired JWT (which would 401 + fall back anyway, 104 // just with an extra round trip). 105 var GW_EXPIRES_AT = (GW && typeof GW.expires_at === 'number') ? GW.expires_at : 0; 106 107 var I18N = (function () { 108 var d = { 109 no_results: 'No results', 110 powered_by: 'Powered by', 111 brand: 'Seekmodo', 112 on_sale: 'Sale', 113 out_of_stock: 'Out of stock', 114 backorder: 'On backorder' 115 }; 116 if (CFG.i18n && typeof CFG.i18n === 'object') { 117 for (var k in CFG.i18n) { 118 if (Object.prototype.hasOwnProperty.call(CFG.i18n, k) && typeof CFG.i18n[k] === 'string') { 119 d[k] = CFG.i18n[k]; 120 } 121 } 122 } 123 return d; 124 })(); 125 126 var DEFAULT_SELECTORS = [ 127 'input[type="search"]', 128 'input[name="s"]', 129 'input[data-seekmodo-typeahead]' 130 ]; 131 var SELECTORS = (Array.isArray(CFG.selectors) && CFG.selectors.length > 0) 132 ? CFG.selectors 133 : DEFAULT_SELECTORS; 134 135 var ROOT_CLASS = 'seekmodo-typeahead'; 136 var DROPDOWN_CLASS = ROOT_CLASS + '-dropdown'; 137 var ROW_CLASS = ROOT_CLASS + '-row'; 138 139 function ready(fn) { 140 if (document.readyState === 'loading') { 141 document.addEventListener('DOMContentLoaded', fn, { once: true });
142 } else { 143 fn(); 144 } 145 } 146 147 function escapeHtml(s) { 148 return String(s == null ? '' : s) 149 .replace(/&/g, '&') 150 .replace(/</g, '<') 151 .replace(/>/g, '>') 152 .replace(/"/g, '"') 153 .replace(/'/g, '''); 154 } 155 156 function formatPrice(value, currency) { 157 if (value == null || value === '' || isNaN(parseFloat(value))) { 158 return ''; 159 } 160 var num = parseFloat(value); 161 var symbol = ''; 162 var cur = (currency || '').toUpperCase(); 163 if (cur === 'USD' || cur === 'CAD' || cur === 'AUD' || cur === 'NZD') { 164 symbol = '$'; 165 } else if (cur === 'EUR') { 166 symbol = 'â¬'; 167 } else if (cur === 'GBP') { 168 symbol = '£'; 169 } else if (cur === 'JPY') { 170 symbol = 'Â¥'; 171 } 172 // Two-decimal default; JPY is whole-yen. 173 var fixed = (cur === 'JPY') ? num.toFixed(0) : num.toFixed(2); 174 return symbol + fixed + (symbol ? '' : (cur ? (' ' + cur) : '')); 175 } 176 177 function selectInputs() { 178 var seen = new Set(); 179 var out = []; 180 for (var i = 0; i < SELECTORS.length; i++) { 181 var nodes; 182 try { 183 nodes = document.querySelectorAll(SELECTORS[i]); 184 } catch (e) { 185 continue; 186 } 187 for (var j = 0; j < nodes.length; j++) { 188 var el = nodes[j]; 189 if (!el || el.tagName !== 'INPUT') continue; 190 if (el.type === 'hidden') continue; 191 if (el.hasAttribute('data-seekmodo-typeahead-skip')) continue; 192 if (seen.has(el)) continue; 193 seen.add(el); 194 out.push(el); 195 } 196 } 197 return out; 198 } 199 200 function buildDropdown(input) { 201 var dd = document.createElement('div'); 202 dd.className = DROPDOWN_CLASS; 203 dd.setAttribute('role', 'listbox'); 204 dd.setAttribute('aria-label', I18N.brand); 205 dd.hidden = true; 206 document.body.appendChild(dd); 207 208 function position() { 209 var rect = input.getBoundingClientRect(); 210 // Fixed ancestors (modals / sticky headers / overlays): use 211 // viewport coordinates. Document-absolute top = rect + pageYOffset 212 // places the dropdown thousands of pixels off-screen when the 213 // page is scrolled but the input is position:fixed. 214 var node = input; 215 var useFixed = false; 216 while (node && node !== document.documentElement) { 217 var cs = window.getComputedStyle(node); 218 if (cs.position === 'fixed') { 219 useFixed = true; 220 break; 221 } 222 node = node.parentElement; 223 } 224 if (useFixed) { 225 dd.style.position = 'fixed'; 226 dd.style.top = rect.bottom + 'px'; 227 dd.style.left = rect.left + 'px'; 228 } else { 229 dd.style.position = 'absolute'; 230 dd.style.top = (rect.bottom + window.pageYOffset) + 'px'; 231 dd.style.left = (rect.left + window.pageXOffset) + 'px'; 232 } 233 dd.style.width = rect.width + 'px'; 234 } 235 return { el: dd, position: position }; 236 } 237 238 function renderEmpty(dd, q) { 239 dd.el.innerHTML = '<div class="' + ROOT_CLASS + '-empty">' 240 + escapeHtml(I18N.no_results) + ' "<span>' + escapeHtml(q) + '</span>"</div>' 241 + footerHtml(); 242 dd.el.hidden = false; 243 } 244 245 function footerHtml() { 246 if (!BRAND) { 247 return ''; 248 } 249 return '<div class="' + ROOT_CLASS + '-footer">' 250 + '<span class="' + ROOT_CLASS + '-footer-text">' + escapeHtml(I18N.powered_by) + '</span> ' 251 + '<span class="' + ROOT_CLASS + '-footer-brand">' + escapeHtml(I18N.brand) + '</span>' 252 + '</div>'; 253 } 254 255 function renderContentRows(rows) { 256 var html = ''; 257 for (var i = 0; i < rows.length; i++) { 258 var r = rows[i]; 259 // Accept both `title` (canonical content shape per 260 // CONNECTOR_TYPEAHEAD_SPEC.md) and `name` (defensive â 261 // matches commerce naming if a content tenant ever 262 // remaps fields server-side). 263 var title = (r && (r.title || r.name)) ? String(r.title || r.name) : ''; 264 if (!title) continue; 265 var url = r.permalink || r.url || '#'; 266 var thumb = r.thumbnail_url || r.image_url || r.image || ''; 267 var pt = (r.post_type || '').toLowerCase(); 268 var excerpt = r.excerpt || ''; 269 var oid = parseInt(r.id || 0, 10) || 0; 270 html += '<a class="' + ROW_CLASS + ' ' + ROW_CLASS + '--content"' 271 + ' href="' + escapeHtml(url) + '"' 272 + ' data-seekmodo-id="' + oid + '"' 273 + ' data-seekmodo-pos="' + (i + 1) + '"' 274 + ' data-seekmodo-surface="typeahead"' 275 + ' role="option"' 276 + '>'; 277 if (thumb) { 278 html += '<span class="' + ROW_CLASS + '-thumb">' 279 + '<img src="' + escapeHtml(thumb) + '" alt="" loading="lazy" />' 280 + '</span>'; 281 } 282 html += '<span class="' + ROW_CLASS + '-body">' 283 + '<span class="' + ROW_CLASS + '-title">' + escapeHtml(title) + '</span>'; 284 if (pt && pt !== 'post') { 285 html += '<span class="' + ROW_CLASS + '-meta">' + escapeHtml(pt) + '</span>'; 286 } 287 if (excerpt) { 288 html += '<span class="' + ROW_CLASS + '-excerpt">' + escapeHtml(excerpt) + '</span>'; 289 } 290 html += '</span></a>'; 291 } 292 return html; 293 } 294 295 function renderCommerceRows(rows) { 296 var html = ''; 297 for (var i = 0; i < rows.length; i++) { 298 var r = rows[i]; 299 // SM-602 / PM-303: dual-shape support. The canonical 300 // commerce row from `/v1/typeahead` (gateway-direct) per 301 // CONNECTOR_TYPEAHEAD_SPEC.md uses `name` / `url` / 302 // `image` / `in_stock`. The WP REST proxy endpoint 303 // (`/wp-json/seekmodo/v1/typeahead`) currently rewrites 304 // those to `title` / `permalink` / `image_url` / 305 // `stock_status` for legacy reasons â so when v0.5.0+ 306 // started fetching gateway-direct from the browser,
307 // every `r.title` lookup hit `undefined` and the 308 // dropdown rendered an empty "No results" state even 309 // though the wire returned 8 rows. Accept both names 310 // here so either fetcher path renders identically. 311 var title = (r && (r.name || r.title)) ? String(r.name || r.title) : ''; 312 if (!title) continue; 313 var url = r.url || r.permalink || '#'; 314 var image = r.image_url || r.image || r.thumbnail_url || ''; 315 var oid = parseInt(r.id || 0, 10) || 0; 316 var price = formatPrice(r.price, r.currency); 317 var sale = r.on_sale ? formatPrice(r.sale_price, r.currency) : ''; 318 // Normalize stock state across both shapes: 319 // - canonical: in_stock: true | false 320 // - legacy: stock_status: "instock" | "outofstock" | "onbackorder" 321 var stock = r.stock_status; 322 if (!stock && typeof r.in_stock === 'boolean') { 323 stock = r.in_stock ? 'instock' : 'outofstock'; 324 } 325 stock = stock || 'instock'; 326 327 html += '<a class="' + ROW_CLASS + ' ' + ROW_CLASS + '--commerce"' 328 + ' href="' + escapeHtml(url) + '"' 329 + ' data-seekmodo-id="' + oid + '"' 330 + ' data-seekmodo-pos="' + (i + 1) + '"' 331 + ' data-seekmodo-surface="typeahead"' 332 + ' role="option"' 333 + '>'; 334 if (image) { 335 html += '<span class="' + ROW_CLASS + '-thumb">' 336 + '<img src="' + escapeHtml(image) + '" alt="" loading="lazy" />' 337 + '</span>'; 338 } 339 html += '<span class="' + ROW_CLASS + '-body">' 340 + '<span class="' + ROW_CLASS + '-title">' + escapeHtml(title) + '</span>' 341 + '<span class="' + ROW_CLASS + '-price">'; 342 if (sale) { 343 html += '<span class="' + ROW_CLASS + '-price-now">' + escapeHtml(sale) + '</span>' 344 + '<span class="' + ROW_CLASS + '-price-was">' + escapeHtml(price) + '</span>' 345 + '<span class="' + ROW_CLASS + '-badge ' + ROW_CLASS + '-badge--sale">' + escapeHtml(I18N.on_sale) + '</span>'; 346 } else if (price) { 347 html += '<span class="' + ROW_CLASS + '-price-now">' + escapeHtml(price) + '</span>'; 348 } 349 if (stock === 'outofstock') { 350 html += '<span class="' + ROW_CLASS + '-badge ' + ROW_CLASS + '-badge--oos">' + escapeHtml(I18N.out_of_stock) + '</span>'; 351 } else if (stock === 'onbackorder') { 352 html += '<span class="' + ROW_CLASS + '-badge ' + ROW_CLASS + '-badge--bo">' + escapeHtml(I18N.backorder) + '</span>'; 353 } 354 html += '</span></span></a>'; 355 } 356 return html; 357 } 358 359 function render(dd, q, payload) { 360 var rows = (payload && Array.isArray(payload.rows)) ? payload.rows : []; 361 if (rows.length === 0) { 362 renderEmpty(dd, q); 363 return; 364 } 365 var body = (VERTICAL === 'commerce') 366 ? renderCommerceRows(rows) 367 : renderContentRows(rows); 368 if (!body) { 369 renderEmpty(dd, q); 370 return; 371 } 372 dd.el.innerHTML = '<div class="' + ROOT_CLASS + '-rows" role="listbox">' + body + '</div>' + footerHtml(); 373 dd.el.hidden = false; 374 } 375 376 function debounce(fn, wait) { 377 var t = null; 378 return function () { 379 var args = arguments; 380 var self = this; 381 if (t) clearTimeout(t); 382 t = setTimeout(function () { 383 t = null; 384 fn.apply(self, args); 385 }, wait); 386 }; 387 } 388 389 /** 390 * Tiny LRU keyed on the normalized (vertical, max, q) tuple. 391 * Backspace -> retype on an already-fetched prefix renders 392 * instantly off the cache without a network round trip. Sized
393 * by CACHE_SIZE (default 32 entries) â covers a typical session 394 * without bloating the long-lived JS heap. 395 * 396 * Cache values are the parsed `{rows: [...], meta: {...}}` 397 * payloads the gateway / WP REST endpoint returned, so a hit 398 * skips both the network AND re-rendering from raw rows. 399 */ 400 function lruCreate() { 401 var map = new Map(); 402 return { 403 get: function (k) { 404 if (!map.has(k)) return null; 405 var v = map.get(k); 406 map.delete(k); 407 map.set(k, v); 408 return v; 409 }, 410 set: function (k, v) { 411 if (map.has(k)) map.delete(k); 412 map.set(k, v); 413 if (map.size > CACHE_SIZE) { 414 var oldest = map.keys().next().value; 415 map.delete(oldest); 416 } 417 } 418 }; 419 } 420 421 var CACHE = (typeof Map === 'function') ? lruCreate() : null; 422 423 function cacheKey(q) { 424 // Normalize the prefix to lowercase + collapsed whitespace 425 // so "Boa Engine" / "boa engine" / "BOA ENGINE" share the 426 // same cache slot. Server-side TypeaheadTool does the same 427 // normalization before its APCu/Cloudflare lookup, so the 428 // client + server cache scopes align. 429 var n = String(q || '').toLowerCase().replace(/\s+/g, ' ').trim(); 430 return VERTICAL + '|' + MAX_ROWS + '|' + n; 431 } 432 433 /** 434 * Whether to use the gateway-direct fast path on this keystroke. 435 * Returns false when: 436 * - The page didn't render with a token (tenant unpaired, 437 * mint was unavailable server-side). 438 * - The token is within 5s of expiry (let the next page load 439 * pick up a fresh one). 440 */ 441 function canUseGateway() { 442 if (!GW) return false; 443 if (GW_EXPIRES_AT > 0 && (GW_EXPIRES_AT - 5) < Math.floor(Date.now() / 1000)) { 444 // Stale â drop back to WP REST. Don't re-mint here: 445 // minting needs HMAC-grade auth and only works server- 446 // side; the WP REST endpoint will re-mint a token in 447 // the same WP boot it would pay anyway. 448 return false; 449 } 450 return true; 451 } 452 453 /** 454 * Fetch from the gateway directly, skipping WP REST. Resolves 455 * with the parsed payload on a 2xx, or null on any 4xx/5xx / 456 * network error so the caller falls back to WP REST. 457 * 458 * @param {string} q User-typed prefix 459 * @param {AbortSignal=} signal 460 */ 461 function shopperSessionId() { 462 // Prefer first-party sm_sid (ShopperContext) â never reuse 463 // BrowserToken's site-scoped sid (would collapse every 464 // visitor onto one session for LTR linkage). 465 try { 466 var m = document.cookie.match(/(?:^|; )sm_sid=([^;
466]+)/); 467 if (m && m[1]) { 468 var fromCookie = decodeURIComponent(m[1]).replace(/[^A-Za-z0-9_-]/g, ''); 469 if (fromCookie.length >= 8) return fromCookie.slice(0, 64); 470 } 471 } catch (_e) { /* sandboxed document.cookie */ } 472 if (CFG.shopper_session_id) { 473 return String(CFG.shopper_session_id).slice(0, 64); 474 } 475 return ''; 476 } 477 478 function gatewayFetch(q, signal) { 479 var url = GW.url.replace(/\/$/, '') + '/v1/typeahead'; 480 var bodyObj = { 481 q: q, 482 max: MAX_ROWS, 483 // The server-side TypeaheadTool ignores the request 484 // vertical and uses the tenant's vertical (single 485 // source of truth for collection schema), so we send 486 // it as a hint only â a tenant is paired to one 487 // vertical at a time. 488 vertical: VERTICAL 489 }; 490 var sid = shopperSessionId(); 491 if (sid) bodyObj.session_id = sid; 492 // Real navigator UA / document.referrer only â never invent 493 // a synthetic UA. IP is omitted on the browser-direct path 494 // (gateway sees CF-Connecting-IP on the Worker edge). 495 try { 496 if (typeof navigator !== 'undefined' && navigator.userAgent) { 497 bodyObj.ua = String(navigator.userAgent).slice(0, 512); 498 } 499 } catch (_e) { /* ignore */ } 500 try { 501 if (typeof document !== 'undefined' && document.referrer) { 502 bodyObj.referer = String(document.referrer).slice(0, 255); 503 } 504 } catch (_e) { /* ignore */ } 505 var body = JSON.stringify(bodyObj); 506 var headers = { 507 'Content-Type': 'application/json', 508 'Accept': 'application/json', 509 'Authorization': 'Bearer ' + GW.token, 510 'X-Seekmodo-Tenant': GW.tenant 511 }; 512 if (GW.platform) headers['X-Seekmodo-Connector-Platform'] = GW.platform; 513 if (GW.version) headers['X-Seekmodo-Connector-Version'] = GW.version; 514 // Storefront-host header lets the gateway emit per-host 515 // observability the same way HMAC traffic does. Browser 516 // origin == storefront host on a paired site. 517 try { 518 headers['X-Seekmodo-Storefront-Host'] = window.location.host; 519 } catch (_e) { /* in some sandboxes window.location is locked */ } 520 521 return window.fetch(url, { 522 method: 'POST', 523 mode: 'cors', 524 credentials: 'omit', 525 signal: signal, 526 headers: headers, 527 body: body 528 }).then(function (resp) { 529 if (!resp.ok) return null; 530 return resp.json(); 531 }).catch(function () { 532 return null; 533 }); 534 } 535 536 /** 537 * Fall-back WP REST fetch (the v0.4.0 path). Same shape; uses 538 * GET to the WordPress endpoint that proxies through to the 539 * gateway server-side. Slower because every request boots 540 * WordPress, but always correct (works without a browser token, 541 * survives a token expiry, isolated from CORS / breaker / 542 * gateway-anonymous-zone configuration drift). 543 */ 544 function restFetch(q, signal) { 545 var sep = (CFG.endpoint.indexOf('?') >= 0) ? '&' : '?'; 546 var url = CFG.endpoint + sep 547 + 'q=' + encodeURIComponent(q) 548 + '&max=' + MAX_ROWS 549 + '&vertical=' + encodeURIComponent(VERTICAL); 550 return window.fetch(url, { 551 method: 'GET', 552 credentials: 'same-origin', 553 signal: signal, 554 headers: { 'Accept': 'application/json' } 555 }).then(function (resp) { 556 return resp.ok ? resp.json() : null; 557 }).catch(function () { 558 return null; 559 }); 560 } 561 562 function attach(input) { 563 if (!input || input.dataset.seekmodoTypeaheadBound === '1') { 564 return; 565 } 566 input.dataset.seekmodoTypeaheadBound = '1'; 567 input.setAttribute('autocomplete', 'off'); 568 569 var dd = buildDropdown(input); 570 var inflight = null; 571 var lastQ = ''; 572 573 function hide() { 574 dd.el.hidden = true; 575 } 576 function show() { 577 dd.position(); 578 dd.el.hidden = false; 579 } 580 581 function run(q) { 582 // 1. Cache hit -> render synchronously, no network. 583 if (CACHE) { 584 var cached = CACHE.get(cacheKey(q)); 585 if (cached) { 586 dd.position(); 587 render(dd, q, cached); 588 return; 589 } 590 } 591 592 // 2. Otherwise issue a fresh request. Abort any 593 // in-flight one so a fast typist's "boat" -> "boats" 594 // doesn't render stale "boat" rows after "boats" 595 // arrives. 596 if (inflight && typeof inflight.abort === 'function') { 597 inflight.abort(); 598 } 599 var ctrl = (typeof AbortController === 'function') ? new AbortController() : null; 600 inflight = ctrl; 601 var signal = ctrl ? ctrl.signal : undefined; 602 603 // 3. Pick the fastest available fetcher. Gateway-direct 604 // skips WordPress entirely (~1.5s/keystroke saved on 605 // a heavy WP install). Falls through to the WP REST 606 // endpoint on a 401 (token expired between page 607 // render + this keystroke) or any other failure 608 // mode. 609 var primary; 610 var allowFallback; 611 if (canUseGateway()) { 612 primary = gatewayFetch(q, signal); 613 allowFallback = true; 614 } else { 615 primary = restFetch(q, signal); 616 allowFallback = false; 617 } 618 619 primary.then(function (payload) { 620 if (payload) return payload; 621 if (!allowFallback) return null; 622 // Gateway-direct returned non-2xx (most likely 401 623 // because the page-load token expired). Re-issue 624 // through WP REST. We don't bump the abort
625 // controller â same logical request, second-best 626 // path. 627 return restFetch(q, signal); 628 }).then(function (payload) { 629 if (q !== lastQ) { 630 return; 631 } 632 if (!payload) { 633 if (allowFallback && typeof window.seekmodoShowCorsNotice === 'function') { 634 window.seekmodoShowCorsNotice(input); 635 } 636 hide(); 637 return; 638 } 639 if (CACHE) { 640 CACHE.set(cacheKey(q), payload); 641 } 642 dd.position(); 643 render(dd, q, payload); 644 }).catch(function () { 645 hide(); 646 }); 647 } 648 649 var debounced = debounce(function () { 650 var q = (input.value || '').trim(); 651 lastQ = q; 652 if (q.length < MIN_CHARS) { 653 hide(); 654 return; 655 } 656 run(q); 657 }, DEBOUNCE_MS); 658 659 input.addEventListener('input', debounced); 660 input.addEventListener('focus', function () { 661 var q = (input.value || '').trim(); 662 if (q.length >= MIN_CHARS) { 663 debounced(); 664 } 665 }); 666 input.addEventListener('blur', function () { 667 // Defer hide so a click on a row registers before 668 // the dropdown disappears. 669 setTimeout(hide, 150); 670 }); 671 input.addEventListener('keydown', function (e) { 672 if (e.key === 'Escape') { 673 hide(); 674 } 675 }); 676 window.addEventListener('resize', dd.position); 677 window.addEventListener('scroll', dd.position, true); 678 679 // The dropdown element is appended to <body>; clicks on a 680 // row are <a> tags so the browser handles navigation. The 681 // beacon hooked at document level via seekmodo-beacon.js 682 // intercepts on the way out. 683 } 684 685 ready(function () { 686 var inputs = selectInputs(); 687 for (var i = 0; i < inputs.length; i++) { 688 attach(inputs[i]); 689 } 690 // Re-scan on DOM mutations so inputs added by client-side 691 // routers (e.g. headless Gutenberg pages, AJAX cart drawers 692 // that re-render a search input) get attached too. 693 if (typeof MutationObserver === 'function') { 694 var obs = new MutationObserver(function () { 695 var fresh = selectInputs(); 696 for (var i = 0; i < fresh.length; i++) { 697 attach(fresh[i]); 698 } 699 }); 700 obs.observe(document.body, { childList: true, subtree: true }); 701 } 702 }); 703})();
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.