@athoscommerce/snap-controller 1.0.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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -0
  3. package/dist/cjs/Abstract/AbstractController.d.ts +42 -0
  4. package/dist/cjs/Abstract/AbstractController.d.ts.map +1 -0
  5. package/dist/cjs/Abstract/AbstractController.js +305 -0
  6. package/dist/cjs/Autocomplete/AutocompleteController.d.ts +59 -0
  7. package/dist/cjs/Autocomplete/AutocompleteController.d.ts.map +1 -0
  8. package/dist/cjs/Autocomplete/AutocompleteController.js +1091 -0
  9. package/dist/cjs/Finder/FinderController.d.ts +15 -0
  10. package/dist/cjs/Finder/FinderController.d.ts.map +1 -0
  11. package/dist/cjs/Finder/FinderController.js +336 -0
  12. package/dist/cjs/Recommendation/RecommendationController.d.ts +27 -0
  13. package/dist/cjs/Recommendation/RecommendationController.d.ts.map +1 -0
  14. package/dist/cjs/Recommendation/RecommendationController.js +447 -0
  15. package/dist/cjs/Search/SearchController.d.ts +41 -0
  16. package/dist/cjs/Search/SearchController.d.ts.map +1 -0
  17. package/dist/cjs/Search/SearchController.js +993 -0
  18. package/dist/cjs/index.d.ts +7 -0
  19. package/dist/cjs/index.d.ts.map +1 -0
  20. package/dist/cjs/index.js +29 -0
  21. package/dist/cjs/types.d.ts +87 -0
  22. package/dist/cjs/types.d.ts.map +1 -0
  23. package/dist/cjs/types.js +10 -0
  24. package/dist/cjs/utils/getParams.d.ts +3 -0
  25. package/dist/cjs/utils/getParams.d.ts.map +1 -0
  26. package/dist/cjs/utils/getParams.js +70 -0
  27. package/dist/cjs/utils/isClickWithinBannerLink.d.ts +2 -0
  28. package/dist/cjs/utils/isClickWithinBannerLink.d.ts.map +1 -0
  29. package/dist/cjs/utils/isClickWithinBannerLink.js +21 -0
  30. package/dist/cjs/utils/isClickWithinProductLink.d.ts +5 -0
  31. package/dist/cjs/utils/isClickWithinProductLink.d.ts.map +1 -0
  32. package/dist/cjs/utils/isClickWithinProductLink.js +25 -0
  33. package/dist/esm/Abstract/AbstractController.d.ts +42 -0
  34. package/dist/esm/Abstract/AbstractController.d.ts.map +1 -0
  35. package/dist/esm/Abstract/AbstractController.js +208 -0
  36. package/dist/esm/Autocomplete/AutocompleteController.d.ts +59 -0
  37. package/dist/esm/Autocomplete/AutocompleteController.d.ts.map +1 -0
  38. package/dist/esm/Autocomplete/AutocompleteController.js +882 -0
  39. package/dist/esm/Finder/FinderController.d.ts +15 -0
  40. package/dist/esm/Finder/FinderController.d.ts.map +1 -0
  41. package/dist/esm/Finder/FinderController.js +218 -0
  42. package/dist/esm/Recommendation/RecommendationController.d.ts +27 -0
  43. package/dist/esm/Recommendation/RecommendationController.d.ts.map +1 -0
  44. package/dist/esm/Recommendation/RecommendationController.js +342 -0
  45. package/dist/esm/Search/SearchController.d.ts +41 -0
  46. package/dist/esm/Search/SearchController.d.ts.map +1 -0
  47. package/dist/esm/Search/SearchController.js +795 -0
  48. package/dist/esm/index.d.ts +7 -0
  49. package/dist/esm/index.d.ts.map +1 -0
  50. package/dist/esm/index.js +6 -0
  51. package/dist/esm/types.d.ts +87 -0
  52. package/dist/esm/types.d.ts.map +1 -0
  53. package/dist/esm/types.js +7 -0
  54. package/dist/esm/utils/getParams.d.ts +3 -0
  55. package/dist/esm/utils/getParams.d.ts.map +1 -0
  56. package/dist/esm/utils/getParams.js +67 -0
  57. package/dist/esm/utils/isClickWithinBannerLink.d.ts +2 -0
  58. package/dist/esm/utils/isClickWithinBannerLink.d.ts.map +1 -0
  59. package/dist/esm/utils/isClickWithinBannerLink.js +17 -0
  60. package/dist/esm/utils/isClickWithinProductLink.d.ts +5 -0
  61. package/dist/esm/utils/isClickWithinProductLink.d.ts.map +1 -0
  62. package/dist/esm/utils/isClickWithinProductLink.js +19 -0
  63. package/package.json +41 -0
@@ -0,0 +1,882 @@
1
+ import deepmerge from 'deepmerge';
2
+ import { StorageStore, ErrorType } from '@athoscommerce/snap-store-mobx';
3
+ import { AbstractController } from '../Abstract/AbstractController';
4
+ import { getSearchParams } from '../utils/getParams';
5
+ import { ControllerTypes } from '../types';
6
+ import { CLICK_DUPLICATION_TIMEOUT, isClickWithinProductLink } from '../utils/isClickWithinProductLink';
7
+ import { isClickWithinBannerLink } from '../utils/isClickWithinBannerLink';
8
+ const INPUT_ATTRIBUTE = 'ss-autocomplete-input';
9
+ export const INPUT_DELAY = 200;
10
+ const KEY_ENTER = 13;
11
+ const KEY_ESCAPE = 27;
12
+ const PARAM_FALLBACK_QUERY = 'fallbackQuery';
13
+ const defaultConfig = {
14
+ id: 'autocomplete',
15
+ selector: '',
16
+ action: '',
17
+ globals: {},
18
+ beacon: {
19
+ enabled: true,
20
+ },
21
+ settings: {
22
+ initializeFromUrl: true,
23
+ syncInputs: true,
24
+ serializeForm: false,
25
+ facets: {
26
+ trim: true,
27
+ pinFiltered: true,
28
+ },
29
+ redirects: {
30
+ merchandising: true,
31
+ singleResult: false,
32
+ },
33
+ bind: {
34
+ input: true,
35
+ submit: true,
36
+ },
37
+ },
38
+ };
39
+ export class AutocompleteController extends AbstractController {
40
+ constructor(config, { client, store, urlManager, eventManager, profiler, logger, tracker }, context) {
41
+ super(config, { client, store, urlManager, eventManager, profiler, logger, tracker }, context);
42
+ this.type = ControllerTypes.autocomplete;
43
+ this.events = {};
44
+ this.track = {
45
+ banner: {
46
+ impression: (_banner) => {
47
+ if (!_banner) {
48
+ this.log.warn('No banner provided to track.banner.impression');
49
+ return;
50
+ }
51
+ const { responseId, uid } = _banner;
52
+ if (!this.events[responseId]) {
53
+ this.log.warn('No responseId found in controller, ensure correct controller is used');
54
+ return;
55
+ }
56
+ else if (this.events?.[responseId]?.banner?.[uid]?.impression) {
57
+ return;
58
+ }
59
+ const banner = { uid };
60
+ const data = {
61
+ responseId,
62
+ banners: [banner],
63
+ results: [],
64
+ };
65
+ this.eventManager.fire('track.banner.impression', { controller: this, product: { uid }, trackEvent: data });
66
+ this.config.beacon?.enabled && this.tracker.events.autocomplete.impression({ data, siteId: this.config.globals?.siteId });
67
+ this.events[responseId].banner[uid] = this.events[responseId].banner[uid] || {};
68
+ this.events[responseId].banner[uid].impression = true;
69
+ },
70
+ click: (e, banner) => {
71
+ if (!banner) {
72
+ this.log.warn('No banner provided to track.banner.click');
73
+ return;
74
+ }
75
+ const { responseId, uid } = banner;
76
+ if (!this.events[responseId]) {
77
+ this.log.warn('No responseId found in controller, ensure correct controller is used');
78
+ return;
79
+ }
80
+ if (isClickWithinBannerLink(e)) {
81
+ if (this.events?.[responseId]?.banner[uid]?.clickThrough) {
82
+ return;
83
+ }
84
+ this.track.banner.clickThrough(e, banner);
85
+ this.events[responseId].banner[uid] = this.events[responseId].banner[uid] || {};
86
+ this.events[responseId].banner[uid].clickThrough = true;
87
+ setTimeout(() => {
88
+ this.events[responseId].banner[uid].clickThrough = false;
89
+ }, CLICK_DUPLICATION_TIMEOUT);
90
+ }
91
+ },
92
+ clickThrough: (e, { uid, responseId }) => {
93
+ if (!uid) {
94
+ this.log.warn('No banner uid provided to track.banner.clickThrough');
95
+ return;
96
+ }
97
+ if (!this.events[responseId]) {
98
+ this.log.warn('No responseId found in controller, ensure correct controller is used');
99
+ return;
100
+ }
101
+ const banner = { uid };
102
+ const data = {
103
+ responseId,
104
+ banners: [banner],
105
+ };
106
+ this.eventManager.fire('track.banner.clickThrough', { controller: this, event: e, product: { uid }, trackEvent: data });
107
+ this.config.beacon?.enabled && this.tracker.events.autocomplete.clickThrough({ data, siteId: this.config.globals?.siteId });
108
+ this.events[responseId].banner[uid] = this.events[responseId].banner[uid] || {};
109
+ this.events[responseId].banner[uid].clickThrough = true;
110
+ setTimeout(() => {
111
+ this.events[responseId].banner[uid].clickThrough = false;
112
+ }, CLICK_DUPLICATION_TIMEOUT);
113
+ },
114
+ },
115
+ product: {
116
+ clickThrough: (e, result) => {
117
+ if (!result) {
118
+ this.log.warn('No result provided to track.product.clickThrough');
119
+ return;
120
+ }
121
+ const responseId = result.responseId;
122
+ if (!this.events[responseId]) {
123
+ this.log.warn('No responseId found in controller, ensure correct controller is used');
124
+ return;
125
+ }
126
+ const type = (['product', 'banner'].includes(result.type) ? result.type : 'product');
127
+ const item = {
128
+ type,
129
+ uid: result.id ? '' + result.id : '',
130
+ ...(type === 'product'
131
+ ? {
132
+ parentId: result.mappings.core?.parentId ? '' + result.mappings.core?.parentId : '',
133
+ sku: result.mappings.core?.sku ? '' + result.mappings.core?.sku : undefined,
134
+ }
135
+ : {}),
136
+ };
137
+ const data = {
138
+ responseId,
139
+ results: [item],
140
+ };
141
+ this.eventManager.fire('track.product.clickThrough', { controller: this, event: e, product: result, trackEvent: data });
142
+ this.config.beacon?.enabled && this.tracker.events.autocomplete.clickThrough({ data, siteId: this.config.globals?.siteId });
143
+ },
144
+ click: (e, result) => {
145
+ if (!result) {
146
+ this.log.warn('No result provided to track.product.click');
147
+ return;
148
+ }
149
+ const responseId = result.responseId;
150
+ if (!this.events[responseId]) {
151
+ this.log.warn('No responseId found in controller, ensure correct controller is used');
152
+ return;
153
+ }
154
+ if (result.type === 'banner' && isClickWithinBannerLink(e)) {
155
+ if (this.events?.[responseId]?.product[result.id]?.inlineBannerClickThrough) {
156
+ return;
157
+ }
158
+ this.track.product.clickThrough(e, result);
159
+ this.events[responseId].product[result.id] = this.events[responseId].product[result.id] || {};
160
+ this.events[responseId].product[result.id].inlineBannerClickThrough = true;
161
+ setTimeout(() => {
162
+ this.events[responseId].product[result.id].inlineBannerClickThrough = false;
163
+ }, CLICK_DUPLICATION_TIMEOUT);
164
+ }
165
+ else if (isClickWithinProductLink(e, result)) {
166
+ if (this.events?.[responseId]?.product[result.id]?.productClickThrough) {
167
+ return;
168
+ }
169
+ this.track.product.clickThrough(e, result);
170
+ this.events[responseId].product[result.id] = this.events[responseId].product[result.id] || {};
171
+ this.events[responseId].product[result.id].productClickThrough = true;
172
+ setTimeout(() => {
173
+ this.events[responseId].product[result.id].productClickThrough = false;
174
+ }, CLICK_DUPLICATION_TIMEOUT);
175
+ }
176
+ },
177
+ impression: (result) => {
178
+ if (!result) {
179
+ this.log.warn('No result provided to track.product.impression');
180
+ return;
181
+ }
182
+ const responseId = result.responseId;
183
+ if (!this.events[responseId]) {
184
+ this.log.warn('No responseId found in controller, ensure correct controller is used');
185
+ return;
186
+ }
187
+ else if (this.events?.[responseId]?.product[result.id]?.impression) {
188
+ return;
189
+ }
190
+ const type = (['product', 'banner'].includes(result.type) ? result.type : 'product');
191
+ const item = {
192
+ type,
193
+ uid: result.id ? '' + result.id : '',
194
+ ...(type === 'product'
195
+ ? {
196
+ parentId: result.mappings.core?.parentId ? '' + result.mappings.core?.parentId : '',
197
+ sku: result.mappings.core?.sku ? '' + result.mappings.core?.sku : undefined,
198
+ }
199
+ : {}),
200
+ };
201
+ const data = {
202
+ responseId,
203
+ results: [item],
204
+ banners: [],
205
+ };
206
+ this.eventManager.fire('track.product.impression', { controller: this, product: result, trackEvent: data });
207
+ this.config.beacon?.enabled && this.tracker.events.autocomplete.impression({ data, siteId: this.config.globals?.siteId });
208
+ this.events[responseId].product[result.id] = this.events[responseId].product[result.id] || {};
209
+ this.events[responseId].product[result.id].impression = true;
210
+ },
211
+ addToCart: (result) => {
212
+ if (!result) {
213
+ this.log.warn('No result provided to track.product.addToCart');
214
+ return;
215
+ }
216
+ const responseId = result.responseId;
217
+ if (!this.events[responseId]) {
218
+ this.log.warn('No responseId found in controller, ensure correct controller is used');
219
+ return;
220
+ }
221
+ const product = {
222
+ parentId: result.id,
223
+ uid: result.id,
224
+ sku: result.mappings.core?.sku,
225
+ qty: result.quantity || 1,
226
+ price: Number(result.mappings.core?.price),
227
+ };
228
+ const data = {
229
+ responseId,
230
+ results: [product],
231
+ };
232
+ this.eventManager.fire('track.product.addToCart', { controller: this, product: result, trackEvent: data });
233
+ this.config.beacon?.enabled && this.tracker.events.autocomplete.addToCart({ data, siteId: this.config.globals?.siteId });
234
+ },
235
+ },
236
+ redirect: ({ redirectURL, responseId }) => {
237
+ if (!redirectURL) {
238
+ this.log.warn('No redirectURL provided to track.redirect');
239
+ return;
240
+ }
241
+ const data = {
242
+ responseId,
243
+ redirect: redirectURL,
244
+ };
245
+ this.eventManager.fire('track.redirect', { controller: this, redirectURL, trackEvent: data });
246
+ this.config.beacon?.enabled && this.tracker.events.autocomplete.redirect({ data, siteId: this.config.globals?.siteId });
247
+ },
248
+ };
249
+ this.handlers = {
250
+ input: {
251
+ enterKey: async (e) => {
252
+ if (e.keyCode == KEY_ENTER) {
253
+ const input = e.target;
254
+ let actionUrl = this.store.services.urlManager;
255
+ e.preventDefault();
256
+ // wait until loading is complete before submission
257
+ while (this.store.loading) {
258
+ await timeout(INPUT_DELAY);
259
+ }
260
+ // set fallbackQuery to the correctedQuery
261
+ if (this.store.search.correctedQuery) {
262
+ actionUrl = actionUrl?.set(PARAM_FALLBACK_QUERY, this.store.search.correctedQuery.string);
263
+ }
264
+ actionUrl = actionUrl?.set('query', input.value);
265
+ // wait for input delay
266
+ await timeout(INPUT_DELAY + 1);
267
+ try {
268
+ await this.eventManager.fire('beforeSubmit', {
269
+ controller: this,
270
+ input,
271
+ });
272
+ }
273
+ catch (err) {
274
+ if (err?.message == 'cancelled') {
275
+ this.log.warn(`'beforeSubmit' middleware cancelled`);
276
+ return;
277
+ }
278
+ else {
279
+ this.log.error(`error in 'beforeSubmit' middleware`);
280
+ console.error(err);
281
+ }
282
+ }
283
+ window.location.href = actionUrl?.href || '';
284
+ }
285
+ },
286
+ escKey: (e) => {
287
+ if (e.keyCode == KEY_ESCAPE) {
288
+ e.target.blur();
289
+ this.setFocused();
290
+ }
291
+ },
292
+ focus: (e) => {
293
+ e.stopPropagation();
294
+ // timeout to ensure focus happens AFTER click
295
+ setTimeout(() => {
296
+ this.setFocused(e.target);
297
+ });
298
+ },
299
+ formSubmit: async (e) => {
300
+ const form = e.target;
301
+ const input = form.querySelector(`input[${INPUT_ATTRIBUTE}]`);
302
+ e.preventDefault();
303
+ // wait until loading is complete before submission
304
+ while (this.store.loading) {
305
+ await timeout(INPUT_DELAY);
306
+ }
307
+ // set fallbackQuery to the correctedQuery
308
+ if (this.store.search.correctedQuery) {
309
+ addHiddenFormInput(form, PARAM_FALLBACK_QUERY, this.store.search.correctedQuery.string);
310
+ }
311
+ // wait for input delay
312
+ await timeout(INPUT_DELAY + 1);
313
+ try {
314
+ await this.eventManager.fire('beforeSubmit', {
315
+ controller: this,
316
+ input,
317
+ });
318
+ }
319
+ catch (err) {
320
+ if (err?.message == 'cancelled') {
321
+ this.log.warn(`'beforeSubmit' middleware cancelled`);
322
+ return;
323
+ }
324
+ else {
325
+ this.log.error(`error in 'beforeSubmit' middleware`);
326
+ console.error(err);
327
+ }
328
+ }
329
+ form.submit();
330
+ },
331
+ formElementChange: (e) => {
332
+ const input = e.target;
333
+ const form = input?.form;
334
+ const searchInput = form?.querySelector(`input[${INPUT_ATTRIBUTE}]`);
335
+ if (form && searchInput && this.config.settings?.serializeForm) {
336
+ // get other form parameters (except the input)
337
+ const formParameters = getFormParameters(form, function (elem) {
338
+ return elem != searchInput;
339
+ });
340
+ // set parameters as globals
341
+ this.store.setService('urlManager', this.store.services.urlManager.reset().withGlobals(formParameters));
342
+ this.store.reset();
343
+ // rebuild trending terms with new UrlManager settings
344
+ if (this.config.settings?.trending?.enabled && this.config.settings?.trending?.limit && this.config.settings?.trending?.limit > 0) {
345
+ this.searchTrending();
346
+ }
347
+ }
348
+ },
349
+ input: (e) => {
350
+ // return focus on input if it was lost
351
+ if (e.isTrusted && this.store.state.focusedInput !== e.target) {
352
+ this.setFocused(e.target);
353
+ }
354
+ const value = e.target.value;
355
+ // prevent search when value is unchanged or empty
356
+ if (((!this.store.state.input && !value) || this.store.state.input == value) && this.store.loaded) {
357
+ return;
358
+ }
359
+ this.store.state.source = 'input';
360
+ this.store.state.input = value;
361
+ // remove merch redirect to prevent race condition
362
+ this.store.merchandising.redirect = '';
363
+ if (this.config?.settings?.syncInputs) {
364
+ const inputs = document.querySelectorAll(this.config.selector);
365
+ inputs.forEach((input) => {
366
+ input.value = value;
367
+ });
368
+ }
369
+ // TODO cancel any current requests?
370
+ clearTimeout(this.handlers.input.timeoutDelay);
371
+ const trendingResultsEnabled = this.store.trending?.length && this.config.settings?.trending?.enabled && this.config.settings?.trending?.showResults;
372
+ const historyResultsEnabled = this.store.history?.length && this.config.settings?.history?.enabled && this.config.settings?.history?.showResults;
373
+ this.handlers.input.timeoutDelay = setTimeout(() => {
374
+ if (!value) {
375
+ // there is no input value - reset state of store
376
+ this.store.reset();
377
+ // show results for trending or history (if configured) - trending has priority
378
+ if (trendingResultsEnabled) {
379
+ this.store.trending[0].preview();
380
+ }
381
+ else if (historyResultsEnabled) {
382
+ this.store.history[0].preview();
383
+ }
384
+ else {
385
+ // no input - need to reset URL
386
+ this.urlManager.reset().go();
387
+ }
388
+ }
389
+ else {
390
+ // new query in the input - trigger a new search via UrlManager
391
+ this.store.state.locks.terms.unlock();
392
+ this.store.state.locks.facets.unlock();
393
+ this.urlManager.set({ query: this.store.state.input }).go();
394
+ }
395
+ }, INPUT_DELAY);
396
+ },
397
+ timeoutDelay: undefined,
398
+ },
399
+ document: {
400
+ click: (e) => {
401
+ const inputs = document.querySelectorAll(this.config.selector);
402
+ // if the click is on an input or a form with inputs, stop propagation
403
+ if (Array.from(inputs).includes(e.target) ||
404
+ (e.target?.nodeName == 'FORM' && e.target.querySelectorAll(this.config.selector).length)) {
405
+ e.stopPropagation();
406
+ }
407
+ else {
408
+ this.setFocused();
409
+ }
410
+ },
411
+ },
412
+ };
413
+ this.searchTrending = async (options) => {
414
+ let trending;
415
+ const storedTerms = this.storage.get('terms');
416
+ if (storedTerms && !options?.limit) {
417
+ // terms exist in storage, update store
418
+ trending = JSON.parse(storedTerms);
419
+ }
420
+ else {
421
+ // query for trending terms, save to storage, update store
422
+ const trendingParams = {
423
+ limit: options?.limit || this.config.settings?.trending?.limit || 5,
424
+ };
425
+ const trendingProfile = this.profiler.create({ type: 'event', name: 'trending', context: trendingParams }).start();
426
+ trending = await this.client.trending(trendingParams);
427
+ trendingProfile.stop();
428
+ this.log.profile(trendingProfile);
429
+ if (trending?.trending.queries?.length) {
430
+ this.storage.set('terms', JSON.stringify(trending));
431
+ }
432
+ }
433
+ this.store.updateTrendingTerms(trending);
434
+ };
435
+ this.search = async () => {
436
+ try {
437
+ if (!this.initialized) {
438
+ await this.init();
439
+ }
440
+ // if urlManager has no query, there will be no need to get params and no query
441
+ if (!this.urlManager.state.query) {
442
+ return;
443
+ }
444
+ const params = this.params;
445
+ // if params have no query do not search
446
+ if (!params?.search?.query?.string) {
447
+ return;
448
+ }
449
+ this.store.loading = true;
450
+ // clear the redirect URL until proper abort functionality is implemented
451
+ this.store.merchandising.redirect = '';
452
+ try {
453
+ await this.eventManager.fire('beforeSearch', {
454
+ controller: this,
455
+ request: params,
456
+ });
457
+ }
458
+ catch (err) {
459
+ if (err?.message == 'cancelled') {
460
+ this.log.warn(`'beforeSearch' middleware cancelled`);
461
+ return;
462
+ }
463
+ else {
464
+ this.log.error(`error in 'beforeSearch' middleware`);
465
+ throw err;
466
+ }
467
+ }
468
+ const searchProfile = this.profiler.create({ type: 'event', name: 'search', context: params }).start();
469
+ const { meta, search } = await this.client.autocomplete(params);
470
+ searchProfile.stop();
471
+ this.log.profile(searchProfile);
472
+ const responseId = search.tracking.responseId;
473
+ this.events[responseId] = this.events[responseId] || { product: {}, banner: {} };
474
+ const previousResponseId = this.store.results[0]?.responseId;
475
+ if (previousResponseId && previousResponseId === responseId) {
476
+ const impressedResultIds = Object.keys(this.events[responseId].product || {}).filter((resultId) => this.events[responseId].product?.[resultId]?.impression);
477
+ this.events[responseId] = {
478
+ product: impressedResultIds.reduce((acc, resultId) => {
479
+ acc[resultId] = { impression: true };
480
+ return acc;
481
+ }, {}),
482
+ banner: this.events[responseId].banner,
483
+ };
484
+ }
485
+ else {
486
+ this.events[responseId] = { product: {}, banner: {} };
487
+ }
488
+ const afterSearchProfile = this.profiler.create({ type: 'event', name: 'afterSearch', context: params }).start();
489
+ try {
490
+ await this.eventManager.fire('afterSearch', {
491
+ controller: this,
492
+ request: params,
493
+ response: {
494
+ meta,
495
+ search,
496
+ },
497
+ });
498
+ }
499
+ catch (err) {
500
+ if (err?.message == 'cancelled') {
501
+ this.log.warn(`'afterSearch' middleware cancelled`);
502
+ afterSearchProfile.stop();
503
+ return;
504
+ }
505
+ else {
506
+ this.log.error(`error in 'afterSearch' middleware`);
507
+ throw err;
508
+ }
509
+ }
510
+ afterSearchProfile.stop();
511
+ this.log.profile(afterSearchProfile);
512
+ // update the store
513
+ this.store.update({ meta, search });
514
+ const data = { responseId };
515
+ this.config.beacon?.enabled && this.tracker.events.autocomplete.render({ data, siteId: this.config.globals?.siteId });
516
+ const afterStoreProfile = this.profiler.create({ type: 'event', name: 'afterStore', context: params }).start();
517
+ try {
518
+ await this.eventManager.fire('afterStore', {
519
+ controller: this,
520
+ request: params,
521
+ response: {
522
+ meta,
523
+ search,
524
+ },
525
+ });
526
+ }
527
+ catch (err) {
528
+ if (err?.message == 'cancelled') {
529
+ this.log.warn(`'afterStore' middleware cancelled`);
530
+ afterStoreProfile.stop();
531
+ return;
532
+ }
533
+ else {
534
+ this.log.error(`error in 'afterStore' middleware`);
535
+ throw err;
536
+ }
537
+ }
538
+ afterStoreProfile.stop();
539
+ this.log.profile(afterStoreProfile);
540
+ }
541
+ catch (err) {
542
+ if (err) {
543
+ if (err.err && err.fetchDetails) {
544
+ switch (err.fetchDetails.status) {
545
+ case 429: {
546
+ this.store.error = {
547
+ code: 429,
548
+ type: ErrorType.WARNING,
549
+ message: 'Too many requests try again later',
550
+ };
551
+ break;
552
+ }
553
+ case 500: {
554
+ this.store.error = {
555
+ code: 500,
556
+ type: ErrorType.ERROR,
557
+ message: 'Invalid Search Request or Service Unavailable',
558
+ };
559
+ break;
560
+ }
561
+ default: {
562
+ this.store.error = {
563
+ type: ErrorType.ERROR,
564
+ message: err.err.message,
565
+ };
566
+ break;
567
+ }
568
+ }
569
+ this.log.error(this.store.error);
570
+ this.handleError(err.err, err.fetchDetails);
571
+ }
572
+ else {
573
+ this.store.error = {
574
+ type: ErrorType.ERROR,
575
+ message: `Something went wrong... - ${err}`,
576
+ };
577
+ this.log.error(err);
578
+ this.handleError(err);
579
+ }
580
+ }
581
+ }
582
+ finally {
583
+ this.store.loading = false;
584
+ }
585
+ };
586
+ this.addToCart = async (_products) => {
587
+ const products = typeof _products?.slice == 'function' ? _products.slice() : [_products];
588
+ if (!_products || products.length === 0) {
589
+ this.log.warn('No products provided to autocomplete controller.addToCart');
590
+ return;
591
+ }
592
+ products.forEach((product) => {
593
+ this.track.product.addToCart(product);
594
+ });
595
+ if (products.length > 0) {
596
+ this.eventManager.fire('addToCart', { controller: this, products });
597
+ }
598
+ };
599
+ // deep merge config with defaults
600
+ this.config = deepmerge(defaultConfig, this.config);
601
+ // normalize trending config (old method requires only a limit to be set)
602
+ if (this.config.settings?.trending?.limit && typeof this.config.settings?.trending?.enabled === 'undefined') {
603
+ this.config.settings = {
604
+ ...this.config.settings,
605
+ trending: {
606
+ enabled: true,
607
+ ...this.config.settings.trending,
608
+ },
609
+ };
610
+ }
611
+ // normalize history config (old method requires only a limit to be set)
612
+ if (this.config.settings?.history?.limit && typeof this.config.settings?.history?.enabled === 'undefined') {
613
+ this.config.settings = {
614
+ ...this.config.settings,
615
+ history: {
616
+ enabled: true,
617
+ ...this.config.settings.history,
618
+ },
619
+ };
620
+ }
621
+ this.store.setConfig(this.config);
622
+ // get current search from url before detaching
623
+ if (this.config.settings.initializeFromUrl) {
624
+ this.store.state.input = this.urlManager.state.query;
625
+ // reset to force search on focus
626
+ // TODO: make a config setting for this
627
+ this.urlManager.reset().go();
628
+ }
629
+ // persist trending terms in local storage
630
+ this.storage = new StorageStore({
631
+ type: 'session',
632
+ key: `athos-controller-${this.config.id}`,
633
+ });
634
+ // add 'afterSearch' middleware
635
+ this.eventManager.on('afterSearch', async (ac, next) => {
636
+ await next();
637
+ // cancel search if no input or query doesn't match current urlState
638
+ if (ac.response.search.autocomplete?.query != ac.controller.urlManager.state.query) {
639
+ return false;
640
+ }
641
+ });
642
+ this.eventManager.on('beforeSubmit', async (ac, next) => {
643
+ await next();
644
+ const loading = ac.controller.store.loading;
645
+ if (loading)
646
+ return;
647
+ const inputState = ac.controller.store.state.input;
648
+ const redirectURL = ac.controller.store.merchandising?.redirect;
649
+ if (this.config?.settings?.redirects?.merchandising && inputState && redirectURL) {
650
+ this.track.redirect({ redirectURL, responseId: ac.controller.store.merchandising?.responseId });
651
+ window.location.href = redirectURL;
652
+ return false;
653
+ }
654
+ if (this.config?.settings?.redirects?.singleResult) {
655
+ const { results } = ac.controller.store;
656
+ //remove inline banners
657
+ const filteredResults = results.filter((result) => result.type == 'product');
658
+ const singleResultUrl = filteredResults.length === 1 && filteredResults[0].mappings.core?.url;
659
+ if (singleResultUrl) {
660
+ window.location.href = singleResultUrl;
661
+ return false;
662
+ }
663
+ }
664
+ });
665
+ // attach config plugins and event middleware
666
+ this.use(this.config);
667
+ }
668
+ get params() {
669
+ const urlState = this.urlManager.state;
670
+ const params = deepmerge({ ...getSearchParams(urlState) }, this.config.globals || {});
671
+ const { userId, sessionId, pageLoadId, shopperId } = this.tracker.getContext();
672
+ params.tracking = params.tracking || {};
673
+ params.tracking.domain = window.location.href;
674
+ if (userId) {
675
+ params.tracking.userId = userId;
676
+ }
677
+ if (sessionId) {
678
+ params.tracking.sessionId = sessionId;
679
+ }
680
+ if (pageLoadId) {
681
+ params.tracking.pageLoadId = pageLoadId;
682
+ }
683
+ if (this.store.state.input) {
684
+ params.search = params.search || {};
685
+ params.search.input = this.store.state.input;
686
+ }
687
+ if (this.store.state.source) {
688
+ params.search = params.search || {};
689
+ params.search.source = this.store.state.source;
690
+ }
691
+ if (!this.config.globals?.personalization?.disabled) {
692
+ const cartItems = this.tracker.cookies.cart.get();
693
+ if (cartItems.length) {
694
+ params.personalization = params.personalization || {};
695
+ params.personalization.cart = cartItems.join(',');
696
+ }
697
+ const lastViewedItems = this.tracker.cookies.viewed.get();
698
+ if (lastViewedItems.length) {
699
+ params.personalization = params.personalization || {};
700
+ params.personalization.lastViewed = lastViewedItems.join(',');
701
+ }
702
+ if (shopperId) {
703
+ params.personalization = params.personalization || {};
704
+ params.personalization.shopper = shopperId;
705
+ }
706
+ }
707
+ return params;
708
+ }
709
+ async setFocused(inputElement) {
710
+ if (this.store.state.focusedInput !== inputElement) {
711
+ this.store.state.focusedInput = inputElement;
712
+ // fire focusChange event
713
+ try {
714
+ try {
715
+ await this.eventManager.fire('focusChange', {
716
+ controller: this,
717
+ });
718
+ }
719
+ catch (err) {
720
+ if (err?.message == 'cancelled') {
721
+ this.log.warn(`'focusChange' middleware cancelled`);
722
+ }
723
+ else {
724
+ this.log.error(`error in 'focusChange' middleware`);
725
+ throw err;
726
+ }
727
+ }
728
+ }
729
+ catch (err) {
730
+ if (err) {
731
+ console.error(err);
732
+ }
733
+ }
734
+ }
735
+ inputElement?.dispatchEvent(new Event('input'));
736
+ }
737
+ reset() {
738
+ // reset input values and state
739
+ const inputs = document.querySelectorAll(this.config.selector);
740
+ inputs.forEach((input) => {
741
+ input.value = '';
742
+ });
743
+ this.store.reset();
744
+ }
745
+ unbind() {
746
+ const inputs = document.querySelectorAll(`input[${INPUT_ATTRIBUTE}]`);
747
+ inputs?.forEach((input) => {
748
+ input.removeEventListener('input', this.handlers.input.input);
749
+ input.removeEventListener('keydown', this.handlers.input.enterKey);
750
+ input.removeEventListener('keydown', this.handlers.input.escKey);
751
+ input.removeEventListener('focus', this.handlers.input.focus);
752
+ if (input.form) {
753
+ input.form.removeEventListener('submit', this.handlers.input.formSubmit);
754
+ unbindFormParameters(input.form, this.handlers.input.formElementChange);
755
+ }
756
+ });
757
+ document.removeEventListener('click', this.handlers.document.click);
758
+ }
759
+ async bind() {
760
+ if (!this.initialized) {
761
+ await this.init();
762
+ }
763
+ this.unbind();
764
+ const inputs = document.querySelectorAll(this.config.selector);
765
+ inputs.forEach((input) => {
766
+ input.setAttribute('spellcheck', 'false');
767
+ input.setAttribute('autocomplete', 'off');
768
+ input.setAttribute('autocorrect', 'off');
769
+ input.setAttribute('autocapitalize', 'none');
770
+ input.setAttribute(INPUT_ATTRIBUTE, '');
771
+ this.config.settings?.bind?.input && input.addEventListener('input', this.handlers.input.input);
772
+ if (this.config?.settings?.initializeFromUrl && !input.value && this.store.state.input) {
773
+ input.value = this.store.state.input;
774
+ }
775
+ input.addEventListener('focus', this.handlers.input.focus);
776
+ input.addEventListener('keydown', this.handlers.input.escKey);
777
+ const form = input.form;
778
+ let formActionUrl;
779
+ if (this.config.action) {
780
+ this.config.settings?.bind?.submit && input.addEventListener('keydown', this.handlers.input.enterKey);
781
+ formActionUrl = this.config.action;
782
+ }
783
+ else if (form) {
784
+ this.config.settings?.bind?.submit && form.addEventListener('submit', this.handlers.input.formSubmit);
785
+ formActionUrl = form.action || '';
786
+ // serializeForm will include additional form element in our urlManager as globals
787
+ if (this.config.settings?.serializeForm) {
788
+ bindFormParameters(form, this.handlers.input.formElementChange, function (elem) {
789
+ return elem != input;
790
+ });
791
+ const formParameters = getFormParameters(form, function (elem) {
792
+ return elem != input;
793
+ });
794
+ // set parameters as globals
795
+ this.store.setService('urlManager', this.urlManager.reset().withGlobals(formParameters));
796
+ }
797
+ }
798
+ // set the root URL on urlManager
799
+ if (formActionUrl) {
800
+ this.store.setService('urlManager', this.store.services.urlManager.withConfig((translatorConfig) => {
801
+ return {
802
+ ...translatorConfig,
803
+ urlRoot: formActionUrl,
804
+ };
805
+ }));
806
+ }
807
+ // if the input is currently focused, trigger setFocues which will eventually trigger input - but not if loading
808
+ if (document.activeElement === input && !this.store.loading) {
809
+ this.setFocused(input);
810
+ }
811
+ });
812
+ // get trending terms - this is at the bottom because urlManager changes need to be in place before creating the store
813
+ if (this.config.settings?.trending?.enabled &&
814
+ this.config.settings?.trending?.limit &&
815
+ this.config.settings?.trending?.limit > 0 &&
816
+ !this.store.trending?.length) {
817
+ this.searchTrending();
818
+ }
819
+ // if we are not preventing via `disableClickOutside` setting
820
+ if (!this.config.settings?.disableClickOutside) {
821
+ document.addEventListener('click', this.handlers.document.click);
822
+ }
823
+ }
824
+ }
825
+ function addHiddenFormInput(form, name, value) {
826
+ const inputElem = document.createElement('input');
827
+ inputElem.type = 'hidden';
828
+ inputElem.name = name;
829
+ inputElem.value = value;
830
+ // remove existing form element if it exists (prevent duplicates)
831
+ form.querySelector(`[type="hidden"][name="${name}"]`)?.remove();
832
+ // append form element
833
+ form.append(inputElem);
834
+ }
835
+ async function timeout(time) {
836
+ return new Promise((resolve) => {
837
+ window.setTimeout(resolve, time);
838
+ });
839
+ }
840
+ // for grabbing other parameters from the form and using them in UrlManager
841
+ const INPUT_TYPE_BLOCKLIST = ['file', 'reset', 'submit', 'button', 'image', 'password'];
842
+ function getFormParameters(form, filterFn) {
843
+ const parameters = {};
844
+ if (typeof form == 'object' && form.nodeName == 'FORM') {
845
+ for (let i = form.elements.length - 1; i >= 0; i--) {
846
+ const elem = form.elements[i];
847
+ if (typeof filterFn == 'function' && !filterFn(elem)) {
848
+ continue;
849
+ }
850
+ if (elem.name && !INPUT_TYPE_BLOCKLIST.includes(elem.type)) {
851
+ if ((elem.type != 'checkbox' && elem.type != 'radio') || elem.checked) {
852
+ parameters[elem.name] = elem.value;
853
+ }
854
+ }
855
+ }
856
+ }
857
+ return parameters;
858
+ }
859
+ // this picks up changes to the form
860
+ function bindFormParameters(form, fn, filterFn) {
861
+ if (typeof form == 'object' && form.nodeName == 'FORM') {
862
+ for (let i = form.elements.length - 1; i >= 0; i--) {
863
+ const elem = form.elements[i];
864
+ if (typeof filterFn == 'function' && !filterFn(elem)) {
865
+ continue;
866
+ }
867
+ if (elem.name && !INPUT_TYPE_BLOCKLIST.includes(elem.type)) {
868
+ elem.addEventListener('change', fn);
869
+ }
870
+ }
871
+ }
872
+ }
873
+ function unbindFormParameters(form, fn) {
874
+ if (typeof form == 'object' && form.nodeName == 'FORM') {
875
+ for (let i = form.elements.length - 1; i >= 0; i--) {
876
+ const elem = form.elements[i];
877
+ if (elem.name && !INPUT_TYPE_BLOCKLIST.includes(elem.type)) {
878
+ elem.removeEventListener('change', fn);
879
+ }
880
+ }
881
+ }
882
+ }