@searchspring/snap-controller 0.20.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 +167 -0
- package/dist/cjs/Abstract/AbstractController.d.ts +40 -0
- package/dist/cjs/Abstract/AbstractController.d.ts.map +1 -0
- package/dist/cjs/Abstract/AbstractController.js +281 -0
- package/dist/cjs/Autocomplete/AutocompleteController.d.ts +40 -0
- package/dist/cjs/Autocomplete/AutocompleteController.d.ts.map +1 -0
- package/dist/cjs/Autocomplete/AutocompleteController.js +687 -0
- package/dist/cjs/Finder/FinderController.d.ts +14 -0
- package/dist/cjs/Finder/FinderController.d.ts.map +1 -0
- package/dist/cjs/Finder/FinderController.js +286 -0
- package/dist/cjs/Recommendation/RecommendationController.d.ts +31 -0
- package/dist/cjs/Recommendation/RecommendationController.d.ts.map +1 -0
- package/dist/cjs/Recommendation/RecommendationController.js +452 -0
- package/dist/cjs/Search/SearchController.d.ts +23 -0
- package/dist/cjs/Search/SearchController.d.ts.map +1 -0
- package/dist/cjs/Search/SearchController.js +429 -0
- package/dist/cjs/index.d.ts +7 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +24 -0
- package/dist/cjs/types.d.ts +52 -0
- package/dist/cjs/types.d.ts.map +1 -0
- package/dist/cjs/types.js +2 -0
- package/dist/cjs/utils/getParams.d.ts +2 -0
- package/dist/cjs/utils/getParams.d.ts.map +1 -0
- package/dist/cjs/utils/getParams.js +72 -0
- package/dist/esm/Abstract/AbstractController.d.ts +40 -0
- package/dist/esm/Abstract/AbstractController.d.ts.map +1 -0
- package/dist/esm/Abstract/AbstractController.js +181 -0
- package/dist/esm/Autocomplete/AutocompleteController.d.ts +40 -0
- package/dist/esm/Autocomplete/AutocompleteController.d.ts.map +1 -0
- package/dist/esm/Autocomplete/AutocompleteController.js +472 -0
- package/dist/esm/Finder/FinderController.d.ts +14 -0
- package/dist/esm/Finder/FinderController.d.ts.map +1 -0
- package/dist/esm/Finder/FinderController.js +164 -0
- package/dist/esm/Recommendation/RecommendationController.d.ts +31 -0
- package/dist/esm/Recommendation/RecommendationController.d.ts.map +1 -0
- package/dist/esm/Recommendation/RecommendationController.js +330 -0
- package/dist/esm/Search/SearchController.d.ts +23 -0
- package/dist/esm/Search/SearchController.d.ts.map +1 -0
- package/dist/esm/Search/SearchController.js +282 -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 +52 -0
- package/dist/esm/types.d.ts.map +1 -0
- package/dist/esm/types.js +1 -0
- package/dist/esm/utils/getParams.d.ts +2 -0
- package/dist/esm/utils/getParams.d.ts.map +1 -0
- package/dist/esm/utils/getParams.js +68 -0
- package/package.json +40 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import deepmerge from 'deepmerge';
|
|
2
|
+
import { StorageStore, StorageType, ErrorType } from '@searchspring/snap-store-mobx';
|
|
3
|
+
import { url } from '@searchspring/snap-toolbox';
|
|
4
|
+
import { AbstractController } from '../Abstract/AbstractController';
|
|
5
|
+
import { getSearchParams } from '../utils/getParams';
|
|
6
|
+
const INPUT_ATTRIBUTE = 'ss-autocomplete-input';
|
|
7
|
+
const INPUT_DELAY = 200;
|
|
8
|
+
const KEY_ENTER = 13;
|
|
9
|
+
const KEY_ESCAPE = 27;
|
|
10
|
+
const PARAM_ORIGINAL_QUERY = 'oq';
|
|
11
|
+
const defaultConfig = {
|
|
12
|
+
id: 'autocomplete',
|
|
13
|
+
selector: '',
|
|
14
|
+
action: '',
|
|
15
|
+
globals: {},
|
|
16
|
+
settings: {
|
|
17
|
+
initializeFromUrl: true,
|
|
18
|
+
syncInputs: true,
|
|
19
|
+
facets: {
|
|
20
|
+
trim: true,
|
|
21
|
+
pinFiltered: true,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
export class AutocompleteController extends AbstractController {
|
|
26
|
+
constructor(config, { client, store, urlManager, eventManager, profiler, logger, tracker }) {
|
|
27
|
+
super(config, { client, store, urlManager, eventManager, profiler, logger, tracker });
|
|
28
|
+
this.type = 'autocomplete';
|
|
29
|
+
this.track = {
|
|
30
|
+
// TODO: add in future when autocomplete supports result click tracking
|
|
31
|
+
product: {
|
|
32
|
+
click: (e, result) => {
|
|
33
|
+
this.log.warn('product.click tracking is not currently supported in this controller type');
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
this.handlers = {
|
|
38
|
+
input: {
|
|
39
|
+
enterKey: async (e) => {
|
|
40
|
+
if (e.keyCode == KEY_ENTER) {
|
|
41
|
+
const actionUrl = url(this.config.action);
|
|
42
|
+
const input = e.target;
|
|
43
|
+
// when spellCorrection is enabled
|
|
44
|
+
if (this.config.globals?.search?.query?.spellCorrection) {
|
|
45
|
+
// wait until loading is complete before submission
|
|
46
|
+
// TODO make this better
|
|
47
|
+
await timeout(INPUT_DELAY + 1);
|
|
48
|
+
while (this.store.loading) {
|
|
49
|
+
await timeout(INPUT_DELAY);
|
|
50
|
+
}
|
|
51
|
+
// use corrected query and originalQuery
|
|
52
|
+
if (this.store.search.originalQuery) {
|
|
53
|
+
input.value = this.store.search.query.string;
|
|
54
|
+
actionUrl.params.query[PARAM_ORIGINAL_QUERY] = this.store.search.originalQuery.string;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const inputParam = input.name || this.urlManager.getTranslatorConfig().queryParameter;
|
|
58
|
+
actionUrl.params.query[inputParam] = input.value;
|
|
59
|
+
// TODO expected spell correct behavior queryAssumption
|
|
60
|
+
try {
|
|
61
|
+
await this.eventManager.fire('beforeSubmit', {
|
|
62
|
+
controller: this,
|
|
63
|
+
input,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
if (err?.message == 'cancelled') {
|
|
68
|
+
this.log.warn(`'beforeSubmit' middleware cancelled`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
this.log.error(`error in 'beforeSubmit' middleware`);
|
|
73
|
+
console.error(err);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const newUrl = actionUrl.url();
|
|
77
|
+
window.location.href = newUrl;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
escKey: (e) => {
|
|
81
|
+
if (e.keyCode == KEY_ESCAPE) {
|
|
82
|
+
e.target.blur();
|
|
83
|
+
this.setFocused();
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
focus: (e) => {
|
|
87
|
+
e.stopPropagation();
|
|
88
|
+
// timeout to ensure focus happens AFTER click
|
|
89
|
+
setTimeout(() => {
|
|
90
|
+
this.setFocused(e.target);
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
formSubmit: async (e) => {
|
|
94
|
+
const form = e.target;
|
|
95
|
+
const input = form.querySelector(`input[${INPUT_ATTRIBUTE}]`);
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
// when spellCorrection is enabled
|
|
98
|
+
if (this.config.globals?.search?.query?.spellCorrection) {
|
|
99
|
+
// wait until loading is complete before submission
|
|
100
|
+
// TODO make this better
|
|
101
|
+
await timeout(INPUT_DELAY + 1);
|
|
102
|
+
while (this.store.loading) {
|
|
103
|
+
await timeout(INPUT_DELAY);
|
|
104
|
+
}
|
|
105
|
+
if (this.store.search.originalQuery) {
|
|
106
|
+
input.value = this.store.search.query.string;
|
|
107
|
+
addHiddenFormInput(form, PARAM_ORIGINAL_QUERY, this.store.search.originalQuery.string);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// TODO expected spell correct behavior queryAssumption
|
|
111
|
+
try {
|
|
112
|
+
await this.eventManager.fire('beforeSubmit', {
|
|
113
|
+
controller: this,
|
|
114
|
+
input,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (err?.message == 'cancelled') {
|
|
119
|
+
this.log.warn(`'beforeSubmit' middleware cancelled`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
this.log.error(`error in 'beforeSubmit' middleware`);
|
|
124
|
+
console.error(err);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
form.submit();
|
|
128
|
+
},
|
|
129
|
+
keyUp: (e) => {
|
|
130
|
+
// ignore enter and escape keys
|
|
131
|
+
if (e?.keyCode == KEY_ENTER || e?.keyCode == KEY_ESCAPE)
|
|
132
|
+
return;
|
|
133
|
+
// return focus on keyup if it was lost
|
|
134
|
+
if (e.isTrusted && this.store.state.focusedInput !== e.target) {
|
|
135
|
+
this.setFocused(e.target);
|
|
136
|
+
}
|
|
137
|
+
const value = e.target.value;
|
|
138
|
+
// prevent search when value is unchanged
|
|
139
|
+
if (this.store.state.input == value && this.store.loaded) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
this.store.state.input = value;
|
|
143
|
+
if (this.config.settings.syncInputs) {
|
|
144
|
+
const inputs = document.querySelectorAll(this.config.selector);
|
|
145
|
+
inputs.forEach((input) => {
|
|
146
|
+
input.value = value;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
clearTimeout(this.handlers.input.timeoutDelay);
|
|
150
|
+
if (!value) {
|
|
151
|
+
// TODO cancel any current requests?
|
|
152
|
+
this.store.reset();
|
|
153
|
+
this.urlManager.reset().go();
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
this.handlers.input.timeoutDelay = setTimeout(() => {
|
|
157
|
+
this.store.state.locks.terms.unlock();
|
|
158
|
+
this.store.state.locks.facets.unlock();
|
|
159
|
+
this.urlManager.set({ query: this.store.state.input }).go();
|
|
160
|
+
}, INPUT_DELAY);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
timeoutDelay: undefined,
|
|
164
|
+
},
|
|
165
|
+
document: {
|
|
166
|
+
click: (e) => {
|
|
167
|
+
const inputs = document.querySelectorAll(this.config.selector);
|
|
168
|
+
if (Array.from(inputs).includes(e.target)) {
|
|
169
|
+
e.stopPropagation();
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
this.setFocused();
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
this.searchTrending = async () => {
|
|
178
|
+
let terms;
|
|
179
|
+
const storedTerms = this.storage.get('terms');
|
|
180
|
+
if (storedTerms) {
|
|
181
|
+
// terms exist in storage, update store
|
|
182
|
+
terms = JSON.parse(storedTerms);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
// query for trending terms, save to storage, update store
|
|
186
|
+
const trendingParams = {
|
|
187
|
+
limit: this.config.settings?.trending?.limit || 5,
|
|
188
|
+
};
|
|
189
|
+
const trendingProfile = this.profiler.create({ type: 'event', name: 'trending', context: trendingParams }).start();
|
|
190
|
+
terms = await this.client.trending(trendingParams);
|
|
191
|
+
trendingProfile.stop();
|
|
192
|
+
this.log.profile(trendingProfile);
|
|
193
|
+
this.storage.set('terms', JSON.stringify(terms));
|
|
194
|
+
}
|
|
195
|
+
this.store.updateTrendingTerms(terms);
|
|
196
|
+
};
|
|
197
|
+
this.search = async () => {
|
|
198
|
+
const params = this.params;
|
|
199
|
+
if (!params?.search?.query?.string) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
try {
|
|
204
|
+
await this.eventManager.fire('beforeSearch', {
|
|
205
|
+
controller: this,
|
|
206
|
+
request: params,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
if (err?.message == 'cancelled') {
|
|
211
|
+
this.log.warn(`'beforeSearch' middleware cancelled`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
this.log.error(`error in 'beforeSearch' middleware`);
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const searchProfile = this.profiler.create({ type: 'event', name: 'search', context: params }).start();
|
|
220
|
+
const [meta, response] = await this.client.autocomplete(params);
|
|
221
|
+
if (!response.meta) {
|
|
222
|
+
/**
|
|
223
|
+
* MockClient will overwrite the client search() method and use
|
|
224
|
+
* SearchData to return mock data which already contains meta data
|
|
225
|
+
*/
|
|
226
|
+
response.meta = meta;
|
|
227
|
+
}
|
|
228
|
+
searchProfile.stop();
|
|
229
|
+
this.log.profile(searchProfile);
|
|
230
|
+
const afterSearchProfile = this.profiler.create({ type: 'event', name: 'afterSearch', context: params }).start();
|
|
231
|
+
try {
|
|
232
|
+
await this.eventManager.fire('afterSearch', {
|
|
233
|
+
controller: this,
|
|
234
|
+
request: params,
|
|
235
|
+
response,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
if (err?.message == 'cancelled') {
|
|
240
|
+
this.log.warn(`'afterSearch' middleware cancelled`);
|
|
241
|
+
afterSearchProfile.stop();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
this.log.error(`error in 'afterSearch' middleware`);
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
afterSearchProfile.stop();
|
|
250
|
+
this.log.profile(afterSearchProfile);
|
|
251
|
+
// update the store
|
|
252
|
+
this.store.update(response);
|
|
253
|
+
const afterStoreProfile = this.profiler.create({ type: 'event', name: 'afterStore', context: params }).start();
|
|
254
|
+
try {
|
|
255
|
+
await this.eventManager.fire('afterStore', {
|
|
256
|
+
controller: this,
|
|
257
|
+
request: params,
|
|
258
|
+
response,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
if (err?.message == 'cancelled') {
|
|
263
|
+
this.log.warn(`'afterStore' middleware cancelled`);
|
|
264
|
+
afterStoreProfile.stop();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
this.log.error(`error in 'afterStore' middleware`);
|
|
269
|
+
throw err;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
afterStoreProfile.stop();
|
|
273
|
+
this.log.profile(afterStoreProfile);
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
if (err) {
|
|
277
|
+
switch (err) {
|
|
278
|
+
case 429:
|
|
279
|
+
this.store.error = {
|
|
280
|
+
code: 429,
|
|
281
|
+
type: ErrorType.WARNING,
|
|
282
|
+
message: 'Too many requests try again later',
|
|
283
|
+
};
|
|
284
|
+
this.log.warn(this.store.error);
|
|
285
|
+
break;
|
|
286
|
+
case 500:
|
|
287
|
+
this.store.error = {
|
|
288
|
+
code: 500,
|
|
289
|
+
type: ErrorType.ERROR,
|
|
290
|
+
message: 'Invalid Search Request or Service Unavailable',
|
|
291
|
+
};
|
|
292
|
+
this.log.error(this.store.error);
|
|
293
|
+
break;
|
|
294
|
+
default:
|
|
295
|
+
this.log.error(err);
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
this.store.loading = false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
// deep merge config with defaults
|
|
303
|
+
this.config = deepmerge(defaultConfig, this.config);
|
|
304
|
+
this.store.setConfig(this.config);
|
|
305
|
+
// get current search from url before detaching
|
|
306
|
+
if (this.config.settings.initializeFromUrl) {
|
|
307
|
+
this.store.state.input = this.urlManager.state.query;
|
|
308
|
+
// reset to force search on focus
|
|
309
|
+
// TODO: make a config setting for this
|
|
310
|
+
this.urlManager.reset().go();
|
|
311
|
+
}
|
|
312
|
+
// persist trending terms in local storage
|
|
313
|
+
this.storage = new StorageStore({
|
|
314
|
+
type: StorageType.SESSION,
|
|
315
|
+
key: `ss-controller-${this.config.id}`,
|
|
316
|
+
});
|
|
317
|
+
// add 'beforeSearch' middleware
|
|
318
|
+
this.eventManager.on('beforeSearch', async (ac, next) => {
|
|
319
|
+
ac.controller.store.loading = true;
|
|
320
|
+
await next();
|
|
321
|
+
});
|
|
322
|
+
// add 'afterSearch' middleware
|
|
323
|
+
this.eventManager.on('afterSearch', async (ac, next) => {
|
|
324
|
+
await next();
|
|
325
|
+
// cancel search if no input or query doesn't match current urlState
|
|
326
|
+
if (ac.response.autocomplete.query != ac.controller.urlManager.state.query) {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
// add 'afterStore' middleware
|
|
331
|
+
this.eventManager.on('afterStore', async (ac, next) => {
|
|
332
|
+
await next();
|
|
333
|
+
ac.controller.store.loading = false;
|
|
334
|
+
});
|
|
335
|
+
// attach config plugins and event middleware
|
|
336
|
+
this.use(this.config);
|
|
337
|
+
}
|
|
338
|
+
get params() {
|
|
339
|
+
const urlState = this.urlManager.state;
|
|
340
|
+
const params = deepmerge({ ...getSearchParams(urlState) }, this.config.globals);
|
|
341
|
+
const userId = this.tracker.getUserId();
|
|
342
|
+
if (userId) {
|
|
343
|
+
params.tracking = params.tracking || {};
|
|
344
|
+
params.tracking.userId = userId;
|
|
345
|
+
}
|
|
346
|
+
if (!this.config.globals?.personalization?.disabled) {
|
|
347
|
+
const cartItems = this.tracker.cookies.cart.get();
|
|
348
|
+
if (cartItems.length) {
|
|
349
|
+
params.personalization = params.personalization || {};
|
|
350
|
+
params.personalization.cart = cartItems.join(',');
|
|
351
|
+
}
|
|
352
|
+
const lastViewedItems = this.tracker.cookies.viewed.get();
|
|
353
|
+
if (lastViewedItems.length) {
|
|
354
|
+
params.personalization = params.personalization || {};
|
|
355
|
+
params.personalization.lastViewed = lastViewedItems.join(',');
|
|
356
|
+
}
|
|
357
|
+
const shopperId = this.tracker.getShopperId();
|
|
358
|
+
if (shopperId) {
|
|
359
|
+
params.personalization = params.personalization || {};
|
|
360
|
+
params.personalization.shopper = shopperId;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return params;
|
|
364
|
+
}
|
|
365
|
+
async setFocused(inputElement) {
|
|
366
|
+
if (this.store.state.focusedInput !== inputElement) {
|
|
367
|
+
this.store.state.focusedInput = inputElement;
|
|
368
|
+
// fire focusChange event
|
|
369
|
+
try {
|
|
370
|
+
try {
|
|
371
|
+
await this.eventManager.fire('focusChange', {
|
|
372
|
+
controller: this,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
if (err?.message == 'cancelled') {
|
|
377
|
+
this.log.warn(`'focusChange' middleware cancelled`);
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
this.log.error(`error in 'focusChange' middleware`);
|
|
381
|
+
throw err;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
if (err) {
|
|
387
|
+
console.error(err);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
inputElement?.dispatchEvent(new Event('keyup'));
|
|
392
|
+
}
|
|
393
|
+
reset() {
|
|
394
|
+
// reset input values and state
|
|
395
|
+
const inputs = document.querySelectorAll(this.config.selector);
|
|
396
|
+
inputs.forEach((input) => {
|
|
397
|
+
input.value = '';
|
|
398
|
+
});
|
|
399
|
+
this.store.reset();
|
|
400
|
+
}
|
|
401
|
+
unbind() {
|
|
402
|
+
const inputs = document.querySelectorAll(`input[${INPUT_ATTRIBUTE}]`);
|
|
403
|
+
inputs?.forEach((input) => {
|
|
404
|
+
input.removeEventListener('keyup', this.handlers.input.keyUp);
|
|
405
|
+
input.removeEventListener('keyup', this.handlers.input.enterKey);
|
|
406
|
+
input.removeEventListener('keyup', this.handlers.input.escKey);
|
|
407
|
+
input.removeEventListener('focus', this.handlers.input.focus);
|
|
408
|
+
input.form?.removeEventListener('submit', this.handlers.input.formSubmit);
|
|
409
|
+
});
|
|
410
|
+
document.removeEventListener('click', this.handlers.document.click);
|
|
411
|
+
}
|
|
412
|
+
async bind() {
|
|
413
|
+
if (!this.initialized) {
|
|
414
|
+
await this.init();
|
|
415
|
+
}
|
|
416
|
+
this.unbind();
|
|
417
|
+
const inputs = document.querySelectorAll(this.config.selector);
|
|
418
|
+
inputs.forEach((input) => {
|
|
419
|
+
input.setAttribute('spellcheck', 'false');
|
|
420
|
+
input.setAttribute('autocomplete', 'off');
|
|
421
|
+
input.setAttribute(INPUT_ATTRIBUTE, '');
|
|
422
|
+
input.addEventListener('keyup', this.handlers.input.keyUp);
|
|
423
|
+
if (this.config.settings.initializeFromUrl && !input.value && this.store.state.input) {
|
|
424
|
+
input.value = this.store.state.input;
|
|
425
|
+
}
|
|
426
|
+
input.addEventListener('focus', this.handlers.input.focus);
|
|
427
|
+
input.addEventListener('keyup', this.handlers.input.escKey);
|
|
428
|
+
const form = input.form;
|
|
429
|
+
let formActionUrl = this.config.action;
|
|
430
|
+
if (!form && this.config.action) {
|
|
431
|
+
input.addEventListener('keyup', this.handlers.input.enterKey);
|
|
432
|
+
}
|
|
433
|
+
else if (form) {
|
|
434
|
+
if (this.config.action) {
|
|
435
|
+
form.action = this.config.action;
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
formActionUrl = form.action;
|
|
439
|
+
}
|
|
440
|
+
form.addEventListener('submit', this.handlers.input.formSubmit);
|
|
441
|
+
}
|
|
442
|
+
// set the root URL on urlManager
|
|
443
|
+
if (formActionUrl) {
|
|
444
|
+
this.store.setService('urlManager', this.urlManager.withConfig((translatorConfig) => {
|
|
445
|
+
return {
|
|
446
|
+
...translatorConfig,
|
|
447
|
+
urlRoot: formActionUrl,
|
|
448
|
+
};
|
|
449
|
+
}));
|
|
450
|
+
}
|
|
451
|
+
if (document.activeElement === input) {
|
|
452
|
+
this.setFocused(input);
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
if (this.config.settings?.trending?.limit > 0 && !this.store.trending?.length) {
|
|
456
|
+
this.searchTrending();
|
|
457
|
+
}
|
|
458
|
+
document.addEventListener('click', this.handlers.document.click);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
function addHiddenFormInput(form, name, value) {
|
|
462
|
+
const inputElem = document.createElement('input');
|
|
463
|
+
inputElem.type = 'hidden';
|
|
464
|
+
inputElem.name = name;
|
|
465
|
+
inputElem.value = value;
|
|
466
|
+
form.append(inputElem);
|
|
467
|
+
}
|
|
468
|
+
async function timeout(time) {
|
|
469
|
+
return new Promise((resolve) => {
|
|
470
|
+
window.setTimeout(resolve, time);
|
|
471
|
+
});
|
|
472
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { AbstractController } from '../Abstract/AbstractController';
|
|
2
|
+
import type { FinderStore } from '@searchspring/snap-store-mobx';
|
|
3
|
+
import type { FinderControllerConfig, ControllerServices } from '../types';
|
|
4
|
+
export declare class FinderController extends AbstractController {
|
|
5
|
+
type: string;
|
|
6
|
+
store: FinderStore;
|
|
7
|
+
config: FinderControllerConfig;
|
|
8
|
+
constructor(config: FinderControllerConfig, { client, store, urlManager, eventManager, profiler, logger, tracker }: ControllerServices);
|
|
9
|
+
get params(): Record<string, any>;
|
|
10
|
+
find: () => void;
|
|
11
|
+
reset: () => void;
|
|
12
|
+
search: () => Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=FinderController.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FinderController.d.ts","sourceRoot":"","sources":["../../../src/Finder/FinderController.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,kBAAkB,EAAE,MAAM,gCAAgC,CAAC;AAEpE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AACjE,OAAO,KAAK,EAAE,sBAAsB,EAAmC,kBAAkB,EAAa,MAAM,UAAU,CAAC;AAQvH,qBAAa,gBAAiB,SAAQ,kBAAkB;IAChD,IAAI,SAAY;IAChB,KAAK,EAAE,WAAW,CAAC;IAC1B,MAAM,EAAE,sBAAsB,CAAC;gBAEnB,MAAM,EAAE,sBAAsB,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,kBAAkB;IAoCtI,IAAI,MAAM,IAAI,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAUhC;IAED,IAAI,QAAO,IAAI,CAEb;IAEF,KAAK,QAAO,IAAI,CAMd;IAEF,MAAM,QAAa,QAAQ,IAAI,CAAC,CA6G9B;CACF"}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import deepmerge from 'deepmerge';
|
|
2
|
+
import { ErrorType } from '@searchspring/snap-store-mobx';
|
|
3
|
+
import { AbstractController } from '../Abstract/AbstractController';
|
|
4
|
+
import { getSearchParams } from '../utils/getParams';
|
|
5
|
+
const defaultConfig = {
|
|
6
|
+
id: 'finder',
|
|
7
|
+
globals: {},
|
|
8
|
+
fields: [],
|
|
9
|
+
};
|
|
10
|
+
export class FinderController extends AbstractController {
|
|
11
|
+
constructor(config, { client, store, urlManager, eventManager, profiler, logger, tracker }) {
|
|
12
|
+
super(config, { client, store, urlManager, eventManager, profiler, logger, tracker });
|
|
13
|
+
this.type = 'finder';
|
|
14
|
+
this.find = () => {
|
|
15
|
+
window.location.href = this.urlManager.href;
|
|
16
|
+
};
|
|
17
|
+
this.reset = () => {
|
|
18
|
+
// only need to reset when selections have been made
|
|
19
|
+
if (this.urlManager.state.filter) {
|
|
20
|
+
this.store.storage.clear();
|
|
21
|
+
this.urlManager.remove('filter').go();
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
this.search = async () => {
|
|
25
|
+
if (!this.initialized) {
|
|
26
|
+
await this.init();
|
|
27
|
+
}
|
|
28
|
+
const params = this.params;
|
|
29
|
+
try {
|
|
30
|
+
try {
|
|
31
|
+
await this.eventManager.fire('beforeSearch', {
|
|
32
|
+
controller: this,
|
|
33
|
+
request: params,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
if (err?.message == 'cancelled') {
|
|
38
|
+
this.log.warn(`'beforeSearch' middleware cancelled`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
this.log.error(`error in 'beforeSearch' middleware`);
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const searchProfile = this.profiler.create({ type: 'event', name: 'search', context: params }).start();
|
|
47
|
+
const [meta, response] = await this.client.search(params);
|
|
48
|
+
if (!response.meta) {
|
|
49
|
+
/**
|
|
50
|
+
* MockClient will overwrite the client search() method and use
|
|
51
|
+
* SearchData to return mock data which already contains meta data
|
|
52
|
+
*/
|
|
53
|
+
response.meta = meta;
|
|
54
|
+
}
|
|
55
|
+
searchProfile.stop();
|
|
56
|
+
this.log.profile(searchProfile);
|
|
57
|
+
const afterSearchProfile = this.profiler.create({ type: 'event', name: 'afterSearch', context: params }).start();
|
|
58
|
+
try {
|
|
59
|
+
await this.eventManager.fire('afterSearch', {
|
|
60
|
+
controller: this,
|
|
61
|
+
request: params,
|
|
62
|
+
response,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
if (err?.message == 'cancelled') {
|
|
67
|
+
this.log.warn(`'afterSearch' middleware cancelled`);
|
|
68
|
+
afterSearchProfile.stop();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
this.log.error(`error in 'afterSearch' middleware`);
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
afterSearchProfile.stop();
|
|
77
|
+
this.log.profile(afterSearchProfile);
|
|
78
|
+
// update the store
|
|
79
|
+
this.store.update(response);
|
|
80
|
+
const afterStoreProfile = this.profiler.create({ type: 'event', name: 'afterStore', context: params }).start();
|
|
81
|
+
try {
|
|
82
|
+
await this.eventManager.fire('afterStore', {
|
|
83
|
+
controller: this,
|
|
84
|
+
request: params,
|
|
85
|
+
response,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
if (err?.message == 'cancelled') {
|
|
90
|
+
this.log.warn(`'afterStore' middleware cancelled`);
|
|
91
|
+
afterStoreProfile.stop();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
this.log.error(`error in 'afterStore' middleware`);
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
afterStoreProfile.stop();
|
|
100
|
+
this.log.profile(afterStoreProfile);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
if (err) {
|
|
104
|
+
switch (err) {
|
|
105
|
+
case 429:
|
|
106
|
+
this.store.error = {
|
|
107
|
+
code: 429,
|
|
108
|
+
type: ErrorType.WARNING,
|
|
109
|
+
message: 'Too many requests try again later',
|
|
110
|
+
};
|
|
111
|
+
this.log.warn(this.store.error);
|
|
112
|
+
break;
|
|
113
|
+
case 500:
|
|
114
|
+
this.store.error = {
|
|
115
|
+
code: 500,
|
|
116
|
+
type: ErrorType.ERROR,
|
|
117
|
+
message: 'Invalid Search Request or Service Unavailable',
|
|
118
|
+
};
|
|
119
|
+
this.log.error(this.store.error);
|
|
120
|
+
break;
|
|
121
|
+
default:
|
|
122
|
+
this.log.error(err);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
this.store.loading = false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
// deep merge config with defaults
|
|
130
|
+
this.config = deepmerge(defaultConfig, this.config);
|
|
131
|
+
this.store.setConfig(this.config);
|
|
132
|
+
// set the root URL on urlManager
|
|
133
|
+
if (this.config.url) {
|
|
134
|
+
this.urlManager = this.urlManager.withConfig((translatorConfig) => {
|
|
135
|
+
return {
|
|
136
|
+
...translatorConfig,
|
|
137
|
+
urlRoot: this.config.url,
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
// add 'beforeSearch' middleware
|
|
142
|
+
this.eventManager.on('beforeSearch', async (finder, next) => {
|
|
143
|
+
finder.controller.store.loading = true;
|
|
144
|
+
await next();
|
|
145
|
+
});
|
|
146
|
+
// TODO: move this to afterStore
|
|
147
|
+
// add 'afterSearch' middleware
|
|
148
|
+
this.eventManager.on('afterSearch', async (finder, next) => {
|
|
149
|
+
await next();
|
|
150
|
+
finder.controller.store.loading = false;
|
|
151
|
+
});
|
|
152
|
+
// attach config plugins and event middleware
|
|
153
|
+
this.use(this.config);
|
|
154
|
+
}
|
|
155
|
+
get params() {
|
|
156
|
+
const urlState = this.urlManager.state;
|
|
157
|
+
const params = deepmerge({ ...getSearchParams(urlState) }, this.config.globals);
|
|
158
|
+
// get only the finder fields
|
|
159
|
+
params.facets = {
|
|
160
|
+
include: this.config.fields.map((fieldConfig) => fieldConfig.field),
|
|
161
|
+
};
|
|
162
|
+
return params;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { AbstractController } from '../Abstract/AbstractController';
|
|
2
|
+
import type { BeaconEvent } from '@searchspring/snap-tracker';
|
|
3
|
+
import type { RecommendationStore } from '@searchspring/snap-store-mobx';
|
|
4
|
+
import type { RecommendationControllerConfig, ControllerServices } from '../types';
|
|
5
|
+
declare type RecommendationTrackMethods = {
|
|
6
|
+
product: {
|
|
7
|
+
click: (e: any, result: any) => BeaconEvent;
|
|
8
|
+
render: (result: any) => BeaconEvent;
|
|
9
|
+
impression: (result: any) => BeaconEvent;
|
|
10
|
+
};
|
|
11
|
+
click: (e: any) => BeaconEvent;
|
|
12
|
+
impression: () => BeaconEvent;
|
|
13
|
+
render: () => BeaconEvent;
|
|
14
|
+
};
|
|
15
|
+
export declare class RecommendationController extends AbstractController {
|
|
16
|
+
type: string;
|
|
17
|
+
store: RecommendationStore;
|
|
18
|
+
config: RecommendationControllerConfig;
|
|
19
|
+
events: {
|
|
20
|
+
click: any;
|
|
21
|
+
impression: any;
|
|
22
|
+
render: any;
|
|
23
|
+
product: {};
|
|
24
|
+
};
|
|
25
|
+
constructor(config: RecommendationControllerConfig, { client, store, urlManager, eventManager, profiler, logger, tracker }: ControllerServices);
|
|
26
|
+
track: RecommendationTrackMethods;
|
|
27
|
+
get params(): Record<string, any>;
|
|
28
|
+
search: () => Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export {};
|
|
31
|
+
//# sourceMappingURL=RecommendationController.d.ts.map
|