@mundogamernetwork/shared-ui 1.8.17 → 1.8.19
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/components/MediaKit/MgRateCardBundleEditor.vue +2 -2
- package/components/MediaKit/MgRateCardEditor.vue +12 -12
- package/components/PressKit/AssetGallery.vue +2 -2
- package/components/PressKit/Awards.vue +1 -1
- package/components/PressKit/Hero.vue +1 -1
- package/components/PressKit/VideoPlayer.vue +1 -1
- package/components/keys/KeyCampaignDashboardCard.vue +16 -10
- package/components/playtest/PlaytestBrief.vue +1 -1
- package/components/playtest/PlaytestPendingConfirmation.vue +2 -2
- package/components/playtest/concept-poll/RankedChoiceVote.vue +2 -2
- package/components/playtest/moderated/JoinCallPanel.vue +1 -1
- package/components/ui/MgHeaderUIUser.vue +1 -1
- package/components/ui/MgWalletPlanBadge.vue +1 -1
- package/locales/de.json +2 -0
- package/locales/en.json +2 -0
- package/locales/es.json +2 -0
- package/locales/pt-BR.json +2 -0
- package/locales/ro.json +2 -0
- package/package.json +1 -1
- package/pages/icons.vue +41 -6
- package/pages/key-campaigns/index.vue +48 -11
- package/pages/key-campaigns/key-materials.vue +1 -1
- package/pages/key-campaigns/redeem-key.vue +30 -3
- package/pages/key-campaign/[slug].vue +0 -164
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
<!-- Bundle Form -->
|
|
13
13
|
<div v-if="showForm" class="bundle-form">
|
|
14
14
|
<div class="visibility-note">
|
|
15
|
-
<MGIcon icon="
|
|
15
|
+
<MGIcon icon="about" />
|
|
16
16
|
<span>{{ $t("media_kit.bundles.visibility_note") }}</span>
|
|
17
17
|
</div>
|
|
18
18
|
<div class="form-group">
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
<span class="card-price">{{ formatPrice(card.price_min, card.currency) }}</span>
|
|
48
48
|
</div>
|
|
49
49
|
<div class="check-indicator">
|
|
50
|
-
<MGIcon icon="
|
|
50
|
+
<MGIcon icon="verified" v-if="form.rate_card_ids.includes(card.id)" />
|
|
51
51
|
</div>
|
|
52
52
|
<div v-if="form.rate_card_ids.includes(card.id)" class="card-qty" @click.stop.prevent>
|
|
53
53
|
<span>×</span>
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
</select>
|
|
25
25
|
</div>
|
|
26
26
|
<div class="form-group">
|
|
27
|
-
<label>{{ $t("media_kit.editor.
|
|
28
|
-
<select v-model="form.
|
|
27
|
+
<label>{{ $t("media_kit.editor.ad_channel") }}</label>
|
|
28
|
+
<select v-model="form.ad_channel_id" :class="{ 'input-error': rateCardErrors.ad_channel_id }" @change="delete rateCardErrors.ad_channel_id" required>
|
|
29
29
|
<option value="">{{ $t("media_kit.editor.select") }}</option>
|
|
30
|
-
<option v-for="
|
|
31
|
-
{{
|
|
30
|
+
<option v-for="channel in adChannels" :key="channel.id" :value="channel.id">
|
|
31
|
+
{{ channel.name }}
|
|
32
32
|
</option>
|
|
33
33
|
</select>
|
|
34
34
|
</div>
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
<div v-for="card in rateCards" :key="card.id" class="rate-card-item">
|
|
111
111
|
<div class="card-info">
|
|
112
112
|
<span class="format">{{ card.content_format?.name || card.label }}</span>
|
|
113
|
-
<span class="channel">{{ card.
|
|
113
|
+
<span class="channel">{{ card.ad_channel?.name }}</span>
|
|
114
114
|
<span class="price">{{ formatPrice(card.price_min, card.price_max, card.currency) }}</span>
|
|
115
115
|
</div>
|
|
116
116
|
<div class="card-actions">
|
|
@@ -134,7 +134,7 @@ const { t } = useI18n();
|
|
|
134
134
|
interface RateCard {
|
|
135
135
|
id: number;
|
|
136
136
|
content_format_id: number;
|
|
137
|
-
|
|
137
|
+
ad_channel_id: number;
|
|
138
138
|
label?: string;
|
|
139
139
|
price_min: string;
|
|
140
140
|
price_max?: string;
|
|
@@ -143,7 +143,7 @@ interface RateCard {
|
|
|
143
143
|
description?: string;
|
|
144
144
|
is_active: boolean;
|
|
145
145
|
content_format?: { id: number; name: string };
|
|
146
|
-
|
|
146
|
+
ad_channel?: { id: number; name: string };
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
interface LookupItem {
|
|
@@ -156,7 +156,7 @@ const props = withDefaults(
|
|
|
156
156
|
rateCards: RateCard[];
|
|
157
157
|
influencerProInfoId: number;
|
|
158
158
|
contentFormats: LookupItem[];
|
|
159
|
-
|
|
159
|
+
adChannels: LookupItem[];
|
|
160
160
|
isEnabled?: boolean;
|
|
161
161
|
/** MGN commission % for the current plan (host app resolves this, e.g. from feature_limits). */
|
|
162
162
|
commissionPercent?: number;
|
|
@@ -196,7 +196,7 @@ const saving = ref(false);
|
|
|
196
196
|
|
|
197
197
|
const defaultForm = {
|
|
198
198
|
content_format_id: '',
|
|
199
|
-
|
|
199
|
+
ad_channel_id: '',
|
|
200
200
|
price_min: '',
|
|
201
201
|
price_max: '',
|
|
202
202
|
currency: 'USD',
|
|
@@ -265,7 +265,7 @@ const editCard = (card: RateCard) => {
|
|
|
265
265
|
editing.value = card.id;
|
|
266
266
|
form.value = {
|
|
267
267
|
content_format_id: String(card.content_format_id),
|
|
268
|
-
|
|
268
|
+
ad_channel_id: String(card.ad_channel_id),
|
|
269
269
|
price_min: card.price_min,
|
|
270
270
|
price_max: card.price_max || '',
|
|
271
271
|
currency: card.currency,
|
|
@@ -283,7 +283,7 @@ const saveRateCard = async () => {
|
|
|
283
283
|
// Client-side required field check (HTML required bypassed by @click)
|
|
284
284
|
Object.keys(rateCardErrors).forEach(k => delete rateCardErrors[k]);
|
|
285
285
|
if (!form.value.content_format_id) rateCardErrors.content_format_id = 'required';
|
|
286
|
-
if (!form.value.
|
|
286
|
+
if (!form.value.ad_channel_id) rateCardErrors.ad_channel_id = 'required';
|
|
287
287
|
if (!form.value.price_min && form.value.price_min !== 0) rateCardErrors.price_min = 'required';
|
|
288
288
|
if (Object.keys(rateCardErrors).length) return;
|
|
289
289
|
|
|
@@ -293,7 +293,7 @@ const saveRateCard = async () => {
|
|
|
293
293
|
...form.value,
|
|
294
294
|
influencer_professional_info_id: props.influencerProInfoId,
|
|
295
295
|
content_format_id: Number(form.value.content_format_id),
|
|
296
|
-
|
|
296
|
+
ad_channel_id: Number(form.value.ad_channel_id),
|
|
297
297
|
price_min: Number(form.value.price_min),
|
|
298
298
|
price_max: form.value.price_max ? Number(form.value.price_max) : null,
|
|
299
299
|
delivery_time_days: form.value.delivery_time_days ? Number(form.value.delivery_time_days) : null,
|
|
@@ -80,7 +80,7 @@ function downloadAll() {
|
|
|
80
80
|
<div class="gallery-header">
|
|
81
81
|
<h3 class="section-title">{{ $t('press_kit.assets') }}</h3>
|
|
82
82
|
<button v-if="allowDownload && assets.length" class="btn-download-all" @click="downloadAll">
|
|
83
|
-
<MGIcon icon="
|
|
83
|
+
<MGIcon icon="save" /> {{ $t('press_kit.download_all') }}
|
|
84
84
|
</button>
|
|
85
85
|
</div>
|
|
86
86
|
|
|
@@ -134,7 +134,7 @@ function downloadAll() {
|
|
|
134
134
|
<div class="lb-info">
|
|
135
135
|
<span>{{ imageAssets[lightboxIndex]?.title || imageAssets[lightboxIndex]?.type }}</span>
|
|
136
136
|
<button v-if="allowDownload" class="lb-download" @click="downloadAsset(imageAssets[lightboxIndex])">
|
|
137
|
-
<MGIcon icon="
|
|
137
|
+
<MGIcon icon="save" /> {{ $t('press_kit.download') }}
|
|
138
138
|
</button>
|
|
139
139
|
</div>
|
|
140
140
|
</div>
|
|
@@ -27,7 +27,7 @@ defineProps<Props>();
|
|
|
27
27
|
rel="noopener"
|
|
28
28
|
:class="['award-card', { 'no-link': !award.url }]"
|
|
29
29
|
>
|
|
30
|
-
<MGIcon icon="
|
|
30
|
+
<MGIcon icon="achievements" class="award-icon" />
|
|
31
31
|
<div class="award-info">
|
|
32
32
|
<span class="award-title">{{ award.title }}</span>
|
|
33
33
|
<span v-if="award.event || award.year" class="award-event">
|
|
@@ -27,7 +27,7 @@ const youtubeEmbedUrl = computed(() => {
|
|
|
27
27
|
<h1 v-else class="hero-title">{{ name }}</h1>
|
|
28
28
|
<p v-if="tagline" class="hero-tagline">{{ tagline }}</p>
|
|
29
29
|
<button v-if="trailerUrl" class="btn-trailer" @click="showTrailer = true">
|
|
30
|
-
<MGIcon icon="
|
|
30
|
+
<MGIcon icon="videos" /> {{ $t('press_kit.watch_trailer') }}
|
|
31
31
|
</button>
|
|
32
32
|
</div>
|
|
33
33
|
</div>
|
|
@@ -55,7 +55,7 @@ function formatType(type?: string) {
|
|
|
55
55
|
:class="['video-item', { active: activeVideo?.id === video.id }]"
|
|
56
56
|
@click="activeVideo = video"
|
|
57
57
|
>
|
|
58
|
-
<MGIcon icon="
|
|
58
|
+
<MGIcon icon="videos" />
|
|
59
59
|
<div class="video-meta">
|
|
60
60
|
<span class="video-title">{{ video.title }}</span>
|
|
61
61
|
<span v-if="video.type" class="video-type">{{ formatType(video.type) }}</span>
|
|
@@ -89,10 +89,16 @@ function formatCountdown(availableAt: string | null | undefined): string {
|
|
|
89
89
|
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// key-campaigns (plural) is the canonical route on every front. The singular
|
|
93
|
+
// used to be a second, standalone copy of this page in shared-ui — worse than
|
|
94
|
+
// the real one (hardcoded English, no embargo handling) and the reason a shared
|
|
95
|
+
// link previewed differently depending on which app served it. It is now a
|
|
96
|
+
// server-level 301, so defaulting here to the singular would only send every
|
|
97
|
+
// card click through a needless redirect.
|
|
92
98
|
const detailRoute = computed(() =>
|
|
93
99
|
props.publicMode
|
|
94
|
-
? `/${locale.value}/${props.baseRoute ?? 'key-
|
|
95
|
-
: `/${locale.value}/dashboard/${props.baseRoute ?? 'key-
|
|
100
|
+
? `/${locale.value}/${props.baseRoute ?? 'key-campaigns'}/${props.card.slug}`
|
|
101
|
+
: `/${locale.value}/dashboard/${props.baseRoute ?? 'key-campaigns'}/${props.card.slug}`
|
|
96
102
|
);
|
|
97
103
|
</script>
|
|
98
104
|
|
|
@@ -130,38 +136,38 @@ const detailRoute = computed(() =>
|
|
|
130
136
|
|
|
131
137
|
<!-- Exclusive badge -->
|
|
132
138
|
<div v-if="card.is_exclusive && !userCanAccess" class="kc-card__exclusive-badge kc-card__exclusive-lock">
|
|
133
|
-
<
|
|
139
|
+
<MGIcon size="1x" icon="reward" /> {{ $t("keys.card.exclusive_upgrade") }}
|
|
134
140
|
</div>
|
|
135
141
|
<div v-else-if="card.is_exclusive" class="kc-card__exclusive-badge">
|
|
136
|
-
<
|
|
142
|
+
<MGIcon size="1x" icon="reward" /> {{ $t("keys.card.exclusive") }}
|
|
137
143
|
</div>
|
|
138
144
|
|
|
139
145
|
<!-- Tier-locked badge -->
|
|
140
146
|
<div v-else-if="card.is_tier_locked && card.access_tier !== 'free' && !userCanAccess" class="kc-card__exclusive-badge">
|
|
141
|
-
<
|
|
147
|
+
<MGIcon size="1x" icon="reward" /> {{ cardTierLabel(card.access_tier) }}
|
|
142
148
|
</div>
|
|
143
149
|
|
|
144
150
|
<!-- Early access badge -->
|
|
145
151
|
<div v-else-if="(card.early_access_hours ?? 0) > 0 && userCanAccess" class="kc-card__early-access-badge">
|
|
146
|
-
<
|
|
152
|
+
<MGIcon size="1x" icon="clock" /> {{ $t("keys.card.early_access") }}
|
|
147
153
|
</div>
|
|
148
154
|
|
|
149
155
|
<!-- Streamer level badge -->
|
|
150
156
|
<div v-if="card.streamer_type === 2 || card.streamer_type === 3" class="kc-card__exclusive-badge kc-card__level-badge">
|
|
151
|
-
<
|
|
157
|
+
<MGIcon size="1x" icon="verified" />
|
|
152
158
|
{{ card.streamer_type === 2 ? $t("keys.card.level_official") : $t("keys.card.level_affiliate") }}
|
|
153
159
|
</div>
|
|
154
160
|
|
|
155
161
|
<!-- Early-access lock overlay -->
|
|
156
162
|
<div v-if="!userCanAccess && card.available_at_for_user" class="kc-card__lock-overlay">
|
|
157
|
-
<
|
|
163
|
+
<MGIcon size="1x" icon="lock" />
|
|
158
164
|
<span class="lock-label">{{ $t("keys.card.early_access_lock_title") }}</span>
|
|
159
165
|
<span class="lock-countdown">{{ $t("keys.card.early_access_opens_in") }} {{ formatCountdown(card.available_at_for_user) }}</span>
|
|
160
166
|
</div>
|
|
161
167
|
|
|
162
168
|
<!-- Exclusive lock overlay -->
|
|
163
169
|
<div v-else-if="!userCanAccess && card.is_exclusive" class="kc-card__lock-overlay kc-card__lock-overlay--exclusive">
|
|
164
|
-
<
|
|
170
|
+
<MGIcon size="1x" icon="reward" />
|
|
165
171
|
<span class="lock-label">{{ $t("keys.card.exclusive_lock_title") }}</span>
|
|
166
172
|
<span class="lock-cta">{{ $t("keys.card.exclusive_upgrade_cta") }}</span>
|
|
167
173
|
</div>
|
|
@@ -198,7 +204,7 @@ const detailRoute = computed(() =>
|
|
|
198
204
|
</NuxtLink>
|
|
199
205
|
<NuxtLink v-if="!publicMode" :to="`/${locale}/dashboard/pricing`">
|
|
200
206
|
<button class="btn secondary upgrade-btn">
|
|
201
|
-
<
|
|
207
|
+
<MGIcon size="1x" icon="chevron-up" /> {{ $t("keys.card.upgrade_plan") }}
|
|
202
208
|
</button>
|
|
203
209
|
</NuxtLink>
|
|
204
210
|
</template>
|
|
@@ -54,7 +54,7 @@ const categoryLabel = computed(() => {
|
|
|
54
54
|
</div>
|
|
55
55
|
|
|
56
56
|
<div v-if="campaign.eligible === false" class="pt-brief__ineligible">
|
|
57
|
-
<
|
|
57
|
+
<MGIcon size="1x" icon="lock" />
|
|
58
58
|
<span>{{ campaign.ineligibility_reason || $t("playtest.brief.ineligible_generic", "You don't currently qualify for this playtest.") }}</span>
|
|
59
59
|
</div>
|
|
60
60
|
|
|
@@ -23,8 +23,8 @@ const emit = defineEmits<{
|
|
|
23
23
|
<template>
|
|
24
24
|
<div class="pt-pending" :class="{ 'pt-pending--ineligible': ineligible }">
|
|
25
25
|
<div class="pt-pending__icon">
|
|
26
|
-
<
|
|
27
|
-
<
|
|
26
|
+
<MGIcon v-if="ineligible" size="1x" icon="exclamation" />
|
|
27
|
+
<MGIcon v-else size="1x" icon="verified-filled" />
|
|
28
28
|
</div>
|
|
29
29
|
|
|
30
30
|
<h3 class="pt-pending__title">
|
|
@@ -65,7 +65,7 @@ function submit() {
|
|
|
65
65
|
:aria-label="$t('playtest.concept_poll.move_up', 'Move up')"
|
|
66
66
|
@click="moveUp(index)"
|
|
67
67
|
>
|
|
68
|
-
<
|
|
68
|
+
<MGIcon size="1x" icon="chevron-up" />
|
|
69
69
|
</button>
|
|
70
70
|
<button
|
|
71
71
|
type="button"
|
|
@@ -74,7 +74,7 @@ function submit() {
|
|
|
74
74
|
:aria-label="$t('playtest.concept_poll.move_down', 'Move down')"
|
|
75
75
|
@click="moveDown(index)"
|
|
76
76
|
>
|
|
77
|
-
<
|
|
77
|
+
<MGIcon size="1x" icon="chevron-down" />
|
|
78
78
|
</button>
|
|
79
79
|
</span>
|
|
80
80
|
</li>
|
|
@@ -35,7 +35,7 @@ const scheduledAt = computed(() => (props.session?.scheduled_at ? new Date(props
|
|
|
35
35
|
<p class="join-call-panel__time">{{ scheduledAt?.toLocaleString() }}</p>
|
|
36
36
|
|
|
37
37
|
<div v-if="session.tester_confirmed_at" class="join-call-panel__confirmed-badge">
|
|
38
|
-
<
|
|
38
|
+
<MGIcon size="1x" icon="verified-filled" />
|
|
39
39
|
{{ $t("playtest.moderated.you_confirmed", "You've confirmed attendance") }}
|
|
40
40
|
</div>
|
|
41
41
|
|
|
@@ -106,7 +106,7 @@ const handleLogout = async () => {
|
|
|
106
106
|
|
|
107
107
|
<div v-if="features.wallet && user?.coins !== null" class="row info-row">
|
|
108
108
|
<span class="coins">
|
|
109
|
-
<MGIcon icon="coin" />
|
|
109
|
+
<MGIcon icon="coin-v2" />
|
|
110
110
|
{{ Math.floor(user?.coins ?? 0).toLocaleString() }}
|
|
111
111
|
</span>
|
|
112
112
|
</div>
|
|
@@ -43,7 +43,7 @@ const isSubscriber = computed(() => !!(user.value as any)?.subscription?.is_subs
|
|
|
43
43
|
<template>
|
|
44
44
|
<div class="mg-wallet-plan">
|
|
45
45
|
<span v-if="showWallet" class="mg-wallet-plan__coins">
|
|
46
|
-
<MGIcon icon="coin" />
|
|
46
|
+
<MGIcon icon="coin-v2" />
|
|
47
47
|
{{ Math.floor(walletBalance).toLocaleString() }}
|
|
48
48
|
<span class="mg-wallet-plan__currency">MGC</span>
|
|
49
49
|
</span>
|
package/locales/de.json
CHANGED
|
@@ -195,6 +195,8 @@
|
|
|
195
195
|
"required_data": "Erforderliche Angaben",
|
|
196
196
|
"already_requested": "Sie haben bereits eine Anfrage für diese Kampagne gestellt.",
|
|
197
197
|
"sold_out": "Alle Keys für diese Kampagne wurden bereits eingelöst.",
|
|
198
|
+
"keys_left": "{n} Key übrig | {n} Keys übrig",
|
|
199
|
+
"keys_low_stock": "Letzter Key | Letzte {n}",
|
|
198
200
|
"back_to_campaign": "Zur Kampagne zurückkehren",
|
|
199
201
|
"status_pending": "Anfrage ausstehend — bitte warten Sie auf die Genehmigung",
|
|
200
202
|
"status_approved": "Anfrage genehmigt — Sie können jetzt Ihren Schlüssel abrufen",
|
package/locales/en.json
CHANGED
|
@@ -195,6 +195,8 @@
|
|
|
195
195
|
"required_data": "Required data",
|
|
196
196
|
"already_requested": "You have already submitted a request for this campaign.",
|
|
197
197
|
"sold_out": "All keys for this campaign have already been claimed.",
|
|
198
|
+
"keys_left": "{n} key left | {n} keys left",
|
|
199
|
+
"keys_low_stock": "Last key | Last {n}",
|
|
198
200
|
"back_to_campaign": "Back to campaign",
|
|
199
201
|
"status_pending": "Request pending — waiting for approval",
|
|
200
202
|
"status_approved": "Request approved — you can now retrieve your key",
|
package/locales/es.json
CHANGED
|
@@ -195,6 +195,8 @@
|
|
|
195
195
|
"required_data": "Datos requeridos",
|
|
196
196
|
"already_requested": "Ya has enviado una solicitud para esta campaña.",
|
|
197
197
|
"sold_out": "Todas las claves de esta campaña ya fueron reclamadas.",
|
|
198
|
+
"keys_left": "{n} clave restante | {n} claves restantes",
|
|
199
|
+
"keys_low_stock": "Última clave | Últimas {n}",
|
|
198
200
|
"back_to_campaign": "Volver a la campaña",
|
|
199
201
|
"status_pending": "Solicitud pendiente — esperando aprobación",
|
|
200
202
|
"status_approved": "Solicitud aprobada — ya puedes obtener tu clave",
|
package/locales/pt-BR.json
CHANGED
|
@@ -195,6 +195,8 @@
|
|
|
195
195
|
"required_data": "Dados obrigatórios",
|
|
196
196
|
"already_requested": "Você já enviou uma solicitação para esta campanha.",
|
|
197
197
|
"sold_out": "Todas as chaves desta campanha já foram resgatadas.",
|
|
198
|
+
"keys_left": "{n} chave restante | {n} chaves restantes",
|
|
199
|
+
"keys_low_stock": "Última chave | Últimas {n}",
|
|
198
200
|
"back_to_campaign": "Voltar para a campanha",
|
|
199
201
|
"status_pending": "Solicitação pendente — aguardando aprovação",
|
|
200
202
|
"status_approved": "Solicitação aprovada — você pode resgatar sua chave",
|
package/locales/ro.json
CHANGED
|
@@ -195,6 +195,8 @@
|
|
|
195
195
|
"required_data": "Date obligatorii",
|
|
196
196
|
"already_requested": "Ați trimis deja o cerere pentru această campanie.",
|
|
197
197
|
"sold_out": "Toate cheile pentru această campanie au fost deja revendicate.",
|
|
198
|
+
"keys_left": "{n} cheie rămasă | {n} chei rămase",
|
|
199
|
+
"keys_low_stock": "Ultima cheie | Ultimele {n}",
|
|
198
200
|
"back_to_campaign": "Înapoi la campanie",
|
|
199
201
|
"status_pending": "Cerere în așteptare — așteptați aprobarea",
|
|
200
202
|
"status_approved": "Cerere aprobată — puteți prelua cheia",
|
package/package.json
CHANGED
package/pages/icons.vue
CHANGED
|
@@ -1,13 +1,48 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
definePageMeta({ layout: "default" });
|
|
3
3
|
|
|
4
|
+
// The real contents of the custom-icons font (assets/scss/_icons.scss in
|
|
5
|
+
// each consuming app — identical across tv/community/agency).
|
|
6
|
+
//
|
|
7
|
+
// This list used to be a plausible-sounding invention: "bell", "user",
|
|
8
|
+
// "star", "check", "info", "download"... none of which exist in the font.
|
|
9
|
+
// A catalogue that lists icons you cannot use is worse than no catalogue —
|
|
10
|
+
// it is where `icon="check"` and friends came from, rendering an empty
|
|
11
|
+
// element on real screens.
|
|
4
12
|
const icons = [
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
13
|
+
"about", "achievements", "add-community", "add-friend", "add-team", "added", "administration",
|
|
14
|
+
"ai", "ai-vr", "analytics-research", "animation", "arcade", "arts", "arts-crafts", "audio-sound",
|
|
15
|
+
"battle-royale", "beta-feature", "bug", "business-operations", "camera", "camera-solid",
|
|
16
|
+
"cancel-message", "cancel-notification", "card-games", "cart", "cel-v1", "cel-v2", "chevron-down",
|
|
17
|
+
"chevron-left", "chevron-right", "chevron-up", "childcare-voucher", "clear", "clock",
|
|
18
|
+
"clock-filled", "close", "coin-v1", "coin-v2", "community", "community-filled",
|
|
19
|
+
"community-management", "community-plus", "contact-v2", "coop", "creatormode",
|
|
20
|
+
"customer-services", "cycle", "dental-healthcare", "discord", "discounts", "discover",
|
|
21
|
+
"display-blocks", "display-list", "double-", "double-chevron-left", "e-sports", "edit",
|
|
22
|
+
"editorial-journalism", "education", "educational", "engineering", "event-notification",
|
|
23
|
+
"exclamation", "exclusive-themes", "external-link", "eye", "eye-slash", "facebook", "facebook-v2",
|
|
24
|
+
"faq", "feed", "fight", "filter", "flexible-work", "follow", "free-food", "gambling", "game-jam",
|
|
25
|
+
"game-release-notification", "game-room", "games", "games-filled", "gear", "gear-dev", "giveaway",
|
|
26
|
+
"gym", "hack-slash", "handshake-filled", "healthcheck", "home", "horror", "impressum",
|
|
27
|
+
"infinity-community", "infinity-team", "information-technology", "instagran", "interaction",
|
|
28
|
+
"jack-of-all-trades", "job-apply", "jolife-insurance", "language", "language-class", "legal",
|
|
29
|
+
"like", "like-filled", "link", "linkedin", "linkedin-v2", "list", "localisation", "location",
|
|
30
|
+
"location-v1", "location-v2", "lock", "log-status", "mail-v1", "mail-v2", "manage-team",
|
|
31
|
+
"membership", "menu", "message", "message-active", "money", "moon", "more",
|
|
32
|
+
"narrative-design-writting", "new-friend", "news", "no-ads", "no-data", "no-dresscode",
|
|
33
|
+
"not-interested", "notification", "notification-active", "office-library", "official-streamer",
|
|
34
|
+
"official-streamer-filled", "official-writer", "operations", "others", "paid-overtime", "partner",
|
|
35
|
+
"partner-filled", "phone", "picture", "plan", "plan-filled", "platformer", "politics",
|
|
36
|
+
"private-medic", "procurement-supply-chain", "production", "profile", "profile-filled",
|
|
37
|
+
"programmmig-development", "public-relations", "public-transport", "puzzlers",
|
|
38
|
+
"quality-assurance", "quality-control", "question", "racing", "rate", "recognition",
|
|
39
|
+
"recruitment", "region", "report", "retirement", "retro", "reward", "roadmap", "rpg",
|
|
40
|
+
"sales-representative", "save", "save-filled", "search", "senior-management", "share",
|
|
41
|
+
"share-filled", "shooter", "simulation", "simulator", "smile", "social-medias", "sort", "sports",
|
|
42
|
+
"stipend", "stocks", "strategy", "strategy-1", "stream", "suppot", "survival", "team",
|
|
43
|
+
"team-event", "telegran", "terms", "text-size", "theme", "tiktok", "training", "trash", "twitter",
|
|
44
|
+
"twitter-v2", "twtch", "ux-design", "verified", "verified-filled", "version", "videos", "vr",
|
|
45
|
+
"wallet", "web-development", "webpage", "whatsapp", "wiki", "youtube",
|
|
11
46
|
];
|
|
12
47
|
</script>
|
|
13
48
|
|
|
@@ -127,44 +127,49 @@
|
|
|
127
127
|
@error="(e) => { (e.target as HTMLImageElement).src = '/imgs/no-cover-img.jpg' }"
|
|
128
128
|
/>
|
|
129
129
|
|
|
130
|
+
<!-- Scarcity only. The plain count now lives in the card's
|
|
131
|
+
info row, where card metadata is actually read — a bare
|
|
132
|
+
number floating on the artwork said nothing, and the icon
|
|
133
|
+
that was supposed to explain it never rendered. Up here it
|
|
134
|
+
earns its place only when stock is running out. -->
|
|
130
135
|
<div
|
|
131
136
|
class="keys-badge"
|
|
132
|
-
v-if="card
|
|
137
|
+
v-if="showsLowStock(card)"
|
|
133
138
|
:title="$t('keys.campaigns.keys_left', { n: card.available_count })"
|
|
134
139
|
>
|
|
135
|
-
<
|
|
136
|
-
{{ card.available_count }}
|
|
140
|
+
<MGIcon size="1x" icon="giveaway" />
|
|
141
|
+
{{ $t("keys.campaigns.keys_low_stock", { n: card.available_count }) }}
|
|
137
142
|
</div>
|
|
138
143
|
|
|
139
144
|
<div
|
|
140
145
|
v-if="card.is_exclusive && !card.user_can_access"
|
|
141
146
|
class="exclusive-badge exclusive-lock"
|
|
142
147
|
>
|
|
143
|
-
<
|
|
148
|
+
<MGIcon size="1x" icon="reward" /> {{ $t("keys.campaigns.exclusive_upgrade") }}
|
|
144
149
|
</div>
|
|
145
150
|
<div v-else-if="card.is_exclusive" class="exclusive-badge">
|
|
146
|
-
<
|
|
151
|
+
<MGIcon size="1x" icon="reward" /> {{ $t("keys.campaigns.exclusive") }}
|
|
147
152
|
</div>
|
|
148
153
|
|
|
149
154
|
<div
|
|
150
155
|
v-else-if="card.is_tier_locked && card.access_tier !== 'free' && !card.user_can_access"
|
|
151
156
|
class="exclusive-badge"
|
|
152
157
|
>
|
|
153
|
-
<
|
|
158
|
+
<MGIcon size="1x" icon="reward" /> {{ cardTierLabel(card.access_tier) }}
|
|
154
159
|
</div>
|
|
155
160
|
|
|
156
161
|
<div
|
|
157
162
|
v-else-if="card.early_access_hours > 0 && card.user_can_access"
|
|
158
163
|
class="early-access-badge"
|
|
159
164
|
>
|
|
160
|
-
<
|
|
165
|
+
<MGIcon size="1x" icon="clock" /> {{ $t("keys.campaigns.early_access") }}
|
|
161
166
|
</div>
|
|
162
167
|
|
|
163
168
|
<div
|
|
164
169
|
v-if="card.streamer_type === 2 || card.streamer_type === 3"
|
|
165
170
|
class="exclusive-badge level-badge"
|
|
166
171
|
>
|
|
167
|
-
<
|
|
172
|
+
<MGIcon size="1x" icon="verified" />
|
|
168
173
|
{{
|
|
169
174
|
card.streamer_type === 2
|
|
170
175
|
? $t("keys.campaigns.restriction.level_official")
|
|
@@ -176,7 +181,7 @@
|
|
|
176
181
|
v-if="!card.user_can_access && card.available_at_for_user"
|
|
177
182
|
class="early-access-lock-overlay"
|
|
178
183
|
>
|
|
179
|
-
<
|
|
184
|
+
<MGIcon size="1x" icon="lock" />
|
|
180
185
|
<span class="lock-label">{{ $t("keys.campaigns.early_access_lock_title") }}</span>
|
|
181
186
|
<span class="lock-countdown">
|
|
182
187
|
{{ $t("keys.campaigns.early_access_opens_in") }}
|
|
@@ -188,7 +193,7 @@
|
|
|
188
193
|
v-else-if="!card.user_can_access && card.is_exclusive"
|
|
189
194
|
class="exclusive-lock-overlay"
|
|
190
195
|
>
|
|
191
|
-
<
|
|
196
|
+
<MGIcon size="1x" icon="reward" />
|
|
192
197
|
<span class="lock-label">{{ $t("keys.campaigns.exclusive_lock_title") }}</span>
|
|
193
198
|
<span class="lock-cta">{{ $t("keys.campaigns.exclusive_upgrade_cta") }}</span>
|
|
194
199
|
</div>
|
|
@@ -208,6 +213,15 @@
|
|
|
208
213
|
<div class="days" v-if="card.time > 0 && card.user_can_access">
|
|
209
214
|
<span>{{ card.time }}</span> {{ $t("keys.campaigns.days") }}
|
|
210
215
|
</div>
|
|
216
|
+
<!-- Labelled, next to the deadline: the two numbers a creator
|
|
217
|
+
actually decides on are "how long left" and "how many left". -->
|
|
218
|
+
<span
|
|
219
|
+
v-if="card.available_count > 0 && card.user_can_access && !card.isExpired && !card.isUpcoming"
|
|
220
|
+
class="card-keys-left"
|
|
221
|
+
>
|
|
222
|
+
<MGIcon size="1x" icon="giveaway" />
|
|
223
|
+
{{ $t("keys.campaigns.keys_left", { n: card.available_count }) }}
|
|
224
|
+
</span>
|
|
211
225
|
<span>
|
|
212
226
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="17" viewBox="0 0 16 17" fill="none">
|
|
213
227
|
<path d="M13.3333 4.1112L12.8666 3.64453L11.9333 4.57786L10.9333 3.64453L8.33329 4.1112L2.66663 9.77786L7.19996 14.3112L12.8666 8.64453L13.3333 6.04453L12.4 5.1112L13.3333 4.1112ZM12.6 6.24453L12.2666 8.3112L7.19996 13.3779L3.59996 9.77786L8.66663 4.7112L10.7333 4.3112L11.4666 5.04453L11 5.5112L10.5333 5.04453L9.99996 5.57786L11.4 6.97786L11.8666 6.5112L11.4 6.04453L11.8666 5.57786L12.6 6.24453Z" fill="var(--key-accent, var(--key-accent, var(--primary, #D297FF)))" />
|
|
@@ -231,7 +245,7 @@
|
|
|
231
245
|
</nuxt-link>
|
|
232
246
|
<nuxt-link :to="upgradeRoute">
|
|
233
247
|
<button class="btn secondary upgrade-btn">
|
|
234
|
-
<
|
|
248
|
+
<MGIcon size="1x" icon="chevron-up" />
|
|
235
249
|
{{ $t("keys.campaigns.upgrade_plan") }}
|
|
236
250
|
</button>
|
|
237
251
|
</nuxt-link>
|
|
@@ -473,6 +487,20 @@ function platformAbbr(name: string): string {
|
|
|
473
487
|
return PLATFORM_ABBR[name.toLowerCase()] ?? name
|
|
474
488
|
}
|
|
475
489
|
|
|
490
|
+
/**
|
|
491
|
+
* At or below this many keys left, the cover badge appears as a scarcity
|
|
492
|
+
* signal. Above it, the count is just a fact and lives in the info row.
|
|
493
|
+
*/
|
|
494
|
+
const LOW_STOCK_THRESHOLD = 5
|
|
495
|
+
|
|
496
|
+
function showsLowStock(card: any): boolean {
|
|
497
|
+
return card.available_count > 0
|
|
498
|
+
&& card.available_count <= LOW_STOCK_THRESHOLD
|
|
499
|
+
&& card.user_can_access
|
|
500
|
+
&& !card.isExpired
|
|
501
|
+
&& !card.isUpcoming
|
|
502
|
+
}
|
|
503
|
+
|
|
476
504
|
function cardTierLabel(tier: string): string {
|
|
477
505
|
const labels: Record<string, string> = { free: "Free", pro: "PRO", business: "Business", official: "Official" }
|
|
478
506
|
return $i18n.t("keys.campaigns.restriction.tier_locked", { tier: labels[tier] || tier })
|
|
@@ -874,6 +902,15 @@ onUnmounted(() => {
|
|
|
874
902
|
opacity: 0.5;
|
|
875
903
|
}
|
|
876
904
|
|
|
905
|
+
.card-keys-left {
|
|
906
|
+
display: inline-flex;
|
|
907
|
+
align-items: center;
|
|
908
|
+
gap: 4px;
|
|
909
|
+
white-space: nowrap;
|
|
910
|
+
|
|
911
|
+
i.icon { color: var(--key-accent, var(--primary, #D297FF)); }
|
|
912
|
+
}
|
|
913
|
+
|
|
877
914
|
.keys-badge {
|
|
878
915
|
position: absolute;
|
|
879
916
|
top: 8px;
|
|
@@ -150,7 +150,7 @@
|
|
|
150
150
|
<!-- Success modal -->
|
|
151
151
|
<div v-if="showSuccessModal" class="success-modal-overlay" @click.self="handleSuccessClose">
|
|
152
152
|
<div class="success-modal">
|
|
153
|
-
<MGIcon size="2x" icon="
|
|
153
|
+
<MGIcon size="2x" icon="verified" style="color: #42FF00" />
|
|
154
154
|
<p>{{ $t("keys.materials.success_message") }}</p>
|
|
155
155
|
<button class="btn button-submit" @click="handleSuccessClose">
|
|
156
156
|
{{ $t("keys.materials.success_close") }}
|
|
@@ -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>
|
|
@@ -248,7 +275,7 @@ function goBack() {
|
|
|
248
275
|
<div v-else-if="error" class="state error">{{ error }}</div>
|
|
249
276
|
|
|
250
277
|
<div v-else-if="success" class="state success">
|
|
251
|
-
<MGIcon icon="
|
|
278
|
+
<MGIcon icon="verified-filled" size="3x" />
|
|
252
279
|
<p>{{ $t("keys.campaigns.request_sent") }}</p>
|
|
253
280
|
<button class="btn" @click="goBack">
|
|
254
281
|
{{ $t("keys.campaigns.back_to_campaign") }}
|
|
@@ -292,7 +319,7 @@ function goBack() {
|
|
|
292
319
|
<div class="required-data">
|
|
293
320
|
<div class="required-data-header" @click="toggleBox">
|
|
294
321
|
<div class="required-data-header-left">
|
|
295
|
-
<MGIcon size="1x" icon="
|
|
322
|
+
<MGIcon size="1x" icon="verified" :style="{ color: isBoxOpen ? 'var(--key-accent, var(--primary, #D297FF))' : 'var(--inactive)' }" />
|
|
296
323
|
<span>{{ $t("keys.campaigns.required_data") }}</span>
|
|
297
324
|
</div>
|
|
298
325
|
<button type="button" class="box-toggler" :aria-expanded="isBoxOpen">
|
|
@@ -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>
|