@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,795 @@
1
+ import deepmerge from 'deepmerge';
2
+ import cssEscape from 'css.escape';
3
+ import { AbstractController } from '../Abstract/AbstractController';
4
+ import { StorageStore, ErrorType } from '@athoscommerce/snap-store-mobx';
5
+ import { getSearchParams } from '../utils/getParams';
6
+ import { ControllerTypes } from '../types';
7
+ import { CLICK_DUPLICATION_TIMEOUT, isClickWithinProductLink } from '../utils/isClickWithinProductLink';
8
+ import { isClickWithinBannerLink } from '../utils/isClickWithinBannerLink';
9
+ const BACKGROUND_FILTER_FIELD_MATCHES = ['collection', 'category', 'categories', 'hierarchy', 'brand', 'manufacturer'];
10
+ const BACKGROUND_FILTERS_VALUE_FLAGS = [1, 0, '1', '0', 'true', 'false', true, false];
11
+ const defaultConfig = {
12
+ id: 'search',
13
+ globals: {},
14
+ beacon: {
15
+ enabled: true,
16
+ },
17
+ settings: {
18
+ redirects: {
19
+ merchandising: true,
20
+ singleResult: true,
21
+ },
22
+ facets: {
23
+ trim: true,
24
+ pinFiltered: true,
25
+ storeRange: true,
26
+ autoOpenActive: true,
27
+ },
28
+ },
29
+ };
30
+ export class SearchController extends AbstractController {
31
+ constructor(config, { client, store, urlManager, eventManager, profiler, logger, tracker }, context) {
32
+ super(config, { client, store, urlManager, eventManager, profiler, logger, tracker }, context);
33
+ this.type = ControllerTypes.search;
34
+ this.previousResults = [];
35
+ this.page = {
36
+ type: 'search',
37
+ };
38
+ this.events = {};
39
+ this.track = {
40
+ banner: {
41
+ impression: ({ uid, responseId }) => {
42
+ if (!uid) {
43
+ this.log.warn('No banner provided to track.banner.impression');
44
+ return;
45
+ }
46
+ if (!this.events[responseId]) {
47
+ this.log.warn('No responseId found in controller, ensure correct controller is used');
48
+ return;
49
+ }
50
+ else if (this.events[responseId]?.banner[uid]?.impression) {
51
+ return;
52
+ }
53
+ const banner = { uid };
54
+ const data = {
55
+ responseId,
56
+ banners: [banner],
57
+ results: [],
58
+ };
59
+ this.eventManager.fire('track.banner.impression', { controller: this, product: { uid }, trackEvent: data });
60
+ this.config.beacon?.enabled && this.tracker.events[this.page.type].impression({ data, siteId: this.config.globals?.siteId });
61
+ this.events[responseId].banner[uid] = this.events[responseId].banner[uid] || {};
62
+ this.events[responseId].banner[uid].impression = true;
63
+ },
64
+ click: (e, banner) => {
65
+ if (!banner) {
66
+ this.log.warn('No banner provided to track.banner.click');
67
+ return;
68
+ }
69
+ const { responseId, uid } = banner;
70
+ if (!this.events[responseId]) {
71
+ this.log.warn('No responseId found in controller, ensure correct controller is used');
72
+ return;
73
+ }
74
+ if (isClickWithinBannerLink(e)) {
75
+ if (this.events?.[responseId]?.banner[uid]?.clickThrough) {
76
+ return;
77
+ }
78
+ this.track.banner.clickThrough(e, banner);
79
+ this.events[responseId].banner[uid] = this.events[responseId].banner[uid] || {};
80
+ this.events[responseId].banner[uid].clickThrough = true;
81
+ setTimeout(() => {
82
+ this.events[responseId].banner[uid].clickThrough = false;
83
+ }, CLICK_DUPLICATION_TIMEOUT);
84
+ }
85
+ },
86
+ clickThrough: (e, { uid, responseId }) => {
87
+ if (!uid) {
88
+ this.log.warn('No banner provided to track.banner.clickThrough');
89
+ return;
90
+ }
91
+ if (!this.events[responseId]) {
92
+ this.log.warn('No responseId found in controller, ensure correct controller is used');
93
+ return;
94
+ }
95
+ const banner = { uid };
96
+ const data = {
97
+ responseId,
98
+ banners: [banner],
99
+ };
100
+ this.eventManager.fire('track.banner.clickThrough', { controller: this, event: e, product: { uid }, trackEvent: data });
101
+ this.config.beacon?.enabled && this.tracker.events[this.page.type].clickThrough({ data, siteId: this.config.globals?.siteId });
102
+ this.events[responseId].banner[uid] = this.events[responseId].banner[uid] || {};
103
+ this.events[responseId].banner[uid].clickThrough = true;
104
+ setTimeout(() => {
105
+ this.events[responseId].banner[uid].clickThrough = false;
106
+ }, CLICK_DUPLICATION_TIMEOUT);
107
+ },
108
+ },
109
+ product: {
110
+ clickThrough: (e, result) => {
111
+ if (!result) {
112
+ this.log.warn('No result provided to track.product.clickThrough');
113
+ return;
114
+ }
115
+ const responseId = result.responseId;
116
+ if (!this.events[responseId]) {
117
+ this.log.warn('No responseId found in controller, ensure correct controller is used');
118
+ return;
119
+ }
120
+ const target = e.target;
121
+ const resultHref = result.display?.mappings.core?.url || result.mappings.core?.url || '';
122
+ const elemHref = target?.getAttribute('href');
123
+ // the href that should be used for restoration - if the elemHref contains the resultHref - use resultHref
124
+ const storedHref = elemHref?.indexOf(resultHref) != -1 ? resultHref : elemHref || resultHref;
125
+ const scrollMap = {};
126
+ // generate the selector using element class and parent classes
127
+ const selector = generateHrefSelector(target, storedHref);
128
+ const domRect = selector ? document?.querySelector(selector)?.getBoundingClientRect() : undefined;
129
+ // store element position data to scrollMap
130
+ if (selector || storedHref || domRect) {
131
+ try {
132
+ const lastRequest = this.storage.get('lastStringyParams');
133
+ if (lastRequest) {
134
+ const storableRequestParams = getStorableRequestParams(JSON.parse(lastRequest));
135
+ const storableStringyParams = JSON.stringify(storableRequestParams);
136
+ scrollMap[storableStringyParams] = { domRect, href: storedHref, selector };
137
+ }
138
+ }
139
+ catch (err) {
140
+ // failed to get lastStringParams
141
+ this.log.warn('Failed to save srcollMap!', err);
142
+ }
143
+ }
144
+ // store position data or empty object
145
+ this.storage.set('scrollMap', scrollMap);
146
+ const type = (['product', 'banner'].includes(result.type) ? result.type : 'product');
147
+ const item = {
148
+ type,
149
+ uid: result.id ? '' + result.id : '',
150
+ ...(type === 'product'
151
+ ? {
152
+ parentId: result.mappings.core?.parentId ? '' + result.mappings.core?.parentId : '',
153
+ sku: result.mappings.core?.sku ? '' + result.mappings.core?.sku : undefined,
154
+ }
155
+ : {}),
156
+ };
157
+ const data = {
158
+ responseId,
159
+ results: [item],
160
+ };
161
+ this.eventManager.fire('track.product.clickThrough', { controller: this, event: e, product: result, trackEvent: data });
162
+ this.config.beacon?.enabled && this.tracker.events[this.page.type].clickThrough({ data, siteId: this.config.globals?.siteId });
163
+ },
164
+ click: (e, result) => {
165
+ if (!result) {
166
+ this.log.warn('No result provided to track.product.click');
167
+ return;
168
+ }
169
+ const responseId = result.responseId;
170
+ if (!this.events[responseId]) {
171
+ this.log.warn('No responseId found in controller, ensure correct controller is used');
172
+ return;
173
+ }
174
+ if (result.type === 'banner' && isClickWithinBannerLink(e)) {
175
+ if (this.events?.[responseId]?.product[result.id]?.inlineBannerClickThrough) {
176
+ return;
177
+ }
178
+ this.track.product.clickThrough(e, result);
179
+ this.events[responseId].product[result.id] = this.events[responseId].product[result.id] || {};
180
+ this.events[responseId].product[result.id].inlineBannerClickThrough = true;
181
+ setTimeout(() => {
182
+ this.events[responseId].product[result.id].inlineBannerClickThrough = false;
183
+ }, CLICK_DUPLICATION_TIMEOUT);
184
+ }
185
+ else if (isClickWithinProductLink(e, result)) {
186
+ if (this.events?.[responseId]?.product[result.id]?.productClickThrough) {
187
+ return;
188
+ }
189
+ this.track.product.clickThrough(e, result);
190
+ this.events[responseId].product[result.id] = this.events[responseId].product[result.id] || {};
191
+ this.events[responseId].product[result.id].productClickThrough = true;
192
+ setTimeout(() => {
193
+ this.events[responseId].product[result.id].productClickThrough = false;
194
+ }, CLICK_DUPLICATION_TIMEOUT);
195
+ }
196
+ },
197
+ impression: (result) => {
198
+ if (!result) {
199
+ this.log.warn('No result provided to track.product.impression');
200
+ return;
201
+ }
202
+ const responseId = result.responseId;
203
+ if (!this.events[responseId]) {
204
+ this.log.warn('No responseId found in controller, ensure correct controller is used');
205
+ return;
206
+ }
207
+ else if (this.events[responseId]?.product[result.id]?.impression) {
208
+ return;
209
+ }
210
+ const type = (['product', 'banner'].includes(result.type) ? result.type : 'product');
211
+ const item = {
212
+ type,
213
+ uid: result.id ? '' + result.id : '',
214
+ ...(type === 'product'
215
+ ? {
216
+ parentId: result.mappings.core?.parentId ? '' + result.mappings.core?.parentId : '',
217
+ sku: result.mappings.core?.sku ? '' + result.mappings.core?.sku : undefined,
218
+ }
219
+ : {}),
220
+ };
221
+ const data = {
222
+ responseId,
223
+ results: [item],
224
+ banners: [],
225
+ };
226
+ this.eventManager.fire('track.product.impression', { controller: this, product: result, trackEvent: data });
227
+ this.config.beacon?.enabled && this.tracker.events[this.page.type].impression({ data, siteId: this.config.globals?.siteId });
228
+ this.events[responseId].product[result.id] = this.events[responseId].product[result.id] || {};
229
+ this.events[responseId].product[result.id].impression = true;
230
+ },
231
+ addToCart: (result) => {
232
+ if (!result) {
233
+ this.log.warn('No result provided to track.product.addToCart');
234
+ return;
235
+ }
236
+ const responseId = result.responseId;
237
+ if (!this.events[responseId]) {
238
+ this.log.warn('No responseId found in controller, ensure correct controller is used');
239
+ return;
240
+ }
241
+ const product = {
242
+ parentId: result.mappings.core?.parentId ? '' + result.mappings.core?.parentId : '',
243
+ uid: result.id,
244
+ sku: result.mappings.core?.sku,
245
+ qty: result.quantity || 1,
246
+ price: Number(result.mappings.core?.price),
247
+ };
248
+ const data = {
249
+ responseId,
250
+ results: [product],
251
+ };
252
+ this.eventManager.fire('track.product.addToCart', { controller: this, product: result, trackEvent: data });
253
+ this.config.beacon?.enabled && this.tracker.events[this.page.type].addToCart({ data, siteId: this.config.globals?.siteId });
254
+ },
255
+ },
256
+ redirect: ({ redirectURL, responseId }) => {
257
+ if (!redirectURL) {
258
+ this.log.warn('No redirectURL provided to track.redirect');
259
+ return;
260
+ }
261
+ const data = {
262
+ responseId,
263
+ redirect: redirectURL,
264
+ };
265
+ this.eventManager.fire('track.redirect', { controller: this, redirectURL, trackEvent: data });
266
+ this.config.beacon?.enabled && this.tracker.events.search.redirect({ data, siteId: this.config.globals?.siteId });
267
+ },
268
+ };
269
+ this.search = async () => {
270
+ try {
271
+ if (!this.initialized) {
272
+ await this.init();
273
+ }
274
+ const params = this.params;
275
+ if (params.search?.query?.string && params.search?.query?.string.length) {
276
+ // save it to the history store
277
+ this.store.history.save(params.search.query.string);
278
+ }
279
+ this.store.loading = true;
280
+ try {
281
+ await this.eventManager.fire('beforeSearch', {
282
+ controller: this,
283
+ request: params,
284
+ });
285
+ }
286
+ catch (err) {
287
+ if (err?.message == 'cancelled') {
288
+ this.log.warn(`'beforeSearch' middleware cancelled`);
289
+ return;
290
+ }
291
+ else {
292
+ this.log.error(`error in 'beforeSearch' middleware`);
293
+ throw err;
294
+ }
295
+ }
296
+ const stringyParams = JSON.stringify(getStorableRequestParams(params));
297
+ const prevStringyParams = this.storage.get('lastStringyParams');
298
+ if (this.store.loaded && stringyParams === prevStringyParams) {
299
+ // no param change - not searching
300
+ return;
301
+ }
302
+ const searchProfile = this.profiler.create({ type: 'event', name: 'search', context: params }).start();
303
+ let meta = {};
304
+ let search;
305
+ // infinite scroll functionality (after page 1)
306
+ if (this.config.settings?.infinite?.enabled && params.pagination?.page && params.pagination.page > 1) {
307
+ const preventBackfill = this.config.settings.infinite?.backfill && !this.store.results.length && params.pagination.page > this.config.settings.infinite.backfill;
308
+ const dontBackfill = !this.config.settings.infinite?.backfill && !this.store.results.length;
309
+ // if the page is higher than the backfill setting redirect back to page 1
310
+ if (preventBackfill || dontBackfill) {
311
+ this.storage.set('scrollMap', {});
312
+ this.urlManager.set('page', 1).go();
313
+ return;
314
+ }
315
+ // infinite backfill is enabled AND we have not yet fetched any results
316
+ if (this.config.settings?.infinite.backfill && !this.store.loaded) {
317
+ // create requests for all missing pages (using Arrray(page).fill() to populate an array to map)
318
+ const backfillRequestsParams = [];
319
+ const backfillRequests = Array(params.pagination.page)
320
+ .fill('backfill')
321
+ .map((v, i) => {
322
+ const backfillParams = deepmerge({ ...params }, { pagination: { page: i + 1 }, search: { redirectResponse: 'full' } });
323
+ // don't include page parameter if on page 1
324
+ if (i + 1 == 1) {
325
+ delete backfillParams?.pagination?.page;
326
+ if (this.config.settings?.redirects?.merchandising) {
327
+ // redirect setting
328
+ // DUPLICATED LOGIC can be found in params getter
329
+ delete backfillParams?.search?.redirectResponse;
330
+ }
331
+ }
332
+ backfillRequestsParams.push(backfillParams);
333
+ return this.client[this.page.type](backfillParams);
334
+ });
335
+ const backfillResponses = await Promise.all(backfillRequests);
336
+ // backfillResponses are [{ meta: MetaResponseModel, search: SearchResponseModel }]
337
+ // set the meta and response to the first page of backfillResponses
338
+ meta = backfillResponses[0].meta;
339
+ search = backfillResponses[0].search;
340
+ // accumulate results from all backfill responses
341
+ const backfillResults = backfillResponses.reduce((results, response) => {
342
+ const responseId = response.search.tracking.responseId;
343
+ this.events[responseId] = this.events[responseId] || { product: {}, banner: {} };
344
+ return results.concat(...response.search.results);
345
+ }, []);
346
+ // overwrite pagination params to expected state
347
+ search.pagination.totalPages = Math.ceil(search.pagination.totalResults / search.pagination.pageSize);
348
+ search.pagination.page = params.pagination?.page;
349
+ // set the response results with results from backfill responses
350
+ search.results = backfillResults;
351
+ }
352
+ else {
353
+ // infinite with no backfills.
354
+ const infiniteResponse = await this.client[this.page.type](params);
355
+ meta = infiniteResponse.meta;
356
+ search = infiniteResponse.search;
357
+ const responseId = search.tracking.responseId;
358
+ this.events[responseId] = this.events[responseId] || { product: {}, banner: {} };
359
+ // append new results to previous results
360
+ search.results = [...this.previousResults, ...(search.results || [])];
361
+ }
362
+ }
363
+ else {
364
+ // normal request
365
+ // clear previousResults to prevent infinite scroll from using them
366
+ this.previousResults = [];
367
+ const searchResponse = await this.client[this.page.type](params);
368
+ meta = searchResponse.meta;
369
+ search = searchResponse.search;
370
+ const responseId = search.tracking.responseId;
371
+ this.events[responseId] = { product: {}, banner: {} };
372
+ }
373
+ // need to reconsolidate in order for references to be preserved
374
+ const response = { meta, search };
375
+ searchProfile.stop();
376
+ this.log.profile(searchProfile);
377
+ const afterSearchProfile = this.profiler.create({ type: 'event', name: 'afterSearch', context: params }).start();
378
+ try {
379
+ await this.eventManager.fire('afterSearch', {
380
+ controller: this,
381
+ request: params,
382
+ response,
383
+ });
384
+ }
385
+ catch (err) {
386
+ if (err?.message == 'cancelled') {
387
+ this.log.warn(`'afterSearch' middleware cancelled`);
388
+ afterSearchProfile.stop();
389
+ return;
390
+ }
391
+ else {
392
+ this.log.error(`error in 'afterSearch' middleware`);
393
+ throw err;
394
+ }
395
+ }
396
+ afterSearchProfile.stop();
397
+ this.log.profile(afterSearchProfile);
398
+ // store previous results for infinite usage (need to alsways store in case switch to infinite after pagination)
399
+ this.previousResults = JSON.parse(JSON.stringify(response.search.results));
400
+ // update the store
401
+ this.store.update(response);
402
+ const data = { responseId: response.search.tracking.responseId };
403
+ this.config.beacon?.enabled && this.tracker.events[this.page.type].render({ data, siteId: this.config.globals?.siteId });
404
+ const afterStoreProfile = this.profiler.create({ type: 'event', name: 'afterStore', context: params }).start();
405
+ try {
406
+ await this.eventManager.fire('afterStore', {
407
+ controller: this,
408
+ request: params,
409
+ response,
410
+ });
411
+ }
412
+ catch (err) {
413
+ if (err?.message == 'cancelled') {
414
+ this.log.warn(`'afterStore' middleware cancelled`);
415
+ afterStoreProfile.stop();
416
+ return;
417
+ }
418
+ else {
419
+ this.log.error(`error in 'afterStore' middleware`);
420
+ throw err;
421
+ }
422
+ }
423
+ afterStoreProfile.stop();
424
+ this.log.profile(afterStoreProfile);
425
+ }
426
+ catch (err) {
427
+ if (err) {
428
+ if (err.err && err.fetchDetails) {
429
+ switch (err.fetchDetails.status) {
430
+ case 429: {
431
+ this.store.error = {
432
+ code: 429,
433
+ type: ErrorType.WARNING,
434
+ message: 'Too many requests try again later',
435
+ };
436
+ break;
437
+ }
438
+ case 500: {
439
+ this.store.error = {
440
+ code: 500,
441
+ type: ErrorType.ERROR,
442
+ message: 'Invalid Search Request or Service Unavailable',
443
+ };
444
+ break;
445
+ }
446
+ default: {
447
+ this.store.error = {
448
+ type: ErrorType.ERROR,
449
+ message: err.err.message,
450
+ };
451
+ break;
452
+ }
453
+ }
454
+ this.log.error(this.store.error);
455
+ this.handleError(err.err, err.fetchDetails);
456
+ }
457
+ else {
458
+ this.store.error = {
459
+ type: ErrorType.ERROR,
460
+ message: `Something went wrong... - ${err}`,
461
+ };
462
+ this.log.error(err);
463
+ this.handleError(err);
464
+ }
465
+ }
466
+ }
467
+ finally {
468
+ this.store.loading = false;
469
+ }
470
+ };
471
+ this.addToCart = async (_products) => {
472
+ const products = typeof _products?.slice == 'function' ? _products.slice() : [_products];
473
+ if (!_products || products.length === 0) {
474
+ this.log.warn('No products provided to search controller.addToCart');
475
+ return;
476
+ }
477
+ products.forEach((product) => {
478
+ this.track.product.addToCart(product);
479
+ });
480
+ if (products.length > 0) {
481
+ this.eventManager.fire('addToCart', { controller: this, products });
482
+ }
483
+ };
484
+ // deep merge config with defaults
485
+ this.config = deepmerge(defaultConfig, this.config);
486
+ if (this.config.settings?.infinite &&
487
+ typeof this.config.settings?.infinite == 'object' &&
488
+ (Object.keys(this.config.settings?.infinite).length == 0 || typeof this.config.settings?.infinite?.backfill != 'undefined')) {
489
+ // infinite is enabled by setting config.infinite={} (old method)
490
+ // set config.infinite.enabled=true
491
+ this.config.settings = {
492
+ ...this.config.settings,
493
+ infinite: {
494
+ enabled: true,
495
+ ...this.config.settings.infinite,
496
+ },
497
+ };
498
+ }
499
+ // set restorePosition to be enabled by default when using infinite (if not provided)
500
+ if (this.config.settings?.infinite?.enabled && typeof this.config.settings.restorePosition == 'undefined') {
501
+ this.config.settings.restorePosition = { enabled: true };
502
+ }
503
+ this.store.setConfig(this.config);
504
+ this.storage = new StorageStore({
505
+ type: 'session',
506
+ key: `athos-controller-${this.config.id}`,
507
+ });
508
+ if (typeof this.context?.page === 'object' && ['search', 'category'].includes(this.context.page.type)) {
509
+ this.page = deepmerge(this.page, this.context.page);
510
+ }
511
+ this.eventManager.on('beforeSearch', async ({ request }, next) => {
512
+ // wait for other middleware to resolve
513
+ await next();
514
+ const req = request;
515
+ const query = req.search?.query;
516
+ if (!query) {
517
+ const hasCategoryBackgroundFilters = req.filters
518
+ ?.filter((filter) => filter.background)
519
+ .filter((filter) => {
520
+ return BACKGROUND_FILTER_FIELD_MATCHES.find((bgFilter) => {
521
+ return filter.field?.toLowerCase().includes(bgFilter);
522
+ });
523
+ })
524
+ .filter((filter) => {
525
+ return BACKGROUND_FILTERS_VALUE_FLAGS.every((flag) => {
526
+ switch (filter.type) {
527
+ case 'range':
528
+ const rangeFilter = filter;
529
+ return rangeFilter.value !== flag;
530
+ case 'value':
531
+ default:
532
+ const valueFilter = filter;
533
+ return valueFilter.value !== flag;
534
+ }
535
+ });
536
+ });
537
+ if (hasCategoryBackgroundFilters?.length) {
538
+ this.page = deepmerge(this.page, { type: 'category' });
539
+ }
540
+ }
541
+ });
542
+ // add 'afterSearch' middleware
543
+ this.eventManager.on('afterSearch', async (search, next) => {
544
+ const config = search.controller.config;
545
+ const redirectURL = search.response?.search?.merchandising?.redirect;
546
+ const searchStore = search.controller.store;
547
+ if (redirectURL && config?.settings?.redirects?.merchandising && !search?.response?.search?.filters?.length && !searchStore.loaded) {
548
+ // set loaded to true to prevent infinite search/reloading from happening
549
+ searchStore.loaded = true;
550
+ this.track.redirect({ redirectURL, responseId: search.response.search.tracking.responseId });
551
+ window.location.replace(redirectURL);
552
+ return false;
553
+ }
554
+ await next();
555
+ });
556
+ this.eventManager.on('afterStore', async (search, next) => {
557
+ await next();
558
+ // get scrollTo positioning and send it to 'restorePosition' event
559
+ const storableRequestParams = getStorableRequestParams(search.request);
560
+ const stringyParams = JSON.stringify(storableRequestParams);
561
+ this.storage.set('lastStringyParams', stringyParams);
562
+ const scrollMap = this.storage.get('scrollMap') || {};
563
+ const elementPosition = scrollMap[stringyParams];
564
+ if (!elementPosition) {
565
+ // search params have changed - empty the scrollMap
566
+ this.storage.set('scrollMap', {});
567
+ }
568
+ // not awaiting this event as it relies on render, and render is blocked by afterStore event
569
+ this.eventManager.fire('restorePosition', { controller: this, element: elementPosition });
570
+ });
571
+ this.eventManager.on('afterSearch', async (search, next) => {
572
+ await next();
573
+ // add hierarchy filter to filter summary
574
+ const facets = search.response.search.facets;
575
+ if (facets) {
576
+ facets.forEach((facet) => {
577
+ if (search.response.meta?.facets && facet.field) {
578
+ const field = facet.field || '';
579
+ const metaFacet = search.response.meta.facets[field];
580
+ const dataDelimiter = metaFacet?.hierarchyDelimiter || ' / ';
581
+ const filterSettings = this.config?.settings?.filters?.fields
582
+ ? this.config?.settings?.filters?.fields[field]
583
+ : this.config?.settings?.filters;
584
+ const displayDelimiter = filterSettings?.hierarchy?.displayDelimiter ?? ' / '; // choose delimiter for label
585
+ const showFullPath = filterSettings?.hierarchy?.showFullPath ?? false; // display full hierarchy path or just the current level
586
+ if (filterSettings?.hierarchy?.enabled &&
587
+ metaFacet &&
588
+ metaFacet.display === 'hierarchy' &&
589
+ facet.filtered &&
590
+ facet.values?.length > 0) {
591
+ const filteredValues = facet.values?.filter((val) => val?.filtered === true);
592
+ if (filteredValues && filteredValues.length) {
593
+ const filterToAdd = {
594
+ field: facet.field,
595
+ //escape special charactors used in regex
596
+ label: showFullPath
597
+ ? (filteredValues[0].value ?? filteredValues[0].label ?? '').replace(new RegExp(dataDelimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), displayDelimiter)
598
+ : filteredValues[0].label,
599
+ type: 'value',
600
+ };
601
+ if (search.response.search.filters) {
602
+ search.response.search.filters.push(filterToAdd);
603
+ }
604
+ else {
605
+ search.response.search.filters = [filterToAdd];
606
+ }
607
+ }
608
+ }
609
+ }
610
+ });
611
+ }
612
+ });
613
+ this.eventManager.on('afterStore', async (search, next) => {
614
+ await next();
615
+ const controller = search.controller;
616
+ const response = search.response.search;
617
+ if (controller.store.loaded && !controller.store.error) {
618
+ const config = search.controller.config;
619
+ const nonBackgroundFilters = search?.request?.filters?.filter((filter) => !filter.background);
620
+ const singleResultUrl = response?.results?.length && response?.results[0].mappings?.core?.url;
621
+ if (config?.settings?.redirects?.singleResult &&
622
+ response.search?.query &&
623
+ response.pagination?.totalResults === 1 &&
624
+ !nonBackgroundFilters?.length &&
625
+ singleResultUrl) {
626
+ window.location.replace(singleResultUrl);
627
+ return false;
628
+ }
629
+ }
630
+ });
631
+ // restore position
632
+ if (this.config.settings?.restorePosition?.enabled) {
633
+ this.eventManager.on('restorePosition', async ({ controller, element }, next) => {
634
+ // attempt to grab the element from storage if it is not provided
635
+ if (!element?.selector) {
636
+ const lastRequest = this.storage.get('lastStringyParams');
637
+ if (lastRequest) {
638
+ const storableRequestParams = getStorableRequestParams(JSON.parse(lastRequest));
639
+ const stringyParams = JSON.stringify(storableRequestParams);
640
+ const scrollMap = this.storage.get('scrollMap') || {};
641
+ element = scrollMap[stringyParams];
642
+ }
643
+ }
644
+ const scrollToPosition = () => {
645
+ return new Promise(async (resolve) => {
646
+ const maxCheckTime = 600;
647
+ const checkTime = 60;
648
+ const maxScrolls = Math.ceil(maxCheckTime / checkTime);
649
+ const maxCheckCount = maxScrolls + 2;
650
+ let scrollBackCount = 0;
651
+ let checkCount = 0;
652
+ let scrolledElem = undefined;
653
+ const checkAndScroll = () => {
654
+ let offset = element?.domRect?.top || 0;
655
+ let elem = document.querySelector(element?.selector);
656
+ // for case where the element clicked on has no height
657
+ while (elem && !elem.getBoundingClientRect().height) {
658
+ elem = elem.parentElement;
659
+ // original offset no longer applies since using different element
660
+ offset = 0;
661
+ }
662
+ if (elem) {
663
+ const { y } = elem.getBoundingClientRect();
664
+ scrollBackCount++;
665
+ // if the offset is off, we need to scroll into position (can be caused by lazy loaded images)
666
+ if (y > offset + 1 || y < offset - 1) {
667
+ window.scrollBy(0, y - offset);
668
+ }
669
+ else {
670
+ // don't need to scroll - it is right where we want it
671
+ scrolledElem = elem;
672
+ }
673
+ }
674
+ else {
675
+ checkCount++;
676
+ }
677
+ return true;
678
+ };
679
+ while (checkAndScroll() && scrollBackCount <= maxScrolls && checkCount <= maxCheckCount) {
680
+ await new Promise((resolve) => setTimeout(resolve, checkTime));
681
+ }
682
+ if (scrolledElem) {
683
+ controller.log.debug('restored position to: ', scrolledElem);
684
+ }
685
+ else {
686
+ controller.log.debug('attempted to scroll back to element with selector: ', element?.selector);
687
+ }
688
+ resolve();
689
+ });
690
+ };
691
+ if (element)
692
+ await scrollToPosition();
693
+ await next();
694
+ });
695
+ // fire restorePosition event on 'pageshow' when setting is enabled
696
+ if (this.config.settings?.restorePosition?.onPageShow) {
697
+ window.addEventListener('pageshow', (e) => {
698
+ if (e.persisted && this.store.loaded) {
699
+ this.eventManager.fire('restorePosition', { controller: this, element: {} });
700
+ }
701
+ });
702
+ }
703
+ }
704
+ // attach config plugins and event middleware
705
+ this.use(this.config);
706
+ }
707
+ get params() {
708
+ const params = deepmerge({ ...getSearchParams(this.urlManager.state) }, this.config.globals || {});
709
+ // redirect setting
710
+ // DUPLICATED LOGIC can be found in infinite backfill (change both if updating)
711
+ if (!this.config.settings?.redirects?.merchandising || this.store.loaded) {
712
+ params.search = params.search || {};
713
+ params.search.redirectResponse = 'full';
714
+ }
715
+ params.tracking = params.tracking || {};
716
+ params.tracking.domain = window.location.href;
717
+ const { userId, sessionId, pageLoadId, shopperId } = this.tracker.getContext();
718
+ if (userId) {
719
+ params.tracking.userId = userId;
720
+ }
721
+ if (sessionId) {
722
+ params.tracking.sessionId = sessionId;
723
+ }
724
+ if (pageLoadId) {
725
+ params.tracking.pageLoadId = pageLoadId;
726
+ }
727
+ if (!this.config.globals?.personalization?.disabled) {
728
+ const cartItems = this.tracker.cookies.cart.get();
729
+ if (cartItems.length) {
730
+ params.personalization = params.personalization || {};
731
+ params.personalization.cart = cartItems.join(',');
732
+ }
733
+ const lastViewedItems = this.tracker.cookies.viewed.get();
734
+ if (lastViewedItems.length) {
735
+ params.personalization = params.personalization || {};
736
+ params.personalization.lastViewed = lastViewedItems.join(',');
737
+ }
738
+ if (shopperId) {
739
+ params.personalization = params.personalization || {};
740
+ params.personalization.shopper = shopperId;
741
+ }
742
+ }
743
+ return params;
744
+ }
745
+ }
746
+ export function getStorableRequestParams(request) {
747
+ return {
748
+ siteId: request.siteId,
749
+ sorts: request.sorts,
750
+ search: {
751
+ query: {
752
+ string: request?.search?.query?.string || '',
753
+ },
754
+ subQuery: request?.search?.subQuery || '',
755
+ },
756
+ filters: request.filters,
757
+ pagination: request.pagination,
758
+ facets: request.facets,
759
+ merchandising: {
760
+ landingPage: request.merchandising?.landingPage || '',
761
+ },
762
+ };
763
+ }
764
+ export function generateHrefSelector(element, href, levels = 7) {
765
+ let level = 0;
766
+ let elem = element;
767
+ while (elem && level <= levels) {
768
+ let innerHrefElem = null;
769
+ try {
770
+ innerHrefElem = elem.querySelector(`[href*="${href}"]`);
771
+ }
772
+ catch (e) {
773
+ try {
774
+ innerHrefElem = elem.querySelector(cssEscape(`[href*="${href}"]`));
775
+ }
776
+ catch { }
777
+ }
778
+ if (innerHrefElem) {
779
+ // innerHrefElem was found! now get selectors up to elem that contained it
780
+ let selector = '';
781
+ let parentElem = innerHrefElem;
782
+ while (parentElem && parentElem != elem.parentElement) {
783
+ const classNames = parentElem.classList.value.trim().split(' ');
784
+ // document.querySelector does not appreciate special characters - must escape them
785
+ const escapedClassSelector = classNames.reduce((classes, classname) => (classname.trim() ? `${classes}.${cssEscape(classname.trim())}` : classes), '');
786
+ selector = `${parentElem.tagName}${escapedClassSelector}${selector ? ` ${selector}` : ''}`;
787
+ parentElem = parentElem.parentElement;
788
+ }
789
+ return `${selector}[href*="${href}"]`;
790
+ }
791
+ elem = elem.parentElement;
792
+ level++;
793
+ }
794
+ return;
795
+ }