@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.
- package/assets/kit/kit-base.css +58 -0
- package/components/keys/KeyBrowser.vue +10 -10
- package/components/keys/KeyCampaignDashboardCard.vue +25 -2
- package/components/keys/KeyCampaignsCarousel.vue +17 -0
- package/components/keys/KeyRevealModal.vue +2 -2
- package/locales/de.json +1 -1
- package/locales/en.json +1 -1
- package/locales/es.json +1 -1
- package/locales/pt-BR.json +1 -1
- package/locales/ro.json +1 -1
- package/package.json +1 -1
- package/pages/key-campaigns/[slug].vue +14 -0
- package/pages/key-campaigns/index.vue +11 -0
- package/pages/key-campaigns/key-materials.vue +7 -5
- package/pages/key-campaigns/redeem-key-approved.vue +67 -9
- package/pages/key-campaigns/redeem-key.vue +11 -5
- package/pages/keys/index.vue +1 -1
- package/pages/presskit/game/[slug].vue +52 -1
- package/services/campaignService.ts +3 -0
- package/utils/navigation.ts +26 -0
- package/plugins/seo-tag-priority.ts +0 -67
package/assets/kit/kit-base.css
CHANGED
|
@@ -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(--
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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": "
|
|
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
package/locales/pt-BR.json
CHANGED
|
@@ -240,7 +240,7 @@
|
|
|
240
240
|
"link": "Content link",
|
|
241
241
|
"description": "Description (optional)",
|
|
242
242
|
"date": "Publication date",
|
|
243
|
-
"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": "
|
|
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
|
@@ -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
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(--
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/pages/keys/index.vue
CHANGED
|
@@ -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
|
|
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
|
-
});
|