@mundogamernetwork/shared-ui 1.8.19 → 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.
- package/assets/kit/kit-base.css +58 -0
- package/components/keys/KeyCampaignDashboardCard.vue +23 -0
- package/components/keys/KeyCampaignsCarousel.vue +17 -0
- 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 +5 -3
- package/pages/key-campaigns/redeem-key-approved.vue +64 -3
- package/pages/key-campaigns/redeem-key.vue +10 -0
- package/pages/presskit/game/[slug].vue +52 -1
- package/services/campaignService.ts +3 -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;
|
|
@@ -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 -->
|
|
@@ -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);
|
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) {
|
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
|
@@ -532,6 +532,16 @@ onUnmounted(() => {
|
|
|
532
532
|
font-size: 13px;
|
|
533
533
|
line-height: 1.4;
|
|
534
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
|
+
}
|
|
535
545
|
}
|
|
536
546
|
}
|
|
537
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
|
|
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,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
|
-
});
|