@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,661 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Núcleo do Rocket Search para Uappi — port do rs-config.js do SDK Linx 2.0.0.
|
|
3
|
+
*
|
|
4
|
+
* Sem Vue de propósito: só fetch + sessionStorage. Cópia de
|
|
5
|
+
* packages/nuxt-uappi/runtime/rocket.js — correção aqui vale lá também.
|
|
6
|
+
*
|
|
7
|
+
* Diferenças para a Linx:
|
|
8
|
+
* - contexto da página vem do Pinia do tema (general.currentPageData), não do browsingContext;
|
|
9
|
+
* - sessionId = sempre o id anônimo local (ver getSessionId);
|
|
10
|
+
* - add_to_cart vem da action `add` da store `cart` (plugin), com o payload da Uappi;
|
|
11
|
+
* - purchase fica para o GTM do checkout (o checkout da Uappi não roda no tema).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const SHOWCASE_ATTR_KEY = 'rs_showcase_attr';
|
|
15
|
+
const SHOWCASE_ATTR_TTL = 30 * 60 * 1000;
|
|
16
|
+
// Mapa persistente produto -> vitrine (last-touch por produto). Diferente do slot único,
|
|
17
|
+
// NÃO é apagado no add_to_cart: sobrevive até o checkout para atribuir GMV no purchase.
|
|
18
|
+
const SHOWCASE_ATTR_MAP_KEY = 'rs_showcase_attr_map';
|
|
19
|
+
const SEARCH_ATTR_KEY = 'rs_search_attr';
|
|
20
|
+
const PENDING_CART_EVENTS_KEY = 'rs_pending_add_to_cart_events';
|
|
21
|
+
const ANON_SESSION_KEY = 'rs_anon_session_id';
|
|
22
|
+
const ANON_SESSION_TTL = 30 * 60 * 1000;
|
|
23
|
+
const LAST_SECTION_KEY = 'rs_last_section';
|
|
24
|
+
const LOGIN_SENT_KEY = 'rs_login_sent';
|
|
25
|
+
const DEBUG_TOKEN_KEY = 'rs_debug_token';
|
|
26
|
+
const SHOWCASE_DEBUG_KEY = 'rs_showcase_debug';
|
|
27
|
+
// 2h — em sincronia com DEBUG_SESSION_TTL_SECONDS no backend.
|
|
28
|
+
const DEBUG_TTL = 7200000;
|
|
29
|
+
|
|
30
|
+
const isBrowser = typeof window !== 'undefined';
|
|
31
|
+
|
|
32
|
+
function storageGet (key) {
|
|
33
|
+
try {
|
|
34
|
+
const raw = sessionStorage.getItem(key);
|
|
35
|
+
return raw ? JSON.parse(raw) : null;
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function storageSet (key, value) {
|
|
43
|
+
try {
|
|
44
|
+
sessionStorage.setItem(key, JSON.stringify(value));
|
|
45
|
+
}
|
|
46
|
+
catch (e) { /* storage bloqueado (aba anônima/iframe) */ }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function storageRemove (key) {
|
|
50
|
+
try {
|
|
51
|
+
sessionStorage.removeItem(key);
|
|
52
|
+
}
|
|
53
|
+
catch (e) { /* storage bloqueado */ }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Converte valor cru em número > 0, ou null. Evita NaN: `Number('null')` é NaN, e
|
|
57
|
+
// NaN != null é true — curto-circuitava o cascade de preço e o totalValue saía null.
|
|
58
|
+
export function toPositiveNumber (value) {
|
|
59
|
+
if (value == null || value === '') return null;
|
|
60
|
+
const n = Number(value);
|
|
61
|
+
return isFinite(n) && n > 0 ? n : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Clique que o browser abre em nova aba/janela por conta própria (ctrl/cmd/shift/alt,
|
|
66
|
+
* botão do meio ou target="_blank"). Nesses casos NÃO se chama preventDefault().
|
|
67
|
+
*/
|
|
68
|
+
export function isModifiedClick (event, el) {
|
|
69
|
+
if (!event) return false;
|
|
70
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return true;
|
|
71
|
+
if (event.button === 1 || event.which === 2) return true;
|
|
72
|
+
const target = el && el.getAttribute ? el.getAttribute('target') : null;
|
|
73
|
+
return !!target && target !== '_self';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Vocabulário de SEÇÃO (aba Navegação/Jornada): home|pdp|category|list|brand|search|other.
|
|
77
|
+
// Mapeia o `estrutura.nivel` que a Front API da Uappi devolve em toda página.
|
|
78
|
+
export function sectionFromNivel (nivel) {
|
|
79
|
+
switch (nivel) {
|
|
80
|
+
case 'home': return 'home';
|
|
81
|
+
case 'product/detail': return 'pdp';
|
|
82
|
+
case 'product/listing/category': return 'category';
|
|
83
|
+
case 'product/listing/landing-page': return 'list';
|
|
84
|
+
case 'product/listing/brand': return 'brand';
|
|
85
|
+
case 'product/listing/search': return 'search';
|
|
86
|
+
default: return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Vocabulário da API de vitrines/banners (pageType): product|category|home|search|cart|checkout.
|
|
91
|
+
export function pageTypeFromSection (section) {
|
|
92
|
+
if (section === 'pdp') return 'product';
|
|
93
|
+
if (section === 'list' || section === 'brand') return 'category';
|
|
94
|
+
if (section === 'other') return '';
|
|
95
|
+
return section || '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function randomSessionId () {
|
|
99
|
+
let random = '';
|
|
100
|
+
try {
|
|
101
|
+
const bytes = new Uint8Array(8);
|
|
102
|
+
crypto.getRandomValues(bytes);
|
|
103
|
+
random = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
random = Math.random().toString(16).slice(2) + Math.random().toString(16).slice(2);
|
|
107
|
+
}
|
|
108
|
+
return 'rs-anon-' + Date.now().toString(36) + '-' + random.slice(0, 16);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createRocket (config) {
|
|
112
|
+
const rocket = {
|
|
113
|
+
config,
|
|
114
|
+
storeId: config.storeId,
|
|
115
|
+
apiKey: config.apiKey,
|
|
116
|
+
apiUrl: config.apiUrl
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// ---- Contexto da página (equivalente ao browsingContext da Linx) ----
|
|
120
|
+
|
|
121
|
+
let page = { section: null, productId: null, categoryId: null, product: null };
|
|
122
|
+
|
|
123
|
+
rocket.setPage = function (next) {
|
|
124
|
+
page = Object.assign({ section: null, productId: null, categoryId: null, product: null }, next);
|
|
125
|
+
pageShowcases = null;
|
|
126
|
+
};
|
|
127
|
+
rocket.getPage = () => page;
|
|
128
|
+
|
|
129
|
+
function resolveSection () {
|
|
130
|
+
if (page.section) return page.section;
|
|
131
|
+
if (!isBrowser) return 'other';
|
|
132
|
+
const path = (location.pathname || '').toLowerCase();
|
|
133
|
+
if (path === '/' || path === '') return 'home';
|
|
134
|
+
if (path.indexOf(config.searchPagePath) === 0) return 'search';
|
|
135
|
+
if (path.indexOf('/checkout') === 0) return 'checkout';
|
|
136
|
+
return 'other';
|
|
137
|
+
}
|
|
138
|
+
rocket.resolveSection = resolveSection;
|
|
139
|
+
|
|
140
|
+
function resolvePageType (type) {
|
|
141
|
+
if (type) return String(type).toLowerCase();
|
|
142
|
+
return pageTypeFromSection(resolveSection());
|
|
143
|
+
}
|
|
144
|
+
rocket.resolvePageType = resolvePageType;
|
|
145
|
+
|
|
146
|
+
// ---- Sessão ----
|
|
147
|
+
|
|
148
|
+
// Sessão = id local, igual à Linx para shopper anônimo. NÃO usamos o PHPSESSID da Uappi:
|
|
149
|
+
// ele é o token de sessão do cliente, e a Uappi o reemite como HttpOnly pelas respostas
|
|
150
|
+
// da /api — o JS o lê na 1ª carga e perde depois, trocando o sessionId no meio da visita
|
|
151
|
+
// (visto na homolog: page_view com um id, click/view seguintes com outro). O checkout
|
|
152
|
+
// roda na mesma origem/aba, então o GTM de purchase lê este id do sessionStorage.
|
|
153
|
+
let anonSessionFallback = null;
|
|
154
|
+
|
|
155
|
+
function clearAnonSessionId () {
|
|
156
|
+
anonSessionFallback = null;
|
|
157
|
+
storageRemove(ANON_SESSION_KEY);
|
|
158
|
+
}
|
|
159
|
+
rocket.clearAnonSessionId = clearAnonSessionId;
|
|
160
|
+
|
|
161
|
+
function getOrCreateAnonSessionId () {
|
|
162
|
+
let record = storageGet(ANON_SESSION_KEY);
|
|
163
|
+
if (!record || !record.id || !record.ts) record = anonSessionFallback;
|
|
164
|
+
if (record && Date.now() - record.ts > ANON_SESSION_TTL) {
|
|
165
|
+
clearAnonSessionId();
|
|
166
|
+
record = null;
|
|
167
|
+
}
|
|
168
|
+
// ts renovado a cada leitura: expira por inatividade, não por tempo absoluto.
|
|
169
|
+
const next = { id: record ? record.id : randomSessionId(), ts: Date.now() };
|
|
170
|
+
anonSessionFallback = next;
|
|
171
|
+
storageSet(ANON_SESSION_KEY, next);
|
|
172
|
+
return next.id;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
rocket.getSessionId = function () {
|
|
176
|
+
return Promise.resolve(isBrowser ? getOrCreateAnonSessionId() : null);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Aguarda o sessionId com teto de tempo: o split A/B das vitrines precisa dele para ser
|
|
180
|
+
// determinístico, mas a vitrine não pode ficar refém disso.
|
|
181
|
+
function getSessionIdWithTimeout () {
|
|
182
|
+
return Promise.race([
|
|
183
|
+
rocket.getSessionId().catch(() => null),
|
|
184
|
+
new Promise(resolve => setTimeout(() => resolve(null), 2000))
|
|
185
|
+
]);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---- HTTP ----
|
|
189
|
+
|
|
190
|
+
function headers (extra) {
|
|
191
|
+
const h = Object.assign({ 'X-API-Key': config.apiKey }, extra);
|
|
192
|
+
// No servidor não há navegador para mandar Origin — a API pública exige um autorizado.
|
|
193
|
+
if (!isBrowser && config.siteOrigin) h.Origin = config.siteOrigin;
|
|
194
|
+
return h;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function query (params) {
|
|
198
|
+
const search = new URLSearchParams();
|
|
199
|
+
Object.keys(params).forEach((key) => {
|
|
200
|
+
const value = params[key];
|
|
201
|
+
if (value !== undefined && value !== null) search.set(key, value);
|
|
202
|
+
});
|
|
203
|
+
return search.toString();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---- Caches de preço (cascade do totalValue no add_to_cart) ----
|
|
207
|
+
|
|
208
|
+
const showcasePriceCache = {};
|
|
209
|
+
const searchPriceCache = {};
|
|
210
|
+
rocket._searchPriceCache = searchPriceCache;
|
|
211
|
+
|
|
212
|
+
rocket.rememberSearchPrices = function (products) {
|
|
213
|
+
(products || []).forEach((p) => {
|
|
214
|
+
if (p && p.id != null && p.price != null) searchPriceCache[String(p.id)] = Number(p.price);
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
function getShowcaseProductPrice (showcaseId, productId) {
|
|
219
|
+
if (!showcaseId || !productId) return null;
|
|
220
|
+
const byShowcase = showcasePriceCache[String(showcaseId)];
|
|
221
|
+
const price = byShowcase ? byShowcase[String(productId)] : null;
|
|
222
|
+
return price != null ? price : null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function findPriceInAnyShowcase (productId) {
|
|
226
|
+
if (!productId) return null;
|
|
227
|
+
const pid = String(productId);
|
|
228
|
+
const keys = Object.keys(showcasePriceCache);
|
|
229
|
+
for (let i = 0; i < keys.length; i++) {
|
|
230
|
+
const price = showcasePriceCache[keys[i]][pid];
|
|
231
|
+
if (price != null) return price;
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---- API ----
|
|
237
|
+
|
|
238
|
+
rocket.autocomplete = async function (term) {
|
|
239
|
+
const res = await fetch(config.apiUrl + '/autocomplete?' + query({ storeId: config.storeId, q: term || '' }), {
|
|
240
|
+
headers: headers()
|
|
241
|
+
});
|
|
242
|
+
return res.json();
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
rocket.search = async function (params) {
|
|
246
|
+
const sessionId = await rocket.getSessionId();
|
|
247
|
+
const qs = Object.assign({ storeId: config.storeId }, params, {
|
|
248
|
+
// Só ordem + preço: os dados do card vêm da própria Uappi (mesmo desenho da Linx,
|
|
249
|
+
// que pedia id,price e renderizava pelo widget nativo da loja).
|
|
250
|
+
fields: 'id,price',
|
|
251
|
+
sessionId: sessionId || ''
|
|
252
|
+
});
|
|
253
|
+
const res = await fetch(config.apiUrl + '/search?' + query(qs), { headers: headers() });
|
|
254
|
+
// Erro vira exceção: quem chama decide (o tema volta para a listagem da Uappi) em vez de
|
|
255
|
+
// exibir "0 produtos" com o corpo do erro.
|
|
256
|
+
if (!res.ok) throw new Error('Rocket respondeu ' + res.status);
|
|
257
|
+
const data = await res.json();
|
|
258
|
+
if (isBrowser && data && data.redirect && data.redirect.type === 'url' && data.redirect.url) {
|
|
259
|
+
window.location.href = data.redirect.url;
|
|
260
|
+
}
|
|
261
|
+
return data;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// Coleção do painel (ROC-499) pelo slug, no mesmo formato da busca. null = não existe ou
|
|
265
|
+
// não está no ar (404): o tema segue com a listagem dele.
|
|
266
|
+
rocket.collection = async function (slug, params) {
|
|
267
|
+
const qs = Object.assign({ storeId: config.storeId }, params, { fields: 'id,price' });
|
|
268
|
+
const res = await fetch(config.apiUrl + '/collections/' + encodeURIComponent(slug) + '?' + query(qs), { headers: headers() });
|
|
269
|
+
if (res.status === 404) return null;
|
|
270
|
+
if (!res.ok) throw new Error('Rocket respondeu ' + res.status);
|
|
271
|
+
return res.json();
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
rocket.searchImage = async function (file, params) {
|
|
275
|
+
if (typeof file !== 'string') throw new Error('File must be a base64 string or URL');
|
|
276
|
+
const sessionId = await rocket.getSessionId();
|
|
277
|
+
const body = Object.assign({ sessionId }, params);
|
|
278
|
+
if (/^data:image\/[a-zA-Z]+;base64,/.test(file)) {
|
|
279
|
+
body.imageBase64 = file;
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
try {
|
|
283
|
+
body.imageUrl = new URL(file).toString();
|
|
284
|
+
}
|
|
285
|
+
catch (e) {
|
|
286
|
+
throw new Error('Invalid image format. Expected base64 or URL');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const res = await fetch(config.apiUrl + '/search/image?' + query({ storeId: config.storeId }), {
|
|
290
|
+
method: 'POST',
|
|
291
|
+
headers: headers({ 'Content-Type': 'application/json' }),
|
|
292
|
+
body: JSON.stringify(body)
|
|
293
|
+
});
|
|
294
|
+
return res.json();
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
rocket.getShowcasesByContext = async function (type) {
|
|
298
|
+
const pageType = resolvePageType(type);
|
|
299
|
+
if (!pageType) return [];
|
|
300
|
+
const sessionId = await getSessionIdWithTimeout();
|
|
301
|
+
const params = { storeId: config.storeId, pageType, page: pageType, sessionId };
|
|
302
|
+
if (pageType === 'product' && page.productId) params.productId = page.productId;
|
|
303
|
+
if (pageType === 'category' && page.categoryId) params.categoryId = page.categoryId;
|
|
304
|
+
|
|
305
|
+
const res = await fetch(config.apiUrl + '/showcases/context?' + query(params), { headers: headers() });
|
|
306
|
+
const showcases = await res.json();
|
|
307
|
+
if (Array.isArray(showcases)) {
|
|
308
|
+
showcases.forEach((showcase) => {
|
|
309
|
+
if (!showcase || !showcase.id || !Array.isArray(showcase.products)) return;
|
|
310
|
+
const cache = showcasePriceCache[String(showcase.id)] = {};
|
|
311
|
+
showcase.products.forEach((p) => {
|
|
312
|
+
if (p && p.id != null && p.price != null) cache[String(p.id)] = Number(p.price);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
return Array.isArray(showcases) ? showcases : [];
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// Uma chamada por página, repartida entre os slots (<RocketShowcase position>) — na Linx
|
|
320
|
+
// era o fetchShowCases único distribuindo por data-rocket-showcase-position. Zera no setPage.
|
|
321
|
+
let pageShowcases = null;
|
|
322
|
+
rocket.getPageShowcases = function (type) {
|
|
323
|
+
if (!pageShowcases) pageShowcases = rocket.getShowcasesByContext(type).catch(() => []);
|
|
324
|
+
return pageShowcases;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
// Só banners (DTO banner-native). Gated pela flag `banners`: loja sem ela recebe [].
|
|
328
|
+
rocket.getBannersByContext = async function (type, options) {
|
|
329
|
+
const pageType = resolvePageType(type);
|
|
330
|
+
if (!pageType) return [];
|
|
331
|
+
const opts = options || {};
|
|
332
|
+
try {
|
|
333
|
+
const sessionId = await getSessionIdWithTimeout();
|
|
334
|
+
const params = { storeId: config.storeId, pageType, sessionId, position: opts.position };
|
|
335
|
+
const res = await fetch(config.apiUrl + '/banners/context?' + query(params), { headers: headers() });
|
|
336
|
+
if (!res.ok) return [];
|
|
337
|
+
const banners = await res.json();
|
|
338
|
+
return Array.isArray(banners) ? banners : [];
|
|
339
|
+
}
|
|
340
|
+
catch (e) {
|
|
341
|
+
// Banner é conteúdo promocional — falha não pode quebrar a página.
|
|
342
|
+
return [];
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// ---- Atribuição (sessionStorage) ----
|
|
347
|
+
|
|
348
|
+
function saveShowcaseAttribution (showcaseId, productId, price, experiment) {
|
|
349
|
+
if (!showcaseId) return;
|
|
350
|
+
const record = {
|
|
351
|
+
showcaseId,
|
|
352
|
+
productId: productId || null,
|
|
353
|
+
productIds: productId ? [String(productId)] : null,
|
|
354
|
+
price: price != null ? Number(price) : null,
|
|
355
|
+
ts: Date.now()
|
|
356
|
+
};
|
|
357
|
+
if (experiment && experiment.experimentId != null) {
|
|
358
|
+
record.experimentId = experiment.experimentId;
|
|
359
|
+
record.variantId = experiment.variantId != null ? experiment.variantId : null;
|
|
360
|
+
}
|
|
361
|
+
storageSet(SHOWCASE_ATTR_KEY, record);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function getShowcaseAttribution (productId) {
|
|
365
|
+
const attr = storageGet(SHOWCASE_ATTR_KEY);
|
|
366
|
+
if (!attr || !attr.ts || Date.now() - attr.ts > SHOWCASE_ATTR_TTL) {
|
|
367
|
+
storageRemove(SHOWCASE_ATTR_KEY);
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
if (productId && Array.isArray(attr.productIds) && attr.productIds.length &&
|
|
371
|
+
attr.productIds.indexOf(String(productId)) === -1) return null;
|
|
372
|
+
return attr;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function saveShowcaseAttributionEntry (showcaseId, productId, position) {
|
|
376
|
+
if (!showcaseId || productId == null || productId === '') return;
|
|
377
|
+
const map = storageGet(SHOWCASE_ATTR_MAP_KEY) || {};
|
|
378
|
+
const now = Date.now();
|
|
379
|
+
Object.keys(map).forEach((key) => {
|
|
380
|
+
if (!map[key] || !map[key].ts || now - map[key].ts > SHOWCASE_ATTR_TTL) delete map[key];
|
|
381
|
+
});
|
|
382
|
+
map[String(productId)] = { showcaseId, position: position != null && position >= 0 ? position : null, ts: now };
|
|
383
|
+
storageSet(SHOWCASE_ATTR_MAP_KEY, map);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function getSearchAttribution () {
|
|
387
|
+
const attr = storageGet(SEARCH_ATTR_KEY);
|
|
388
|
+
if (!attr || !attr.ts || Date.now() - attr.ts > SHOWCASE_ATTR_TTL) {
|
|
389
|
+
storageRemove(SEARCH_ATTR_KEY);
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
return attr;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
rocket.getShowcaseAttribution = getShowcaseAttribution;
|
|
396
|
+
rocket.getSearchAttribution = getSearchAttribution;
|
|
397
|
+
|
|
398
|
+
// ---- Eventos ----
|
|
399
|
+
|
|
400
|
+
function readDebugToken () {
|
|
401
|
+
const parsed = storageGet(DEBUG_TOKEN_KEY);
|
|
402
|
+
if (!parsed) return null;
|
|
403
|
+
if (parsed.exp > Date.now()) return parsed.token;
|
|
404
|
+
storageRemove(DEBUG_TOKEN_KEY);
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
rocket.dispatchEvent = async function (eventType, data) {
|
|
409
|
+
if (!isBrowser) return null;
|
|
410
|
+
// Carimba a seção em TODO evento, num único ponto — o backend agrega por pageType.
|
|
411
|
+
data = data || {};
|
|
412
|
+
data.meta = data.meta || {};
|
|
413
|
+
if (!data.meta.pageType) data.meta.pageType = resolveSection();
|
|
414
|
+
|
|
415
|
+
if (eventType === 'click' && data.vitrine && data.productId) {
|
|
416
|
+
saveShowcaseAttribution(data.vitrine, data.productId, getShowcaseProductPrice(data.vitrine, data.productId), {
|
|
417
|
+
experimentId: data.experimentId,
|
|
418
|
+
variantId: data.variantId
|
|
419
|
+
});
|
|
420
|
+
saveShowcaseAttributionEntry(data.vitrine, data.productId, data.position);
|
|
421
|
+
}
|
|
422
|
+
if (eventType === 'click' && (data.meta.source === 'autocomplete' || data.meta.source === 'search-page')) {
|
|
423
|
+
storageSet(SEARCH_ATTR_KEY, { source: data.meta.source, ts: Date.now() });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const sessionId = await rocket.getSessionId();
|
|
427
|
+
const payload = Object.assign({
|
|
428
|
+
storeId: config.storeId,
|
|
429
|
+
eventType,
|
|
430
|
+
type: eventType,
|
|
431
|
+
sessionId: sessionId || null,
|
|
432
|
+
timestamp: new Date().toISOString()
|
|
433
|
+
}, data);
|
|
434
|
+
|
|
435
|
+
const reqHeaders = headers({ 'Content-Type': 'application/json' });
|
|
436
|
+
// Token de debug ativo: o backend roteia o evento só para o Redis (não grava no banco).
|
|
437
|
+
const debugToken = readDebugToken();
|
|
438
|
+
if (debugToken) {
|
|
439
|
+
reqHeaders['X-RS-Debug'] = debugToken;
|
|
440
|
+
payload.pageUrl = window.location.href;
|
|
441
|
+
}
|
|
442
|
+
if (config.debug) console.log('[RocketSearch] event', payload);
|
|
443
|
+
|
|
444
|
+
const res = await fetch(config.apiUrl + '/events', {
|
|
445
|
+
method: 'POST',
|
|
446
|
+
headers: reqHeaders,
|
|
447
|
+
body: JSON.stringify(payload),
|
|
448
|
+
keepalive: true
|
|
449
|
+
});
|
|
450
|
+
if (debugToken) {
|
|
451
|
+
res.clone().json().then((body) => {
|
|
452
|
+
if (body && (body._debug === 'expired' || body.code === 'DEBUG_SESSION_EXPIRED')) storageRemove(DEBUG_TOKEN_KEY);
|
|
453
|
+
}).catch(() => {});
|
|
454
|
+
}
|
|
455
|
+
return res;
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Envia o clique e navega na sequência. Clique normal: segura a navegação até o evento
|
|
460
|
+
* sair (o unload cancelaria o fetch), com teto de 600ms. Clique com modificador: o
|
|
461
|
+
* browser já abriu a aba — só dispara o evento. `navigate` recebe o href (o componente
|
|
462
|
+
* passa o $router.push, que não descarrega a página).
|
|
463
|
+
*
|
|
464
|
+
* @returns {boolean} true se assumiu a navegação.
|
|
465
|
+
*/
|
|
466
|
+
rocket.trackThenNavigate = function (event, el, href, send, navigate) {
|
|
467
|
+
if (!href || isModifiedClick(event, el)) {
|
|
468
|
+
try {
|
|
469
|
+
send();
|
|
470
|
+
}
|
|
471
|
+
catch (e) { /* best-effort */ }
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
if (event && typeof event.preventDefault === 'function') event.preventDefault();
|
|
475
|
+
let navigated = false;
|
|
476
|
+
const go = () => {
|
|
477
|
+
if (navigated) return;
|
|
478
|
+
navigated = true;
|
|
479
|
+
if (navigate) navigate(href);
|
|
480
|
+
else window.location.href = href;
|
|
481
|
+
};
|
|
482
|
+
setTimeout(go, 600);
|
|
483
|
+
Promise.resolve().then(send).then(go, go);
|
|
484
|
+
return true;
|
|
485
|
+
};
|
|
486
|
+
rocket.isModifiedClick = isModifiedClick;
|
|
487
|
+
|
|
488
|
+
// Jornada: page_view SOMENTE quando a seção muda na sessão (dedupe no sessionStorage),
|
|
489
|
+
// para reconstruir o fluxo entre seções sem 1 evento por pageview.
|
|
490
|
+
rocket.firePageViewIfSectionChanged = function () {
|
|
491
|
+
const section = resolveSection();
|
|
492
|
+
let prev = null;
|
|
493
|
+
try {
|
|
494
|
+
prev = sessionStorage.getItem(LAST_SECTION_KEY);
|
|
495
|
+
}
|
|
496
|
+
catch (e) { /* storage bloqueado */ }
|
|
497
|
+
if (prev === section) return;
|
|
498
|
+
try {
|
|
499
|
+
sessionStorage.setItem(LAST_SECTION_KEY, section);
|
|
500
|
+
}
|
|
501
|
+
catch (e) { /* storage bloqueado */ }
|
|
502
|
+
rocket.dispatchEvent('page_view', { meta: { pageType: section, fromSection: prev || null } }).catch(() => {});
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
// Cliente logado → 'login' com o id do cliente na Uappi (o mesmo `cliente.id` dos pedidos),
|
|
506
|
+
// uma vez por (sessão, cliente) na aba (ROC-656). `user` é a resposta da Front API em
|
|
507
|
+
// checkout/user, checkout/login e checkout/login-multiple-profile: `{ id, logado, ... }`.
|
|
508
|
+
// A Uappi não tem verificador de comprador: o vínculo fica `declared`. A marca só é gravada
|
|
509
|
+
// depois de entregue — envio perdido sai de novo na próxima resposta com o cliente.
|
|
510
|
+
rocket.trackLogin = async function (user) {
|
|
511
|
+
const customerId = user && user.logado !== false ? toPositiveNumber(user.id) : null;
|
|
512
|
+
if (!isBrowser || !customerId) return;
|
|
513
|
+
const sessionId = await rocket.getSessionId();
|
|
514
|
+
if (!sessionId) return;
|
|
515
|
+
const mark = sessionId + ':' + customerId;
|
|
516
|
+
if (storageGet(LOGIN_SENT_KEY) === mark) return;
|
|
517
|
+
const res = await rocket.dispatchEvent('login', { customerId });
|
|
518
|
+
if (res && res.ok) storageSet(LOGIN_SENT_KEY, mark);
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
rocket.fireProductView = function () {
|
|
522
|
+
if (!page.productId) return;
|
|
523
|
+
const viewData = { productId: page.productId };
|
|
524
|
+
const showcaseAttr = getShowcaseAttribution(page.productId);
|
|
525
|
+
const searchAttr = getSearchAttribution();
|
|
526
|
+
if (showcaseAttr && showcaseAttr.showcaseId) viewData.meta = { source: 'showcase:' + showcaseAttr.showcaseId };
|
|
527
|
+
else if (searchAttr && searchAttr.source) viewData.meta = { source: searchAttr.source };
|
|
528
|
+
rocket.dispatchEvent('view', viewData).catch(() => {});
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
// ---- add_to_cart (payload do POST /v2/front/checkout/cart da Uappi) ----
|
|
532
|
+
|
|
533
|
+
function getPendingCartEvents () {
|
|
534
|
+
const events = storageGet(PENDING_CART_EVENTS_KEY);
|
|
535
|
+
if (!Array.isArray(events)) return [];
|
|
536
|
+
return events.filter(e => e && e.ts && Date.now() - e.ts <= SHOWCASE_ATTR_TTL);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function setPendingCartEvents (events) {
|
|
540
|
+
storageSet(PENDING_CART_EVENTS_KEY, events.slice(-20));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function removePendingCartEvent (id) {
|
|
544
|
+
setPendingCartEvents(getPendingCartEvents().filter(e => e.id !== id));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// O evento entra na fila ANTES do envio: se a página navegar e cancelar o fetch, o
|
|
548
|
+
// próximo carregamento reenvia (flushPendingCartEvents).
|
|
549
|
+
function sendCartEvent (eventData) {
|
|
550
|
+
const event = { id: Date.now() + '-' + Math.random().toString(36).slice(2), eventType: 'add_to_cart', data: eventData, ts: Date.now() };
|
|
551
|
+
setPendingCartEvents(getPendingCartEvents().concat(event));
|
|
552
|
+
rocket.dispatchEvent('add_to_cart', eventData).then(() => removePendingCartEvent(event.id)).catch(() => {});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
rocket.flushPendingCartEvents = function () {
|
|
556
|
+
getPendingCartEvents().forEach((event) => {
|
|
557
|
+
rocket.dispatchEvent(event.eventType, event.data).then(() => removePendingCartEvent(event.id)).catch(() => {});
|
|
558
|
+
});
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
function resolveProductPrice (productId, attr) {
|
|
562
|
+
if (page.productId && String(page.productId) === String(productId) && page.product) {
|
|
563
|
+
const pagePrice = toPositiveNumber(page.product.precos && page.product.precos.por);
|
|
564
|
+
if (pagePrice != null) return pagePrice;
|
|
565
|
+
}
|
|
566
|
+
if (attr && attr.price != null) return attr.price;
|
|
567
|
+
if (attr && attr.showcaseId) {
|
|
568
|
+
const price = getShowcaseProductPrice(attr.showcaseId, productId);
|
|
569
|
+
if (price != null) return price;
|
|
570
|
+
}
|
|
571
|
+
const showcasePrice = findPriceInAnyShowcase(productId);
|
|
572
|
+
if (showcasePrice != null) return showcasePrice;
|
|
573
|
+
const cached = searchPriceCache[String(productId)];
|
|
574
|
+
return cached != null ? cached : null;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function resolveProductSource (productId, attr, searchAttr) {
|
|
578
|
+
if (attr && attr.showcaseId) return 'showcase:' + attr.showcaseId;
|
|
579
|
+
if (searchAttr && searchAttr.source) return searchAttr.source;
|
|
580
|
+
if (searchPriceCache[String(productId)] != null) return 'search-page';
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
rocket.trackAddToCart = function (payload) {
|
|
585
|
+
const itens = payload && Array.isArray(payload.itens) ? payload.itens : [];
|
|
586
|
+
const products = itens
|
|
587
|
+
.filter(item => item && item.idProduto != null)
|
|
588
|
+
.map(item => ({
|
|
589
|
+
productId: Number(item.idProduto),
|
|
590
|
+
skuId: item.idAtributoSimples != null ? Number(item.idAtributoSimples) : null,
|
|
591
|
+
quantity: Number(item.quantidade) || 1
|
|
592
|
+
}));
|
|
593
|
+
if (!products.length) return;
|
|
594
|
+
|
|
595
|
+
const attr = getShowcaseAttribution(products[0].productId);
|
|
596
|
+
const searchAttr = getSearchAttribution();
|
|
597
|
+
const experiment = attr && attr.experimentId != null
|
|
598
|
+
? { experimentId: attr.experimentId, variantId: attr.variantId != null ? attr.variantId : null }
|
|
599
|
+
: {};
|
|
600
|
+
|
|
601
|
+
if (products.length === 1) {
|
|
602
|
+
const p = products[0];
|
|
603
|
+
const meta = { quantity: p.quantity };
|
|
604
|
+
const price = resolveProductPrice(p.productId, attr);
|
|
605
|
+
const source = resolveProductSource(p.productId, attr, searchAttr);
|
|
606
|
+
if (source) meta.source = source;
|
|
607
|
+
if (price != null) meta.totalValue = price * p.quantity;
|
|
608
|
+
const eventData = Object.assign({ productId: p.productId, meta }, experiment);
|
|
609
|
+
if (attr && attr.showcaseId) eventData.vitrine = Number(attr.showcaseId);
|
|
610
|
+
sendCartEvent(eventData);
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
const items = products.map((p) => {
|
|
614
|
+
const item = { productId: p.productId, quantity: p.quantity };
|
|
615
|
+
if (p.skuId) item.skuId = p.skuId;
|
|
616
|
+
const price = resolveProductPrice(p.productId, attr);
|
|
617
|
+
if (price != null) item.totalValue = price * p.quantity;
|
|
618
|
+
const source = resolveProductSource(p.productId, attr, searchAttr);
|
|
619
|
+
if (source) item.source = source;
|
|
620
|
+
if (attr && attr.showcaseId && String(attr.productId) === String(p.productId)) item.showcaseId = Number(attr.showcaseId);
|
|
621
|
+
return item;
|
|
622
|
+
});
|
|
623
|
+
sendCartEvent(Object.assign({ items }, experiment));
|
|
624
|
+
}
|
|
625
|
+
if (attr) storageRemove(SHOWCASE_ATTR_KEY);
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
// ---- Debug (?rocket-search-debug=true e ?rs_debug=TOKEN persistem 2h na sessão) ----
|
|
629
|
+
|
|
630
|
+
rocket.initDebugFromUrl = function () {
|
|
631
|
+
if (!isBrowser) return;
|
|
632
|
+
let params;
|
|
633
|
+
try {
|
|
634
|
+
params = new URLSearchParams(location.search);
|
|
635
|
+
}
|
|
636
|
+
catch (e) {
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const showcaseDebug = params.get('rocket-search-debug');
|
|
641
|
+
if (showcaseDebug === 'false' || showcaseDebug === '0') {
|
|
642
|
+
storageRemove(SHOWCASE_DEBUG_KEY);
|
|
643
|
+
}
|
|
644
|
+
else if (showcaseDebug === 'true' || showcaseDebug === '1') {
|
|
645
|
+
storageSet(SHOWCASE_DEBUG_KEY, { exp: Date.now() + DEBUG_TTL });
|
|
646
|
+
}
|
|
647
|
+
const active = storageGet(SHOWCASE_DEBUG_KEY);
|
|
648
|
+
if (active && active.exp > Date.now()) document.body.classList.add('rocket-search-debug');
|
|
649
|
+
else storageRemove(SHOWCASE_DEBUG_KEY);
|
|
650
|
+
|
|
651
|
+
const token = params.get('rs_debug');
|
|
652
|
+
if (token) {
|
|
653
|
+
const existing = storageGet(DEBUG_TOKEN_KEY);
|
|
654
|
+
if (!existing || existing.token !== token || existing.exp <= Date.now()) {
|
|
655
|
+
storageSet(DEBUG_TOKEN_KEY, { token, exp: Date.now() + DEBUG_TTL });
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
return rocket;
|
|
661
|
+
}
|