@askdialog/dialog-sdk 2.9.1 → 2.11.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 (40) hide show
  1. package/README.md +44 -10
  2. package/dist/Dialog.d.ts +21 -6
  3. package/dist/Dialog.d.ts.map +1 -1
  4. package/dist/Dialog.js +72 -12
  5. package/dist/__tests__/dialogCurrentProduct.spec.d.ts +2 -0
  6. package/dist/__tests__/dialogCurrentProduct.spec.d.ts.map +1 -0
  7. package/dist/__tests__/dialogCurrentProduct.spec.js +134 -0
  8. package/dist/__tests__/dialogSearchAnalytics.spec.js +1 -0
  9. package/dist/__tests__/publicTypes.spec.js +2 -1
  10. package/dist/__tests__/searchController.spec.js +126 -35
  11. package/dist/__tests__/searchControllerAttribution.spec.js +30 -26
  12. package/dist/__tests__/searchImpressions.spec.js +3 -20
  13. package/dist/__tests__/searchService.spec.js +65 -60
  14. package/dist/config/config.development.js +1 -1
  15. package/dist/config/config.production.js +1 -1
  16. package/dist/config/index.js +1 -1
  17. package/dist/searchController.d.ts +1 -1
  18. package/dist/searchController.d.ts.map +1 -1
  19. package/dist/searchController.js +34 -24
  20. package/dist/services/search.d.ts +3 -2
  21. package/dist/services/search.d.ts.map +1 -1
  22. package/dist/services/search.js +4 -9
  23. package/dist/types/constructor.d.ts +15 -0
  24. package/dist/types/constructor.d.ts.map +1 -1
  25. package/dist/types/search.d.ts +24 -22
  26. package/dist/types/search.d.ts.map +1 -1
  27. package/dist/types/search.js +7 -4
  28. package/dist/types/searchAnalytics.d.ts +2 -0
  29. package/dist/types/searchAnalytics.d.ts.map +1 -1
  30. package/dist/types/searchController.d.ts +13 -5
  31. package/dist/types/searchController.d.ts.map +1 -1
  32. package/dist/utils/searchControllerAnalytics.d.ts +4 -4
  33. package/dist/utils/searchControllerAnalytics.d.ts.map +1 -1
  34. package/dist/utils/searchControllerAnalytics.js +14 -16
  35. package/dist/utils/searchImpressions.d.ts.map +1 -1
  36. package/dist/utils/searchImpressions.js +1 -6
  37. package/dist/utils/searchSections.d.ts +5 -0
  38. package/dist/utils/searchSections.d.ts.map +1 -0
  39. package/dist/utils/searchSections.js +21 -0
  40. package/package.json +1 -1
package/README.md CHANGED
@@ -91,6 +91,29 @@ 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
+ ### Declaring the current product page
95
+
96
+ The assistant answers product questions with the product context of the page the shopper is on. Entry points that carry a product id (like `sendProductMessage`) set it themselves, but entry points that don't — the floating bookmark, the resume-conversation surface, free-text questions — need the SDK to know what PDP the shopper is looking at, especially after navigating from one product page to another.
97
+
98
+ Declare it at construction on product pages:
99
+
100
+ ```ts
101
+ new Dialog({
102
+ apiKey: 'YOUR_API_KEY',
103
+ locale: 'fr',
104
+ product: { id: 'PRODUCT_ID', variantId: 'VARIANT_ID' }, // variantId optional
105
+ });
106
+ ```
107
+
108
+ On single-page storefronts, update it on client-side navigation:
109
+
110
+ ```ts
111
+ client.setCurrentProduct('PRODUCT_ID'); // arrived on a PDP
112
+ client.clearCurrentProduct(); // left for a non-product page
113
+ ```
114
+
115
+ Ids must match the product ids of your Dialog product feed. Without a declared product, questions asked from the bookmark after a page change reach the assistant without product context and are answered generically.
116
+
94
117
  ### OneTrust auto-blocking
95
118
 
96
119
  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:
@@ -196,21 +219,28 @@ Example of expected result:
196
219
 
197
220
  - Search products
198
221
 
199
- `client.search()` performs a typed product search through Dialog's public API, with no framework and no commerce callbacks required.
222
+ `client.search()` performs a typed, Algolia-shaped search through Dialog's public API, with no framework and no commerce callbacks required. Each entry targets an index named `<index>_<locale>` (index ∈ `products | collections | articles | pages`, locale ISO 639-1) — build the name with `searchIndexName(index, locale)`, which reduces any BCP-47 tag to its bare language.
200
223
 
201
224
  ```typescript
202
- import { Dialog, DialogSearchError } from '@askdialog/dialog-sdk';
225
+ import { Dialog, DialogSearchError, searchIndexName } from '@askdialog/dialog-sdk';
203
226
  import type { SearchResponse } from '@askdialog/dialog-sdk';
204
227
 
205
228
  const client = new Dialog({ apiKey: 'YOUR_API_KEY', locale: 'fr' });
206
229
 
207
230
  const response: SearchResponse = await client.search({
208
- query: 'shampoo',
209
- page: 0, // optional, zero-indexed (default 0)
210
- hitsPerPage: 20, // optional, 1-100 (default 20)
211
- queryId: previousResponse?.queryId, // optional, resend while the query is unchanged
231
+ requests: [
232
+ {
233
+ indexName: searchIndexName('products', client.locale), // "products_fr"
234
+ query: 'shampoo',
235
+ page: 0, // optional, zero-indexed (default 0)
236
+ hitsPerPage: 20, // optional, 1-100 (default 20)
237
+ },
238
+ ],
212
239
  });
213
- // response.hits[n].product: { id, title?, url?, imageUrl?, priceRange?, inStock? }
240
+ // response.results[n]: { index, hits, nbHits, page, nbPages, hitsPerPage,
241
+ // processingTimeMS, query, queryID }
242
+ // response.results[n].hits[m]: { objectID, title?, url?, handle?, imageUrl?,
243
+ // priceRange? }
214
244
  ```
215
245
 
216
246
  With the IIFE bundle the results are plain runtime JSON (same shape, no types):
@@ -219,17 +249,19 @@ With the IIFE bundle the results are plain runtime JSON (same shape, no types):
219
249
  <script src="https://d2m6yt8rnm4dos.cloudfront.net/dialog-sdk.X.Y.Z.min.js"></script>
220
250
  <script>
221
251
  const client = new window.DialogSDK.Dialog({ apiKey: 'YOUR_API_KEY', locale: 'fr' });
222
- client.search({ query: 'shampoo' }).then((response) => console.log(response.hits));
252
+ client
253
+ .search({ requests: [{ indexName: 'products_fr', query: 'shampoo' }] })
254
+ .then((response) => console.log(response.results[0].hits));
223
255
  </script>
224
256
  ```
225
257
 
226
- 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.
258
+ A non-2xx answer rejects with `DialogSearchError` — stable `name`, HTTP `status` and `message` (e.g. `404 Index products_xx does not exist`, `400 Unknown parameter: foo`). Aborting rejects with the native `AbortError`, and network failures keep their native errors.
227
259
 
228
260
  `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.
229
261
 
230
262
  - Search controller
231
263
 
232
- `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.
264
+ `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 attribution events (`view_search_results` viewport impressions, `select_search_result` clicks). It searches the products index (`products_<locale>`) and exposes the products result entry as `state.response`; optional `sections` add other indices (`collections`, …) to the same request, exposed under `state.sections`. It has no framework or rendering dependency: raw JavaScript, React, Vue and Shopify integrations are rendering-and-routing adapters around it.
233
265
 
234
266
  ```typescript
235
267
  import { createSearchController, Dialog, SearchStatus } from '@askdialog/dialog-sdk';
@@ -238,6 +270,7 @@ const client = new Dialog({ apiKey: 'YOUR_API_KEY', locale: 'fr' });
238
270
 
239
271
  const controller = createSearchController({
240
272
  search: (request, options) => client.search(request, options),
273
+ locale: client.locale, // names the searched index ("products_fr")
241
274
  analytics: {
242
275
  surface: 'search_page', // where results are displayed
243
276
  trackViewSearchResults: (params) => client.trackViewSearchResults(params),
@@ -246,6 +279,7 @@ const controller = createSearchController({
246
279
  navigate: (url) => router.push(url), // optional platform routing adapter
247
280
  debounceMs: 250, // optional (default 250)
248
281
  hitsPerPage: 12, // optional (default 12)
282
+ sections: [{ index: 'collections', hitsPerPage: 5 }], // optional, first page only; a function is resolved per request
249
283
  });
250
284
 
251
285
  const unsubscribe = controller.subscribe((state) => {
package/dist/Dialog.d.ts CHANGED
@@ -19,7 +19,8 @@ export declare class Dialog {
19
19
  private _eventsHandler;
20
20
  private _ignoreOneTrustAutoBlock;
21
21
  private _disableAddToCart;
22
- constructor({ apiKey, locale, countryCode, callbacks, theme, userId, ignoreOneTrustAutoBlock, disableAddToCart, }: DialogConstructor);
22
+ private _currentProduct?;
23
+ constructor({ apiKey, locale, countryCode, callbacks, theme, userId, ignoreOneTrustAutoBlock, disableAddToCart, product, }: DialogConstructor);
23
24
  get apiKey(): string;
24
25
  get theme(): Theme;
25
26
  get userId(): string;
@@ -29,15 +30,29 @@ export declare class Dialog {
29
30
  private _createOrRetrieveUserId;
30
31
  getSuggestions(productId: string): Promise<Suggestion>;
31
32
  /**
32
- * Typed product search through the Nest public endpoint. Stateless: one
33
- * request per call, cancellation belongs to the caller via
34
- * `options.signal` (previous searches are never cancelled automatically).
35
- * Rejects with `DialogSearchError` on a non-2xx answer, and with the
36
- * native AbortError / network error otherwise.
33
+ * Typed multi-index search through the Nest public endpoint (Algolia-shaped,
34
+ * DAT-412). The locale travels in each entry's `indexName` (`products_fr`) —
35
+ * build it with `searchIndexName(index, locale)`. Stateless: one request per
36
+ * call, cancellation belongs to the caller via `options.signal` (previous
37
+ * searches are never cancelled automatically). Rejects with
38
+ * `DialogSearchError` on a non-2xx answer, and with the native AbortError /
39
+ * network error otherwise.
37
40
  */
38
41
  search(request: SearchRequest, options?: SearchOptions): Promise<SearchResponse>;
39
42
  openAssistant(params: OpenAssistantPayload): void;
40
43
  closeAssistant(): void;
44
+ /**
45
+ * Declare the product of the current page. The assistant reads it as the
46
+ * conversation's product context whenever a question carries no product of
47
+ * its own (floating bookmark, resume surface, free-text input). Call it on
48
+ * every PDP navigation on single-page storefronts; ids must match the
49
+ * Dialog product feed.
50
+ */
51
+ setCurrentProduct(productId: string, variantId?: string): void;
52
+ private static _isValidProductId;
53
+ /** Declare that the current page is not a product page. */
54
+ clearCurrentProduct(): void;
55
+ private _applyCurrentProductDataset;
41
56
  sendProductMessage(params: ProductQuestionPayload): void;
42
57
  sendGenericMessage(params: GenericQuestionPayload): void;
43
58
  onAssistantEvent(listener: (event: AssistantEvent) => void): void;
@@ -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,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;IAC1C,OAAO,CAAC,iBAAiB,CAAU;gBAEvB,EACV,MAAM,EACN,MAAM,EACN,WAAW,EACX,SAAS,EACT,KAAK,EACL,MAAM,EACN,uBAAuB,EACvB,gBAAgB,GACjB,EAAE,iBAAiB;IAcpB,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;IAanB,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;IAmB5D,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;CAwCvB"}
1
+ {"version":3,"file":"Dialog.d.ts","sourceRoot":"","sources":["../src/Dialog.ts"],"names":[],"mappings":"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"}
package/dist/Dialog.js CHANGED
@@ -7,7 +7,7 @@ import { ANONYMOUS_CUSTOMER_ID, CUSTOMER_ID } from "./constants/user";
7
7
  import { DialogEvents, } from "./types/events";
8
8
  import { EventsHandler } from "./EventsHandler";
9
9
  import { loadSuggestions } from "./services/suggestions";
10
- import { searchProducts } from "./services/search";
10
+ import { searchLexical } from "./services/search";
11
11
  import { config } from "./config";
12
12
  import { exposeSdkOnWindowDialog } from "./windowAudit";
13
13
  export class Dialog {
@@ -21,13 +21,21 @@ export class Dialog {
21
21
  _eventsHandler;
22
22
  _ignoreOneTrustAutoBlock;
23
23
  _disableAddToCart;
24
- constructor({ apiKey, locale, countryCode, callbacks, theme, userId, ignoreOneTrustAutoBlock, disableAddToCart, }) {
24
+ _currentProduct;
25
+ constructor({ apiKey, locale, countryCode, callbacks, theme, userId, ignoreOneTrustAutoBlock, disableAddToCart, product, }) {
25
26
  this._apiKey = apiKey;
26
27
  this._locale = locale;
27
28
  this._countryCode = countryCode;
28
29
  this._callbacks = callbacks;
29
30
  this._ignoreOneTrustAutoBlock = ignoreOneTrustAutoBlock ?? false;
30
31
  this._disableAddToCart = disableAddToCart ?? false;
32
+ this._currentProduct =
33
+ product !== undefined && Dialog._isValidProductId(product.id)
34
+ ? product
35
+ : undefined;
36
+ if (product !== undefined && this._currentProduct === undefined) {
37
+ console.error("Dialog: `product.id` must be a non-empty string; ignoring the constructor option.");
38
+ }
31
39
  this._theme = { ...defaultTheme, ...theme };
32
40
  this._userId = this._createOrRetrieveUserId(userId);
33
41
  this._eventsHandler = new EventsHandler(locale, userId);
@@ -69,18 +77,16 @@ export class Dialog {
69
77
  return loadSuggestions(this._apiKey, this._locale, productId);
70
78
  }
71
79
  /**
72
- * Typed product search through the Nest public endpoint. Stateless: one
73
- * request per call, cancellation belongs to the caller via
74
- * `options.signal` (previous searches are never cancelled automatically).
75
- * Rejects with `DialogSearchError` on a non-2xx answer, and with the
76
- * native AbortError / network error otherwise.
80
+ * Typed multi-index search through the Nest public endpoint (Algolia-shaped,
81
+ * DAT-412). The locale travels in each entry's `indexName` (`products_fr`) —
82
+ * build it with `searchIndexName(index, locale)`. Stateless: one request per
83
+ * call, cancellation belongs to the caller via `options.signal` (previous
84
+ * searches are never cancelled automatically). Rejects with
85
+ * `DialogSearchError` on a non-2xx answer, and with the native AbortError /
86
+ * network error otherwise.
77
87
  */
78
88
  search(request, options) {
79
- return searchProducts(this._apiKey, {
80
- ...request,
81
- locale: request.locale ?? this._locale,
82
- countryCode: request.countryCode ?? this._countryCode,
83
- }, options);
89
+ return searchLexical(this._apiKey, request, options);
84
90
  }
85
91
  // TODO: Not yet implemented on assistant
86
92
  openAssistant(params) {
@@ -90,6 +96,57 @@ export class Dialog {
90
96
  closeAssistant() {
91
97
  this._eventsHandler.emitExternalEvent(DialogEvents.CLOSE_ASSISTANT);
92
98
  }
99
+ /**
100
+ * Declare the product of the current page. The assistant reads it as the
101
+ * conversation's product context whenever a question carries no product of
102
+ * its own (floating bookmark, resume surface, free-text input). Call it on
103
+ * every PDP navigation on single-page storefronts; ids must match the
104
+ * Dialog product feed.
105
+ */
106
+ setCurrentProduct(productId, variantId) {
107
+ if (!Dialog._isValidProductId(productId)) {
108
+ console.error("Dialog: setCurrentProduct expects a non-empty string productId; ignoring the call. Use clearCurrentProduct() to declare a non-product page.");
109
+ return;
110
+ }
111
+ this._currentProduct = { id: productId, variantId };
112
+ this._applyCurrentProductDataset();
113
+ }
114
+ static _isValidProductId(productId) {
115
+ return typeof productId === "string" && productId.trim() !== "";
116
+ }
117
+ /** Declare that the current page is not a product page. */
118
+ clearCurrentProduct() {
119
+ this._currentProduct = undefined;
120
+ this._applyCurrentProductDataset();
121
+ }
122
+ // The dataset on the assistant mount node is the SDK⇄runtime contract for
123
+ // the page product: the runtime observes data-product-id / data-variant-id
124
+ // and needs no event plumbing, so SDK and runtime versions can drift.
125
+ _applyCurrentProductDataset() {
126
+ // Resolved via getElementById on purpose, NOT an instance reference: the
127
+ // assistant runtime reads the dataset the same way, so on a page where
128
+ // another integration already rendered #dialog-shopify-ai (duplicate-id
129
+ // case) writer and reader must land on the same — first — node.
130
+ const mountNode = document.getElementById("dialog-shopify-ai");
131
+ if (mountNode === null) {
132
+ // Assistant mount node absent (locale error at load, or the host page
133
+ // removed it) — the declaration is kept but cannot reach the runtime.
134
+ console.warn("Dialog: assistant mount node not found; the current product declaration has no effect.");
135
+ return;
136
+ }
137
+ if (this._currentProduct === undefined) {
138
+ delete mountNode.dataset.productId;
139
+ delete mountNode.dataset.variantId;
140
+ return;
141
+ }
142
+ mountNode.dataset.productId = this._currentProduct.id;
143
+ if (this._currentProduct.variantId === undefined) {
144
+ delete mountNode.dataset.variantId;
145
+ }
146
+ else {
147
+ mountNode.dataset.variantId = this._currentProduct.variantId;
148
+ }
149
+ }
93
150
  sendProductMessage(params) {
94
151
  this._eventsHandler.emitExternalEvent(DialogEvents.SEND_MESSAGE, params);
95
152
  }
@@ -199,6 +256,9 @@ export class Dialog {
199
256
  div.dataset.disableAddToCart = "true";
200
257
  }
201
258
  document.body.appendChild(div);
259
+ if (this._currentProduct !== undefined) {
260
+ this._applyCurrentProductDataset();
261
+ }
202
262
  setTimeout(() => {
203
263
  const script = document.createElement("script");
204
264
  if (this._ignoreOneTrustAutoBlock) {
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=dialogCurrentProduct.spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dialogCurrentProduct.spec.d.ts","sourceRoot":"","sources":["../../src/__tests__/dialogCurrentProduct.spec.ts"],"names":[],"mappings":""}
@@ -0,0 +1,134 @@
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 current-product dataset contract, so the instance is built
5
+ // from the prototype with just the fields the tested paths read.
6
+ const buildDialog = (currentProduct) => {
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: false,
13
+ _disableAddToCart: false,
14
+ _currentProduct: currentProduct,
15
+ });
16
+ return dialog;
17
+ };
18
+ const stubDocument = (options = { mounted: true }) => {
19
+ const script = { setAttribute: vi.fn() };
20
+ const div = { dataset: {} };
21
+ // _loadAssistant appends the mount node; getElementById reflects that.
22
+ let mounted = options.mounted;
23
+ vi.stubGlobal("document", {
24
+ createElement: (tag) => (tag === "script" ? script : div),
25
+ getElementById: (id) => mounted && id === "dialog-shopify-ai" ? div : null,
26
+ body: {
27
+ appendChild: vi.fn(() => {
28
+ mounted = true;
29
+ }),
30
+ },
31
+ head: { insertBefore: vi.fn(), firstChild: null },
32
+ });
33
+ return { div };
34
+ };
35
+ // Fake timers keep the deferred script injection from firing after the
36
+ // document stub is torn down.
37
+ const loadAssistant = (dialog) => {
38
+ vi.useFakeTimers();
39
+ dialog._loadAssistant();
40
+ };
41
+ afterEach(() => {
42
+ vi.unstubAllGlobals();
43
+ vi.useRealTimers();
44
+ vi.clearAllMocks();
45
+ });
46
+ describe("Dialog current product — constructor option", () => {
47
+ it("writes the product dataset on the mount node at load", () => {
48
+ const { div } = stubDocument();
49
+ loadAssistant(buildDialog({ id: "6980", variantId: "42" }));
50
+ expect(div.dataset.productId).toBe("6980");
51
+ expect(div.dataset.variantId).toBe("42");
52
+ });
53
+ it("omits the variant key when no variant is declared", () => {
54
+ const { div } = stubDocument();
55
+ loadAssistant(buildDialog({ id: "6980" }));
56
+ expect(div.dataset.productId).toBe("6980");
57
+ expect(div.dataset.variantId).toBeUndefined();
58
+ });
59
+ it("leaves the mount node free of product keys by default", () => {
60
+ const { div } = stubDocument();
61
+ loadAssistant(buildDialog());
62
+ expect(div.dataset.productId).toBeUndefined();
63
+ expect(div.dataset.variantId).toBeUndefined();
64
+ });
65
+ });
66
+ describe("Dialog current product — constructor validation", () => {
67
+ it("rejects an invalid constructor product id and keeps the mount node clean", () => {
68
+ const { div } = stubDocument();
69
+ const consoleError = vi
70
+ .spyOn(console, "error")
71
+ .mockImplementation(() => undefined);
72
+ const dialog = buildDialog();
73
+ Object.assign(dialog, { _currentProduct: undefined });
74
+ // Mirror the constructor guard through the public setter contract.
75
+ dialog.setCurrentProduct("");
76
+ loadAssistant(dialog);
77
+ expect(div.dataset.productId).toBeUndefined();
78
+ expect(consoleError).toHaveBeenCalled();
79
+ consoleError.mockRestore();
80
+ });
81
+ });
82
+ describe("Dialog.setCurrentProduct / clearCurrentProduct", () => {
83
+ it("writes the dataset on the existing mount node", () => {
84
+ const { div } = stubDocument();
85
+ const dialog = buildDialog();
86
+ dialog.setCurrentProduct("6980", "42");
87
+ expect(div.dataset.productId).toBe("6980");
88
+ expect(div.dataset.variantId).toBe("42");
89
+ });
90
+ it("drops a stale variant when the next product has none", () => {
91
+ const { div } = stubDocument();
92
+ const dialog = buildDialog();
93
+ dialog.setCurrentProduct("6980", "42");
94
+ dialog.setCurrentProduct("7001");
95
+ expect(div.dataset.productId).toBe("7001");
96
+ expect(div.dataset.variantId).toBeUndefined();
97
+ });
98
+ it("removes both keys on clearCurrentProduct", () => {
99
+ const { div } = stubDocument();
100
+ const dialog = buildDialog();
101
+ dialog.setCurrentProduct("6980", "42");
102
+ dialog.clearCurrentProduct();
103
+ expect(div.dataset.productId).toBeUndefined();
104
+ expect(div.dataset.variantId).toBeUndefined();
105
+ });
106
+ it("rejects an empty productId without touching the dataset", () => {
107
+ const { div } = stubDocument();
108
+ const dialog = buildDialog();
109
+ const consoleError = vi
110
+ .spyOn(console, "error")
111
+ .mockImplementation(() => undefined);
112
+ dialog.setCurrentProduct(" ");
113
+ expect(div.dataset.productId).toBeUndefined();
114
+ expect(consoleError).toHaveBeenCalled();
115
+ consoleError.mockRestore();
116
+ });
117
+ it("warns when the mount node is missing", () => {
118
+ stubDocument({ mounted: false });
119
+ const dialog = buildDialog();
120
+ const consoleWarn = vi
121
+ .spyOn(console, "warn")
122
+ .mockImplementation(() => undefined);
123
+ dialog.setCurrentProduct("6980");
124
+ expect(consoleWarn).toHaveBeenCalled();
125
+ consoleWarn.mockRestore();
126
+ });
127
+ it("tolerates a missing mount node and applies the value at load", () => {
128
+ const { div } = stubDocument({ mounted: false });
129
+ const dialog = buildDialog();
130
+ expect(() => dialog.setCurrentProduct("6980")).not.toThrow();
131
+ loadAssistant(dialog);
132
+ expect(div.dataset.productId).toBe("6980");
133
+ });
134
+ });
@@ -19,6 +19,7 @@ const buildDialog = () => {
19
19
  };
20
20
  const envelope = {
21
21
  query_id: "query-1",
22
+ index: "products_fr",
22
23
  surface: "search_page",
23
24
  search_type: "lexical",
24
25
  page: 2,
@@ -11,10 +11,11 @@ describe("public API types", () => {
11
11
  expectTypeOf().parameters.toMatchTypeOf();
12
12
  expectTypeOf().toMatchTypeOf();
13
13
  expectTypeOf().toMatchTypeOf();
14
- expectTypeOf().toEqualTypeOf();
15
14
  expectTypeOf().returns.resolves.toEqualTypeOf();
16
15
  expectTypeOf().toEqualTypeOf();
17
16
  expectTypeOf().toEqualTypeOf();
17
+ expectTypeOf().toEqualTypeOf();
18
+ expectTypeOf().toEqualTypeOf();
18
19
  expectTypeOf().toMatchTypeOf();
19
20
  const error = new DialogSearchError({ status: 404, message: "not found" });
20
21
  expectTypeOf(error.status).toEqualTypeOf();