@rocketsearch/nuxt-uappi 0.1.0 → 0.2.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/module.js +3 -2
- package/package.json +1 -1
- package/runtime/components/RocketSearch.vue +480 -480
- package/runtime/listing.js +189 -0
package/module.js
CHANGED
|
@@ -77,8 +77,9 @@ module.exports = function rocketSearchUappi (moduleOptions) {
|
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
// O pacote chega fora de node_modules via link em dev e dentro dele em produção:
|
|
80
|
-
// nos dois casos o Babel do tema precisa transpilar.
|
|
81
|
-
|
|
80
|
+
// nos dois casos o Babel do tema precisa transpilar. O nome cobre o import direto do
|
|
81
|
+
// tema (runtime/listing.js): sem ele o bundle do SSR faz require() de ES module.
|
|
82
|
+
this.options.build.transpile.push(__dirname, module.exports.meta.name);
|
|
82
83
|
|
|
83
84
|
// Conexão fria + preflight CORS era o grosso da latência do storefront, não a busca.
|
|
84
85
|
this.options.head.link = this.options.head.link || [];
|
package/package.json
CHANGED
|
@@ -1,480 +1,480 @@
|
|
|
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
|
-
<div v-if="context.appliedFilters.length > 0" class="rocket-search__facets__selected--v2">
|
|
64
|
-
<span>Filtros aplicados:</span>
|
|
65
|
-
<ul>
|
|
66
|
-
<template v-for="(facet, i) in context.appliedFilters">
|
|
67
|
-
<li v-for="(value, j) in facet.values || []" :key="'f-' + i + '-' + j" :data-alias="facet.alias">
|
|
68
|
-
<span class="option-title" :data-alias="facet.alias">
|
|
69
|
-
<span class="name" v-text="facet.alias + ':'" />
|
|
70
|
-
<span class="label" v-text="value" />
|
|
71
|
-
</span>
|
|
72
|
-
<button class="rocket-search__facets__selected__clear-filter" aria-label="Remover filtro" @click="clearFacet(value, facet)">
|
|
73
|
-
x
|
|
74
|
-
</button>
|
|
75
|
-
</li>
|
|
76
|
-
<li v-if="facet.type === 'range'" :key="'r-' + i" :data-alias="facet.alias">
|
|
77
|
-
<span class="option-title" :data-alias="facet.alias">
|
|
78
|
-
<span class="name" v-text="facet.alias + ':'" />
|
|
79
|
-
<span class="label">De {{ formatCurrency(facet.min) }} até {{ formatCurrency(facet.max) }}</span>
|
|
80
|
-
</span>
|
|
81
|
-
<button class="rocket-search__facets__selected__clear-filter" aria-label="Remover filtro" @click="clearPrice">
|
|
82
|
-
x
|
|
83
|
-
</button>
|
|
84
|
-
</li>
|
|
85
|
-
<li v-if="facet.type === 'boolean'" :key="'b-' + i" :data-alias="facet.alias" :data-type="facet.type">
|
|
86
|
-
<span class="option-title" :data-alias="facet.alias">
|
|
87
|
-
<span class="name" v-text="facet.alias + ':'" />
|
|
88
|
-
<span class="label" v-text="facet.value ? 'Sim' : 'Não'" />
|
|
89
|
-
</span>
|
|
90
|
-
<button class="rocket-search__facets__selected__clear-filter" aria-label="Remover filtro" @click="clearBoolean(facet)">
|
|
91
|
-
x
|
|
92
|
-
</button>
|
|
93
|
-
</li>
|
|
94
|
-
</template>
|
|
95
|
-
<li class="rocket-search__clear-all-filters">
|
|
96
|
-
<button @click="clearAllFilters">
|
|
97
|
-
Limpar
|
|
98
|
-
</button>
|
|
99
|
-
</li>
|
|
100
|
-
</ul>
|
|
101
|
-
</div>
|
|
102
|
-
|
|
103
|
-
<div v-show="isLoading && !livescrolling" class="rocket-search__product-placeholder">
|
|
104
|
-
<div v-for="n in size" :key="n" class="rocket-search__product-placeholder__product-line">
|
|
105
|
-
<div v-for="i in 6" :key="i" class="rocket-search__product-placeholder__item" :class="'item-' + i" />
|
|
106
|
-
</div>
|
|
107
|
-
</div>
|
|
108
|
-
|
|
109
|
-
<ul v-show="!isLoading || livescrolling" ref="products" class="rocket-search__products">
|
|
110
|
-
<li
|
|
111
|
-
v-for="(product, index) in products"
|
|
112
|
-
:key="product.id"
|
|
113
|
-
:data-product-id="product.id"
|
|
114
|
-
class="rocket-search__product"
|
|
115
|
-
@click.capture="trackProductClick(product, index, $event)"
|
|
116
|
-
>
|
|
117
|
-
<slot name="product" :product="product" :position="index">
|
|
118
|
-
<nuxt-link :to="productPath(product)" class="rocket-search__card">
|
|
119
|
-
<img v-if="productImage(product)" :src="productImage(product)" :alt="product.nome" loading="lazy">
|
|
120
|
-
<span class="rocket-search__card-name" v-text="product.nome" />
|
|
121
|
-
<span v-if="productPrice(product) !== null" class="rocket-search__card-price" v-text="formatCurrency(productPrice(product))" />
|
|
122
|
-
</nuxt-link>
|
|
123
|
-
</slot>
|
|
124
|
-
</li>
|
|
125
|
-
</ul>
|
|
126
|
-
|
|
127
|
-
<p v-if="!isLoading && loaded && !products.length" class="rocket-search__empty">
|
|
128
|
-
Nenhum produto encontrado.
|
|
129
|
-
</p>
|
|
130
|
-
|
|
131
|
-
<RocketSearchPagination
|
|
132
|
-
v-if="!livescroll && context.totalPages > 1"
|
|
133
|
-
:page="context.page"
|
|
134
|
-
:total-pages="context.totalPages"
|
|
135
|
-
:has-prev-page="context.hasPrevPage"
|
|
136
|
-
:has-next-page="context.hasNextPage"
|
|
137
|
-
@change="changePage"
|
|
138
|
-
/>
|
|
139
|
-
|
|
140
|
-
<div v-if="livescroll && context.hasNextPage" class="rocket-search__footer">
|
|
141
|
-
<button class="rocket-search__livescroll" :class="{ 'button-loading': livescrolling }" @click="nextPageLivescroll">
|
|
142
|
-
Veja mais
|
|
143
|
-
</button>
|
|
144
|
-
</div>
|
|
145
|
-
</div>
|
|
146
|
-
</div>
|
|
147
|
-
</div>
|
|
148
|
-
|
|
149
|
-
<div v-if="fixedFacetsOpened && mode === 'fixed'" id="rocket-search__overlay" class="visible" @click="fixedFacetsOpened = false" />
|
|
150
|
-
<RocketSearchFacets
|
|
151
|
-
v-if="mode === 'fixed'"
|
|
152
|
-
:facets="context.facets"
|
|
153
|
-
:applied-filters="context.appliedFilters"
|
|
154
|
-
:closed-facets="closedFacets"
|
|
155
|
-
:fixed-facets-opened="fixedFacetsOpened"
|
|
156
|
-
facets-mode="fixed"
|
|
157
|
-
:is-mobile="isMobile"
|
|
158
|
-
:is-selected="isSelected"
|
|
159
|
-
:range-key="rangeKey"
|
|
160
|
-
@close="fixedFacetsOpened = false"
|
|
161
|
-
@toggle-facet="toggleFacet"
|
|
162
|
-
@click-facet="clickFacet"
|
|
163
|
-
@apply-price="applyPrice"
|
|
164
|
-
@clear-facet="clearFacet"
|
|
165
|
-
@clear-price="clearPrice"
|
|
166
|
-
@clear-boolean="clearBoolean"
|
|
167
|
-
@clear-all="clearAllFilters"
|
|
168
|
-
/>
|
|
169
|
-
</div>
|
|
170
|
-
</div>
|
|
171
|
-
</template>
|
|
172
|
-
|
|
173
|
-
<script>
|
|
174
|
-
import RocketSearchFacets from './search/RocketSearchFacets.vue';
|
|
175
|
-
import RocketSearchPagination from './search/RocketSearchPagination.vue';
|
|
176
|
-
import { fetchUappiProducts, productImage, productPath, productPrice, formatCurrency } from '../uappi.js';
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
function emptyContext () {
|
|
181
|
-
return {
|
|
182
|
-
appliedFilters: [],
|
|
183
|
-
total: 0,
|
|
184
|
-
page: 0,
|
|
185
|
-
size: 0,
|
|
186
|
-
totalPages: 0,
|
|
187
|
-
hasNextPage: false,
|
|
188
|
-
hasPrevPage: false,
|
|
189
|
-
products: [],
|
|
190
|
-
facets: {},
|
|
191
|
-
sortInfo: { requested: 'relevance', applied: 'relevance' },
|
|
192
|
-
sortOptions: []
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function splitValues (raw) {
|
|
197
|
-
return String(raw || '').split(';').filter(v => v !== '');
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
export default {
|
|
201
|
-
name: 'RocketSearch',
|
|
202
|
-
components: { RocketSearchFacets, RocketSearchPagination },
|
|
203
|
-
props: {
|
|
204
|
-
pageSize: { type: Number, default: 20 },
|
|
205
|
-
facetsMode: { type: String, default: 'side' },
|
|
206
|
-
livescroll: { type: Boolean, default: false },
|
|
207
|
-
// Modo categoria: filtra a busca pela categoria (id da Rocket = id da categoria na Uappi).
|
|
208
|
-
isCategory: { type: Boolean, default: false },
|
|
209
|
-
categoryId: { type: [String, Number], default: '' },
|
|
210
|
-
mediaSize: { type: String, default: 'medium' }
|
|
211
|
-
},
|
|
212
|
-
data () {
|
|
213
|
-
return {
|
|
214
|
-
context: emptyContext(),
|
|
215
|
-
products: [],
|
|
216
|
-
isLoading: true,
|
|
217
|
-
loaded: false,
|
|
218
|
-
livescrolling: false,
|
|
219
|
-
showSortOptions: false,
|
|
220
|
-
fixedFacetsOpened: false,
|
|
221
|
-
closedFacets: [],
|
|
222
|
-
isMobile: false,
|
|
223
|
-
rangeKey: 0
|
|
224
|
-
};
|
|
225
|
-
},
|
|
226
|
-
computed: {
|
|
227
|
-
size () {
|
|
228
|
-
return this.pageSize;
|
|
229
|
-
},
|
|
230
|
-
mode () {
|
|
231
|
-
// Mobile sempre usa o painel fixo (mesma regra da Linx).
|
|
232
|
-
return this.isMobile || this.facetsMode === 'fixed' ? 'fixed' : 'side';
|
|
233
|
-
},
|
|
234
|
-
queryParam () {
|
|
235
|
-
return this.$rocket.config.searchPageParam;
|
|
236
|
-
},
|
|
237
|
-
term () {
|
|
238
|
-
const q = this.$route.query;
|
|
239
|
-
return String(q[this.queryParam] || q.q || '');
|
|
240
|
-
},
|
|
241
|
-
appliedSortLabel () {
|
|
242
|
-
const applied = this.context.sortInfo && this.context.sortInfo.applied;
|
|
243
|
-
const option = (this.context.sortOptions || []).find(o => o.id === applied);
|
|
244
|
-
return option ? option.label : 'Ordenar';
|
|
245
|
-
}
|
|
246
|
-
},
|
|
247
|
-
watch: {
|
|
248
|
-
'$route.query' () {
|
|
249
|
-
this.fetchResult();
|
|
250
|
-
}
|
|
251
|
-
},
|
|
252
|
-
mounted () {
|
|
253
|
-
this.isMobile = window.innerWidth < 1024;
|
|
254
|
-
this.onDocumentClick = (event) => {
|
|
255
|
-
if (!event.target.closest || !event.target.closest('.rocket-search__sorter')) this.showSortOptions = false;
|
|
256
|
-
};
|
|
257
|
-
this.onKeyup = (event) => {
|
|
258
|
-
if (event.code === 'Escape') this.fixedFacetsOpened = false;
|
|
259
|
-
};
|
|
260
|
-
document.addEventListener('click', this.onDocumentClick);
|
|
261
|
-
document.addEventListener('keyup', this.onKeyup);
|
|
262
|
-
this.fetchResult();
|
|
263
|
-
},
|
|
264
|
-
beforeDestroy () {
|
|
265
|
-
document.removeEventListener('click', this.onDocumentClick);
|
|
266
|
-
document.removeEventListener('keyup', this.onKeyup);
|
|
267
|
-
document.body.classList.remove('faceting');
|
|
268
|
-
},
|
|
269
|
-
methods: {
|
|
270
|
-
productImage (product) {
|
|
271
|
-
return productImage(product, this.mediaSize);
|
|
272
|
-
},
|
|
273
|
-
productPath,
|
|
274
|
-
productPrice,
|
|
275
|
-
formatCurrency,
|
|
276
|
-
|
|
277
|
-
// ---- Busca ----
|
|
278
|
-
|
|
279
|
-
buildParams (page) {
|
|
280
|
-
const query = this.$route.query;
|
|
281
|
-
const params = {
|
|
282
|
-
q: this.term,
|
|
283
|
-
size: this.size,
|
|
284
|
-
sort: query.sort || 'relevance',
|
|
285
|
-
page: page || query.page || 1
|
|
286
|
-
};
|
|
287
|
-
Object.keys(query).forEach((key) => {
|
|
288
|
-
const value = query[key];
|
|
289
|
-
if (value === undefined || value === null || value === '') return;
|
|
290
|
-
if (key === this.queryParam || key === 'q' || key === 'sort' || key === 'page') return;
|
|
291
|
-
const lower = key.toLowerCase();
|
|
292
|
-
if (TRACKING_PARAMS.includes(lower) || lower.indexOf('utm_') === 0) return;
|
|
293
|
-
params[key] = Array.isArray(value) ? value.join(';') : value;
|
|
294
|
-
});
|
|
295
|
-
if (this.isCategory && this.categoryId) params.categoryId = this.categoryId;
|
|
296
|
-
return params;
|
|
297
|
-
},
|
|
298
|
-
|
|
299
|
-
async fetchResult (page) {
|
|
300
|
-
const seq = this.seq = (this.seq || 0) + 1;
|
|
301
|
-
const append = this.livescrolling;
|
|
302
|
-
this.isLoading = true;
|
|
303
|
-
document.body.classList.add('faceting');
|
|
304
|
-
|
|
305
|
-
try {
|
|
306
|
-
const start = Date.now();
|
|
307
|
-
const response = await this.$rocket.search(this.buildParams(page));
|
|
308
|
-
if (seq !== this.seq) return;
|
|
309
|
-
const responseTime = Date.now() - start;
|
|
310
|
-
this.context = Object.assign(emptyContext(), response);
|
|
311
|
-
this.$rocket.rememberSearchPrices(this.context.products);
|
|
312
|
-
|
|
313
|
-
if (this.term !== '') {
|
|
314
|
-
this.$rocket.dispatchEvent('search', {
|
|
315
|
-
query: this.term,
|
|
316
|
-
resultsCount: this.context.total,
|
|
317
|
-
meta: { query: this.term, resultsCount: this.context.total, responseTime }
|
|
318
|
-
}).catch(() => {});
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// Rocket ordena/filtra; o card mostra o produto como a Uappi o tem.
|
|
322
|
-
const ids = (this.context.products || []).map(p => p.id);
|
|
323
|
-
const products = await fetchUappiProducts(this.$axios, ids);
|
|
324
|
-
if (seq !== this.seq) return;
|
|
325
|
-
this.products = append ? this.products.concat(products) : products;
|
|
326
|
-
this.$emit('products-loaded', { products: this.products, total: this.context.total, term: this.term });
|
|
327
|
-
}
|
|
328
|
-
catch (e) {
|
|
329
|
-
if (seq !== this.seq) return;
|
|
330
|
-
if (!append) this.products = [];
|
|
331
|
-
console.warn('[RocketSearch] busca falhou', e && e.message ? e.message : e);
|
|
332
|
-
}
|
|
333
|
-
this.isLoading = false;
|
|
334
|
-
this.livescrolling = false;
|
|
335
|
-
this.loaded = true;
|
|
336
|
-
document.body.classList.remove('faceting');
|
|
337
|
-
// Depois da resposta: o layout do tema rola para o topo a cada troca de rota, e um
|
|
338
|
-
// scroll disparado junto com o push perderia para ele.
|
|
339
|
-
if (this.pendingScroll) {
|
|
340
|
-
this.pendingScroll = false;
|
|
341
|
-
this.$nextTick(() => this.scrollIntoView());
|
|
342
|
-
}
|
|
343
|
-
},
|
|
344
|
-
|
|
345
|
-
// Estado mora na URL (como na Linx): toda mudança vira navegação, e o watch de
|
|
346
|
-
// $route.query refaz a busca — voltar/avançar do navegador funciona sozinho.
|
|
347
|
-
updateQuery (mutate) {
|
|
348
|
-
const query = Object.assign({}, this.$route.query);
|
|
349
|
-
mutate(query);
|
|
350
|
-
Object.keys(query).forEach((key) => {
|
|
351
|
-
const keepEmpty = key === this.queryParam && !this.isCategory;
|
|
352
|
-
if (!keepEmpty && (query[key] === '' || query[key] == null)) delete query[key];
|
|
353
|
-
});
|
|
354
|
-
// Na busca o param fica mesmo vazio: é ele que marca "busca sem termo".
|
|
355
|
-
if (!this.isCategory && query[this.queryParam] === undefined) query[this.queryParam] = this.term;
|
|
356
|
-
this.pendingScroll = true;
|
|
357
|
-
this.$router.push({ path: this.$route.path, query }).catch(() => {});
|
|
358
|
-
},
|
|
359
|
-
|
|
360
|
-
scrollIntoView () {
|
|
361
|
-
const el = this.$refs.products;
|
|
362
|
-
if (el && el.scrollIntoView) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
363
|
-
},
|
|
364
|
-
|
|
365
|
-
// ---- Facets ----
|
|
366
|
-
|
|
367
|
-
isSelected (value, facet) {
|
|
368
|
-
if (value === 0 || value === 1) {
|
|
369
|
-
const booleanFilter = (this.context.appliedFilters || []).find(f => f.type === 'boolean' && f.field === facet.field);
|
|
370
|
-
return !!booleanFilter && booleanFilter.value === (value === 1);
|
|
371
|
-
}
|
|
372
|
-
return (this.context.appliedFilters || []).some(f => (f.values || []).includes(value));
|
|
373
|
-
},
|
|
374
|
-
|
|
375
|
-
clickFacet (value, facet) {
|
|
376
|
-
const selected = this.isSelected(value.value, facet);
|
|
377
|
-
this.updateQuery((query) => {
|
|
378
|
-
if (value.value === 0 || value.value === 1) {
|
|
379
|
-
query[facet.label] = selected ? '' : String(value.value);
|
|
380
|
-
}
|
|
381
|
-
else {
|
|
382
|
-
let values = splitValues(query[facet.label]);
|
|
383
|
-
values = selected ? values.filter(v => v !== String(value.value)) : values.concat(String(value.value));
|
|
384
|
-
query[facet.label] = values.join(';');
|
|
385
|
-
}
|
|
386
|
-
delete query.page;
|
|
387
|
-
delete query.minPrice;
|
|
388
|
-
delete query.maxPrice;
|
|
389
|
-
});
|
|
390
|
-
},
|
|
391
|
-
|
|
392
|
-
applyPrice ({ min, max }) {
|
|
393
|
-
this.updateQuery((query) => {
|
|
394
|
-
query.minPrice = String(min);
|
|
395
|
-
query.maxPrice = String(max);
|
|
396
|
-
delete query.page;
|
|
397
|
-
});
|
|
398
|
-
},
|
|
399
|
-
|
|
400
|
-
clearFacet (value, facet) {
|
|
401
|
-
this.updateQuery((query) => {
|
|
402
|
-
query[facet.alias] = splitValues(query[facet.alias]).filter(v => v !== String(value)).join(';');
|
|
403
|
-
});
|
|
404
|
-
},
|
|
405
|
-
|
|
406
|
-
clearPrice () {
|
|
407
|
-
this.rangeKey++;
|
|
408
|
-
this.updateQuery((query) => {
|
|
409
|
-
delete query.page;
|
|
410
|
-
delete query.minPrice;
|
|
411
|
-
delete query.maxPrice;
|
|
412
|
-
});
|
|
413
|
-
},
|
|
414
|
-
|
|
415
|
-
clearBoolean (facet) {
|
|
416
|
-
this.updateQuery((query) => {
|
|
417
|
-
delete query[facet.alias];
|
|
418
|
-
delete query.page;
|
|
419
|
-
});
|
|
420
|
-
},
|
|
421
|
-
|
|
422
|
-
clearAllFilters () {
|
|
423
|
-
this.rangeKey++;
|
|
424
|
-
this.updateQuery((query) => {
|
|
425
|
-
Object.keys(query).forEach((key) => {
|
|
426
|
-
if (key !== this.queryParam) delete query[key];
|
|
427
|
-
});
|
|
428
|
-
});
|
|
429
|
-
},
|
|
430
|
-
|
|
431
|
-
toggleFacet (facet) {
|
|
432
|
-
const i = this.closedFacets.indexOf(facet.field);
|
|
433
|
-
if (i !== -1) this.closedFacets.splice(i, 1);
|
|
434
|
-
else this.closedFacets.push(facet.field);
|
|
435
|
-
},
|
|
436
|
-
|
|
437
|
-
// ---- Ordenação / paginação ----
|
|
438
|
-
|
|
439
|
-
changeSort (option) {
|
|
440
|
-
this.showSortOptions = false;
|
|
441
|
-
this.updateQuery((query) => {
|
|
442
|
-
query.sort = option.id;
|
|
443
|
-
delete query.page;
|
|
444
|
-
});
|
|
445
|
-
},
|
|
446
|
-
|
|
447
|
-
changePage (page) {
|
|
448
|
-
this.updateQuery((query) => {
|
|
449
|
-
query.page = String(page);
|
|
450
|
-
});
|
|
451
|
-
},
|
|
452
|
-
|
|
453
|
-
// Sem mexer na URL (diferente da Linx): toda troca de rota faz o layout do tema rolar
|
|
454
|
-
// para o topo, o que jogaria o cliente para cima no meio da lista.
|
|
455
|
-
nextPageLivescroll () {
|
|
456
|
-
if (this.livescrolling) return;
|
|
457
|
-
this.livescrolling = true;
|
|
458
|
-
this.fetchResult((this.context.page || 1) + 1);
|
|
459
|
-
},
|
|
460
|
-
|
|
461
|
-
// ---- Tracking ----
|
|
462
|
-
|
|
463
|
-
// Captura o clique em qualquer link do card (o tema navega pelo próprio router-link;
|
|
464
|
-
// em SPA a página não descarrega, então o evento sai sem precisar segurar a navegação).
|
|
465
|
-
trackProductClick (product, index, event) {
|
|
466
|
-
const link = event.target && event.target.closest ? event.target.closest('a[href]') : null;
|
|
467
|
-
if (!link) return;
|
|
468
|
-
// position = índice na lista exibida (mesma semântica da Linx).
|
|
469
|
-
this.$rocket.dispatchEvent('click', {
|
|
470
|
-
productId: String(product.id),
|
|
471
|
-
position: index,
|
|
472
|
-
meta: { source: 'search-page', query: this.term || '' }
|
|
473
|
-
}).catch(() => {});
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
};
|
|
477
|
-
</script>
|
|
478
|
-
|
|
479
|
-
<style src="../css/common.css"></style>
|
|
480
|
-
<style src="../css/search.css"></style>
|
|
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
|
+
<div v-if="context.appliedFilters.length > 0" class="rocket-search__facets__selected--v2">
|
|
64
|
+
<span>Filtros aplicados:</span>
|
|
65
|
+
<ul>
|
|
66
|
+
<template v-for="(facet, i) in context.appliedFilters">
|
|
67
|
+
<li v-for="(value, j) in facet.values || []" :key="'f-' + i + '-' + j" :data-alias="facet.alias">
|
|
68
|
+
<span class="option-title" :data-alias="facet.alias">
|
|
69
|
+
<span class="name" v-text="facet.alias + ':'" />
|
|
70
|
+
<span class="label" v-text="value" />
|
|
71
|
+
</span>
|
|
72
|
+
<button class="rocket-search__facets__selected__clear-filter" aria-label="Remover filtro" @click="clearFacet(value, facet)">
|
|
73
|
+
x
|
|
74
|
+
</button>
|
|
75
|
+
</li>
|
|
76
|
+
<li v-if="facet.type === 'range'" :key="'r-' + i" :data-alias="facet.alias">
|
|
77
|
+
<span class="option-title" :data-alias="facet.alias">
|
|
78
|
+
<span class="name" v-text="facet.alias + ':'" />
|
|
79
|
+
<span class="label">De {{ formatCurrency(facet.min) }} até {{ formatCurrency(facet.max) }}</span>
|
|
80
|
+
</span>
|
|
81
|
+
<button class="rocket-search__facets__selected__clear-filter" aria-label="Remover filtro" @click="clearPrice">
|
|
82
|
+
x
|
|
83
|
+
</button>
|
|
84
|
+
</li>
|
|
85
|
+
<li v-if="facet.type === 'boolean'" :key="'b-' + i" :data-alias="facet.alias" :data-type="facet.type">
|
|
86
|
+
<span class="option-title" :data-alias="facet.alias">
|
|
87
|
+
<span class="name" v-text="facet.alias + ':'" />
|
|
88
|
+
<span class="label" v-text="facet.value ? 'Sim' : 'Não'" />
|
|
89
|
+
</span>
|
|
90
|
+
<button class="rocket-search__facets__selected__clear-filter" aria-label="Remover filtro" @click="clearBoolean(facet)">
|
|
91
|
+
x
|
|
92
|
+
</button>
|
|
93
|
+
</li>
|
|
94
|
+
</template>
|
|
95
|
+
<li class="rocket-search__clear-all-filters">
|
|
96
|
+
<button @click="clearAllFilters">
|
|
97
|
+
Limpar
|
|
98
|
+
</button>
|
|
99
|
+
</li>
|
|
100
|
+
</ul>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div v-show="isLoading && !livescrolling" class="rocket-search__product-placeholder">
|
|
104
|
+
<div v-for="n in size" :key="n" class="rocket-search__product-placeholder__product-line">
|
|
105
|
+
<div v-for="i in 6" :key="i" class="rocket-search__product-placeholder__item" :class="'item-' + i" />
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<ul v-show="!isLoading || livescrolling" ref="products" class="rocket-search__products">
|
|
110
|
+
<li
|
|
111
|
+
v-for="(product, index) in products"
|
|
112
|
+
:key="product.id"
|
|
113
|
+
:data-product-id="product.id"
|
|
114
|
+
class="rocket-search__product"
|
|
115
|
+
@click.capture="trackProductClick(product, index, $event)"
|
|
116
|
+
>
|
|
117
|
+
<slot name="product" :product="product" :position="index">
|
|
118
|
+
<nuxt-link :to="productPath(product)" class="rocket-search__card">
|
|
119
|
+
<img v-if="productImage(product)" :src="productImage(product)" :alt="product.nome" loading="lazy">
|
|
120
|
+
<span class="rocket-search__card-name" v-text="product.nome" />
|
|
121
|
+
<span v-if="productPrice(product) !== null" class="rocket-search__card-price" v-text="formatCurrency(productPrice(product))" />
|
|
122
|
+
</nuxt-link>
|
|
123
|
+
</slot>
|
|
124
|
+
</li>
|
|
125
|
+
</ul>
|
|
126
|
+
|
|
127
|
+
<p v-if="!isLoading && loaded && !products.length" class="rocket-search__empty">
|
|
128
|
+
Nenhum produto encontrado.
|
|
129
|
+
</p>
|
|
130
|
+
|
|
131
|
+
<RocketSearchPagination
|
|
132
|
+
v-if="!livescroll && context.totalPages > 1"
|
|
133
|
+
:page="context.page"
|
|
134
|
+
:total-pages="context.totalPages"
|
|
135
|
+
:has-prev-page="context.hasPrevPage"
|
|
136
|
+
:has-next-page="context.hasNextPage"
|
|
137
|
+
@change="changePage"
|
|
138
|
+
/>
|
|
139
|
+
|
|
140
|
+
<div v-if="livescroll && context.hasNextPage" class="rocket-search__footer">
|
|
141
|
+
<button class="rocket-search__livescroll" :class="{ 'button-loading': livescrolling }" @click="nextPageLivescroll">
|
|
142
|
+
Veja mais
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<div v-if="fixedFacetsOpened && mode === 'fixed'" id="rocket-search__overlay" class="visible" @click="fixedFacetsOpened = false" />
|
|
150
|
+
<RocketSearchFacets
|
|
151
|
+
v-if="mode === 'fixed'"
|
|
152
|
+
:facets="context.facets"
|
|
153
|
+
:applied-filters="context.appliedFilters"
|
|
154
|
+
:closed-facets="closedFacets"
|
|
155
|
+
:fixed-facets-opened="fixedFacetsOpened"
|
|
156
|
+
facets-mode="fixed"
|
|
157
|
+
:is-mobile="isMobile"
|
|
158
|
+
:is-selected="isSelected"
|
|
159
|
+
:range-key="rangeKey"
|
|
160
|
+
@close="fixedFacetsOpened = false"
|
|
161
|
+
@toggle-facet="toggleFacet"
|
|
162
|
+
@click-facet="clickFacet"
|
|
163
|
+
@apply-price="applyPrice"
|
|
164
|
+
@clear-facet="clearFacet"
|
|
165
|
+
@clear-price="clearPrice"
|
|
166
|
+
@clear-boolean="clearBoolean"
|
|
167
|
+
@clear-all="clearAllFilters"
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</template>
|
|
172
|
+
|
|
173
|
+
<script>
|
|
174
|
+
import RocketSearchFacets from './search/RocketSearchFacets.vue';
|
|
175
|
+
import RocketSearchPagination from './search/RocketSearchPagination.vue';
|
|
176
|
+
import { fetchUappiProducts, productImage, productPath, productPrice, formatCurrency } from '../uappi.js';
|
|
177
|
+
import { TRACKING_PARAMS } from '../listing.js';
|
|
178
|
+
|
|
179
|
+
// Parâmetros de campanha nunca viram filtro na busca.
|
|
180
|
+
function emptyContext () {
|
|
181
|
+
return {
|
|
182
|
+
appliedFilters: [],
|
|
183
|
+
total: 0,
|
|
184
|
+
page: 0,
|
|
185
|
+
size: 0,
|
|
186
|
+
totalPages: 0,
|
|
187
|
+
hasNextPage: false,
|
|
188
|
+
hasPrevPage: false,
|
|
189
|
+
products: [],
|
|
190
|
+
facets: {},
|
|
191
|
+
sortInfo: { requested: 'relevance', applied: 'relevance' },
|
|
192
|
+
sortOptions: []
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function splitValues (raw) {
|
|
197
|
+
return String(raw || '').split(';').filter(v => v !== '');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export default {
|
|
201
|
+
name: 'RocketSearch',
|
|
202
|
+
components: { RocketSearchFacets, RocketSearchPagination },
|
|
203
|
+
props: {
|
|
204
|
+
pageSize: { type: Number, default: 20 },
|
|
205
|
+
facetsMode: { type: String, default: 'side' },
|
|
206
|
+
livescroll: { type: Boolean, default: false },
|
|
207
|
+
// Modo categoria: filtra a busca pela categoria (id da Rocket = id da categoria na Uappi).
|
|
208
|
+
isCategory: { type: Boolean, default: false },
|
|
209
|
+
categoryId: { type: [String, Number], default: '' },
|
|
210
|
+
mediaSize: { type: String, default: 'medium' }
|
|
211
|
+
},
|
|
212
|
+
data () {
|
|
213
|
+
return {
|
|
214
|
+
context: emptyContext(),
|
|
215
|
+
products: [],
|
|
216
|
+
isLoading: true,
|
|
217
|
+
loaded: false,
|
|
218
|
+
livescrolling: false,
|
|
219
|
+
showSortOptions: false,
|
|
220
|
+
fixedFacetsOpened: false,
|
|
221
|
+
closedFacets: [],
|
|
222
|
+
isMobile: false,
|
|
223
|
+
rangeKey: 0
|
|
224
|
+
};
|
|
225
|
+
},
|
|
226
|
+
computed: {
|
|
227
|
+
size () {
|
|
228
|
+
return this.pageSize;
|
|
229
|
+
},
|
|
230
|
+
mode () {
|
|
231
|
+
// Mobile sempre usa o painel fixo (mesma regra da Linx).
|
|
232
|
+
return this.isMobile || this.facetsMode === 'fixed' ? 'fixed' : 'side';
|
|
233
|
+
},
|
|
234
|
+
queryParam () {
|
|
235
|
+
return this.$rocket.config.searchPageParam;
|
|
236
|
+
},
|
|
237
|
+
term () {
|
|
238
|
+
const q = this.$route.query;
|
|
239
|
+
return String(q[this.queryParam] || q.q || '');
|
|
240
|
+
},
|
|
241
|
+
appliedSortLabel () {
|
|
242
|
+
const applied = this.context.sortInfo && this.context.sortInfo.applied;
|
|
243
|
+
const option = (this.context.sortOptions || []).find(o => o.id === applied);
|
|
244
|
+
return option ? option.label : 'Ordenar';
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
watch: {
|
|
248
|
+
'$route.query' () {
|
|
249
|
+
this.fetchResult();
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
mounted () {
|
|
253
|
+
this.isMobile = window.innerWidth < 1024;
|
|
254
|
+
this.onDocumentClick = (event) => {
|
|
255
|
+
if (!event.target.closest || !event.target.closest('.rocket-search__sorter')) this.showSortOptions = false;
|
|
256
|
+
};
|
|
257
|
+
this.onKeyup = (event) => {
|
|
258
|
+
if (event.code === 'Escape') this.fixedFacetsOpened = false;
|
|
259
|
+
};
|
|
260
|
+
document.addEventListener('click', this.onDocumentClick);
|
|
261
|
+
document.addEventListener('keyup', this.onKeyup);
|
|
262
|
+
this.fetchResult();
|
|
263
|
+
},
|
|
264
|
+
beforeDestroy () {
|
|
265
|
+
document.removeEventListener('click', this.onDocumentClick);
|
|
266
|
+
document.removeEventListener('keyup', this.onKeyup);
|
|
267
|
+
document.body.classList.remove('faceting');
|
|
268
|
+
},
|
|
269
|
+
methods: {
|
|
270
|
+
productImage (product) {
|
|
271
|
+
return productImage(product, this.mediaSize);
|
|
272
|
+
},
|
|
273
|
+
productPath,
|
|
274
|
+
productPrice,
|
|
275
|
+
formatCurrency,
|
|
276
|
+
|
|
277
|
+
// ---- Busca ----
|
|
278
|
+
|
|
279
|
+
buildParams (page) {
|
|
280
|
+
const query = this.$route.query;
|
|
281
|
+
const params = {
|
|
282
|
+
q: this.term,
|
|
283
|
+
size: this.size,
|
|
284
|
+
sort: query.sort || 'relevance',
|
|
285
|
+
page: page || query.page || 1
|
|
286
|
+
};
|
|
287
|
+
Object.keys(query).forEach((key) => {
|
|
288
|
+
const value = query[key];
|
|
289
|
+
if (value === undefined || value === null || value === '') return;
|
|
290
|
+
if (key === this.queryParam || key === 'q' || key === 'sort' || key === 'page') return;
|
|
291
|
+
const lower = key.toLowerCase();
|
|
292
|
+
if (TRACKING_PARAMS.includes(lower) || lower.indexOf('utm_') === 0) return;
|
|
293
|
+
params[key] = Array.isArray(value) ? value.join(';') : value;
|
|
294
|
+
});
|
|
295
|
+
if (this.isCategory && this.categoryId) params.categoryId = this.categoryId;
|
|
296
|
+
return params;
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
async fetchResult (page) {
|
|
300
|
+
const seq = this.seq = (this.seq || 0) + 1;
|
|
301
|
+
const append = this.livescrolling;
|
|
302
|
+
this.isLoading = true;
|
|
303
|
+
document.body.classList.add('faceting');
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
const start = Date.now();
|
|
307
|
+
const response = await this.$rocket.search(this.buildParams(page));
|
|
308
|
+
if (seq !== this.seq) return;
|
|
309
|
+
const responseTime = Date.now() - start;
|
|
310
|
+
this.context = Object.assign(emptyContext(), response);
|
|
311
|
+
this.$rocket.rememberSearchPrices(this.context.products);
|
|
312
|
+
|
|
313
|
+
if (this.term !== '') {
|
|
314
|
+
this.$rocket.dispatchEvent('search', {
|
|
315
|
+
query: this.term,
|
|
316
|
+
resultsCount: this.context.total,
|
|
317
|
+
meta: { query: this.term, resultsCount: this.context.total, responseTime }
|
|
318
|
+
}).catch(() => {});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Rocket ordena/filtra; o card mostra o produto como a Uappi o tem.
|
|
322
|
+
const ids = (this.context.products || []).map(p => p.id);
|
|
323
|
+
const products = await fetchUappiProducts(this.$axios, ids);
|
|
324
|
+
if (seq !== this.seq) return;
|
|
325
|
+
this.products = append ? this.products.concat(products) : products;
|
|
326
|
+
this.$emit('products-loaded', { products: this.products, total: this.context.total, term: this.term });
|
|
327
|
+
}
|
|
328
|
+
catch (e) {
|
|
329
|
+
if (seq !== this.seq) return;
|
|
330
|
+
if (!append) this.products = [];
|
|
331
|
+
console.warn('[RocketSearch] busca falhou', e && e.message ? e.message : e);
|
|
332
|
+
}
|
|
333
|
+
this.isLoading = false;
|
|
334
|
+
this.livescrolling = false;
|
|
335
|
+
this.loaded = true;
|
|
336
|
+
document.body.classList.remove('faceting');
|
|
337
|
+
// Depois da resposta: o layout do tema rola para o topo a cada troca de rota, e um
|
|
338
|
+
// scroll disparado junto com o push perderia para ele.
|
|
339
|
+
if (this.pendingScroll) {
|
|
340
|
+
this.pendingScroll = false;
|
|
341
|
+
this.$nextTick(() => this.scrollIntoView());
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
// Estado mora na URL (como na Linx): toda mudança vira navegação, e o watch de
|
|
346
|
+
// $route.query refaz a busca — voltar/avançar do navegador funciona sozinho.
|
|
347
|
+
updateQuery (mutate) {
|
|
348
|
+
const query = Object.assign({}, this.$route.query);
|
|
349
|
+
mutate(query);
|
|
350
|
+
Object.keys(query).forEach((key) => {
|
|
351
|
+
const keepEmpty = key === this.queryParam && !this.isCategory;
|
|
352
|
+
if (!keepEmpty && (query[key] === '' || query[key] == null)) delete query[key];
|
|
353
|
+
});
|
|
354
|
+
// Na busca o param fica mesmo vazio: é ele que marca "busca sem termo".
|
|
355
|
+
if (!this.isCategory && query[this.queryParam] === undefined) query[this.queryParam] = this.term;
|
|
356
|
+
this.pendingScroll = true;
|
|
357
|
+
this.$router.push({ path: this.$route.path, query }).catch(() => {});
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
scrollIntoView () {
|
|
361
|
+
const el = this.$refs.products;
|
|
362
|
+
if (el && el.scrollIntoView) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
// ---- Facets ----
|
|
366
|
+
|
|
367
|
+
isSelected (value, facet) {
|
|
368
|
+
if (value === 0 || value === 1) {
|
|
369
|
+
const booleanFilter = (this.context.appliedFilters || []).find(f => f.type === 'boolean' && f.field === facet.field);
|
|
370
|
+
return !!booleanFilter && booleanFilter.value === (value === 1);
|
|
371
|
+
}
|
|
372
|
+
return (this.context.appliedFilters || []).some(f => (f.values || []).includes(value));
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
clickFacet (value, facet) {
|
|
376
|
+
const selected = this.isSelected(value.value, facet);
|
|
377
|
+
this.updateQuery((query) => {
|
|
378
|
+
if (value.value === 0 || value.value === 1) {
|
|
379
|
+
query[facet.label] = selected ? '' : String(value.value);
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
let values = splitValues(query[facet.label]);
|
|
383
|
+
values = selected ? values.filter(v => v !== String(value.value)) : values.concat(String(value.value));
|
|
384
|
+
query[facet.label] = values.join(';');
|
|
385
|
+
}
|
|
386
|
+
delete query.page;
|
|
387
|
+
delete query.minPrice;
|
|
388
|
+
delete query.maxPrice;
|
|
389
|
+
});
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
applyPrice ({ min, max }) {
|
|
393
|
+
this.updateQuery((query) => {
|
|
394
|
+
query.minPrice = String(min);
|
|
395
|
+
query.maxPrice = String(max);
|
|
396
|
+
delete query.page;
|
|
397
|
+
});
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
clearFacet (value, facet) {
|
|
401
|
+
this.updateQuery((query) => {
|
|
402
|
+
query[facet.alias] = splitValues(query[facet.alias]).filter(v => v !== String(value)).join(';');
|
|
403
|
+
});
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
clearPrice () {
|
|
407
|
+
this.rangeKey++;
|
|
408
|
+
this.updateQuery((query) => {
|
|
409
|
+
delete query.page;
|
|
410
|
+
delete query.minPrice;
|
|
411
|
+
delete query.maxPrice;
|
|
412
|
+
});
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
clearBoolean (facet) {
|
|
416
|
+
this.updateQuery((query) => {
|
|
417
|
+
delete query[facet.alias];
|
|
418
|
+
delete query.page;
|
|
419
|
+
});
|
|
420
|
+
},
|
|
421
|
+
|
|
422
|
+
clearAllFilters () {
|
|
423
|
+
this.rangeKey++;
|
|
424
|
+
this.updateQuery((query) => {
|
|
425
|
+
Object.keys(query).forEach((key) => {
|
|
426
|
+
if (key !== this.queryParam) delete query[key];
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
},
|
|
430
|
+
|
|
431
|
+
toggleFacet (facet) {
|
|
432
|
+
const i = this.closedFacets.indexOf(facet.field);
|
|
433
|
+
if (i !== -1) this.closedFacets.splice(i, 1);
|
|
434
|
+
else this.closedFacets.push(facet.field);
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
// ---- Ordenação / paginação ----
|
|
438
|
+
|
|
439
|
+
changeSort (option) {
|
|
440
|
+
this.showSortOptions = false;
|
|
441
|
+
this.updateQuery((query) => {
|
|
442
|
+
query.sort = option.id;
|
|
443
|
+
delete query.page;
|
|
444
|
+
});
|
|
445
|
+
},
|
|
446
|
+
|
|
447
|
+
changePage (page) {
|
|
448
|
+
this.updateQuery((query) => {
|
|
449
|
+
query.page = String(page);
|
|
450
|
+
});
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
// Sem mexer na URL (diferente da Linx): toda troca de rota faz o layout do tema rolar
|
|
454
|
+
// para o topo, o que jogaria o cliente para cima no meio da lista.
|
|
455
|
+
nextPageLivescroll () {
|
|
456
|
+
if (this.livescrolling) return;
|
|
457
|
+
this.livescrolling = true;
|
|
458
|
+
this.fetchResult((this.context.page || 1) + 1);
|
|
459
|
+
},
|
|
460
|
+
|
|
461
|
+
// ---- Tracking ----
|
|
462
|
+
|
|
463
|
+
// Captura o clique em qualquer link do card (o tema navega pelo próprio router-link;
|
|
464
|
+
// em SPA a página não descarrega, então o evento sai sem precisar segurar a navegação).
|
|
465
|
+
trackProductClick (product, index, event) {
|
|
466
|
+
const link = event.target && event.target.closest ? event.target.closest('a[href]') : null;
|
|
467
|
+
if (!link) return;
|
|
468
|
+
// position = índice na lista exibida (mesma semântica da Linx).
|
|
469
|
+
this.$rocket.dispatchEvent('click', {
|
|
470
|
+
productId: String(product.id),
|
|
471
|
+
position: index,
|
|
472
|
+
meta: { source: 'search-page', query: this.term || '' }
|
|
473
|
+
}).catch(() => {});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
</script>
|
|
478
|
+
|
|
479
|
+
<style src="../css/common.css"></style>
|
|
480
|
+
<style src="../css/search.css"></style>
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Busca da Rocket no formato da listagem da Front API da Uappi
|
|
3
|
+
* (`/v2/front/url/product/listing/search`).
|
|
4
|
+
*
|
|
5
|
+
* Para o tema que quer manter a própria página de listagem (filtros, ordenação,
|
|
6
|
+
* paginação, card): troca só a fonte dos dados. A URL segue o vocabulário do tema
|
|
7
|
+
* (busca, pg, ipp, order, preco); facets da Rocket entram como `?<Facet>=v1;v2`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { fetchUappiProducts } from './uappi.js';
|
|
11
|
+
|
|
12
|
+
export const TRACKING_PARAMS = ['gclid', 'gbraid', 'wbraid', 'gad_source', 'gad_campaignid', 'gclsrc', 'dclid', 'fbclid', 'msclkid', 'yclid', 'ttclid', 'twclid', 'epik', 'sccid', 'sc_click_id', 'li_fat_id', 'rdt_cid', 'irclickid', 'ko_click_id', 'ob_click_id', 'tblci', 'mc_cid', 'mc_eid', '_hsenc', '_hsmi', 'hsctatracking', 'mkt_tok', 'vero_id', 'wickedid'];
|
|
13
|
+
|
|
14
|
+
// Params do tema que não são facet. marca/filtro/atributo/selo são os filtros nativos da
|
|
15
|
+
// Uappi (ids dela): link antigo com eles não pode zerar a busca.
|
|
16
|
+
const THEME_PARAMS = ['busca', 'pg', 'ipp', 'order', 'preco', 'marca', 'filtro', 'atributo', 'selo'];
|
|
17
|
+
|
|
18
|
+
const SEARCH_ROUTE = '/busca';
|
|
19
|
+
|
|
20
|
+
function splitValues (raw) {
|
|
21
|
+
return String(raw || '').split(';').filter(v => v !== '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isForwardable (key) {
|
|
25
|
+
const lower = key.toLowerCase();
|
|
26
|
+
return !THEME_PARAMS.includes(key) && !TRACKING_PARAMS.includes(lower) && lower.indexOf('utm_') !== 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function toRocketParams (query, limit) {
|
|
30
|
+
const params = {
|
|
31
|
+
q: String(query.busca || ''),
|
|
32
|
+
page: parseInt(query.pg, 10) || 1,
|
|
33
|
+
size: parseInt(query.ipp, 10) || limit,
|
|
34
|
+
sort: query.order || 'relevance'
|
|
35
|
+
};
|
|
36
|
+
if (query.preco) {
|
|
37
|
+
const [min, max] = String(query.preco).split('-');
|
|
38
|
+
if (min) params.minPrice = min;
|
|
39
|
+
if (max) params.maxPrice = max;
|
|
40
|
+
}
|
|
41
|
+
Object.keys(query).forEach((key) => {
|
|
42
|
+
const value = query[key];
|
|
43
|
+
if (value === undefined || value === null || value === '' || !isForwardable(key)) return;
|
|
44
|
+
params[key] = Array.isArray(value) ? value.join(';') : value;
|
|
45
|
+
});
|
|
46
|
+
return params;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function route (query) {
|
|
50
|
+
return { name: 'search', params: {}, query, route: SEARCH_ROUTE };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Query do link de uma opção: estado atual com o valor ligado/desligado, página zerada.
|
|
54
|
+
function toggled (query, key, value) {
|
|
55
|
+
const next = Object.assign({}, query);
|
|
56
|
+
delete next.pg;
|
|
57
|
+
const values = splitValues(next[key]);
|
|
58
|
+
const on = values.includes(value);
|
|
59
|
+
const result = on ? values.filter(v => v !== value) : values.concat(value);
|
|
60
|
+
if (result.length) next[key] = result.join(';');
|
|
61
|
+
else delete next[key];
|
|
62
|
+
return next;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function facetValues (facet, query) {
|
|
66
|
+
const active = splitValues(query[facet.label]);
|
|
67
|
+
return (facet.values || []).map((v, index) => {
|
|
68
|
+
const value = String(v.value);
|
|
69
|
+
return {
|
|
70
|
+
id: index + 1,
|
|
71
|
+
nome: value,
|
|
72
|
+
label: value,
|
|
73
|
+
imagem: '',
|
|
74
|
+
imagemOriginal: '',
|
|
75
|
+
ordem: index,
|
|
76
|
+
ativo: active.includes(value),
|
|
77
|
+
rota: route(toggled(query, facet.label, value)),
|
|
78
|
+
url: ''
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function toUappiListing (response, products, query, options) {
|
|
84
|
+
const opts = options || {};
|
|
85
|
+
const term = String(query.busca || '');
|
|
86
|
+
const facets = Object.values((response && response.facets) || {});
|
|
87
|
+
const baseQuery = Object.assign({}, query);
|
|
88
|
+
delete baseQuery.pg;
|
|
89
|
+
|
|
90
|
+
const marcas = [];
|
|
91
|
+
const atributos = [];
|
|
92
|
+
const filtrosPreco = [];
|
|
93
|
+
facets.forEach((facet, index) => {
|
|
94
|
+
if (facet.type === 'range') {
|
|
95
|
+
if (facet.min != null && facet.max != null) {
|
|
96
|
+
const de = Math.floor(facet.min);
|
|
97
|
+
const ate = Math.ceil(facet.max);
|
|
98
|
+
filtrosPreco.push({ de, ate, ativo: !!query.preco, rota: route(Object.assign({}, baseQuery, { preco: `${de}-${ate}` })), url: '' });
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Disponibilidade (0/1) não tem par na listagem da Uappi.
|
|
103
|
+
if (facet.type !== 'terms' || facet.field === 'hasStock' || !(facet.values || []).length) return;
|
|
104
|
+
const values = facetValues(facet, query);
|
|
105
|
+
if (facet.field === 'brand.name') marcas.push(...values);
|
|
106
|
+
else atributos.push({ id: index + 1, nome: facet.label, tipo: 'texto', valores: values });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const ordenacao = ((response && response.sortOptions) || []).map(option => ({
|
|
110
|
+
label: option.label,
|
|
111
|
+
title: option.description || option.label,
|
|
112
|
+
atual: (query.order || 'relevance') === option.id,
|
|
113
|
+
rota: route(Object.assign({}, baseQuery, { order: option.id })),
|
|
114
|
+
url: ''
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
const size = (response && response.size) || parseInt(query.ipp, 10) || opts.limit;
|
|
118
|
+
const page = (response && response.page) || 1;
|
|
119
|
+
const title = (term ? term.charAt(0).toUpperCase() + term.slice(1) : 'Busca') + (opts.titleSuffix || '');
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
estrutura: {
|
|
123
|
+
nivel: 'product/listing/search',
|
|
124
|
+
seo: { title, titleSocial: title, description: '', descriptionSocial: '', keywords: '', indexar: false },
|
|
125
|
+
breadcrumb: [
|
|
126
|
+
{ label: 'Home', atual: false, rota: { name: 'home', params: {}, query: {}, route: '/' }, url: '/' },
|
|
127
|
+
{ label: 'Busca', atual: true, rota: route({ busca: term }), url: '' }
|
|
128
|
+
]
|
|
129
|
+
},
|
|
130
|
+
conteudo: {
|
|
131
|
+
busca: { texto: term, sugestao: [] },
|
|
132
|
+
layoutPersonalizado: { layout: '', codigoCss: '' },
|
|
133
|
+
produtos: products,
|
|
134
|
+
detalhes: {
|
|
135
|
+
categorias: { niveisAnteriores: [], atual: [], irmas: [], filhas: [] },
|
|
136
|
+
marcas,
|
|
137
|
+
filtros: [],
|
|
138
|
+
filtrosPreco,
|
|
139
|
+
filtrosDescontoPrecoPor: [],
|
|
140
|
+
selos: [],
|
|
141
|
+
atributos,
|
|
142
|
+
ordenacao
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
info: {
|
|
146
|
+
filtros: { busca: term },
|
|
147
|
+
offset: (page - 1) * size,
|
|
148
|
+
limit: size,
|
|
149
|
+
exibindo: products.length,
|
|
150
|
+
total: (response && response.total) || 0
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Busca na Rocket + dados dos produtos na Uappi, já no formato da listagem.
|
|
157
|
+
* `rocketSearch` leva o que o evento `search` precisa: no SSR o dispatchEvent é no-op,
|
|
158
|
+
* então quem chama dispara com `dispatchListingSearch` já no cliente.
|
|
159
|
+
*/
|
|
160
|
+
export async function rocketSearchListing ({ $rocket, $axios, query, limit, titleSuffix }) {
|
|
161
|
+
const start = Date.now();
|
|
162
|
+
const response = await $rocket.search(toRocketParams(query, limit));
|
|
163
|
+
const responseTime = Date.now() - start;
|
|
164
|
+
$rocket.rememberSearchPrices(response.products);
|
|
165
|
+
const products = await fetchUappiProducts($axios, (response.products || []).map(p => p.id));
|
|
166
|
+
const listing = toUappiListing(response, products, query, { limit, titleSuffix });
|
|
167
|
+
listing.rocketSearch = { query: String(query.busca || ''), total: response.total || 0, responseTime, sent: false };
|
|
168
|
+
return listing;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function dispatchListingSearch ($rocket, listing) {
|
|
172
|
+
const info = listing && listing.rocketSearch;
|
|
173
|
+
if (!info || info.sent || !info.query || typeof window === 'undefined') return;
|
|
174
|
+
info.sent = true;
|
|
175
|
+
$rocket.dispatchEvent('search', {
|
|
176
|
+
query: info.query,
|
|
177
|
+
resultsCount: info.total,
|
|
178
|
+
meta: { query: info.query, resultsCount: info.total, responseTime: info.responseTime }
|
|
179
|
+
}).catch(() => {});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function dispatchListingClick ($rocket, listing, product, position) {
|
|
183
|
+
if (!product || product.id == null) return;
|
|
184
|
+
$rocket.dispatchEvent('click', {
|
|
185
|
+
productId: String(product.id),
|
|
186
|
+
position,
|
|
187
|
+
meta: { source: 'search-page', query: (listing && listing.rocketSearch && listing.rocketSearch.query) || '' }
|
|
188
|
+
}).catch(() => {});
|
|
189
|
+
}
|