@mundogamernetwork/shared-ui 1.11.2 → 1.12.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.
@@ -440,6 +440,66 @@ body:has(.kit-press) {
440
440
  }
441
441
  .kit-logo-tile .ic { color: var(--kit-accent, #FDB215); }
442
442
 
443
+ /* Downloads: press releases, fact sheets, asset bundles. Its own section
444
+ because these used to sit inside "Logos & icons", where a journalist
445
+ hunting for the press release would never look. One row per file so the
446
+ same release in several languages reads as a list of choices. */
447
+ .kit-docs {
448
+ display: flex;
449
+ flex-direction: column;
450
+ gap: 8px;
451
+ }
452
+ .kit-doc {
453
+ display: flex;
454
+ align-items: center;
455
+ gap: 12px;
456
+ padding: 12px 16px;
457
+ background: var(--kit-surface, #14171d);
458
+ border: 1px solid var(--kit-line, #252a34);
459
+ color: var(--kit-text, #f4f6fa);
460
+ text-decoration: none;
461
+ transition: border-color 0.15s;
462
+ }
463
+ .kit-doc:hover { border-color: var(--kit-accent, #FDB215); }
464
+ .kit-doc__ic {
465
+ color: var(--kit-accent, #FDB215);
466
+ font-size: 0.85rem;
467
+ flex-shrink: 0;
468
+ }
469
+ .kit-doc__body {
470
+ display: flex;
471
+ flex-direction: column;
472
+ gap: 2px;
473
+ min-width: 0;
474
+ flex: 1;
475
+ }
476
+ .kit-doc__title {
477
+ font-size: 0.9rem;
478
+ overflow: hidden;
479
+ text-overflow: ellipsis;
480
+ white-space: nowrap;
481
+ }
482
+ .kit-doc__meta {
483
+ font-size: 0.72rem;
484
+ color: var(--kit-muted, #8b92a0);
485
+ }
486
+ .kit-doc__lang {
487
+ display: flex;
488
+ align-items: center;
489
+ gap: 6px;
490
+ flex-shrink: 0;
491
+ padding: 3px 9px;
492
+ border: 1px solid var(--kit-line, #252a34);
493
+ color: var(--kit-muted, #8b92a0);
494
+ font-size: 0.72rem;
495
+ }
496
+ .kit-doc__lang img {
497
+ width: 16px;
498
+ height: 12px;
499
+ object-fit: cover;
500
+ display: block;
501
+ }
502
+
443
503
  /* Articles */
444
504
  .kit-articles {
445
505
  display: grid;
@@ -4,8 +4,11 @@ interface Video {
4
4
  title: string;
5
5
  url: string;
6
6
  type?: string;
7
+ thumbnail_url?: string;
7
8
  is_primary?: boolean;
8
9
  sort_order?: number;
10
+ /** Spoken/subtitled language, for studios shipping a dubbed cut per market. */
11
+ language?: { name?: string; native_name?: string; name_abrev?: string; flag_url?: string } | null;
9
12
  }
10
13
 
11
14
  interface Props {
@@ -14,21 +17,33 @@ interface Props {
14
17
 
15
18
  const props = defineProps<Props>();
16
19
 
17
- const activeVideo = ref<Video | null>(null);
18
-
19
- onMounted(() => {
20
- const primary = props.videos.find(v => v.is_primary);
21
- activeVideo.value = primary || props.videos[0] || null;
22
- });
20
+ // Initialize synchronously so direct public-page loads also have a player
21
+ // during SSR, instead of waiting for client hydration.
22
+ const selectedVideoId = ref<number | null>(
23
+ (props.videos.find(v => v.is_primary) || props.videos[0])?.id ?? null,
24
+ );
25
+ const activeVideo = computed(() =>
26
+ props.videos.find(v => v.id === selectedVideoId.value) || props.videos[0] || null,
27
+ );
28
+ const playing = ref(false);
29
+
30
+ function selectVideo(id: number) {
31
+ selectedVideoId.value = id;
32
+ playing.value = false;
33
+ }
23
34
 
24
35
  function getEmbedUrl(url: string) {
25
- const ytMatch = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/);
36
+ const ytMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([\w-]{11})/);
26
37
  if (ytMatch) return `https://www.youtube.com/embed/${ytMatch[1]}`;
27
38
  const vimeoMatch = url.match(/vimeo\.com\/(\d+)/);
28
39
  if (vimeoMatch) return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
29
40
  return url;
30
41
  }
31
42
 
43
+ function isDirectVideo(url: string) {
44
+ return /\.(mp4|webm|ogg)(?:[?#].*)?$/i.test(url);
45
+ }
46
+
32
47
  function formatType(type?: string) {
33
48
  if (!type) return '';
34
49
  return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
@@ -40,7 +55,19 @@ function formatType(type?: string) {
40
55
  <h3 class="section-title">{{ $t('press_kit.videos') }}</h3>
41
56
 
42
57
  <div v-if="activeVideo" class="video-embed">
58
+ <video v-if="isDirectVideo(activeVideo.url)" :src="activeVideo.url" :poster="activeVideo.thumbnail_url || undefined" controls preload="metadata" />
59
+ <button
60
+ v-else-if="activeVideo.thumbnail_url && !playing"
61
+ type="button"
62
+ class="video-poster"
63
+ :aria-label="activeVideo.title"
64
+ @click="playing = true"
65
+ >
66
+ <img :src="activeVideo.thumbnail_url" :alt="activeVideo.title" />
67
+ <span class="video-poster__play">▶</span>
68
+ </button>
43
69
  <iframe
70
+ v-else
44
71
  :src="getEmbedUrl(activeVideo.url)"
45
72
  frameborder="0"
46
73
  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
@@ -53,12 +80,19 @@ function formatType(type?: string) {
53
80
  v-for="video in videos"
54
81
  :key="video.id"
55
82
  :class="['video-item', { active: activeVideo?.id === video.id }]"
56
- @click="activeVideo = video"
83
+ @click="selectVideo(video.id)"
57
84
  >
58
- <MGIcon icon="videos" />
85
+ <img v-if="video.thumbnail_url" :src="video.thumbnail_url" :alt="video.title" class="video-item__thumb" />
86
+ <MGIcon v-else icon="videos" />
59
87
  <div class="video-meta">
60
88
  <span class="video-title">{{ video.title }}</span>
61
- <span v-if="video.type" class="video-type">{{ formatType(video.type) }}</span>
89
+ <span class="video-sub">
90
+ <span v-if="video.type" class="video-type">{{ formatType(video.type) }}</span>
91
+ <span v-if="video.language" class="video-lang">
92
+ <img v-if="video.language.flag_url" :src="video.language.flag_url" :alt="video.language.name || ''" loading="lazy" />
93
+ {{ video.language.native_name || video.language.name }}
94
+ </span>
95
+ </span>
62
96
  </div>
63
97
  </div>
64
98
  </div>
@@ -86,6 +120,46 @@ function formatType(type?: string) {
86
120
  width: 100%;
87
121
  height: 100%;
88
122
  }
123
+
124
+ video {
125
+ width: 100%;
126
+ height: 100%;
127
+ object-fit: contain;
128
+ }
129
+
130
+ .video-poster {
131
+ position: absolute;
132
+ inset: 0;
133
+ width: 100%;
134
+ height: 100%;
135
+ padding: 0;
136
+ border: 0;
137
+ background: #000;
138
+ cursor: pointer;
139
+
140
+ img {
141
+ width: 100%;
142
+ height: 100%;
143
+ object-fit: cover;
144
+ display: block;
145
+ }
146
+ }
147
+
148
+ .video-poster__play {
149
+ position: absolute;
150
+ left: 50%;
151
+ top: 50%;
152
+ transform: translate(-50%, -50%);
153
+ width: 64px;
154
+ height: 64px;
155
+ display: grid;
156
+ place-items: center;
157
+ padding-left: 4px;
158
+ background: rgba(253, 178, 21, 0.95);
159
+ color: #101217;
160
+ font-size: 24px;
161
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.55);
162
+ }
89
163
  }
90
164
 
91
165
  .video-list {
@@ -121,6 +195,14 @@ function formatType(type?: string) {
121
195
  flex-direction: column;
122
196
  }
123
197
 
198
+ .video-item__thumb {
199
+ width: 72px;
200
+ height: 42px;
201
+ object-fit: cover;
202
+ flex-shrink: 0;
203
+ border: 1px solid #343842;
204
+ }
205
+
124
206
  .video-title {
125
207
  font-size: 0.9rem;
126
208
  font-weight: 500;
@@ -130,5 +212,27 @@ function formatType(type?: string) {
130
212
  font-size: 0.75rem;
131
213
  color: #888;
132
214
  }
215
+
216
+ .video-sub {
217
+ display: flex;
218
+ align-items: center;
219
+ gap: 8px;
220
+ flex-wrap: wrap;
221
+ }
222
+
223
+ .video-lang {
224
+ display: inline-flex;
225
+ align-items: center;
226
+ gap: 5px;
227
+ font-size: 0.75rem;
228
+ color: #888;
229
+
230
+ img {
231
+ width: 15px;
232
+ height: 11px;
233
+ object-fit: cover;
234
+ display: block;
235
+ }
236
+ }
133
237
  }
134
238
  </style>
@@ -5,7 +5,7 @@ import {
5
5
  fetchPlatforms,
6
6
  requestKey as apiRequestKey,
7
7
  revealKey as apiRevealKey,
8
- type KeyPool,
8
+ type KeyCampaign,
9
9
  type KeyRequest,
10
10
  type RevealResult,
11
11
  } from '../../services/keyService'
@@ -30,7 +30,7 @@ const emit = defineEmits<{
30
30
 
31
31
  // ─── State ────────────────────────────────────────────────────────────────────
32
32
 
33
- const pools = ref<KeyPool[]>([])
33
+ const pools = ref<KeyCampaign[]>([])
34
34
  const myRequests = ref<KeyRequest[]>([])
35
35
  const platforms = ref<{ id: number; name: string }[]>([])
36
36
  const loadingPools = ref(true)
@@ -116,7 +116,7 @@ function redirectToLogin() {
116
116
 
117
117
  // ─── Request ──────────────────────────────────────────────────────────────────
118
118
 
119
- async function doRequestKey(pool: KeyPool) {
119
+ async function doRequestKey(pool: KeyCampaign) {
120
120
  requesting.value = pool.id
121
121
  delete requestErrors.value[pool.id]
122
122
  try {
@@ -156,7 +156,7 @@ async function doRequestKey(pool: KeyPool) {
156
156
  }
157
157
  }
158
158
 
159
- function handleRequestKey(pool: KeyPool) {
159
+ function handleRequestKey(pool: KeyCampaign) {
160
160
  if (!isAuthenticated.value) {
161
161
  redirectToLogin()
162
162
  return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.11.2",
3
+ "version": "1.12.0",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -104,7 +104,26 @@ const showcases = computed(() => list(kit.value?.showcases));
104
104
  const hasEcosystem = computed(() => showcases.value.length || kit.value?.ventures_url || kit.value?.magazine_feature || kit.value?.community_url);
105
105
 
106
106
  const screenshots = computed(() => assets.value.filter((a: any) => ['screenshot', 'artwork', 'gif'].includes(a.type)));
107
- const brandAssets = computed(() => assets.value.filter((a: any) => ['logo', 'document'].includes(a.type)));
107
+ // Documents used to be lumped in with logos under "Logos & icons", which is
108
+ // where a journalist looking for the press release would never think to look.
109
+ const brandAssets = computed(() => assets.value.filter((a: any) => a.type === 'logo'));
110
+ // Grouped by language so the same release in several languages reads as one
111
+ // row of choices rather than a pile of near-identical filenames.
112
+ const documents = computed(() => [...assets.value.filter((a: any) => a.type === 'document')]
113
+ .sort((a: any, b: any) => (a.language?.name_abrev || '').localeCompare(b.language?.name_abrev || '')));
114
+
115
+ const fileExtension = (a: any) => {
116
+ const match = String(a?.url || '').split('?')[0].match(/\.([a-z0-9]{1,5})$/i);
117
+ return match ? match[1].toUpperCase() : '';
118
+ };
119
+
120
+ const fileSize = (bytes?: number) => {
121
+ if (!bytes) return '';
122
+ if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
123
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
124
+ };
125
+
126
+ const documentMeta = (a: any) => [fileExtension(a), fileSize(a.file_size)].filter(Boolean).join(' · ');
108
127
 
109
128
  // Animated assets get the full gallery row at their natural height — cropping a
110
129
  // gameplay loop into the 16/9 thumbnail grid makes it unreadable. The type is
@@ -135,12 +154,23 @@ const allowDownload = computed(() => kit.value?.allow_asset_download !== false);
135
154
  const hasDownloadableAssets = computed(() => assets.value.length > 0);
136
155
  const canDownload = computed(() => allowDownload.value && hasDownloadableAssets.value);
137
156
  const zipDownloadUrl = computed(() => `${apiBase}/public/press-kits/${slug}/download${previewToken ? `?preview_token=${previewToken}` : ''}`);
138
- const interviewMailto = computed(() => {
157
+ /**
158
+ * The interview button used to be a hardcoded mailto: that showed up whenever
159
+ * the kit had a press e-mail — studios could neither hide it nor point it at
160
+ * the scheduler they already run. `interview_mode` is now the source of truth;
161
+ * kits that predate the field default to 'email' server-side, so nothing about
162
+ * an existing published kit changes.
163
+ */
164
+ const interviewMode = computed(() => kit.value?.interview_mode || 'email');
165
+ const interviewHref = computed(() => {
166
+ if (interviewMode.value === 'off') return '';
167
+ if (interviewMode.value === 'external') return kit.value?.interview_url || '';
139
168
  const email = kit.value?.press_contact_email;
140
169
  if (!email) return '';
141
170
  const subject = `${t('kit.press.request_interview')} — ${kit.value?.name || ''}`;
142
171
  return `mailto:${email}?subject=${encodeURIComponent(subject)}`;
143
172
  });
173
+ const interviewIsExternal = computed(() => interviewMode.value === 'external' && !!interviewHref.value);
144
174
 
145
175
  const keyPoolsVisible = ref(false);
146
176
  function onPoolsReady(count: number) {
@@ -261,6 +291,7 @@ const navSections = computed(() => {
261
291
  s.push({ id: 'factsheet', label: 'kit.press.fact_sheet' });
262
292
  if (steamAppId.value) s.push({ id: 'steam', label: 'kit.press.steam_widget' });
263
293
  if (screenshots.value.length || brandAssets.value.length) s.push({ id: 'media', label: 'kit.press.screenshots' });
294
+ if (documents.value.length) s.push({ id: 'downloads', label: 'kit.press.downloads' });
264
295
  if (videos.value.length) s.push({ id: 'trailers', label: 'kit.press.videos' });
265
296
  if (articles.value.length) s.push({ id: 'articles', label: 'kit.press.articles' });
266
297
  if (coverage.value.length) s.push({ id: 'creators', label: 'kit.press.coverage' });
@@ -403,7 +434,14 @@ useHead(() => ({
403
434
  offered when the kit is actually linked to a game with open pools, same
404
435
  gate as the section itself, so this never links to an empty/broken state. -->
405
436
  <a v-if="kit.game_id" class="kit-btn kit-btn-ghost" href="#review-key" @click.prevent="scrollToSection('review-key')">{{ $t('kit.press.request_review_key') }}</a>
406
- <a v-if="interviewMailto" class="kit-btn kit-btn-ghost" :href="interviewMailto" @click="track('contact')">{{ $t('kit.press.request_interview') }}</a>
437
+ <a
438
+ v-if="interviewHref"
439
+ class="kit-btn kit-btn-ghost"
440
+ :href="interviewHref"
441
+ :target="interviewIsExternal ? '_blank' : undefined"
442
+ :rel="interviewIsExternal ? 'noopener' : undefined"
443
+ @click="track('contact')"
444
+ >{{ $t('kit.press.request_interview') }}</a>
407
445
  </div>
408
446
  </div>
409
447
  </div>
@@ -515,6 +553,32 @@ useHead(() => ({
515
553
  </div>
516
554
  </section>
517
555
 
556
+ <!-- Documentos: press release, fact sheet, bundles -->
557
+ <section v-if="documents.length" id="downloads" class="kit-block">
558
+ <div class="kit-shead"><h2>{{ $t('kit.press.downloads') }}</h2></div>
559
+ <div class="kit-docs">
560
+ <a
561
+ v-for="(d, i) in documents"
562
+ :key="i"
563
+ class="kit-doc"
564
+ :href="d.url"
565
+ target="_blank"
566
+ rel="noopener"
567
+ @click="track('download')"
568
+ >
569
+ <span class="kit-doc__ic">▼</span>
570
+ <span class="kit-doc__body">
571
+ <span class="kit-doc__title">{{ d.title || fileExtension(d) }}</span>
572
+ <span v-if="documentMeta(d)" class="kit-doc__meta">{{ documentMeta(d) }}</span>
573
+ </span>
574
+ <span v-if="d.language" class="kit-doc__lang">
575
+ <img v-if="d.language.flag_url" :src="d.language.flag_url" :alt="d.language.name || ''" loading="lazy" />
576
+ {{ d.language.native_name || d.language.name }}
577
+ </span>
578
+ </a>
579
+ </div>
580
+ </section>
581
+
518
582
  <!-- Conteúdos & Artigos -->
519
583
  <section v-if="articles.length" id="articles" class="kit-block">
520
584
  <div class="kit-shead"><h2>{{ $t('kit.press.articles') }}</h2></div>
@@ -1,6 +1,6 @@
1
1
  import httpService from './httpService'
2
2
 
3
- export type KeyPool = {
3
+ export type KeyCampaign = {
4
4
  id: number
5
5
  name: string
6
6
  slug: string
@@ -29,17 +29,26 @@ export type RevealResult = {
29
29
  revealed_at: string
30
30
  }
31
31
 
32
+ // Every route below is registered by agency-api inside its `public` group
33
+ // (routes/api.php: `['prefix' => 'public']`), so the real paths are
34
+ // /api/v1/public/... — httpService's baseURL only supplies /api/v1. These
35
+ // calls omitted the `public` segment and had been 404ing: KeyBrowser rendered
36
+ // an empty "no campaigns available" state instead of the real list, on the
37
+ // press room, the media-partner dashboard and the public press kit.
38
+ // Confirmed against `php artisan route:list`; agency-frontend is the only
39
+ // consumer of this service, and it points at agency-api.
40
+
32
41
  export const fetchAvailablePools = (params: Record<string, any> = {}) =>
33
- httpService.get<{ data: KeyPool[] }>('/key-pools/available', { params })
42
+ httpService.get<{ data: KeyCampaign[] }>('/public/key-campaigns/available', { params })
34
43
 
35
44
  export const fetchMyRequests = () =>
36
- httpService.get<{ data: KeyRequest[] }>('/key-requests/mine')
45
+ httpService.get<{ data: KeyRequest[] }>('/public/key-requests/mine')
37
46
 
38
47
  export const requestKey = (poolId: number, body: Record<string, any>) =>
39
- httpService.post(`/key-pools/${poolId}/request`, body)
48
+ httpService.post(`/public/key-campaigns/${poolId}/request`, body)
40
49
 
41
50
  export const revealKey = (requestId: number, body: Record<string, any> = {}) =>
42
- httpService.post<{ data: RevealResult }>(`/key-requests/${requestId}/reveal`, body)
51
+ httpService.post<{ data: RevealResult }>(`/public/key-requests/${requestId}/reveal`, body)
43
52
 
44
53
  export const fetchPlatforms = () =>
45
- httpService.get<{ data: { id: number; name: string }[] }>('/showcase-studio-dashboard/platforms')
54
+ httpService.get<{ data: { id: number; name: string }[] }>('/public/showcase-studio-dashboard/platforms')