@askdialog/dialog-sdk 2.9.1 → 2.10.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 (37) hide show
  1. package/README.md +43 -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 +46 -34
  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 +18 -21
  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 +7 -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/package.json +1 -1
@@ -12,22 +12,23 @@ vi.mock("../utils/searchImpressions", () => ({
12
12
  createSearchImpressionTracker: () => tracker,
13
13
  }));
14
14
  const response = (overrides = {}) => ({
15
- queryId: "qid-1",
16
- hits: [
15
+ results: [
17
16
  {
18
- id: "h1",
19
- score: 1,
20
- product: { id: "p1", url: "https://shop.example/p1" },
17
+ index: "products_fr",
18
+ hits: [
19
+ { objectID: "p1", url: "https://shop.example/p1" },
20
+ { objectID: "p2" },
21
+ ],
22
+ nbHits: 30,
23
+ page: 0,
24
+ nbPages: 3,
25
+ hitsPerPage: 12,
26
+ processingTimeMS: 5,
27
+ query: "shoes",
28
+ queryID: "qid-1",
29
+ ...overrides,
21
30
  },
22
- { id: "h2", score: 0.9, product: { id: "p2" } },
23
31
  ],
24
- nbHits: 30,
25
- page: 0,
26
- nbPages: 3,
27
- hitsPerPage: 12,
28
- processingTimeMs: 5,
29
- query: "shoes",
30
- ...overrides,
31
32
  });
32
33
  const search = vi.fn();
33
34
  const trackViewSearchResults = vi.fn();
@@ -41,6 +42,7 @@ const createController = () => createSearchController({
41
42
  trackSelectSearchResult,
42
43
  },
43
44
  navigate,
45
+ locale: "fr",
44
46
  });
45
47
  beforeEach(() => {
46
48
  vi.useFakeTimers();
@@ -54,27 +56,26 @@ const settle = async () => {
54
56
  await Promise.resolve();
55
57
  };
56
58
  describe("search controller attribution", () => {
57
- it("omits queryId for a new query and resends it while the query is unchanged", async () => {
59
+ it("never resends a query id: each request is its own query", async () => {
58
60
  const controller = createController();
59
61
  search.mockResolvedValue(response());
60
62
  controller.submit("shoes");
61
63
  await settle();
62
64
  controller.setPage(1);
63
65
  await settle();
64
- expect(search.mock.calls[0][0].queryId).toBeUndefined();
65
- expect(search.mock.calls[1][0]).toMatchObject({
66
- query: "shoes",
67
- page: 1,
68
- queryId: "qid-1",
69
- });
66
+ for (const [request] of search.mock.calls) {
67
+ expect(request.requests[0]).not.toHaveProperty("queryId");
68
+ expect(request.requests[0]).not.toHaveProperty("queryID");
69
+ }
70
70
  });
71
- it("declares the rendered response's envelope to the impression tracker", async () => {
71
+ it("declares the rendered result's envelope to the impression tracker", async () => {
72
72
  const controller = createController();
73
73
  search.mockResolvedValue(response({ page: 1, query: "shoes" }));
74
74
  controller.submit("shoes");
75
75
  await settle();
76
76
  expect(tracker.setContext).toHaveBeenCalledWith({
77
77
  query_id: "qid-1",
78
+ index: "products_fr",
78
79
  surface: "search_page",
79
80
  search_type: "lexical",
80
81
  page: 2,
@@ -82,12 +83,12 @@ describe("search controller attribution", () => {
82
83
  query_length: 5,
83
84
  });
84
85
  });
85
- it("keeps query_id stable across pagination and rotates it on a new query", async () => {
86
+ it("follows the response's queryID: every page is its own query", async () => {
86
87
  const controller = createController();
87
88
  search
88
89
  .mockResolvedValueOnce(response())
89
- .mockResolvedValueOnce(response({ page: 1, queryId: "qid-1" }))
90
- .mockResolvedValueOnce(response({ query: "boots", queryId: "qid-2" }));
90
+ .mockResolvedValueOnce(response({ page: 1, queryID: "qid-2" }))
91
+ .mockResolvedValueOnce(response({ query: "boots", queryID: "qid-3" }));
91
92
  controller.submit("shoes");
92
93
  await settle();
93
94
  controller.setPage(1);
@@ -95,7 +96,7 @@ describe("search controller attribution", () => {
95
96
  controller.submit("boots");
96
97
  await settle();
97
98
  const queryIds = tracker.setContext.mock.calls.map(([envelope]) => envelope.query_id);
98
- expect(queryIds).toEqual(["qid-1", "qid-1", "qid-2"]);
99
+ expect(queryIds).toEqual(["qid-1", "qid-2", "qid-3"]);
99
100
  });
100
101
  it("emits the zero-items view event for a rendered no-results state", async () => {
101
102
  const controller = createController();
@@ -104,6 +105,7 @@ describe("search controller attribution", () => {
104
105
  await settle();
105
106
  expect(trackViewSearchResults).toHaveBeenCalledWith({
106
107
  query_id: "qid-1",
108
+ index: "products_fr",
107
109
  surface: "search_page",
108
110
  search_type: "lexical",
109
111
  page: 1,
@@ -149,6 +151,7 @@ describe("search controller attribution", () => {
149
151
  });
150
152
  expect(trackSelectSearchResult).toHaveBeenCalledWith({
151
153
  query_id: "qid-1",
154
+ index: "products_fr",
152
155
  surface: "search_page",
153
156
  search_type: "lexical",
154
157
  page: 1,
@@ -156,7 +159,7 @@ describe("search controller attribution", () => {
156
159
  query_length: 5,
157
160
  items: [{ product_id: "p1", position: 1 }],
158
161
  });
159
- expect(navigate).toHaveBeenCalledWith("https://shop.example/p1", landed.hits[0]);
162
+ expect(navigate).toHaveBeenCalledWith("https://shop.example/p1", landed.results[0].hits[0]);
160
163
  expect(order).toEqual(["impression", "select", "navigate"]);
161
164
  });
162
165
  it("still records attribution when the hit has no URL to navigate to", async () => {
@@ -203,6 +206,7 @@ describe("search controller attribution", () => {
203
206
  trackViewSearchResults,
204
207
  trackSelectSearchResult,
205
208
  },
209
+ locale: "fr",
206
210
  });
207
211
  search.mockResolvedValue(response());
208
212
  controller.submit("shoes");
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import { createSearchImpressionTracker } from "../utils/searchImpressions";
4
4
  const envelope = {
5
5
  query_id: "query-1",
6
+ index: "products_fr",
6
7
  surface: "search_page",
7
8
  search_type: "lexical",
8
9
  page: 1,
@@ -133,7 +134,7 @@ describe("createSearchImpressionTracker", () => {
133
134
  tracker.forceImpression({ product_id: "product-1", position: 1 });
134
135
  expect(emit).toHaveBeenCalledTimes(1);
135
136
  });
136
- it("keeps deduplication across pagination of the same query", () => {
137
+ it("restarts deduplication on every context change: each response mints a fresh query_id", () => {
137
138
  const emit = vi.fn();
138
139
  const tracker = createSearchImpressionTracker({ emit });
139
140
  tracker.setContext(envelope);
@@ -141,29 +142,11 @@ describe("createSearchImpressionTracker", () => {
141
142
  tracker.observe(card, { product_id: "product-1", position: 1 });
142
143
  intersect(card, 0.6);
143
144
  vi.advanceTimersByTime(2000);
144
- tracker.setContext({ ...envelope, page: 2 });
145
+ tracker.setContext({ ...envelope, query_id: "query-2", page: 2 });
145
146
  const cardOnPage2 = element();
146
147
  tracker.observe(cardOnPage2, { product_id: "product-1", position: 1 });
147
148
  intersect(cardOnPage2, 0.6);
148
149
  vi.advanceTimersByTime(2000);
149
- expect(emit).toHaveBeenCalledTimes(1);
150
- });
151
- it("drops a previous query's dedup keys when the query changes", () => {
152
- const emit = vi.fn();
153
- const tracker = createSearchImpressionTracker({ emit });
154
- tracker.setContext(envelope);
155
- const card = element();
156
- tracker.observe(card, { product_id: "product-1", position: 1 });
157
- intersect(card, 0.6);
158
- vi.advanceTimersByTime(2000);
159
- tracker.setContext({ ...envelope, query_id: "query-2" });
160
- // Same query_id served again (e.g. a cached response): a fresh render is
161
- // a new exposure, like the bfcache restore.
162
- tracker.setContext(envelope);
163
- const cardAgain = element();
164
- tracker.observe(cardAgain, { product_id: "product-1", position: 1 });
165
- intersect(cardAgain, 0.6);
166
- vi.advanceTimersByTime(2000);
167
150
  expect(emit).toHaveBeenCalledTimes(2);
168
151
  });
169
152
  it("flushes qualified impressions on pagehide", () => {
@@ -1,16 +1,31 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import { DialogSearchError } from "../DialogSearchError";
3
- import { searchProducts } from "../services/search";
3
+ import { searchIndexName, searchLexical } from "../services/search";
4
4
  const API_KEY = "pk_test_abcdef";
5
+ const request = {
6
+ requests: [
7
+ {
8
+ indexName: "products_fr",
9
+ query: "running shoes",
10
+ page: 2,
11
+ hitsPerPage: 50,
12
+ },
13
+ ],
14
+ };
5
15
  const emptyResponse = {
6
- queryId: "0198c3f2-0000-7000-8000-000000000000",
7
- hits: [],
8
- nbHits: 0,
9
- page: 0,
10
- nbPages: 0,
11
- hitsPerPage: 20,
12
- processingTimeMs: 3,
13
- query: "running shoes",
16
+ results: [
17
+ {
18
+ index: "products_fr",
19
+ hits: [],
20
+ nbHits: 0,
21
+ page: 0,
22
+ nbPages: 0,
23
+ hitsPerPage: 20,
24
+ processingTimeMS: 3,
25
+ query: "running shoes",
26
+ queryID: "0198c3f2-0000-7000-8000-000000000000",
27
+ },
28
+ ],
14
29
  };
15
30
  const jsonResponse = (body, status = 200) => new Response(JSON.stringify(body), {
16
31
  status,
@@ -24,86 +39,76 @@ afterEach(() => {
24
39
  vi.unstubAllGlobals();
25
40
  vi.clearAllMocks();
26
41
  });
27
- describe("searchProducts", () => {
28
- it("POSTs the exact request to /public/search with only the api key header", async () => {
42
+ describe("searchIndexName", () => {
43
+ it.each([
44
+ ["fr-FR", "products_fr"],
45
+ ["pt-BR", "products_pt"],
46
+ ["fr", "products_fr"],
47
+ ["not a locale", "products_not a locale"],
48
+ ])("reduces the locale %s to a bare-language index name", (locale, name) => {
49
+ expect(searchIndexName("products", locale)).toBe(name);
50
+ });
51
+ it("names every logical index", () => {
52
+ expect(searchIndexName("collections", "en-US")).toBe("collections_en");
53
+ });
54
+ });
55
+ describe("searchLexical", () => {
56
+ it("POSTs the exact request to /public/search/lexical with only the api key header", async () => {
29
57
  fetchMock.mockResolvedValue(jsonResponse(emptyResponse));
30
- await searchProducts(API_KEY, {
31
- query: "running shoes",
32
- page: 2,
33
- hitsPerPage: 50,
34
- queryId: "0198c3f2-0000-7000-8000-000000000000",
35
- });
58
+ await searchLexical(API_KEY, request);
36
59
  expect(fetchMock).toHaveBeenCalledTimes(1);
37
60
  const [url, init] = fetchMock.mock.calls[0];
38
- expect(url).toMatch(/\/public\/search$/);
61
+ expect(url).toMatch(/\/public\/search\/lexical$/);
39
62
  expect(init.method).toBe("POST");
40
63
  expect(init.headers).toEqual({
41
64
  "Content-Type": "application/json",
42
65
  "x-dialog-api-key": API_KEY,
43
66
  });
44
- expect(JSON.parse(init.body)).toEqual({
45
- query: "running shoes",
46
- page: 2,
47
- hitsPerPage: 50,
48
- queryId: "0198c3f2-0000-7000-8000-000000000000",
49
- });
67
+ expect(JSON.parse(init.body)).toEqual(request);
50
68
  });
51
- it.each([
52
- ["fr-FR", "fr"],
53
- ["pt-BR", "pt"],
54
- ["fr", "fr"],
55
- ["not a locale", "not a locale"],
56
- ])("sends the locale %s as its bare ISO 639-1 language %s", async (locale, expected) => {
69
+ it("keeps optional per-entry fields the caller did not provide off the wire", async () => {
57
70
  fetchMock.mockResolvedValue(jsonResponse(emptyResponse));
58
- await searchProducts(API_KEY, { query: "running shoes", locale });
59
- const [, init] = fetchMock.mock.calls[0];
60
- expect(JSON.parse(init.body)).toEqual({
61
- query: "running shoes",
62
- locale: expected,
71
+ await searchLexical(API_KEY, {
72
+ requests: [{ indexName: "products_fr", query: "running shoes" }],
63
73
  });
64
- });
65
- it("omits pagination fields and queryId the caller did not provide", async () => {
66
- fetchMock.mockResolvedValue(jsonResponse(emptyResponse));
67
- await searchProducts(API_KEY, { query: "running shoes" });
68
74
  const [, init] = fetchMock.mock.calls[0];
69
75
  expect(JSON.parse(init.body)).toEqual({
70
- query: "running shoes",
76
+ requests: [{ indexName: "products_fr", query: "running shoes" }],
71
77
  });
72
78
  });
73
79
  it("resolves the typed response, including empty results", async () => {
74
80
  fetchMock.mockResolvedValue(jsonResponse(emptyResponse));
75
- await expect(searchProducts(API_KEY, { query: "running shoes" })).resolves.toEqual(emptyResponse);
81
+ await expect(searchLexical(API_KEY, request)).resolves.toEqual(emptyResponse);
76
82
  });
77
83
  it.each([
78
- [401, "INVALID_WIDGET_API_KEY", "Invalid widget API key"],
79
- [
80
- 422,
81
- "VALIDATION_ERROR",
82
- "query must contain at least 2 visible characters",
83
- ],
84
- [429, "THROTTLED", "Too many requests"],
85
- [503, "STOREFRONT_SEARCH_UNAVAILABLE", "Storefront search is unavailable"],
86
- ])("rejects a %i answer with a DialogSearchError carrying status, code and message", async (status, code, message) => {
87
- fetchMock.mockResolvedValue(jsonResponse({ statusCode: status, error: code, message }, status));
88
- const error = await searchProducts(API_KEY, {
89
- query: "running shoes",
90
- }).catch((caught) => caught);
84
+ [404, "Index products_xx does not exist"],
85
+ [400, "Unknown parameter: foo"],
86
+ ])("rejects the Algolia-shaped %i error body with a DialogSearchError", async (status, message) => {
87
+ fetchMock.mockResolvedValue(jsonResponse({ message, status }, status));
88
+ const error = await searchLexical(API_KEY, request).catch((caught) => caught);
91
89
  expect(error).toBeInstanceOf(DialogSearchError);
92
90
  expect(error).toMatchObject({
93
91
  name: "DialogSearchError",
94
92
  status,
95
- code,
93
+ code: undefined,
96
94
  message,
97
95
  });
98
96
  });
97
+ it.each([
98
+ [401, "INVALID_WIDGET_API_KEY", "Invalid widget API key"],
99
+ [429, "THROTTLED", "Too many requests"],
100
+ ])("still reads the code of a Nest-shaped %i guard error", async (status, code, message) => {
101
+ fetchMock.mockResolvedValue(jsonResponse({ statusCode: status, error: code, message }, status));
102
+ const error = await searchLexical(API_KEY, request).catch((caught) => caught);
103
+ expect(error).toBeInstanceOf(DialogSearchError);
104
+ expect(error).toMatchObject({ status, code, message });
105
+ });
99
106
  it("falls back to statusText when the error body is not JSON", async () => {
100
107
  fetchMock.mockResolvedValue(new Response("<html>Bad Gateway</html>", {
101
108
  status: 502,
102
109
  statusText: "Bad Gateway",
103
110
  }));
104
- const error = await searchProducts(API_KEY, {
105
- query: "running shoes",
106
- }).catch((caught) => caught);
111
+ const error = await searchLexical(API_KEY, request).catch((caught) => caught);
107
112
  expect(error).toBeInstanceOf(DialogSearchError);
108
113
  expect(error).toMatchObject({
109
114
  status: 502,
@@ -114,13 +119,13 @@ describe("searchProducts", () => {
114
119
  it("propagates network failures untouched", async () => {
115
120
  const networkError = new TypeError("Failed to fetch");
116
121
  fetchMock.mockRejectedValue(networkError);
117
- await expect(searchProducts(API_KEY, { query: "running shoes" })).rejects.toBe(networkError);
122
+ await expect(searchLexical(API_KEY, request)).rejects.toBe(networkError);
118
123
  });
119
124
  it("forwards the AbortSignal and preserves the native AbortError", async () => {
120
125
  const abortError = new DOMException("The operation was aborted.", "AbortError");
121
126
  fetchMock.mockRejectedValue(abortError);
122
127
  const controller = new AbortController();
123
- await expect(searchProducts(API_KEY, { query: "running shoes" }, { signal: controller.signal })).rejects.toBe(abortError);
128
+ await expect(searchLexical(API_KEY, request, { signal: controller.signal })).rejects.toBe(abortError);
124
129
  const [, init] = fetchMock.mock.calls[0];
125
130
  expect(init.signal).toBe(controller.signal);
126
131
  });
@@ -1,6 +1,6 @@
1
1
  export const config = {
2
2
  baseApiUrl: "https://hr5buzenb1.execute-api.eu-west-1.amazonaws.com",
3
- // Nest monolith (staging) — serves POST /public/search.
3
+ // Nest monolith (staging) — serves POST /public/search/lexical.
4
4
  monolithApiUrl: "https://fvcphlqyle.execute-api.eu-west-1.amazonaws.com",
5
5
  assistantUrl: "https://d2bycosa71tnxv.cloudfront.net/assets/index.js",
6
6
  };
@@ -1,6 +1,6 @@
1
1
  export const config = {
2
2
  baseApiUrl: "https://rtbzcxkmwj.execute-api.eu-west-1.amazonaws.com",
3
- // Nest monolith (production) — serves POST /public/search. Custom domain,
3
+ // Nest monolith (production) — serves POST /public/search/lexical. Custom domain,
4
4
  // same as the dashboard's VITE_MONOLITH_API_URL: the raw execute-api
5
5
  // gateway URL answers 500 on every route and must not be used.
6
6
  monolithApiUrl: "https://api.askdialog.ai",
@@ -1,6 +1,6 @@
1
1
  export const config = {
2
2
  baseApiUrl: "https://rtbzcxkmwj.execute-api.eu-west-1.amazonaws.com",
3
- // Nest monolith (production) — serves POST /public/search. Custom domain,
3
+ // Nest monolith (production) — serves POST /public/search/lexical. Custom domain,
4
4
  // same as the dashboard's VITE_MONOLITH_API_URL: the raw execute-api
5
5
  // gateway URL answers 500 on every route and must not be used.
6
6
  monolithApiUrl: "https://api.askdialog.ai",
@@ -1,3 +1,3 @@
1
1
  import { SearchController, SearchControllerOptions } from "./types/searchController";
2
- export declare function createSearchController({ search, analytics, navigate, debounceMs, hitsPerPage, locale, countryCode, }: SearchControllerOptions): SearchController;
2
+ export declare function createSearchController({ search, analytics, navigate, debounceMs, hitsPerPage, locale, }: SearchControllerOptions): SearchController;
3
3
  //# sourceMappingURL=searchController.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"searchController.d.ts","sourceRoot":"","sources":["../src/searchController.ts"],"names":[],"mappings":"AAKA,OAAO,EACL,gBAAgB,EAChB,uBAAuB,EAGxB,MAAM,0BAA0B,CAAC;AAgBlC,wBAAgB,sBAAsB,CAAC,EACrC,MAAM,EACN,SAAS,EACT,QAAQ,EACR,UAAgC,EAChC,WAAmC,EACnC,MAAM,EACN,WAAW,GACZ,EAAE,uBAAuB,GAAG,gBAAgB,CAsL5C"}
1
+ {"version":3,"file":"searchController.d.ts","sourceRoot":"","sources":["../src/searchController.ts"],"names":[],"mappings":"AAMA,OAAO,EACL,gBAAgB,EAChB,uBAAuB,EAGxB,MAAM,0BAA0B,CAAC;AAgBlC,wBAAgB,sBAAsB,CAAC,EACrC,MAAM,EACN,SAAS,EACT,QAAQ,EACR,UAAgC,EAChC,WAAmC,EACnC,MAAM,GACP,EAAE,uBAAuB,GAAG,gBAAgB,CA8K5C"}
@@ -1,3 +1,8 @@
1
+ // This controller is one cohesive unit. `selectResult` now returns whether the
2
+ // navigate adapter handled the transition, nudging the file just past the
3
+ // default 200-line cap — raised modestly rather than split artificially.
4
+ /* eslint max-lines: ["error", 220] */
5
+ import { searchIndexName } from "./services/search";
1
6
  import { SearchStatus, } from "./types/searchController";
2
7
  import { createControllerAnalytics } from "./utils/searchControllerAnalytics";
3
8
  const DEFAULT_DEBOUNCE_MS = 250;
@@ -11,7 +16,8 @@ const INITIAL_STATE = {
11
16
  response: undefined,
12
17
  error: undefined,
13
18
  };
14
- export function createSearchController({ search, analytics, navigate, debounceMs = DEFAULT_DEBOUNCE_MS, hitsPerPage = DEFAULT_HITS_PER_PAGE, locale, countryCode, }) {
19
+ export function createSearchController({ search, analytics, navigate, debounceMs = DEFAULT_DEBOUNCE_MS, hitsPerPage = DEFAULT_HITS_PER_PAGE, locale, }) {
20
+ const indexName = searchIndexName("products", locale);
15
21
  let state = INITIAL_STATE;
16
22
  const listeners = new Set();
17
23
  const controllerAnalytics = createControllerAnalytics(analytics);
@@ -34,22 +40,9 @@ export function createSearchController({ search, analytics, navigate, debounceMs
34
40
  abortController?.abort();
35
41
  abortController = undefined;
36
42
  };
37
- const buildRequest = (query, page) => {
38
- const request = { query, page, hitsPerPage };
39
- if (locale !== undefined) {
40
- request.locale = locale;
41
- }
42
- if (countryCode !== undefined) {
43
- request.countryCode = countryCode;
44
- }
45
- // Query unchanged since the last response: resend its queryId; a new
46
- // query gets a fresh engine-generated one.
47
- const previous = state.response;
48
- if (previous?.query === query) {
49
- request.queryId = previous.queryId;
50
- }
51
- return request;
52
- };
43
+ const buildRequest = (query, page) => ({
44
+ requests: [{ indexName, query, page, hitsPerPage }],
45
+ });
53
46
  const run = async (query, page) => {
54
47
  cancelInFlight();
55
48
  abortController = new AbortController();
@@ -63,10 +56,14 @@ export function createSearchController({ search, analytics, navigate, debounceMs
63
56
  if (id !== requestId) {
64
57
  return; // A newer request landed first: this response is stale.
65
58
  }
66
- controllerAnalytics.onResponse(response);
59
+ const result = response.results.find((entry) => entry.index === indexName);
60
+ if (result === undefined) {
61
+ throw new Error(`Dialog search returned no ${indexName} entry`);
62
+ }
63
+ controllerAnalytics.onResponse(result);
67
64
  setState({
68
- status: response.nbHits === 0 ? SearchStatus.EMPTY : SearchStatus.SUCCESS,
69
- response,
65
+ status: result.nbHits === 0 ? SearchStatus.EMPTY : SearchStatus.SUCCESS,
66
+ response: result,
70
67
  error: undefined,
71
68
  });
72
69
  }
@@ -146,7 +143,7 @@ export function createSearchController({ search, analytics, navigate, debounceMs
146
143
  return false;
147
144
  }
148
145
  controllerAnalytics.select(response, index);
149
- const url = response.hits[index].product.url;
146
+ const url = response.hits[index].url;
150
147
  const runAdapter = options?.navigate ?? true;
151
148
  if (runAdapter && navigate !== undefined && url !== undefined) {
152
149
  navigate(url, response.hits[index]);
@@ -1,7 +1,8 @@
1
- import { SearchOptions, SearchRequest, SearchResponse } from "../types/search";
1
+ import { SearchIndex, SearchOptions, SearchRequest, SearchResponse } from "../types/search";
2
+ export declare const searchIndexName: (index: SearchIndex, locale: string) => string;
2
3
  /**
3
4
  * One POST per invocation — no debounce, cache, retry or request state; the
4
5
  * caller owns cancellation through `options.signal`.
5
6
  */
6
- export declare const searchProducts: (apiKey: string, request: SearchRequest, options?: SearchOptions) => Promise<SearchResponse>;
7
+ export declare const searchLexical: (apiKey: string, request: SearchRequest, options?: SearchOptions) => Promise<SearchResponse>;
7
8
  //# sourceMappingURL=search.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../src/services/search.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAgC/E;;;GAGG;AACH,eAAO,MAAM,cAAc,GACzB,QAAQ,MAAM,EACd,SAAS,aAAa,EACtB,UAAU,aAAa,KACtB,OAAO,CAAC,cAAc,CAgBxB,CAAC"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../src/services/search.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,WAAW,EACX,aAAa,EACb,aAAa,EACb,cAAc,EACf,MAAM,iBAAiB,CAAC;AAMzB,eAAO,MAAM,eAAe,GAAI,OAAO,WAAW,EAAE,QAAQ,MAAM,KAAG,MACzB,CAAC;AAoB7C;;;GAGG;AACH,eAAO,MAAM,aAAa,GACxB,QAAQ,MAAM,EACd,SAAS,aAAa,EACtB,UAAU,aAAa,KACtB,OAAO,CAAC,cAAc,CAgBxB,CAAC"}
@@ -1,8 +1,9 @@
1
1
  import { config } from "../config";
2
2
  import { DialogSearchError } from "../DialogSearchError";
3
3
  import { toIso639LanguageCode } from "../utils/localization";
4
- const SEARCH_PATH = "/public/search";
4
+ const SEARCH_PATH = "/public/search/lexical";
5
5
  const API_KEY_HEADER = "x-dialog-api-key";
6
+ export const searchIndexName = (index, locale) => `${index}_${toIso639LanguageCode(locale)}`;
6
7
  const toSearchError = async (response) => {
7
8
  let body;
8
9
  try {
@@ -17,24 +18,18 @@ const toSearchError = async (response) => {
17
18
  : response.statusText;
18
19
  return new DialogSearchError({ status: response.status, code, message });
19
20
  };
20
- // Every search path (Dialog instance, search controller, direct transport)
21
- // funnels through here, so the locale reaches the wire as the bare ISO 639-1
22
- // language whatever tag the caller holds.
23
- const withNormalizedLocale = (request) => request.locale === undefined
24
- ? request
25
- : { ...request, locale: toIso639LanguageCode(request.locale) };
26
21
  /**
27
22
  * One POST per invocation — no debounce, cache, retry or request state; the
28
23
  * caller owns cancellation through `options.signal`.
29
24
  */
30
- export const searchProducts = async (apiKey, request, options) => {
25
+ export const searchLexical = async (apiKey, request, options) => {
31
26
  const response = await fetch(`${config.monolithApiUrl}${SEARCH_PATH}`, {
32
27
  method: "POST",
33
28
  headers: {
34
29
  "Content-Type": "application/json",
35
30
  [API_KEY_HEADER]: apiKey,
36
31
  },
37
- body: JSON.stringify(withNormalizedLocale(request)),
32
+ body: JSON.stringify(request),
38
33
  signal: options?.signal,
39
34
  });
40
35
  if (!response.ok) {
@@ -5,6 +5,11 @@ export interface DialogCallbacks {
5
5
  addToCart: (input: AddToCartInput) => Promise<void>;
6
6
  getProduct: (productId: string, variantId?: string) => Promise<SimplifiedProduct>;
7
7
  }
8
+ export interface CurrentProduct {
9
+ /** The catalog product id — must match the id in the Dialog product feed. */
10
+ id: string;
11
+ variantId?: string;
12
+ }
8
13
  export interface DialogConstructor {
9
14
  apiKey: string;
10
15
  locale: string;
@@ -40,5 +45,15 @@ export interface DialogConstructor {
40
45
  * Product links and recommendation browsing are unaffected.
41
46
  */
42
47
  disableAddToCart?: boolean;
48
+ /**
49
+ * The product of the page the SDK is instantiated on, when it is a product
50
+ * page. The assistant uses it as the conversation's product context for
51
+ * entry points that carry no product of their own (floating bookmark,
52
+ * resume surface, free-text questions), so answers stay grounded on the
53
+ * product under the visitor's eyes after PDP-to-PDP navigation. On
54
+ * single-page storefronts, update it on navigation with
55
+ * `setCurrentProduct()` / `clearCurrentProduct()`.
56
+ */
57
+ product?: CurrentProduct;
43
58
  }
44
59
  //# sourceMappingURL=constructor.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"constructor.d.ts","sourceRoot":"","sources":["../../src/types/constructor.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAC1C,OAAO,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC9C,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAEhC,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,UAAU,EAAE,CACV,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,KACf,OAAO,CAAC,iBAAiB,CAAC,CAAC;CACjC;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B,KAAK,CAAC,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;OAMG;IACH,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAClC;;;;;;;;OAQG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B"}
1
+ {"version":3,"file":"constructor.d.ts","sourceRoot":"","sources":["../../src/types/constructor.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAC1C,OAAO,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC9C,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAEhC,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,UAAU,EAAE,CACV,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,KACf,OAAO,CAAC,iBAAiB,CAAC,CAAC;CACjC;AAED,MAAM,WAAW,cAAc;IAC7B,6EAA6E;IAC7E,EAAE,EAAE,MAAM,CAAC;IACX,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B,KAAK,CAAC,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;OAMG;IACH,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAClC;;;;;;;;OAQG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;;;;;OAQG;IACH,OAAO,CAAC,EAAE,cAAc,CAAC;CAC1B"}
@@ -1,14 +1,18 @@
1
- export interface SearchRequest {
1
+ /** Logical indices the public search serves; the wire name adds the locale. */
2
+ export declare const SEARCH_INDICES: readonly ["products", "collections", "articles", "pages"];
3
+ export type SearchIndex = (typeof SEARCH_INDICES)[number];
4
+ export interface SearchQuery {
5
+ /** `<index>_<locale>` (e.g. `products_fr`), one shared locale per request; unknown or unserved → 404. */
6
+ indexName: string;
2
7
  /** Trimmed server-side; must keep at least two visible characters (code points). */
3
8
  query: string;
4
9
  /** Zero-indexed results page. Defaults to 0 server-side. */
5
10
  page?: number;
6
11
  /** Between 1 and 100. Defaults to 20 server-side. */
7
12
  hitsPerPage?: number;
8
- /** Previous response's queryId while the query is unchanged; omit for a new query. */
9
- queryId?: string;
10
- locale?: string;
11
- countryCode?: string;
13
+ }
14
+ export interface SearchRequest {
15
+ requests: SearchQuery[];
12
16
  }
13
17
  export interface SearchOptions {
14
18
  /** Forwarded to fetch untouched: aborting rejects with the native AbortError. */
@@ -17,41 +21,39 @@ export interface SearchOptions {
17
21
  export interface SearchPrice {
18
22
  /** Decimal amount as a string, exactly as indexed (e.g. "24.90"). */
19
23
  amount: string;
20
- currencyCode: string;
24
+ /** Absent when the price was indexed without a currency. */
25
+ currencyCode?: string;
21
26
  }
22
27
  export interface SearchPriceRange {
23
28
  min: SearchPrice;
24
29
  max: SearchPrice;
25
30
  }
26
31
  /**
27
- * Storefront-ready projection of a search hit. Every display field is
28
- * best-effort: a product may carry only its id.
32
+ * A flat, storefront-ready record: `objectID` plus the record's attributes.
33
+ * Every display field is best-effort a hit may carry only its id;
34
+ * `priceRange` only comes from the products index.
29
35
  */
30
- export interface SearchProduct {
31
- id: string;
36
+ export interface SearchHit {
37
+ objectID: string;
32
38
  title?: string;
33
39
  url?: string;
34
- /** Shopify URL handle, lets the storefront build `/products/{handle}` when `url` is absent (DEC-2543). */
35
40
  handle?: string;
36
41
  imageUrl?: string;
37
42
  priceRange?: SearchPriceRange;
38
- compareAtPriceRange?: SearchPriceRange;
39
- inStock?: boolean;
40
43
  }
41
- export interface SearchHit {
42
- id: string;
43
- score: number;
44
- product: SearchProduct;
45
- }
46
- export interface SearchResponse {
47
- /** Engine-generated id for search attribution analytics. */
48
- queryId: string;
44
+ export interface SearchResult {
45
+ index: string;
49
46
  hits: SearchHit[];
50
47
  nbHits: number;
51
48
  page: number;
52
49
  nbPages: number;
53
50
  hitsPerPage: number;
54
- processingTimeMs: number;
51
+ processingTimeMS: number;
55
52
  query: string;
53
+ queryID: string;
54
+ }
55
+ /** One entry per request entry, in request order. */
56
+ export interface SearchResponse {
57
+ results: SearchResult[];
56
58
  }
57
59
  //# sourceMappingURL=search.d.ts.map