@rocketsearch/nuxt-uappi 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 ADDED
@@ -0,0 +1,16 @@
1
+ # @rocketsearch/nuxt-uappi
2
+
3
+ Rocket Search (busca, autocomplete, vitrines, banners e analytics) para lojas Uappi em Nuxt 2.
4
+
5
+ ```bash
6
+ npm install @rocketsearch/nuxt-uappi
7
+ ```
8
+
9
+ ```js
10
+ // nuxt.config.js
11
+ modules: [
12
+ ['@rocketsearch/nuxt-uappi', { storeId: 'minha-loja', apiKey: 'pk_live_xxx', uappiDomain: 'loja.minhaloja.com.br' }]
13
+ ]
14
+ ```
15
+
16
+ Documentação completa: https://api.rocketsearch.com.br/sdk/uappi/docs.html
package/module.js ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Módulo Nuxt 2 do Rocket Search para lojas Uappi (wap.store).
3
+ *
4
+ * Port do SDK Linx 2.0.0 (backend/public/sdk/linxecommerce/2.0.0). O que lá era
5
+ * snippet + leitura do DOM/browsingContext aqui é módulo + hooks do Nuxt.
6
+ *
7
+ * nuxt.config.js:
8
+ * modules: [['@rocketsearch/nuxt-uappi', {
9
+ * storeId: 'daikin',
10
+ * apiKey: 'pk_live_xxx',
11
+ * uappiDomain: 'homolog.loja.daikin.uappi.dev.br'
12
+ * }]]
13
+ */
14
+ const path = require('path');
15
+
16
+ const DEFAULTS = {
17
+ storeId: '',
18
+ apiKey: '',
19
+ apiUrl: 'https://api.rocketsearch.com.br/api/v2',
20
+ // Domínio da API Uappi da loja. Trava contra snippet colado no repo errado: se não bater
21
+ // com o axios.baseURL do tema, o módulo não sobe (mesma ideia do data-platform-store-id
22
+ // da Nuvemshop — sem isso, a loja B serviria o catálogo da loja A em silêncio).
23
+ uappiDomain: '',
24
+ // Origin enviado nas chamadas feitas no SERVIDOR (banner loading="ssr"). Node não é
25
+ // navegador: sem Origin explícito a API pública responde "Domínio não autorizado".
26
+ siteOrigin: '',
27
+ searchPagePath: '/busca',
28
+ searchPageParam: 'busca',
29
+ debug: false
30
+ };
31
+
32
+ function hostOf (url) {
33
+ try {
34
+ return new URL(url).hostname.toLowerCase();
35
+ }
36
+ catch (e) {
37
+ return '';
38
+ }
39
+ }
40
+
41
+ module.exports = function rocketSearchUappi (moduleOptions) {
42
+ const options = Object.assign({}, DEFAULTS, this.options.rocketSearch, moduleOptions);
43
+ const tag = '[RocketSearch]';
44
+
45
+ if (!options.storeId || !options.apiKey) {
46
+ console.error(tag, 'storeId e apiKey são obrigatórios — módulo desativado.');
47
+ return;
48
+ }
49
+
50
+ const axiosHost = hostOf((this.options.axios || {}).baseURL);
51
+ if (options.uappiDomain && axiosHost && axiosHost !== options.uappiDomain.toLowerCase()) {
52
+ console.error(tag, `uappiDomain "${options.uappiDomain}" não bate com axios.baseURL "${axiosHost}" — módulo desativado.`);
53
+ return;
54
+ }
55
+
56
+ if (!options.siteOrigin) {
57
+ const domain = options.uappiDomain || axiosHost;
58
+ if (domain) options.siteOrigin = `https://${domain}`;
59
+ }
60
+ options.apiUrl = String(options.apiUrl).replace(/\/$/, '');
61
+
62
+ // addTemplate + push em vez de addPlugin: o addPlugin faz unshift, e o plugin rodaria
63
+ // antes do @nuxtjs/axios — sem $axios não há interceptor de add_to_cart.
64
+ const { dst } = this.addTemplate({
65
+ src: path.resolve(__dirname, 'runtime/plugin.js'),
66
+ fileName: 'rocket-search.js',
67
+ options: {
68
+ config: options,
69
+ runtimeDir: path.resolve(__dirname, 'runtime').replace(/\\/g, '/')
70
+ }
71
+ });
72
+ this.options.plugins.push(path.resolve(this.options.buildDir, dst));
73
+
74
+ this.nuxt.hook('components:dirs', (dirs) => {
75
+ // Só o 1º nível: os subcomponentes de search/ são internos (importados pelo RocketSearch).
76
+ dirs.push({ path: path.resolve(__dirname, 'runtime/components'), pattern: '*.vue' });
77
+ });
78
+
79
+ // O pacote chega fora de node_modules via link em dev e dentro dele em produção:
80
+ // nos dois casos o Babel do tema precisa transpilar.
81
+ this.options.build.transpile.push(__dirname);
82
+
83
+ // Conexão fria + preflight CORS era o grosso da latência do storefront, não a busca.
84
+ this.options.head.link = this.options.head.link || [];
85
+ this.options.head.link.push(
86
+ { rel: 'preconnect', href: new URL(options.apiUrl).origin, crossorigin: '' }
87
+ );
88
+ };
89
+
90
+ module.exports.meta = require('./package.json');
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@rocketsearch/nuxt-uappi",
3
+ "version": "0.1.0",
4
+ "description": "Rocket Search para storefronts Uappi (Nuxt 2): busca, autocomplete, vitrines, banners e eventos",
5
+ "main": "module.js",
6
+ "files": [
7
+ "module.js",
8
+ "runtime",
9
+ "README.md"
10
+ ],
11
+ "peerDependencies": {
12
+ "nuxt": "^2.15.0",
13
+ "vue": "^2.7.0"
14
+ },
15
+ "license": "UNLICENSED",
16
+ "homepage": "https://api.rocketsearch.com.br/sdk/uappi/docs.html",
17
+ "publishConfig": {
18
+ "access": "public"
19
+ }
20
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Banners — port das partes puras de linxecommerce/2.0.0/src/js/rs-banner.js
3
+ * (URL de resize, proporção, estilos, contrato dos eventos). DOM/observer ficam
4
+ * no RocketBannerItem.vue.
5
+ *
6
+ * Contrato (alinhado às vitrines, incrementa showcases.metrics):
7
+ * view + meta.type = 'showcase_impression' + showcaseId
8
+ * click + meta.type = 'banner_click' + showcaseId (sem productId)
9
+ */
10
+
11
+ // Larguras do srcset — o BE guarda 1 master (até 3840px) e o CF gera as variantes.
12
+ export const SRCSET_WIDTHS = [360, 480, 768, 1024, 1366, 1600, 1920, 2560];
13
+ export const VIEW_MILESTONES = [0.25, 0.5, 0.75];
14
+ export const MOBILE_MEDIA = '(max-width: 768px)';
15
+ // Sem proporção conhecida, reserva 3:1 (hero típico) para não haver CLS.
16
+ const DEFAULT_ASPECT_RATIO = '3 / 1';
17
+ // Cada largura distinta é uma variante nova no cache da CF: arredonda num passo.
18
+ const WIDTH_STEP = 100;
19
+ const MAX_EXACT_WIDTH = 3840;
20
+
21
+ export function clamp01 (n) {
22
+ if (typeof n !== 'number' || isNaN(n)) return undefined;
23
+ if (n < 0) return 0;
24
+ if (n > 1) return 1;
25
+ return Math.round(n * 10000) / 10000;
26
+ }
27
+
28
+ export function highestMilestone (ratio) {
29
+ let best = 0;
30
+ VIEW_MILESTONES.forEach((m) => {
31
+ if (ratio >= m) best = m;
32
+ });
33
+ return best;
34
+ }
35
+
36
+ /**
37
+ * Cloudflare Image Resizing na origem da própria imagem (o /cdn-cgi/image/ só existe
38
+ * na zona que a serve). ⚠️ Sem o recurso na zona, responde 404 e o banner some —
39
+ * por isso dá para desligar (prop imageResizing=false).
40
+ */
41
+ export function cdnResize (url, width, enabled) {
42
+ if (!url || enabled === false) return url;
43
+ if (/\/cdn-cgi\/image\//.test(url) || /^data:/i.test(url)) return url;
44
+ try {
45
+ const abs = new URL(url);
46
+ if (abs.protocol !== 'https:' && abs.protocol !== 'http:') return url;
47
+ return abs.origin + '/cdn-cgi/image/width=' + width + ',format=auto,quality=82,fit=scale-down' + abs.pathname + abs.search;
48
+ }
49
+ catch (e) {
50
+ return url;
51
+ }
52
+ }
53
+
54
+ export function buildSrcset (url, enabled) {
55
+ if (!url || enabled === false) return '';
56
+ return SRCSET_WIDTHS.map(w => cdnResize(url, w, enabled) + ' ' + w + 'w').join(', ');
57
+ }
58
+
59
+ // Largura a pedir à CF: slot × DPR (teto 2), arredondada para cima no passo.
60
+ export function exactWidth (cssWidth, dpr) {
61
+ if (!cssWidth) return 0;
62
+ const ratio = Math.min(Math.max(dpr || 1, 1), 2);
63
+ return Math.min(Math.ceil((cssWidth * ratio) / WIDTH_STEP) * WIDTH_STEP, MAX_EXACT_WIDTH);
64
+ }
65
+
66
+ // Aceita "16/9", "16:9" ou número. Sem valor, o placeholder anti-CLS.
67
+ export function resolveAspectRatio (styles, fallback) {
68
+ const raw = styles && (styles.aspectRatio || styles.aspect_ratio);
69
+ if (raw == null || raw === '') return fallback || DEFAULT_ASPECT_RATIO;
70
+ return String(raw).trim().replace(':', ' / ') || fallback || DEFAULT_ASPECT_RATIO;
71
+ }
72
+
73
+ export function hasExplicitAspectRatio (styles, fallback) {
74
+ const raw = styles && (styles.aspectRatio || styles.aspect_ratio);
75
+ return (raw != null && raw !== '') || !!fallback;
76
+ }
77
+
78
+ // Só deixa passar chave CSS conhecida — o JSON de styles vem do dashboard.
79
+ const ALLOWED_STYLE_KEYS = {
80
+ backgroundColor: 'backgroundColor',
81
+ textColor: 'color',
82
+ borderRadius: 'borderRadius',
83
+ objectFit: 'objectFit',
84
+ objectPosition: 'objectPosition',
85
+ minHeight: 'minHeight',
86
+ maxWidth: 'maxWidth',
87
+ padding: 'padding',
88
+ margin: 'margin'
89
+ };
90
+
91
+ export function pickStyles (styles, keys) {
92
+ const out = {};
93
+ if (!styles) return out;
94
+ keys.forEach((key) => {
95
+ const value = styles[key];
96
+ if (ALLOWED_STYLE_KEYS[key] && value != null && value !== '') out[ALLOWED_STYLE_KEYS[key]] = String(value);
97
+ });
98
+ return out;
99
+ }
100
+
101
+ export function isHeroSlot (position) {
102
+ return /^(hero|fullbanner|header|top)$/i.test(String(position || ''));
103
+ }
104
+
105
+ /** True para banner autônomo vindo de /showcases/context (type='banner', ou sem produtos + imagem). */
106
+ export function isBannerItem (item) {
107
+ if (!item) return false;
108
+ if (item.type === 'banner') return true;
109
+ const hasProducts = Array.isArray(item.products) && item.products.length > 0;
110
+ const content = item.banner || {};
111
+ return !hasProducts && !!(content.imageUrl || content.mobileImageUrl);
112
+ }
113
+
114
+ /**
115
+ * Quem medir (contrato ROC-26):
116
+ * - autônomo → o próprio id (já é o showcaseId do banner);
117
+ * - embutido em vitrine → showcaseId da VITRINE + bannerId do criativo.
118
+ */
119
+ export function bannerTracking (item, opts) {
120
+ const o = opts || {};
121
+ const embedded = item.type !== 'banner' && item.bannerId != null && item.bannerId !== '';
122
+ const ownId = item.bannerId != null && item.bannerId !== '' ? item.bannerId : item.id;
123
+ const tracking = {
124
+ id: embedded ? item.id : ownId,
125
+ slot: (item.placement && item.placement.position) || item.position || o.slot || '',
126
+ slideIndex: o.slideIndex
127
+ };
128
+ if (embedded) tracking.bannerId = item.bannerId;
129
+ if (item.experiment) {
130
+ tracking.experimentId = item.experiment.experimentId;
131
+ tracking.variantId = item.experiment.variantId;
132
+ }
133
+ return tracking;
134
+ }
135
+
136
+ function baseData (t, meta) {
137
+ const data = { vitrine: t.id, showcaseId: t.id, meta };
138
+ if (t.slot) meta.slot = String(t.slot);
139
+ if (t.imageVariant) meta.imageVariant = t.imageVariant;
140
+ if (t.bannerId != null) {
141
+ data.bannerId = t.bannerId;
142
+ meta.bannerId = t.bannerId;
143
+ }
144
+ if (t.slideIndex != null && t.slideIndex !== '') {
145
+ data.position = Number(t.slideIndex) || 0;
146
+ meta.slideIndex = data.position;
147
+ }
148
+ if (t.experimentId) data.experimentId = t.experimentId;
149
+ if (t.variantId) data.variantId = t.variantId;
150
+ return data;
151
+ }
152
+
153
+ export function bannerViewData (t, visibleRatio, milestone) {
154
+ const meta = { type: 'showcase_impression', source: 'showcase:' + t.id };
155
+ const vr = clamp01(visibleRatio);
156
+ if (vr) meta.visibleRatio = vr;
157
+ if (milestone > 0) meta.visibleThreshold = milestone;
158
+ return baseData(t, meta);
159
+ }
160
+
161
+ export function bannerClickData (t, clickX, clickY) {
162
+ const data = baseData(t, { source: 'showcase:' + t.id, type: 'banner_click' });
163
+ const cx = clamp01(clickX);
164
+ const cy = clamp01(clickY);
165
+ if (cx != null) data.clickX = data.meta.clickX = cx;
166
+ if (cy != null) data.clickY = data.meta.clickY = cy;
167
+ return data;
168
+ }
@@ -0,0 +1,279 @@
1
+ <template>
2
+ <div
3
+ class="rocket-search rocket-search--autocomplete"
4
+ :data-button-type-mode="searchButtonType"
5
+ data-search-mode="autocomplete"
6
+ >
7
+ <div class="rocket-search__content">
8
+ <form class="rocket-search__form" @submit.prevent="handleSubmit">
9
+ <input
10
+ ref="inputSearch"
11
+ v-model="term"
12
+ :name="searchPageParam"
13
+ autocomplete="off"
14
+ type="text"
15
+ :placeholder="inputPlaceholder"
16
+ aria-label="Buscar produtos"
17
+ class="rocket-search__form__input"
18
+ @keyup="handleKeyup"
19
+ @focus="openAutocomplete"
20
+ >
21
+ <button type="submit" class="rocket-search__form__button" aria-label="Buscar produtos">
22
+ <span v-if="searchButtonType === 'text'" class="rocket-search__form__button-text" v-text="searchButtonText" />
23
+ <svg v-else xmlns="http://www.w3.org/2000/svg" width="20.35" height="20.35">
24
+ <path
25
+ d="M14.355 15.383a8.727 8.727 0 111.028-1.028l4.759 4.758a.728.728 0 11-1.029 1.029zm0-2.06a7.271 7.271 0 10-1.032 1.027 7.536 7.536 0 001.027-1.027z"
26
+ fill="#29282e"
27
+ fill-rule="evenodd"
28
+ />
29
+ </svg>
30
+ </button>
31
+ </form>
32
+
33
+ <div class="rocket-search__autocomplete-panel" :class="{ opened: suggestionTermsVisible || isOpened }">
34
+ <div v-if="suggestionTerms.length > 0 && term === ''" class="rocket-search__autocomplete-section">
35
+ <strong class="rocket-search__terms-suggestions__title" v-text="suggestionsTitle" />
36
+ <ul class="rocket-search__autocomplete-terms">
37
+ <li v-for="(t, index) in suggestionTerms" :key="'term-' + index">
38
+ <a :href="searchUrl(t)" @click="handleClickTerm(t, $event)">
39
+ <i><span v-text="index + 1" />º</i>
40
+ <span v-text="t" />
41
+ </a>
42
+ </li>
43
+ </ul>
44
+ </div>
45
+
46
+ <div v-if="autocompleteProducts.length > 0" class="rocket-search__autocomplete-section">
47
+ <strong class="rocket-search__terms-suggestions__title" v-text="productsTitle" />
48
+ <ul class="rocket-search__autocomplete-products">
49
+ <li
50
+ v-for="(product, index) in autocompleteProducts"
51
+ :key="'product-' + product.id"
52
+ :data-product-id="product.id"
53
+ >
54
+ <a :href="productPath(product)" @click="handleClickProduct(product, index, $event)">
55
+ <span class="rocket-search__autocomplete-thumb">
56
+ <img v-if="productImage(product)" :src="productImage(product)" :alt="product.nome" loading="lazy">
57
+ <span v-else class="rocket-search__autocomplete-thumb-placeholder" />
58
+ </span>
59
+ <span class="rocket-search__autocomplete-info">
60
+ <strong
61
+ v-if="product.marca && product.marca.nome"
62
+ class="rocket-search__autocomplete-brand"
63
+ v-text="product.marca.nome"
64
+ />
65
+ <span class="rocket-search__autocomplete-name" v-text="product.nome" />
66
+ <span
67
+ v-if="productPrice(product) !== null"
68
+ class="rocket-search__autocomplete-price"
69
+ v-text="formatCurrency(productPrice(product))"
70
+ />
71
+ </span>
72
+ </a>
73
+ </li>
74
+ </ul>
75
+ </div>
76
+
77
+ <div
78
+ v-if="term.length >= minChars && suggestionTerms.length === 0 && autocompleteProducts.length === 0 && !isLoading"
79
+ class="rocket-search__autocomplete-empty"
80
+ >
81
+ Nenhum resultado encontrado.
82
+ </div>
83
+ </div>
84
+
85
+ <div
86
+ v-show="suggestionTermsVisible || isOpened"
87
+ class="rocket-search__autocomplete-overlay"
88
+ @mousedown.prevent="closeSearch"
89
+ />
90
+ </div>
91
+ </div>
92
+ </template>
93
+
94
+ <script>
95
+ import { fetchUappiProducts, productImage, productPath, productPrice, formatCurrency } from '../uappi.js';
96
+
97
+ // Limite opcional: null = exibe tudo que o backend mandou (quem manda é o maxSuggestions
98
+ // do admin). Só um valor explícito na prop volta a cortar.
99
+ function limitList (list, limit) {
100
+ const arr = list || [];
101
+ return limit === null || limit === undefined || limit === '' ? arr : arr.slice(0, Number(limit));
102
+ }
103
+
104
+ function normalizeTerm (item) {
105
+ if (item && typeof item === 'object') return String(item.term || item.label || item.value || '');
106
+ return String(item || '');
107
+ }
108
+
109
+ export default {
110
+ name: 'RocketAutocomplete',
111
+ props: {
112
+ minChars: { type: Number, default: 2 },
113
+ productsLimit: { type: Number, default: null },
114
+ termsLimit: { type: Number, default: null },
115
+ mediaSize: { type: String, default: 'small' },
116
+ inputPlaceholder: { type: String, default: 'O que você procura?' },
117
+ searchButtonType: { type: String, default: 'icon' },
118
+ searchButtonText: { type: String, default: 'Buscar' },
119
+ suggestionsTitle: { type: String, default: 'Termos mais buscados:' },
120
+ productsTitle: { type: String, default: 'Produtos' }
121
+ },
122
+ data () {
123
+ return {
124
+ term: '',
125
+ isOpened: false,
126
+ isLoading: false,
127
+ suggestionTerms: [],
128
+ suggestionTermsVisible: false,
129
+ autocompleteProducts: []
130
+ };
131
+ },
132
+ computed: {
133
+ searchPagePath () {
134
+ return this.$rocket.config.searchPagePath;
135
+ },
136
+ searchPageParam () {
137
+ return this.$rocket.config.searchPageParam;
138
+ }
139
+ },
140
+ watch: {
141
+ term () {
142
+ if (!this.term) {
143
+ this.autocompleteProducts = [];
144
+ this.isOpened = false;
145
+ }
146
+ }
147
+ },
148
+ mounted () {
149
+ this.onDocumentClick = (event) => {
150
+ if (!this.$el.contains(event.target)) {
151
+ this.suggestionTermsVisible = false;
152
+ this.isOpened = false;
153
+ }
154
+ };
155
+ this.onKeyup = (event) => {
156
+ if (event.code === 'Escape') this.closeSearch();
157
+ };
158
+ document.addEventListener('click', this.onDocumentClick);
159
+ document.addEventListener('keyup', this.onKeyup);
160
+ },
161
+ beforeDestroy () {
162
+ clearTimeout(this.timeout);
163
+ document.removeEventListener('click', this.onDocumentClick);
164
+ document.removeEventListener('keyup', this.onKeyup);
165
+ },
166
+ methods: {
167
+ productImage (product) {
168
+ return productImage(product, this.mediaSize);
169
+ },
170
+ productPath,
171
+ productPrice,
172
+ formatCurrency,
173
+ searchUrl (term) {
174
+ return this.$router.resolve({ path: this.searchPagePath, query: { [this.searchPageParam]: term || '' } }).href;
175
+ },
176
+ goToSearch (term) {
177
+ this.closeSearch();
178
+ this.$router.push({ path: this.searchPagePath, query: { [this.searchPageParam]: term || '' } });
179
+ },
180
+ openAutocomplete () {
181
+ if (this.term && this.term.length >= this.minChars) {
182
+ this.fetchResult();
183
+ return;
184
+ }
185
+ this.fetchTerms();
186
+ },
187
+ handleKeyup (event) {
188
+ // Enter é tratado pelo submit do form.
189
+ if (event && event.key === 'Enter') return;
190
+ clearTimeout(this.timeout);
191
+ if (this.term.length < this.minChars) {
192
+ this.isOpened = false;
193
+ this.autocompleteProducts = [];
194
+ this.fetchTerms();
195
+ return;
196
+ }
197
+ this.timeout = setTimeout(() => this.fetchResult(), 250);
198
+ },
199
+ // Submit (botão ou Enter) é submissão explícita: mesmo com o campo vazio vai para a
200
+ // página de busca, que lista o catálogo. O dropdown abre pelo foco.
201
+ handleSubmit () {
202
+ this.goToSearch(this.term);
203
+ },
204
+ closeSearch () {
205
+ this.isOpened = false;
206
+ this.suggestionTermsVisible = false;
207
+ if (this.$refs.inputSearch) this.$refs.inputSearch.blur();
208
+ },
209
+ async fetchTerms () {
210
+ try {
211
+ const response = await this.$rocket.autocomplete('');
212
+ this.suggestionTerms = limitList((response.terms || []).map(normalizeTerm), this.termsLimit);
213
+ }
214
+ catch (e) {
215
+ this.suggestionTerms = [];
216
+ }
217
+ this.autocompleteProducts = [];
218
+ this.suggestionTermsVisible = this.suggestionTerms.length > 0;
219
+ this.isOpened = false;
220
+ this.isLoading = false;
221
+ },
222
+ async fetchResult () {
223
+ if (!this.term || this.term.length < this.minChars) {
224
+ this.autocompleteProducts = [];
225
+ this.isOpened = false;
226
+ this.suggestionTermsVisible = this.suggestionTerms.length > 0;
227
+ return;
228
+ }
229
+
230
+ this.isLoading = true;
231
+ const searchedTerm = this.term;
232
+ try {
233
+ const response = await this.$rocket.autocomplete(searchedTerm);
234
+ // Digitou de novo enquanto a resposta vinha: descarta a resposta velha.
235
+ if (searchedTerm !== this.term) return;
236
+ this.suggestionTerms = limitList((response.terms || []).map(normalizeTerm), this.termsLimit);
237
+ const ids = limitList(response.products || [], this.productsLimit).map(p => p && p.id);
238
+ const products = await fetchUappiProducts(this.$axios, ids);
239
+ if (searchedTerm !== this.term) return;
240
+ this.autocompleteProducts = products;
241
+ }
242
+ catch (e) {
243
+ if (searchedTerm !== this.term) return;
244
+ this.suggestionTerms = [];
245
+ this.autocompleteProducts = [];
246
+ }
247
+ finally {
248
+ if (searchedTerm === this.term) this.isLoading = false;
249
+ }
250
+ this.suggestionTermsVisible = false;
251
+ this.isOpened = true;
252
+ },
253
+ handleClickTerm (term, event) {
254
+ // Ctrl/cmd/meio: o browser abre pelo href em nova aba. Não mexe no painel.
255
+ if (this.$rocket.isModifiedClick(event)) return;
256
+ if (event && event.preventDefault) event.preventDefault();
257
+ this.term = term;
258
+ this.goToSearch(term);
259
+ },
260
+ handleClickProduct (product, index, event) {
261
+ const href = productPath(product);
262
+ const send = () => this.$rocket.dispatchEvent('click', {
263
+ productId: String(product.id),
264
+ position: index,
265
+ meta: { source: 'autocomplete', query: this.term || '' }
266
+ });
267
+ const el = event && event.currentTarget;
268
+ const took = this.$rocket.trackThenNavigate(event, el, href, send, (path) => {
269
+ this.closeSearch();
270
+ this.$router.push(path);
271
+ });
272
+ if (!took && !this.$rocket.isModifiedClick(event, el)) this.closeSearch();
273
+ }
274
+ }
275
+ };
276
+ </script>
277
+
278
+ <style src="../css/common.css"></style>
279
+ <style src="../css/autocomplete.css"></style>