PageSourceSearch

https://www.numinix.com/wordpress/wp-content/plugins/seekmodo/asse…/seekmodo-typeahead.js?ver=0.11.64

js numinix.com collected 2026-10-02 22:13:54 UTC 28,993 bytes, 703 lines download raw bytes

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, '&lt;')
151            .replace(/>/g, '&gt;')
152            .replace(/"/g, '&quot;')
153            .replace(/'/g, '&#39;');
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.