@rocketsearch/vue-uappi 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,30 @@
1
- # Temporary Holding Version
1
+ # @rocketsearch/vue-uappi
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Rocket Search (busca, autocomplete, vitrines, banners e analytics) para lojas Uappi em **Vue 3 + Vite + Pinia**.
4
+ Para lojas Uappi em Nuxt 2, use `@rocketsearch/nuxt-uappi`.
5
+
6
+ ```bash
7
+ npm install @rocketsearch/vue-uappi
8
+ ```
9
+
10
+ ```js
11
+ // src/main.js — depois do app.use(pinia) e do app.use(router)
12
+ import RocketSearch from '@rocketsearch/vue-uappi';
13
+
14
+ app.use(RocketSearch, {
15
+ storeId: 'minha-loja',
16
+ apiKey: 'pk_live_xxx',
17
+ uappiUrl: import.meta.env.VITE_REQUEST_URL,
18
+ uappiDomain: 'www.minhaloja.com.br'
19
+ });
20
+ ```
21
+
22
+ ```js
23
+ // vite.config.js — o pacote publica os .vue sem compilar
24
+ optimizeDeps: { exclude: ['@rocketsearch/vue-uappi'] }
25
+ ```
26
+
27
+ Use os componentes em PascalCase (`<RocketSearch>`, `<RocketShowcase position="top">`): o tema pode ter
28
+ `isCustomElement` para tags com hífen.
29
+
30
+ Documentação completa: https://api.rocketsearch.com.br/sdk/uappi/docs.html
package/index.js ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Plugin Vue 3 do Rocket Search para lojas Uappi (wap.store) em Vue 3 + Vite + Pinia.
3
+ *
4
+ * Port do @rocketsearch/nuxt-uappi (Nuxt 2). O que lá vinha do Vuex e do $axios aqui
5
+ * vem do Pinia do tema:
6
+ * general.currentPageData -> contexto da página (setDadosPageAtual na versão Nuxt)
7
+ * user.user -> login
8
+ * cart.add() -> add_to_cart
9
+ *
10
+ * main.js (depois do Pinia e do router):
11
+ * app.use(RocketSearch, {
12
+ * storeId: 'minha-loja',
13
+ * apiKey: 'pk_live_xxx',
14
+ * uappiUrl: import.meta.env.VITE_REQUEST_URL,
15
+ * uappiDomain: 'www.minhaloja.com.br'
16
+ * });
17
+ */
18
+ import { defineAsyncComponent, inject, shallowRef, watch } from 'vue';
19
+ import { createRocket, sectionFromNivel } from './runtime/rocket.js';
20
+ import { fetchUappiProducts } from './runtime/uappi.js';
21
+
22
+ export {
23
+ rocketSearchListing,
24
+ rocketCategoryListing,
25
+ rocketCollectionListing,
26
+ dispatchListingSearch,
27
+ dispatchListingClick,
28
+ dispatchDidYouMeanClick,
29
+ didYouMeanQuery,
30
+ productIndexFromClick
31
+ } from './runtime/listing.js';
32
+
33
+ const ROCKET_KEY = Symbol('rocket');
34
+ const TAG = '[RocketSearch]';
35
+
36
+ const DEFAULTS = {
37
+ storeId: '',
38
+ apiKey: '',
39
+ apiUrl: 'https://api.rocketsearch.com.br/api/v2',
40
+ // Origem da loja para a Front API (o VITE_REQUEST_URL do tema). Vazio = a própria página.
41
+ uappiUrl: '',
42
+ // Trava contra config colada no repo errado: se não bater com o host de uappiUrl, o
43
+ // plugin não sobe (sem isso, a loja B serviria o catálogo da loja A em silêncio).
44
+ uappiDomain: '',
45
+ searchPagePath: '/busca',
46
+ searchPageParam: 'busca',
47
+ debug: false
48
+ };
49
+
50
+ function hostOf (url) {
51
+ try {
52
+ return new URL(url).hostname.toLowerCase();
53
+ }
54
+ catch (e) {
55
+ return '';
56
+ }
57
+ }
58
+
59
+ // Traduz o `currentPageData` do tema (resposta da Front API da Uappi) para o contexto da
60
+ // página. Página estática do tema (404, landing) não tem `nivel`: fica com o fallback.
61
+ function pageFromState (dados) {
62
+ const nivel = dados && dados.estrutura && dados.estrutura.nivel;
63
+ const section = sectionFromNivel(nivel);
64
+ if (!section) return null;
65
+ const conteudo = (dados && dados.conteudo) || {};
66
+ return {
67
+ section,
68
+ productId: section === 'pdp' && conteudo.id != null ? String(conteudo.id) : null,
69
+ // A Front API manda a categoria em `conteudo.categoria` (`conteudo.id` vem null na listagem).
70
+ categoryId: section === 'category' && conteudo.categoria && conteudo.categoria.id != null ? String(conteudo.categoria.id) : null,
71
+ product: section === 'pdp' ? conteudo : null
72
+ };
73
+ }
74
+
75
+ // null quando o plugin não subiu (sem storeId, trava de domínio). Padrão explícito: sem ele o
76
+ // Vue avisa no console a cada componente que chama, com a Rocket desligada.
77
+ export function useRocket () {
78
+ return inject(ROCKET_KEY, null);
79
+ }
80
+
81
+ export default {
82
+ install (app, options) {
83
+ const config = Object.assign({}, DEFAULTS, options);
84
+
85
+ if (!config.storeId || !config.apiKey) {
86
+ console.error(TAG, 'storeId e apiKey são obrigatórios — plugin desativado.');
87
+ return;
88
+ }
89
+
90
+ config.uappiUrl = String(config.uappiUrl || window.location.origin).replace(/\/$/, '');
91
+ const uappiHost = hostOf(config.uappiUrl);
92
+ if (config.uappiDomain && uappiHost !== config.uappiDomain.toLowerCase()) {
93
+ console.error(TAG, `uappiDomain "${config.uappiDomain}" não bate com uappiUrl "${uappiHost}" — plugin desativado.`);
94
+ return;
95
+ }
96
+ config.apiUrl = String(config.apiUrl).replace(/\/$/, '');
97
+
98
+ const rocket = createRocket(config);
99
+ rocket.fetchProducts = ids => fetchUappiProducts(config.uappiUrl, ids);
100
+ // Reativo: vitrines e banners esperam o contexto da página para buscar.
101
+ rocket.currentPage = shallowRef(rocket.getPage());
102
+
103
+ app.config.globalProperties.$rocket = rocket;
104
+ app.provide(ROCKET_KEY, rocket);
105
+ // PascalCase de propósito: tema Uappi Vue costuma ter isCustomElement para tag com hífen.
106
+ // Async: JS e CSS de cada componente só baixam se o tema usar (quem só troca a fonte
107
+ // de dados da listagem não carrega nada).
108
+ app.component('RocketAutocomplete', defineAsyncComponent(() => import('./runtime/components/RocketAutocomplete.vue')));
109
+ app.component('RocketBanner', defineAsyncComponent(() => import('./runtime/components/RocketBanner.vue')));
110
+ app.component('RocketSearch', defineAsyncComponent(() => import('./runtime/components/RocketSearch.vue')));
111
+ app.component('RocketShowcase', defineAsyncComponent(() => import('./runtime/components/RocketShowcase.vue')));
112
+
113
+ // Conexão fria + preflight CORS era o grosso da latência do storefront, não a busca.
114
+ const preconnect = document.createElement('link');
115
+ preconnect.rel = 'preconnect';
116
+ preconnect.href = new URL(config.apiUrl).origin;
117
+ preconnect.crossOrigin = '';
118
+ document.head.appendChild(preconnect);
119
+
120
+ rocket.initDebugFromUrl();
121
+ rocket.flushPendingCartEvents();
122
+
123
+ let pageResolved = false;
124
+
125
+ function applyPage (dados) {
126
+ const page = pageFromState(dados);
127
+ if (!page) return;
128
+ pageResolved = true;
129
+ rocket.setPage(page);
130
+ rocket.currentPage.value = rocket.getPage();
131
+ rocket.fireProductView();
132
+ rocket.firePageViewIfSectionChanged();
133
+ }
134
+
135
+ // Página que não publica dados (rota estática sem Front API) ainda precisa de
136
+ // page_view — sem isso a jornada ficaria presa na seção anterior.
137
+ function pageViewFallback () {
138
+ setTimeout(() => {
139
+ if (!pageResolved) rocket.firePageViewIfSectionChanged();
140
+ }, 1500);
141
+ }
142
+
143
+ const pinia = app.config.globalProperties.$pinia;
144
+ if (pinia) {
145
+ // pinia.state é reativo e ganha a chave da store quando ela é criada: o watch pega
146
+ // tanto a primeira página quanto a navegação SPA.
147
+ watch(() => pinia.state.value.general && pinia.state.value.general.currentPageData, applyPage, { immediate: true });
148
+ // Retorno de checkout/user (`{ id, logado }`); o tema troca no login e na troca de perfil.
149
+ watch(() => pinia.state.value.user && pinia.state.value.user.user, (user) => {
150
+ rocket.trackLogin(user).catch(() => {});
151
+ }, { immediate: true });
152
+
153
+ // Action e não o POST: item já no carrinho vira PUT com a quantidade somada, e o
154
+ // payload da action traz só o que foi adicionado. after = só add que deu certo.
155
+ const trackCart = (store) => {
156
+ store.$onAction(({ name, args, after }) => {
157
+ if (name === 'add') after(() => rocket.trackAddToCart(args[0]));
158
+ });
159
+ };
160
+ pinia.use(({ store }) => {
161
+ if (store.$id === 'cart') trackCart(store);
162
+ });
163
+ // pinia.use só alcança store criada depois dele.
164
+ if (pinia._s && pinia._s.has('cart')) trackCart(pinia._s.get('cart'));
165
+ }
166
+ else {
167
+ console.warn(TAG, 'Pinia não encontrado — instale o plugin depois do app.use(pinia). Sem contexto de página, login e add_to_cart.');
168
+ }
169
+
170
+ const router = app.config.globalProperties.$router;
171
+ if (router) {
172
+ router.beforeEach((to, from) => {
173
+ if (to.path !== from.path) {
174
+ pageResolved = false;
175
+ rocket.setPage({});
176
+ rocket.currentPage.value = rocket.getPage();
177
+ }
178
+ });
179
+ router.afterEach((to, from, failure) => {
180
+ // from sem matched = navegação inicial (START_LOCATION), que também conta.
181
+ if (failure || (to.path === from.path && from.matched.length)) return;
182
+ pageViewFallback();
183
+ });
184
+ }
185
+ else {
186
+ pageViewFallback();
187
+ }
188
+ }
189
+ };
package/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "@rocketsearch/vue-uappi",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Rocket Search para storefronts Uappi em Vue 3 + Vite + Pinia: busca, autocomplete, vitrines, banners e eventos",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "runtime",
13
+ "README.md"
14
+ ],
15
+ "peerDependencies": {
16
+ "vue": "^3.3.0",
17
+ "vue-router": "^4.0.0",
18
+ "pinia": "^2.0.0 || ^3.0.0"
19
+ },
20
+ "license": "UNLICENSED",
21
+ "homepage": "https://api.rocketsearch.com.br/sdk/uappi/docs.html",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ }
25
+ }
@@ -0,0 +1,171 @@
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
+ // ROC-690: entrega sem perda: format=webp + quality=100 = WebP lossless na CF (com format=auto
21
+ // o navegador com AVIF recebe AVIF, que é sempre com perda). Mesmo valor em linxecommerce/2.0.0/src/js/rs-banner.js.
22
+ const CDN_RESIZE_OPTS = ',format=webp,quality=100,fit=scale-down';
23
+
24
+ export function clamp01 (n) {
25
+ if (typeof n !== 'number' || isNaN(n)) return undefined;
26
+ if (n < 0) return 0;
27
+ if (n > 1) return 1;
28
+ return Math.round(n * 10000) / 10000;
29
+ }
30
+
31
+ export function highestMilestone (ratio) {
32
+ let best = 0;
33
+ VIEW_MILESTONES.forEach((m) => {
34
+ if (ratio >= m) best = m;
35
+ });
36
+ return best;
37
+ }
38
+
39
+ /**
40
+ * Cloudflare Image Resizing na origem da própria imagem (o /cdn-cgi/image/ só existe
41
+ * na zona que a serve). ⚠️ Sem o recurso na zona, responde 404 e o banner some —
42
+ * por isso dá para desligar (prop imageResizing=false).
43
+ */
44
+ export function cdnResize (url, width, enabled) {
45
+ if (!url || enabled === false) return url;
46
+ if (/\/cdn-cgi\/image\//.test(url) || /^data:/i.test(url)) return url;
47
+ try {
48
+ const abs = new URL(url);
49
+ if (abs.protocol !== 'https:' && abs.protocol !== 'http:') return url;
50
+ return abs.origin + '/cdn-cgi/image/width=' + width + CDN_RESIZE_OPTS + abs.pathname + abs.search;
51
+ }
52
+ catch (e) {
53
+ return url;
54
+ }
55
+ }
56
+
57
+ export function buildSrcset (url, enabled) {
58
+ if (!url || enabled === false) return '';
59
+ return SRCSET_WIDTHS.map(w => cdnResize(url, w, enabled) + ' ' + w + 'w').join(', ');
60
+ }
61
+
62
+ // Largura a pedir à CF: slot × DPR (teto 2), arredondada para cima no passo.
63
+ export function exactWidth (cssWidth, dpr) {
64
+ if (!cssWidth) return 0;
65
+ const ratio = Math.min(Math.max(dpr || 1, 1), 2);
66
+ return Math.min(Math.ceil((cssWidth * ratio) / WIDTH_STEP) * WIDTH_STEP, MAX_EXACT_WIDTH);
67
+ }
68
+
69
+ // Aceita "16/9", "16:9" ou número. Sem valor, o placeholder anti-CLS.
70
+ export function resolveAspectRatio (styles, fallback) {
71
+ const raw = styles && (styles.aspectRatio || styles.aspect_ratio);
72
+ if (raw == null || raw === '') return fallback || DEFAULT_ASPECT_RATIO;
73
+ return String(raw).trim().replace(':', ' / ') || fallback || DEFAULT_ASPECT_RATIO;
74
+ }
75
+
76
+ export function hasExplicitAspectRatio (styles, fallback) {
77
+ const raw = styles && (styles.aspectRatio || styles.aspect_ratio);
78
+ return (raw != null && raw !== '') || !!fallback;
79
+ }
80
+
81
+ // Só deixa passar chave CSS conhecida — o JSON de styles vem do dashboard.
82
+ const ALLOWED_STYLE_KEYS = {
83
+ backgroundColor: 'backgroundColor',
84
+ textColor: 'color',
85
+ borderRadius: 'borderRadius',
86
+ objectFit: 'objectFit',
87
+ objectPosition: 'objectPosition',
88
+ minHeight: 'minHeight',
89
+ maxWidth: 'maxWidth',
90
+ padding: 'padding',
91
+ margin: 'margin'
92
+ };
93
+
94
+ export function pickStyles (styles, keys) {
95
+ const out = {};
96
+ if (!styles) return out;
97
+ keys.forEach((key) => {
98
+ const value = styles[key];
99
+ if (ALLOWED_STYLE_KEYS[key] && value != null && value !== '') out[ALLOWED_STYLE_KEYS[key]] = String(value);
100
+ });
101
+ return out;
102
+ }
103
+
104
+ export function isHeroSlot (position) {
105
+ return /^(hero|fullbanner|header|top)$/i.test(String(position || ''));
106
+ }
107
+
108
+ /** True para banner autônomo vindo de /showcases/context (type='banner', ou sem produtos + imagem). */
109
+ export function isBannerItem (item) {
110
+ if (!item) return false;
111
+ if (item.type === 'banner') return true;
112
+ const hasProducts = Array.isArray(item.products) && item.products.length > 0;
113
+ const content = item.banner || {};
114
+ return !hasProducts && !!(content.imageUrl || content.mobileImageUrl);
115
+ }
116
+
117
+ /**
118
+ * Quem medir (contrato ROC-26):
119
+ * - autônomo → o próprio id (já é o showcaseId do banner);
120
+ * - embutido em vitrine → showcaseId da VITRINE + bannerId do criativo.
121
+ */
122
+ export function bannerTracking (item, opts) {
123
+ const o = opts || {};
124
+ const embedded = item.type !== 'banner' && item.bannerId != null && item.bannerId !== '';
125
+ const ownId = item.bannerId != null && item.bannerId !== '' ? item.bannerId : item.id;
126
+ const tracking = {
127
+ id: embedded ? item.id : ownId,
128
+ slot: (item.placement && item.placement.position) || item.position || o.slot || '',
129
+ slideIndex: o.slideIndex
130
+ };
131
+ if (embedded) tracking.bannerId = item.bannerId;
132
+ if (item.experiment) {
133
+ tracking.experimentId = item.experiment.experimentId;
134
+ tracking.variantId = item.experiment.variantId;
135
+ }
136
+ return tracking;
137
+ }
138
+
139
+ function baseData (t, meta) {
140
+ const data = { vitrine: t.id, showcaseId: t.id, meta };
141
+ if (t.slot) meta.slot = String(t.slot);
142
+ if (t.imageVariant) meta.imageVariant = t.imageVariant;
143
+ if (t.bannerId != null) {
144
+ data.bannerId = t.bannerId;
145
+ meta.bannerId = t.bannerId;
146
+ }
147
+ if (t.slideIndex != null && t.slideIndex !== '') {
148
+ data.position = Number(t.slideIndex) || 0;
149
+ meta.slideIndex = data.position;
150
+ }
151
+ if (t.experimentId) data.experimentId = t.experimentId;
152
+ if (t.variantId) data.variantId = t.variantId;
153
+ return data;
154
+ }
155
+
156
+ export function bannerViewData (t, visibleRatio, milestone) {
157
+ const meta = { type: 'showcase_impression', source: 'showcase:' + t.id };
158
+ const vr = clamp01(visibleRatio);
159
+ if (vr) meta.visibleRatio = vr;
160
+ if (milestone > 0) meta.visibleThreshold = milestone;
161
+ return baseData(t, meta);
162
+ }
163
+
164
+ export function bannerClickData (t, clickX, clickY) {
165
+ const data = baseData(t, { source: 'showcase:' + t.id, type: 'banner_click' });
166
+ const cx = clamp01(clickX);
167
+ const cy = clamp01(clickY);
168
+ if (cx != null) data.clickX = data.meta.clickX = cx;
169
+ if (cy != null) data.clickY = data.meta.clickY = cy;
170
+ return data;
171
+ }