@askdialog/dialog-sdk 2.4.0 → 2.6.0

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.
Files changed (53) hide show
  1. package/README.md +56 -22
  2. package/dist/Dialog.d.ts +14 -1
  3. package/dist/Dialog.d.ts.map +1 -1
  4. package/dist/Dialog.js +30 -1
  5. package/dist/EventsHandler.d.ts.map +1 -1
  6. package/dist/EventsHandler.js +2 -0
  7. package/dist/__tests__/dialogLoadAssistant.spec.d.ts +2 -0
  8. package/dist/__tests__/dialogLoadAssistant.spec.d.ts.map +1 -0
  9. package/dist/__tests__/dialogLoadAssistant.spec.js +50 -0
  10. package/dist/__tests__/dialogSearchAnalytics.spec.d.ts +2 -0
  11. package/dist/__tests__/dialogSearchAnalytics.spec.d.ts.map +1 -0
  12. package/dist/__tests__/dialogSearchAnalytics.spec.js +90 -0
  13. package/dist/__tests__/publicTypes.spec.js +3 -0
  14. package/dist/__tests__/searchController.spec.d.ts +2 -0
  15. package/dist/__tests__/searchController.spec.d.ts.map +1 -0
  16. package/dist/__tests__/searchController.spec.js +254 -0
  17. package/dist/__tests__/searchControllerAttribution.spec.d.ts +2 -0
  18. package/dist/__tests__/searchControllerAttribution.spec.d.ts.map +1 -0
  19. package/dist/__tests__/searchControllerAttribution.spec.js +176 -0
  20. package/dist/__tests__/searchImpressions.spec.d.ts +2 -0
  21. package/dist/__tests__/searchImpressions.spec.d.ts.map +1 -0
  22. package/dist/__tests__/searchImpressions.spec.js +218 -0
  23. package/dist/__tests__/searchService.spec.js +3 -1
  24. package/dist/config/config.development.js +1 -1
  25. package/dist/index.d.ts +2 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2 -0
  28. package/dist/searchController.d.ts +3 -0
  29. package/dist/searchController.d.ts.map +1 -0
  30. package/dist/searchController.js +164 -0
  31. package/dist/types/constructor.d.ts +8 -0
  32. package/dist/types/constructor.d.ts.map +1 -1
  33. package/dist/types/events.d.ts +5 -2
  34. package/dist/types/events.d.ts.map +1 -1
  35. package/dist/types/events.js +2 -0
  36. package/dist/types/index.d.ts +2 -0
  37. package/dist/types/index.d.ts.map +1 -1
  38. package/dist/types/index.js +2 -0
  39. package/dist/types/search.d.ts +3 -1
  40. package/dist/types/search.d.ts.map +1 -1
  41. package/dist/types/searchAnalytics.d.ts +30 -0
  42. package/dist/types/searchAnalytics.d.ts.map +1 -0
  43. package/dist/types/searchAnalytics.js +10 -0
  44. package/dist/types/searchController.d.ts +80 -0
  45. package/dist/types/searchController.d.ts.map +1 -0
  46. package/dist/types/searchController.js +8 -0
  47. package/dist/utils/searchControllerAnalytics.d.ts +14 -0
  48. package/dist/utils/searchControllerAnalytics.d.ts.map +1 -0
  49. package/dist/utils/searchControllerAnalytics.js +54 -0
  50. package/dist/utils/searchImpressions.d.ts +21 -0
  51. package/dist/utils/searchImpressions.d.ts.map +1 -0
  52. package/dist/utils/searchImpressions.js +126 -0
  53. package/package.json +1 -1
package/README.md CHANGED
@@ -91,6 +91,15 @@ The getProduct function is used to display product information in the assistant.
91
91
 
92
92
  When the client is instantiated, it will automatically insert into the DOM the Dialog Assistant script, so you can interact with the assistant using `sendProductMessage` or `sendGenericMessage`. This assistant runtime always loads — it owns the shopper identity, consent handling and analytics bridge — while the heavy assistant UI stays lazy-loaded and is not fetched eagerly.
93
93
 
94
+ ### OneTrust auto-blocking
95
+
96
+ If your site uses OneTrust auto-blocking, it may neutralize the assistant script injected by the SDK (`type` rewritten to `text/plain`) for visitors who declined cookies — a `data-ot-ignore` on your own SDK `<script>` tag does not cover dynamically injected scripts. Two remedies, both merchant-side decisions:
97
+
98
+ - categorize the assistant CDN domain as strictly necessary in your OneTrust console, or
99
+ - pass the optional `ignoreOneTrustAutoBlock: true` constructor flag so the SDK adds `data-ot-ignore` on the script it injects.
100
+
101
+ Either is compliance-safe with regard to analytics: the assistant gates all analytics on consent internally and sends nothing without an explicit opt-in (inspect `window.dialog.audit.consent`).
102
+
94
103
  ### Getters
95
104
 
96
105
  - apiKey
@@ -179,6 +188,7 @@ const response: SearchResponse = await client.search({
179
188
  query: 'shampoo',
180
189
  page: 0, // optional, zero-indexed (default 0)
181
190
  hitsPerPage: 20, // optional, 1-100 (default 20)
191
+ queryId: previousResponse?.queryId, // optional, resend while the query is unchanged
182
192
  });
183
193
  // response.hits[n].product: { id, title?, url?, imageUrl?, priceRange?, inStock? }
184
194
  ```
@@ -195,32 +205,56 @@ With the IIFE bundle the results are plain runtime JSON (same shape, no types):
195
205
 
196
206
  A non-2xx answer rejects with `DialogSearchError` — stable `name`, HTTP `status`, optional machine-readable `code` (e.g. `SEARCH_INDEX_NOT_FOUND`) and `message`. Aborting rejects with the native `AbortError`, and network failures keep their native errors.
197
207
 
198
- The SDK is stateless: no debounce, no cache, no automatic cancellation of previous searches. For search-as-you-type, own those in the integration:
208
+ `client.search()` itself is stateless: no debounce, no cache, no automatic cancellation of previous searches. For search-as-you-type, use the search controller below instead of hand-rolling those.
209
+
210
+ - Search controller
211
+
212
+ `createSearchController()` wraps the stateless transport with the stateful behavior every search UI needs — debounce (immediate on explicit submission), cancellation of the in-flight request, stale-response protection (a late response never replaces newer results, even if the transport ignores the abort), pagination that resets on a new query, `idle` / `loading` / `success` / `empty` / `error` states, retry, and the DEC-2448 attribution events (`view_search_results` viewport impressions, `select_search_result` clicks) with a consistent `query_id`. It has no framework or rendering dependency: raw JavaScript, React, Vue and Shopify integrations are rendering-and-routing adapters around it.
199
213
 
200
214
  ```typescript
201
- let controller: AbortController | undefined;
202
- let latestRequestId = 0;
203
- let debounceTimer: number | undefined;
204
-
205
- const onInput = (query: string) => {
206
- window.clearTimeout(debounceTimer);
207
- debounceTimer = window.setTimeout(async () => {
208
- controller?.abort(); // cancel the in-flight request
209
- controller = new AbortController();
210
- const requestId = ++latestRequestId;
211
- try {
212
- const response = await client.search({ query }, { signal: controller.signal });
213
- if (requestId !== latestRequestId) return; // stale response, ignore
214
- render(response.hits);
215
- } catch (error) {
216
- if (error instanceof DOMException && error.name === 'AbortError') return;
217
- if (error instanceof DialogSearchError) renderError(error.message);
218
- else throw error;
219
- }
220
- }, 200);
221
- };
215
+ import { createSearchController, Dialog, SearchStatus } from '@askdialog/dialog-sdk';
216
+
217
+ const client = new Dialog({ apiKey: 'YOUR_API_KEY', locale: 'fr' });
218
+
219
+ const controller = createSearchController({
220
+ search: (request, options) => client.search(request, options),
221
+ analytics: {
222
+ surface: 'search_page', // where results are displayed
223
+ trackViewSearchResults: (params) => client.trackViewSearchResults(params),
224
+ trackSelectSearchResult: (params) => client.trackSelectSearchResult(params),
225
+ },
226
+ navigate: (url) => router.push(url), // optional platform routing adapter
227
+ debounceMs: 250, // optional (default 250)
228
+ hitsPerPage: 12, // optional (default 12)
229
+ });
230
+
231
+ const unsubscribe = controller.subscribe((state) => {
232
+ // state: { status, query, page, response?, error? }
233
+ if (state.status === SearchStatus.SUCCESS) {
234
+ renderCards(state.response.hits).forEach((element, index) => {
235
+ controller.observeResult(element, index); // viewport impression
236
+ element.onclick = () => controller.selectResult(index); // attribution, then `navigate`
237
+ });
238
+ }
239
+ });
240
+
241
+ input.oninput = () => controller.setQuery(input.value); // debounced
242
+ form.onsubmit = () => controller.submit(input.value); // immediate
243
+ nextButton.onclick = () => controller.setPage(controller.getState().page + 1);
244
+ retryButton.onclick = () => controller.retry();
245
+ // On teardown (SPA unmount): cancel in-flight work and detach observers.
246
+ controller.dispose();
222
247
  ```
223
248
 
249
+ The adapter contract for a framework binding (React, Vue, Shopify):
250
+
251
+ - **Rendering** — subscribe to the controller (`subscribe`/`getState` fit React's `useSyncExternalStore` and a Vue `shallowRef` updated by the listener) and render the five states; never re-implement debounce, `AbortController` or race protection locally.
252
+ - **Attribution** — call `observeResult(element, index)` for every rendered result and `selectResult(index)` on every result click (including middle-click/cmd+click). Do not `preventDefault` a same-tab navigation: attribution is recorded first and the events survive it.
253
+ - **Routing** — platform navigation and URL synchronization (query params, history) stay in the adapter: pass `navigate` for router-driven platforms, or let plain `<a href>` links navigate natively.
254
+ - **Lifecycle** — create one controller per search surface and `dispose()` it on unmount.
255
+
256
+ The raw JavaScript reference adapter lives in [`packages/search-example`](../search-example).
257
+
224
258
  - Handler for fetch product
225
259
 
226
260
  The `getProduct` callback is called by the assistant to display product information. You must return an object matching the `SimplifiedProduct` interface.
package/dist/Dialog.d.ts CHANGED
@@ -4,6 +4,7 @@ import { DetailedLocaleInfo } from "./utils/localization";
4
4
  import { Suggestion } from "./types/suggestion";
5
5
  import { AddToCartInput, GenericQuestionPayload, LegacyCheckoutParams, OpenAssistantPayload, ProductQuestionPayload, SubmitCheckoutParams } from "./types/events";
6
6
  import { SimplifiedProduct } from "./types/product";
7
+ import { SelectSearchResultParams, ViewSearchResultsParams } from "./types/searchAnalytics";
7
8
  import { EventsHandler } from "./EventsHandler";
8
9
  import { SearchOptions, SearchRequest, SearchResponse } from "./types/search";
9
10
  import { AssistantEvent } from "./types/assistantEvent";
@@ -16,7 +17,8 @@ export declare class Dialog {
16
17
  private _theme;
17
18
  private _userId;
18
19
  private _eventsHandler;
19
- constructor({ apiKey, locale, countryCode, callbacks, theme, userId, }: DialogConstructor);
20
+ private _ignoreOneTrustAutoBlock;
21
+ constructor({ apiKey, locale, countryCode, callbacks, theme, userId, ignoreOneTrustAutoBlock, }: DialogConstructor);
20
22
  get apiKey(): string;
21
23
  get theme(): Theme;
22
24
  get userId(): string;
@@ -44,6 +46,17 @@ export declare class Dialog {
44
46
  private _getCallbacksOrThrow;
45
47
  registerAddToCartEvent(input: AddToCartInput): void;
46
48
  registerSubmitCheckoutEvent(params: SubmitCheckoutParams | LegacyCheckoutParams): void;
49
+ /**
50
+ * Emit a batch of viewport impressions. The batching/dedup semantics live
51
+ * in `createSearchImpressionTracker` — wire its `emit` here, or call
52
+ * directly with `items: []` for a rendered no-results state.
53
+ */
54
+ trackViewSearchResults(params: ViewSearchResultsParams): void;
55
+ /**
56
+ * Emit on a result click — including auxclick / cmd+click, not only before
57
+ * a same-tab navigation. Force the clicked item's impression first.
58
+ */
59
+ trackSelectSearchResult(params: SelectSearchResultParams): void;
47
60
  private _loadAssistant;
48
61
  }
49
62
  //# sourceMappingURL=Dialog.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Dialog.d.ts","sourceRoot":"","sources":["../src/Dialog.ts"],"names":[],"mappings":"AAIA,OAAO,EAAmB,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACzE,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AACtC,OAAO,EACL,kBAAkB,EAEnB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAChD,OAAO,EACL,cAAc,EAEd,sBAAsB,EACtB,oBAAoB,EACpB,oBAAoB,EACpB,sBAAsB,EACtB,oBAAoB,EACrB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAGhD,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAExD,qBAAa,MAAM;IACjB,gBAAuB,OAAO,SAAuB;IAErD,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,YAAY,CAAC,CAAS;IAE9B,OAAO,CAAC,UAAU,CAAC,CAAkB;IACrC,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,cAAc,CAAgB;gBAE1B,EACV,MAAM,EACN,MAAM,EACN,WAAW,EACX,SAAS,EACT,KAAK,EACL,MAAM,GACP,EAAE,iBAAiB;IAYpB,IAAW,MAAM,IAAI,MAAM,CAE1B;IACD,IAAW,KAAK,IAAI,KAAK,CAExB;IACD,IAAW,MAAM,IAAI,MAAM,CAE1B;IACD,IAAW,MAAM,IAAI,MAAM,CAE1B;IACD,IAAW,aAAa,IAAI,aAAa,CAExC;IAEM,2BAA2B,IAAI,kBAAkB,GAAG,IAAI;IAI/D,OAAO,CAAC,uBAAuB;IAkBlB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAInE;;;;;;OAMG;IACI,MAAM,CACX,OAAO,EAAE,aAAa,EACtB,OAAO,CAAC,EAAE,aAAa,GACtB,OAAO,CAAC,cAAc,CAAC;IAKnB,aAAa,CAAC,MAAM,EAAE,oBAAoB,GAAG,IAAI;IAKjD,cAAc,IAAI,IAAI;IAItB,kBAAkB,CAAC,MAAM,EAAE,sBAAsB,GAAG,IAAI;IAIxD,kBAAkB,CAAC,MAAM,EAAE,sBAAsB,GAAG,IAAI;IAOxD,gBAAgB,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,GAAG,IAAI;IAIjE,sBAAsB,CAAC,KAAK,EAAE,cAAc,GAAG,IAAI;IAInD,UAAU,CACf,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,iBAAiB,CAAC;IAUhB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;IAS5D,OAAO,CAAC,oBAAoB;IAUrB,sBAAsB,CAAC,KAAK,EAAE,cAAc,GAAG,IAAI;IAcnD,2BAA2B,CAChC,MAAM,EAAE,oBAAoB,GAAG,oBAAoB,GAClD,IAAI;IA0BP,OAAO,CAAC,cAAc;CA4BvB"}
1
+ {"version":3,"file":"Dialog.d.ts","sourceRoot":"","sources":["../src/Dialog.ts"],"names":[],"mappings":"AAIA,OAAO,EAAmB,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACzE,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AACtC,OAAO,EACL,kBAAkB,EAEnB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAChD,OAAO,EACL,cAAc,EAEd,sBAAsB,EACtB,oBAAoB,EACpB,oBAAoB,EACpB,sBAAsB,EACtB,oBAAoB,EACrB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EACL,wBAAwB,EACxB,uBAAuB,EACxB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAGhD,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAExD,qBAAa,MAAM;IACjB,gBAAuB,OAAO,SAAuB;IAErD,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,YAAY,CAAC,CAAS;IAE9B,OAAO,CAAC,UAAU,CAAC,CAAkB;IACrC,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,cAAc,CAAgB;IACtC,OAAO,CAAC,wBAAwB,CAAU;gBAE9B,EACV,MAAM,EACN,MAAM,EACN,WAAW,EACX,SAAS,EACT,KAAK,EACL,MAAM,EACN,uBAAuB,GACxB,EAAE,iBAAiB;IAapB,IAAW,MAAM,IAAI,MAAM,CAE1B;IACD,IAAW,KAAK,IAAI,KAAK,CAExB;IACD,IAAW,MAAM,IAAI,MAAM,CAE1B;IACD,IAAW,MAAM,IAAI,MAAM,CAE1B;IACD,IAAW,aAAa,IAAI,aAAa,CAExC;IAEM,2BAA2B,IAAI,kBAAkB,GAAG,IAAI;IAI/D,OAAO,CAAC,uBAAuB;IAkBlB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAInE;;;;;;OAMG;IACI,MAAM,CACX,OAAO,EAAE,aAAa,EACtB,OAAO,CAAC,EAAE,aAAa,GACtB,OAAO,CAAC,cAAc,CAAC;IAKnB,aAAa,CAAC,MAAM,EAAE,oBAAoB,GAAG,IAAI;IAKjD,cAAc,IAAI,IAAI;IAItB,kBAAkB,CAAC,MAAM,EAAE,sBAAsB,GAAG,IAAI;IAIxD,kBAAkB,CAAC,MAAM,EAAE,sBAAsB,GAAG,IAAI;IAOxD,gBAAgB,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,GAAG,IAAI;IAIjE,sBAAsB,CAAC,KAAK,EAAE,cAAc,GAAG,IAAI;IAInD,UAAU,CACf,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,iBAAiB,CAAC;IAUhB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;IAS5D,OAAO,CAAC,oBAAoB;IAUrB,sBAAsB,CAAC,KAAK,EAAE,cAAc,GAAG,IAAI;IAcnD,2BAA2B,CAChC,MAAM,EAAE,oBAAoB,GAAG,oBAAoB,GAClD,IAAI;IA0BP;;;;OAIG;IACI,sBAAsB,CAAC,MAAM,EAAE,uBAAuB,GAAG,IAAI;IAUpE;;;OAGG;IACI,uBAAuB,CAAC,MAAM,EAAE,wBAAwB,GAAG,IAAI;IAUtE,OAAO,CAAC,cAAc;CAkCvB"}
package/dist/Dialog.js CHANGED
@@ -19,11 +19,13 @@ export class Dialog {
19
19
  _theme;
20
20
  _userId;
21
21
  _eventsHandler;
22
- constructor({ apiKey, locale, countryCode, callbacks, theme, userId, }) {
22
+ _ignoreOneTrustAutoBlock;
23
+ constructor({ apiKey, locale, countryCode, callbacks, theme, userId, ignoreOneTrustAutoBlock, }) {
23
24
  this._apiKey = apiKey;
24
25
  this._locale = locale;
25
26
  this._countryCode = countryCode;
26
27
  this._callbacks = callbacks;
28
+ this._ignoreOneTrustAutoBlock = ignoreOneTrustAutoBlock ?? false;
27
29
  this._theme = { ...defaultTheme, ...theme };
28
30
  this._userId = this._createOrRetrieveUserId(userId);
29
31
  this._eventsHandler = new EventsHandler(locale, userId);
@@ -146,6 +148,27 @@ export class Dialog {
146
148
  currency: params.currency,
147
149
  });
148
150
  }
151
+ /**
152
+ * Emit a batch of viewport impressions. The batching/dedup semantics live
153
+ * in `createSearchImpressionTracker` — wire its `emit` here, or call
154
+ * directly with `items: []` for a rendered no-results state.
155
+ */
156
+ trackViewSearchResults(params) {
157
+ this._eventsHandler.emitExternalEvent(DialogEvents.TRACK_VIEW_SEARCH_RESULTS, {
158
+ userId: this._userId,
159
+ ...params,
160
+ });
161
+ }
162
+ /**
163
+ * Emit on a result click — including auxclick / cmd+click, not only before
164
+ * a same-tab navigation. Force the clicked item's impression first.
165
+ */
166
+ trackSelectSearchResult(params) {
167
+ this._eventsHandler.emitExternalEvent(DialogEvents.TRACK_SELECT_SEARCH_RESULT, {
168
+ userId: this._userId,
169
+ ...params,
170
+ });
171
+ }
149
172
  _loadAssistant() {
150
173
  const localeInfo = getDetailedLocaleInfo(this._locale, this._countryCode);
151
174
  if (localeInfo === null) {
@@ -167,6 +190,12 @@ export class Dialog {
167
190
  script.async = true;
168
191
  script.type = "module";
169
192
  script.src = config.assistantUrl;
193
+ if (this._ignoreOneTrustAutoBlock) {
194
+ // OneTrust auto-blocking also intercepts dynamically injected scripts
195
+ // (by domain), so the merchant's data-ot-ignore on their own SDK tag
196
+ // cannot cover this one — it has to be set here.
197
+ script.setAttribute("data-ot-ignore", "");
198
+ }
170
199
  document.head.insertBefore(script, document.head.firstChild);
171
200
  }, 50);
172
201
  }
@@ -1 +1 @@
1
- {"version":3,"file":"EventsHandler.d.ts","sourceRoot":"","sources":["../src/EventsHandler.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,qBAAqB,EACrB,aAAa,EAEb,4BAA4B,EAC7B,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAuB,WAAW,EAAgB,MAAM,gBAAgB,CAAC;AAehF,qBAAa,aAAa;IACxB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,OAAO,CAAC,CAAS;IACzB,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,eAAe,CAAuB;gBAElC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM;IAKpC,iBAAiB,CACtB,IAAI,EAAE,WAAW,CAAC,MAAM,CAAC,EACzB,OAAO,CAAC,EAAE,WAAW,CAAC,SAAS,CAAC,GAC/B,IAAI;IAYA,mBAAmB,IAAI,IAAI;IAU3B,kBAAkB,IAAI,IAAI;IAIjC,OAAO,CAAC,sBAAsB;IAWvB,kBAAkB,CACvB,IAAI,EAAE,cAAc,CAAC,MAAM,CAAC,EAC5B,OAAO,CAAC,EAAE,4BAA4B,GACrC,IAAI;IAQA,gBAAgB,CACrB,QAAQ,EAAE,CACR,KAAK,EAAE,cAAc,CAAC,aAAa,GAAG,qBAAqB,CAAC,KACzD,IAAI,GACR,MAAM,IAAI;CA0Bd"}
1
+ {"version":3,"file":"EventsHandler.d.ts","sourceRoot":"","sources":["../src/EventsHandler.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,qBAAqB,EACrB,aAAa,EAEb,4BAA4B,EAC7B,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAuB,WAAW,EAAgB,MAAM,gBAAgB,CAAC;AAiBhF,qBAAa,aAAa;IACxB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,OAAO,CAAC,CAAS;IACzB,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,eAAe,CAAuB;gBAElC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM;IAKpC,iBAAiB,CACtB,IAAI,EAAE,WAAW,CAAC,MAAM,CAAC,EACzB,OAAO,CAAC,EAAE,WAAW,CAAC,SAAS,CAAC,GAC/B,IAAI;IAYA,mBAAmB,IAAI,IAAI;IAU3B,kBAAkB,IAAI,IAAI;IAIjC,OAAO,CAAC,sBAAsB;IAWvB,kBAAkB,CACvB,IAAI,EAAE,cAAc,CAAC,MAAM,CAAC,EAC5B,OAAO,CAAC,EAAE,4BAA4B,GACrC,IAAI;IAQA,gBAAgB,CACrB,QAAQ,EAAE,CACR,KAAK,EAAE,cAAc,CAAC,aAAa,GAAG,qBAAqB,CAAC,KACzD,IAAI,GACR,MAAM,IAAI;CA0Bd"}
@@ -6,6 +6,8 @@ import { DIALOG_CUSTOM_EVENT, DialogEvents } from "./types/events";
6
6
  const BUFFERED_EVENT_TYPES = new Set([
7
7
  DialogEvents.TRACK_ADD_TO_CART,
8
8
  DialogEvents.TRACK_SUBMIT_CHECKOUT,
9
+ DialogEvents.TRACK_VIEW_SEARCH_RESULTS,
10
+ DialogEvents.TRACK_SELECT_SEARCH_RESULT,
9
11
  ]);
10
12
  export class EventsHandler {
11
13
  _locale;
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=dialogLoadAssistant.spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dialogLoadAssistant.spec.d.ts","sourceRoot":"","sources":["../../src/__tests__/dialogLoadAssistant.spec.ts"],"names":[],"mappings":""}
@@ -0,0 +1,50 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { Dialog } from "../Dialog";
3
+ // The full Dialog constructor touches window/localStorage; these tests only
4
+ // exercise the assistant script injection, so the instance is built from the
5
+ // prototype with just the fields _loadAssistant reads.
6
+ const buildDialog = (ignoreOneTrustAutoBlock) => {
7
+ const dialog = Object.create(Dialog.prototype);
8
+ Object.assign(dialog, {
9
+ _apiKey: "api-key-1",
10
+ _userId: "user-1",
11
+ _locale: "fr-FR",
12
+ _ignoreOneTrustAutoBlock: ignoreOneTrustAutoBlock,
13
+ });
14
+ return dialog;
15
+ };
16
+ const stubDocument = () => {
17
+ const script = { setAttribute: vi.fn() };
18
+ const insertBefore = vi.fn();
19
+ vi.stubGlobal("document", {
20
+ createElement: (tag) => tag === "script" ? script : { dataset: {} },
21
+ body: { appendChild: vi.fn() },
22
+ head: { insertBefore, firstChild: null },
23
+ });
24
+ return { script, insertBefore };
25
+ };
26
+ const loadAssistant = (dialog) => {
27
+ dialog._loadAssistant();
28
+ vi.advanceTimersByTime(50);
29
+ };
30
+ afterEach(() => {
31
+ vi.unstubAllGlobals();
32
+ vi.useRealTimers();
33
+ vi.clearAllMocks();
34
+ });
35
+ describe("Dialog._loadAssistant OneTrust exemption", () => {
36
+ it("sets data-ot-ignore on the injected script when opted in", () => {
37
+ vi.useFakeTimers();
38
+ const { script, insertBefore } = stubDocument();
39
+ loadAssistant(buildDialog(true));
40
+ expect(script.setAttribute).toHaveBeenCalledWith("data-ot-ignore", "");
41
+ expect(insertBefore).toHaveBeenCalledWith(script, null);
42
+ });
43
+ it("leaves the injected script untouched by default", () => {
44
+ vi.useFakeTimers();
45
+ const { script, insertBefore } = stubDocument();
46
+ loadAssistant(buildDialog(false));
47
+ expect(script.setAttribute).not.toHaveBeenCalled();
48
+ expect(insertBefore).toHaveBeenCalledWith(script, null);
49
+ });
50
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=dialogSearchAnalytics.spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dialogSearchAnalytics.spec.d.ts","sourceRoot":"","sources":["../../src/__tests__/dialogSearchAnalytics.spec.ts"],"names":[],"mappings":""}
@@ -0,0 +1,90 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { Dialog } from "../Dialog";
3
+ import { EventsHandler } from "../EventsHandler";
4
+ import { DIALOG_CUSTOM_EVENT, DialogEvents } from "../types/events";
5
+ // The Dialog constructor loads the assistant into the DOM; these tests only
6
+ // exercise the search tracking methods, so the instance is built from the
7
+ // prototype with just the fields those methods read.
8
+ const buildDialog = () => {
9
+ const eventsHandler = new EventsHandler("fr", "user-1");
10
+ const emitExternalEvent = vi.fn();
11
+ eventsHandler.emitExternalEvent =
12
+ emitExternalEvent;
13
+ const dialog = Object.create(Dialog.prototype);
14
+ Object.assign(dialog, {
15
+ _eventsHandler: eventsHandler,
16
+ _userId: "user-1",
17
+ });
18
+ return { dialog, emitExternalEvent };
19
+ };
20
+ const envelope = {
21
+ query_id: "query-1",
22
+ surface: "search_page",
23
+ page: 2,
24
+ total_hits: 1247,
25
+ query_length: 14,
26
+ };
27
+ afterEach(() => {
28
+ vi.unstubAllGlobals();
29
+ vi.clearAllMocks();
30
+ });
31
+ describe("Dialog search analytics", () => {
32
+ it("emits TRACK_VIEW_SEARCH_RESULTS with the snake_case envelope, items and userId", () => {
33
+ const { dialog, emitExternalEvent } = buildDialog();
34
+ const items = [
35
+ { product_id: "product-1", position: 21 },
36
+ { product_id: "product-2", position: 22 },
37
+ ];
38
+ dialog.trackViewSearchResults({ ...envelope, items });
39
+ expect(emitExternalEvent).toHaveBeenCalledWith(DialogEvents.TRACK_VIEW_SEARCH_RESULTS, { userId: "user-1", ...envelope, items });
40
+ });
41
+ it("represents a rendered no-results state as empty items with total_hits 0", () => {
42
+ const { dialog, emitExternalEvent } = buildDialog();
43
+ dialog.trackViewSearchResults({ ...envelope, total_hits: 0, items: [] });
44
+ expect(emitExternalEvent).toHaveBeenCalledWith(DialogEvents.TRACK_VIEW_SEARCH_RESULTS, { userId: "user-1", ...envelope, total_hits: 0, items: [] });
45
+ });
46
+ it("emits TRACK_SELECT_SEARCH_RESULT with a single-item array", () => {
47
+ const { dialog, emitExternalEvent } = buildDialog();
48
+ const item = { product_id: "product-1", position: 22 };
49
+ dialog.trackSelectSearchResult({ ...envelope, items: [item] });
50
+ expect(emitExternalEvent).toHaveBeenCalledWith(DialogEvents.TRACK_SELECT_SEARCH_RESULT, { userId: "user-1", ...envelope, items: [item] });
51
+ });
52
+ });
53
+ describe("EventsHandler search event buffering", () => {
54
+ it("buffers both search events until consumer-ready, then flushes them intact and in order", () => {
55
+ const dispatchEvent = vi.fn();
56
+ vi.stubGlobal("window", { dispatchEvent });
57
+ const eventsHandler = new EventsHandler("fr", "user-1");
58
+ const item = { product_id: "product-1", position: 22 };
59
+ eventsHandler.emitExternalEvent(DialogEvents.TRACK_VIEW_SEARCH_RESULTS, {
60
+ ...envelope,
61
+ items: [],
62
+ });
63
+ eventsHandler.emitExternalEvent(DialogEvents.TRACK_SELECT_SEARCH_RESULT, {
64
+ ...envelope,
65
+ items: [item],
66
+ });
67
+ expect(dispatchEvent).not.toHaveBeenCalled();
68
+ eventsHandler.notifyConsumerReady();
69
+ const dispatched = dispatchEvent.mock.calls.map(([event]) => ({
70
+ name: event.type,
71
+ detail: event.detail,
72
+ }));
73
+ expect(dispatched).toEqual([
74
+ {
75
+ name: DIALOG_CUSTOM_EVENT,
76
+ detail: {
77
+ type: DialogEvents.TRACK_VIEW_SEARCH_RESULTS,
78
+ payload: { ...envelope, items: [] },
79
+ },
80
+ },
81
+ {
82
+ name: DIALOG_CUSTOM_EVENT,
83
+ detail: {
84
+ type: DialogEvents.TRACK_SELECT_SEARCH_RESULT,
85
+ payload: { ...envelope, items: [item] },
86
+ },
87
+ },
88
+ ]);
89
+ });
90
+ });
@@ -9,6 +9,9 @@ describe("public API types", () => {
9
9
  // Invalid callback values must be rejected.
10
10
  expectTypeOf().not.toMatchTypeOf();
11
11
  expectTypeOf().parameters.toMatchTypeOf();
12
+ expectTypeOf().toMatchTypeOf();
13
+ expectTypeOf().toMatchTypeOf();
14
+ expectTypeOf().toEqualTypeOf();
12
15
  expectTypeOf().returns.resolves.toEqualTypeOf();
13
16
  expectTypeOf().toEqualTypeOf();
14
17
  expectTypeOf().toEqualTypeOf();
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=searchController.spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"searchController.spec.d.ts","sourceRoot":"","sources":["../../src/__tests__/searchController.spec.ts"],"names":[],"mappings":""}
@@ -0,0 +1,254 @@
1
+ /* eslint max-lines: ["error", 400] */
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { createSearchController } from "../searchController";
4
+ import { SearchStatus } from "../types/searchController";
5
+ vi.mock("../utils/searchImpressions", () => ({
6
+ createSearchImpressionTracker: () => ({
7
+ setContext: vi.fn(),
8
+ observe: vi.fn(),
9
+ forceImpression: vi.fn(),
10
+ flush: vi.fn(),
11
+ disconnect: vi.fn(),
12
+ }),
13
+ }));
14
+ const DEBOUNCE_MS = 250;
15
+ const response = (overrides = {}) => ({
16
+ queryId: "qid-1",
17
+ hits: [
18
+ { id: "h1", score: 1, product: { id: "p1" } },
19
+ { id: "h2", score: 0.9, product: { id: "p2" } },
20
+ ],
21
+ nbHits: 2,
22
+ page: 0,
23
+ nbPages: 3,
24
+ hitsPerPage: 12,
25
+ processingTimeMs: 5,
26
+ query: "shoes",
27
+ ...overrides,
28
+ });
29
+ const deferred = () => {
30
+ let resolve;
31
+ let reject;
32
+ const promise = new Promise((res, rej) => {
33
+ resolve = res;
34
+ reject = rej;
35
+ });
36
+ return { promise, resolve, reject };
37
+ };
38
+ // Lets the controller's `await search(...)` continuation run.
39
+ const settle = async () => {
40
+ await Promise.resolve();
41
+ await Promise.resolve();
42
+ };
43
+ const search = vi.fn();
44
+ const trackViewSearchResults = vi.fn();
45
+ const trackSelectSearchResult = vi.fn();
46
+ const states = [];
47
+ const createController = () => {
48
+ const controller = createSearchController({
49
+ search,
50
+ analytics: {
51
+ surface: "search_page",
52
+ trackViewSearchResults,
53
+ trackSelectSearchResult,
54
+ },
55
+ });
56
+ controller.subscribe((state) => states.push(state));
57
+ return controller;
58
+ };
59
+ beforeEach(() => {
60
+ vi.useFakeTimers();
61
+ });
62
+ afterEach(() => {
63
+ vi.useRealTimers();
64
+ vi.clearAllMocks();
65
+ states.length = 0;
66
+ });
67
+ describe("createSearchController", () => {
68
+ it("starts idle", () => {
69
+ const controller = createController();
70
+ expect(controller.getState()).toMatchObject({
71
+ status: SearchStatus.IDLE,
72
+ query: "",
73
+ page: 0,
74
+ });
75
+ });
76
+ it("debounces rapid input into a single request for the last query", async () => {
77
+ const controller = createController();
78
+ search.mockResolvedValue(response({ query: "shoes" }));
79
+ controller.setQuery("sh");
80
+ vi.advanceTimersByTime(DEBOUNCE_MS - 1);
81
+ controller.setQuery("sho");
82
+ vi.advanceTimersByTime(DEBOUNCE_MS - 1);
83
+ controller.setQuery("shoes");
84
+ vi.advanceTimersByTime(DEBOUNCE_MS);
85
+ await settle();
86
+ expect(search).toHaveBeenCalledTimes(1);
87
+ expect(search.mock.calls[0][0]).toMatchObject({ query: "shoes", page: 0 });
88
+ expect(controller.getState().status).toBe(SearchStatus.SUCCESS);
89
+ });
90
+ it("submits immediately without waiting for the debounce", async () => {
91
+ const controller = createController();
92
+ search.mockResolvedValue(response());
93
+ controller.setQuery("sho");
94
+ controller.submit("shoes");
95
+ await settle();
96
+ expect(search).toHaveBeenCalledTimes(1);
97
+ expect(search.mock.calls[0][0]).toMatchObject({ query: "shoes" });
98
+ expect(controller.getState().status).toBe(SearchStatus.SUCCESS);
99
+ });
100
+ it("aborts the previous in-flight request when a new one starts", async () => {
101
+ const controller = createController();
102
+ const first = deferred();
103
+ const second = deferred();
104
+ search
105
+ .mockReturnValueOnce(first.promise)
106
+ .mockReturnValueOnce(second.promise);
107
+ controller.submit("shoes");
108
+ const firstSignal = search.mock.calls[0][1]?.signal;
109
+ controller.submit("boots");
110
+ expect(firstSignal?.aborted).toBe(true);
111
+ second.resolve(response({ query: "boots", queryId: "qid-2" }));
112
+ await settle();
113
+ expect(controller.getState().response?.query).toBe("boots");
114
+ });
115
+ it("never lets a late stale response replace newer results, even without transport cancellation", async () => {
116
+ const controller = createController();
117
+ const stale = deferred();
118
+ const fresh = deferred();
119
+ search
120
+ .mockReturnValueOnce(stale.promise)
121
+ .mockReturnValueOnce(fresh.promise);
122
+ controller.submit("shoes");
123
+ controller.submit("boots");
124
+ fresh.resolve(response({ query: "boots", queryId: "qid-2" }));
125
+ await settle();
126
+ // The stale transport ignored the abort and answers after the fresh one.
127
+ stale.resolve(response({ query: "shoes", queryId: "qid-1" }));
128
+ await settle();
129
+ expect(controller.getState().status).toBe(SearchStatus.SUCCESS);
130
+ expect(controller.getState().response?.query).toBe("boots");
131
+ });
132
+ it("swallows the AbortError of a superseded request", async () => {
133
+ const controller = createController();
134
+ const first = deferred();
135
+ search
136
+ .mockReturnValueOnce(first.promise)
137
+ .mockResolvedValueOnce(response({ query: "boots", queryId: "qid-2" }));
138
+ controller.submit("shoes");
139
+ controller.submit("boots");
140
+ first.reject(new DOMException("The operation was aborted.", "AbortError"));
141
+ await settle();
142
+ expect(controller.getState().status).toBe(SearchStatus.SUCCESS);
143
+ expect(controller.getState().error).toBeUndefined();
144
+ });
145
+ it("surfaces errors and retries the same query and page", async () => {
146
+ const controller = createController();
147
+ const failure = new TypeError("Failed to fetch");
148
+ search
149
+ .mockResolvedValueOnce(response())
150
+ .mockRejectedValueOnce(failure)
151
+ .mockResolvedValueOnce(response({ page: 2 }));
152
+ controller.submit("shoes");
153
+ await settle();
154
+ controller.setPage(2);
155
+ await settle();
156
+ expect(controller.getState()).toMatchObject({
157
+ status: SearchStatus.ERROR,
158
+ error: failure,
159
+ response: undefined,
160
+ });
161
+ controller.retry();
162
+ await settle();
163
+ expect(search).toHaveBeenCalledTimes(3);
164
+ expect(search.mock.calls[2][0]).toMatchObject({ query: "shoes", page: 2 });
165
+ expect(controller.getState().status).toBe(SearchStatus.SUCCESS);
166
+ });
167
+ it("ignores retry outside the error state", async () => {
168
+ const controller = createController();
169
+ search.mockResolvedValue(response());
170
+ controller.submit("shoes");
171
+ await settle();
172
+ controller.retry();
173
+ await settle();
174
+ expect(search).toHaveBeenCalledTimes(1);
175
+ });
176
+ it("reports the empty status on zero hits", async () => {
177
+ const controller = createController();
178
+ search.mockResolvedValue(response({ hits: [], nbHits: 0, nbPages: 0 }));
179
+ controller.submit("shoes");
180
+ await settle();
181
+ expect(controller.getState().status).toBe(SearchStatus.EMPTY);
182
+ });
183
+ it("resets to idle and invalidates in-flight work when the query gets too short", async () => {
184
+ const controller = createController();
185
+ const inFlight = deferred();
186
+ search.mockReturnValueOnce(inFlight.promise);
187
+ controller.submit("shoes");
188
+ controller.setQuery("s");
189
+ inFlight.resolve(response());
190
+ await settle();
191
+ expect(search).toHaveBeenCalledTimes(1);
192
+ expect(controller.getState().status).toBe(SearchStatus.IDLE);
193
+ });
194
+ it("resets pagination when a new query is submitted", async () => {
195
+ const controller = createController();
196
+ search.mockResolvedValue(response());
197
+ controller.submit("shoes");
198
+ await settle();
199
+ controller.setPage(2);
200
+ await settle();
201
+ controller.submit("boots");
202
+ await settle();
203
+ expect(search.mock.calls[2][0]).toMatchObject({ query: "boots", page: 0 });
204
+ });
205
+ it("flushes a pending debounced query instead of paginating stale results", async () => {
206
+ const controller = createController();
207
+ search.mockResolvedValue(response());
208
+ controller.submit("shoes");
209
+ await settle();
210
+ controller.setQuery("boots");
211
+ controller.setPage(1);
212
+ await settle();
213
+ expect(search).toHaveBeenCalledTimes(2);
214
+ expect(search.mock.calls[1][0]).toMatchObject({ query: "boots", page: 0 });
215
+ });
216
+ it("ignores pagination without a committed query", () => {
217
+ const controller = createController();
218
+ controller.setPage(1);
219
+ expect(search).not.toHaveBeenCalled();
220
+ });
221
+ it("notifies subscribers and stops after unsubscribe", async () => {
222
+ const controller = createController();
223
+ search.mockResolvedValue(response());
224
+ const listener = vi.fn();
225
+ const unsubscribe = controller.subscribe(listener);
226
+ controller.submit("shoes");
227
+ await settle();
228
+ const notified = listener.mock.calls.length;
229
+ unsubscribe();
230
+ controller.submit("boots");
231
+ await settle();
232
+ expect(notified).toBeGreaterThan(0);
233
+ expect(listener).toHaveBeenCalledTimes(notified);
234
+ });
235
+ it("stops all work after dispose", async () => {
236
+ const controller = createController();
237
+ const inFlight = deferred();
238
+ search.mockReturnValueOnce(inFlight.promise);
239
+ controller.submit("shoes");
240
+ const signal = search.mock.calls[0][1]?.signal;
241
+ controller.dispose();
242
+ expect(signal?.aborted).toBe(true);
243
+ inFlight.resolve(response());
244
+ await settle();
245
+ controller.setQuery("boots");
246
+ controller.submit("boots");
247
+ controller.setPage(1);
248
+ controller.retry();
249
+ vi.advanceTimersByTime(DEBOUNCE_MS);
250
+ await settle();
251
+ expect(search).toHaveBeenCalledTimes(1);
252
+ expect(controller.getState().status).toBe(SearchStatus.LOADING);
253
+ });
254
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=searchControllerAttribution.spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"searchControllerAttribution.spec.d.ts","sourceRoot":"","sources":["../../src/__tests__/searchControllerAttribution.spec.ts"],"names":[],"mappings":""}