@rocketsearch/nuxt-uappi 0.2.5 → 0.2.7

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
@@ -15,5 +15,6 @@ modules: [
15
15
 
16
16
  Landing page ("Personalizado" no painel): `<RocketLanding>` mostra todas as vitrines da landing, na ordem do
17
17
  painel. O identificador vem do nome da rota (`/black-friday` → `black-friday`) ou de `custom-key="..."`.
18
+ Banners da landing: `<RocketBanner page-type="custom" />`, com a mesma regra do identificador.
18
19
 
19
20
  Documentação completa: https://api.rocketsearch.com.br/sdk/uappi/docs.html
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocketsearch/nuxt-uappi",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Rocket Search para storefronts Uappi (Nuxt 2): busca, autocomplete, vitrines, banners e eventos",
5
5
  "main": "module.js",
6
6
  "files": [
package/runtime/banner.js CHANGED
@@ -101,6 +101,30 @@ export function pickStyles (styles, keys) {
101
101
  return out;
102
102
  }
103
103
 
104
+ // Vídeo no banner (ROC-420): a imagem segue sendo o pôster e o LCP.
105
+ function hasVideo (content) {
106
+ return !!(content && (content.videoUrl || content.mobileVideoUrl));
107
+ }
108
+
109
+ /**
110
+ * URL do vídeo a tocar, ou '' quando a imagem deve ficar sozinha: reduced-motion,
111
+ * economia de dados ou formato que o navegador não toca. Só no cliente (recebe o window).
112
+ */
113
+ export function pickVideoUrl (content, win) {
114
+ if (!win || !hasVideo(content)) return '';
115
+ const mq = q => !!(win.matchMedia && win.matchMedia(q).matches);
116
+ const url = (mq(MOBILE_MEDIA) && content.mobileVideoUrl) || content.videoUrl || content.mobileVideoUrl;
117
+ if (mq('(prefers-reduced-motion: reduce)')) return '';
118
+ const conn = win.navigator && win.navigator.connection;
119
+ if (conn && conn.saveData) return '';
120
+ // Contrato da ROC-418: a chave leva a extensão real (banners/{storeId}/{uuid}.mp4|webm).
121
+ // Sem extensão conhecida não arrisca: canPlayType('video/mp4') aprovaria um .mov.
122
+ const ext = /\.(mp4|webm)(?:[?#]|$)/i.exec(url);
123
+ if (!ext) return '';
124
+ const probe = win.document.createElement('video');
125
+ return probe.canPlayType && probe.canPlayType('video/' + ext[1].toLowerCase()) ? url : '';
126
+ }
127
+
104
128
  export function isHeroSlot (position) {
105
129
  return /^(hero|fullbanner|header|top)$/i.test(String(position || ''));
106
130
  }
@@ -129,6 +153,8 @@ export function bannerTracking (item, opts) {
129
153
  slideIndex: o.slideIndex
130
154
  };
131
155
  if (embedded) tracking.bannerId = item.bannerId;
156
+ // Tipo do criativo, não do que tocou: com reduced-motion o visitante vê o pôster.
157
+ if (hasVideo(item.banner)) tracking.mediaType = 'video';
132
158
  if (item.experiment) {
133
159
  tracking.experimentId = item.experiment.experimentId;
134
160
  tracking.variantId = item.experiment.variantId;
@@ -140,6 +166,7 @@ function baseData (t, meta) {
140
166
  const data = { vitrine: t.id, showcaseId: t.id, meta };
141
167
  if (t.slot) meta.slot = String(t.slot);
142
168
  if (t.imageVariant) meta.imageVariant = t.imageVariant;
169
+ if (t.mediaType) meta.mediaType = t.mediaType;
143
170
  if (t.bannerId != null) {
144
171
  data.bannerId = t.bannerId;
145
172
  meta.bannerId = t.bannerId;
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <div
3
3
  :class="['rocket-search', 'rocket-search__banner-slot', { 'rocket-search--empty': !waiting && !banners.length }]"
4
- :data-rocket-banner-position="position"
4
+ :data-rocket-banner-position="position || undefined"
5
5
  >
6
6
  <!-- Skeleton só com proporção declarada: sem saber a altura, ele é que causaria o pulo. -->
7
7
  <div v-if="waiting && ratio" class="rocket-search__banner-skeleton" :style="{ aspectRatio: ratioCss }" aria-hidden="true" />
@@ -26,12 +26,14 @@
26
26
  import RocketBannerItem from './banner/RocketBannerItem.vue';
27
27
  import { exactWidth, isHeroSlot } from '../banner.js';
28
28
 
29
+ const positionOf = b => (b && ((b.placement && b.placement.position) || b.position)) || '';
30
+
29
31
  export default {
30
32
  name: 'RocketBanner',
31
33
  components: { RocketBannerItem },
32
34
  props: {
33
- // placement.position cadastrada no painel (hero, top, middle...).
34
- position: { type: String, required: true },
35
+ // placement.position cadastrada no painel (hero, top, middle...). Obrigatória fora da landing.
36
+ position: { type: String, default: '' },
35
37
  // ssr: vem no HTML (hero/LCP). ondemand: busca só quando o slot chega perto da viewport.
36
38
  loading: {
37
39
  type: String,
@@ -40,6 +42,9 @@ export default {
40
42
  },
41
43
  // Força o pageType; sem ele vem da página atual (dadosPageAtual do tema).
42
44
  pageType: { type: String, default: '' },
45
+ // Landing (page-type="custom"): identificador no painel; sem ele, o nome da rota
46
+ // (/black-friday -> black-friday). A landing não tem posição: vêm todos os banners dela.
47
+ customKey: { type: String, default: '' },
43
48
  limit: { type: Number, default: 0 },
44
49
  // Proporção do slot ("16/9", "3:1") — reserva a caixa e liga o skeleton.
45
50
  ratio: { type: String, default: '' },
@@ -65,11 +70,22 @@ export default {
65
70
  return this.loading === 'ssr';
66
71
  },
67
72
  computed: {
73
+ landingKey () {
74
+ if (this.pageType !== 'custom') return '';
75
+ if (this.customKey) return this.customKey;
76
+ return ((this.$route && this.$route.path) || '').split('/').filter(Boolean).pop() || '';
77
+ },
68
78
  ratioCss () {
69
79
  return String(this.ratio).trim().replace(':', ' / ');
70
80
  }
71
81
  },
72
82
  watch: {
83
+ // Mesma página, outra landing (rota dinâmica reaproveita o componente): busca de novo.
84
+ landingKey () {
85
+ if (!this.requested) return;
86
+ this.requested = false;
87
+ this.tryLoad();
88
+ },
73
89
  // O contexto da página chega por setDadosPageAtual (o plugin traduz para $rocket.setPage).
74
90
  '$store.state.dadosPageAtual' () {
75
91
  if (this.loading === 'ssr' || this.visible) this.tryLoad();
@@ -101,16 +117,23 @@ export default {
101
117
  if (process.client && this.$el && this.$el.clientWidth) {
102
118
  this.width = exactWidth(this.$el.clientWidth, window.devicePixelRatio);
103
119
  }
104
- const list = await this.$rocket.getBannersByContext(this.pageType || undefined, { position: this.position });
105
- const mine = list.filter(b => ((b.placement && b.placement.position) || b.position) === this.position);
120
+ const key = this.landingKey;
121
+ const list = this.pageType === 'custom'
122
+ ? (key ? await this.$rocket.getBannersByContext('custom', { customKey: key }) : [])
123
+ : await this.$rocket.getBannersByContext(this.pageType || undefined, { position: this.position });
124
+ // Troca rápida de landing: a resposta da anterior não sobrescreve a atual.
125
+ if (key !== this.landingKey) return;
126
+ const mine = this.pageType === 'custom' ? list : list.filter(b => positionOf(b) === this.position);
106
127
  this.banners = this.limit > 0 ? mine.slice(0, this.limit) : mine;
107
128
  this.waiting = false;
108
129
  },
109
130
 
110
131
  itemProps (index) {
111
- const hero = isHeroSlot(this.position) && index === 0;
132
+ // Na landing o slot é a posição do próprio banner.
133
+ const position = this.position || positionOf(this.banners[index]);
134
+ const hero = isHeroSlot(position) && index === 0;
112
135
  return {
113
- position: this.position,
136
+ position,
114
137
  slideIndex: index,
115
138
  loading: hero ? 'eager' : 'lazy',
116
139
  fetchPriority: hero ? 'high' : '',
@@ -25,6 +25,22 @@
25
25
  @load="syncRatio"
26
26
  @error="onImageError"
27
27
  >
28
+ <!-- Vídeo (ROC-420) por cima do pôster; src e play() só depois do load e na tela. -->
29
+ <video
30
+ v-if="videoSrc"
31
+ ref="video"
32
+ :key="videoSrc"
33
+ class="rocket-search__banner__video"
34
+ :class="{ 'rocket-search__banner__video--playing': videoPlaying }"
35
+ muted
36
+ loop
37
+ playsinline
38
+ preload="none"
39
+ aria-hidden="true"
40
+ :style="imgStyle"
41
+ @playing="videoPlaying = true"
42
+ @error="dropVideo"
43
+ />
28
44
  </picture>
29
45
  <!-- Texto só quando o lojista marcou showStoreText no dashboard. -->
30
46
  <div v-if="content.showStoreText && (content.title || content.subtitle || content.ctaText)" class="rocket-search__banner__caption" :style="captionStyle">
@@ -38,7 +54,7 @@
38
54
  <script>
39
55
  import {
40
56
  MOBILE_MEDIA, cdnResize, buildSrcset, resolveAspectRatio, hasExplicitAspectRatio, pickStyles,
41
- highestMilestone, bannerTracking, bannerViewData, bannerClickData
57
+ highestMilestone, bannerTracking, bannerViewData, bannerClickData, pickVideoUrl
42
58
  } from '../../banner.js';
43
59
 
44
60
  const VIEW_THRESHOLD = 0.5;
@@ -62,7 +78,7 @@ export default {
62
78
  aspectRatio: { type: String, default: '' }
63
79
  },
64
80
  data () {
65
- return { naturalRatio: null, resizeFailed: false, mobileMedia: MOBILE_MEDIA };
81
+ return { naturalRatio: null, resizeFailed: false, mobileMedia: MOBILE_MEDIA, videoSrc: '', videoPlaying: false };
66
82
  },
67
83
  computed: {
68
84
  content () {
@@ -125,11 +141,21 @@ export default {
125
141
  else this.onImageError(); // falhou antes da hidratação (SSR)
126
142
  }
127
143
  this.observeImpression();
144
+ // mounted só roda no cliente: o SSR entrega só a imagem (pôster).
145
+ this.setupVideo();
128
146
  },
129
147
  beforeDestroy () {
130
148
  if (this.observer) this.observer.disconnect();
149
+ this.dropVideo();
131
150
  clearTimeout(this.dwellTimer);
132
151
  },
152
+ watch: {
153
+ // O pai reaproveita o componente sem :key quando a vitrine recarrega (SPA):
154
+ // o vídeo tem que acompanhar o banner novo, como a imagem já acompanha.
155
+ content () {
156
+ this.setupVideo();
157
+ }
158
+ },
133
159
  methods: {
134
160
  // Sem proporção cadastrada, a caixa reservada é um chute (3:1): corrige para a
135
161
  // proporção real do arquivo assim que carrega — senão o cover cortaria o banner.
@@ -145,6 +171,55 @@ export default {
145
171
  if (this.resize) this.resizeFailed = true;
146
172
  },
147
173
 
174
+ // Toca na tela, pausa fora dela. Falha (rede, codec, autoplay bloqueado) tira o vídeo
175
+ // via v-if e deixa o pôster.
176
+ watchVideo () {
177
+ if (!this.videoSrc) return; // caiu entre o setup e o nextTick/load
178
+ if (!('IntersectionObserver' in window)) return this.startVideo();
179
+ this.videoObserver = new IntersectionObserver((entries) => {
180
+ entries.forEach((entry) => {
181
+ const video = this.$refs.video;
182
+ if (entry.isIntersecting) this.startVideo();
183
+ else if (video && video.getAttribute('src')) video.pause();
184
+ });
185
+ });
186
+ this.videoObserver.observe(this.$el);
187
+ },
188
+
189
+ // muted/playsinline vêm do template (o Vue 2 aplica muted como propriedade), antes do play().
190
+ startVideo () {
191
+ const video = this.$refs.video;
192
+ if (!video) return;
193
+ if (!video.getAttribute('src')) video.setAttribute('src', this.videoSrc);
194
+ const p = video.play();
195
+ // AbortError = pause() saindo da tela no meio do play(); não é falha.
196
+ if (p && p.catch) p.catch((err) => { if (!err || err.name !== 'AbortError') this.dropVideo(); });
197
+ },
198
+
199
+ setupVideo () {
200
+ this.dropVideo();
201
+ this.videoSrc = pickVideoUrl(this.content, window);
202
+ if (!this.videoSrc) return;
203
+ if (document.readyState === 'complete') this.$nextTick(this.watchVideo);
204
+ else window.addEventListener('load', this.watchVideo, { once: true });
205
+ // A imagem troca sozinha de variante (<source media>) ao girar a tela; o vídeo não.
206
+ this.videoMq = window.matchMedia && window.matchMedia(MOBILE_MEDIA);
207
+ if (this.videoMq && this.videoMq.addEventListener) this.videoMq.addEventListener('change', this.onBreakpoint);
208
+ },
209
+
210
+ // Variante do vídeo deixou de valer: sai e fica o pôster certo.
211
+ onBreakpoint () {
212
+ if (pickVideoUrl(this.content, window) !== this.videoSrc) this.dropVideo();
213
+ },
214
+
215
+ dropVideo () {
216
+ this.videoSrc = '';
217
+ this.videoPlaying = false;
218
+ if (this.videoObserver) this.videoObserver.disconnect();
219
+ window.removeEventListener('load', this.watchVideo);
220
+ if (this.videoMq && this.videoMq.removeEventListener) this.videoMq.removeEventListener('change', this.onBreakpoint);
221
+ },
222
+
148
223
  imageVariant () {
149
224
  const narrow = window.matchMedia && window.matchMedia(MOBILE_MEDIA).matches;
150
225
  if (narrow && this.content.mobileImageUrl) return 'mobile';
@@ -21,6 +21,7 @@
21
21
  color: inherit;
22
22
 
23
23
  & .rocket-search__banner__media {
24
+ position: relative;
24
25
  display: block;
25
26
  width: 100%;
26
27
  /* fallback p/ browser sem aspect-ratio: a imagem define a altura */
@@ -36,6 +37,23 @@
36
37
  object-fit: cover;
37
38
  }
38
39
 
40
+ /* Vídeo (ROC-420) por cima do pôster; só aparece quando começa a tocar, sem quadro preto. */
41
+ & .rocket-search__banner__video {
42
+ position: absolute;
43
+ top: 0;
44
+ left: 0;
45
+ width: 100%;
46
+ height: 100%;
47
+ object-fit: cover;
48
+ opacity: 0;
49
+ transition: opacity 0.3s;
50
+ pointer-events: none;
51
+ }
52
+
53
+ & .rocket-search__banner__video--playing {
54
+ opacity: 1;
55
+ }
56
+
39
57
  & .rocket-search__banner__caption {
40
58
  position: absolute;
41
59
  left: 0;
package/runtime/rocket.js CHANGED
@@ -320,6 +320,7 @@ export function createRocket (config) {
320
320
  };
321
321
 
322
322
  // Só banners (DTO banner-native). Gated pela flag `banners`: loja sem ela recebe [].
323
+ // options.customKey: identificador da landing (pageType=custom); sem ele a API responde 400.
323
324
  rocket.getBannersByContext = async function (type, options) {
324
325
  const pageType = resolvePageType(type);
325
326
  if (!pageType) return [];
@@ -327,6 +328,7 @@ export function createRocket (config) {
327
328
  try {
328
329
  const sessionId = await getSessionIdWithTimeout();
329
330
  const params = { storeId: config.storeId, pageType, sessionId, position: opts.position };
331
+ if (pageType === 'custom') params.customKey = opts.customKey;
330
332
  const res = await fetch(config.apiUrl + '/banners/context?' + query(params), { headers: headers() });
331
333
  if (!res.ok) return [];
332
334
  const banners = await res.json();