@mundogamernetwork/shared-ui 1.1.67 → 1.1.68

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.
@@ -0,0 +1,190 @@
1
+ <script lang="ts" setup>
2
+ import { ref, onMounted, inject } from "vue"
3
+ import { fetchCampaigns } from "../../services/campaignService"
4
+
5
+ const props = defineProps<{
6
+ excludeId?: number | null
7
+ mgPlatform?: string
8
+ }>()
9
+
10
+ const mgPlatform = props.mgPlatform || (inject<string>("mgPlatform", "MGTV"))
11
+
12
+ const items = ref<any[]>([])
13
+ const loading = ref(true)
14
+
15
+ onMounted(async () => {
16
+ try {
17
+ const res = await fetchCampaigns(
18
+ {
19
+ filter: { is_open: 1 },
20
+ per_page: 10,
21
+ sort: "id",
22
+ order: "desc",
23
+ },
24
+ mgPlatform,
25
+ )
26
+ const data: any[] = res?.data?.data || res?.data || []
27
+ items.value = props.excludeId
28
+ ? data.filter((c: any) => c.id !== props.excludeId)
29
+ : data
30
+ } catch {
31
+ // silently ignore — carousel is non-critical
32
+ } finally {
33
+ loading.value = false
34
+ }
35
+ })
36
+ </script>
37
+
38
+ <template>
39
+ <div v-if="loading || items.length > 0" class="kc-carousel">
40
+ <div class="kc-carousel__header">
41
+ <span class="kc-carousel__title">{{ $t("keys.campaigns.other_campaigns") }}</span>
42
+ </div>
43
+
44
+ <div v-if="loading" class="kc-carousel__track">
45
+ <div v-for="i in 4" :key="i" class="kc-carousel__skeleton" />
46
+ </div>
47
+
48
+ <div v-else class="kc-carousel__track">
49
+ <NuxtLink
50
+ v-for="item in items"
51
+ :key="item.id"
52
+ :to="`/key-campaigns/${item.slug}`"
53
+ class="kc-carousel__card"
54
+ >
55
+ <div class="kc-carousel__thumb">
56
+ <img
57
+ v-if="item.cover_src || item.cover"
58
+ :src="item.cover_src || item.cover"
59
+ :alt="item.name"
60
+ />
61
+ <div v-else class="kc-carousel__thumb-placeholder">
62
+ <i class="mg-icon mg-icon-game-key" />
63
+ </div>
64
+ <span v-if="!item.is_open" class="kc-carousel__closed">{{ $t("keys.campaigns.closed") }}</span>
65
+ </div>
66
+ <div class="kc-carousel__info">
67
+ <div class="kc-carousel__name">{{ item.name }}</div>
68
+ <div class="kc-carousel__avail">
69
+ {{ item.available_count ?? 0 }}/{{ item.total_count ?? item.quantity ?? "?" }}
70
+ {{ $t("keys.campaigns.available") }}
71
+ </div>
72
+ </div>
73
+ </NuxtLink>
74
+ </div>
75
+ </div>
76
+ </template>
77
+
78
+ <style scoped lang="scss">
79
+ .kc-carousel {
80
+ margin-top: 40px;
81
+ padding-top: 24px;
82
+ border-top: 1px solid var(--button-secondary-default-bg);
83
+
84
+ &__header {
85
+ margin-bottom: 16px;
86
+ }
87
+
88
+ &__title {
89
+ font-size: 16px;
90
+ font-weight: 700;
91
+ color: var(--card-article-title);
92
+ }
93
+
94
+ &__track {
95
+ display: flex;
96
+ gap: 12px;
97
+ overflow-x: auto;
98
+ padding-bottom: 8px;
99
+ scrollbar-width: thin;
100
+ scrollbar-color: var(--button-secondary-default-bg) transparent;
101
+
102
+ &::-webkit-scrollbar { height: 4px; }
103
+ &::-webkit-scrollbar-track { background: transparent; }
104
+ &::-webkit-scrollbar-thumb { background: var(--button-secondary-default-bg); }
105
+ }
106
+
107
+ &__skeleton {
108
+ flex: 0 0 180px;
109
+ height: 140px;
110
+ background: var(--bg-app-badge);
111
+ animation: pulse 1.4s ease-in-out infinite;
112
+ }
113
+
114
+ &__card {
115
+ flex: 0 0 180px;
116
+ display: flex;
117
+ flex-direction: column;
118
+ text-decoration: none;
119
+ color: inherit;
120
+ border: 1px solid var(--button-secondary-default-bg);
121
+ transition: border-color 0.15s;
122
+
123
+ &:hover { border-color: var(--key-accent, var(--primary, #D297FF)); }
124
+ }
125
+
126
+ &__thumb {
127
+ position: relative;
128
+ width: 100%;
129
+ height: 101px;
130
+ overflow: hidden;
131
+ background: var(--bg-app-badge);
132
+
133
+ img {
134
+ width: 100%;
135
+ height: 100%;
136
+ object-fit: cover;
137
+ }
138
+ }
139
+
140
+ &__thumb-placeholder {
141
+ width: 100%;
142
+ height: 100%;
143
+ display: flex;
144
+ align-items: center;
145
+ justify-content: center;
146
+ color: var(--secondary-info-fg);
147
+ font-size: 24px;
148
+ }
149
+
150
+ &__closed {
151
+ position: absolute;
152
+ inset: 0;
153
+ display: flex;
154
+ align-items: center;
155
+ justify-content: center;
156
+ background: rgba(0, 0, 0, 0.55);
157
+ font-size: 11px;
158
+ font-weight: 700;
159
+ color: #fff;
160
+ letter-spacing: 0.5px;
161
+ text-transform: uppercase;
162
+ }
163
+
164
+ &__info {
165
+ padding: 8px 10px;
166
+ display: flex;
167
+ flex-direction: column;
168
+ gap: 4px;
169
+ }
170
+
171
+ &__name {
172
+ font-size: 12px;
173
+ font-weight: 600;
174
+ color: var(--card-article-title);
175
+ overflow: hidden;
176
+ text-overflow: ellipsis;
177
+ white-space: nowrap;
178
+ }
179
+
180
+ &__avail {
181
+ font-size: 11px;
182
+ color: var(--secondary-info-fg);
183
+ }
184
+ }
185
+
186
+ @keyframes pulse {
187
+ 0%, 100% { opacity: 1; }
188
+ 50% { opacity: 0.4; }
189
+ }
190
+ </style>
package/locales/de.json CHANGED
@@ -133,7 +133,27 @@
133
133
  "who_redeem": "Who redeemed",
134
134
  "created_by": "Published by",
135
135
  "no_data": "Campaign not found"
136
- }
136
+ },
137
+ "other_campaigns": "Andere Kampagnen",
138
+ "available": "verfügbar",
139
+ "closed": "Beendet",
140
+ "platform": "Plattform",
141
+ "region": "Region",
142
+ "description_optional": "Beschreibung (optional)",
143
+ "description_placeholder": "Erzähl uns etwas über deinen Kanal...",
144
+ "sending": "Wird gesendet...",
145
+ "request_sent": "Anfrage gesendet! Weiterleitung...",
146
+ "error_loading": "Kampagnendaten konnten nicht geladen werden.",
147
+ "error_request": "Anfrage konnte nicht gesendet werden. Bitte erneut versuchen.",
148
+ "error_reveal": "Schlüssel konnte nicht enthüllt werden.",
149
+ "redeem_title": "Schlüssel anfordern",
150
+ "reveal_hint": "Klicke unten, um deinen Schlüsselcode zu enthüllen.",
151
+ "your_key": "Dein Schlüssel",
152
+ "copy": "Kopieren",
153
+ "copied": "Kopiert!",
154
+ "redeem_on_platform": "Löse diesen Schlüssel auf der entsprechenden Plattform ein.",
155
+ "revealing": "Enthülle...",
156
+ "status": "Status"
137
157
  },
138
158
  "materials": {
139
159
  "title": "Submit material",
@@ -158,4 +178,4 @@
158
178
  "success_close": "Close"
159
179
  }
160
180
  }
161
- }
181
+ }
package/locales/en.json CHANGED
@@ -133,7 +133,27 @@
133
133
  "who_redeem": "Who redeemed",
134
134
  "created_by": "Published by",
135
135
  "no_data": "Campaign not found"
136
- }
136
+ },
137
+ "other_campaigns": "Other campaigns",
138
+ "available": "available",
139
+ "closed": "Closed",
140
+ "platform": "Platform",
141
+ "region": "Region",
142
+ "description_optional": "Description (optional)",
143
+ "description_placeholder": "Tell us a bit about your channel or content plans...",
144
+ "sending": "Sending...",
145
+ "request_sent": "Request sent! Redirecting...",
146
+ "error_loading": "Failed to load campaign data.",
147
+ "error_request": "Could not submit request. Please try again.",
148
+ "error_reveal": "Could not reveal key. Please try again.",
149
+ "redeem_title": "Request a Key",
150
+ "reveal_hint": "Click the button below to reveal your key code.",
151
+ "your_key": "Your Key",
152
+ "copy": "Copy",
153
+ "copied": "Copied!",
154
+ "redeem_on_platform": "Redeem this key on the platform where you received it.",
155
+ "revealing": "Revealing...",
156
+ "status": "Status"
137
157
  },
138
158
  "materials": {
139
159
  "title": "Submit material",
@@ -158,4 +178,4 @@
158
178
  "success_close": "Close"
159
179
  }
160
180
  }
161
- }
181
+ }
@@ -133,7 +133,27 @@
133
133
  "who_redeem": "Who redeemed",
134
134
  "created_by": "Published by",
135
135
  "no_data": "Campaign not found"
136
- }
136
+ },
137
+ "other_campaigns": "Outras campanhas",
138
+ "available": "disponível",
139
+ "closed": "Encerrada",
140
+ "platform": "Plataforma",
141
+ "region": "Região",
142
+ "description_optional": "Descrição (opcional)",
143
+ "description_placeholder": "Conte um pouco sobre seu canal ou planos de conteúdo...",
144
+ "sending": "Enviando...",
145
+ "request_sent": "Solicitação enviada! Redirecionando...",
146
+ "error_loading": "Falha ao carregar dados da campanha.",
147
+ "error_request": "Não foi possível enviar a solicitação. Tente novamente.",
148
+ "error_reveal": "Não foi possível revelar a chave. Tente novamente.",
149
+ "redeem_title": "Solicitar uma Chave",
150
+ "reveal_hint": "Clique no botão abaixo para revelar o código da sua chave.",
151
+ "your_key": "Sua Chave",
152
+ "copy": "Copiar",
153
+ "copied": "Copiado!",
154
+ "redeem_on_platform": "Resgate esta chave na plataforma correspondente.",
155
+ "revealing": "Revelando...",
156
+ "status": "Status"
137
157
  },
138
158
  "materials": {
139
159
  "title": "Submit material",
@@ -158,4 +178,4 @@
158
178
  "success_close": "Close"
159
179
  }
160
180
  }
161
- }
181
+ }
package/locales/ro.json CHANGED
@@ -133,7 +133,27 @@
133
133
  "who_redeem": "Who redeemed",
134
134
  "created_by": "Published by",
135
135
  "no_data": "Campaign not found"
136
- }
136
+ },
137
+ "other_campaigns": "Alte campanii",
138
+ "available": "disponibil",
139
+ "closed": "Închis",
140
+ "platform": "Platformă",
141
+ "region": "Regiune",
142
+ "description_optional": "Descriere (opțional)",
143
+ "description_placeholder": "Spune-ne puțin despre canalul tău...",
144
+ "sending": "Se trimite...",
145
+ "request_sent": "Cerere trimisă! Redirecționare...",
146
+ "error_loading": "Eroare la încărcarea datelor campaniei.",
147
+ "error_request": "Nu s-a putut trimite cererea. Încearcă din nou.",
148
+ "error_reveal": "Nu s-a putut dezvălui cheia.",
149
+ "redeem_title": "Solicită o Cheie",
150
+ "reveal_hint": "Apasă butonul de mai jos pentru a dezvălui codul cheii.",
151
+ "your_key": "Cheia Ta",
152
+ "copy": "Copiază",
153
+ "copied": "Copiat!",
154
+ "redeem_on_platform": "Activează această cheie pe platforma corespunzătoare.",
155
+ "revealing": "Se dezvăluie...",
156
+ "status": "Status"
137
157
  },
138
158
  "materials": {
139
159
  "title": "Submit material",
@@ -158,4 +178,4 @@
158
178
  "success_close": "Close"
159
179
  }
160
180
  }
161
- }
181
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.1.67",
3
+ "version": "1.1.68",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -41,6 +41,19 @@ const key = computed(() => keyData.value?.data || null);
41
41
  const loading = pending;
42
42
  const error = computed(() => !!fetchError.value || (!pending.value && !key.value));
43
43
 
44
+ const { $i18n } = useNuxtApp()
45
+ const t = $i18n.t.bind($i18n)
46
+
47
+ useSeoMeta({
48
+ title: () => key.value?.name ? t('keys.campaigns.seo.title', { game: key.value.name }) : t('keys.campaigns.seo.title_generic'),
49
+ description: () => key.value?.name ? t('keys.campaigns.seo.description', { game: key.value.name }) : t('keys.campaigns.seo.title_generic'),
50
+ ogTitle: () => key.value?.name ? t('keys.campaigns.seo.title', { game: key.value.name }) : t('keys.campaigns.seo.title_generic'),
51
+ ogDescription: () => key.value?.name ? t('keys.campaigns.seo.description', { game: key.value.name }) : t('keys.campaigns.seo.title_generic'),
52
+ ogImage: () => key.value?.cover_src || key.value?.cover || undefined,
53
+ ogType: 'website',
54
+ twitterCard: 'summary_large_image',
55
+ })
56
+
44
57
  const restriction = computed(() => {
45
58
  const k = key.value;
46
59
  if (!k) return '';
@@ -76,6 +89,8 @@ const restriction = computed(() => {
76
89
  :game-id="key.game?.id || key.game_id"
77
90
  />
78
91
  </div>
92
+
93
+ <KeyCampaignsCarousel :exclude-id="key.id ?? null" />
79
94
  </template>
80
95
  </div>
81
96
  </template>
@@ -681,8 +681,9 @@ onMounted(async () => {
681
681
  <img
682
682
  v-for="(u, index) in redeemedUsers?.users?.slice(0, 4)"
683
683
  :key="index"
684
- :src="u.avatar_url"
684
+ :src="u.avatar_url || '/imgs/avatar-default.jpg'"
685
685
  :alt="u.nickname"
686
+ @error="(e: Event) => { const t = e.target as HTMLImageElement; if (t && t.src !== '/imgs/avatar-default.jpg') t.src = '/imgs/avatar-default.jpg' }"
686
687
  />
687
688
  </div>
688
689
  <div class="text">
@@ -719,6 +720,11 @@ onMounted(async () => {
719
720
  </div>
720
721
  </div>
721
722
  </div>
723
+ <KeyCampaignsCarousel
724
+ :exclude-id="campaign?.id ?? null"
725
+ :mg-platform="mgPlatform"
726
+ class="container"
727
+ />
722
728
  </div>
723
729
  </template>
724
730
 
@@ -761,7 +767,7 @@ onMounted(async () => {
761
767
  justify-content: space-between;
762
768
 
763
769
  .btn {
764
- background: var(--key-accent, #D297FF);
770
+ background: var(--key-accent, var(--primary, #D297FF));
765
771
  display: flex;
766
772
  height: 44px;
767
773
  padding: 0 14px;
@@ -787,7 +793,7 @@ onMounted(async () => {
787
793
  align-items: center;
788
794
  gap: 6px;
789
795
  font-size: 13px;
790
- color: var(--key-accent, #D297FF);
796
+ color: var(--key-accent, var(--primary, #D297FF));
791
797
  cursor: pointer;
792
798
  text-decoration: none;
793
799
  white-space: nowrap;
@@ -806,7 +812,7 @@ onMounted(async () => {
806
812
  font-size: 14px;
807
813
  font-weight: 600;
808
814
  color: var(--card-article-title);
809
- span { color: var(--key-accent, #d297ff); }
815
+ span { color: var(--key-accent, var(--primary, #D297FF)); }
810
816
  .yellow { color: #FFD600; }
811
817
  .red { color: #ff4800; }
812
818
  }
@@ -819,7 +825,7 @@ onMounted(async () => {
819
825
 
820
826
  .progress {
821
827
  height: 24px;
822
- background-color: var(--key-accent, #d297ff);
828
+ background-color: var(--key-accent, var(--primary, #D297FF));
823
829
  span {
824
830
  position: absolute;
825
831
  right: 5px;
@@ -835,7 +841,7 @@ onMounted(async () => {
835
841
  .info {
836
842
  font-size: 12px;
837
843
  color: var(--secondary-info-fg);
838
- i { color: var(--key-accent, #d297ff); }
844
+ i { color: var(--key-accent, var(--primary, #D297FF)); }
839
845
  .yellow { color: #FFD600; }
840
846
  .red { color: #ff4800; }
841
847
  }
@@ -882,7 +888,7 @@ onMounted(async () => {
882
888
  padding: 4px 10px;
883
889
  font-size: 12px;
884
890
  font-weight: 600;
885
- color: var(--key-accent, #D297FF);
891
+ color: var(--key-accent, var(--primary, #D297FF));
886
892
  background: rgba(210, 151, 255, 0.12);
887
893
  border: 1px solid rgba(210, 151, 255, 0.35);
888
894
  i { font-size: 13px; }
@@ -981,7 +987,7 @@ onMounted(async () => {
981
987
  gap: 6px;
982
988
  background-color: var(--button-secondary-default-bg);
983
989
  font-size: 28px;
984
- color: var(--key-accent, #D297FF);
990
+ color: var(--key-accent, var(--primary, #D297FF));
985
991
  font-weight: 700;
986
992
  width: -webkit-fill-available;
987
993
  span { font-size: 10px; color: var(--secondary-info-fg); font-weight: 400; }
@@ -1015,7 +1021,7 @@ onMounted(async () => {
1015
1021
  width: -webkit-fill-available;
1016
1022
  cursor: pointer;
1017
1023
  &:hover, &.active {
1018
- background-color: var(--key-accent, #D297FF);
1024
+ background-color: var(--key-accent, var(--primary, #D297FF));
1019
1025
  color: #1C1C1C;
1020
1026
  i { color: #1C1C1C; }
1021
1027
  }
@@ -1060,7 +1066,7 @@ onMounted(async () => {
1060
1066
  flex-shrink: 0;
1061
1067
  border-radius: 100%;
1062
1068
  text-align-last: center;
1063
- i { color: var(--key-accent, #D297FF); }
1069
+ i { color: var(--key-accent, var(--primary, #D297FF)); }
1064
1070
  }
1065
1071
  .texts {
1066
1072
  display: flex;
@@ -1081,7 +1087,7 @@ onMounted(async () => {
1081
1087
  width: 100%;
1082
1088
  }
1083
1089
 
1084
- .reward span { color: var(--key-accent, #D297FF); }
1090
+ .reward span { color: var(--key-accent, var(--primary, #D297FF)); }
1085
1091
 
1086
1092
  .data {
1087
1093
  display: flex;
@@ -1114,7 +1120,7 @@ onMounted(async () => {
1114
1120
  font-size: 12px;
1115
1121
  color: var(--secondary-info-fg);
1116
1122
  width: -webkit-fill-available;
1117
- span { font-size: 16px; color: var(--key-accent, #D297FF); font-weight: 600; }
1123
+ span { font-size: 16px; color: var(--key-accent, var(--primary, #D297FF)); font-weight: 600; }
1118
1124
  }
1119
1125
  }
1120
1126
 
@@ -0,0 +1,311 @@
1
+ <script lang="ts" setup>
2
+ import { ref, inject, onMounted } from "vue"
3
+ import { useRoute, navigateTo } from "#app"
4
+ import { useNuxtApp } from "#app"
5
+ import { revealKey, fetchKeyRequestById } from "../../services/campaignService"
6
+
7
+ const route = useRoute()
8
+ const { $i18n } = useNuxtApp()
9
+ const locale = $i18n.locale
10
+
11
+ const currentUser = inject<any>("currentUser", null)
12
+ const loginUrl = inject<string>("loginUrl", "/")
13
+
14
+ const requestId = Number(route.query.requestId)
15
+ const keyItemsParam = route.query.keyItems as string | undefined
16
+
17
+ const loading = ref(true)
18
+ const revealing = ref(false)
19
+ const keyCode = ref<string | null>(null)
20
+ const error = ref("")
21
+ const requestData = ref<any>(null)
22
+ const copied = ref(false)
23
+
24
+ onMounted(async () => {
25
+ if (!currentUser) {
26
+ const returnTo = typeof window !== "undefined" ? window.location.href : ""
27
+ navigateTo(`${loginUrl}?redirect_to=${encodeURIComponent(returnTo)}`, { external: true })
28
+ return
29
+ }
30
+ if (!requestId) {
31
+ navigateTo(`/${locale.value}/key-campaigns`)
32
+ return
33
+ }
34
+ try {
35
+ const res = await fetchKeyRequestById(requestId)
36
+ requestData.value = res?.data?.data || res?.data || null
37
+ } catch {
38
+ error.value = $i18n.t("keys.campaigns.error_loading")
39
+ } finally {
40
+ loading.value = false
41
+ }
42
+ })
43
+
44
+ async function reveal() {
45
+ revealing.value = true
46
+ error.value = ""
47
+ try {
48
+ const res = await revealKey(requestId)
49
+ const data = res?.data?.data || res?.data || null
50
+ keyCode.value = data?.key || data?.code || data?.value || null
51
+ if (!keyCode.value) {
52
+ error.value = $i18n.t("keys.campaigns.error_reveal")
53
+ }
54
+ } catch (e: any) {
55
+ error.value = e?.response?.data?.message || $i18n.t("keys.campaigns.error_reveal")
56
+ } finally {
57
+ revealing.value = false
58
+ }
59
+ }
60
+
61
+ async function copyCode() {
62
+ if (!keyCode.value) return
63
+ try {
64
+ await navigator.clipboard.writeText(keyCode.value)
65
+ copied.value = true
66
+ setTimeout(() => { copied.value = false }, 2000)
67
+ } catch {
68
+ // fallback: select text
69
+ }
70
+ }
71
+
72
+ function goBack() {
73
+ if (typeof window !== "undefined" && window.history.length > 1) {
74
+ window.history.back()
75
+ } else {
76
+ navigateTo(`/${locale.value}/key-campaigns`)
77
+ }
78
+ }
79
+ </script>
80
+
81
+ <template>
82
+ <div id="shared-redeem-key-approved">
83
+ <div class="container">
84
+ <div class="header">
85
+ <NuxtLink @click.prevent="goBack" class="back">
86
+ <MGIcon size="2x" icon="chevron-left" />
87
+ <span>{{ $t("keys.campaigns.back") }}</span>
88
+ </NuxtLink>
89
+ </div>
90
+
91
+ <div v-if="loading" class="state">
92
+ <div class="skeleton" v-for="i in 2" :key="i" />
93
+ </div>
94
+
95
+ <div v-else-if="error && !keyCode" class="state error">{{ error }}</div>
96
+
97
+ <div v-else class="content">
98
+ <div class="title-bar">
99
+ <MGIcon icon="version" size="2x" class="title-icon" />
100
+ <div>
101
+ <h3>{{ $t("keys.campaigns.status_key.reveal_key") }}</h3>
102
+ <p v-if="requestData?.key?.name" class="campaign-name">{{ requestData.key.name }}</p>
103
+ </div>
104
+ </div>
105
+
106
+ <!-- Key not yet revealed -->
107
+ <div v-if="!keyCode" class="reveal-block">
108
+ <p class="hint">{{ $t("keys.campaigns.reveal_hint") }}</p>
109
+ <button
110
+ class="btn"
111
+ :class="{ disabled: revealing }"
112
+ :disabled="revealing"
113
+ @click="reveal"
114
+ >
115
+ {{ revealing ? $t("keys.campaigns.revealing") : $t("keys.campaigns.status_key.reveal_key") }}
116
+ </button>
117
+ <div v-if="error" class="req-error">{{ error }}</div>
118
+ </div>
119
+
120
+ <!-- Key revealed -->
121
+ <div v-else class="key-block">
122
+ <div class="key-label">{{ $t("keys.campaigns.your_key") }}</div>
123
+ <div class="key-code-row">
124
+ <span class="key-code">{{ keyCode }}</span>
125
+ <button class="copy-btn" @click="copyCode">
126
+ <MGIcon :icon="copied ? 'check' : 'copy'" size="1x" />
127
+ {{ copied ? $t("keys.campaigns.copied") : $t("keys.campaigns.copy") }}
128
+ </button>
129
+ </div>
130
+ <p class="redeem-info">{{ $t("keys.campaigns.redeem_on_platform") }}</p>
131
+ </div>
132
+
133
+ <!-- Request info -->
134
+ <div v-if="requestData" class="info-grid">
135
+ <div class="info-row" v-if="requestData.status_request?.name">
136
+ <span class="info-label">{{ $t("keys.campaigns.status") }}</span>
137
+ <span class="info-val">{{ requestData.status_request.name }}</span>
138
+ </div>
139
+ <div class="info-row" v-if="requestData.key?.name">
140
+ <span class="info-label">{{ $t("keys.campaigns.campaign.title") }}</span>
141
+ <span class="info-val">{{ requestData.key.name }}</span>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ </div>
147
+ </template>
148
+
149
+ <style lang="scss" scoped>
150
+ #shared-redeem-key-approved {
151
+ align-items: center;
152
+ width: 100%;
153
+ justify-content: center;
154
+ overflow: auto;
155
+ height: 100%;
156
+
157
+ .container { max-width: 680px !important; padding: 32px 16px 64px; }
158
+
159
+ .header {
160
+ margin-bottom: 24px;
161
+ .back {
162
+ display: inline-flex;
163
+ align-items: center;
164
+ gap: 8px;
165
+ font-size: 14px;
166
+ color: var(--secondary-info-fg);
167
+ cursor: pointer;
168
+ text-decoration: none;
169
+ &:hover { color: var(--card-article-title); }
170
+ }
171
+ }
172
+
173
+ .state {
174
+ display: flex;
175
+ flex-direction: column;
176
+ align-items: center;
177
+ gap: 12px;
178
+ padding: 64px 0;
179
+ text-align: center;
180
+ color: var(--secondary-info-fg);
181
+ &.error { color: #ff4800; }
182
+ }
183
+
184
+ .skeleton {
185
+ width: 100%;
186
+ height: 48px;
187
+ background: var(--bg-app-badge);
188
+ animation: pulse 1.4s ease-in-out infinite;
189
+ & + & { margin-top: 8px; }
190
+ }
191
+
192
+ .content { display: flex; flex-direction: column; gap: 28px; }
193
+
194
+ .title-bar {
195
+ display: flex;
196
+ align-items: flex-start;
197
+ gap: 14px;
198
+ .title-icon { color: var(--key-accent, var(--primary, #D297FF)); margin-top: 2px; }
199
+ h3 { font-size: 20px; font-weight: 700; color: var(--card-article-title); margin: 0 0 4px; }
200
+ .campaign-name { font-size: 13px; color: var(--secondary-info-fg); margin: 0; }
201
+ }
202
+
203
+ .reveal-block {
204
+ display: flex;
205
+ flex-direction: column;
206
+ gap: 16px;
207
+ padding: 24px;
208
+ background: var(--bg-app-badge);
209
+ border: 1px solid var(--button-secondary-default-bg);
210
+
211
+ .hint { font-size: 14px; color: var(--secondary-info-fg); margin: 0; }
212
+ }
213
+
214
+ .key-block {
215
+ display: flex;
216
+ flex-direction: column;
217
+ gap: 12px;
218
+ padding: 24px;
219
+ background: var(--bg-app-badge);
220
+ border: 1px solid var(--key-accent, var(--primary, #D297FF));
221
+ }
222
+
223
+ .key-label {
224
+ font-size: 12px;
225
+ font-weight: 600;
226
+ text-transform: uppercase;
227
+ letter-spacing: 0.5px;
228
+ color: var(--secondary-info-fg);
229
+ }
230
+
231
+ .key-code-row {
232
+ display: flex;
233
+ align-items: center;
234
+ gap: 12px;
235
+ flex-wrap: wrap;
236
+ }
237
+
238
+ .key-code {
239
+ font-size: 20px;
240
+ font-weight: 700;
241
+ font-family: monospace;
242
+ letter-spacing: 2px;
243
+ color: var(--card-article-title);
244
+ word-break: break-all;
245
+ }
246
+
247
+ .copy-btn {
248
+ display: inline-flex;
249
+ align-items: center;
250
+ gap: 6px;
251
+ padding: 6px 14px;
252
+ font-size: 13px;
253
+ font-weight: 600;
254
+ background: transparent;
255
+ border: 1px solid var(--key-accent, var(--primary, #D297FF));
256
+ color: var(--key-accent, var(--primary, #D297FF));
257
+ cursor: pointer;
258
+ transition: background 0.15s;
259
+ &:hover { background: rgba(210, 151, 255, 0.1); }
260
+ }
261
+
262
+ .redeem-info {
263
+ font-size: 12px;
264
+ color: var(--secondary-info-fg);
265
+ margin: 0;
266
+ }
267
+
268
+ .req-error { font-size: 13px; color: #ff4800; }
269
+
270
+ .info-grid {
271
+ display: flex;
272
+ flex-direction: column;
273
+ gap: 8px;
274
+ padding-top: 8px;
275
+ border-top: 1px solid var(--button-secondary-default-bg);
276
+ }
277
+
278
+ .info-row {
279
+ display: flex;
280
+ gap: 12px;
281
+ font-size: 13px;
282
+ }
283
+
284
+ .info-label {
285
+ color: var(--secondary-info-fg);
286
+ min-width: 100px;
287
+ }
288
+
289
+ .info-val { color: var(--card-article-title); }
290
+
291
+ .btn {
292
+ align-self: flex-start;
293
+ padding: 0 28px;
294
+ height: 44px;
295
+ font-size: 14px;
296
+ font-weight: 700;
297
+ background: var(--key-accent, var(--primary, #D297FF));
298
+ color: #fff;
299
+ border: none;
300
+ cursor: pointer;
301
+ transition: opacity 0.15s;
302
+ &.disabled, &:disabled { opacity: 0.45; cursor: not-allowed; }
303
+ &:not(.disabled):hover { opacity: 0.85; }
304
+ }
305
+ }
306
+
307
+ @keyframes pulse {
308
+ 0%, 100% { opacity: 1; }
309
+ 50% { opacity: 0.4; }
310
+ }
311
+ </style>
@@ -0,0 +1,320 @@
1
+ <script lang="ts" setup>
2
+ import { ref, computed, inject, onMounted } from "vue"
3
+ import { useRoute, navigateTo } from "#app"
4
+ import { useNuxtApp } from "#app"
5
+ import {
6
+ fetchCampaignBySlug,
7
+ fetchKeyPlatforms,
8
+ fetchKeyRegions,
9
+ requestKey,
10
+ } from "../../services/campaignService"
11
+
12
+ const route = useRoute()
13
+ const { $i18n } = useNuxtApp()
14
+ const locale = $i18n.locale
15
+
16
+ const currentUser = inject<any>("currentUser", null)
17
+ const loginUrl = inject<string>("loginUrl", "/")
18
+
19
+ const keyId = Number(route.query.keyId)
20
+
21
+ // Redirect away if no keyId
22
+ if (!keyId) {
23
+ navigateTo(`/${locale.value}/key-campaigns`)
24
+ }
25
+
26
+ const campaign = ref<any>(null)
27
+ const platforms = ref<any[]>([])
28
+ const regions = ref<any[]>([])
29
+ const loading = ref(true)
30
+ const submitting = ref(false)
31
+ const error = ref("")
32
+ const success = ref(false)
33
+ const requestError = ref("")
34
+
35
+ const selectedPlatformId = ref<number | null>(null)
36
+ const selectedRegionId = ref<number | null>(null)
37
+ const description = ref("")
38
+
39
+ onMounted(async () => {
40
+ if (!currentUser) {
41
+ const returnTo = typeof window !== "undefined" ? window.location.href : ""
42
+ navigateTo(`${loginUrl}?redirect_to=${encodeURIComponent(returnTo)}`, { external: true })
43
+ return
44
+ }
45
+ try {
46
+ const [campaignRes, platformsRes, regionsRes] = await Promise.all([
47
+ fetchCampaignBySlug(String(keyId)),
48
+ fetchKeyPlatforms(keyId),
49
+ fetchKeyRegions(keyId),
50
+ ])
51
+ campaign.value = campaignRes?.data?.data || campaignRes?.data || null
52
+ platforms.value = platformsRes?.data?.data || platformsRes?.data || []
53
+ regions.value = regionsRes?.data?.data || regionsRes?.data || []
54
+
55
+ // Pre-select if only one option
56
+ if (platforms.value.length === 1) selectedPlatformId.value = platforms.value[0].id
57
+ if (regions.value.length === 1) selectedRegionId.value = regions.value[0].id
58
+ } catch {
59
+ error.value = $i18n.t("keys.campaigns.error_loading")
60
+ } finally {
61
+ loading.value = false
62
+ }
63
+ })
64
+
65
+ const canSubmit = computed(() =>
66
+ !!selectedPlatformId.value && !!selectedRegionId.value && !submitting.value,
67
+ )
68
+
69
+ async function submit() {
70
+ if (!canSubmit.value) return
71
+ submitting.value = true
72
+ requestError.value = ""
73
+ try {
74
+ await requestKey({
75
+ key_id: keyId,
76
+ platform_ids: [selectedPlatformId.value],
77
+ region_ids: [selectedRegionId.value],
78
+ description: description.value || "-",
79
+ quantity_keys: 1,
80
+ })
81
+ success.value = true
82
+ setTimeout(() => {
83
+ navigateTo(`/${locale.value}/key-campaigns`)
84
+ }, 2000)
85
+ } catch (e: any) {
86
+ const msg = e?.response?.data?.message || e?.message || ""
87
+ requestError.value = msg || $i18n.t("keys.campaigns.error_request")
88
+ } finally {
89
+ submitting.value = false
90
+ }
91
+ }
92
+
93
+ function goBack() {
94
+ if (typeof window !== "undefined" && window.history.length > 1) {
95
+ window.history.back()
96
+ } else {
97
+ navigateTo(`/${locale.value}/key-campaigns`)
98
+ }
99
+ }
100
+ </script>
101
+
102
+ <template>
103
+ <div id="shared-redeem-key">
104
+ <div class="container">
105
+ <div class="header">
106
+ <NuxtLink @click.prevent="goBack" class="back">
107
+ <MGIcon size="2x" icon="chevron-left" />
108
+ <span>{{ $t("keys.campaigns.back") }}</span>
109
+ </NuxtLink>
110
+ </div>
111
+
112
+ <div v-if="loading" class="state">
113
+ <div class="skeleton" v-for="i in 3" :key="i" />
114
+ </div>
115
+
116
+ <div v-else-if="error" class="state error">{{ error }}</div>
117
+
118
+ <div v-else-if="success" class="state success">
119
+ <MGIcon icon="check-circle" size="3x" />
120
+ <p>{{ $t("keys.campaigns.request_sent") }}</p>
121
+ </div>
122
+
123
+ <div v-else class="form-content">
124
+ <div class="title-bar">
125
+ <h3>{{ $t("keys.campaigns.redeem_title") }}</h3>
126
+ <p v-if="campaign?.name" class="campaign-name">{{ campaign.name }}</p>
127
+ </div>
128
+
129
+ <form class="form" @submit.prevent="submit">
130
+ <!-- Platform -->
131
+ <div class="field">
132
+ <label class="label">{{ $t("keys.campaigns.platform") }}</label>
133
+ <div class="options">
134
+ <button
135
+ v-for="p in platforms"
136
+ :key="p.id"
137
+ type="button"
138
+ class="option"
139
+ :class="{ selected: selectedPlatformId === p.id }"
140
+ @click="selectedPlatformId = p.id"
141
+ >
142
+ {{ p.name }}
143
+ </button>
144
+ </div>
145
+ </div>
146
+
147
+ <!-- Region -->
148
+ <div class="field">
149
+ <label class="label">{{ $t("keys.campaigns.region") }}</label>
150
+ <div class="options">
151
+ <button
152
+ v-for="r in regions"
153
+ :key="r.id"
154
+ type="button"
155
+ class="option"
156
+ :class="{ selected: selectedRegionId === r.id }"
157
+ @click="selectedRegionId = r.id"
158
+ >
159
+ {{ r.name }}
160
+ </button>
161
+ </div>
162
+ </div>
163
+
164
+ <!-- Description (optional) -->
165
+ <div class="field">
166
+ <label class="label">{{ $t("keys.campaigns.description_optional") }}</label>
167
+ <textarea
168
+ v-model="description"
169
+ class="textarea"
170
+ :placeholder="$t('keys.campaigns.description_placeholder')"
171
+ rows="3"
172
+ />
173
+ </div>
174
+
175
+ <div v-if="requestError" class="req-error">{{ requestError }}</div>
176
+
177
+ <button
178
+ type="submit"
179
+ class="btn"
180
+ :class="{ disabled: !canSubmit }"
181
+ :disabled="!canSubmit"
182
+ >
183
+ <span v-if="submitting">{{ $t("keys.campaigns.sending") }}</span>
184
+ <span v-else>{{ $t("keys.campaigns.campaign.redeem") }}</span>
185
+ </button>
186
+ </form>
187
+ </div>
188
+ </div>
189
+ </div>
190
+ </template>
191
+
192
+ <style lang="scss" scoped>
193
+ #shared-redeem-key {
194
+ align-items: center;
195
+ width: 100%;
196
+ justify-content: center;
197
+ overflow: auto;
198
+ height: 100%;
199
+
200
+ .container { max-width: 680px !important; padding: 32px 16px 64px; }
201
+
202
+ .header {
203
+ margin-bottom: 24px;
204
+ .back {
205
+ display: inline-flex;
206
+ align-items: center;
207
+ gap: 8px;
208
+ font-size: 14px;
209
+ color: var(--secondary-info-fg);
210
+ cursor: pointer;
211
+ text-decoration: none;
212
+ &:hover { color: var(--card-article-title); }
213
+ }
214
+ }
215
+
216
+ .state {
217
+ display: flex;
218
+ flex-direction: column;
219
+ align-items: center;
220
+ gap: 12px;
221
+ padding: 64px 0;
222
+ text-align: center;
223
+ color: var(--secondary-info-fg);
224
+
225
+ &.error { color: #ff4800; }
226
+ &.success { color: #4caf50; font-size: 16px; }
227
+ }
228
+
229
+ .skeleton {
230
+ width: 100%;
231
+ height: 48px;
232
+ background: var(--bg-app-badge);
233
+ animation: pulse 1.4s ease-in-out infinite;
234
+ & + & { margin-top: 8px; }
235
+ }
236
+
237
+ .title-bar {
238
+ margin-bottom: 28px;
239
+ h3 { font-size: 20px; font-weight: 700; color: var(--card-article-title); margin: 0 0 6px; }
240
+ .campaign-name { font-size: 13px; color: var(--secondary-info-fg); margin: 0; }
241
+ }
242
+
243
+ .form { display: flex; flex-direction: column; gap: 24px; }
244
+
245
+ .field {
246
+ display: flex;
247
+ flex-direction: column;
248
+ gap: 10px;
249
+ }
250
+
251
+ .label {
252
+ font-size: 13px;
253
+ font-weight: 600;
254
+ color: var(--card-article-title);
255
+ }
256
+
257
+ .options {
258
+ display: flex;
259
+ flex-wrap: wrap;
260
+ gap: 8px;
261
+ }
262
+
263
+ .option {
264
+ padding: 8px 16px;
265
+ font-size: 13px;
266
+ font-weight: 500;
267
+ border: 1px solid var(--button-secondary-default-bg);
268
+ background: transparent;
269
+ color: var(--secondary-info-fg);
270
+ cursor: pointer;
271
+ transition: border-color 0.15s, color 0.15s;
272
+
273
+ &:hover { border-color: var(--key-accent, var(--primary, #D297FF)); color: var(--card-article-title); }
274
+ &.selected {
275
+ border-color: var(--key-accent, var(--primary, #D297FF));
276
+ color: var(--key-accent, var(--primary, #D297FF));
277
+ background: rgba(210, 151, 255, 0.08);
278
+ }
279
+ }
280
+
281
+ .textarea {
282
+ width: 100%;
283
+ background: var(--bg-app-badge);
284
+ border: 1px solid var(--button-secondary-default-bg);
285
+ color: var(--card-article-title);
286
+ font-size: 13px;
287
+ padding: 10px 12px;
288
+ resize: vertical;
289
+ outline: none;
290
+ font-family: inherit;
291
+ &:focus { border-color: var(--key-accent, var(--primary, #D297FF)); }
292
+ }
293
+
294
+ .req-error {
295
+ font-size: 13px;
296
+ color: #ff4800;
297
+ }
298
+
299
+ .btn {
300
+ align-self: flex-start;
301
+ padding: 0 28px;
302
+ height: 44px;
303
+ font-size: 14px;
304
+ font-weight: 700;
305
+ background: var(--key-accent, var(--primary, #D297FF));
306
+ color: #fff;
307
+ border: none;
308
+ cursor: pointer;
309
+ transition: opacity 0.15s;
310
+
311
+ &.disabled, &:disabled { opacity: 0.45; cursor: not-allowed; }
312
+ &:not(.disabled):hover { opacity: 0.85; }
313
+ }
314
+ }
315
+
316
+ @keyframes pulse {
317
+ 0%, 100% { opacity: 1; }
318
+ 50% { opacity: 0.4; }
319
+ }
320
+ </style>
@@ -8,14 +8,21 @@ import axios, { type AxiosInstance } from "axios"
8
8
  let _mainClient: AxiosInstance | null = null
9
9
  let _authClient: AxiosInstance | null = null
10
10
 
11
+ // Strip /api/v1 suffix so service paths (/api/v1/...) don't double up when the
12
+ // env var already contains it (e.g. VITE_BASE_URL_NETWORK=https://host/api/v1).
13
+ function normalizeBaseUrl(url: string): string {
14
+ return url.replace(/\/api\/v1\/?$/, "")
15
+ }
16
+
11
17
  function getMainClient(): AxiosInstance {
12
18
  if (_mainClient) return _mainClient
13
19
 
14
- const baseURL =
20
+ const baseURL = normalizeBaseUrl(
15
21
  import.meta.env.VITE_CAMPAIGN_API_URL ||
16
22
  import.meta.env.VITE_BASE_URL_NETWORK ||
17
23
  import.meta.env.VITE_API_BASE_URL ||
18
- ""
24
+ "",
25
+ )
19
26
 
20
27
  _mainClient = axios.create({ baseURL })
21
28
 
@@ -41,11 +48,12 @@ function getMainClient(): AxiosInstance {
41
48
  function getAuthClient(): AxiosInstance {
42
49
  if (_authClient) return _authClient
43
50
 
44
- const baseURL =
51
+ const baseURL = normalizeBaseUrl(
45
52
  import.meta.env.VITE_CAMPAIGN_API_URL ||
46
53
  import.meta.env.VITE_BASE_URL_NETWORK ||
47
54
  import.meta.env.VITE_API_BASE_URL ||
48
- ""
55
+ "",
56
+ )
49
57
 
50
58
  _authClient = axios.create({ baseURL, withCredentials: true })
51
59