@rocketsearch/nuxt-uappi 0.2.0 → 0.2.2
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/package.json +1 -1
- package/runtime/components/RocketSearch.vue +480 -480
- package/runtime/components/RocketShowcase.vue +10 -3
- package/runtime/css/common.css +44 -31
- package/runtime/plugin.js +6 -0
- package/runtime/rocket.js +17 -0
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
|
-
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>
|
|
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>
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
2
|
+
<div
|
|
3
|
+
v-if="showcase || loading || debug"
|
|
4
|
+
:class="['rocket-search', { 'rocket-search--empty': !showcase && !loading }]"
|
|
5
|
+
:data-rocket-showcase-position="position"
|
|
6
|
+
>
|
|
3
7
|
<div v-if="loading" class="rocket-search__showcase rocket-search__showcase--skeleton" :style="columnsStyle">
|
|
4
8
|
<div class="rocket-search__showcase__title rocket-search__product-placeholder__item" />
|
|
5
9
|
<div class="rocket-search__product-placeholder">
|
|
@@ -13,7 +17,7 @@
|
|
|
13
17
|
<RocketBannerItem v-else-if="bannerOnly" :item="showcase" :position="position" />
|
|
14
18
|
|
|
15
19
|
<section
|
|
16
|
-
v-else
|
|
20
|
+
v-else-if="showcase"
|
|
17
21
|
ref="root"
|
|
18
22
|
class="rocket-search__showcase"
|
|
19
23
|
:data-rocket-showcase-id="showcase.id"
|
|
@@ -62,7 +66,7 @@ export default {
|
|
|
62
66
|
mediaSize: { type: String, default: 'medium' }
|
|
63
67
|
},
|
|
64
68
|
data () {
|
|
65
|
-
return { showcase: null, products: [], loading: false };
|
|
69
|
+
return { showcase: null, products: [], loading: false, debug: false };
|
|
66
70
|
},
|
|
67
71
|
computed: {
|
|
68
72
|
columns () {
|
|
@@ -88,6 +92,9 @@ export default {
|
|
|
88
92
|
}
|
|
89
93
|
},
|
|
90
94
|
mounted () {
|
|
95
|
+
// ?rocket-search-debug=true: o slot aparece mesmo vazio. Lido no mounted (não no data)
|
|
96
|
+
// para o HTML do SSR bater com a hidratação.
|
|
97
|
+
this.debug = document.body.classList.contains('rocket-search-debug');
|
|
91
98
|
if (this.pageType || this.$rocket.getPage().section) this.load();
|
|
92
99
|
},
|
|
93
100
|
beforeDestroy () {
|
package/runtime/css/common.css
CHANGED
|
@@ -93,63 +93,76 @@
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
/* DEBUG: ?rocket-search-debug=true contorna e rotula
|
|
96
|
+
/* DEBUG: ?rocket-search-debug=true contorna e rotula toda posição de vitrine — inclusive as
|
|
97
|
+
vazias, que o RocketShowcase só renderiza no debug. Mesmo layout da Linx 2.0.0. */
|
|
97
98
|
body.rocket-search-debug {
|
|
98
99
|
& [data-rocket-showcase-position] {
|
|
100
|
+
--color: #64748b;
|
|
101
|
+
--content: attr(data-rocket-showcase-position);
|
|
99
102
|
position: relative;
|
|
100
|
-
|
|
101
|
-
padding:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
min-height: 140px;
|
|
104
|
+
padding: 52px 16px 16px;
|
|
105
|
+
border: 2px dashed var(--color);
|
|
106
|
+
border-radius: 12px;
|
|
107
|
+
overflow: hidden;
|
|
108
|
+
background-color: color-mix(in srgb, var(--color) 6%, transparent);
|
|
109
|
+
|
|
110
|
+
/* Selo com o nome da posição */
|
|
111
|
+
&::before {
|
|
105
112
|
content: var(--content);
|
|
106
113
|
position: absolute;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
114
|
+
top: 12px;
|
|
115
|
+
left: 12px;
|
|
116
|
+
padding: 5px 12px;
|
|
117
|
+
border-radius: 999px;
|
|
118
|
+
color: #fff;
|
|
119
|
+
background-color: var(--color);
|
|
120
|
+
font: 700 12px/1.2 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
121
|
+
letter-spacing: 0.06em;
|
|
122
|
+
text-transform: uppercase;
|
|
123
|
+
z-index: 2;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* Atributo do slot, para quem vai mexer no template */
|
|
127
|
+
&::after {
|
|
128
|
+
content: "data-rocket-showcase-position=\"" attr(data-rocket-showcase-position) "\"";
|
|
129
|
+
position: absolute;
|
|
130
|
+
top: 16px;
|
|
131
|
+
right: 14px;
|
|
111
132
|
color: var(--color);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
text-align: center;
|
|
116
|
-
background-color: #f6f6f6;
|
|
117
|
-
z-index: 1;
|
|
133
|
+
font: 500 11px/1.2 ui-monospace, Consolas, monospace;
|
|
134
|
+
opacity: 0.8;
|
|
135
|
+
z-index: 2;
|
|
118
136
|
}
|
|
119
137
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
&::after {
|
|
126
|
-
bottom: 0;
|
|
127
|
-
transform: translate(-50%, 50%);
|
|
138
|
+
/* Posição sem vitrine (o RocketShowcase só a renderiza no debug) */
|
|
139
|
+
&.rocket-search--empty::before {
|
|
140
|
+
content: var(--content) " · vazia";
|
|
128
141
|
}
|
|
129
142
|
|
|
130
143
|
&[data-rocket-showcase-position="hero"] {
|
|
131
|
-
--color:
|
|
144
|
+
--color: #7c3aed;
|
|
132
145
|
--content: "Hero";
|
|
133
146
|
}
|
|
134
147
|
|
|
135
148
|
&[data-rocket-showcase-position="top"] {
|
|
136
|
-
--color:
|
|
149
|
+
--color: #059669;
|
|
137
150
|
--content: "Topo";
|
|
138
151
|
}
|
|
139
152
|
|
|
140
153
|
&[data-rocket-showcase-position="middle"] {
|
|
141
|
-
--color:
|
|
154
|
+
--color: #d97706;
|
|
142
155
|
--content: "Meio";
|
|
143
156
|
}
|
|
144
157
|
|
|
145
158
|
&[data-rocket-showcase-position="bottom"] {
|
|
146
|
-
--color:
|
|
147
|
-
--content: "
|
|
159
|
+
--color: #dc2626;
|
|
160
|
+
--content: "Rodapé";
|
|
148
161
|
}
|
|
149
162
|
|
|
150
163
|
&[data-rocket-showcase-position="prefooter"] {
|
|
151
|
-
--color:
|
|
152
|
-
--content: "Antes do
|
|
164
|
+
--color: #2563eb;
|
|
165
|
+
--content: "Antes do footer";
|
|
153
166
|
}
|
|
154
167
|
}
|
|
155
168
|
}
|
package/runtime/plugin.js
CHANGED
|
@@ -51,10 +51,16 @@ export default function ({ app, store, $axios }, inject) {
|
|
|
51
51
|
// chega hidratado — não há mutation no cliente para ouvir.
|
|
52
52
|
if (store && store.state.dadosPageAtual) applyPage(store.state.dadosPageAtual);
|
|
53
53
|
|
|
54
|
+
// Cliente logado (ROC-656): o tema guarda o retorno de checkout/user (`{ id, logado }`) no
|
|
55
|
+
// Vuex — `setUser` no SSR, então chega hidratado — e commita `setUser` de novo no login e na
|
|
56
|
+
// troca de perfil, que não recarregam a página.
|
|
57
|
+
if (store && store.state.user) rocket.trackLogin(store.state.user).catch(() => {});
|
|
58
|
+
|
|
54
59
|
// Navegação SPA: o tema commita setDadosPageAtual quando a página nova carrega os dados.
|
|
55
60
|
if (store) {
|
|
56
61
|
store.subscribe((mutation) => {
|
|
57
62
|
if (mutation.type === 'setDadosPageAtual') applyPage(mutation.payload);
|
|
63
|
+
else if (mutation.type === 'setUser') rocket.trackLogin(mutation.payload).catch(() => {});
|
|
58
64
|
});
|
|
59
65
|
}
|
|
60
66
|
|
package/runtime/rocket.js
CHANGED
|
@@ -22,6 +22,7 @@ const PENDING_CART_EVENTS_KEY = 'rs_pending_add_to_cart_events';
|
|
|
22
22
|
const ANON_SESSION_KEY = 'rs_anon_session_id';
|
|
23
23
|
const ANON_SESSION_TTL = 30 * 60 * 1000;
|
|
24
24
|
const LAST_SECTION_KEY = 'rs_last_section';
|
|
25
|
+
const LOGIN_SENT_KEY = 'rs_login_sent';
|
|
25
26
|
const DEBUG_TOKEN_KEY = 'rs_debug_token';
|
|
26
27
|
const SHOWCASE_DEBUG_KEY = 'rs_showcase_debug';
|
|
27
28
|
// 2h — em sincronia com DEBUG_SESSION_TTL_SECONDS no backend.
|
|
@@ -489,6 +490,22 @@ export function createRocket (config) {
|
|
|
489
490
|
rocket.dispatchEvent('page_view', { meta: { pageType: section, fromSection: prev || null } }).catch(() => {});
|
|
490
491
|
};
|
|
491
492
|
|
|
493
|
+
// Cliente logado → 'login' com o id do cliente na Uappi (o mesmo `cliente.id` dos pedidos),
|
|
494
|
+
// uma vez por (sessão, cliente) na aba (ROC-656). `user` é a resposta da Front API em
|
|
495
|
+
// checkout/user, checkout/login e checkout/login-multiple-profile: `{ id, logado, ... }`.
|
|
496
|
+
// A Uappi não tem verificador de comprador: o vínculo fica `declared`. A marca só é gravada
|
|
497
|
+
// depois de entregue — envio perdido sai de novo na próxima resposta com o cliente.
|
|
498
|
+
rocket.trackLogin = async function (user) {
|
|
499
|
+
const customerId = user && user.logado !== false ? toPositiveNumber(user.id) : null;
|
|
500
|
+
if (!isBrowser || !customerId) return;
|
|
501
|
+
const sessionId = await rocket.getSessionId();
|
|
502
|
+
if (!sessionId) return;
|
|
503
|
+
const mark = sessionId + ':' + customerId;
|
|
504
|
+
if (storageGet(LOGIN_SENT_KEY) === mark) return;
|
|
505
|
+
const res = await rocket.dispatchEvent('login', { customerId });
|
|
506
|
+
if (res && res.ok) storageSet(LOGIN_SENT_KEY, mark);
|
|
507
|
+
};
|
|
508
|
+
|
|
492
509
|
rocket.fireProductView = function () {
|
|
493
510
|
if (!page.productId) return;
|
|
494
511
|
const viewData = { productId: page.productId };
|