@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-campaign'}/${props.card.slug}`
95
- : `/${locale.value}/dashboard/${props.baseRoute ?? 'key-campaign'}/${props.card.slug}`
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.8.18",
3
+ "version": "1.8.19",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -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>