@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.
@@ -0,0 +1,632 @@
1
+ /**
2
+ * Núcleo do Rocket Search para Uappi — port do rs-config.js do SDK Linx 2.0.0.
3
+ *
4
+ * Sem Vue e sem Nuxt de propósito: só fetch + sessionStorage, para poder ser
5
+ * compartilhado com outras plataformas. O plugin injeta o contexto da página
6
+ * (setPage) e o cookie de sessão; o resto é o comportamento da Linx.
7
+ *
8
+ * Diferenças para a Linx:
9
+ * - contexto da página vem do Vuex do tema (setDadosPageAtual), não do browsingContext;
10
+ * - sessionId = sempre o id anônimo local (ver getSessionId);
11
+ * - add_to_cart vem do interceptor do $axios (plugin), com o payload da Uappi;
12
+ * - purchase fica para o GTM do checkout (o checkout da Uappi não roda no Nuxt).
13
+ */
14
+
15
+ const SHOWCASE_ATTR_KEY = 'rs_showcase_attr';
16
+ const SHOWCASE_ATTR_TTL = 30 * 60 * 1000;
17
+ // Mapa persistente produto -> vitrine (last-touch por produto). Diferente do slot único,
18
+ // NÃO é apagado no add_to_cart: sobrevive até o checkout para atribuir GMV no purchase.
19
+ const SHOWCASE_ATTR_MAP_KEY = 'rs_showcase_attr_map';
20
+ const SEARCH_ATTR_KEY = 'rs_search_attr';
21
+ const PENDING_CART_EVENTS_KEY = 'rs_pending_add_to_cart_events';
22
+ const ANON_SESSION_KEY = 'rs_anon_session_id';
23
+ const ANON_SESSION_TTL = 30 * 60 * 1000;
24
+ const LAST_SECTION_KEY = 'rs_last_section';
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
+ const data = await res.json();
255
+ if (isBrowser && data && data.redirect && data.redirect.type === 'url' && data.redirect.url) {
256
+ window.location.href = data.redirect.url;
257
+ }
258
+ return data;
259
+ };
260
+
261
+ rocket.searchImage = async function (file, params) {
262
+ if (typeof file !== 'string') throw new Error('File must be a base64 string or URL');
263
+ const sessionId = await rocket.getSessionId();
264
+ const body = Object.assign({ sessionId }, params);
265
+ if (/^data:image\/[a-zA-Z]+;base64,/.test(file)) {
266
+ body.imageBase64 = file;
267
+ }
268
+ else {
269
+ try {
270
+ body.imageUrl = new URL(file).toString();
271
+ }
272
+ catch (e) {
273
+ throw new Error('Invalid image format. Expected base64 or URL');
274
+ }
275
+ }
276
+ const res = await fetch(config.apiUrl + '/search/image?' + query({ storeId: config.storeId }), {
277
+ method: 'POST',
278
+ headers: headers({ 'Content-Type': 'application/json' }),
279
+ body: JSON.stringify(body)
280
+ });
281
+ return res.json();
282
+ };
283
+
284
+ rocket.getShowcasesByContext = async function (type) {
285
+ const pageType = resolvePageType(type);
286
+ if (!pageType) return [];
287
+ const sessionId = await getSessionIdWithTimeout();
288
+ const params = { storeId: config.storeId, pageType, page: pageType, sessionId };
289
+ if (pageType === 'product' && page.productId) params.productId = page.productId;
290
+ if (pageType === 'category' && page.categoryId) params.categoryId = page.categoryId;
291
+
292
+ const res = await fetch(config.apiUrl + '/showcases/context?' + query(params), { headers: headers() });
293
+ const showcases = await res.json();
294
+ if (Array.isArray(showcases)) {
295
+ showcases.forEach((showcase) => {
296
+ if (!showcase || !showcase.id || !Array.isArray(showcase.products)) return;
297
+ const cache = showcasePriceCache[String(showcase.id)] = {};
298
+ showcase.products.forEach((p) => {
299
+ if (p && p.id != null && p.price != null) cache[String(p.id)] = Number(p.price);
300
+ });
301
+ });
302
+ }
303
+ return Array.isArray(showcases) ? showcases : [];
304
+ };
305
+
306
+ // Uma chamada por página, repartida entre os slots (<RocketShowcase position>) — na Linx
307
+ // era o fetchShowCases único distribuindo por data-rocket-showcase-position. Zera no setPage.
308
+ let pageShowcases = null;
309
+ rocket.getPageShowcases = function (type) {
310
+ if (!pageShowcases) pageShowcases = rocket.getShowcasesByContext(type).catch(() => []);
311
+ return pageShowcases;
312
+ };
313
+
314
+ // Só banners (DTO banner-native). Gated pela flag `banners`: loja sem ela recebe [].
315
+ rocket.getBannersByContext = async function (type, options) {
316
+ const pageType = resolvePageType(type);
317
+ if (!pageType) return [];
318
+ const opts = options || {};
319
+ try {
320
+ const sessionId = await getSessionIdWithTimeout();
321
+ const params = { storeId: config.storeId, pageType, sessionId, position: opts.position };
322
+ const res = await fetch(config.apiUrl + '/banners/context?' + query(params), { headers: headers() });
323
+ if (!res.ok) return [];
324
+ const banners = await res.json();
325
+ return Array.isArray(banners) ? banners : [];
326
+ }
327
+ catch (e) {
328
+ // Banner é conteúdo promocional — falha não pode quebrar a página.
329
+ return [];
330
+ }
331
+ };
332
+
333
+ // ---- Atribuição (sessionStorage) ----
334
+
335
+ function saveShowcaseAttribution (showcaseId, productId, price, experiment) {
336
+ if (!showcaseId) return;
337
+ const record = {
338
+ showcaseId,
339
+ productId: productId || null,
340
+ productIds: productId ? [String(productId)] : null,
341
+ price: price != null ? Number(price) : null,
342
+ ts: Date.now()
343
+ };
344
+ if (experiment && experiment.experimentId != null) {
345
+ record.experimentId = experiment.experimentId;
346
+ record.variantId = experiment.variantId != null ? experiment.variantId : null;
347
+ }
348
+ storageSet(SHOWCASE_ATTR_KEY, record);
349
+ }
350
+
351
+ function getShowcaseAttribution (productId) {
352
+ const attr = storageGet(SHOWCASE_ATTR_KEY);
353
+ if (!attr || !attr.ts || Date.now() - attr.ts > SHOWCASE_ATTR_TTL) {
354
+ storageRemove(SHOWCASE_ATTR_KEY);
355
+ return null;
356
+ }
357
+ if (productId && Array.isArray(attr.productIds) && attr.productIds.length &&
358
+ attr.productIds.indexOf(String(productId)) === -1) return null;
359
+ return attr;
360
+ }
361
+
362
+ function saveShowcaseAttributionEntry (showcaseId, productId, position) {
363
+ if (!showcaseId || productId == null || productId === '') return;
364
+ const map = storageGet(SHOWCASE_ATTR_MAP_KEY) || {};
365
+ const now = Date.now();
366
+ Object.keys(map).forEach((key) => {
367
+ if (!map[key] || !map[key].ts || now - map[key].ts > SHOWCASE_ATTR_TTL) delete map[key];
368
+ });
369
+ map[String(productId)] = { showcaseId, position: position != null && position >= 0 ? position : null, ts: now };
370
+ storageSet(SHOWCASE_ATTR_MAP_KEY, map);
371
+ }
372
+
373
+ function getSearchAttribution () {
374
+ const attr = storageGet(SEARCH_ATTR_KEY);
375
+ if (!attr || !attr.ts || Date.now() - attr.ts > SHOWCASE_ATTR_TTL) {
376
+ storageRemove(SEARCH_ATTR_KEY);
377
+ return null;
378
+ }
379
+ return attr;
380
+ }
381
+
382
+ rocket.getShowcaseAttribution = getShowcaseAttribution;
383
+ rocket.getSearchAttribution = getSearchAttribution;
384
+
385
+ // ---- Eventos ----
386
+
387
+ function readDebugToken () {
388
+ const parsed = storageGet(DEBUG_TOKEN_KEY);
389
+ if (!parsed) return null;
390
+ if (parsed.exp > Date.now()) return parsed.token;
391
+ storageRemove(DEBUG_TOKEN_KEY);
392
+ return null;
393
+ }
394
+
395
+ rocket.dispatchEvent = async function (eventType, data) {
396
+ if (!isBrowser) return null;
397
+ // Carimba a seção em TODO evento, num único ponto — o backend agrega por pageType.
398
+ data = data || {};
399
+ data.meta = data.meta || {};
400
+ if (!data.meta.pageType) data.meta.pageType = resolveSection();
401
+
402
+ if (eventType === 'click' && data.vitrine && data.productId) {
403
+ saveShowcaseAttribution(data.vitrine, data.productId, getShowcaseProductPrice(data.vitrine, data.productId), {
404
+ experimentId: data.experimentId,
405
+ variantId: data.variantId
406
+ });
407
+ saveShowcaseAttributionEntry(data.vitrine, data.productId, data.position);
408
+ }
409
+ if (eventType === 'click' && (data.meta.source === 'autocomplete' || data.meta.source === 'search-page')) {
410
+ storageSet(SEARCH_ATTR_KEY, { source: data.meta.source, ts: Date.now() });
411
+ }
412
+
413
+ const sessionId = await rocket.getSessionId();
414
+ const payload = Object.assign({
415
+ storeId: config.storeId,
416
+ eventType,
417
+ type: eventType,
418
+ sessionId: sessionId || null,
419
+ timestamp: new Date().toISOString()
420
+ }, data);
421
+
422
+ const reqHeaders = headers({ 'Content-Type': 'application/json' });
423
+ // Token de debug ativo: o backend roteia o evento só para o Redis (não grava no banco).
424
+ const debugToken = readDebugToken();
425
+ if (debugToken) {
426
+ reqHeaders['X-RS-Debug'] = debugToken;
427
+ payload.pageUrl = window.location.href;
428
+ }
429
+ if (config.debug) console.log('[RocketSearch] event', payload);
430
+
431
+ const res = await fetch(config.apiUrl + '/events', {
432
+ method: 'POST',
433
+ headers: reqHeaders,
434
+ body: JSON.stringify(payload),
435
+ keepalive: true
436
+ });
437
+ if (debugToken) {
438
+ res.clone().json().then((body) => {
439
+ if (body && (body._debug === 'expired' || body.code === 'DEBUG_SESSION_EXPIRED')) storageRemove(DEBUG_TOKEN_KEY);
440
+ }).catch(() => {});
441
+ }
442
+ return res;
443
+ };
444
+
445
+ /**
446
+ * Envia o clique e navega na sequência. Clique normal: segura a navegação até o evento
447
+ * sair (o unload cancelaria o fetch), com teto de 600ms. Clique com modificador: o
448
+ * browser já abriu a aba — só dispara o evento. `navigate` recebe o href (o componente
449
+ * passa o $router.push, que não descarrega a página).
450
+ *
451
+ * @returns {boolean} true se assumiu a navegação.
452
+ */
453
+ rocket.trackThenNavigate = function (event, el, href, send, navigate) {
454
+ if (!href || isModifiedClick(event, el)) {
455
+ try {
456
+ send();
457
+ }
458
+ catch (e) { /* best-effort */ }
459
+ return false;
460
+ }
461
+ if (event && typeof event.preventDefault === 'function') event.preventDefault();
462
+ let navigated = false;
463
+ const go = () => {
464
+ if (navigated) return;
465
+ navigated = true;
466
+ if (navigate) navigate(href);
467
+ else window.location.href = href;
468
+ };
469
+ setTimeout(go, 600);
470
+ Promise.resolve().then(send).then(go, go);
471
+ return true;
472
+ };
473
+ rocket.isModifiedClick = isModifiedClick;
474
+
475
+ // Jornada: page_view SOMENTE quando a seção muda na sessão (dedupe no sessionStorage),
476
+ // para reconstruir o fluxo entre seções sem 1 evento por pageview.
477
+ rocket.firePageViewIfSectionChanged = function () {
478
+ const section = resolveSection();
479
+ let prev = null;
480
+ try {
481
+ prev = sessionStorage.getItem(LAST_SECTION_KEY);
482
+ }
483
+ catch (e) { /* storage bloqueado */ }
484
+ if (prev === section) return;
485
+ try {
486
+ sessionStorage.setItem(LAST_SECTION_KEY, section);
487
+ }
488
+ catch (e) { /* storage bloqueado */ }
489
+ rocket.dispatchEvent('page_view', { meta: { pageType: section, fromSection: prev || null } }).catch(() => {});
490
+ };
491
+
492
+ rocket.fireProductView = function () {
493
+ if (!page.productId) return;
494
+ const viewData = { productId: page.productId };
495
+ const showcaseAttr = getShowcaseAttribution(page.productId);
496
+ const searchAttr = getSearchAttribution();
497
+ if (showcaseAttr && showcaseAttr.showcaseId) viewData.meta = { source: 'showcase:' + showcaseAttr.showcaseId };
498
+ else if (searchAttr && searchAttr.source) viewData.meta = { source: searchAttr.source };
499
+ rocket.dispatchEvent('view', viewData).catch(() => {});
500
+ };
501
+
502
+ // ---- add_to_cart (payload do POST /v2/front/checkout/cart da Uappi) ----
503
+
504
+ function getPendingCartEvents () {
505
+ const events = storageGet(PENDING_CART_EVENTS_KEY);
506
+ if (!Array.isArray(events)) return [];
507
+ return events.filter(e => e && e.ts && Date.now() - e.ts <= SHOWCASE_ATTR_TTL);
508
+ }
509
+
510
+ function setPendingCartEvents (events) {
511
+ storageSet(PENDING_CART_EVENTS_KEY, events.slice(-20));
512
+ }
513
+
514
+ function removePendingCartEvent (id) {
515
+ setPendingCartEvents(getPendingCartEvents().filter(e => e.id !== id));
516
+ }
517
+
518
+ // O evento entra na fila ANTES do envio: se a página navegar e cancelar o fetch, o
519
+ // próximo carregamento reenvia (flushPendingCartEvents).
520
+ function sendCartEvent (eventData) {
521
+ const event = { id: Date.now() + '-' + Math.random().toString(36).slice(2), eventType: 'add_to_cart', data: eventData, ts: Date.now() };
522
+ setPendingCartEvents(getPendingCartEvents().concat(event));
523
+ rocket.dispatchEvent('add_to_cart', eventData).then(() => removePendingCartEvent(event.id)).catch(() => {});
524
+ }
525
+
526
+ rocket.flushPendingCartEvents = function () {
527
+ getPendingCartEvents().forEach((event) => {
528
+ rocket.dispatchEvent(event.eventType, event.data).then(() => removePendingCartEvent(event.id)).catch(() => {});
529
+ });
530
+ };
531
+
532
+ function resolveProductPrice (productId, attr) {
533
+ if (page.productId && String(page.productId) === String(productId) && page.product) {
534
+ const pagePrice = toPositiveNumber(page.product.precos && page.product.precos.por);
535
+ if (pagePrice != null) return pagePrice;
536
+ }
537
+ if (attr && attr.price != null) return attr.price;
538
+ if (attr && attr.showcaseId) {
539
+ const price = getShowcaseProductPrice(attr.showcaseId, productId);
540
+ if (price != null) return price;
541
+ }
542
+ const showcasePrice = findPriceInAnyShowcase(productId);
543
+ if (showcasePrice != null) return showcasePrice;
544
+ const cached = searchPriceCache[String(productId)];
545
+ return cached != null ? cached : null;
546
+ }
547
+
548
+ function resolveProductSource (productId, attr, searchAttr) {
549
+ if (attr && attr.showcaseId) return 'showcase:' + attr.showcaseId;
550
+ if (searchAttr && searchAttr.source) return searchAttr.source;
551
+ if (searchPriceCache[String(productId)] != null) return 'search-page';
552
+ return null;
553
+ }
554
+
555
+ rocket.trackAddToCart = function (payload) {
556
+ const itens = payload && Array.isArray(payload.itens) ? payload.itens : [];
557
+ const products = itens
558
+ .filter(item => item && item.idProduto != null)
559
+ .map(item => ({
560
+ productId: Number(item.idProduto),
561
+ skuId: item.idAtributoSimples != null ? Number(item.idAtributoSimples) : null,
562
+ quantity: Number(item.quantidade) || 1
563
+ }));
564
+ if (!products.length) return;
565
+
566
+ const attr = getShowcaseAttribution(products[0].productId);
567
+ const searchAttr = getSearchAttribution();
568
+ const experiment = attr && attr.experimentId != null
569
+ ? { experimentId: attr.experimentId, variantId: attr.variantId != null ? attr.variantId : null }
570
+ : {};
571
+
572
+ if (products.length === 1) {
573
+ const p = products[0];
574
+ const meta = { quantity: p.quantity };
575
+ const price = resolveProductPrice(p.productId, attr);
576
+ const source = resolveProductSource(p.productId, attr, searchAttr);
577
+ if (source) meta.source = source;
578
+ if (price != null) meta.totalValue = price * p.quantity;
579
+ const eventData = Object.assign({ productId: p.productId, meta }, experiment);
580
+ if (attr && attr.showcaseId) eventData.vitrine = Number(attr.showcaseId);
581
+ sendCartEvent(eventData);
582
+ }
583
+ else {
584
+ const items = products.map((p) => {
585
+ const item = { productId: p.productId, quantity: p.quantity };
586
+ if (p.skuId) item.skuId = p.skuId;
587
+ const price = resolveProductPrice(p.productId, attr);
588
+ if (price != null) item.totalValue = price * p.quantity;
589
+ const source = resolveProductSource(p.productId, attr, searchAttr);
590
+ if (source) item.source = source;
591
+ if (attr && attr.showcaseId && String(attr.productId) === String(p.productId)) item.showcaseId = Number(attr.showcaseId);
592
+ return item;
593
+ });
594
+ sendCartEvent(Object.assign({ items }, experiment));
595
+ }
596
+ if (attr) storageRemove(SHOWCASE_ATTR_KEY);
597
+ };
598
+
599
+ // ---- Debug (?rocket-search-debug=true e ?rs_debug=TOKEN persistem 2h na sessão) ----
600
+
601
+ rocket.initDebugFromUrl = function () {
602
+ if (!isBrowser) return;
603
+ let params;
604
+ try {
605
+ params = new URLSearchParams(location.search);
606
+ }
607
+ catch (e) {
608
+ return;
609
+ }
610
+
611
+ const showcaseDebug = params.get('rocket-search-debug');
612
+ if (showcaseDebug === 'false' || showcaseDebug === '0') {
613
+ storageRemove(SHOWCASE_DEBUG_KEY);
614
+ }
615
+ else if (showcaseDebug === 'true' || showcaseDebug === '1') {
616
+ storageSet(SHOWCASE_DEBUG_KEY, { exp: Date.now() + DEBUG_TTL });
617
+ }
618
+ const active = storageGet(SHOWCASE_DEBUG_KEY);
619
+ if (active && active.exp > Date.now()) document.body.classList.add('rocket-search-debug');
620
+ else storageRemove(SHOWCASE_DEBUG_KEY);
621
+
622
+ const token = params.get('rs_debug');
623
+ if (token) {
624
+ const existing = storageGet(DEBUG_TOKEN_KEY);
625
+ if (!existing || existing.token !== token || existing.exp <= Date.now()) {
626
+ storageSet(DEBUG_TOKEN_KEY, { token, exp: Date.now() + DEBUG_TTL });
627
+ }
628
+ }
629
+ };
630
+
631
+ return rocket;
632
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Dados de produto vindos da própria Uappi.
3
+ *
4
+ * A Rocket decide ordem/filtro/paginação; o card mostra o produto como a loja o tem
5
+ * (preço, parcelamento, selos, rota). É o mesmo desenho da Linx, que pedia só id,price
6
+ * à Rocket e renderizava pelo widget nativo product_list_by_id.
7
+ *
8
+ * Aqui o equivalente é a vitrine "últimos vistos" da Front API: recebe uma lista de ids
9
+ * e devolve os produtos completos, na ordem pedida (conferido na homolog Daikin com 24
10
+ * ids). Produto inativo/indisponível é omitido — por isso a reordenação abaixo.
11
+ */
12
+
13
+ export async function fetchUappiProducts ($axios, ids) {
14
+ const list = (ids || []).map(String).filter(Boolean);
15
+ if (!list.length || !$axios) return [];
16
+
17
+ const data = await $axios.$get('/v2/front/showcase/products/ultimos-vistos', {
18
+ params: { productId: 0, seenProducts: list.join(';'), limit: list.length }
19
+ });
20
+ const byId = {};
21
+ ((data && data.produtos) || []).forEach((p) => {
22
+ if (p && p.id != null) byId[String(p.id)] = p;
23
+ });
24
+ return list.map(id => byId[id]).filter(Boolean);
25
+ }
26
+
27
+ // Tamanhos de imagem da Uappi, do menor para o maior. Se o pedido não existir no
28
+ // produto, usa o vizinho mais próximo (mesma regra do mediaSize da Linx).
29
+ const IMAGE_SIZES = ['thumb', 'small', 'medium', 'big', 'zoom'];
30
+
31
+ export function productImage (product, size) {
32
+ const imagens = product && product.midias && product.midias.imagens;
33
+ const arquivos = Array.isArray(imagens) && imagens[0] && imagens[0].arquivos;
34
+ if (!arquivos) return '';
35
+
36
+ let start = IMAGE_SIZES.indexOf(String(size || '').toLowerCase());
37
+ if (start < 0) start = IMAGE_SIZES.indexOf('medium');
38
+ for (let step = 0; step < IMAGE_SIZES.length; step++) {
39
+ const up = IMAGE_SIZES[start + step];
40
+ if (up && arquivos[up]) return arquivos[up];
41
+ const down = IMAGE_SIZES[start - step];
42
+ if (down && arquivos[down]) return arquivos[down];
43
+ }
44
+ return '';
45
+ }
46
+
47
+ export function productPath (product) {
48
+ if (product && product.rota && product.rota.route) return product.rota.route;
49
+ if (product && product.url) {
50
+ try {
51
+ return new URL(product.url).pathname;
52
+ }
53
+ catch (e) {
54
+ return product.url;
55
+ }
56
+ }
57
+ return '';
58
+ }
59
+
60
+ export function productPrice (product) {
61
+ const precos = product && product.precos;
62
+ if (!precos) return null;
63
+ const value = precos.por != null ? precos.por : precos.vista;
64
+ const n = Number(value);
65
+ return isFinite(n) && n > 0 ? n : null;
66
+ }
67
+
68
+ export function formatCurrency (value) {
69
+ if (typeof value !== 'number' || isNaN(value)) return '';
70
+ return value.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
71
+ }