@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.
- package/assets/kit/kit-base.css +58 -0
- package/components/keys/KeyCampaignDashboardCard.vue +31 -2
- 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 +38 -1
- package/pages/presskit/game/[slug].vue +52 -1
- package/services/campaignService.ts +3 -0
- package/pages/key-campaign/[slug].vue +0 -164
- 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
|
|
|
@@ -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-
|
|
95
|
-
: `/${locale.value}/dashboard/${props.baseRoute ?? 'key-
|
|
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": "
|
|
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
|
|
|
@@ -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
|
|
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
|
-
});
|