@mundogamernetwork/shared-ui 1.8.18 → 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.
|
@@ -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
|
|
package/package.json
CHANGED
|
@@ -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>
|
|
@@ -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>
|