@rocketsearch/vue-uappi 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +270 -0
- package/runtime/rocket.js +661 -0
- package/runtime/uappi.js +76 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
v-if="showcase || loading || debug"
|
|
4
|
+
:class="['rocket-search', { 'rocket-search--empty': !showcase && !loading }]"
|
|
5
|
+
:data-rocket-showcase-position="position"
|
|
6
|
+
>
|
|
7
|
+
<div v-if="loading" class="rocket-search__showcase rocket-search__showcase--skeleton" :style="columnsStyle">
|
|
8
|
+
<div class="rocket-search__showcase__title rocket-search__product-placeholder__item" />
|
|
9
|
+
<div class="rocket-search__product-placeholder">
|
|
10
|
+
<div v-for="n in columns" :key="n" class="rocket-search__product-placeholder__product-line">
|
|
11
|
+
<div v-for="i in 6" :key="i" :class="['rocket-search__product-placeholder__item', 'item-' + i]" />
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<!-- Banner autônomo na posição de vitrine: só o criativo, sem título nem produtos. -->
|
|
17
|
+
<RocketBannerItem v-else-if="bannerOnly" :item="showcase" :position="position" />
|
|
18
|
+
|
|
19
|
+
<section
|
|
20
|
+
v-else-if="showcase"
|
|
21
|
+
ref="root"
|
|
22
|
+
class="rocket-search__showcase"
|
|
23
|
+
:data-rocket-showcase-id="showcase.id"
|
|
24
|
+
:style="columnsStyle"
|
|
25
|
+
@click.capture="trackClick"
|
|
26
|
+
>
|
|
27
|
+
<!-- Banner vinculado (linked_banner_id): acima dos produtos, medido com showcaseId + bannerId. -->
|
|
28
|
+
<div v-if="linkedBanner" class="rocket-search__showcase__banner">
|
|
29
|
+
<RocketBannerItem :item="showcase" :position="position" />
|
|
30
|
+
</div>
|
|
31
|
+
<!-- Slot default: o tema pode usar o carrossel dele (recebe showcase + produtos Uappi). -->
|
|
32
|
+
<slot v-if="products.length" :showcase="showcase" :products="products">
|
|
33
|
+
<div v-if="showcase.name" class="rocket-search__showcase__title" v-text="showcase.name" />
|
|
34
|
+
<div v-if="showcase.description" class="rocket-search__showcase__description" v-text="showcase.description" />
|
|
35
|
+
<ul ref="track" class="rocket-search__showcase__track">
|
|
36
|
+
<li v-for="(product, index) in products" :key="product.id" class="rocket-search__showcase__slide">
|
|
37
|
+
<slot name="product" :product="product" :position="index">
|
|
38
|
+
<RouterLink :to="productPath(product)" class="rocket-search__card">
|
|
39
|
+
<img v-if="productImage(product)" :src="productImage(product)" :alt="product.nome" loading="lazy">
|
|
40
|
+
<span class="rocket-search__card-name" v-text="product.nome" />
|
|
41
|
+
<span v-if="productPrice(product) !== null" class="rocket-search__card-price" v-text="formatCurrency(productPrice(product))" />
|
|
42
|
+
</RouterLink>
|
|
43
|
+
</slot>
|
|
44
|
+
</li>
|
|
45
|
+
</ul>
|
|
46
|
+
<button type="button" class="rocket-search__swiper-button-prev" aria-label="Anteriores" @click="slide(-1)" />
|
|
47
|
+
<button type="button" class="rocket-search__swiper-button-next" aria-label="Próximos" @click="slide(1)" />
|
|
48
|
+
</slot>
|
|
49
|
+
</section>
|
|
50
|
+
</div>
|
|
51
|
+
</template>
|
|
52
|
+
|
|
53
|
+
<script>
|
|
54
|
+
// CSS pelo script e não por <style src>: o plugin-vue registra o dono de um src pelo caminho
|
|
55
|
+
// do arquivo, e o mesmo .css em dois SFCs (com índices diferentes) quebra o build.
|
|
56
|
+
import '../css/common.css';
|
|
57
|
+
import '../css/showcase.css';
|
|
58
|
+
import '../css/banner.css';
|
|
59
|
+
import RocketBannerItem from './banner/RocketBannerItem.vue';
|
|
60
|
+
import { productImage, productPath, productPrice, formatCurrency } from '../uappi.js';
|
|
61
|
+
import { isBannerItem } from '../banner.js';
|
|
62
|
+
|
|
63
|
+
export default {
|
|
64
|
+
name: 'RocketShowcase',
|
|
65
|
+
components: { RocketBannerItem },
|
|
66
|
+
emits: ['products-loaded'],
|
|
67
|
+
props: {
|
|
68
|
+
// Posição cadastrada no painel (placement.position): top, middle, bottom...
|
|
69
|
+
position: { type: String, required: true },
|
|
70
|
+
// Força o pageType; sem ele vem da página atual (currentPageData do tema).
|
|
71
|
+
pageType: { type: String, default: '' },
|
|
72
|
+
mediaSize: { type: String, default: 'medium' }
|
|
73
|
+
},
|
|
74
|
+
data () {
|
|
75
|
+
return { showcase: null, products: [], loading: false, debug: false };
|
|
76
|
+
},
|
|
77
|
+
computed: {
|
|
78
|
+
columns () {
|
|
79
|
+
const layout = (this.showcase && this.showcase.layout) || {};
|
|
80
|
+
const mobile = typeof window !== 'undefined' && window.innerWidth < 1024;
|
|
81
|
+
return Number(mobile ? layout.mobileColumns : layout.desktopColumns) || (mobile ? 2 : 4);
|
|
82
|
+
},
|
|
83
|
+
columnsStyle () {
|
|
84
|
+
return { '--product-columns': this.columns };
|
|
85
|
+
},
|
|
86
|
+
bannerOnly () {
|
|
87
|
+
return isBannerItem(this.showcase);
|
|
88
|
+
},
|
|
89
|
+
linkedBanner () {
|
|
90
|
+
const content = this.showcase.banner || {};
|
|
91
|
+
return this.showcase.bannerId != null && !!(content.imageUrl || content.mobileImageUrl);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
watch: {
|
|
95
|
+
// O contexto da página chega pelo currentPageData do tema (o plugin traduz para $rocket.setPage).
|
|
96
|
+
'$rocket.currentPage.value' () {
|
|
97
|
+
if (this.pageType || this.$rocket.getPage().section) this.load();
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
mounted () {
|
|
101
|
+
// ?rocket-search-debug=true: o slot aparece mesmo vazio.
|
|
102
|
+
this.debug = document.body.classList.contains('rocket-search-debug');
|
|
103
|
+
if (this.pageType || this.$rocket.getPage().section) this.load();
|
|
104
|
+
},
|
|
105
|
+
beforeUnmount () {
|
|
106
|
+
if (this.observer) this.observer.disconnect();
|
|
107
|
+
},
|
|
108
|
+
methods: {
|
|
109
|
+
productImage (product) {
|
|
110
|
+
return productImage(product, this.mediaSize);
|
|
111
|
+
},
|
|
112
|
+
productPath,
|
|
113
|
+
productPrice,
|
|
114
|
+
formatCurrency,
|
|
115
|
+
|
|
116
|
+
async load () {
|
|
117
|
+
if (this.started) return;
|
|
118
|
+
this.started = true;
|
|
119
|
+
try {
|
|
120
|
+
const showcases = await this.$rocket.getPageShowcases(this.pageType || undefined);
|
|
121
|
+
const showcase = showcases.find(s => ((s.placement && s.placement.position) || s.position) === this.position);
|
|
122
|
+
if (!showcase) return;
|
|
123
|
+
// Banner autônomo: render direto, sem produtos, skeleton nem impressão de vitrine
|
|
124
|
+
// (o RocketBannerItem mede o criativo).
|
|
125
|
+
if (isBannerItem(showcase)) {
|
|
126
|
+
this.showcase = showcase;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const ids = (showcase.products || []).map(p => p.id);
|
|
130
|
+
const hasBanner = showcase.bannerId != null && showcase.banner;
|
|
131
|
+
if (!ids.length && !hasBanner) return;
|
|
132
|
+
// Esqueleto só quando há vitrine na posição — slot vazio não pisca.
|
|
133
|
+
this.loading = true;
|
|
134
|
+
const products = ids.length ? await this.$rocket.fetchProducts(ids) : [];
|
|
135
|
+
// Sem produto ativo na Uappi, o banner vinculado ainda entra.
|
|
136
|
+
if (!products.length && !hasBanner) return;
|
|
137
|
+
this.showcase = showcase;
|
|
138
|
+
this.products = products;
|
|
139
|
+
this.$emit('products-loaded', { showcase, products });
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
// Vitrine é complementar: falha não pode quebrar a página.
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
this.loading = false;
|
|
146
|
+
}
|
|
147
|
+
this.$nextTick(this.observeImpression);
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
experimentData (data) {
|
|
151
|
+
const exp = this.showcase.experiment;
|
|
152
|
+
if (exp) {
|
|
153
|
+
data.experimentId = exp.experimentId;
|
|
154
|
+
data.variantId = exp.variantId;
|
|
155
|
+
}
|
|
156
|
+
return data;
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
observeImpression () {
|
|
160
|
+
const el = this.$refs.root;
|
|
161
|
+
if (!el) return;
|
|
162
|
+
const send = () => this.$rocket.dispatchEvent('view', this.experimentData({
|
|
163
|
+
vitrine: this.showcase.id,
|
|
164
|
+
showcaseId: this.showcase.id,
|
|
165
|
+
meta: { type: 'showcase_impression', source: 'showcase:' + this.showcase.id }
|
|
166
|
+
})).catch(() => {});
|
|
167
|
+
if (!('IntersectionObserver' in window)) return send();
|
|
168
|
+
this.observer = new IntersectionObserver((entries) => {
|
|
169
|
+
if (!entries.some(e => e.isIntersecting)) return;
|
|
170
|
+
this.observer.disconnect();
|
|
171
|
+
send();
|
|
172
|
+
});
|
|
173
|
+
this.observer.observe(el);
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
// Casa o link clicado com o produto pela rota — funciona com o card padrão e com o
|
|
177
|
+
// carrossel do tema no slot. SPA não descarrega a página: o evento sai sem segurar a navegação.
|
|
178
|
+
trackClick (event) {
|
|
179
|
+
const link = event.target && event.target.closest ? event.target.closest('a[href]') : null;
|
|
180
|
+
if (!link) return;
|
|
181
|
+
const path = new URL(link.href, window.location.href).pathname;
|
|
182
|
+
const position = this.products.findIndex(p => productPath(p) === path);
|
|
183
|
+
if (position < 0) return;
|
|
184
|
+
this.$rocket.dispatchEvent('click', this.experimentData({
|
|
185
|
+
vitrine: this.showcase.id,
|
|
186
|
+
productId: String(this.products[position].id),
|
|
187
|
+
position,
|
|
188
|
+
meta: { source: 'showcase:' + this.showcase.id }
|
|
189
|
+
})).catch(() => {});
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
slide (direction) {
|
|
193
|
+
const track = this.$refs.track;
|
|
194
|
+
if (track) track.scrollBy({ left: direction * track.clientWidth, behavior: 'smooth' });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
</script>
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<component
|
|
3
|
+
:is="href ? 'a' : 'div'"
|
|
4
|
+
:href="href || null"
|
|
5
|
+
:rel="href && external ? 'noopener' : null"
|
|
6
|
+
class="rocket-search rocket-search__banner"
|
|
7
|
+
:style="rootStyle"
|
|
8
|
+
:data-rocket-banner-id="ownId"
|
|
9
|
+
:data-rocket-showcase-id="item.id"
|
|
10
|
+
@click="onClick"
|
|
11
|
+
>
|
|
12
|
+
<picture class="rocket-search__banner__media" :style="{ aspectRatio: naturalRatio || ratio }">
|
|
13
|
+
<source v-if="mobileUrl && mobileUrl !== desktopUrl" :media="mobileMedia" :srcset="mobileSrcset" :sizes="width ? null : sizes">
|
|
14
|
+
<img
|
|
15
|
+
ref="img"
|
|
16
|
+
class="rocket-search__banner__image"
|
|
17
|
+
:src="src"
|
|
18
|
+
:srcset="width ? null : desktopSrcset || null"
|
|
19
|
+
:sizes="width || !desktopSrcset ? null : sizes"
|
|
20
|
+
:alt="content.alt || content.title || item.name || ''"
|
|
21
|
+
:loading="loading"
|
|
22
|
+
:fetchpriority="fetchPriority || null"
|
|
23
|
+
decoding="async"
|
|
24
|
+
:style="imgStyle"
|
|
25
|
+
@load="syncRatio"
|
|
26
|
+
@error="onImageError"
|
|
27
|
+
>
|
|
28
|
+
</picture>
|
|
29
|
+
<!-- Texto só quando o lojista marcou showStoreText no dashboard. -->
|
|
30
|
+
<div v-if="content.showStoreText && (content.title || content.subtitle || content.ctaText)" class="rocket-search__banner__caption" :style="captionStyle">
|
|
31
|
+
<strong v-if="content.title" class="rocket-search__banner__title" v-text="content.title" />
|
|
32
|
+
<span v-if="content.subtitle" class="rocket-search__banner__subtitle" v-text="content.subtitle" />
|
|
33
|
+
<span v-if="content.ctaText" class="rocket-search__banner__cta" v-text="content.ctaText" />
|
|
34
|
+
</div>
|
|
35
|
+
</component>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<script>
|
|
39
|
+
import {
|
|
40
|
+
MOBILE_MEDIA, cdnResize, buildSrcset, resolveAspectRatio, hasExplicitAspectRatio, pickStyles,
|
|
41
|
+
highestMilestone, bannerTracking, bannerViewData, bannerClickData
|
|
42
|
+
} from '../../banner.js';
|
|
43
|
+
|
|
44
|
+
const VIEW_THRESHOLD = 0.5;
|
|
45
|
+
const MIN_VISIBLE_MS = 1000;
|
|
46
|
+
|
|
47
|
+
export default {
|
|
48
|
+
name: 'RocketBannerItem',
|
|
49
|
+
props: {
|
|
50
|
+
// Item de /banners/context ou de /showcases/context (autônomo ou vitrine com bannerId).
|
|
51
|
+
item: { type: Object, required: true },
|
|
52
|
+
// Posição do slot (placement.position do item tem precedência).
|
|
53
|
+
position: { type: String, default: '' },
|
|
54
|
+
slideIndex: { type: Number, default: null },
|
|
55
|
+
// Hero é candidato a LCP: eager. Demais, lazy.
|
|
56
|
+
loading: { type: String, default: 'lazy' },
|
|
57
|
+
fetchPriority: { type: String, default: '' },
|
|
58
|
+
sizes: { type: String, default: '100vw' },
|
|
59
|
+
// Largura exata (px) medida do slot; 0 = usa srcset.
|
|
60
|
+
width: { type: Number, default: 0 },
|
|
61
|
+
imageResizing: { type: Boolean, default: true },
|
|
62
|
+
aspectRatio: { type: String, default: '' }
|
|
63
|
+
},
|
|
64
|
+
data () {
|
|
65
|
+
return { naturalRatio: null, resizeFailed: false, mobileMedia: MOBILE_MEDIA };
|
|
66
|
+
},
|
|
67
|
+
computed: {
|
|
68
|
+
content () {
|
|
69
|
+
return this.item.banner || {};
|
|
70
|
+
},
|
|
71
|
+
styles () {
|
|
72
|
+
return this.content.styles || {};
|
|
73
|
+
},
|
|
74
|
+
desktopUrl () {
|
|
75
|
+
return this.content.imageUrl || this.content.mobileImageUrl || '';
|
|
76
|
+
},
|
|
77
|
+
mobileUrl () {
|
|
78
|
+
return this.content.mobileImageUrl || '';
|
|
79
|
+
},
|
|
80
|
+
// Resize da CF desligado por prop ou porque falhou nesta imagem (origem sem o recurso).
|
|
81
|
+
resize () {
|
|
82
|
+
return this.imageResizing && !this.resizeFailed;
|
|
83
|
+
},
|
|
84
|
+
src () {
|
|
85
|
+
if (!this.resize) return this.desktopUrl;
|
|
86
|
+
return cdnResize(this.desktopUrl, this.width || 1920);
|
|
87
|
+
},
|
|
88
|
+
desktopSrcset () {
|
|
89
|
+
return buildSrcset(this.desktopUrl, this.resize);
|
|
90
|
+
},
|
|
91
|
+
mobileSrcset () {
|
|
92
|
+
if (!this.resize) return this.mobileUrl;
|
|
93
|
+
return this.width ? cdnResize(this.mobileUrl, this.width) : buildSrcset(this.mobileUrl);
|
|
94
|
+
},
|
|
95
|
+
href () {
|
|
96
|
+
return this.content.link || '';
|
|
97
|
+
},
|
|
98
|
+
external () {
|
|
99
|
+
return !this.internalPath(this.href);
|
|
100
|
+
},
|
|
101
|
+
ownId () {
|
|
102
|
+
return this.item.bannerId != null ? this.item.bannerId : this.item.id;
|
|
103
|
+
},
|
|
104
|
+
ratio () {
|
|
105
|
+
return resolveAspectRatio(this.styles, this.aspectRatio);
|
|
106
|
+
},
|
|
107
|
+
rootStyle () {
|
|
108
|
+
return pickStyles(this.styles, ['backgroundColor', 'borderRadius', 'maxWidth', 'margin', 'padding']);
|
|
109
|
+
},
|
|
110
|
+
imgStyle () {
|
|
111
|
+
return pickStyles(this.styles, ['objectFit', 'objectPosition', 'borderRadius']);
|
|
112
|
+
},
|
|
113
|
+
captionStyle () {
|
|
114
|
+
return pickStyles(this.styles, ['textColor']);
|
|
115
|
+
},
|
|
116
|
+
tracking () {
|
|
117
|
+
return bannerTracking(this.item, { slot: this.position, slideIndex: this.slideIndex });
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
mounted () {
|
|
121
|
+
// Imagem do cache pode completar antes do mount e não disparar @load.
|
|
122
|
+
const img = this.$refs.img;
|
|
123
|
+
if (img && img.complete) {
|
|
124
|
+
if (img.naturalWidth) this.syncRatio();
|
|
125
|
+
else this.onImageError(); // falhou antes do mount
|
|
126
|
+
}
|
|
127
|
+
this.observeImpression();
|
|
128
|
+
},
|
|
129
|
+
beforeUnmount () {
|
|
130
|
+
if (this.observer) this.observer.disconnect();
|
|
131
|
+
clearTimeout(this.dwellTimer);
|
|
132
|
+
},
|
|
133
|
+
methods: {
|
|
134
|
+
// Sem proporção cadastrada, a caixa reservada é um chute (3:1): corrige para a
|
|
135
|
+
// proporção real do arquivo assim que carrega — senão o cover cortaria o banner.
|
|
136
|
+
syncRatio () {
|
|
137
|
+
const img = this.$refs.img;
|
|
138
|
+
if (hasExplicitAspectRatio(this.styles, this.aspectRatio) || !img || !img.naturalWidth) return;
|
|
139
|
+
this.naturalRatio = img.naturalWidth + ' / ' + img.naturalHeight;
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
// /cdn-cgi/image/ responde 404 em origem sem o Image Resizing (ex.: upload da Uappi).
|
|
143
|
+
// Na Linx o banner sumia; aqui cai para a URL original.
|
|
144
|
+
onImageError () {
|
|
145
|
+
if (this.resize) this.resizeFailed = true;
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
imageVariant () {
|
|
149
|
+
const narrow = window.matchMedia && window.matchMedia(MOBILE_MEDIA).matches;
|
|
150
|
+
if (narrow && this.content.mobileImageUrl) return 'mobile';
|
|
151
|
+
if (this.content.imageUrl) return 'desktop';
|
|
152
|
+
return this.content.mobileImageUrl ? 'mobile' : undefined;
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
trackingNow () {
|
|
156
|
+
return Object.assign({}, this.tracking, { imageVariant: this.imageVariant() });
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
// Impressão viewable: ≥50% visível por ≥1s; meta leva o maior marco 25/50/75.
|
|
160
|
+
observeImpression () {
|
|
161
|
+
let lastRatio = 0;
|
|
162
|
+
let peak = 0;
|
|
163
|
+
const fire = () => {
|
|
164
|
+
if (this.observer) this.observer.disconnect();
|
|
165
|
+
this.$rocket.dispatchEvent('view', bannerViewData(this.trackingNow(), lastRatio, peak || highestMilestone(lastRatio))).catch(() => {});
|
|
166
|
+
};
|
|
167
|
+
if (!('IntersectionObserver' in window)) {
|
|
168
|
+
lastRatio = 1;
|
|
169
|
+
peak = 0.75;
|
|
170
|
+
return fire();
|
|
171
|
+
}
|
|
172
|
+
this.observer = new IntersectionObserver((entries) => {
|
|
173
|
+
entries.forEach((entry) => {
|
|
174
|
+
lastRatio = entry.intersectionRatio || 0;
|
|
175
|
+
peak = Math.max(peak, highestMilestone(lastRatio));
|
|
176
|
+
if (entry.isIntersecting && lastRatio >= VIEW_THRESHOLD) {
|
|
177
|
+
if (!this.dwellTimer) this.dwellTimer = setTimeout(fire, MIN_VISIBLE_MS);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
clearTimeout(this.dwellTimer);
|
|
181
|
+
this.dwellTimer = null;
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}, { threshold: [0, 0.25, 0.5, 0.75, 1] });
|
|
185
|
+
this.observer.observe(this.$el);
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
internalPath (href) {
|
|
189
|
+
try {
|
|
190
|
+
const origin = window.location.origin;
|
|
191
|
+
const url = new URL(href, origin);
|
|
192
|
+
return url.origin === origin ? url.pathname + url.search + url.hash : null;
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
// Clique com clickX/clickY relativos (0–1). Destino interno vai pelo router (SPA não
|
|
200
|
+
// descarrega, o evento sai sem segurar); externo segura até 600ms (trackThenNavigate).
|
|
201
|
+
onClick (event) {
|
|
202
|
+
if (!this.href) return;
|
|
203
|
+
const rect = this.$el.getBoundingClientRect();
|
|
204
|
+
const x = rect.width > 0 ? (event.clientX - rect.left) / rect.width : undefined;
|
|
205
|
+
const y = rect.height > 0 ? (event.clientY - rect.top) / rect.height : undefined;
|
|
206
|
+
const send = () => this.$rocket.dispatchEvent('click', bannerClickData(this.trackingNow(), x, y)).catch(() => {});
|
|
207
|
+
const path = this.internalPath(this.href);
|
|
208
|
+
if (path && !this.$rocket.isModifiedClick(event, this.$el)) {
|
|
209
|
+
event.preventDefault();
|
|
210
|
+
send();
|
|
211
|
+
this.$router.push(path).catch(() => {});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
this.$rocket.trackThenNavigate(event, this.$el, this.href, send);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
</script>
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="rocket-search__facets" :class="[{ opened: fixedFacetsOpened }, { 'rocket-search__facets--fixed': facetsMode === 'fixed' }]">
|
|
3
|
+
<div class="rocket-search__facets__header">
|
|
4
|
+
<strong>Filtrar</strong>
|
|
5
|
+
<button v-if="facetsMode === 'fixed'" class="rocket-search__close" aria-label="Fechar filtros" @click="$emit('close')">
|
|
6
|
+
X
|
|
7
|
+
</button>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<div v-if="appliedFilters.length > 0 && isMobile" class="rocket-search__facets__selected">
|
|
11
|
+
<span>Filtros aplicados:</span>
|
|
12
|
+
<ul>
|
|
13
|
+
<template v-for="(facet, i) in appliedFilters" :key="'af-' + i">
|
|
14
|
+
<li v-for="(value, j) in facet.values || []" :key="'f-' + j" :data-alias="facet.alias">
|
|
15
|
+
<span class="option-title" :data-alias="facet.alias">
|
|
16
|
+
<span class="name" v-text="facet.alias + ':'" />
|
|
17
|
+
<span class="label" v-text="value" />
|
|
18
|
+
</span>
|
|
19
|
+
<button class="rocket-search__facets__selected__clear-filter" @click="$emit('clear-facet', value, facet)">
|
|
20
|
+
x
|
|
21
|
+
</button>
|
|
22
|
+
</li>
|
|
23
|
+
<li v-if="facet.type === 'range'" :data-alias="facet.alias">
|
|
24
|
+
<span class="option-title" :data-alias="facet.alias">
|
|
25
|
+
<span class="name" v-text="facet.alias + ':'" />
|
|
26
|
+
<span class="label">De {{ formatCurrency(facet.min) }} até {{ formatCurrency(facet.max) }}</span>
|
|
27
|
+
</span>
|
|
28
|
+
<button class="rocket-search__facets__selected__clear-filter" @click="$emit('clear-price')">
|
|
29
|
+
x
|
|
30
|
+
</button>
|
|
31
|
+
</li>
|
|
32
|
+
<li v-if="facet.type === 'boolean'" :data-alias="facet.alias" :data-type="facet.type">
|
|
33
|
+
<span class="option-title" :data-alias="facet.alias">
|
|
34
|
+
<span class="name" v-text="facet.alias + ':'" />
|
|
35
|
+
<span class="label" v-text="facet.value ? 'Sim' : 'Não'" />
|
|
36
|
+
</span>
|
|
37
|
+
<button class="rocket-search__facets__selected__clear-filter" @click="$emit('clear-boolean', facet)">
|
|
38
|
+
x
|
|
39
|
+
</button>
|
|
40
|
+
</li>
|
|
41
|
+
</template>
|
|
42
|
+
<li class="rocket-search__clear-all-filters">
|
|
43
|
+
<button @click="$emit('clear-all')">
|
|
44
|
+
Limpar filtros
|
|
45
|
+
</button>
|
|
46
|
+
</li>
|
|
47
|
+
</ul>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div class="rocket-search__facets__group">
|
|
51
|
+
<div
|
|
52
|
+
v-for="(facet, key, index) in facets"
|
|
53
|
+
:key="key"
|
|
54
|
+
class="rocket-search__facets-type"
|
|
55
|
+
:facet-alias="key"
|
|
56
|
+
:facet-type="facet.type"
|
|
57
|
+
:class="{ closed: closedFacets.includes(facet.field), last: index === facetCount - 1, first: index === 0 }"
|
|
58
|
+
>
|
|
59
|
+
<div class="rocket-search__facets-title" @click="$emit('toggle-facet', facet)">
|
|
60
|
+
<strong v-text="facet.label" />
|
|
61
|
+
<span class="icon">
|
|
62
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="9.44" height="6.195" viewBox="0 0 9.44 6.195">
|
|
63
|
+
<g transform="translate(-2 26.995) rotate(-90)">
|
|
64
|
+
<path d="M25.51,2.5a.91.91,0,0,1,.631.258.9.9,0,0,1,0,1.261l-2.691,2.7,2.7,2.7a.892.892,0,0,1-1.261,1.261L21.558,7.349a.879.879,0,0,1-.258-.631.917.917,0,0,1,.258-.631l3.322-3.331A.926.926,0,0,1,25.51,2.5Z" fill="#000" stroke="#000" stroke-width="1" />
|
|
65
|
+
</g>
|
|
66
|
+
</svg>
|
|
67
|
+
</span>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="rocket-search__facets-content">
|
|
70
|
+
<RocketSearchRange
|
|
71
|
+
v-if="facet.type === 'range'"
|
|
72
|
+
:key="rangeKey"
|
|
73
|
+
:facet="facet"
|
|
74
|
+
@apply="$emit('apply-price', $event)"
|
|
75
|
+
/>
|
|
76
|
+
<!-- terms e boolean usam a mesma lista (na Linx o boolean reaproveitava o template de terms). -->
|
|
77
|
+
<ul v-else>
|
|
78
|
+
<li
|
|
79
|
+
v-for="(value, i) in facet.values || []"
|
|
80
|
+
:key="i"
|
|
81
|
+
:data-selected="isSelected(value.value, facet)"
|
|
82
|
+
:class="{ selected: isSelected(value.value, facet) }"
|
|
83
|
+
:style="facet.label.includes('Tamanho') ? ('order: ' + (String(value.value).split('/')[0] || '')) : ''"
|
|
84
|
+
>
|
|
85
|
+
<a role="button" tabindex="0" @click="$emit('click-facet', value, facet)" @keyup.enter="$emit('click-facet', value, facet)">
|
|
86
|
+
<div class="icon">
|
|
87
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="11.999" height="11.999" viewBox="0 0 11.999 11.999">
|
|
88
|
+
<path d="M6,12a6,6,0,1,1,6-6A6.006,6.006,0,0,1,6,12ZM3.106,5.349l-.045,0a.639.639,0,0,0-.447.226.642.642,0,0,0,.09.915L4.843,8.31a.646.646,0,0,0,.416.151h.025a.646.646,0,0,0,.428-.188c.544-.546,1.138-1.139,1.735-1.735l.059-.059c.631-.63,1.263-1.262,1.84-1.841a.635.635,0,0,0,.193-.459.654.654,0,0,0-.195-.459A.643.643,0,0,0,8.9,3.538H8.878a.638.638,0,0,0-.442.195C7.9,4.275,7.311,4.859,6.723,5.446l-1.5,1.5L3.532,5.514A.637.637,0,0,0,3.106,5.349Z" fill="#ff495d" />
|
|
89
|
+
</svg>
|
|
90
|
+
</div>
|
|
91
|
+
<strong class="option-title" v-text="valueLabel(value.value)" />
|
|
92
|
+
</a>
|
|
93
|
+
</li>
|
|
94
|
+
</ul>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div v-if="facetsMode === 'fixed'" class="rocket-search__facets__buttons">
|
|
100
|
+
<button class="apply" @click="$emit('close')">
|
|
101
|
+
Aplicar filtros
|
|
102
|
+
</button>
|
|
103
|
+
<button class="clear-all" @click="$emit('clear-all')">
|
|
104
|
+
Remover filtros
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
</template>
|
|
109
|
+
|
|
110
|
+
<script>
|
|
111
|
+
import RocketSearchRange from './RocketSearchRange.vue';
|
|
112
|
+
import { formatCurrency } from '../../uappi.js';
|
|
113
|
+
|
|
114
|
+
export default {
|
|
115
|
+
name: 'RocketSearchFacets',
|
|
116
|
+
components: { RocketSearchRange },
|
|
117
|
+
emits: ['close', 'toggle-facet', 'click-facet', 'apply-price', 'clear-facet', 'clear-price', 'clear-boolean', 'clear-all'],
|
|
118
|
+
props: {
|
|
119
|
+
facets: { type: Object, default: () => ({}) },
|
|
120
|
+
appliedFilters: { type: Array, default: () => [] },
|
|
121
|
+
closedFacets: { type: Array, default: () => [] },
|
|
122
|
+
fixedFacetsOpened: { type: Boolean, default: false },
|
|
123
|
+
facetsMode: { type: String, default: 'side' },
|
|
124
|
+
isMobile: { type: Boolean, default: false },
|
|
125
|
+
isSelected: { type: Function, required: true },
|
|
126
|
+
rangeKey: { type: Number, default: 0 }
|
|
127
|
+
},
|
|
128
|
+
computed: {
|
|
129
|
+
facetCount () {
|
|
130
|
+
return Object.keys(this.facets || {}).length;
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
methods: {
|
|
134
|
+
formatCurrency,
|
|
135
|
+
// Facet booleano (ex.: Disponibilidade/hasStock) vem como 0/1 — mesmo texto do chip aplicado.
|
|
136
|
+
valueLabel (value) {
|
|
137
|
+
if (value === 0) return 'Não';
|
|
138
|
+
if (value === 1) return 'Sim';
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
</script>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="rocket-search__pager">
|
|
3
|
+
<div class="rocket-search__pager__page-numbers">
|
|
4
|
+
<span v-if="hasPrevPage" class="rocket-search__pager__prev">
|
|
5
|
+
<a rel="prev" :data-index="page - 1" aria-label="Página anterior" @click="$emit('change', page - 1)">
|
|
6
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="7.428" height="12.155" viewBox="0 0 7.428 12.155">
|
|
7
|
+
<path d="M213.673,195.338l-1.351,1.351-4.727-4.727-4.727,4.727-1.351-1.351,6.078-6.078Z" transform="translate(196.688 -201.518) rotate(90)" fill="#828282" />
|
|
8
|
+
</svg>
|
|
9
|
+
</a>
|
|
10
|
+
</span>
|
|
11
|
+
|
|
12
|
+
<template v-for="p in pages" :key="'p' + p">
|
|
13
|
+
<span v-if="p === page" class="rocket-search__pager__page-selected" :title="p" v-text="p" />
|
|
14
|
+
<span v-else class="rocket-search__pager__page-number" @click="$emit('change', p)">
|
|
15
|
+
<a class="rocket-search__pager__page-index" :title="p" :data-index="p" v-text="p" />
|
|
16
|
+
</span>
|
|
17
|
+
</template>
|
|
18
|
+
|
|
19
|
+
<span v-if="hasNextPage" class="rocket-search__pager__next">
|
|
20
|
+
<a rel="next" :data-index="page + 1" aria-label="Próxima página" @click="$emit('change', page + 1)">
|
|
21
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="7.428" height="12.155" viewBox="0 0 7.428 12.155">
|
|
22
|
+
<path d="M213.673,195.338l-1.351,1.351-4.727-4.727-4.727,4.727-1.351-1.351,6.078-6.078Z" transform="translate(196.688 -201.518) rotate(90)" fill="#828282" />
|
|
23
|
+
</svg>
|
|
24
|
+
</a>
|
|
25
|
+
</span>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
</template>
|
|
29
|
+
|
|
30
|
+
<script>
|
|
31
|
+
// Janela de 5 páginas centrada na atual (mesma regra da Linx).
|
|
32
|
+
const WINDOW_SIZE = 5;
|
|
33
|
+
|
|
34
|
+
export default {
|
|
35
|
+
name: 'RocketSearchPagination',
|
|
36
|
+
emits: ['change'],
|
|
37
|
+
props: {
|
|
38
|
+
page: { type: Number, required: true },
|
|
39
|
+
totalPages: { type: Number, required: true },
|
|
40
|
+
hasPrevPage: { type: Boolean, default: false },
|
|
41
|
+
hasNextPage: { type: Boolean, default: false }
|
|
42
|
+
},
|
|
43
|
+
computed: {
|
|
44
|
+
pages () {
|
|
45
|
+
const half = Math.floor(WINDOW_SIZE / 2);
|
|
46
|
+
let start = this.page - half;
|
|
47
|
+
let end = this.page + half;
|
|
48
|
+
if (start < 1) {
|
|
49
|
+
start = 1;
|
|
50
|
+
end = Math.min(WINDOW_SIZE, this.totalPages);
|
|
51
|
+
}
|
|
52
|
+
if (end > this.totalPages) {
|
|
53
|
+
end = this.totalPages;
|
|
54
|
+
start = Math.max(1, this.totalPages - WINDOW_SIZE + 1);
|
|
55
|
+
}
|
|
56
|
+
const pages = [];
|
|
57
|
+
for (let i = start; i <= end; i++) pages.push(i);
|
|
58
|
+
return pages;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
</script>
|