@blotoutio/providers-evo-search-sdk 1.63.3 → 1.64.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,6 +54,36 @@ function HeaderSearch() {
54
54
  }
55
55
  ```
56
56
 
57
+ ## Tracking interactions
58
+
59
+ EvoSearch needs three interaction signals to power the dashboard: searches, result clicks, and cart/purchase events. **In a headless integration, only `trackClick` requires code on your side.**
60
+
61
+ ### Searches — handled server-side
62
+
63
+ Search volume, top queries, and zero-result queries are measured automatically from the search API. No client code needed.
64
+
65
+ ### Result clicks — `trackClick`
66
+
67
+ Call `trackClick(productId)` when a user clicks a result, passing the `id` field from the search result:
68
+
69
+ ```tsx
70
+ {
71
+ results.map((p) => (
72
+ <li key={p.id}>
73
+ <button type='button' onClick={() => trackClick(p.id)}>
74
+ {p.title}
75
+ </button>
76
+ </li>
77
+ ))
78
+ }
79
+ ```
80
+
81
+ ### Add-to-cart / purchase — use your existing EdgeTag tags
82
+
83
+ If you already send EdgeTag `AddToCart` and `Purchase` events via the EdgeTag SDK, you're done — EvoSearch receives them server-side and attributes them to searches automatically.
84
+
85
+ **Important:** The product id in `trackClick` must match the product id in your EdgeTag commerce tags. If your commerce tags use SKUs or variant ids, normalize them to product id on both sides, or search-to-cart attribution will report zero.
86
+
57
87
  For full API reference, see the design spec in the Blotout solutions repo or the docs site.
58
88
 
59
89
  ## Disabling the default UI (headless mode)
package/core.cjs.js CHANGED
@@ -388,7 +388,31 @@ const usStates = new Map([
388
388
  ['US-UM', 'United States Minor Outlying Islands'],
389
389
  ['US-VI', 'Virgin Islands, U.S.'],
390
390
  ]);
391
- new Set([...isoCountries.keys(), ...usStates.keys()]);
391
+ /**
392
+ * ISO 3166-2:CA province and territory codes, keyed `CA-XX` to mirror
393
+ * `usStates`. Used by the consent banner's Canada region to offer province
394
+ * selection (Québec is treated specially by the regional consent config).
395
+ */
396
+ const caProvinces = new Map([
397
+ ['CA-AB', 'Alberta'],
398
+ ['CA-BC', 'British Columbia'],
399
+ ['CA-MB', 'Manitoba'],
400
+ ['CA-NB', 'New Brunswick'],
401
+ ['CA-NL', 'Newfoundland and Labrador'],
402
+ ['CA-NS', 'Nova Scotia'],
403
+ ['CA-NT', 'Northwest Territories'],
404
+ ['CA-NU', 'Nunavut'],
405
+ ['CA-ON', 'Ontario'],
406
+ ['CA-PE', 'Prince Edward Island'],
407
+ ['CA-QC', 'Quebec'],
408
+ ['CA-SK', 'Saskatchewan'],
409
+ ['CA-YT', 'Yukon'],
410
+ ]);
411
+ new Set([
412
+ ...isoCountries.keys(),
413
+ ...usStates.keys(),
414
+ ...caProvinces.keys(),
415
+ ]);
392
416
 
393
417
  /**
394
418
  * Exact utm_source normalization (lowercase key → canonical name).
@@ -638,10 +662,12 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
638
662
  throw new Error('sendTag missing');
639
663
  }
640
664
  const getHeaders = () => {
665
+ var _a;
641
666
  const headers = new Headers({ EdgeTagUserId: userId });
642
667
  if (sessionId) {
643
668
  headers.set('EdgeTagSessionId', sessionId);
644
669
  }
670
+ headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
645
671
  return headers;
646
672
  };
647
673
  // Parsing `baseURL` once at API construction avoids re-parsing the host on
@@ -675,7 +701,9 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
675
701
  if (!response.ok) {
676
702
  const errorText = await response.text().catch(() => '');
677
703
  logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
678
- throw new Error(`${label} failed - ${response.status}`);
704
+ const error = new Error(`${label} failed - ${response.status}`);
705
+ error.status = response.status;
706
+ throw error;
679
707
  }
680
708
  return (await response.json());
681
709
  };
package/core.js CHANGED
@@ -389,7 +389,31 @@ var ProvidersEvoSearchSdk = (function () {
389
389
  ['US-UM', 'United States Minor Outlying Islands'],
390
390
  ['US-VI', 'Virgin Islands, U.S.'],
391
391
  ]);
392
- new Set([...isoCountries.keys(), ...usStates.keys()]);
392
+ /**
393
+ * ISO 3166-2:CA province and territory codes, keyed `CA-XX` to mirror
394
+ * `usStates`. Used by the consent banner's Canada region to offer province
395
+ * selection (Québec is treated specially by the regional consent config).
396
+ */
397
+ const caProvinces = new Map([
398
+ ['CA-AB', 'Alberta'],
399
+ ['CA-BC', 'British Columbia'],
400
+ ['CA-MB', 'Manitoba'],
401
+ ['CA-NB', 'New Brunswick'],
402
+ ['CA-NL', 'Newfoundland and Labrador'],
403
+ ['CA-NS', 'Nova Scotia'],
404
+ ['CA-NT', 'Northwest Territories'],
405
+ ['CA-NU', 'Nunavut'],
406
+ ['CA-ON', 'Ontario'],
407
+ ['CA-PE', 'Prince Edward Island'],
408
+ ['CA-QC', 'Quebec'],
409
+ ['CA-SK', 'Saskatchewan'],
410
+ ['CA-YT', 'Yukon'],
411
+ ]);
412
+ new Set([
413
+ ...isoCountries.keys(),
414
+ ...usStates.keys(),
415
+ ...caProvinces.keys(),
416
+ ]);
393
417
 
394
418
  /**
395
419
  * Exact utm_source normalization (lowercase key → canonical name).
@@ -639,10 +663,12 @@ var ProvidersEvoSearchSdk = (function () {
639
663
  throw new Error('sendTag missing');
640
664
  }
641
665
  const getHeaders = () => {
666
+ var _a;
642
667
  const headers = new Headers({ EdgeTagUserId: userId });
643
668
  if (sessionId) {
644
669
  headers.set('EdgeTagSessionId', sessionId);
645
670
  }
671
+ headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
646
672
  return headers;
647
673
  };
648
674
  // Parsing `baseURL` once at API construction avoids re-parsing the host on
@@ -676,7 +702,9 @@ var ProvidersEvoSearchSdk = (function () {
676
702
  if (!response.ok) {
677
703
  const errorText = await response.text().catch(() => '');
678
704
  logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
679
- throw new Error(`${label} failed - ${response.status}`);
705
+ const error = new Error(`${label} failed - ${response.status}`);
706
+ error.status = response.status;
707
+ throw error;
680
708
  }
681
709
  return (await response.json());
682
710
  };
package/core.mjs CHANGED
@@ -386,7 +386,31 @@ const usStates = new Map([
386
386
  ['US-UM', 'United States Minor Outlying Islands'],
387
387
  ['US-VI', 'Virgin Islands, U.S.'],
388
388
  ]);
389
- new Set([...isoCountries.keys(), ...usStates.keys()]);
389
+ /**
390
+ * ISO 3166-2:CA province and territory codes, keyed `CA-XX` to mirror
391
+ * `usStates`. Used by the consent banner's Canada region to offer province
392
+ * selection (Québec is treated specially by the regional consent config).
393
+ */
394
+ const caProvinces = new Map([
395
+ ['CA-AB', 'Alberta'],
396
+ ['CA-BC', 'British Columbia'],
397
+ ['CA-MB', 'Manitoba'],
398
+ ['CA-NB', 'New Brunswick'],
399
+ ['CA-NL', 'Newfoundland and Labrador'],
400
+ ['CA-NS', 'Nova Scotia'],
401
+ ['CA-NT', 'Northwest Territories'],
402
+ ['CA-NU', 'Nunavut'],
403
+ ['CA-ON', 'Ontario'],
404
+ ['CA-PE', 'Prince Edward Island'],
405
+ ['CA-QC', 'Quebec'],
406
+ ['CA-SK', 'Saskatchewan'],
407
+ ['CA-YT', 'Yukon'],
408
+ ]);
409
+ new Set([
410
+ ...isoCountries.keys(),
411
+ ...usStates.keys(),
412
+ ...caProvinces.keys(),
413
+ ]);
390
414
 
391
415
  /**
392
416
  * Exact utm_source normalization (lowercase key → canonical name).
@@ -636,10 +660,12 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
636
660
  throw new Error('sendTag missing');
637
661
  }
638
662
  const getHeaders = () => {
663
+ var _a;
639
664
  const headers = new Headers({ EdgeTagUserId: userId });
640
665
  if (sessionId) {
641
666
  headers.set('EdgeTagSessionId', sessionId);
642
667
  }
668
+ headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
643
669
  return headers;
644
670
  };
645
671
  // Parsing `baseURL` once at API construction avoids re-parsing the host on
@@ -673,7 +699,9 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
673
699
  if (!response.ok) {
674
700
  const errorText = await response.text().catch(() => '');
675
701
  logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
676
- throw new Error(`${label} failed - ${response.status}`);
702
+ const error = new Error(`${label} failed - ${response.status}`);
703
+ error.status = response.status;
704
+ throw error;
677
705
  }
678
706
  return (await response.json());
679
707
  };
package/hooks.cjs.js CHANGED
@@ -475,7 +475,31 @@ const usStates = new Map([
475
475
  ['US-UM', 'United States Minor Outlying Islands'],
476
476
  ['US-VI', 'Virgin Islands, U.S.'],
477
477
  ]);
478
- new Set([...isoCountries.keys(), ...usStates.keys()]);
478
+ /**
479
+ * ISO 3166-2:CA province and territory codes, keyed `CA-XX` to mirror
480
+ * `usStates`. Used by the consent banner's Canada region to offer province
481
+ * selection (Québec is treated specially by the regional consent config).
482
+ */
483
+ const caProvinces = new Map([
484
+ ['CA-AB', 'Alberta'],
485
+ ['CA-BC', 'British Columbia'],
486
+ ['CA-MB', 'Manitoba'],
487
+ ['CA-NB', 'New Brunswick'],
488
+ ['CA-NL', 'Newfoundland and Labrador'],
489
+ ['CA-NS', 'Nova Scotia'],
490
+ ['CA-NT', 'Northwest Territories'],
491
+ ['CA-NU', 'Nunavut'],
492
+ ['CA-ON', 'Ontario'],
493
+ ['CA-PE', 'Prince Edward Island'],
494
+ ['CA-QC', 'Quebec'],
495
+ ['CA-SK', 'Saskatchewan'],
496
+ ['CA-YT', 'Yukon'],
497
+ ]);
498
+ new Set([
499
+ ...isoCountries.keys(),
500
+ ...usStates.keys(),
501
+ ...caProvinces.keys(),
502
+ ]);
479
503
 
480
504
  /**
481
505
  * Exact utm_source normalization (lowercase key → canonical name).
@@ -608,6 +632,11 @@ const uiActions = new Set([
608
632
  new Set([...uiActions]);
609
633
 
610
634
  const DEBOUNCE_MS = 200;
635
+ // How long a typed query must sit unchanged after its results render before
636
+ // the widget counts it as one real search (recents + server-side query
637
+ // metrics). Search fires per keystroke, so anything shorter than a genuine
638
+ // reading pause would count prefixes (`a`, `ap`, `app`) as searches.
639
+ const SEARCH_SETTLE_MS = 3000;
611
640
 
612
641
  // Intentionally duplicated from @blotoutio/providers/evo-search/config to keep the hooks bundle lean — importing from the config barrel pulls the shared/universal geo data into dist/hooks.mjs (~4KB gzipped). Drift between the two values is caught by utils.test.ts.
613
642
  const isHttpURL = (value) => {
@@ -666,6 +695,87 @@ const processAutocompleteResponse = (response) => {
666
695
  };
667
696
  };
668
697
 
698
+ // Resilience wrapper for /search requests. A transient network failure or a
699
+ // hung request must not wedge the UI: add a per-attempt timeout (a hang becomes
700
+ // a rejection), retry transient failures with backoff, and never retry a
701
+ // caller-superseded (aborted) request. Shared by the widget and the headless
702
+ // hook. See callers: search-widget.ts (performAutocomplete) and
703
+ // hooks/use-evo-search.ts.
704
+ const DEFAULTS = {
705
+ timeoutMs: 7000,
706
+ retries: 2,
707
+ backoffMs: [400, 1200],
708
+ };
709
+ // Deterministic HTTP errors (4xx other than 429) will never succeed on
710
+ // retry — only retry timeouts, network errors, 429, and 5xx. Errors without
711
+ // an attached `status` (network/timeout failures) are always retryable.
712
+ const isRetryable = (error) => {
713
+ const status = error === null || error === void 0 ? void 0 : error.status;
714
+ return status == null || status === 429 || status >= 500;
715
+ };
716
+ const delay = (ms, signal) => new Promise((resolve, reject) => {
717
+ if (signal.aborted)
718
+ return reject(new DOMException('Aborted', 'AbortError'));
719
+ const t = setTimeout(resolve, ms);
720
+ signal.addEventListener('abort', () => {
721
+ clearTimeout(t);
722
+ reject(new DOMException('Aborted', 'AbortError'));
723
+ }, { once: true });
724
+ });
725
+ /**
726
+ * Run `attempt` with a timeout, retrying transient failures. `callerSignal`
727
+ * supersedes everything: if it aborts, we stop immediately without retrying
728
+ * (the query changed / the widget cleaned up). A timeout or network error is
729
+ * retried up to `retries` times; anything left over is rethrown.
730
+ */
731
+ async function withSearchResilience(attempt, callerSignal, options = {}) {
732
+ var _a, _b;
733
+ const { timeoutMs, retries, backoffMs } = { ...DEFAULTS, ...options };
734
+ let lastError;
735
+ for (let i = 0; i <= retries; i++) {
736
+ if (callerSignal.aborted)
737
+ throw new DOMException('Aborted', 'AbortError');
738
+ const local = new AbortController();
739
+ const onCallerAbort = () => local.abort();
740
+ callerSignal.addEventListener('abort', onCallerAbort, { once: true });
741
+ // Race the attempt against an explicit timeout that REJECTS. Aborting the
742
+ // signal alone isn't enough — an attempt that ignores its signal would
743
+ // otherwise hang forever and never retry. Reject with a TimeoutError (not
744
+ // an AbortError) so callers surface it instead of treating it as a
745
+ // caller-cancellation.
746
+ let timer;
747
+ const timeout = new Promise((_, reject) => {
748
+ timer = setTimeout(() => {
749
+ local.abort(); // still cancel the in-flight request to free the socket
750
+ reject(new DOMException('Search request timed out', 'TimeoutError'));
751
+ }, timeoutMs);
752
+ });
753
+ try {
754
+ return await Promise.race([attempt(local.signal), timeout]);
755
+ }
756
+ catch (error) {
757
+ lastError = error;
758
+ // The caller superseded this request — do not retry, propagate the abort.
759
+ if (callerSignal.aborted)
760
+ throw error;
761
+ // Otherwise it was a timeout or a transient network error: retry if budget remains.
762
+ if (i < retries && isRetryable(error)) {
763
+ const wait = (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
764
+ // Rejects with an AbortError if the caller aborts mid-backoff —
765
+ // propagate that cancellation instead of the transient `error` above.
766
+ await delay(wait, callerSignal);
767
+ continue;
768
+ }
769
+ throw error;
770
+ }
771
+ finally {
772
+ clearTimeout(timer);
773
+ callerSignal.removeEventListener('abort', onCallerAbort);
774
+ }
775
+ }
776
+ throw lastError;
777
+ }
778
+
669
779
  const isDevEnv$1 = () => typeof process != 'undefined' && process.env['NODE_ENV'] != 'production';
670
780
  const emptyReturn = (query, setQuery, trackClick) => ({
671
781
  query,
@@ -686,6 +796,9 @@ const useEvoSearch = (options = {}) => {
686
796
  const [suggestions, setSuggestions] = react.useState([]);
687
797
  const [loading, setLoading] = react.useState(false);
688
798
  const [error, setError] = react.useState(null);
799
+ // Bumped when the browser regains connectivity, to re-run the search effect
800
+ // and self-heal a query that failed while offline.
801
+ const [retryNonce, setRetryNonce] = react.useState(0);
689
802
  const ready = (_a = ctx === null || ctx === void 0 ? void 0 : ctx.ready) !== null && _a !== void 0 ? _a : false;
690
803
  const api = (_b = ctx === null || ctx === void 0 ? void 0 : ctx.api) !== null && _b !== void 0 ? _b : null;
691
804
  react.useEffect(() => {
@@ -693,6 +806,25 @@ const useEvoSearch = (options = {}) => {
693
806
  console.error('[EvoSearch] useEvoSearch called outside <EvoSearchProvider>. Wrap your app in <EvoSearchProvider> at the root.');
694
807
  }
695
808
  }, [ctx]);
809
+ // Re-running a successful search on every reconnect would refetch (and
810
+ // re-bill) work that already completed; only self-heal a query that's
811
+ // currently in an error state, mirroring the widget's `hasError` gate.
812
+ const hasErrorRef = react.useRef(false);
813
+ react.useEffect(() => {
814
+ hasErrorRef.current = error != null;
815
+ }, [error]);
816
+ react.useEffect(() => {
817
+ if (typeof window === 'undefined') {
818
+ return;
819
+ }
820
+ const onOnline = () => {
821
+ if (hasErrorRef.current) {
822
+ setRetryNonce((n) => n + 1);
823
+ }
824
+ };
825
+ window.addEventListener('online', onOnline);
826
+ return () => window.removeEventListener('online', onOnline);
827
+ }, []);
696
828
  react.useEffect(() => {
697
829
  if (!ready || !api) {
698
830
  return;
@@ -715,7 +847,7 @@ const useEvoSearch = (options = {}) => {
715
847
  const controller = new AbortController();
716
848
  const timer = setTimeout(async () => {
717
849
  try {
718
- const res = await api.search(query, undefined, controller.signal);
850
+ const res = await withSearchResilience((signal) => api.search(query, undefined, signal), controller.signal);
719
851
  setResults(processAutocompleteResponse(res).products);
720
852
  if (wantsSuggestions) {
721
853
  setSuggestions((prev) => {
@@ -744,7 +876,32 @@ const useEvoSearch = (options = {}) => {
744
876
  clearTimeout(timer);
745
877
  controller.abort();
746
878
  };
747
- }, [query, ready, api, mode]);
879
+ }, [query, ready, api, mode, retryNonce]);
880
+ // Widget parity: report a settled query as one real search after SEARCH_SETTLE_MS.
881
+ const lastFinalizedRef = react.useRef('');
882
+ react.useEffect(() => {
883
+ if (!api || mode === 'autocomplete' || loading || error) {
884
+ return;
885
+ }
886
+ const cleaned = query.trim();
887
+ if (!cleaned) {
888
+ // Clearing the query lets the same term finalize again on re-entry.
889
+ lastFinalizedRef.current = '';
890
+ return;
891
+ }
892
+ if (cleaned.toLowerCase() === lastFinalizedRef.current) {
893
+ return;
894
+ }
895
+ const timer = setTimeout(() => {
896
+ lastFinalizedRef.current = cleaned.toLowerCase();
897
+ const actionData = {
898
+ query: cleaned,
899
+ resultCount: results.length,
900
+ };
901
+ api.sendEvent('evoSearchSearchIssued', undefined, actionData);
902
+ }, SEARCH_SETTLE_MS);
903
+ return () => clearTimeout(timer);
904
+ }, [api, mode, query, results, loading, error]);
748
905
  const trackClick = react.useCallback((productId, extra) => {
749
906
  if (!api) {
750
907
  return;
package/hooks.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx } from 'react/jsx-runtime';
2
- import { createContext, useSyncExternalStore, useEffect, useContext, useState, useCallback, useMemo } from 'react';
2
+ import { createContext, useSyncExternalStore, useEffect, useContext, useState, useRef, useCallback, useMemo } from 'react';
3
3
 
4
4
  const EvoSearchContext = createContext(null);
5
5
 
@@ -473,7 +473,31 @@ const usStates = new Map([
473
473
  ['US-UM', 'United States Minor Outlying Islands'],
474
474
  ['US-VI', 'Virgin Islands, U.S.'],
475
475
  ]);
476
- new Set([...isoCountries.keys(), ...usStates.keys()]);
476
+ /**
477
+ * ISO 3166-2:CA province and territory codes, keyed `CA-XX` to mirror
478
+ * `usStates`. Used by the consent banner's Canada region to offer province
479
+ * selection (Québec is treated specially by the regional consent config).
480
+ */
481
+ const caProvinces = new Map([
482
+ ['CA-AB', 'Alberta'],
483
+ ['CA-BC', 'British Columbia'],
484
+ ['CA-MB', 'Manitoba'],
485
+ ['CA-NB', 'New Brunswick'],
486
+ ['CA-NL', 'Newfoundland and Labrador'],
487
+ ['CA-NS', 'Nova Scotia'],
488
+ ['CA-NT', 'Northwest Territories'],
489
+ ['CA-NU', 'Nunavut'],
490
+ ['CA-ON', 'Ontario'],
491
+ ['CA-PE', 'Prince Edward Island'],
492
+ ['CA-QC', 'Quebec'],
493
+ ['CA-SK', 'Saskatchewan'],
494
+ ['CA-YT', 'Yukon'],
495
+ ]);
496
+ new Set([
497
+ ...isoCountries.keys(),
498
+ ...usStates.keys(),
499
+ ...caProvinces.keys(),
500
+ ]);
477
501
 
478
502
  /**
479
503
  * Exact utm_source normalization (lowercase key → canonical name).
@@ -606,6 +630,11 @@ const uiActions = new Set([
606
630
  new Set([...uiActions]);
607
631
 
608
632
  const DEBOUNCE_MS = 200;
633
+ // How long a typed query must sit unchanged after its results render before
634
+ // the widget counts it as one real search (recents + server-side query
635
+ // metrics). Search fires per keystroke, so anything shorter than a genuine
636
+ // reading pause would count prefixes (`a`, `ap`, `app`) as searches.
637
+ const SEARCH_SETTLE_MS = 3000;
609
638
 
610
639
  // Intentionally duplicated from @blotoutio/providers/evo-search/config to keep the hooks bundle lean — importing from the config barrel pulls the shared/universal geo data into dist/hooks.mjs (~4KB gzipped). Drift between the two values is caught by utils.test.ts.
611
640
  const isHttpURL = (value) => {
@@ -664,6 +693,87 @@ const processAutocompleteResponse = (response) => {
664
693
  };
665
694
  };
666
695
 
696
+ // Resilience wrapper for /search requests. A transient network failure or a
697
+ // hung request must not wedge the UI: add a per-attempt timeout (a hang becomes
698
+ // a rejection), retry transient failures with backoff, and never retry a
699
+ // caller-superseded (aborted) request. Shared by the widget and the headless
700
+ // hook. See callers: search-widget.ts (performAutocomplete) and
701
+ // hooks/use-evo-search.ts.
702
+ const DEFAULTS = {
703
+ timeoutMs: 7000,
704
+ retries: 2,
705
+ backoffMs: [400, 1200],
706
+ };
707
+ // Deterministic HTTP errors (4xx other than 429) will never succeed on
708
+ // retry — only retry timeouts, network errors, 429, and 5xx. Errors without
709
+ // an attached `status` (network/timeout failures) are always retryable.
710
+ const isRetryable = (error) => {
711
+ const status = error === null || error === void 0 ? void 0 : error.status;
712
+ return status == null || status === 429 || status >= 500;
713
+ };
714
+ const delay = (ms, signal) => new Promise((resolve, reject) => {
715
+ if (signal.aborted)
716
+ return reject(new DOMException('Aborted', 'AbortError'));
717
+ const t = setTimeout(resolve, ms);
718
+ signal.addEventListener('abort', () => {
719
+ clearTimeout(t);
720
+ reject(new DOMException('Aborted', 'AbortError'));
721
+ }, { once: true });
722
+ });
723
+ /**
724
+ * Run `attempt` with a timeout, retrying transient failures. `callerSignal`
725
+ * supersedes everything: if it aborts, we stop immediately without retrying
726
+ * (the query changed / the widget cleaned up). A timeout or network error is
727
+ * retried up to `retries` times; anything left over is rethrown.
728
+ */
729
+ async function withSearchResilience(attempt, callerSignal, options = {}) {
730
+ var _a, _b;
731
+ const { timeoutMs, retries, backoffMs } = { ...DEFAULTS, ...options };
732
+ let lastError;
733
+ for (let i = 0; i <= retries; i++) {
734
+ if (callerSignal.aborted)
735
+ throw new DOMException('Aborted', 'AbortError');
736
+ const local = new AbortController();
737
+ const onCallerAbort = () => local.abort();
738
+ callerSignal.addEventListener('abort', onCallerAbort, { once: true });
739
+ // Race the attempt against an explicit timeout that REJECTS. Aborting the
740
+ // signal alone isn't enough — an attempt that ignores its signal would
741
+ // otherwise hang forever and never retry. Reject with a TimeoutError (not
742
+ // an AbortError) so callers surface it instead of treating it as a
743
+ // caller-cancellation.
744
+ let timer;
745
+ const timeout = new Promise((_, reject) => {
746
+ timer = setTimeout(() => {
747
+ local.abort(); // still cancel the in-flight request to free the socket
748
+ reject(new DOMException('Search request timed out', 'TimeoutError'));
749
+ }, timeoutMs);
750
+ });
751
+ try {
752
+ return await Promise.race([attempt(local.signal), timeout]);
753
+ }
754
+ catch (error) {
755
+ lastError = error;
756
+ // The caller superseded this request — do not retry, propagate the abort.
757
+ if (callerSignal.aborted)
758
+ throw error;
759
+ // Otherwise it was a timeout or a transient network error: retry if budget remains.
760
+ if (i < retries && isRetryable(error)) {
761
+ const wait = (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
762
+ // Rejects with an AbortError if the caller aborts mid-backoff —
763
+ // propagate that cancellation instead of the transient `error` above.
764
+ await delay(wait, callerSignal);
765
+ continue;
766
+ }
767
+ throw error;
768
+ }
769
+ finally {
770
+ clearTimeout(timer);
771
+ callerSignal.removeEventListener('abort', onCallerAbort);
772
+ }
773
+ }
774
+ throw lastError;
775
+ }
776
+
667
777
  const isDevEnv$1 = () => typeof process != 'undefined' && process.env['NODE_ENV'] != 'production';
668
778
  const emptyReturn = (query, setQuery, trackClick) => ({
669
779
  query,
@@ -684,6 +794,9 @@ const useEvoSearch = (options = {}) => {
684
794
  const [suggestions, setSuggestions] = useState([]);
685
795
  const [loading, setLoading] = useState(false);
686
796
  const [error, setError] = useState(null);
797
+ // Bumped when the browser regains connectivity, to re-run the search effect
798
+ // and self-heal a query that failed while offline.
799
+ const [retryNonce, setRetryNonce] = useState(0);
687
800
  const ready = (_a = ctx === null || ctx === void 0 ? void 0 : ctx.ready) !== null && _a !== void 0 ? _a : false;
688
801
  const api = (_b = ctx === null || ctx === void 0 ? void 0 : ctx.api) !== null && _b !== void 0 ? _b : null;
689
802
  useEffect(() => {
@@ -691,6 +804,25 @@ const useEvoSearch = (options = {}) => {
691
804
  console.error('[EvoSearch] useEvoSearch called outside <EvoSearchProvider>. Wrap your app in <EvoSearchProvider> at the root.');
692
805
  }
693
806
  }, [ctx]);
807
+ // Re-running a successful search on every reconnect would refetch (and
808
+ // re-bill) work that already completed; only self-heal a query that's
809
+ // currently in an error state, mirroring the widget's `hasError` gate.
810
+ const hasErrorRef = useRef(false);
811
+ useEffect(() => {
812
+ hasErrorRef.current = error != null;
813
+ }, [error]);
814
+ useEffect(() => {
815
+ if (typeof window === 'undefined') {
816
+ return;
817
+ }
818
+ const onOnline = () => {
819
+ if (hasErrorRef.current) {
820
+ setRetryNonce((n) => n + 1);
821
+ }
822
+ };
823
+ window.addEventListener('online', onOnline);
824
+ return () => window.removeEventListener('online', onOnline);
825
+ }, []);
694
826
  useEffect(() => {
695
827
  if (!ready || !api) {
696
828
  return;
@@ -713,7 +845,7 @@ const useEvoSearch = (options = {}) => {
713
845
  const controller = new AbortController();
714
846
  const timer = setTimeout(async () => {
715
847
  try {
716
- const res = await api.search(query, undefined, controller.signal);
848
+ const res = await withSearchResilience((signal) => api.search(query, undefined, signal), controller.signal);
717
849
  setResults(processAutocompleteResponse(res).products);
718
850
  if (wantsSuggestions) {
719
851
  setSuggestions((prev) => {
@@ -742,7 +874,32 @@ const useEvoSearch = (options = {}) => {
742
874
  clearTimeout(timer);
743
875
  controller.abort();
744
876
  };
745
- }, [query, ready, api, mode]);
877
+ }, [query, ready, api, mode, retryNonce]);
878
+ // Widget parity: report a settled query as one real search after SEARCH_SETTLE_MS.
879
+ const lastFinalizedRef = useRef('');
880
+ useEffect(() => {
881
+ if (!api || mode === 'autocomplete' || loading || error) {
882
+ return;
883
+ }
884
+ const cleaned = query.trim();
885
+ if (!cleaned) {
886
+ // Clearing the query lets the same term finalize again on re-entry.
887
+ lastFinalizedRef.current = '';
888
+ return;
889
+ }
890
+ if (cleaned.toLowerCase() === lastFinalizedRef.current) {
891
+ return;
892
+ }
893
+ const timer = setTimeout(() => {
894
+ lastFinalizedRef.current = cleaned.toLowerCase();
895
+ const actionData = {
896
+ query: cleaned,
897
+ resultCount: results.length,
898
+ };
899
+ api.sendEvent('evoSearchSearchIssued', undefined, actionData);
900
+ }, SEARCH_SETTLE_MS);
901
+ return () => clearTimeout(timer);
902
+ }, [api, mode, query, results, loading, error]);
746
903
  const trackClick = useCallback((productId, extra) => {
747
904
  if (!api) {
748
905
  return;
package/index.cjs.js CHANGED
@@ -388,7 +388,31 @@ const usStates = new Map([
388
388
  ['US-UM', 'United States Minor Outlying Islands'],
389
389
  ['US-VI', 'Virgin Islands, U.S.'],
390
390
  ]);
391
- new Set([...isoCountries.keys(), ...usStates.keys()]);
391
+ /**
392
+ * ISO 3166-2:CA province and territory codes, keyed `CA-XX` to mirror
393
+ * `usStates`. Used by the consent banner's Canada region to offer province
394
+ * selection (Québec is treated specially by the regional consent config).
395
+ */
396
+ const caProvinces = new Map([
397
+ ['CA-AB', 'Alberta'],
398
+ ['CA-BC', 'British Columbia'],
399
+ ['CA-MB', 'Manitoba'],
400
+ ['CA-NB', 'New Brunswick'],
401
+ ['CA-NL', 'Newfoundland and Labrador'],
402
+ ['CA-NS', 'Nova Scotia'],
403
+ ['CA-NT', 'Northwest Territories'],
404
+ ['CA-NU', 'Nunavut'],
405
+ ['CA-ON', 'Ontario'],
406
+ ['CA-PE', 'Prince Edward Island'],
407
+ ['CA-QC', 'Quebec'],
408
+ ['CA-SK', 'Saskatchewan'],
409
+ ['CA-YT', 'Yukon'],
410
+ ]);
411
+ new Set([
412
+ ...isoCountries.keys(),
413
+ ...usStates.keys(),
414
+ ...caProvinces.keys(),
415
+ ]);
392
416
 
393
417
  /**
394
418
  * Exact utm_source normalization (lowercase key → canonical name).
@@ -823,10 +847,12 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
823
847
  throw new Error('sendTag missing');
824
848
  }
825
849
  const getHeaders = () => {
850
+ var _a;
826
851
  const headers = new Headers({ EdgeTagUserId: userId });
827
852
  if (sessionId) {
828
853
  headers.set('EdgeTagSessionId', sessionId);
829
854
  }
855
+ headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
830
856
  return headers;
831
857
  };
832
858
  // Parsing `baseURL` once at API construction avoids re-parsing the host on
@@ -860,7 +886,9 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
860
886
  if (!response.ok) {
861
887
  const errorText = await response.text().catch(() => '');
862
888
  logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
863
- throw new Error(`${label} failed - ${response.status}`);
889
+ const error = new Error(`${label} failed - ${response.status}`);
890
+ error.status = response.status;
891
+ throw error;
864
892
  }
865
893
  return (await response.json());
866
894
  };
@@ -8281,6 +8309,87 @@ const searchWidgetStyles = i$6 `
8281
8309
  }
8282
8310
  `;
8283
8311
 
8312
+ // Resilience wrapper for /search requests. A transient network failure or a
8313
+ // hung request must not wedge the UI: add a per-attempt timeout (a hang becomes
8314
+ // a rejection), retry transient failures with backoff, and never retry a
8315
+ // caller-superseded (aborted) request. Shared by the widget and the headless
8316
+ // hook. See callers: search-widget.ts (performAutocomplete) and
8317
+ // hooks/use-evo-search.ts.
8318
+ const DEFAULTS = {
8319
+ timeoutMs: 7000,
8320
+ retries: 2,
8321
+ backoffMs: [400, 1200],
8322
+ };
8323
+ // Deterministic HTTP errors (4xx other than 429) will never succeed on
8324
+ // retry — only retry timeouts, network errors, 429, and 5xx. Errors without
8325
+ // an attached `status` (network/timeout failures) are always retryable.
8326
+ const isRetryable = (error) => {
8327
+ const status = error === null || error === void 0 ? void 0 : error.status;
8328
+ return status == null || status === 429 || status >= 500;
8329
+ };
8330
+ const delay = (ms, signal) => new Promise((resolve, reject) => {
8331
+ if (signal.aborted)
8332
+ return reject(new DOMException('Aborted', 'AbortError'));
8333
+ const t = setTimeout(resolve, ms);
8334
+ signal.addEventListener('abort', () => {
8335
+ clearTimeout(t);
8336
+ reject(new DOMException('Aborted', 'AbortError'));
8337
+ }, { once: true });
8338
+ });
8339
+ /**
8340
+ * Run `attempt` with a timeout, retrying transient failures. `callerSignal`
8341
+ * supersedes everything: if it aborts, we stop immediately without retrying
8342
+ * (the query changed / the widget cleaned up). A timeout or network error is
8343
+ * retried up to `retries` times; anything left over is rethrown.
8344
+ */
8345
+ async function withSearchResilience(attempt, callerSignal, options = {}) {
8346
+ var _a, _b;
8347
+ const { timeoutMs, retries, backoffMs } = { ...DEFAULTS, ...options };
8348
+ let lastError;
8349
+ for (let i = 0; i <= retries; i++) {
8350
+ if (callerSignal.aborted)
8351
+ throw new DOMException('Aborted', 'AbortError');
8352
+ const local = new AbortController();
8353
+ const onCallerAbort = () => local.abort();
8354
+ callerSignal.addEventListener('abort', onCallerAbort, { once: true });
8355
+ // Race the attempt against an explicit timeout that REJECTS. Aborting the
8356
+ // signal alone isn't enough — an attempt that ignores its signal would
8357
+ // otherwise hang forever and never retry. Reject with a TimeoutError (not
8358
+ // an AbortError) so callers surface it instead of treating it as a
8359
+ // caller-cancellation.
8360
+ let timer;
8361
+ const timeout = new Promise((_, reject) => {
8362
+ timer = setTimeout(() => {
8363
+ local.abort(); // still cancel the in-flight request to free the socket
8364
+ reject(new DOMException('Search request timed out', 'TimeoutError'));
8365
+ }, timeoutMs);
8366
+ });
8367
+ try {
8368
+ return await Promise.race([attempt(local.signal), timeout]);
8369
+ }
8370
+ catch (error) {
8371
+ lastError = error;
8372
+ // The caller superseded this request — do not retry, propagate the abort.
8373
+ if (callerSignal.aborted)
8374
+ throw error;
8375
+ // Otherwise it was a timeout or a transient network error: retry if budget remains.
8376
+ if (i < retries && isRetryable(error)) {
8377
+ const wait = (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
8378
+ // Rejects with an AbortError if the caller aborts mid-backoff —
8379
+ // propagate that cancellation instead of the transient `error` above.
8380
+ await delay(wait, callerSignal);
8381
+ continue;
8382
+ }
8383
+ throw error;
8384
+ }
8385
+ finally {
8386
+ clearTimeout(timer);
8387
+ callerSignal.removeEventListener('abort', onCallerAbort);
8388
+ }
8389
+ }
8390
+ throw lastError;
8391
+ }
8392
+
8284
8393
  const MIN_QUERY_LENGTH = 3;
8285
8394
  // @state fields whose change should be persisted to the session snapshot.
8286
8395
  const SESSION_PERSIST_KEYS = [
@@ -8371,6 +8480,7 @@ class SearchWidget extends ElementWithStylesheet {
8371
8480
  // autocomplete can't overwrite a fresh search's results.
8372
8481
  this.requestGeneration = 0;
8373
8482
  this.keydownListener = (event) => this.handleKeydown(event);
8483
+ this.onlineListener = () => this.handleReconnect();
8374
8484
  // Streaming tokens are coalesced into one per-frame state update rather than
8375
8485
  // reassigning `askAnswer` on every token — a ~220-token answer would
8376
8486
  // otherwise trigger ~220 full widget renders.
@@ -8415,6 +8525,20 @@ class SearchWidget extends ElementWithStylesheet {
8415
8525
  var _a, _b;
8416
8526
  (_b = (_a = this.api) === null || _a === void 0 ? void 0 : _a.warm) === null || _b === void 0 ? void 0 : _b.call(_a);
8417
8527
  }
8528
+ // A transient network failure leaves the results in an error state; when
8529
+ // connectivity returns, re-issue the current query so it self-heals without
8530
+ // the shopper having to retype.
8531
+ handleReconnect() {
8532
+ if (!this.hasError) {
8533
+ return;
8534
+ }
8535
+ const trimmed = this.searchQuery.trim();
8536
+ if (trimmed.length < MIN_QUERY_LENGTH) {
8537
+ return;
8538
+ }
8539
+ this.loading = true;
8540
+ void this.performAutocomplete(trimmed);
8541
+ }
8418
8542
  connectedCallback() {
8419
8543
  super.connectedCallback();
8420
8544
  // The widget always opens in Search view; Ask AI is entered only by
@@ -8431,6 +8555,7 @@ class SearchWidget extends ElementWithStylesheet {
8431
8555
  this.mobileMediaQuery = window.matchMedia('(max-width: 768px)');
8432
8556
  this.isMobile = this.mobileMediaQuery.matches;
8433
8557
  this.mobileMediaQuery.addEventListener('change', this.mobileMediaListener);
8558
+ window.addEventListener('online', this.onlineListener);
8434
8559
  }
8435
8560
  firstUpdated() {
8436
8561
  this.focusSearchBox();
@@ -8446,6 +8571,7 @@ class SearchWidget extends ElementWithStylesheet {
8446
8571
  this.resetAnswerBuffer();
8447
8572
  this.removeEventListener('keydown', this.keydownListener);
8448
8573
  (_c = this.mobileMediaQuery) === null || _c === void 0 ? void 0 : _c.removeEventListener('change', this.mobileMediaListener);
8574
+ window.removeEventListener('online', this.onlineListener);
8449
8575
  super.disconnectedCallback();
8450
8576
  }
8451
8577
  willUpdate(changed) {
@@ -8864,7 +8990,8 @@ class SearchWidget extends ElementWithStylesheet {
8864
8990
  this.loading = false;
8865
8991
  return;
8866
8992
  }
8867
- const response = await this.api.search(query, undefined, this.newResultsSignal());
8993
+ const api = this.api;
8994
+ const response = await withSearchResilience((signal) => api.search(query, undefined, signal), this.newResultsSignal());
8868
8995
  if (generation !== this.requestGeneration) {
8869
8996
  return;
8870
8997
  }
package/index.js CHANGED
@@ -389,7 +389,31 @@ var ProvidersEvoSearchSdk = (function () {
389
389
  ['US-UM', 'United States Minor Outlying Islands'],
390
390
  ['US-VI', 'Virgin Islands, U.S.'],
391
391
  ]);
392
- new Set([...isoCountries.keys(), ...usStates.keys()]);
392
+ /**
393
+ * ISO 3166-2:CA province and territory codes, keyed `CA-XX` to mirror
394
+ * `usStates`. Used by the consent banner's Canada region to offer province
395
+ * selection (Québec is treated specially by the regional consent config).
396
+ */
397
+ const caProvinces = new Map([
398
+ ['CA-AB', 'Alberta'],
399
+ ['CA-BC', 'British Columbia'],
400
+ ['CA-MB', 'Manitoba'],
401
+ ['CA-NB', 'New Brunswick'],
402
+ ['CA-NL', 'Newfoundland and Labrador'],
403
+ ['CA-NS', 'Nova Scotia'],
404
+ ['CA-NT', 'Northwest Territories'],
405
+ ['CA-NU', 'Nunavut'],
406
+ ['CA-ON', 'Ontario'],
407
+ ['CA-PE', 'Prince Edward Island'],
408
+ ['CA-QC', 'Quebec'],
409
+ ['CA-SK', 'Saskatchewan'],
410
+ ['CA-YT', 'Yukon'],
411
+ ]);
412
+ new Set([
413
+ ...isoCountries.keys(),
414
+ ...usStates.keys(),
415
+ ...caProvinces.keys(),
416
+ ]);
393
417
 
394
418
  /**
395
419
  * Exact utm_source normalization (lowercase key → canonical name).
@@ -824,10 +848,12 @@ var ProvidersEvoSearchSdk = (function () {
824
848
  throw new Error('sendTag missing');
825
849
  }
826
850
  const getHeaders = () => {
851
+ var _a;
827
852
  const headers = new Headers({ EdgeTagUserId: userId });
828
853
  if (sessionId) {
829
854
  headers.set('EdgeTagSessionId', sessionId);
830
855
  }
856
+ headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
831
857
  return headers;
832
858
  };
833
859
  // Parsing `baseURL` once at API construction avoids re-parsing the host on
@@ -861,7 +887,9 @@ var ProvidersEvoSearchSdk = (function () {
861
887
  if (!response.ok) {
862
888
  const errorText = await response.text().catch(() => '');
863
889
  logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
864
- throw new Error(`${label} failed - ${response.status}`);
890
+ const error = new Error(`${label} failed - ${response.status}`);
891
+ error.status = response.status;
892
+ throw error;
865
893
  }
866
894
  return (await response.json());
867
895
  };
@@ -8282,6 +8310,87 @@ var ProvidersEvoSearchSdk = (function () {
8282
8310
  }
8283
8311
  `;
8284
8312
 
8313
+ // Resilience wrapper for /search requests. A transient network failure or a
8314
+ // hung request must not wedge the UI: add a per-attempt timeout (a hang becomes
8315
+ // a rejection), retry transient failures with backoff, and never retry a
8316
+ // caller-superseded (aborted) request. Shared by the widget and the headless
8317
+ // hook. See callers: search-widget.ts (performAutocomplete) and
8318
+ // hooks/use-evo-search.ts.
8319
+ const DEFAULTS = {
8320
+ timeoutMs: 7000,
8321
+ retries: 2,
8322
+ backoffMs: [400, 1200],
8323
+ };
8324
+ // Deterministic HTTP errors (4xx other than 429) will never succeed on
8325
+ // retry — only retry timeouts, network errors, 429, and 5xx. Errors without
8326
+ // an attached `status` (network/timeout failures) are always retryable.
8327
+ const isRetryable = (error) => {
8328
+ const status = error === null || error === void 0 ? void 0 : error.status;
8329
+ return status == null || status === 429 || status >= 500;
8330
+ };
8331
+ const delay = (ms, signal) => new Promise((resolve, reject) => {
8332
+ if (signal.aborted)
8333
+ return reject(new DOMException('Aborted', 'AbortError'));
8334
+ const t = setTimeout(resolve, ms);
8335
+ signal.addEventListener('abort', () => {
8336
+ clearTimeout(t);
8337
+ reject(new DOMException('Aborted', 'AbortError'));
8338
+ }, { once: true });
8339
+ });
8340
+ /**
8341
+ * Run `attempt` with a timeout, retrying transient failures. `callerSignal`
8342
+ * supersedes everything: if it aborts, we stop immediately without retrying
8343
+ * (the query changed / the widget cleaned up). A timeout or network error is
8344
+ * retried up to `retries` times; anything left over is rethrown.
8345
+ */
8346
+ async function withSearchResilience(attempt, callerSignal, options = {}) {
8347
+ var _a, _b;
8348
+ const { timeoutMs, retries, backoffMs } = { ...DEFAULTS, ...options };
8349
+ let lastError;
8350
+ for (let i = 0; i <= retries; i++) {
8351
+ if (callerSignal.aborted)
8352
+ throw new DOMException('Aborted', 'AbortError');
8353
+ const local = new AbortController();
8354
+ const onCallerAbort = () => local.abort();
8355
+ callerSignal.addEventListener('abort', onCallerAbort, { once: true });
8356
+ // Race the attempt against an explicit timeout that REJECTS. Aborting the
8357
+ // signal alone isn't enough — an attempt that ignores its signal would
8358
+ // otherwise hang forever and never retry. Reject with a TimeoutError (not
8359
+ // an AbortError) so callers surface it instead of treating it as a
8360
+ // caller-cancellation.
8361
+ let timer;
8362
+ const timeout = new Promise((_, reject) => {
8363
+ timer = setTimeout(() => {
8364
+ local.abort(); // still cancel the in-flight request to free the socket
8365
+ reject(new DOMException('Search request timed out', 'TimeoutError'));
8366
+ }, timeoutMs);
8367
+ });
8368
+ try {
8369
+ return await Promise.race([attempt(local.signal), timeout]);
8370
+ }
8371
+ catch (error) {
8372
+ lastError = error;
8373
+ // The caller superseded this request — do not retry, propagate the abort.
8374
+ if (callerSignal.aborted)
8375
+ throw error;
8376
+ // Otherwise it was a timeout or a transient network error: retry if budget remains.
8377
+ if (i < retries && isRetryable(error)) {
8378
+ const wait = (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
8379
+ // Rejects with an AbortError if the caller aborts mid-backoff —
8380
+ // propagate that cancellation instead of the transient `error` above.
8381
+ await delay(wait, callerSignal);
8382
+ continue;
8383
+ }
8384
+ throw error;
8385
+ }
8386
+ finally {
8387
+ clearTimeout(timer);
8388
+ callerSignal.removeEventListener('abort', onCallerAbort);
8389
+ }
8390
+ }
8391
+ throw lastError;
8392
+ }
8393
+
8285
8394
  const MIN_QUERY_LENGTH = 3;
8286
8395
  // @state fields whose change should be persisted to the session snapshot.
8287
8396
  const SESSION_PERSIST_KEYS = [
@@ -8372,6 +8481,7 @@ var ProvidersEvoSearchSdk = (function () {
8372
8481
  // autocomplete can't overwrite a fresh search's results.
8373
8482
  this.requestGeneration = 0;
8374
8483
  this.keydownListener = (event) => this.handleKeydown(event);
8484
+ this.onlineListener = () => this.handleReconnect();
8375
8485
  // Streaming tokens are coalesced into one per-frame state update rather than
8376
8486
  // reassigning `askAnswer` on every token — a ~220-token answer would
8377
8487
  // otherwise trigger ~220 full widget renders.
@@ -8416,6 +8526,20 @@ var ProvidersEvoSearchSdk = (function () {
8416
8526
  var _a, _b;
8417
8527
  (_b = (_a = this.api) === null || _a === void 0 ? void 0 : _a.warm) === null || _b === void 0 ? void 0 : _b.call(_a);
8418
8528
  }
8529
+ // A transient network failure leaves the results in an error state; when
8530
+ // connectivity returns, re-issue the current query so it self-heals without
8531
+ // the shopper having to retype.
8532
+ handleReconnect() {
8533
+ if (!this.hasError) {
8534
+ return;
8535
+ }
8536
+ const trimmed = this.searchQuery.trim();
8537
+ if (trimmed.length < MIN_QUERY_LENGTH) {
8538
+ return;
8539
+ }
8540
+ this.loading = true;
8541
+ void this.performAutocomplete(trimmed);
8542
+ }
8419
8543
  connectedCallback() {
8420
8544
  super.connectedCallback();
8421
8545
  // The widget always opens in Search view; Ask AI is entered only by
@@ -8432,6 +8556,7 @@ var ProvidersEvoSearchSdk = (function () {
8432
8556
  this.mobileMediaQuery = window.matchMedia('(max-width: 768px)');
8433
8557
  this.isMobile = this.mobileMediaQuery.matches;
8434
8558
  this.mobileMediaQuery.addEventListener('change', this.mobileMediaListener);
8559
+ window.addEventListener('online', this.onlineListener);
8435
8560
  }
8436
8561
  firstUpdated() {
8437
8562
  this.focusSearchBox();
@@ -8447,6 +8572,7 @@ var ProvidersEvoSearchSdk = (function () {
8447
8572
  this.resetAnswerBuffer();
8448
8573
  this.removeEventListener('keydown', this.keydownListener);
8449
8574
  (_c = this.mobileMediaQuery) === null || _c === void 0 ? void 0 : _c.removeEventListener('change', this.mobileMediaListener);
8575
+ window.removeEventListener('online', this.onlineListener);
8450
8576
  super.disconnectedCallback();
8451
8577
  }
8452
8578
  willUpdate(changed) {
@@ -8865,7 +8991,8 @@ var ProvidersEvoSearchSdk = (function () {
8865
8991
  this.loading = false;
8866
8992
  return;
8867
8993
  }
8868
- const response = await this.api.search(query, undefined, this.newResultsSignal());
8994
+ const api = this.api;
8995
+ const response = await withSearchResilience((signal) => api.search(query, undefined, signal), this.newResultsSignal());
8869
8996
  if (generation !== this.requestGeneration) {
8870
8997
  return;
8871
8998
  }
package/index.mjs CHANGED
@@ -386,7 +386,31 @@ const usStates = new Map([
386
386
  ['US-UM', 'United States Minor Outlying Islands'],
387
387
  ['US-VI', 'Virgin Islands, U.S.'],
388
388
  ]);
389
- new Set([...isoCountries.keys(), ...usStates.keys()]);
389
+ /**
390
+ * ISO 3166-2:CA province and territory codes, keyed `CA-XX` to mirror
391
+ * `usStates`. Used by the consent banner's Canada region to offer province
392
+ * selection (Québec is treated specially by the regional consent config).
393
+ */
394
+ const caProvinces = new Map([
395
+ ['CA-AB', 'Alberta'],
396
+ ['CA-BC', 'British Columbia'],
397
+ ['CA-MB', 'Manitoba'],
398
+ ['CA-NB', 'New Brunswick'],
399
+ ['CA-NL', 'Newfoundland and Labrador'],
400
+ ['CA-NS', 'Nova Scotia'],
401
+ ['CA-NT', 'Northwest Territories'],
402
+ ['CA-NU', 'Nunavut'],
403
+ ['CA-ON', 'Ontario'],
404
+ ['CA-PE', 'Prince Edward Island'],
405
+ ['CA-QC', 'Quebec'],
406
+ ['CA-SK', 'Saskatchewan'],
407
+ ['CA-YT', 'Yukon'],
408
+ ]);
409
+ new Set([
410
+ ...isoCountries.keys(),
411
+ ...usStates.keys(),
412
+ ...caProvinces.keys(),
413
+ ]);
390
414
 
391
415
  /**
392
416
  * Exact utm_source normalization (lowercase key → canonical name).
@@ -821,10 +845,12 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
821
845
  throw new Error('sendTag missing');
822
846
  }
823
847
  const getHeaders = () => {
848
+ var _a;
824
849
  const headers = new Headers({ EdgeTagUserId: userId });
825
850
  if (sessionId) {
826
851
  headers.set('EdgeTagSessionId', sessionId);
827
852
  }
853
+ headers.set('X-EvoSearch-Channel', ((_a = window[registryKey]) === null || _a === void 0 ? void 0 : _a.ui) ? 'widget' : 'headless');
828
854
  return headers;
829
855
  };
830
856
  // Parsing `baseURL` once at API construction avoids re-parsing the host on
@@ -858,7 +884,9 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
858
884
  if (!response.ok) {
859
885
  const errorText = await response.text().catch(() => '');
860
886
  logger.error(`EvoSearch API: ${label} failed - ${response.status}: ${errorText}`);
861
- throw new Error(`${label} failed - ${response.status}`);
887
+ const error = new Error(`${label} failed - ${response.status}`);
888
+ error.status = response.status;
889
+ throw error;
862
890
  }
863
891
  return (await response.json());
864
892
  };
@@ -8279,6 +8307,87 @@ const searchWidgetStyles = i$6 `
8279
8307
  }
8280
8308
  `;
8281
8309
 
8310
+ // Resilience wrapper for /search requests. A transient network failure or a
8311
+ // hung request must not wedge the UI: add a per-attempt timeout (a hang becomes
8312
+ // a rejection), retry transient failures with backoff, and never retry a
8313
+ // caller-superseded (aborted) request. Shared by the widget and the headless
8314
+ // hook. See callers: search-widget.ts (performAutocomplete) and
8315
+ // hooks/use-evo-search.ts.
8316
+ const DEFAULTS = {
8317
+ timeoutMs: 7000,
8318
+ retries: 2,
8319
+ backoffMs: [400, 1200],
8320
+ };
8321
+ // Deterministic HTTP errors (4xx other than 429) will never succeed on
8322
+ // retry — only retry timeouts, network errors, 429, and 5xx. Errors without
8323
+ // an attached `status` (network/timeout failures) are always retryable.
8324
+ const isRetryable = (error) => {
8325
+ const status = error === null || error === void 0 ? void 0 : error.status;
8326
+ return status == null || status === 429 || status >= 500;
8327
+ };
8328
+ const delay = (ms, signal) => new Promise((resolve, reject) => {
8329
+ if (signal.aborted)
8330
+ return reject(new DOMException('Aborted', 'AbortError'));
8331
+ const t = setTimeout(resolve, ms);
8332
+ signal.addEventListener('abort', () => {
8333
+ clearTimeout(t);
8334
+ reject(new DOMException('Aborted', 'AbortError'));
8335
+ }, { once: true });
8336
+ });
8337
+ /**
8338
+ * Run `attempt` with a timeout, retrying transient failures. `callerSignal`
8339
+ * supersedes everything: if it aborts, we stop immediately without retrying
8340
+ * (the query changed / the widget cleaned up). A timeout or network error is
8341
+ * retried up to `retries` times; anything left over is rethrown.
8342
+ */
8343
+ async function withSearchResilience(attempt, callerSignal, options = {}) {
8344
+ var _a, _b;
8345
+ const { timeoutMs, retries, backoffMs } = { ...DEFAULTS, ...options };
8346
+ let lastError;
8347
+ for (let i = 0; i <= retries; i++) {
8348
+ if (callerSignal.aborted)
8349
+ throw new DOMException('Aborted', 'AbortError');
8350
+ const local = new AbortController();
8351
+ const onCallerAbort = () => local.abort();
8352
+ callerSignal.addEventListener('abort', onCallerAbort, { once: true });
8353
+ // Race the attempt against an explicit timeout that REJECTS. Aborting the
8354
+ // signal alone isn't enough — an attempt that ignores its signal would
8355
+ // otherwise hang forever and never retry. Reject with a TimeoutError (not
8356
+ // an AbortError) so callers surface it instead of treating it as a
8357
+ // caller-cancellation.
8358
+ let timer;
8359
+ const timeout = new Promise((_, reject) => {
8360
+ timer = setTimeout(() => {
8361
+ local.abort(); // still cancel the in-flight request to free the socket
8362
+ reject(new DOMException('Search request timed out', 'TimeoutError'));
8363
+ }, timeoutMs);
8364
+ });
8365
+ try {
8366
+ return await Promise.race([attempt(local.signal), timeout]);
8367
+ }
8368
+ catch (error) {
8369
+ lastError = error;
8370
+ // The caller superseded this request — do not retry, propagate the abort.
8371
+ if (callerSignal.aborted)
8372
+ throw error;
8373
+ // Otherwise it was a timeout or a transient network error: retry if budget remains.
8374
+ if (i < retries && isRetryable(error)) {
8375
+ const wait = (_b = (_a = backoffMs[i]) !== null && _a !== void 0 ? _a : backoffMs[backoffMs.length - 1]) !== null && _b !== void 0 ? _b : 0;
8376
+ // Rejects with an AbortError if the caller aborts mid-backoff —
8377
+ // propagate that cancellation instead of the transient `error` above.
8378
+ await delay(wait, callerSignal);
8379
+ continue;
8380
+ }
8381
+ throw error;
8382
+ }
8383
+ finally {
8384
+ clearTimeout(timer);
8385
+ callerSignal.removeEventListener('abort', onCallerAbort);
8386
+ }
8387
+ }
8388
+ throw lastError;
8389
+ }
8390
+
8282
8391
  const MIN_QUERY_LENGTH = 3;
8283
8392
  // @state fields whose change should be persisted to the session snapshot.
8284
8393
  const SESSION_PERSIST_KEYS = [
@@ -8369,6 +8478,7 @@ class SearchWidget extends ElementWithStylesheet {
8369
8478
  // autocomplete can't overwrite a fresh search's results.
8370
8479
  this.requestGeneration = 0;
8371
8480
  this.keydownListener = (event) => this.handleKeydown(event);
8481
+ this.onlineListener = () => this.handleReconnect();
8372
8482
  // Streaming tokens are coalesced into one per-frame state update rather than
8373
8483
  // reassigning `askAnswer` on every token — a ~220-token answer would
8374
8484
  // otherwise trigger ~220 full widget renders.
@@ -8413,6 +8523,20 @@ class SearchWidget extends ElementWithStylesheet {
8413
8523
  var _a, _b;
8414
8524
  (_b = (_a = this.api) === null || _a === void 0 ? void 0 : _a.warm) === null || _b === void 0 ? void 0 : _b.call(_a);
8415
8525
  }
8526
+ // A transient network failure leaves the results in an error state; when
8527
+ // connectivity returns, re-issue the current query so it self-heals without
8528
+ // the shopper having to retype.
8529
+ handleReconnect() {
8530
+ if (!this.hasError) {
8531
+ return;
8532
+ }
8533
+ const trimmed = this.searchQuery.trim();
8534
+ if (trimmed.length < MIN_QUERY_LENGTH) {
8535
+ return;
8536
+ }
8537
+ this.loading = true;
8538
+ void this.performAutocomplete(trimmed);
8539
+ }
8416
8540
  connectedCallback() {
8417
8541
  super.connectedCallback();
8418
8542
  // The widget always opens in Search view; Ask AI is entered only by
@@ -8429,6 +8553,7 @@ class SearchWidget extends ElementWithStylesheet {
8429
8553
  this.mobileMediaQuery = window.matchMedia('(max-width: 768px)');
8430
8554
  this.isMobile = this.mobileMediaQuery.matches;
8431
8555
  this.mobileMediaQuery.addEventListener('change', this.mobileMediaListener);
8556
+ window.addEventListener('online', this.onlineListener);
8432
8557
  }
8433
8558
  firstUpdated() {
8434
8559
  this.focusSearchBox();
@@ -8444,6 +8569,7 @@ class SearchWidget extends ElementWithStylesheet {
8444
8569
  this.resetAnswerBuffer();
8445
8570
  this.removeEventListener('keydown', this.keydownListener);
8446
8571
  (_c = this.mobileMediaQuery) === null || _c === void 0 ? void 0 : _c.removeEventListener('change', this.mobileMediaListener);
8572
+ window.removeEventListener('online', this.onlineListener);
8447
8573
  super.disconnectedCallback();
8448
8574
  }
8449
8575
  willUpdate(changed) {
@@ -8862,7 +8988,8 @@ class SearchWidget extends ElementWithStylesheet {
8862
8988
  this.loading = false;
8863
8989
  return;
8864
8990
  }
8865
- const response = await this.api.search(query, undefined, this.newResultsSignal());
8991
+ const api = this.api;
8992
+ const response = await withSearchResilience((signal) => api.search(query, undefined, signal), this.newResultsSignal());
8866
8993
  if (generation !== this.requestGeneration) {
8867
8994
  return;
8868
8995
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blotoutio/providers-evo-search-sdk",
3
- "version": "1.63.3",
3
+ "version": "1.64.2",
4
4
  "description": "Evo Search SDK for EdgeTag",
5
5
  "author": "Blotout",
6
6
  "license": "MIT",