@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.
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/dist/cjs/Abstract/AbstractController.d.ts +42 -0
- package/dist/cjs/Abstract/AbstractController.d.ts.map +1 -0
- package/dist/cjs/Abstract/AbstractController.js +305 -0
- package/dist/cjs/Autocomplete/AutocompleteController.d.ts +59 -0
- package/dist/cjs/Autocomplete/AutocompleteController.d.ts.map +1 -0
- package/dist/cjs/Autocomplete/AutocompleteController.js +1091 -0
- package/dist/cjs/Finder/FinderController.d.ts +15 -0
- package/dist/cjs/Finder/FinderController.d.ts.map +1 -0
- package/dist/cjs/Finder/FinderController.js +336 -0
- package/dist/cjs/Recommendation/RecommendationController.d.ts +27 -0
- package/dist/cjs/Recommendation/RecommendationController.d.ts.map +1 -0
- package/dist/cjs/Recommendation/RecommendationController.js +447 -0
- package/dist/cjs/Search/SearchController.d.ts +41 -0
- package/dist/cjs/Search/SearchController.d.ts.map +1 -0
- package/dist/cjs/Search/SearchController.js +993 -0
- package/dist/cjs/index.d.ts +7 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +29 -0
- package/dist/cjs/types.d.ts +87 -0
- package/dist/cjs/types.d.ts.map +1 -0
- package/dist/cjs/types.js +10 -0
- package/dist/cjs/utils/getParams.d.ts +3 -0
- package/dist/cjs/utils/getParams.d.ts.map +1 -0
- package/dist/cjs/utils/getParams.js +70 -0
- package/dist/cjs/utils/isClickWithinBannerLink.d.ts +2 -0
- package/dist/cjs/utils/isClickWithinBannerLink.d.ts.map +1 -0
- package/dist/cjs/utils/isClickWithinBannerLink.js +21 -0
- package/dist/cjs/utils/isClickWithinProductLink.d.ts +5 -0
- package/dist/cjs/utils/isClickWithinProductLink.d.ts.map +1 -0
- package/dist/cjs/utils/isClickWithinProductLink.js +25 -0
- package/dist/esm/Abstract/AbstractController.d.ts +42 -0
- package/dist/esm/Abstract/AbstractController.d.ts.map +1 -0
- package/dist/esm/Abstract/AbstractController.js +208 -0
- package/dist/esm/Autocomplete/AutocompleteController.d.ts +59 -0
- package/dist/esm/Autocomplete/AutocompleteController.d.ts.map +1 -0
- package/dist/esm/Autocomplete/AutocompleteController.js +882 -0
- package/dist/esm/Finder/FinderController.d.ts +15 -0
- package/dist/esm/Finder/FinderController.d.ts.map +1 -0
- package/dist/esm/Finder/FinderController.js +218 -0
- package/dist/esm/Recommendation/RecommendationController.d.ts +27 -0
- package/dist/esm/Recommendation/RecommendationController.d.ts.map +1 -0
- package/dist/esm/Recommendation/RecommendationController.js +342 -0
- package/dist/esm/Search/SearchController.d.ts +41 -0
- package/dist/esm/Search/SearchController.d.ts.map +1 -0
- package/dist/esm/Search/SearchController.js +795 -0
- package/dist/esm/index.d.ts +7 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/types.d.ts +87 -0
- package/dist/esm/types.d.ts.map +1 -0
- package/dist/esm/types.js +7 -0
- package/dist/esm/utils/getParams.d.ts +3 -0
- package/dist/esm/utils/getParams.d.ts.map +1 -0
- package/dist/esm/utils/getParams.js +67 -0
- package/dist/esm/utils/isClickWithinBannerLink.d.ts +2 -0
- package/dist/esm/utils/isClickWithinBannerLink.d.ts.map +1 -0
- package/dist/esm/utils/isClickWithinBannerLink.js +17 -0
- package/dist/esm/utils/isClickWithinProductLink.d.ts +5 -0
- package/dist/esm/utils/isClickWithinProductLink.d.ts.map +1 -0
- package/dist/esm/utils/isClickWithinProductLink.js +19 -0
- 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
|
+
}
|