@mundogamernetwork/shared-ui 1.8.19 → 1.8.23

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;
@@ -399,7 +399,7 @@ function statusColor(slug: string): string {
399
399
  &__auth-wall {
400
400
  padding: 40px;
401
401
  text-align: center;
402
- color: var(--text-secondary, #888);
402
+ color: var(--secondary-info-fg);
403
403
  display: flex;
404
404
  flex-direction: column;
405
405
  align-items: center;
@@ -430,7 +430,7 @@ function statusColor(slug: string): string {
430
430
  background: none;
431
431
  border: none;
432
432
  border-bottom: 2px solid transparent;
433
- color: var(--text-secondary, #888);
433
+ color: var(--secondary-info-fg);
434
434
  cursor: pointer;
435
435
  display: flex;
436
436
  align-items: center;
@@ -455,7 +455,7 @@ function statusColor(slug: string): string {
455
455
  &__empty {
456
456
  padding: 40px;
457
457
  text-align: center;
458
- color: var(--text-secondary, #888);
458
+ color: var(--secondary-info-fg);
459
459
  font-size: 0.9rem;
460
460
  }
461
461
 
@@ -484,13 +484,13 @@ function statusColor(slug: string): string {
484
484
 
485
485
  &__pool-availability { font-size: 0.82rem; }
486
486
  &__avail-ok { color: var(--success, #22c55e); }
487
- &__avail-empty { color: var(--text-secondary, #888); }
487
+ &__avail-empty { color: var(--secondary-info-fg); }
488
488
 
489
489
  &__platform-sel { display: flex; flex-direction: column; gap: 6px; }
490
490
 
491
491
  &__label {
492
492
  font-size: 0.75rem;
493
- color: var(--text-secondary, #888);
493
+ color: var(--secondary-info-fg);
494
494
  }
495
495
 
496
496
  &__platform-chips {
@@ -503,7 +503,7 @@ function statusColor(slug: string): string {
503
503
  padding: 4px 10px;
504
504
  font-size: 0.78rem;
505
505
  border: 1px solid var(--border-color, #2a2a2a);
506
- color: var(--text-secondary, #888);
506
+ color: var(--secondary-info-fg);
507
507
  cursor: pointer;
508
508
  transition: border-color 0.15s, color 0.15s;
509
509
 
@@ -577,7 +577,7 @@ function statusColor(slug: string): string {
577
577
 
578
578
  &__request-platform {
579
579
  font-size: 0.78rem;
580
- color: var(--text-secondary, #888);
580
+ color: var(--secondary-info-fg);
581
581
  }
582
582
 
583
583
  &__status-chip {
@@ -613,7 +613,7 @@ function statusColor(slug: string): string {
613
613
 
614
614
  &__pending-label {
615
615
  font-size: 0.8rem;
616
- color: var(--text-secondary, #888);
616
+ color: var(--secondary-info-fg);
617
617
  font-style: italic;
618
618
  }
619
619
 
@@ -647,7 +647,7 @@ function statusColor(slug: string): string {
647
647
  p {
648
648
  margin: 0;
649
649
  font-size: 0.82rem;
650
- color: var(--text-secondary, #888);
650
+ color: var(--secondary-info-fg);
651
651
  line-height: 1.4;
652
652
  }
653
653
  }
@@ -664,7 +664,7 @@ function statusColor(slug: string): string {
664
664
  font-weight: 600;
665
665
  border: 1px solid var(--border-color, #2a2a2a);
666
666
  background: none;
667
- color: var(--text-secondary, #888);
667
+ color: var(--secondary-info-fg);
668
668
  cursor: pointer;
669
669
  transition: opacity 0.15s, border-color 0.15s, color 0.15s;
670
670
 
@@ -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
 
@@ -195,6 +208,7 @@ const detailRoute = computed(() =>
195
208
  </span>
196
209
  </div>
197
210
  <p class="kc-card__name">{{ card.name }}</p>
211
+ <p v-if="cardGenres" class="kc-card__genres">{{ cardGenres }}</p>
198
212
 
199
213
  <div class="kc-card__buttons">
200
214
  <!-- Not accessible -->
@@ -356,7 +370,7 @@ const detailRoute = computed(() =>
356
370
 
357
371
  &__days {
358
372
  font-size: 11px;
359
- color: var(--text-secondary, #aaa);
373
+ color: var(--secondary-info-fg);
360
374
  }
361
375
 
362
376
  &__type {
@@ -387,7 +401,7 @@ const detailRoute = computed(() =>
387
401
  font-weight: 600;
388
402
  padding: 2px 6px;
389
403
  border: 1px solid var(--border-color, rgba(255,255,255,0.2));
390
- color: var(--text-secondary, #aaa);
404
+ color: var(--secondary-info-fg);
391
405
  text-transform: uppercase;
392
406
  letter-spacing: 0.4px;
393
407
  }
@@ -404,6 +418,15 @@ const detailRoute = computed(() =>
404
418
  line-height: 1.3;
405
419
  }
406
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
+
407
430
  &__buttons {
408
431
  display: flex;
409
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);
@@ -77,7 +77,7 @@ function copyCode() {
77
77
 
78
78
  &__sub {
79
79
  font-size: 0.88rem;
80
- color: var(--text-secondary, #888);
80
+ color: var(--secondary-info-fg);
81
81
  margin: 0;
82
82
  }
83
83
 
@@ -112,7 +112,7 @@ function copyCode() {
112
112
  &__copy {
113
113
  background: none;
114
114
  border: none;
115
- color: var(--text-secondary, #888);
115
+ color: var(--secondary-info-fg);
116
116
  cursor: pointer;
117
117
  font-size: 1.1rem;
118
118
  padding: 4px;
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.19",
3
+ "version": "1.8.23",
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) {
@@ -304,9 +306,9 @@ function toggleBox(boxNumber: number) {
304
306
  function goBack() {
305
307
  if (props.goBackPage) {
306
308
  window.location.href = props.goBackPage
307
- } else {
308
- window.history.back()
309
+ return
309
310
  }
311
+ goBackWithin("/key-campaigns")
310
312
  }
311
313
 
312
314
  function handleSuccessClose() {
@@ -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))
@@ -180,11 +191,7 @@ function goToLogin() {
180
191
  }
181
192
 
182
193
  function goBack() {
183
- if (typeof window !== "undefined" && window.history.length > 1) {
184
- window.history.back()
185
- } else {
186
- navigateTo(`/${locale.value}/key-campaigns`)
187
- }
194
+ goBackWithin(`/${locale.value}/key-campaigns`)
188
195
  }
189
196
  </script>
190
197
 
@@ -330,7 +337,7 @@ function goBack() {
330
337
  </div>
331
338
  </div>
332
339
 
333
- <div class="resources" v-if="requestData?.key?.press_kit_link || requestData?.key?.review_guideline">
340
+ <div class="resources" v-if="requestData?.key?.press_kit_link || requestData?.key?.review_guideline || prContact">
334
341
  <div class="resources-title">{{ $t("keys.campaigns.resources_title") }}</div>
335
342
 
336
343
  <a
@@ -346,7 +353,29 @@ function goBack() {
346
353
 
347
354
  <div v-if="requestData?.key?.review_guideline" class="resource-item">
348
355
  <div class="resource-label">{{ $t("keys.campaigns.review_guideline") }}</div>
349
- <div class="resource-text">{{ requestData.key.review_guideline }}</div>
356
+ <!-- The admin field accepts free text or a link to a
357
+ document; render a link as a link rather than as a
358
+ URL the reader has to copy by hand. -->
359
+ <div class="resource-text">
360
+ <a
361
+ v-if="guidelineIsLink"
362
+ :href="requestData.key.review_guideline"
363
+ target="_blank"
364
+ rel="noopener"
365
+ >{{ requestData.key.review_guideline }}</a>
366
+ <template v-else>{{ requestData.key.review_guideline }}</template>
367
+ </div>
368
+ </div>
369
+
370
+ <!-- Who to ask when something is wrong with the key or the
371
+ coverage terms. The data was already on the campaign;
372
+ this screen just never showed it. -->
373
+ <div v-if="prContact" class="resource-item">
374
+ <div class="resource-label">{{ $t("keys.campaigns.pr_contact") }}</div>
375
+ <div class="resource-text">
376
+ <a v-if="requestData?.key?.pr_contact_email" :href="`mailto:${requestData.key.pr_contact_email}`">{{ prContact }}</a>
377
+ <template v-else>{{ prContact }}</template>
378
+ </div>
350
379
  </div>
351
380
  </div>
352
381
  </div>
@@ -547,7 +576,7 @@ function goBack() {
547
576
 
548
577
  .embargo-warning-text {
549
578
  strong { display: block; color: var(--danger, #e53935); font-size: 14px; margin-bottom: 4px; }
550
- p { margin: 0; font-size: 13px; color: var(--text-secondary, #ccc); }
579
+ p { margin: 0; font-size: 13px; color: var(--secondary-info-fg); }
551
580
 
552
581
  .embargo-countdown {
553
582
  margin-top: 4px;
@@ -595,8 +624,37 @@ function goBack() {
595
624
  gap: 8px;
596
625
  font-size: 13px;
597
626
  cursor: pointer;
627
+ // The one thing the user has to read before agreeing to it. This
628
+ // used to reference --text-secondary, which is defined nowhere in
629
+ // the ecosystem, so it always fell back to #ccc — fine on dark,
630
+ // invisible on the light-mode pink. --secondary-info-fg is the real
631
+ // token and is themed in both _light.scss and _dark.scss.
632
+ color: var(--secondary-info-fg);
633
+
634
+ input {
635
+ margin-top: 2px;
636
+ flex-shrink: 0;
637
+ accent-color: var(--danger, #e53935);
638
+ }
639
+ }
598
640
 
599
- input { margin-top: 2px; }
641
+ // .btn carries no local styling here (only .code-box button does), so
642
+ // the confirm button came out unstyled and unreadable.
643
+ .btn {
644
+ align-self: flex-start;
645
+ padding: 10px 18px;
646
+ border: none;
647
+ cursor: pointer;
648
+ font-size: 14px;
649
+ font-weight: 600;
650
+ background-color: var(--key-accent, var(--primary, #D297FF));
651
+ color: var(--key-accent-ink, #13161C);
652
+
653
+ &.disabled,
654
+ &:disabled {
655
+ opacity: 0.5;
656
+ cursor: not-allowed;
657
+ }
600
658
  }
601
659
  }
602
660
 
@@ -246,11 +246,7 @@ function goBack() {
246
246
  navigateTo(campaignPath.value)
247
247
  return
248
248
  }
249
- if (typeof window !== "undefined" && window.history.length > 1) {
250
- window.history.back()
251
- } else {
252
- navigateTo(`/${locale.value}/key-campaigns`)
253
- }
249
+ goBackWithin(`/${locale.value}/key-campaigns`)
254
250
  }
255
251
 
256
252
  onUnmounted(() => {
@@ -532,6 +528,16 @@ onUnmounted(() => {
532
528
  font-size: 13px;
533
529
  line-height: 1.4;
534
530
  cursor: pointer;
531
+ // Same omission as the reveal screen: no colour meant the text the
532
+ // user has to agree to inherited whatever the surrounding card left
533
+ // behind, and came out unreadable on dark.
534
+ color: var(--secondary-info-fg);
535
+
536
+ input {
537
+ flex-shrink: 0;
538
+ margin-top: 2px;
539
+ accent-color: var(--key-accent, var(--primary, #D297FF));
540
+ }
535
541
  }
536
542
  }
537
543
 
@@ -121,7 +121,7 @@ onMounted(() => fetchCampaigns())
121
121
 
122
122
  &__loading, &__empty {
123
123
  text-align: center;
124
- color: var(--text-secondary, #aaa);
124
+ color: var(--secondary-info-fg);
125
125
  padding: 3rem 0;
126
126
  font-size: 0.9rem;
127
127
  }
@@ -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,
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Go back without falling out of the app.
3
+ *
4
+ * `window.history.length > 1` is the wrong test: it counts the whole tab
5
+ * session, so a user who landed here from a Google result has length 2 and
6
+ * `history.back()` returns them to Google. Same for an email link, a Discord
7
+ * link, or anything else external.
8
+ *
9
+ * Vue Router records the previous in-app entry in `history.state.back`. It is
10
+ * only set when the navigation happened inside the SPA, which is exactly the
11
+ * condition under which going back is safe. Anything else falls through to an
12
+ * explicit route.
13
+ */
14
+ export function goBackWithin(fallbackPath: string): void {
15
+ if (typeof window === "undefined") return
16
+
17
+ const router = useRouter()
18
+ const previous = (router.options.history.state as Record<string, unknown> | undefined)?.back
19
+
20
+ if (typeof previous === "string" && previous.length > 0) {
21
+ router.back()
22
+ return
23
+ }
24
+
25
+ navigateTo(fallbackPath)
26
+ }
@@ -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
- });