@mundogamernetwork/shared-ui 1.8.18 → 1.8.22

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.
@@ -342,6 +342,7 @@ body:has(.kit-press) {
342
342
  }
343
343
  .kit-shot {
344
344
  display: block;
345
+ position: relative;
345
346
  aspect-ratio: 16 / 9;
346
347
  overflow: hidden;
347
348
  background: var(--kit-surface, #14171d);
@@ -358,6 +359,63 @@ body:has(.kit-press) {
358
359
  }
359
360
  .kit-shot:hover img { transform: scale(1.03); }
360
361
 
362
+ /* Animated GIFs take the whole row at their natural height — the 16/9 crop of
363
+ the thumbnail grid turns a gameplay loop into an unreadable sliver. Spanning
364
+ `1 / -1` (not `span 2`) keeps it safe at any column count, including the
365
+ single-column mobile grid. */
366
+ .kit-shot--wide {
367
+ grid-column: 1 / -1;
368
+ aspect-ratio: auto;
369
+ }
370
+ .kit-shot--wide img {
371
+ height: auto;
372
+ max-height: 520px;
373
+ object-fit: contain;
374
+ }
375
+ .kit-shot--wide:hover img { transform: none; }
376
+ .kit-shot__tag {
377
+ position: absolute;
378
+ top: 8px;
379
+ left: 8px;
380
+ padding: 2px 7px;
381
+ background: var(--kit-accent, #FDB215);
382
+ color: var(--kit-accent-ink, #13161C);
383
+ font-size: 0.6rem;
384
+ font-weight: 700;
385
+ letter-spacing: 1px;
386
+ line-height: 1.6;
387
+ pointer-events: none;
388
+ }
389
+
390
+ /* Steam store widget — Steam's embed has a fixed internal layout that stops
391
+ reflowing below ~466px, so it can't just be given a narrower box. */
392
+ .kit-steam {
393
+ max-width: 646px;
394
+ overflow-x: auto;
395
+ -webkit-overflow-scrolling: touch;
396
+ }
397
+ .kit-steam__frame {
398
+ display: block;
399
+ width: 100%;
400
+ min-width: 466px;
401
+ height: 190px;
402
+ border: 1px solid var(--kit-line, #252a34);
403
+ background: var(--kit-surface, #14171d);
404
+ }
405
+ /* Phone widths: scale the whole embed down instead of clipping it. The wrapper
406
+ height is the scaled height, otherwise the transform leaves a gap under it. */
407
+ @media (max-width: 500px) {
408
+ .kit-steam {
409
+ overflow: hidden;
410
+ height: 130px;
411
+ }
412
+ .kit-steam__frame {
413
+ width: 466px;
414
+ transform: scale(0.68);
415
+ transform-origin: 0 0;
416
+ }
417
+ }
418
+
361
419
  /* Brand / logo assets */
362
420
  .kit-logos {
363
421
  display: flex;
@@ -10,6 +10,8 @@ const props = defineProps<{
10
10
  cover_focal_x?: number | null;
11
11
  cover_focal_y?: number | null;
12
12
  game_cover?: string | null;
13
+ game?: { url_cover_src?: string | null; genres?: Array<{ id?: number; name?: string }> } | null;
14
+ game_genres?: string[];
13
15
  available_count: number;
14
16
  is_exclusive?: boolean;
15
17
  is_tier_locked?: boolean;
@@ -28,6 +30,17 @@ const props = defineProps<{
28
30
  baseRoute?: string;
29
31
  }>();
30
32
 
33
+ // Accepts either the normalised game_genres (campaignService) or the raw
34
+ // game.genres straight off the API payload.
35
+ const cardGenres = computed(() => {
36
+ const c = props.card as { game_genres?: string[]; game?: { genres?: Array<{ name?: string }> } | null };
37
+ const names = c.game_genres?.length
38
+ ? c.game_genres
39
+ : (c.game?.genres || []).map((g) => g.name);
40
+
41
+ return names.filter(Boolean).slice(0, 2).join(" • ");
42
+ });
43
+
31
44
  const { $i18n } = useNuxtApp();
32
45
  const locale = $i18n.locale;
33
46
 
@@ -89,10 +102,16 @@ function formatCountdown(availableAt: string | null | undefined): string {
89
102
  return h > 0 ? `${h}h ${m}m` : `${m}m`;
90
103
  }
91
104
 
105
+ // key-campaigns (plural) is the canonical route on every front. The singular
106
+ // used to be a second, standalone copy of this page in shared-ui — worse than
107
+ // the real one (hardcoded English, no embargo handling) and the reason a shared
108
+ // link previewed differently depending on which app served it. It is now a
109
+ // server-level 301, so defaulting here to the singular would only send every
110
+ // card click through a needless redirect.
92
111
  const detailRoute = computed(() =>
93
112
  props.publicMode
94
- ? `/${locale.value}/${props.baseRoute ?? 'key-campaign'}/${props.card.slug}`
95
- : `/${locale.value}/dashboard/${props.baseRoute ?? 'key-campaign'}/${props.card.slug}`
113
+ ? `/${locale.value}/${props.baseRoute ?? 'key-campaigns'}/${props.card.slug}`
114
+ : `/${locale.value}/dashboard/${props.baseRoute ?? 'key-campaigns'}/${props.card.slug}`
96
115
  );
97
116
  </script>
98
117
 
@@ -189,6 +208,7 @@ const detailRoute = computed(() =>
189
208
  </span>
190
209
  </div>
191
210
  <p class="kc-card__name">{{ card.name }}</p>
211
+ <p v-if="cardGenres" class="kc-card__genres">{{ cardGenres }}</p>
192
212
 
193
213
  <div class="kc-card__buttons">
194
214
  <!-- Not accessible -->
@@ -398,6 +418,15 @@ const detailRoute = computed(() =>
398
418
  line-height: 1.3;
399
419
  }
400
420
 
421
+ &__genres {
422
+ font-size: 11px;
423
+ color: var(--secondary-info-fg, #9a9a9a);
424
+ margin: 2px 0 0;
425
+ overflow: hidden;
426
+ text-overflow: ellipsis;
427
+ white-space: nowrap;
428
+ }
429
+
401
430
  &__buttons {
402
431
  display: flex;
403
432
  flex-wrap: wrap;
@@ -15,6 +15,14 @@ const locale = useNuxtApp().$i18n.locale
15
15
  const items = ref<any[]>([])
16
16
  const loading = ref(true)
17
17
 
18
+ // Up to two genres, matching the campaign listing card.
19
+ const genresOf = (item: any) =>
20
+ (item.game?.genres || [])
21
+ .map((g: any) => g.name)
22
+ .filter(Boolean)
23
+ .slice(0, 2)
24
+ .join(" • ")
25
+
18
26
  const detailRoute = (item: any) =>
19
27
  props.publicMode
20
28
  ? `/${locale.value}/${props.baseRoute ?? 'key-campaigns'}/${item.slug}`
@@ -73,6 +81,7 @@ onMounted(async () => {
73
81
  </div>
74
82
  <div class="kc-carousel__info">
75
83
  <div class="kc-carousel__name">{{ item.name }}</div>
84
+ <div v-if="genresOf(item)" class="kc-carousel__genres">{{ genresOf(item) }}</div>
76
85
  <div class="kc-carousel__avail">
77
86
  {{ item.available_count ?? 0 }}/{{ item.total_count ?? item.quantity ?? "?" }}
78
87
  {{ $t("keys.campaigns.available") }}
@@ -185,6 +194,14 @@ onMounted(async () => {
185
194
  white-space: nowrap;
186
195
  }
187
196
 
197
+ &__genres {
198
+ font-size: 11px;
199
+ color: var(--secondary-info-fg);
200
+ overflow: hidden;
201
+ text-overflow: ellipsis;
202
+ white-space: nowrap;
203
+ }
204
+
188
205
  &__avail {
189
206
  font-size: 11px;
190
207
  color: var(--secondary-info-fg);
package/locales/de.json CHANGED
@@ -240,7 +240,7 @@
240
240
  "link": "Content link",
241
241
  "description": "Description (optional)",
242
242
  "date": "Publication date",
243
- "type": "Content type",
243
+ "type": "Inhaltstyp (einer pro Link)",
244
244
  "type_stream": "Live stream",
245
245
  "type_video": "Video",
246
246
  "type_article": "Article / Review",
package/locales/en.json CHANGED
@@ -240,7 +240,7 @@
240
240
  "link": "Content link",
241
241
  "description": "Description (optional)",
242
242
  "date": "Publication date",
243
- "type": "Content type",
243
+ "type": "Content type (one per link)",
244
244
  "type_stream": "Live stream",
245
245
  "type_video": "Video",
246
246
  "type_article": "Article / Review",
package/locales/es.json CHANGED
@@ -240,7 +240,7 @@
240
240
  "link": "Enlace de acceso",
241
241
  "description": "Descripción",
242
242
  "date": "Fecha",
243
- "type": "Tipo",
243
+ "type": "Tipo de contenido (uno por enlace)",
244
244
  "type_stream": "Stream",
245
245
  "type_video": "Vídeo",
246
246
  "type_article": "Artículo",
@@ -240,7 +240,7 @@
240
240
  "link": "Content link",
241
241
  "description": "Description (optional)",
242
242
  "date": "Publication date",
243
- "type": "Content type",
243
+ "type": "Tipo de conteúdo (um por link)",
244
244
  "type_stream": "Live stream",
245
245
  "type_video": "Video",
246
246
  "type_article": "Article / Review",
package/locales/ro.json CHANGED
@@ -240,7 +240,7 @@
240
240
  "link": "Content link",
241
241
  "description": "Description (optional)",
242
242
  "date": "Publication date",
243
- "type": "Content type",
243
+ "type": "Tipul conținutului (unul per link)",
244
244
  "type_stream": "Live stream",
245
245
  "type_video": "Video",
246
246
  "type_article": "Article / Review",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.8.18",
3
+ "version": "1.8.22",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -691,6 +691,12 @@ onMounted(async () => {
691
691
  <div v-else class="content-key">
692
692
  <div class="content-key-header">
693
693
  <h4>{{ campaign?.name }}</h4>
694
+ <!-- Genres come with the campaign payload; the tag list further
695
+ down needs the separate game-details request, which only
696
+ runs when the campaign is linked to a game slug. -->
697
+ <div v-if="campaign?.game_genres?.length" class="campaign-genres">
698
+ {{ campaign.game_genres.slice(0, 3).join(" • ") }}
699
+ </div>
694
700
 
695
701
  <div class="restriction-badges" v-if="restrictionBadges.length">
696
702
  <div class="restriction-badge" v-for="(b, i) in restrictionBadges" :key="i">
@@ -1200,6 +1206,14 @@ onMounted(async () => {
1200
1206
 
1201
1207
  h4 { color: var(--card-article-title); font-size: 24px; font-weight: 700; }
1202
1208
 
1209
+ .campaign-genres {
1210
+ // The header's 18px gap is meant to separate blocks; this line
1211
+ // belongs to the title above it.
1212
+ margin-top: -12px;
1213
+ font-size: 13px;
1214
+ color: var(--secondary-info-fg);
1215
+ }
1216
+
1203
1217
  .restriction-badges {
1204
1218
  display: flex;
1205
1219
  flex-wrap: wrap;
@@ -204,6 +204,7 @@
204
204
 
205
205
  <div class="text">
206
206
  <div class="card-name">{{ card.gameName || card.text }}</div>
207
+ <div v-if="card.genres.length" class="card-genre-row">{{ card.genres.join(" • ") }}</div>
207
208
  <div v-if="card.studioName || card.regions.length" class="card-studio-row">
208
209
  <span v-if="card.studioName" class="card-studio-name">{{ card.studioName }}</span>
209
210
  <span v-if="card.studioName && card.regions.length" class="card-studio-sep">·</span>
@@ -647,6 +648,7 @@ async function loadCampaigns(page = 1) {
647
648
  max_keys_for_user: item.max_keys_for_user || 1,
648
649
  platforms: (item.platforms || []).map((p: any) => p.name),
649
650
  gameName: item.game?.name || null,
651
+ genres: (item.game?.genres || []).map((g: any) => g.name).filter(Boolean).slice(0, 2),
650
652
  studioName: item.company?.name || null,
651
653
  regions: (item.regions || []).map((r: any) => r.name).filter(Boolean),
652
654
  userRequestSlug: null,
@@ -1286,6 +1288,15 @@ onUnmounted(() => {
1286
1288
  color: var(--title-fg);
1287
1289
  }
1288
1290
 
1291
+ .card-genre-row {
1292
+ font-size: 12px;
1293
+ color: var(--secondary-info-fg);
1294
+ font-weight: 400;
1295
+ overflow: hidden;
1296
+ text-overflow: ellipsis;
1297
+ white-space: nowrap;
1298
+ }
1299
+
1289
1300
  .card-studio-row {
1290
1301
  font-size: 12px;
1291
1302
  color: var(--secondary-info-fg);
@@ -288,10 +288,12 @@ async function loadDeadline() {
288
288
  } catch { /* deadline is informational only, ignore failures */ }
289
289
  }
290
290
 
291
+ // One type per submission: each form is a single piece of content, so picking
292
+ // another type replaces the current one instead of stacking. Clicking the
293
+ // selected one clears it.
291
294
  function toggleType(formIndex: number, type: ContentType) {
292
- const currentTypes = forms.value[formIndex].content_types
293
- const idx = currentTypes.indexOf(type)
294
- if (idx === -1) { currentTypes.push(type) } else { currentTypes.splice(idx, 1) }
295
+ const form = forms.value[formIndex]
296
+ form.content_types = form.content_types.includes(type) ? [] : [type]
295
297
  }
296
298
 
297
299
  function toggleBox(boxNumber: number) {
@@ -161,6 +161,17 @@ function formatEmbargo(dateString: string | null | undefined): string {
161
161
  // visit to this page. Creators routinely request a key days before release, so
162
162
  // the acknowledgment they gave once is not enough — the deadline has to be
163
163
  // where they come back to fetch the code.
164
+ const guidelineIsLink = computed(() => {
165
+ const v = (requestData.value?.key?.review_guideline || "").trim()
166
+ return v.startsWith("http://") || v.startsWith("https://")
167
+ })
168
+
169
+ const prContact = computed(() =>
170
+ [requestData.value?.key?.pr_contact_name, requestData.value?.key?.pr_contact_email]
171
+ .filter(Boolean)
172
+ .join(" · "),
173
+ )
174
+
164
175
  const embargoDate = computed<string | null>(() => requestData.value?.key?.embargo_date ?? null)
165
176
  const embargoIsActive = computed(() => isEmbargoActive(embargoDate.value))
166
177
  const embargoMoment = computed(() => formatEmbargo(embargoDate.value))
@@ -330,7 +341,7 @@ function goBack() {
330
341
  </div>
331
342
  </div>
332
343
 
333
- <div class="resources" v-if="requestData?.key?.press_kit_link || requestData?.key?.review_guideline">
344
+ <div class="resources" v-if="requestData?.key?.press_kit_link || requestData?.key?.review_guideline || prContact">
334
345
  <div class="resources-title">{{ $t("keys.campaigns.resources_title") }}</div>
335
346
 
336
347
  <a
@@ -346,7 +357,29 @@ function goBack() {
346
357
 
347
358
  <div v-if="requestData?.key?.review_guideline" class="resource-item">
348
359
  <div class="resource-label">{{ $t("keys.campaigns.review_guideline") }}</div>
349
- <div class="resource-text">{{ requestData.key.review_guideline }}</div>
360
+ <!-- The admin field accepts free text or a link to a
361
+ document; render a link as a link rather than as a
362
+ URL the reader has to copy by hand. -->
363
+ <div class="resource-text">
364
+ <a
365
+ v-if="guidelineIsLink"
366
+ :href="requestData.key.review_guideline"
367
+ target="_blank"
368
+ rel="noopener"
369
+ >{{ requestData.key.review_guideline }}</a>
370
+ <template v-else>{{ requestData.key.review_guideline }}</template>
371
+ </div>
372
+ </div>
373
+
374
+ <!-- Who to ask when something is wrong with the key or the
375
+ coverage terms. The data was already on the campaign;
376
+ this screen just never showed it. -->
377
+ <div v-if="prContact" class="resource-item">
378
+ <div class="resource-label">{{ $t("keys.campaigns.pr_contact") }}</div>
379
+ <div class="resource-text">
380
+ <a v-if="requestData?.key?.pr_contact_email" :href="`mailto:${requestData.key.pr_contact_email}`">{{ prContact }}</a>
381
+ <template v-else>{{ prContact }}</template>
382
+ </div>
350
383
  </div>
351
384
  </div>
352
385
  </div>
@@ -595,8 +628,36 @@ function goBack() {
595
628
  gap: 8px;
596
629
  font-size: 13px;
597
630
  cursor: pointer;
631
+ // Inherited nothing usable and rendered near-black on the dark
632
+ // background — the one thing the user has to read before agreeing
633
+ // to it. Same token the warning above uses, which is legible in
634
+ // both themes.
635
+ color: var(--text-secondary, #ccc);
598
636
 
599
- input { margin-top: 2px; }
637
+ input {
638
+ margin-top: 2px;
639
+ flex-shrink: 0;
640
+ accent-color: var(--danger, #e53935);
641
+ }
642
+ }
643
+
644
+ // .btn carries no local styling here (only .code-box button does), so
645
+ // the confirm button came out unstyled and unreadable.
646
+ .btn {
647
+ align-self: flex-start;
648
+ padding: 10px 18px;
649
+ border: none;
650
+ cursor: pointer;
651
+ font-size: 14px;
652
+ font-weight: 600;
653
+ background-color: var(--key-accent, var(--primary, #D297FF));
654
+ color: var(--key-accent-ink, #13161C);
655
+
656
+ &.disabled,
657
+ &:disabled {
658
+ opacity: 0.5;
659
+ cursor: not-allowed;
660
+ }
600
661
  }
601
662
  }
602
663
 
@@ -1,5 +1,5 @@
1
1
  <script lang="ts" setup>
2
- import { ref, computed, inject, onMounted, unref, watch } from "vue"
2
+ import { ref, computed, inject, onMounted, onUnmounted, unref, watch } from "vue"
3
3
  import { useRoute, navigateTo } from "#app"
4
4
  import { useNuxtApp } from "#app"
5
5
  import {
@@ -51,6 +51,13 @@ const embargoAckChecked = ref(false)
51
51
 
52
52
  const maxKeysForUser = computed(() => campaign.value?.max_keys_for_user ?? 1)
53
53
 
54
+ // Where the success screen sends the user back to. Falls back to the id the
55
+ // page was opened with, which fetchCampaignBySlug also accepts.
56
+ const campaignPath = computed(
57
+ () => `/${locale.value}/key-campaigns/${campaign.value?.slug || keyId}`,
58
+ )
59
+ let redirectTimer: ReturnType<typeof setTimeout> | null = null
60
+
54
61
  const embargoDateTime = computed(() =>
55
62
  formatEmbargoDateTime(campaign.value?.embargo_date, campaign.value?.embargo_timezone, locale.value),
56
63
  )
@@ -196,6 +203,13 @@ async function submit() {
196
203
  return
197
204
  }
198
205
  success.value = true
206
+ // The success screen says "Redirecting…", so actually redirect: it used
207
+ // to sit there forever, leaving the user staring at a promise that never
208
+ // resolved with only a manual back button to escape.
209
+ redirectTimer = setTimeout(() => {
210
+ redirectTimer = null
211
+ navigateTo(campaignPath.value)
212
+ }, 2000)
199
213
  } catch (e: any) {
200
214
  const status = e?.response?.status
201
215
  const errorCode = e?.response?.data?.error_code
@@ -223,12 +237,25 @@ async function submit() {
223
237
  }
224
238
 
225
239
  function goBack() {
240
+ // Tapping the button beats the pending auto-redirect to the punch.
241
+ if (redirectTimer) {
242
+ clearTimeout(redirectTimer)
243
+ redirectTimer = null
244
+ }
245
+ if (success.value) {
246
+ navigateTo(campaignPath.value)
247
+ return
248
+ }
226
249
  if (typeof window !== "undefined" && window.history.length > 1) {
227
250
  window.history.back()
228
251
  } else {
229
252
  navigateTo(`/${locale.value}/key-campaigns`)
230
253
  }
231
254
  }
255
+
256
+ onUnmounted(() => {
257
+ if (redirectTimer) clearTimeout(redirectTimer)
258
+ })
232
259
  </script>
233
260
 
234
261
  <template>
@@ -505,6 +532,16 @@ function goBack() {
505
532
  font-size: 13px;
506
533
  line-height: 1.4;
507
534
  cursor: pointer;
535
+ // Same omission as the reveal screen: no colour meant the text the
536
+ // user has to agree to inherited whatever the surrounding card left
537
+ // behind, and came out unreadable on dark.
538
+ color: var(--text-secondary, #ccc);
539
+
540
+ input {
541
+ flex-shrink: 0;
542
+ margin-top: 2px;
543
+ accent-color: var(--key-accent, var(--primary, #D297FF));
544
+ }
508
545
  }
509
546
  }
510
547
 
@@ -83,6 +83,29 @@ const hasEcosystem = computed(() => showcases.value.length || kit.value?.venture
83
83
  const screenshots = computed(() => assets.value.filter((a: any) => ['screenshot', 'artwork', 'gif'].includes(a.type)));
84
84
  const brandAssets = computed(() => assets.value.filter((a: any) => ['logo', 'document'].includes(a.type)));
85
85
 
86
+ // Animated assets get the full gallery row at their natural height — cropping a
87
+ // gameplay loop into the 16/9 thumbnail grid makes it unreadable. The type is
88
+ // what the uploader sets; the extension check catches assets imported before
89
+ // the `gif` type existed.
90
+ const isAnimatedAsset = (a: any) => a?.type === 'gif' || /\.gif(\?|#|$)/i.test(a?.url || '');
91
+ // Never fall back to thumbnail_url for an animated asset: the thumbnail is a
92
+ // still frame, so the GIF would silently stop animating.
93
+ const shotSrc = (a: any) => (isAnimatedAsset(a) ? a.url : (a.thumbnail_url || a.url));
94
+
95
+ // ── Steam store widget ──────────────────────────────────────────────────────
96
+ // Derived from the steam_url the studio already filled in, so every published
97
+ // kit with a Steam link gets the widget with no extra field to configure. The
98
+ // iframe src is built from the parsed app id — never from raw user input.
99
+ const steamAppId = computed(() => {
100
+ const explicit = kit.value?.steam_app_id;
101
+ if (explicit && /^\d+$/.test(String(explicit))) return String(explicit);
102
+ const match = String(kit.value?.steam_url || '').match(/\/app\/(\d+)/);
103
+ return match ? match[1] : '';
104
+ });
105
+ const steamWidgetUrl = computed(() => (steamAppId.value
106
+ ? `https://store.steampowered.com/widget/${steamAppId.value}/`
107
+ : ''));
108
+
86
109
  const tpl = computed(() => kit.value?.theme || null);
87
110
  const layout = computed(() => kit.value?.layout || 'classic');
88
111
  const allowDownload = computed(() => kit.value?.allow_asset_download !== false);
@@ -213,6 +236,7 @@ const navSections = computed(() => {
213
236
  const s: { id: string; label: string }[] = [];
214
237
  s.push({ id: 'overview', label: 'kit.press.about' });
215
238
  s.push({ id: 'factsheet', label: 'kit.press.fact_sheet' });
239
+ if (steamAppId.value) s.push({ id: 'steam', label: 'kit.press.steam_widget' });
216
240
  if (screenshots.value.length || brandAssets.value.length) s.push({ id: 'media', label: 'kit.press.screenshots' });
217
241
  if (videos.value.length) s.push({ id: 'trailers', label: 'kit.press.videos' });
218
242
  if (articles.value.length) s.push({ id: 'articles', label: 'kit.press.articles' });
@@ -405,6 +429,22 @@ useHead(() => ({
405
429
  <div class="kit-lead" v-html="kit.description" />
406
430
  </section>
407
431
 
432
+ <section v-if="steamWidgetUrl" id="steam" class="kit-block">
433
+ <h2>{{ $t('kit.press.steam_widget') }}</h2>
434
+ <div class="kit-steam">
435
+ <iframe
436
+ class="kit-steam__frame"
437
+ :src="steamWidgetUrl"
438
+ :title="`Steam — ${kit.name}`"
439
+ frameborder="0"
440
+ scrolling="no"
441
+ loading="lazy"
442
+ referrerpolicy="no-referrer-when-downgrade"
443
+ sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"
444
+ />
445
+ </div>
446
+ </section>
447
+
408
448
  <section v-if="features.length" class="kit-block">
409
449
  <h2>{{ $t('kit.press.features') }}</h2>
410
450
  <div class="kit-features">
@@ -421,7 +461,18 @@ useHead(() => ({
421
461
  <div v-if="screenshots.length">
422
462
  <div class="kit-shead"><h2>{{ $t('kit.press.screenshots') }}</h2><a v-if="canDownload" class="kit-dl" @click="track('download')">{{ $t('kit.press.download_all') }}</a></div>
423
463
  <div class="kit-gallery">
424
- <a v-for="(s, i) in screenshots" :key="i" class="kit-shot" :href="s.url" target="_blank" rel="noopener"><img :src="s.thumbnail_url || s.url" :alt="s.title || ''" /></a>
464
+ <a
465
+ v-for="(s, i) in screenshots"
466
+ :key="i"
467
+ class="kit-shot"
468
+ :class="{ 'kit-shot--wide': isAnimatedAsset(s) }"
469
+ :href="s.url"
470
+ target="_blank"
471
+ rel="noopener"
472
+ >
473
+ <img :src="shotSrc(s)" :alt="s.title || ''" loading="lazy" />
474
+ <span v-if="isAnimatedAsset(s)" class="kit-shot__tag">GIF</span>
475
+ </a>
425
476
  </div>
426
477
  </div>
427
478
  <div v-if="brandAssets.length" :class="{ 'kit-block-inner': screenshots.length }">
@@ -376,6 +376,9 @@ export const fetchCampaignBySlug = async (slug: string) => {
376
376
  cover_focal_y: k.cover_focal_y ?? 0.5,
377
377
  game_cover: k.game?.url_cover_src,
378
378
  game_slug: k.game?.slug,
379
+ game_genres: ((k.game?.genres || []) as Array<{ name?: string }>)
380
+ .map((g) => g.name)
381
+ .filter(Boolean) as string[],
379
382
  time_to_expire: k.time_to_expire,
380
383
  request_status: k.request_user_status,
381
384
  end: k.end_date,
@@ -1,164 +0,0 @@
1
- <script setup lang="ts">
2
- // Public, shareable standalone key-campaign page. Reached from broadcast links
3
- // (Discord/Telegram/WhatsApp) and from anywhere — no need to enter a dashboard.
4
- // Anyone can view; requesting a key uses KeyBrowser's hybrid auth (it redirects
5
- // to login on 401 and returns).
6
- definePageMeta({ layout: 'blank' });
7
-
8
- const route = useRoute();
9
- const slug = route.params.slug as string;
10
-
11
- const SUPPORTED_LOCALES = ['en', 'pt-BR', 'es', 'de', 'ro', 'ar-AE'];
12
- const routePath = route.path.split('/');
13
- const lang = SUPPORTED_LOCALES.find(l => routePath.includes(l)) || 'en';
14
-
15
- const config = useRuntimeConfig();
16
- // keyApiUrl overrides apiBaseURL — lets each front point to the API that
17
- // registered the shared key routes.
18
- // Follow the same override pattern as presskit/game/[slug].vue.
19
- const cfg = (config.public as any)?.mgSharedUi || config.public;
20
- const withApiV1 = (base?: string) => {
21
- if (!base) return '';
22
- const clean = base.replace(/\/$/, '');
23
- return /\/api\/v1$/.test(clean) ? clean : `${clean}/api/v1`;
24
- };
25
- const apiBase: string = withApiV1(
26
- (config.public as any).keyApiUrl ||
27
- cfg.keyApiUrl ||
28
- (config.public.apiBaseURL as string) ||
29
- '',
30
- );
31
-
32
- const { data: keyData, error: fetchError, pending, refresh } = await useAsyncData(
33
- `public-key-${slug}`,
34
- () => $fetch<any>(`${apiBase}/public/keys/${slug}`, { params: { lang } }),
35
- { lazy: false, server: !!apiBase },
36
- );
37
-
38
- // If SSR produced no data (e.g. apiBase was empty during SSR), run a client-side
39
- // fetch once mounted so the request appears in DevTools and the page can recover.
40
- onMounted(async () => {
41
- if (!keyData.value && !pending.value) {
42
- await refresh();
43
- }
44
- });
45
-
46
- const key = computed(() => keyData.value?.data || null);
47
- const loading = pending;
48
- const error = computed(() => !!fetchError.value || (!pending.value && !key.value));
49
-
50
- const { $i18n } = useNuxtApp()
51
- const t = $i18n.t.bind($i18n)
52
-
53
- useSeoMeta({
54
- title: () => key.value?.name ? t('keys.campaigns.seo.title', { game: key.value.name }) : t('keys.campaigns.seo.title_generic'),
55
- description: () => key.value?.name ? t('keys.campaigns.seo.description', { game: key.value.name }) : t('keys.campaigns.seo.title_generic'),
56
- ogTitle: () => key.value?.name ? t('keys.campaigns.seo.title', { game: key.value.name }) : t('keys.campaigns.seo.title_generic'),
57
- ogDescription: () => key.value?.name ? t('keys.campaigns.seo.description', { game: key.value.name }) : t('keys.campaigns.seo.title_generic'),
58
- ogImage: () => key.value?.cover_src || key.value?.cover || undefined,
59
- ogType: 'website',
60
- twitterCard: 'summary_large_image',
61
- })
62
-
63
- const restriction = computed(() => {
64
- const k = key.value;
65
- if (!k) return '';
66
- if (k.is_exclusive) return 'Exclusive to official streamers';
67
- if (k.is_tier_locked && k.access_tier && k.access_tier !== 'free') return `Exclusive to ${String(k.access_tier).toUpperCase()} plan or higher`;
68
- if (k.streamer_type_id === 2) return 'Exclusive to official streamers';
69
- if (k.streamer_type_id === 3) return 'Exclusive to affiliates';
70
- return '';
71
- });
72
- </script>
73
-
74
- <template>
75
- <div class="kc">
76
- <div v-if="loading" class="kc__state">Loading…</div>
77
- <div v-else-if="error" class="kc__state">Campaign not found.</div>
78
-
79
- <template v-else>
80
- <div class="kc__hero">
81
- <img
82
- v-if="key.cover_src"
83
- :src="key.cover_src"
84
- :alt="key.name"
85
- class="kc__cover"
86
- :style="{ objectPosition: `${(key.cover_focal_x ?? 0.5) * 100}% ${(key.cover_focal_y ?? 0.5) * 100}%` }"
87
- >
88
- <div class="kc__hero-body">
89
- <h1 class="kc__title">{{ key.name }}</h1>
90
- <div v-if="restriction" class="kc__badge">🔒 {{ restriction }}</div>
91
- <div class="kc__avail">
92
- <span v-if="key.available_count > 0">{{ key.available_count }} {{ key.available_count === 1 ? 'key' : 'keys' }} available to redeem</span>
93
- <span v-else>Keys sold out</span>
94
- </div>
95
- </div>
96
- </div>
97
-
98
- <div class="kc__browser">
99
- <KeyBrowser
100
- requester-context="user"
101
- :game-id="key.game?.id || key.game_id"
102
- />
103
- </div>
104
-
105
- <KeyCampaignsCarousel :exclude-id="key.id ?? null" :public-mode="true" base-route="key-campaign" />
106
- </template>
107
- </div>
108
- </template>
109
-
110
- <style scoped lang="scss">
111
- .kc {
112
- max-width: 960px;
113
- margin: 0 auto;
114
- padding: 24px 16px 64px;
115
-
116
- &__state {
117
- padding: 80px 0;
118
- text-align: center;
119
- color: var(--text-secondary, #888);
120
- }
121
-
122
- &__hero {
123
- display: flex;
124
- gap: 20px;
125
- align-items: flex-start;
126
- margin-bottom: 28px;
127
- flex-wrap: wrap;
128
- }
129
-
130
- &__cover {
131
- width: 260px;
132
- max-width: 100%;
133
- height: auto;
134
- object-fit: cover;
135
- border: 1px solid var(--border-color, #2a2a2a);
136
- }
137
-
138
- &__hero-body { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: 10px; }
139
-
140
- &__title {
141
- font-size: 1.6rem;
142
- font-weight: 700;
143
- color: var(--text-primary, #fff);
144
- margin: 0;
145
- }
146
-
147
- &__badge {
148
- display: inline-flex;
149
- align-items: center;
150
- gap: 6px;
151
- width: fit-content;
152
- padding: 4px 10px;
153
- font-size: 0.8rem;
154
- font-weight: 600;
155
- color: var(--key-accent, var(--primary, #D297FF));
156
- border: 1px solid rgba(210, 151, 255, 0.35);
157
- background: rgba(210, 151, 255, 0.12);
158
- }
159
-
160
- &__avail { font-size: 0.9rem; color: var(--text-secondary, #aaa); }
161
-
162
- &__browser { margin-top: 8px; }
163
- }
164
- </style>
@@ -1,67 +0,0 @@
1
- // Hoists the social/SEO tags to the top of <head> on every front that extends
2
- // this layer.
3
- //
4
- // Why this exists (measured in production 2026-08-02): unhead 2.x applies Capo
5
- // sorting by default, and its weight table (unhead/dist/shared/*.mjs) gives
6
- // sync <style> a weight of 60 while a generic <meta> gets the default 100 —
7
- // so *every* inline stylesheet is emitted before any og:*/twitter:* tag. With
8
- // Nuxt's `features.inlineStyles` on (its default) that meant ~545KB of inlined
9
- // CSS ahead of the OG tags, pushing them to byte 384.000-553.000 across all six
10
- // fronts. Link-preview scrapers (WhatsApp, Facebook, X, LinkedIn, Telegram)
11
- // truncate the HTML they fetch well before that and stop at </head>, so none of
12
- // them ever saw an og:image — only Discord, which reads the full document, did.
13
- //
14
- // `features.inlineStyles: false` in each app's nuxt.config.ts is the primary
15
- // fix; this plugin is the belt-and-braces so the problem cannot silently come
16
- // back if some future component or config re-introduces a large inline <style>.
17
- // Doing it here rather than per-page also covers the 100+ pages across the six
18
- // fronts that set their own og tags, plus every page added from now on.
19
- //
20
- // -5 sits above <style> (60), <link rel=stylesheet> (60) and <title> (10), but
21
- // still below <meta charset> (-20), <meta name=viewport> (-15) and the CSP meta
22
- // (-30), so charset stays inside the first 1024 bytes as the spec requires.
23
- const SEO_TAG_PRIORITY = -5;
24
-
25
- function isSeoTag(tag: { tag: string; props?: Record<string, any> }): boolean {
26
- const props = tag.props || {};
27
-
28
- if (tag.tag === "meta") {
29
- const key = props.property ?? props.name;
30
- return (
31
- typeof key === "string"
32
- && (key.startsWith("og:") || key.startsWith("twitter:") || key === "description")
33
- );
34
- }
35
-
36
- // The canonical rides along: it is cheap and some scrapers resolve the
37
- // preview against it rather than against the requested URL.
38
- return tag.tag === "link" && props.rel === "canonical";
39
- }
40
-
41
- export default defineNuxtPlugin({
42
- name: "shared-ui-seo-tag-priority",
43
- // Must run before any page's useHead/useSeoMeta entry is normalized.
44
- enforce: "pre",
45
- setup(nuxtApp) {
46
- const head = injectHead(nuxtApp as any);
47
- if (!head) return;
48
-
49
- head.use({
50
- key: "shared-ui-seo-tag-priority",
51
- hooks: {
52
- // `entries:normalize` is the correct seam: unhead computes each
53
- // tag's weight (`t._w = tagWeight(head, t)`) immediately *after*
54
- // this hook returns, and `tagWeight` short-circuits on a numeric
55
- // `tagPriority`. Mutating in `tags:resolve` instead would be too
56
- // late — the sort has already run by then.
57
- "entries:normalize": ({ tags }: { tags: any[] }) => {
58
- for (const tag of tags) {
59
- // Never override an explicit priority set by a page.
60
- if (tag.tagPriority !== undefined) continue;
61
- if (isSeoTag(tag)) tag.tagPriority = SEO_TAG_PRIORITY;
62
- }
63
- },
64
- },
65
- });
66
- },
67
- });