@rocketsearch/vue-uappi 0.0.0-stage → 0.1.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.
@@ -0,0 +1,513 @@
1
+ <template>
2
+ <div
3
+ class="rocket-search rocket-search__product-search rocket-search--page"
4
+ data-search-mode="page"
5
+ :data-facets-mode="mode"
6
+ >
7
+ <div class="rocket-search__content">
8
+ <div class="rocket-search__products-result opened">
9
+ <div class="wrapper">
10
+ <div v-if="mode === 'side'" class="rocket-search__products-result__col-left">
11
+ <RocketSearchFacets
12
+ :facets="context.facets"
13
+ :applied-filters="context.appliedFilters"
14
+ :closed-facets="closedFacets"
15
+ facets-mode="side"
16
+ :is-mobile="isMobile"
17
+ :is-selected="isSelected"
18
+ :range-key="rangeKey"
19
+ @toggle-facet="toggleFacet"
20
+ @click-facet="clickFacet"
21
+ @apply-price="applyPrice"
22
+ @clear-facet="clearFacet"
23
+ @clear-price="clearPrice"
24
+ @clear-boolean="clearBoolean"
25
+ @clear-all="clearAllFilters"
26
+ />
27
+ </div>
28
+
29
+ <div class="rocket-search__products-result__col-right">
30
+ <div class="rocket-search__header">
31
+ <span class="rocket-search__count"><span v-text="context.total" /> produtos encontrados.</span>
32
+
33
+ <button v-if="mode === 'fixed'" class="rocket-search__open-facets" @click="fixedFacetsOpened = true">
34
+ <svg xmlns="http://www.w3.org/2000/svg" width="15.096" height="15.812" viewBox="0 0 15.096 15.812">
35
+ <g transform="translate(-8.37 -6.396)">
36
+ <path d="M9.028,9.525h7.593a2.457,2.457,0,0,0,4.741,0h1.445a.658.658,0,0,0,0-1.316H21.357a2.457,2.457,0,0,0-4.741,0H9.028a.658.658,0,1,0,0,1.316ZM19,7.713a1.153,1.153,0,1,1-1.16,1.15A1.153,1.153,0,0,1,19,7.713Z" fill="#fff" />
37
+ <path d="M22.808,38.18h-7.6a2.455,2.455,0,0,0-4.74,0H9.028a.658.658,0,1,0,0,1.316h1.451a2.455,2.455,0,0,0,4.74,0h7.59a.658.658,0,1,0,0-1.316Zm-9.972,1.811a1.155,1.155,0,1,1,.82-.336A1.153,1.153,0,0,1,12.836,39.992Z" transform="translate(0 -24.536)" fill="#fff" />
38
+ <path d="M22.808,68.168H21.357a2.457,2.457,0,0,0-4.741,0H9.028a.658.658,0,1,0,0,1.316h7.593a2.457,2.457,0,0,0,4.741,0h1.445a.658.658,0,1,0,0-1.316ZM19,69.981a1.153,1.153,0,1,1,1.146-1.151A1.153,1.153,0,0,1,19,69.981Z" transform="translate(0 -49.088)" fill="#fff" />
39
+ </g>
40
+ </svg>
41
+ Filtrar
42
+ </button>
43
+
44
+ <div class="rocket-search__sorter">
45
+ <div class="select" @click="showSortOptions = !showSortOptions">
46
+ <span class="text" v-text="appliedSortLabel" />
47
+ <svg xmlns="http://www.w3.org/2000/svg" width="10.582" height="6.692" viewBox="0 0 10.582 6.692">
48
+ <path d="M5.291 4.314L1.401.424l-.977.977 4.867 4.867 4.867-4.867-.977-.977z" fill="#9e9e9e" stroke="#9e9e9e" stroke-width=".6" fill-rule="evenodd" />
49
+ </svg>
50
+ </div>
51
+ <ul v-show="showSortOptions" class="select-options">
52
+ <li
53
+ v-for="option in context.sortOptions"
54
+ :key="option.id"
55
+ :class="{ selected: context.sortInfo.applied === option.id }"
56
+ @click="changeSort(option)"
57
+ v-text="option.label"
58
+ />
59
+ </ul>
60
+ </div>
61
+ </div>
62
+
63
+ <p v-if="context.didYouMean && context.didYouMean.query" class="rocket-search__did-you-mean">
64
+ <template v-if="!context.total">
65
+ Nenhum resultado para <strong v-text="term" />.
66
+ </template>
67
+ Você quis dizer <a :href="didYouMeanUrl" @click="applyDidYouMean" v-text="context.didYouMean.query" />?
68
+ </p>
69
+
70
+ <div v-if="context.appliedFilters.length > 0" class="rocket-search__facets__selected--v2">
71
+ <span>Filtros aplicados:</span>
72
+ <ul>
73
+ <template v-for="(facet, i) in context.appliedFilters" :key="'af-' + i">
74
+ <li v-for="(value, j) in facet.values || []" :key="'f-' + j" :data-alias="facet.alias">
75
+ <span class="option-title" :data-alias="facet.alias">
76
+ <span class="name" v-text="facet.alias + ':'" />
77
+ <span class="label" v-text="value" />
78
+ </span>
79
+ <button class="rocket-search__facets__selected__clear-filter" aria-label="Remover filtro" @click="clearFacet(value, facet)">
80
+ x
81
+ </button>
82
+ </li>
83
+ <li v-if="facet.type === 'range'" :data-alias="facet.alias">
84
+ <span class="option-title" :data-alias="facet.alias">
85
+ <span class="name" v-text="facet.alias + ':'" />
86
+ <span class="label">De {{ formatCurrency(facet.min) }} até {{ formatCurrency(facet.max) }}</span>
87
+ </span>
88
+ <button class="rocket-search__facets__selected__clear-filter" aria-label="Remover filtro" @click="clearPrice">
89
+ x
90
+ </button>
91
+ </li>
92
+ <li v-if="facet.type === 'boolean'" :data-alias="facet.alias" :data-type="facet.type">
93
+ <span class="option-title" :data-alias="facet.alias">
94
+ <span class="name" v-text="facet.alias + ':'" />
95
+ <span class="label" v-text="facet.value ? 'Sim' : 'Não'" />
96
+ </span>
97
+ <button class="rocket-search__facets__selected__clear-filter" aria-label="Remover filtro" @click="clearBoolean(facet)">
98
+ x
99
+ </button>
100
+ </li>
101
+ </template>
102
+ <li class="rocket-search__clear-all-filters">
103
+ <button @click="clearAllFilters">
104
+ Limpar
105
+ </button>
106
+ </li>
107
+ </ul>
108
+ </div>
109
+
110
+ <div v-show="isLoading && !livescrolling" class="rocket-search__product-placeholder">
111
+ <div v-for="n in size" :key="n" class="rocket-search__product-placeholder__product-line">
112
+ <div v-for="i in 6" :key="i" class="rocket-search__product-placeholder__item" :class="'item-' + i" />
113
+ </div>
114
+ </div>
115
+
116
+ <ul v-show="!isLoading || livescrolling" ref="products" class="rocket-search__products">
117
+ <li
118
+ v-for="(product, index) in products"
119
+ :key="product.id"
120
+ :data-product-id="product.id"
121
+ class="rocket-search__product"
122
+ @click.capture="trackProductClick(product, index, $event)"
123
+ >
124
+ <slot name="product" :product="product" :position="index">
125
+ <RouterLink :to="productPath(product)" class="rocket-search__card">
126
+ <img v-if="productImage(product)" :src="productImage(product)" :alt="product.nome" loading="lazy">
127
+ <span class="rocket-search__card-name" v-text="product.nome" />
128
+ <span v-if="productPrice(product) !== null" class="rocket-search__card-price" v-text="formatCurrency(productPrice(product))" />
129
+ </RouterLink>
130
+ </slot>
131
+ </li>
132
+ </ul>
133
+
134
+ <p v-if="!isLoading && loaded && !products.length" class="rocket-search__empty">
135
+ Nenhum produto encontrado.
136
+ </p>
137
+
138
+ <RocketSearchPagination
139
+ v-if="!livescroll && context.totalPages > 1"
140
+ :page="context.page"
141
+ :total-pages="context.totalPages"
142
+ :has-prev-page="context.hasPrevPage"
143
+ :has-next-page="context.hasNextPage"
144
+ @change="changePage"
145
+ />
146
+
147
+ <div v-if="livescroll && context.hasNextPage" class="rocket-search__footer">
148
+ <button class="rocket-search__livescroll" :class="{ 'button-loading': livescrolling }" @click="nextPageLivescroll">
149
+ Veja mais
150
+ </button>
151
+ </div>
152
+ </div>
153
+ </div>
154
+ </div>
155
+
156
+ <div v-if="fixedFacetsOpened && mode === 'fixed'" id="rocket-search__overlay" class="visible" @click="fixedFacetsOpened = false" />
157
+ <RocketSearchFacets
158
+ v-if="mode === 'fixed'"
159
+ :facets="context.facets"
160
+ :applied-filters="context.appliedFilters"
161
+ :closed-facets="closedFacets"
162
+ :fixed-facets-opened="fixedFacetsOpened"
163
+ facets-mode="fixed"
164
+ :is-mobile="isMobile"
165
+ :is-selected="isSelected"
166
+ :range-key="rangeKey"
167
+ @close="fixedFacetsOpened = false"
168
+ @toggle-facet="toggleFacet"
169
+ @click-facet="clickFacet"
170
+ @apply-price="applyPrice"
171
+ @clear-facet="clearFacet"
172
+ @clear-price="clearPrice"
173
+ @clear-boolean="clearBoolean"
174
+ @clear-all="clearAllFilters"
175
+ />
176
+ </div>
177
+ </div>
178
+ </template>
179
+
180
+ <script>
181
+ // CSS pelo script e não por <style src>: o plugin-vue registra o dono de um src pelo caminho
182
+ // do arquivo, e o mesmo .css em dois SFCs (com índices diferentes) quebra o build.
183
+ import '../css/common.css';
184
+ import '../css/search.css';
185
+ import RocketSearchFacets from './search/RocketSearchFacets.vue';
186
+ import RocketSearchPagination from './search/RocketSearchPagination.vue';
187
+ import { productImage, productPath, productPrice, formatCurrency } from '../uappi.js';
188
+ import { TRACKING_PARAMS, didYouMeanQuery, dispatchDidYouMeanClick } from '../listing.js';
189
+
190
+ // Parâmetros de campanha nunca viram filtro na busca.
191
+ function emptyContext () {
192
+ return {
193
+ appliedFilters: [],
194
+ total: 0,
195
+ page: 0,
196
+ size: 0,
197
+ totalPages: 0,
198
+ hasNextPage: false,
199
+ hasPrevPage: false,
200
+ products: [],
201
+ facets: {},
202
+ sortInfo: { requested: 'relevance', applied: 'relevance' },
203
+ sortOptions: [],
204
+ didYouMean: null
205
+ };
206
+ }
207
+
208
+ function splitValues (raw) {
209
+ return String(raw || '').split(';').filter(v => v !== '');
210
+ }
211
+
212
+ export default {
213
+ name: 'RocketSearch',
214
+ components: { RocketSearchFacets, RocketSearchPagination },
215
+ emits: ['products-loaded'],
216
+ props: {
217
+ pageSize: { type: Number, default: 20 },
218
+ facetsMode: { type: String, default: 'side' },
219
+ livescroll: { type: Boolean, default: false },
220
+ // Modo categoria: filtra a busca pela categoria (id da Rocket = id da categoria na Uappi).
221
+ isCategory: { type: Boolean, default: false },
222
+ categoryId: { type: [String, Number], default: '' },
223
+ mediaSize: { type: String, default: 'medium' }
224
+ },
225
+ data () {
226
+ return {
227
+ context: emptyContext(),
228
+ products: [],
229
+ isLoading: true,
230
+ loaded: false,
231
+ livescrolling: false,
232
+ showSortOptions: false,
233
+ fixedFacetsOpened: false,
234
+ closedFacets: [],
235
+ isMobile: false,
236
+ rangeKey: 0
237
+ };
238
+ },
239
+ computed: {
240
+ size () {
241
+ return this.pageSize;
242
+ },
243
+ mode () {
244
+ // Mobile sempre usa o painel fixo (mesma regra da Linx).
245
+ return this.isMobile || this.facetsMode === 'fixed' ? 'fixed' : 'side';
246
+ },
247
+ queryParam () {
248
+ return this.$rocket.config.searchPageParam;
249
+ },
250
+ term () {
251
+ const q = this.$route.query;
252
+ return String(q[this.queryParam] || q.q || '');
253
+ },
254
+ didYouMeanLocation () {
255
+ const suggestion = this.context.didYouMean && this.context.didYouMean.query;
256
+ if (!suggestion) return null;
257
+ const { busca, ...origin } = didYouMeanQuery(suggestion, this.term);
258
+ return { path: this.$rocket.config.searchPagePath, query: { [this.queryParam]: busca, ...origin } };
259
+ },
260
+ didYouMeanUrl () {
261
+ return this.didYouMeanLocation ? this.$router.resolve(this.didYouMeanLocation).href : '';
262
+ },
263
+ appliedSortLabel () {
264
+ const applied = this.context.sortInfo && this.context.sortInfo.applied;
265
+ const option = (this.context.sortOptions || []).find(o => o.id === applied);
266
+ return option ? option.label : 'Ordenar';
267
+ }
268
+ },
269
+ watch: {
270
+ '$route.query' () {
271
+ this.fetchResult();
272
+ }
273
+ },
274
+ mounted () {
275
+ this.isMobile = window.innerWidth < 1024;
276
+ this.onDocumentClick = (event) => {
277
+ if (!event.target.closest || !event.target.closest('.rocket-search__sorter')) this.showSortOptions = false;
278
+ };
279
+ this.onKeyup = (event) => {
280
+ if (event.code === 'Escape') this.fixedFacetsOpened = false;
281
+ };
282
+ document.addEventListener('click', this.onDocumentClick);
283
+ document.addEventListener('keyup', this.onKeyup);
284
+ this.fetchResult();
285
+ },
286
+ beforeUnmount () {
287
+ document.removeEventListener('click', this.onDocumentClick);
288
+ document.removeEventListener('keyup', this.onKeyup);
289
+ document.body.classList.remove('faceting');
290
+ },
291
+ methods: {
292
+ productImage (product) {
293
+ return productImage(product, this.mediaSize);
294
+ },
295
+ productPath,
296
+ productPrice,
297
+ formatCurrency,
298
+
299
+ // ---- Busca ----
300
+
301
+ buildParams (page) {
302
+ const query = this.$route.query;
303
+ const params = {
304
+ q: this.term,
305
+ size: this.size,
306
+ sort: query.sort || 'relevance',
307
+ page: page || query.page || 1
308
+ };
309
+ Object.keys(query).forEach((key) => {
310
+ const value = query[key];
311
+ if (value === undefined || value === null || value === '') return;
312
+ if (key === this.queryParam || key === 'q' || key === 'sort' || key === 'page') return;
313
+ const lower = key.toLowerCase();
314
+ if (TRACKING_PARAMS.includes(lower) || lower.indexOf('utm_') === 0) return;
315
+ params[key] = Array.isArray(value) ? value.join(';') : value;
316
+ });
317
+ if (this.isCategory && this.categoryId) params.categoryId = this.categoryId;
318
+ return params;
319
+ },
320
+
321
+ async fetchResult (page) {
322
+ const seq = this.seq = (this.seq || 0) + 1;
323
+ const append = this.livescrolling;
324
+ this.isLoading = true;
325
+ document.body.classList.add('faceting');
326
+
327
+ try {
328
+ const start = Date.now();
329
+ const response = await this.$rocket.search(this.buildParams(page));
330
+ if (seq !== this.seq) return;
331
+ const responseTime = Date.now() - start;
332
+ this.context = Object.assign(emptyContext(), response);
333
+ this.$rocket.rememberSearchPrices(this.context.products);
334
+
335
+ if (this.term !== '') {
336
+ this.$rocket.dispatchEvent('search', {
337
+ query: this.term,
338
+ resultsCount: this.context.total,
339
+ meta: { query: this.term, resultsCount: this.context.total, responseTime }
340
+ }).catch(() => {});
341
+ }
342
+
343
+ // Rocket ordena/filtra; o card mostra o produto como a Uappi o tem.
344
+ const ids = (this.context.products || []).map(p => p.id);
345
+ const products = await this.$rocket.fetchProducts(ids);
346
+ if (seq !== this.seq) return;
347
+ this.products = append ? this.products.concat(products) : products;
348
+ this.$emit('products-loaded', { products: this.products, total: this.context.total, term: this.term });
349
+ }
350
+ catch (e) {
351
+ if (seq !== this.seq) return;
352
+ if (!append) this.products = [];
353
+ console.warn('[RocketSearch] busca falhou', e && e.message ? e.message : e);
354
+ }
355
+ this.isLoading = false;
356
+ this.livescrolling = false;
357
+ this.loaded = true;
358
+ document.body.classList.remove('faceting');
359
+ // Depois da resposta: o layout do tema rola para o topo a cada troca de rota, e um
360
+ // scroll disparado junto com o push perderia para ele.
361
+ if (this.pendingScroll) {
362
+ this.pendingScroll = false;
363
+ this.$nextTick(() => this.scrollIntoView());
364
+ }
365
+ },
366
+
367
+ // Estado mora na URL (como na Linx): toda mudança vira navegação, e o watch de
368
+ // $route.query refaz a busca — voltar/avançar do navegador funciona sozinho.
369
+ updateQuery (mutate) {
370
+ const query = Object.assign({}, this.$route.query);
371
+ // Origem da sugestão vale só para a busca que veio dela, não para filtro/ordem/página seguintes.
372
+ delete query.rs_src;
373
+ delete query.rs_from;
374
+ mutate(query);
375
+ Object.keys(query).forEach((key) => {
376
+ const keepEmpty = key === this.queryParam && !this.isCategory;
377
+ if (!keepEmpty && (query[key] === '' || query[key] == null)) delete query[key];
378
+ });
379
+ // Na busca o param fica mesmo vazio: é ele que marca "busca sem termo".
380
+ if (!this.isCategory && query[this.queryParam] === undefined) query[this.queryParam] = this.term;
381
+ this.pendingScroll = true;
382
+ this.$router.push({ path: this.$route.path, query });
383
+ },
384
+
385
+ scrollIntoView () {
386
+ const el = this.$refs.products;
387
+ if (el && el.scrollIntoView) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
388
+ },
389
+
390
+ applyDidYouMean (event) {
391
+ dispatchDidYouMeanClick(this.$rocket, {
392
+ from: this.term,
393
+ suggestion: this.context.didYouMean.query,
394
+ source: 'search-page'
395
+ });
396
+ if (this.$rocket.isModifiedClick(event)) return;
397
+ if (event && event.preventDefault) event.preventDefault();
398
+ this.$router.push(this.didYouMeanLocation);
399
+ },
400
+
401
+ // ---- Facets ----
402
+
403
+ isSelected (value, facet) {
404
+ if (value === 0 || value === 1) {
405
+ const booleanFilter = (this.context.appliedFilters || []).find(f => f.type === 'boolean' && f.field === facet.field);
406
+ return !!booleanFilter && booleanFilter.value === (value === 1);
407
+ }
408
+ return (this.context.appliedFilters || []).some(f => (f.values || []).includes(value));
409
+ },
410
+
411
+ clickFacet (value, facet) {
412
+ const selected = this.isSelected(value.value, facet);
413
+ this.updateQuery((query) => {
414
+ if (value.value === 0 || value.value === 1) {
415
+ query[facet.label] = selected ? '' : String(value.value);
416
+ }
417
+ else {
418
+ let values = splitValues(query[facet.label]);
419
+ values = selected ? values.filter(v => v !== String(value.value)) : values.concat(String(value.value));
420
+ query[facet.label] = values.join(';');
421
+ }
422
+ delete query.page;
423
+ delete query.minPrice;
424
+ delete query.maxPrice;
425
+ });
426
+ },
427
+
428
+ applyPrice ({ min, max }) {
429
+ this.updateQuery((query) => {
430
+ query.minPrice = String(min);
431
+ query.maxPrice = String(max);
432
+ delete query.page;
433
+ });
434
+ },
435
+
436
+ clearFacet (value, facet) {
437
+ this.updateQuery((query) => {
438
+ query[facet.alias] = splitValues(query[facet.alias]).filter(v => v !== String(value)).join(';');
439
+ });
440
+ },
441
+
442
+ clearPrice () {
443
+ this.rangeKey++;
444
+ this.updateQuery((query) => {
445
+ delete query.page;
446
+ delete query.minPrice;
447
+ delete query.maxPrice;
448
+ });
449
+ },
450
+
451
+ clearBoolean (facet) {
452
+ this.updateQuery((query) => {
453
+ delete query[facet.alias];
454
+ delete query.page;
455
+ });
456
+ },
457
+
458
+ clearAllFilters () {
459
+ this.rangeKey++;
460
+ this.updateQuery((query) => {
461
+ Object.keys(query).forEach((key) => {
462
+ if (key !== this.queryParam) delete query[key];
463
+ });
464
+ });
465
+ },
466
+
467
+ toggleFacet (facet) {
468
+ const i = this.closedFacets.indexOf(facet.field);
469
+ if (i !== -1) this.closedFacets.splice(i, 1);
470
+ else this.closedFacets.push(facet.field);
471
+ },
472
+
473
+ // ---- Ordenação / paginação ----
474
+
475
+ changeSort (option) {
476
+ this.showSortOptions = false;
477
+ this.updateQuery((query) => {
478
+ query.sort = option.id;
479
+ delete query.page;
480
+ });
481
+ },
482
+
483
+ changePage (page) {
484
+ this.updateQuery((query) => {
485
+ query.page = String(page);
486
+ });
487
+ },
488
+
489
+ // Sem mexer na URL (diferente da Linx): toda troca de rota faz o layout do tema rolar
490
+ // para o topo, o que jogaria o cliente para cima no meio da lista.
491
+ nextPageLivescroll () {
492
+ if (this.livescrolling) return;
493
+ this.livescrolling = true;
494
+ this.fetchResult((this.context.page || 1) + 1);
495
+ },
496
+
497
+ // ---- Tracking ----
498
+
499
+ // Captura o clique em qualquer link do card (o tema navega pelo próprio router-link;
500
+ // em SPA a página não descarrega, então o evento sai sem precisar segurar a navegação).
501
+ trackProductClick (product, index, event) {
502
+ const link = event.target && event.target.closest ? event.target.closest('a[href]') : null;
503
+ if (!link) return;
504
+ // position = índice na lista exibida (mesma semântica da Linx).
505
+ this.$rocket.dispatchEvent('click', {
506
+ productId: String(product.id),
507
+ position: index,
508
+ meta: { source: 'search-page', query: this.term || '' }
509
+ }).catch(() => {});
510
+ }
511
+ }
512
+ };
513
+ </script>