@rocketsearch/vue-uappi 0.0.0-stage → 0.1.1
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/README.md +29 -2
- package/index.js +189 -0
- package/package.json +23 -4
- package/runtime/banner.js +171 -0
- package/runtime/components/RocketAutocomplete.vue +309 -0
- package/runtime/components/RocketBanner.vue +128 -0
- package/runtime/components/RocketSearch.vue +513 -0
- package/runtime/components/RocketShowcase.vue +198 -0
- package/runtime/components/banner/RocketBannerItem.vue +218 -0
- package/runtime/components/search/RocketSearchFacets.vue +143 -0
- package/runtime/components/search/RocketSearchPagination.vue +62 -0
- package/runtime/components/search/RocketSearchRange.vue +76 -0
- package/runtime/css/autocomplete.css +222 -0
- package/runtime/css/banner.css +138 -0
- package/runtime/css/common.css +250 -0
- package/runtime/css/search.css +719 -0
- package/runtime/css/showcase.css +56 -0
- package/runtime/listing.js +274 -0
- package/runtime/rocket.js +661 -0
- package/runtime/uappi.js +76 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/* Port de linxecommerce/2.0.0/src/scss/showcase.scss — CSS nested nativo.
|
|
2
|
+
O Swiper vira scroll-snap: arraste/touch nativos, setas rolam uma "página". */
|
|
3
|
+
|
|
4
|
+
[data-rocket-showcase-position] {
|
|
5
|
+
margin-top: 60px;
|
|
6
|
+
margin-bottom: 60px;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.rocket-search__showcase {
|
|
10
|
+
--showcase-gap: 16px;
|
|
11
|
+
position: relative;
|
|
12
|
+
|
|
13
|
+
& .rocket-search__showcase__title {
|
|
14
|
+
position: relative;
|
|
15
|
+
display: block;
|
|
16
|
+
margin-bottom: 30px;
|
|
17
|
+
font-size: 25px;
|
|
18
|
+
font-weight: 700;
|
|
19
|
+
text-align: center;
|
|
20
|
+
|
|
21
|
+
&.rocket-search__product-placeholder__item {
|
|
22
|
+
width: 150px;
|
|
23
|
+
height: 30px;
|
|
24
|
+
margin-left: auto;
|
|
25
|
+
margin-right: auto;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
& .rocket-search__showcase__description {
|
|
30
|
+
display: block;
|
|
31
|
+
margin-bottom: 30px;
|
|
32
|
+
text-align: center;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
& .rocket-search__showcase__track {
|
|
36
|
+
display: flex;
|
|
37
|
+
gap: var(--showcase-gap);
|
|
38
|
+
margin: 0;
|
|
39
|
+
padding: 0;
|
|
40
|
+
list-style: none;
|
|
41
|
+
overflow-x: auto;
|
|
42
|
+
scroll-snap-type: x mandatory;
|
|
43
|
+
scrollbar-width: none;
|
|
44
|
+
|
|
45
|
+
&::-webkit-scrollbar {
|
|
46
|
+
display: none;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
& .rocket-search__showcase__slide {
|
|
51
|
+
flex: 0 0 calc((100% - (var(--product-columns) - 1) * var(--showcase-gap)) / var(--product-columns));
|
|
52
|
+
min-width: 0;
|
|
53
|
+
scroll-snap-align: start;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
@@ -0,0 +1,274 @@
|
|
|
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 { productPath } 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, path) {
|
|
50
|
+
return { name: 'search', params: {}, query, route: path || 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, path) {
|
|
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), path),
|
|
78
|
+
url: ''
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Origem do "Você quis dizer?": vai à API na busca que veio da sugestão, mas não pode grudar nos
|
|
84
|
+
// links de filtro/ordem — senão toda navegação seguinte seria gravada como vinda da sugestão.
|
|
85
|
+
const ORIGIN_PARAMS = ['rs_src', 'rs_from'];
|
|
86
|
+
|
|
87
|
+
export function toUappiListing (response, products, rawQuery, options) {
|
|
88
|
+
const query = Object.assign({}, rawQuery);
|
|
89
|
+
ORIGIN_PARAMS.forEach((key) => { delete query[key]; });
|
|
90
|
+
const opts = options || {};
|
|
91
|
+
const path = opts.path;
|
|
92
|
+
// Coleção não tem "relevância": a ordem padrão é a da própria coleção (1ª opção da resposta).
|
|
93
|
+
const currentSort = query.order || opts.defaultSort || 'relevance';
|
|
94
|
+
const term = String(query.busca || '');
|
|
95
|
+
const facets = Object.values((response && response.facets) || {});
|
|
96
|
+
const baseQuery = Object.assign({}, query);
|
|
97
|
+
delete baseQuery.pg;
|
|
98
|
+
|
|
99
|
+
const marcas = [];
|
|
100
|
+
const atributos = [];
|
|
101
|
+
const filtrosPreco = [];
|
|
102
|
+
facets.forEach((facet, index) => {
|
|
103
|
+
if (facet.type === 'range') {
|
|
104
|
+
if (facet.min != null && facet.max != null) {
|
|
105
|
+
const de = Math.floor(facet.min);
|
|
106
|
+
const ate = Math.ceil(facet.max);
|
|
107
|
+
filtrosPreco.push({ de, ate, ativo: !!query.preco, rota: route(Object.assign({}, baseQuery, { preco: `${de}-${ate}` }), path), url: '' });
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// Disponibilidade (0/1) não tem par na listagem da Uappi.
|
|
112
|
+
if (facet.type !== 'terms' || facet.field === 'hasStock' || !(facet.values || []).length) return;
|
|
113
|
+
const values = facetValues(facet, query, path);
|
|
114
|
+
if (facet.field === 'brand.name') marcas.push(...values);
|
|
115
|
+
else atributos.push({ id: index + 1, nome: facet.label, tipo: 'texto', campo: facet.field, valores: values });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const ordenacao = ((response && response.sortOptions) || []).map(option => ({
|
|
119
|
+
label: option.label,
|
|
120
|
+
title: option.description || option.label,
|
|
121
|
+
atual: currentSort === option.id,
|
|
122
|
+
rota: route(Object.assign({}, baseQuery, { order: option.id }), path),
|
|
123
|
+
url: ''
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
const size = (response && response.size) || parseInt(query.ipp, 10) || opts.limit;
|
|
127
|
+
const page = (response && response.page) || 1;
|
|
128
|
+
const title = (term ? term.charAt(0).toUpperCase() + term.slice(1) : 'Busca') + (opts.titleSuffix || '');
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
estrutura: {
|
|
132
|
+
nivel: 'product/listing/search',
|
|
133
|
+
seo: { title, titleSocial: title, description: '', descriptionSocial: '', keywords: '', indexar: false },
|
|
134
|
+
breadcrumb: [
|
|
135
|
+
{ label: 'Home', atual: false, rota: { name: 'home', params: {}, query: {}, route: '/' }, url: '/' },
|
|
136
|
+
{ label: 'Busca', atual: true, rota: route({ busca: term }), url: '' }
|
|
137
|
+
]
|
|
138
|
+
},
|
|
139
|
+
conteudo: {
|
|
140
|
+
busca: { texto: term, sugestao: [] },
|
|
141
|
+
layoutPersonalizado: { layout: '', codigoCss: '' },
|
|
142
|
+
produtos: products,
|
|
143
|
+
detalhes: {
|
|
144
|
+
categorias: { niveisAnteriores: [], atual: [], irmas: [], filhas: [] },
|
|
145
|
+
marcas,
|
|
146
|
+
filtros: [],
|
|
147
|
+
filtrosPreco,
|
|
148
|
+
filtrosDescontoPrecoPor: [],
|
|
149
|
+
selos: [],
|
|
150
|
+
atributos,
|
|
151
|
+
ordenacao
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
info: {
|
|
155
|
+
filtros: { busca: term },
|
|
156
|
+
offset: (page - 1) * size,
|
|
157
|
+
limit: size,
|
|
158
|
+
exibindo: products.length,
|
|
159
|
+
total: (response && response.total) || 0
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Busca na Rocket + dados dos produtos na Uappi, já no formato da listagem.
|
|
166
|
+
* `rocketSearch` leva o que o evento `search` precisa: quem chama dispara com
|
|
167
|
+
* `dispatchListingSearch` depois de exibir a página.
|
|
168
|
+
*/
|
|
169
|
+
export async function rocketSearchListing ({ $rocket, query, limit, titleSuffix }) {
|
|
170
|
+
const start = Date.now();
|
|
171
|
+
const response = await $rocket.search(toRocketParams(query, limit));
|
|
172
|
+
const responseTime = Date.now() - start;
|
|
173
|
+
const listing = await withUappiProducts($rocket, response, query, { limit, titleSuffix });
|
|
174
|
+
listing.rocketSearch = {
|
|
175
|
+
query: String(query.busca || ''),
|
|
176
|
+
total: response.total || 0,
|
|
177
|
+
responseTime,
|
|
178
|
+
// "Você quis dizer?" (ROC-349, flag `didYouMean` da loja): a API decide quando e o quê.
|
|
179
|
+
didYouMean: (response.didYouMean && response.didYouMean.query) || null,
|
|
180
|
+
sent: false
|
|
181
|
+
};
|
|
182
|
+
return listing;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function withUappiProducts ($rocket, response, query, opts) {
|
|
186
|
+
$rocket.rememberSearchPrices(response.products);
|
|
187
|
+
const products = await $rocket.fetchProducts((response.products || []).map(p => p.id));
|
|
188
|
+
return toUappiListing(response, products, query, opts);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Categoria e coleção: a página (SEO, breadcrumb, banner da descrição, subcategorias) segue sendo
|
|
192
|
+
// a da Uappi (`base`); produtos, filtros, ordenação e paginação passam a ser os da Rocket. Os
|
|
193
|
+
// filtros nativos da Uappi (ids dela) saem: não filtram nada na Rocket.
|
|
194
|
+
export function mergeIntoUappiPage (base, listing) {
|
|
195
|
+
const conteudo = (base && base.conteudo) || {};
|
|
196
|
+
const rocket = listing.conteudo.detalhes;
|
|
197
|
+
// A faceta de categoria da Rocket duplicaria a navegação de (sub)categorias da Uappi, que a página
|
|
198
|
+
// mantém. Sem essa navegação (coleção, por exemplo), ela fica: é o único filtro de categoria ali.
|
|
199
|
+
const nav = (conteudo.detalhes && conteudo.detalhes.categorias) || {};
|
|
200
|
+
const hasCategoryNav = [nav.filhas, nav.niveisAnteriores].some(list => Array.isArray(list) && list.length > 0);
|
|
201
|
+
const detalhes = Object.assign({}, conteudo.detalhes, {
|
|
202
|
+
marcas: rocket.marcas,
|
|
203
|
+
atributos: hasCategoryNav ? rocket.atributos.filter(a => a.campo !== 'category.name') : rocket.atributos,
|
|
204
|
+
filtrosPreco: rocket.filtrosPreco,
|
|
205
|
+
ordenacao: rocket.ordenacao,
|
|
206
|
+
filtros: [],
|
|
207
|
+
selos: [],
|
|
208
|
+
filtrosDescontoPrecoPor: []
|
|
209
|
+
});
|
|
210
|
+
return Object.assign({}, base, {
|
|
211
|
+
conteudo: Object.assign({}, conteudo, { produtos: listing.conteudo.produtos, detalhes }),
|
|
212
|
+
info: listing.info
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Categoria na Rocket (id da categoria = o da Uappi; a Rocket inclui as subcategorias). */
|
|
217
|
+
export async function rocketCategoryListing ({ $rocket, categoryId, query, limit, base, path }) {
|
|
218
|
+
const params = Object.assign(toRocketParams(query, limit), { categoryId });
|
|
219
|
+
const response = await $rocket.search(params);
|
|
220
|
+
return mergeIntoUappiPage(base, await withUappiProducts($rocket, response, query, { limit, path }));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Coleção do painel pelo slug. null quando a Rocket não tem essa coleção no ar. */
|
|
224
|
+
export async function rocketCollectionListing ({ $rocket, slug, query, limit, base, path }) {
|
|
225
|
+
const params = toRocketParams(query, limit);
|
|
226
|
+
delete params.q;
|
|
227
|
+
if (!query.order) delete params.sort;
|
|
228
|
+
const response = await $rocket.collection(slug, params);
|
|
229
|
+
if (!response) return null;
|
|
230
|
+
const defaultSort = response.sortOptions && response.sortOptions[0] && response.sortOptions[0].id;
|
|
231
|
+
return mergeIntoUappiPage(base, await withUappiProducts($rocket, response, query, { limit, path, defaultSort }));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function dispatchListingSearch ($rocket, listing) {
|
|
235
|
+
const info = listing && listing.rocketSearch;
|
|
236
|
+
if (!info || info.sent || !info.query || typeof window === 'undefined') return;
|
|
237
|
+
info.sent = true;
|
|
238
|
+
$rocket.dispatchEvent('search', {
|
|
239
|
+
query: info.query,
|
|
240
|
+
resultsCount: info.total,
|
|
241
|
+
meta: { query: info.query, resultsCount: info.total, responseTime: info.responseTime }
|
|
242
|
+
}).catch(() => {});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Clique dentro da lista do tema (card dele): casa o link clicado com o produto pela rota.
|
|
246
|
+
// -1 quando o clique não foi num link de produto da lista.
|
|
247
|
+
export function productIndexFromClick (event, products) {
|
|
248
|
+
const link = event && event.target && event.target.closest ? event.target.closest('a[href]') : null;
|
|
249
|
+
if (!link) return -1;
|
|
250
|
+
const path = new URL(link.href, window.location.href).pathname;
|
|
251
|
+
return (products || []).findIndex(p => productPath(p) === path);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Query da busca pela sugestão. rs_src/rs_from fazem a API gravar `origin: did_you_mean` no evento.
|
|
255
|
+
export function didYouMeanQuery (suggestion, from) {
|
|
256
|
+
return { busca: suggestion, rs_src: 'dym', rs_from: from || '' };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// source: 'autocomplete' | 'search-page' (mesmo contrato do SDK Linx 2.0.0).
|
|
260
|
+
export function dispatchDidYouMeanClick ($rocket, { from, suggestion, source }) {
|
|
261
|
+
$rocket.dispatchEvent('suggestion_click', {
|
|
262
|
+
query: from || '',
|
|
263
|
+
meta: { kind: 'did_you_mean', query: from || '', suggestion, source }
|
|
264
|
+
}).catch(() => {});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function dispatchListingClick ($rocket, listing, product, position) {
|
|
268
|
+
if (!product || product.id == null) return;
|
|
269
|
+
$rocket.dispatchEvent('click', {
|
|
270
|
+
productId: String(product.id),
|
|
271
|
+
position,
|
|
272
|
+
meta: { source: 'search-page', query: (listing && listing.rocketSearch && listing.rocketSearch.query) || '' }
|
|
273
|
+
}).catch(() => {});
|
|
274
|
+
}
|