@blotoutio/providers-evo-search-sdk 1.70.2 → 1.71.1
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 +19 -0
- package/core.cjs.js +625 -9
- package/core.js +625 -9
- package/core.mjs +625 -9
- package/hooks.cjs.js +352 -5
- package/hooks.d.ts +31 -1
- package/hooks.mjs +351 -6
- package/index.cjs.js +15703 -8532
- package/index.js +12214 -5043
- package/index.mjs +15703 -8532
- package/package.json +1 -1
package/core.cjs.js
CHANGED
|
@@ -817,6 +817,16 @@ const uiActions = new Set([
|
|
|
817
817
|
'evoSearchProductAddedToCart',
|
|
818
818
|
'evoSearchProductPurchased',
|
|
819
819
|
'evoSearchSearchIssued',
|
|
820
|
+
// Relevance-telemetry pair (PR2 task 3): one impressions beacon per
|
|
821
|
+
// finalized query, one click beacon per result click. Server handling
|
|
822
|
+
// (Task 4) wired in routes/user.ts.
|
|
823
|
+
'evoSearchImpressions',
|
|
824
|
+
'evoSearchResultClick',
|
|
825
|
+
// One beacon per Ask answer the assistant could not produce (generation
|
|
826
|
+
// failure, budget exhaustion) — the client-side counterpart to the worker's
|
|
827
|
+
// own degrade counters, and the only signal for a failure the worker never
|
|
828
|
+
// sees (the request never reaching it).
|
|
829
|
+
'evoSearchAskDegraded',
|
|
820
830
|
]);
|
|
821
831
|
new Set([...uiActions]);
|
|
822
832
|
|
|
@@ -854,6 +864,172 @@ const logger = {
|
|
|
854
864
|
},
|
|
855
865
|
};
|
|
856
866
|
|
|
867
|
+
// Pure, DOM-free session-context logic: what the shopper's tab has seen this
|
|
868
|
+
// visit, kept small enough to ride along on every search request as a `ctx`
|
|
869
|
+
// query param and every finalized-query beacon. No PII — ids, capped query
|
|
870
|
+
// strings, and an enum page type only. Storage wiring (sessionStorage
|
|
871
|
+
// read/write, entry page detection from location.pathname) lives in
|
|
872
|
+
// utils.ts; this module only knows how to grow and shrink the shape
|
|
873
|
+
// immutably, and how to encode it for the wire.
|
|
874
|
+
// Single source of truth for the enum — utils.ts's sessionStorage parse
|
|
875
|
+
// validates membership against this (not just `typeof === 'string'`) before
|
|
876
|
+
// trusting a stored value, so a hostile/corrupted entry can't smuggle an
|
|
877
|
+
// arbitrary string into the wire payload.
|
|
878
|
+
const PAGE_TYPES = [
|
|
879
|
+
'home',
|
|
880
|
+
'plp',
|
|
881
|
+
'pdp',
|
|
882
|
+
'cart',
|
|
883
|
+
'search',
|
|
884
|
+
'other',
|
|
885
|
+
];
|
|
886
|
+
const isPageType = (value) => typeof value === 'string' && PAGE_TYPES.includes(value);
|
|
887
|
+
const MAX_VIEWED = 20;
|
|
888
|
+
// A product id longer than this is not an id any catalog issues; it is a
|
|
889
|
+
// runaway string that would eat the whole byte budget on one entry. Mirrors
|
|
890
|
+
// the decoder's own per-id cap, which DROPS (never truncates) an oversized
|
|
891
|
+
// id — a truncated id silently becomes a different, wrong product.
|
|
892
|
+
const MAX_PRODUCT_ID_CHARS = 64;
|
|
893
|
+
// The platform's global-id form for a product. Hosts hand us whichever form
|
|
894
|
+
// their storefront layer has to hand; the catalog is keyed on the bare id, so
|
|
895
|
+
// normalize here rather than making every caller remember which one to pass.
|
|
896
|
+
const GLOBAL_ID_PREFIX = 'gid://shopify/Product/';
|
|
897
|
+
/**
|
|
898
|
+
* Coerces a caller-supplied product id into the id space the catalog uses, or
|
|
899
|
+
* null when it cannot be one. Accepts a number (storefront globals expose the
|
|
900
|
+
* id numerically) or a string, tolerates the platform's global-id form, and
|
|
901
|
+
* rejects empty/oversized values rather than putting an id on the wire that
|
|
902
|
+
* can never resolve.
|
|
903
|
+
*/
|
|
904
|
+
const normalizeProductId = (value) => {
|
|
905
|
+
const raw = typeof value === 'number' && Number.isFinite(value)
|
|
906
|
+
? String(value)
|
|
907
|
+
: typeof value === 'string'
|
|
908
|
+
? value.trim()
|
|
909
|
+
: '';
|
|
910
|
+
const id = raw.startsWith(GLOBAL_ID_PREFIX)
|
|
911
|
+
? raw.slice(GLOBAL_ID_PREFIX.length)
|
|
912
|
+
: raw;
|
|
913
|
+
if (!id || id.length > MAX_PRODUCT_ID_CHARS) {
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
return id;
|
|
917
|
+
};
|
|
918
|
+
// Hard cap enforced by serializeContext, measured in ENCODED UTF-8 BYTES —
|
|
919
|
+
// `.length` counts UTF-16 code units, which undercounts CJK/emoji text by up
|
|
920
|
+
// to 3x. Kept in sync with the `ctx` query param budget documented in the
|
|
921
|
+
// PR2 brief (base64url grows this ~4/3, so ~2.7KB encoded — comfortably
|
|
922
|
+
// inside URL limits).
|
|
923
|
+
const MAX_SERIALIZED_BYTES = 2048;
|
|
924
|
+
const pushViewed = (ctx, id) => ({
|
|
925
|
+
...ctx,
|
|
926
|
+
viewed: [id, ...ctx.viewed.filter((viewedId) => viewedId !== id)].slice(0, MAX_VIEWED),
|
|
927
|
+
});
|
|
928
|
+
// Drops entries from the tail (pushViewed/pushQuery keep newest-first) until
|
|
929
|
+
// the ENCODED byte length fits the hard cap, in order of how much each byte
|
|
930
|
+
// is worth: the browse list first (its tail is the least recent thing the
|
|
931
|
+
// shopper looked at, and it is the longest list), then the cart, then the
|
|
932
|
+
// queries — fewest entries and the most intent per byte, so they yield last
|
|
933
|
+
// but are not exempt: five 200-char CJK queries alone encode to ~3KB, so a
|
|
934
|
+
// character-bounded list can still blow a byte budget.
|
|
935
|
+
const serializeContext = (ctx) => {
|
|
936
|
+
let working = {
|
|
937
|
+
...ctx,
|
|
938
|
+
viewed: [...ctx.viewed],
|
|
939
|
+
cart: [...ctx.cart],
|
|
940
|
+
queries: [...ctx.queries],
|
|
941
|
+
};
|
|
942
|
+
let serialized = JSON.stringify(working);
|
|
943
|
+
while (textEncoder.encode(serialized).length > MAX_SERIALIZED_BYTES &&
|
|
944
|
+
(working.viewed.length > 0 ||
|
|
945
|
+
working.cart.length > 0 ||
|
|
946
|
+
working.queries.length > 0)) {
|
|
947
|
+
working =
|
|
948
|
+
working.viewed.length > 0
|
|
949
|
+
? { ...working, viewed: working.viewed.slice(0, -1) }
|
|
950
|
+
: working.cart.length > 0
|
|
951
|
+
? { ...working, cart: working.cart.slice(0, -1) }
|
|
952
|
+
: { ...working, queries: working.queries.slice(0, -1) };
|
|
953
|
+
serialized = JSON.stringify(working);
|
|
954
|
+
}
|
|
955
|
+
return serialized;
|
|
956
|
+
};
|
|
957
|
+
// Detected once per session from the entry URL — home/plp/pdp/cart by path
|
|
958
|
+
// shape, everything else is 'other'. Pure so it's testable alongside the
|
|
959
|
+
// rest of this module; utils.ts calls it exactly once, on first read.
|
|
960
|
+
const detectEntryPage = (pathname) => {
|
|
961
|
+
if (pathname === '/') {
|
|
962
|
+
return 'home';
|
|
963
|
+
}
|
|
964
|
+
if (pathname.startsWith('/products/')) {
|
|
965
|
+
return 'pdp';
|
|
966
|
+
}
|
|
967
|
+
if (pathname.startsWith('/collections/')) {
|
|
968
|
+
return 'plp';
|
|
969
|
+
}
|
|
970
|
+
if (pathname.startsWith('/cart')) {
|
|
971
|
+
return 'cart';
|
|
972
|
+
}
|
|
973
|
+
return 'other';
|
|
974
|
+
};
|
|
975
|
+
// Native TextEncoder + btoa, same as the CDN sibling's utils/user-auth.ts —
|
|
976
|
+
// jsdom's missing TextEncoder is a test-environment gap, polyfilled in
|
|
977
|
+
// jest.setup.ts rather than worked around in shipped code.
|
|
978
|
+
const textEncoder = new TextEncoder();
|
|
979
|
+
const toBase64Url = (bytes) => {
|
|
980
|
+
let bin = '';
|
|
981
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
982
|
+
bin += String.fromCharCode(bytes[i]);
|
|
983
|
+
}
|
|
984
|
+
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
985
|
+
};
|
|
986
|
+
// Wire form for the `ctx` search query param: base64url(serializeContext(ctx)),
|
|
987
|
+
// no padding. Returns null when there's nothing worth the extra URL bytes —
|
|
988
|
+
// a context with no viewed products, cart lines or past queries yet (e.g. the very
|
|
989
|
+
// first search of a fresh tab) — so a brand-new session doesn't pay for a
|
|
990
|
+
// param carrying only the entryPage enum.
|
|
991
|
+
const encodeContextParam = (ctx) => {
|
|
992
|
+
if (ctx.viewed.length === 0 &&
|
|
993
|
+
ctx.cart.length === 0 &&
|
|
994
|
+
ctx.queries.length === 0) {
|
|
995
|
+
return null;
|
|
996
|
+
}
|
|
997
|
+
return toBase64Url(textEncoder.encode(serializeContext(ctx)));
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
// `crypto.randomUUID` is secure-context-only: on an `http://` storefront it is
|
|
1001
|
+
// undefined and calling it throws a TypeError. Called bare, that threw from
|
|
1002
|
+
// inside the autocomplete and image-search render paths — after results were
|
|
1003
|
+
// already assigned — so the catch cleared them and the shopper saw an error
|
|
1004
|
+
// state for a working search. `crypto.getRandomValues` has no such
|
|
1005
|
+
// restriction, so it carries the fallback; Math.random is the last resort for
|
|
1006
|
+
// an environment with no crypto at all. The output stays RFC4122-v4 shaped
|
|
1007
|
+
// either way, because the worker's beacon validators reject anything else.
|
|
1008
|
+
const randomBytes16 = () => {
|
|
1009
|
+
const bytes = new Uint8Array(16);
|
|
1010
|
+
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
|
1011
|
+
crypto.getRandomValues(bytes);
|
|
1012
|
+
return bytes;
|
|
1013
|
+
}
|
|
1014
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
1015
|
+
bytes[i] = Math.floor(Math.random() * 256);
|
|
1016
|
+
}
|
|
1017
|
+
return bytes;
|
|
1018
|
+
};
|
|
1019
|
+
const mintId = () => {
|
|
1020
|
+
var _a, _b;
|
|
1021
|
+
if (typeof crypto !== 'undefined' &&
|
|
1022
|
+
typeof crypto.randomUUID === 'function') {
|
|
1023
|
+
return crypto.randomUUID();
|
|
1024
|
+
}
|
|
1025
|
+
const bytes = randomBytes16();
|
|
1026
|
+
// Version 4, variant 10xx — the two bits the shape check would otherwise
|
|
1027
|
+
// read as a malformed id.
|
|
1028
|
+
bytes[6] = (((_a = bytes[6]) !== null && _a !== void 0 ? _a : 0) & 0x0f) | 0x40;
|
|
1029
|
+
bytes[8] = (((_b = bytes[8]) !== null && _b !== void 0 ? _b : 0) & 0x3f) | 0x80;
|
|
1030
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
1031
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
1032
|
+
};
|
|
857
1033
|
// 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.
|
|
858
1034
|
const previewKeyName = 'previewEvoSearch';
|
|
859
1035
|
const hasPreviewKey = () => {
|
|
@@ -878,6 +1054,101 @@ const hasDisableKey = () => {
|
|
|
878
1054
|
return false;
|
|
879
1055
|
}
|
|
880
1056
|
};
|
|
1057
|
+
// Cross-widget, cross-search shopper context (recently viewed products,
|
|
1058
|
+
// finalized queries, entry page) that rides along on every search
|
|
1059
|
+
// request as a `ctx` query param and every finalized-query beacon, so
|
|
1060
|
+
// ranking/telemetry can see the visit's shape without the worker
|
|
1061
|
+
// maintaining its own session. A query param, not a header, so /search
|
|
1062
|
+
// stays a CORS-simple request (no preflight on the per-keystroke hot path).
|
|
1063
|
+
// sessionStorage-scoped like the rest of this file's keys: it dies with the
|
|
1064
|
+
// tab and never needs a retention policy. No PII — see session-context.ts.
|
|
1065
|
+
const sessionContextKeyName = 'evoSearchSessionContext';
|
|
1066
|
+
const emptySessionContext = () => ({
|
|
1067
|
+
viewed: [],
|
|
1068
|
+
cart: [],
|
|
1069
|
+
queries: [],
|
|
1070
|
+
entryPage: detectEntryPage(typeof window !== 'undefined' ? window.location.pathname : ''),
|
|
1071
|
+
});
|
|
1072
|
+
const getSessionContext = () => {
|
|
1073
|
+
try {
|
|
1074
|
+
const raw = sessionStorage.getItem(sessionContextKeyName);
|
|
1075
|
+
if (raw) {
|
|
1076
|
+
const parsed = JSON.parse(raw);
|
|
1077
|
+
// Element types are checked, not just array-ness: a corrupted stored
|
|
1078
|
+
// value (e.g. viewed: [123]) must not flow onto the wire as non-string
|
|
1079
|
+
// data. Fall through to a fresh context instead, same as the
|
|
1080
|
+
// entryPage enum check below.
|
|
1081
|
+
const isStringArray = (value) => Array.isArray(value) && value.every((v) => typeof v === 'string');
|
|
1082
|
+
// Absent, not invalid: a tab that started on an older SDK has a stored
|
|
1083
|
+
// context with no `cart` key at all. Reading that as a shape failure
|
|
1084
|
+
// would throw away its viewed/queries history on every upgrade, so a
|
|
1085
|
+
// missing list reads as an empty one; a wrong-typed one still falls
|
|
1086
|
+
// through to a fresh context like any other corrupted array.
|
|
1087
|
+
const storedCart = parsed.cart === undefined ? [] : parsed.cart;
|
|
1088
|
+
if (isStringArray(parsed.viewed) &&
|
|
1089
|
+
isStringArray(parsed.queries) &&
|
|
1090
|
+
isStringArray(storedCart)) {
|
|
1091
|
+
return {
|
|
1092
|
+
viewed: parsed.viewed,
|
|
1093
|
+
cart: storedCart,
|
|
1094
|
+
queries: parsed.queries,
|
|
1095
|
+
// A hostile/corrupted stored value (or a future enum member from a
|
|
1096
|
+
// newer SDK version) falls back to 'other' rather than discarding
|
|
1097
|
+
// otherwise-good viewed/queries data, and rather than passing
|
|
1098
|
+
// an unvalidated string through to the wire.
|
|
1099
|
+
entryPage: isPageType(parsed.entryPage) ? parsed.entryPage : 'other',
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
catch {
|
|
1105
|
+
// Storage unavailable or corrupt; fall through to a fresh context.
|
|
1106
|
+
}
|
|
1107
|
+
// First read this session: detect entryPage once and persist immediately
|
|
1108
|
+
// so a later navigation within the same tab doesn't re-detect it from a
|
|
1109
|
+
// different path.
|
|
1110
|
+
const fresh = emptySessionContext();
|
|
1111
|
+
setSessionContext(fresh);
|
|
1112
|
+
return fresh;
|
|
1113
|
+
};
|
|
1114
|
+
/** @internal */
|
|
1115
|
+
const setSessionContext = (ctx) => {
|
|
1116
|
+
try {
|
|
1117
|
+
sessionStorage.setItem(sessionContextKeyName, serializeContext(ctx));
|
|
1118
|
+
}
|
|
1119
|
+
catch {
|
|
1120
|
+
// Storage unavailable; the context is best-effort telemetry, not
|
|
1121
|
+
// functionality — swallow rather than fail whatever caller triggered it.
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
// base64url-encoded form for the `ctx` search query param — null when
|
|
1125
|
+
// there's nothing worth adding to the URL yet (see encodeContextParam).
|
|
1126
|
+
// api.ts stays decoupled from session-context's internals. Memoized because
|
|
1127
|
+
// search() calls this on every debounced keystroke: the cache is keyed on
|
|
1128
|
+
// the raw stored string, so a hit costs one getItem + compare instead of the
|
|
1129
|
+
// parse/serialize/encode chain, and any writer (not just setSessionContext)
|
|
1130
|
+
// invalidates it naturally.
|
|
1131
|
+
let cachedContextRaw = null;
|
|
1132
|
+
let cachedContextParam = null;
|
|
1133
|
+
const getSessionContextParam = () => {
|
|
1134
|
+
let raw = null;
|
|
1135
|
+
try {
|
|
1136
|
+
raw = sessionStorage.getItem(sessionContextKeyName);
|
|
1137
|
+
}
|
|
1138
|
+
catch {
|
|
1139
|
+
// Storage unavailable — nothing stable to memoize against.
|
|
1140
|
+
return encodeContextParam(getSessionContext());
|
|
1141
|
+
}
|
|
1142
|
+
if (raw !== null && raw === cachedContextRaw) {
|
|
1143
|
+
return cachedContextParam;
|
|
1144
|
+
}
|
|
1145
|
+
const ctx = getSessionContext();
|
|
1146
|
+
cachedContextParam = encodeContextParam(ctx);
|
|
1147
|
+
// A null raw means getSessionContext just persisted a fresh context — key
|
|
1148
|
+
// the cache on what it wrote so the next call hits.
|
|
1149
|
+
cachedContextRaw = raw !== null && raw !== void 0 ? raw : serializeContext(ctx);
|
|
1150
|
+
return cachedContextParam;
|
|
1151
|
+
};
|
|
881
1152
|
|
|
882
1153
|
var _a;
|
|
883
1154
|
const registryKey = Symbol.for('evo-search');
|
|
@@ -914,6 +1185,260 @@ const notifyReady = () => {
|
|
|
914
1185
|
notifySubscribers(registry.subscribers, 'notifyReady');
|
|
915
1186
|
};
|
|
916
1187
|
|
|
1188
|
+
/**
|
|
1189
|
+
* Storefront-local helpers, deliberately separate from the worker-scoped API
|
|
1190
|
+
* client: `api.ts` injects identity params and headers that must never reach
|
|
1191
|
+
* the merchant's own origin, and its `credentials: omit` policy is the
|
|
1192
|
+
* opposite of what storefront requests (which ride the cart cookie) need.
|
|
1193
|
+
*/
|
|
1194
|
+
/**
|
|
1195
|
+
* The storefront's root path. Shopify publishes it (locale prefix included,
|
|
1196
|
+
* e.g. `/en-ca/`) on `window.Shopify.routes.root`; default to `/`.
|
|
1197
|
+
*/
|
|
1198
|
+
const storefrontRoot = () => {
|
|
1199
|
+
var _a, _b;
|
|
1200
|
+
if (typeof window === 'undefined') {
|
|
1201
|
+
return '/';
|
|
1202
|
+
}
|
|
1203
|
+
const root = (_b = (_a = window.Shopify) === null || _a === void 0 ? void 0 : _a.routes) === null || _b === void 0 ? void 0 : _b.root;
|
|
1204
|
+
return typeof root === 'string' && root.startsWith('/') && root.endsWith('/')
|
|
1205
|
+
? root
|
|
1206
|
+
: '/';
|
|
1207
|
+
};
|
|
1208
|
+
|
|
1209
|
+
// Storefront-side producers for the shopper session context. `session-context.ts`
|
|
1210
|
+
// knows how to grow the shape and `utils.ts` how to persist it; this module is
|
|
1211
|
+
// the only place that decides, from the page itself, WHAT to put in it.
|
|
1212
|
+
//
|
|
1213
|
+
// Why it exists: the widget's product-click handler is the only other producer,
|
|
1214
|
+
// so a shopper who browses the storefront normally — landing on product pages
|
|
1215
|
+
// from a collection, an ad, or a link — contributes nothing to ranking until
|
|
1216
|
+
// they happen to click a result inside search. Capturing the product page the
|
|
1217
|
+
// shopper is standing on closes that gap.
|
|
1218
|
+
//
|
|
1219
|
+
// Constraints this module works under, all deliberate:
|
|
1220
|
+
// - No network calls. This runs on the merchant's storefront; every id comes
|
|
1221
|
+
// from something already in the page (a global the platform published, or
|
|
1222
|
+
// the page's own structured data) or from a host that hands it to us.
|
|
1223
|
+
// - No PII, no page text. Ids only — never titles, prices or descriptions.
|
|
1224
|
+
// - Never throws. Every source is optional and best-effort: a page that
|
|
1225
|
+
// exposes none of them simply contributes nothing.
|
|
1226
|
+
// - No consent gate of its own. The context is anonymous, client-held and
|
|
1227
|
+
// dies with the tab; the provider is loaded under the pixel's own consent
|
|
1228
|
+
// decision, so a suppressed init means no capture at all, and the
|
|
1229
|
+
// transport (the `ctx` search param) stays gated on the merchant opt-in
|
|
1230
|
+
// that api.ts checks.
|
|
1231
|
+
const shopifyMeta = () => {
|
|
1232
|
+
var _a, _b;
|
|
1233
|
+
const w = window;
|
|
1234
|
+
// Two spellings of the same platform-published object: themes render the
|
|
1235
|
+
// analytics one, and some render a bare `meta` as well. Neither is catalog
|
|
1236
|
+
// data — they are the platform's page-scoped conventions.
|
|
1237
|
+
return (_b = (_a = w.ShopifyAnalytics) === null || _a === void 0 ? void 0 : _a.meta) !== null && _b !== void 0 ? _b : w.meta;
|
|
1238
|
+
};
|
|
1239
|
+
/**
|
|
1240
|
+
* True when the page the shopper is on is a product page, by the two signals
|
|
1241
|
+
* a storefront publishes without us asking: the platform's own page-type
|
|
1242
|
+
* marker, and the product path shape under the storefront root (which carries
|
|
1243
|
+
* the locale prefix on localized stores, so `/fr/products/…` is recognized).
|
|
1244
|
+
*/
|
|
1245
|
+
const onProductPage = () => {
|
|
1246
|
+
var _a, _b;
|
|
1247
|
+
if (((_b = (_a = shopifyMeta()) === null || _a === void 0 ? void 0 : _a.page) === null || _b === void 0 ? void 0 : _b.pageType) === 'product') {
|
|
1248
|
+
return true;
|
|
1249
|
+
}
|
|
1250
|
+
return window.location.pathname.startsWith(`${storefrontRoot()}products/`);
|
|
1251
|
+
};
|
|
1252
|
+
// Structured data is the last resort and the only source read out of the DOM,
|
|
1253
|
+
// so it is bounded on every axis: how many blocks are parsed, how big a block
|
|
1254
|
+
// may be, and how deep the search for a Product node goes.
|
|
1255
|
+
const MAX_LD_BLOCKS = 5;
|
|
1256
|
+
// A structured-data block far larger than this is a page-wide graph, not one
|
|
1257
|
+
// product's node; parsing it would cost more than the signal is worth.
|
|
1258
|
+
const MAX_LD_CHARS = 100000;
|
|
1259
|
+
const productIdFromLdNode = (node) => {
|
|
1260
|
+
if (!node || typeof node !== 'object') {
|
|
1261
|
+
return null;
|
|
1262
|
+
}
|
|
1263
|
+
if (Array.isArray(node)) {
|
|
1264
|
+
for (const entry of node) {
|
|
1265
|
+
const found = productIdFromLdNode(entry);
|
|
1266
|
+
if (found) {
|
|
1267
|
+
return found;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
return null;
|
|
1271
|
+
}
|
|
1272
|
+
const record = node;
|
|
1273
|
+
const graph = record['@graph'];
|
|
1274
|
+
if (Array.isArray(graph)) {
|
|
1275
|
+
return productIdFromLdNode(graph);
|
|
1276
|
+
}
|
|
1277
|
+
const type = record['@type'];
|
|
1278
|
+
const isProduct = Array.isArray(type)
|
|
1279
|
+
? type.includes('Product')
|
|
1280
|
+
: type === 'Product';
|
|
1281
|
+
if (!isProduct) {
|
|
1282
|
+
return null;
|
|
1283
|
+
}
|
|
1284
|
+
const id = normalizeProductId(record['productID']);
|
|
1285
|
+
// Only an all-digits id is accepted here, and `sku` is never read: the
|
|
1286
|
+
// catalog is keyed on the platform's own numeric product id, whereas a
|
|
1287
|
+
// merchant SKU (or a handle) is a different id space entirely. An id from
|
|
1288
|
+
// the wrong space can never resolve server-side — it would silently occupy
|
|
1289
|
+
// one of the few recently-viewed slots and contribute nothing. A host whose
|
|
1290
|
+
// structured data carries something else should call `trackProductView`.
|
|
1291
|
+
return id && /^\d+$/.test(id) ? id : null;
|
|
1292
|
+
};
|
|
1293
|
+
const productIdFromStructuredData = () => {
|
|
1294
|
+
const blocks = document.querySelectorAll('script[type="application/ld+json"]');
|
|
1295
|
+
const limit = Math.min(blocks.length, MAX_LD_BLOCKS);
|
|
1296
|
+
for (let i = 0; i < limit; i++) {
|
|
1297
|
+
const text = blocks[i].textContent;
|
|
1298
|
+
if (!text || text.length > MAX_LD_CHARS) {
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
try {
|
|
1302
|
+
const found = productIdFromLdNode(JSON.parse(text));
|
|
1303
|
+
if (found) {
|
|
1304
|
+
return found;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
catch {
|
|
1308
|
+
// A malformed block is one merchant app's problem, not ours; try the
|
|
1309
|
+
// next one.
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
return null;
|
|
1313
|
+
};
|
|
1314
|
+
/**
|
|
1315
|
+
* The id of the product this page is about, or null when the page isn't a
|
|
1316
|
+
* product page (or doesn't say which product it is). Sources are tried
|
|
1317
|
+
* cheapest first and each one is optional.
|
|
1318
|
+
*/
|
|
1319
|
+
const currentProductId = () => {
|
|
1320
|
+
var _a, _b;
|
|
1321
|
+
try {
|
|
1322
|
+
// The platform global is authoritative and free: it is only populated on
|
|
1323
|
+
// a product page, and it carries the id in the catalog's own id space.
|
|
1324
|
+
const fromGlobal = normalizeProductId((_b = (_a = shopifyMeta()) === null || _a === void 0 ? void 0 : _a.product) === null || _b === void 0 ? void 0 : _b.id);
|
|
1325
|
+
if (fromGlobal) {
|
|
1326
|
+
return fromGlobal;
|
|
1327
|
+
}
|
|
1328
|
+
if (!onProductPage()) {
|
|
1329
|
+
return null;
|
|
1330
|
+
}
|
|
1331
|
+
return productIdFromStructuredData();
|
|
1332
|
+
}
|
|
1333
|
+
catch {
|
|
1334
|
+
return null;
|
|
1335
|
+
}
|
|
1336
|
+
};
|
|
1337
|
+
const rememberView = (id) => {
|
|
1338
|
+
const ctx = getSessionContext();
|
|
1339
|
+
// Once per page load: a re-entrant call (a second capture attempt, a
|
|
1340
|
+
// re-init) must not churn storage for a product that is already the most
|
|
1341
|
+
// recent view. A LATER load of the same product is a different matter — by
|
|
1342
|
+
// then something else is at the head, so it pushes again and moves back to
|
|
1343
|
+
// the front, which is how recency is expressed. There is no repeat counter:
|
|
1344
|
+
// `viewed` is a deduplicated, newest-first list and the server tallies each
|
|
1345
|
+
// id once.
|
|
1346
|
+
if (ctx.viewed[0] === id) {
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
setSessionContext(pushViewed(ctx, id));
|
|
1350
|
+
};
|
|
1351
|
+
/**
|
|
1352
|
+
* Records the product page the shopper is currently on, if this is one.
|
|
1353
|
+
* Returns whether anything was recorded (the retry below is its only caller
|
|
1354
|
+
* that cares).
|
|
1355
|
+
*/
|
|
1356
|
+
const captureProductView = () => {
|
|
1357
|
+
try {
|
|
1358
|
+
const id = currentProductId();
|
|
1359
|
+
if (!id) {
|
|
1360
|
+
return false;
|
|
1361
|
+
}
|
|
1362
|
+
rememberView(id);
|
|
1363
|
+
return true;
|
|
1364
|
+
}
|
|
1365
|
+
catch {
|
|
1366
|
+
// Storage or DOM unavailable — the context is best-effort ranking input,
|
|
1367
|
+
// never functionality.
|
|
1368
|
+
return false;
|
|
1369
|
+
}
|
|
1370
|
+
};
|
|
1371
|
+
/**
|
|
1372
|
+
* Called once from the SDK bootstrap. The platform's page globals are written
|
|
1373
|
+
* by the theme's own head markup, so a loader that runs ahead of them would
|
|
1374
|
+
* see nothing at init; when the document is still parsing and nothing was
|
|
1375
|
+
* found, try again once the DOM is complete.
|
|
1376
|
+
*/
|
|
1377
|
+
const startProductViewCapture = () => {
|
|
1378
|
+
try {
|
|
1379
|
+
if (captureProductView()) {
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
if (document.readyState === 'loading') {
|
|
1383
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
1384
|
+
captureProductView();
|
|
1385
|
+
}, { once: true });
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
catch {
|
|
1389
|
+
// See captureProductView.
|
|
1390
|
+
}
|
|
1391
|
+
};
|
|
1392
|
+
|
|
1393
|
+
const createSessionContextGate = (uiPreferences, fetchConfig,
|
|
1394
|
+
// Whether this shopper has accumulated anything worth sending yet. Spending
|
|
1395
|
+
// a request to ask permission to send nothing is the one case where the
|
|
1396
|
+
// probe is pure waste, and it is also the common one — a session that has
|
|
1397
|
+
// just started has an empty context.
|
|
1398
|
+
hasContext) => {
|
|
1399
|
+
let serverEnabled = false;
|
|
1400
|
+
// One probe per API instance, latched BEFORE the request is issued so a
|
|
1401
|
+
// slow or failing config fetch can never turn the per-keystroke search path
|
|
1402
|
+
// into a retry storm. A probe that fails leaves the gate off, which is the
|
|
1403
|
+
// safe answer: the context is best-effort ranking input, never correctness.
|
|
1404
|
+
let probed = false;
|
|
1405
|
+
const prime = () => {
|
|
1406
|
+
// An explicit merchant preference already decides the question in both
|
|
1407
|
+
// directions, so there is nothing to ask the server about.
|
|
1408
|
+
if (probed || (uiPreferences === null || uiPreferences === void 0 ? void 0 : uiPreferences.sessionContext) !== undefined) {
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
// Deliberately does NOT latch `probed`: a session with nothing to say yet
|
|
1412
|
+
// will have something to say later, and that later call is the one worth
|
|
1413
|
+
// spending the probe on.
|
|
1414
|
+
if (!hasContext()) {
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
probed = true;
|
|
1418
|
+
// Deferred to a microtask so the request that triggered the prime is
|
|
1419
|
+
// already on the wire: the probe must never compete with, delay, or
|
|
1420
|
+
// reorder the search it rides behind.
|
|
1421
|
+
void Promise.resolve().then(() => fetchConfig().catch(() => {
|
|
1422
|
+
// Swallowed deliberately — the gate simply stays off. The caller that
|
|
1423
|
+
// triggered this never learns of it, and never retries.
|
|
1424
|
+
}));
|
|
1425
|
+
};
|
|
1426
|
+
return {
|
|
1427
|
+
shouldSend: () => {
|
|
1428
|
+
var _a;
|
|
1429
|
+
prime();
|
|
1430
|
+
// The merchant preference is an explicit override in both directions;
|
|
1431
|
+
// absent — the normal state — defers to the server's last answer.
|
|
1432
|
+
return (_a = uiPreferences === null || uiPreferences === void 0 ? void 0 : uiPreferences.sessionContext) !== null && _a !== void 0 ? _a : serverEnabled;
|
|
1433
|
+
},
|
|
1434
|
+
prime,
|
|
1435
|
+
resolveFrom: (sessionContextEnabled) => {
|
|
1436
|
+
probed = true;
|
|
1437
|
+
serverEnabled = sessionContextEnabled === true;
|
|
1438
|
+
},
|
|
1439
|
+
};
|
|
1440
|
+
};
|
|
1441
|
+
|
|
917
1442
|
// Resilience wrapper for /search requests. A transient network failure or a
|
|
918
1443
|
// hung request must not wedge the UI: add a per-attempt timeout (a hang becomes
|
|
919
1444
|
// a rejection), retry transient failures with backoff, and never retry a
|
|
@@ -1150,6 +1675,13 @@ const downscaleImage = async (source) => {
|
|
|
1150
1675
|
// Ask can't reuse withSearchResilience: a half-consumed stream isn't retryable.
|
|
1151
1676
|
// Idle timeout resets per chunk; both reject with TimeoutError so submitAsk
|
|
1152
1677
|
// routes them to the search fallback rather than treating them as cancellation.
|
|
1678
|
+
// Ceiling for an assembled GET URL, past which the optional session-context
|
|
1679
|
+
// param is dropped (see getJSON). Chosen above the encoder's own worst case —
|
|
1680
|
+
// session-context.ts caps its payload at 2KB, which base64url-grows to ~2.7KB
|
|
1681
|
+
// — so a legitimately full context is never dropped on its own, and several
|
|
1682
|
+
// times under the request-line ceiling of the edge that terminates these
|
|
1683
|
+
// requests. It is a safety bound, not a tuning knob.
|
|
1684
|
+
const MAX_REQUEST_URL_CHARS = 4096;
|
|
1153
1685
|
const QUICK_ADD_VALIDATE_TIMEOUT_MS = 7000;
|
|
1154
1686
|
const ASK_REQUEST_TIMEOUT_MS = 7000;
|
|
1155
1687
|
const ASK_SSE_IDLE_TIMEOUT_MS = 10000;
|
|
@@ -1163,6 +1695,16 @@ const KEEP_WARM_INTERVAL_MS = 60000;
|
|
|
1163
1695
|
// Stand-in caller signal for `search()` calls that don't pass one. Module-level
|
|
1164
1696
|
// and never aborted, so it carries no state between calls.
|
|
1165
1697
|
const NEVER_ABORTED = new AbortController().signal;
|
|
1698
|
+
// Tells /search this client resolves the response's product and collection
|
|
1699
|
+
// lanes against the top-level arrays itself, so the lanes need only carry ids.
|
|
1700
|
+
// Without it the server repeats every lane entry in full, which roughly doubles
|
|
1701
|
+
// the body for entries the client already has. Sent on both search legs so
|
|
1702
|
+
// neither pays for the duplication.
|
|
1703
|
+
//
|
|
1704
|
+
// Opt-in from the client, not a server-side switch: the client is published
|
|
1705
|
+
// separately from the worker, so a storefront can be running any earlier
|
|
1706
|
+
// version of it. One that never sends this keeps receiving whole entries.
|
|
1707
|
+
const LANE_REFERENCES = { lanes: 'ids' };
|
|
1166
1708
|
// Always defer to what the storefront itself is rendering — same source
|
|
1167
1709
|
// shop-gpt reads from. Two widgets on the same page would otherwise drift:
|
|
1168
1710
|
// shop-gpt picks Shopify, evo-search would pick its own override and show
|
|
@@ -1293,6 +1835,16 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
1293
1835
|
if (!credentialed) {
|
|
1294
1836
|
setIdentityParams(url);
|
|
1295
1837
|
}
|
|
1838
|
+
// Measured only here, where the URL is finally assembled: the results-page
|
|
1839
|
+
// leg carries facets, filter selections, a continuation cursor and identity
|
|
1840
|
+
// on top of the query, so its length is not knowable at the call site. A
|
|
1841
|
+
// session context is a ranking hint, so an over-long URL drops the hint
|
|
1842
|
+
// rather than the request — a slightly less personalized answer beats a
|
|
1843
|
+
// refused one.
|
|
1844
|
+
if (url.href.length > MAX_REQUEST_URL_CHARS &&
|
|
1845
|
+
url.searchParams.has('ctx')) {
|
|
1846
|
+
url.searchParams.delete('ctx');
|
|
1847
|
+
}
|
|
1296
1848
|
const response = await fetchImpl(url, {
|
|
1297
1849
|
method: 'GET',
|
|
1298
1850
|
...(credentialed ? { headers: getHeaders() } : {}),
|
|
@@ -1312,9 +1864,21 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
1312
1864
|
logger.info('EvoSearch API: Fetching trending searches');
|
|
1313
1865
|
return getJSON('/trending', 'Trending', undefined, signal);
|
|
1314
1866
|
};
|
|
1315
|
-
|
|
1867
|
+
// Held in the gate rather than read off `uiPreferences` at each send site:
|
|
1868
|
+
// that object is captured by value when this API instance is built, and the
|
|
1869
|
+
// widget replaces its own copy after fetching config, so an overlay there
|
|
1870
|
+
// would never reach this closure. The gate starts closed, so a search that
|
|
1871
|
+
// races ahead of the first answer sends nothing — session context is
|
|
1872
|
+
// best-effort ranking input, never a correctness input. `config` is
|
|
1873
|
+
// referenced lazily (only once the probe fires, long after this line runs).
|
|
1874
|
+
const sessionContextGate = createSessionContextGate(uiPreferences, () => config(), () => getSessionContextParam() !== null);
|
|
1875
|
+
const config = async (signal) => {
|
|
1316
1876
|
logger.info('EvoSearch API: Fetching ask config');
|
|
1317
|
-
|
|
1877
|
+
const data = await getJSON('/config', 'Config', undefined, signal);
|
|
1878
|
+
// A worker deployed before this gate omits the field; an absent answer
|
|
1879
|
+
// must read as off rather than as an opt-in.
|
|
1880
|
+
sessionContextGate.resolveFrom(data.sessionContextEnabled);
|
|
1881
|
+
return data;
|
|
1318
1882
|
};
|
|
1319
1883
|
const searchPageDesign = (signal, previewToken) => {
|
|
1320
1884
|
logger.info(`EvoSearch API: Fetching ${previewToken ? 'preview' : 'published'} search page design`);
|
|
@@ -1384,11 +1948,38 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
1384
1948
|
};
|
|
1385
1949
|
const deleteRecent = (value) => sendMutation('/user/recents', 'DeleteRecent', 'DELETE', { value });
|
|
1386
1950
|
const clearRecents = () => sendMutation('/user/recents', 'ClearRecents', 'DELETE');
|
|
1951
|
+
// The single decision point for whether a request carries the shopper's
|
|
1952
|
+
// session context, shared by both search legs so the autocomplete and
|
|
1953
|
+
// results-page requests cannot drift apart on when it is sent — the results
|
|
1954
|
+
// page is where a shopper spends the most time, so a leg without it is a
|
|
1955
|
+
// silently unpersonalized surface.
|
|
1956
|
+
//
|
|
1957
|
+
// Best-effort shopper context (recently viewed, past queries, entry page)
|
|
1958
|
+
// for server-side ranking, capped well under 2KB by session-context.ts.
|
|
1959
|
+
// Carried as a query param rather than a header so /search stays a
|
|
1960
|
+
// CORS-simple request — no OPTIONS preflight on the per-keystroke hot path.
|
|
1961
|
+
// Omitted while there's nothing to say yet. Gated on the server, which is
|
|
1962
|
+
// the only side that knows whether a `ctx` consumer is live: sending browsing
|
|
1963
|
+
// history nobody reads is pure cost and pure exposure. The merchant
|
|
1964
|
+
// preference is an explicit override in both directions — set, it wins;
|
|
1965
|
+
// absent (the normal state), the server decides.
|
|
1966
|
+
const withSessionContext = (params) => {
|
|
1967
|
+
if (!sessionContextGate.shouldSend()) {
|
|
1968
|
+
return params;
|
|
1969
|
+
}
|
|
1970
|
+
const ctxParam = getSessionContextParam();
|
|
1971
|
+
return ctxParam ? { ...params, ctx: ctxParam } : params;
|
|
1972
|
+
};
|
|
1387
1973
|
// Resilience lives here, not at each call site, so no consumer can opt out —
|
|
1388
1974
|
// the headless channel previously had no deadline and no retry.
|
|
1389
1975
|
const search = async (query, limit = 24, signal) => {
|
|
1390
1976
|
logger.info(`EvoSearch API: Search request - query length: ${query.length}, limit: ${limit}`);
|
|
1391
|
-
const
|
|
1977
|
+
const searchParams = withSessionContext({
|
|
1978
|
+
q: query,
|
|
1979
|
+
limit: String(limit),
|
|
1980
|
+
...LANE_REFERENCES,
|
|
1981
|
+
});
|
|
1982
|
+
const data = await withSearchResilience((attemptSignal) => getJSON('/search', 'Search', searchParams, attemptSignal),
|
|
1392
1983
|
// Shared stand-in is safe: the wrapper removes its abort listener per attempt.
|
|
1393
1984
|
signal !== null && signal !== void 0 ? signal : NEVER_ABORTED);
|
|
1394
1985
|
logger.info(`EvoSearch API: Search success - ${data.llmSuggestions.length} suggestions, ${data.products.length} products`);
|
|
@@ -1402,6 +1993,7 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
1402
1993
|
const params = {
|
|
1403
1994
|
q: query,
|
|
1404
1995
|
facets: '1',
|
|
1996
|
+
...LANE_REFERENCES,
|
|
1405
1997
|
};
|
|
1406
1998
|
if (options.limit != null) {
|
|
1407
1999
|
params['limit'] = String(options.limit);
|
|
@@ -1435,7 +2027,12 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
1435
2027
|
if ((filters === null || filters === void 0 ? void 0 : filters.priceMax) != null) {
|
|
1436
2028
|
params['priceMax'] = String(filters.priceMax);
|
|
1437
2029
|
}
|
|
1438
|
-
return withSearchResilience((attemptSignal) => getJSON('/search', 'SearchResults',
|
|
2030
|
+
return withSearchResilience((attemptSignal) => getJSON('/search', 'SearchResults',
|
|
2031
|
+
// Applied per attempt, and to every page: a continuation (cursor) or
|
|
2032
|
+
// a numbered page (offset) is the same result set the shopper is
|
|
2033
|
+
// still reading, so dropping the context there would re-rank the
|
|
2034
|
+
// next page against a different profile than the first.
|
|
2035
|
+
withSessionContext(params), attemptSignal), (_f = options.signal) !== null && _f !== void 0 ? _f : NEVER_ABORTED);
|
|
1439
2036
|
};
|
|
1440
2037
|
// Same deadline as `validateQuickAdd`, and for the same reason: the only
|
|
1441
2038
|
// caller passes no signal, so without one a stalled request never settles —
|
|
@@ -1692,7 +2289,7 @@ const createEvoSearchAPI = ({ fetch: fetchImpl = window.fetch, baseURL, userId,
|
|
|
1692
2289
|
.catch(logger.error);
|
|
1693
2290
|
// Also send to analytics via sendTag
|
|
1694
2291
|
sendTag({
|
|
1695
|
-
eventId:
|
|
2292
|
+
eventId: mintId(),
|
|
1696
2293
|
eventName: action,
|
|
1697
2294
|
data: {
|
|
1698
2295
|
...actionData,
|
|
@@ -1760,6 +2357,17 @@ const parseSseFrame = (frame) => {
|
|
|
1760
2357
|
}
|
|
1761
2358
|
};
|
|
1762
2359
|
|
|
2360
|
+
const FORCED_KEY = 'evsUiVersion';
|
|
2361
|
+
// ?evs=reset must also drop the QA force so a shopper returns to the configured version.
|
|
2362
|
+
const clearForcedUiVersion = () => {
|
|
2363
|
+
try {
|
|
2364
|
+
sessionStorage.removeItem(FORCED_KEY);
|
|
2365
|
+
}
|
|
2366
|
+
catch {
|
|
2367
|
+
/* no-op: storage unavailable */
|
|
2368
|
+
}
|
|
2369
|
+
};
|
|
2370
|
+
|
|
1763
2371
|
const error = (message) => console.error(message);
|
|
1764
2372
|
// Headless hosts render their own UI, so nothing else pings /warm for them.
|
|
1765
2373
|
let disposeKeepWarm;
|
|
@@ -1832,6 +2440,7 @@ const init = (params) => {
|
|
|
1832
2440
|
try {
|
|
1833
2441
|
sessionStorage.removeItem(previewKeyName);
|
|
1834
2442
|
sessionStorage.removeItem(disableKeyName);
|
|
2443
|
+
clearForcedUiVersion();
|
|
1835
2444
|
logger.info('EvoSearch SDK: Reset to configured state via ?evs=reset URL param');
|
|
1836
2445
|
}
|
|
1837
2446
|
catch {
|
|
@@ -1851,6 +2460,13 @@ const init = (params) => {
|
|
|
1851
2460
|
if (hasPreview) {
|
|
1852
2461
|
logger.info('EvoSearch SDK: Preview mode enabled via sessionStorage');
|
|
1853
2462
|
}
|
|
2463
|
+
// Before the UI/headless fork: a headless host returns early below, and
|
|
2464
|
+
// its shoppers browse product pages exactly like everyone else's. Not
|
|
2465
|
+
// gated on the session-context opt-in — that flag gates the TRANSPORT
|
|
2466
|
+
// (api.ts decides whether the context leaves the browser), and gating
|
|
2467
|
+
// capture too would leave a merchant who turns it on with an empty
|
|
2468
|
+
// history until the shopper navigates again.
|
|
2469
|
+
startProductViewCapture();
|
|
1854
2470
|
const evoSearchAPI = createEvoSearchAPI({
|
|
1855
2471
|
baseURL: params.baseUrl,
|
|
1856
2472
|
userId: params.userId,
|
|
@@ -1862,7 +2478,7 @@ const init = (params) => {
|
|
|
1862
2478
|
const uiImplementation = window[registryKey].ui;
|
|
1863
2479
|
const headless = !uiImplementation;
|
|
1864
2480
|
params.sendTag({
|
|
1865
|
-
eventId:
|
|
2481
|
+
eventId: mintId(),
|
|
1866
2482
|
eventName: 'evoSearchInitialized',
|
|
1867
2483
|
data: { mode, hasPreview, headless },
|
|
1868
2484
|
providers: { evoSearch: true },
|
|
@@ -1894,7 +2510,7 @@ const init = (params) => {
|
|
|
1894
2510
|
visualSearchEnabled,
|
|
1895
2511
|
});
|
|
1896
2512
|
params.sendTag({
|
|
1897
|
-
eventId:
|
|
2513
|
+
eventId: mintId(),
|
|
1898
2514
|
eventName: 'evoSearchLoaded',
|
|
1899
2515
|
data: {},
|
|
1900
2516
|
providers: { evoSearch: true },
|
|
@@ -1914,7 +2530,7 @@ const init = (params) => {
|
|
|
1914
2530
|
const tag = ({ data }) => {
|
|
1915
2531
|
try {
|
|
1916
2532
|
return {
|
|
1917
|
-
sdkVersion: "1.
|
|
2533
|
+
sdkVersion: "1.71.1" || '',
|
|
1918
2534
|
searchQuery: data['searchQuery'],
|
|
1919
2535
|
searchResults: data['searchResults'],
|
|
1920
2536
|
timestamp: Date.now(),
|
|
@@ -1923,7 +2539,7 @@ const tag = ({ data }) => {
|
|
|
1923
2539
|
catch (err) {
|
|
1924
2540
|
console.error(`EvoSearch SDK: Tagging failed - ${err}`);
|
|
1925
2541
|
return {
|
|
1926
|
-
sdkVersion: "1.
|
|
2542
|
+
sdkVersion: "1.71.1" ,
|
|
1927
2543
|
timestamp: Date.now(),
|
|
1928
2544
|
};
|
|
1929
2545
|
}
|