@mundogamernetwork/shared-ui 1.1.82 → 1.1.84

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/locales/de.json CHANGED
@@ -135,7 +135,8 @@
135
135
  "no_data": "Campaign not found",
136
136
  "title": "Kampagne",
137
137
  "about": "Über das Spiel",
138
- "screenshot_alt": "Spiel-Screenshot"
138
+ "screenshot_alt": "Spiel-Screenshot",
139
+ "see_all": "alle anzeigen"
139
140
  },
140
141
  "other_campaigns": "Andere Kampagnen",
141
142
  "available": "verfügbar",
@@ -184,7 +185,23 @@
184
185
  "status_completed": "Anfrage abgeschlossen",
185
186
  "status_refused": "Anfrage abgelehnt",
186
187
  "status_cancelled": "Anfrage storniert",
187
- "refused_reason": "Grund der Ablehnung:"
188
+ "refused_reason": "Grund der Ablehnung:",
189
+ "good": "Super!",
190
+ "good_text": "Du hast einen Code zum Einlösen erhalten",
191
+ "approved_subtitle": "Folge den Schritten und löse deinen Schlüssel ein.",
192
+ "cody_text": "Der Code kann nur einmal eingelöst werden und läuft nach Ende des Angebots ab",
193
+ "redeem_description_1": "Öffne die Website oder App der entsprechenden Plattform (Steam, PlayStation, Xbox usw.) und melde dich bei deinem Konto an.",
194
+ "redeem_description_2": "Gehe zur Option „Code einlösen“, füge den kopierten Code ein und schließe den Vorgang ab.",
195
+ "redeem_description_3": "Der Inhalt wird auf deinem Konto aktiviert.",
196
+ "redeem_at": "Auf {platform} einlösen",
197
+ "no_redeemers": "Noch niemand hat diese Kampagne eingelöst.",
198
+ "required_fields": "Bitte alle Pflichtfelder ausfüllen",
199
+ "plan": "Deckungspläne",
200
+ "plan_placeholder": "Beschreibe deinen Berichterstattungsplan: wo du über das Spiel streamen oder posten wirst, dein Publikum und das geplante Veröffentlichungsdatum",
201
+ "resources_title": "Ressourcen",
202
+ "press_kit_link": "Presse-Kit",
203
+ "review_guideline": "Review-Richtlinie",
204
+ "embargo_date": "Sperrfrist"
188
205
  },
189
206
  "materials": {
190
207
  "title": "Submit material",
package/locales/en.json CHANGED
@@ -135,7 +135,8 @@
135
135
  "no_data": "Campaign not found",
136
136
  "title": "Campaign",
137
137
  "about": "About the game",
138
- "screenshot_alt": "Game screenshot"
138
+ "screenshot_alt": "Game screenshot",
139
+ "see_all": "see all"
139
140
  },
140
141
  "other_campaigns": "Other campaigns",
141
142
  "available": "available",
@@ -184,7 +185,23 @@
184
185
  "status_completed": "Request completed",
185
186
  "status_refused": "Request refused",
186
187
  "status_cancelled": "Request cancelled",
187
- "refused_reason": "Reason for refusal:"
188
+ "refused_reason": "Reason for refusal:",
189
+ "good": "Nice!",
190
+ "good_text": "You received a code to redeem",
191
+ "approved_subtitle": "Follow the steps and redeem your key.",
192
+ "cody_text": "The code can only be redeemed once and expires after the offer ends",
193
+ "redeem_description_1": "Open the website or app of the corresponding platform (Steam, PlayStation, Xbox, etc.) and log in to your account.",
194
+ "redeem_description_2": "Go to the 'Redeem code' option, paste the copied code, and complete the process.",
195
+ "redeem_description_3": "The content will be activated on your account.",
196
+ "redeem_at": "Redeem on {platform}",
197
+ "no_redeemers": "No one has redeemed this campaign yet.",
198
+ "required_fields": "Please fill in all required fields",
199
+ "plan": "Coverage plans",
200
+ "plan_placeholder": "Describe your coverage plan: where you will stream or post about this game, your audience, and expected publish date",
201
+ "resources_title": "Resources",
202
+ "press_kit_link": "Press Kit",
203
+ "review_guideline": "Review guideline",
204
+ "embargo_date": "Embargo date"
188
205
  },
189
206
  "materials": {
190
207
  "title": "Submit material",
@@ -135,7 +135,8 @@
135
135
  "no_data": "Campaign not found",
136
136
  "title": "Campanha",
137
137
  "about": "Sobre o jogo",
138
- "screenshot_alt": "Captura de tela do jogo"
138
+ "screenshot_alt": "Captura de tela do jogo",
139
+ "see_all": "ver todos"
139
140
  },
140
141
  "other_campaigns": "Outras campanhas",
141
142
  "available": "disponível",
@@ -184,7 +185,23 @@
184
185
  "status_completed": "Solicitação concluída",
185
186
  "status_refused": "Solicitação recusada",
186
187
  "status_cancelled": "Solicitação cancelada",
187
- "refused_reason": "Motivo da recusa:"
188
+ "refused_reason": "Motivo da recusa:",
189
+ "good": "Boa!",
190
+ "good_text": "Você recebeu um código para resgatar",
191
+ "approved_subtitle": "Siga as etapas e resgate sua chave.",
192
+ "cody_text": "O código só pode ser resgatado uma vez e expira após o término da oferta",
193
+ "redeem_description_1": "Abra o site ou app da plataforma correspondente (Steam, PlayStation, Xbox, etc.) e faça login na sua conta.",
194
+ "redeem_description_2": " Vá até a opção “Resgatar código”, cole o código copiado e finalize.",
195
+ "redeem_description_3": "O conteúdo será ativado na sua conta.",
196
+ "redeem_at": "Resgatar em {platform}",
197
+ "no_redeemers": "Nenhum criador resgatou esta campanha ainda.",
198
+ "required_fields": "Por favor, preencha todos os campos obrigatórios",
199
+ "plan": " Planos de cobertura",
200
+ "plan_placeholder": "Descreva seu plano de cobertura: onde vai fazer stream ou publicar sobre o jogo, seu público e a data prevista de publicação",
201
+ "resources_title": "Recursos",
202
+ "press_kit_link": "Press Kit",
203
+ "review_guideline": "Diretriz de review",
204
+ "embargo_date": "Data de embargo"
188
205
  },
189
206
  "materials": {
190
207
  "title": "Submit material",
package/locales/ro.json CHANGED
@@ -135,7 +135,8 @@
135
135
  "no_data": "Campaign not found",
136
136
  "title": "Campanie",
137
137
  "about": "Despre joc",
138
- "screenshot_alt": "Captură de ecran a jocului"
138
+ "screenshot_alt": "Captură de ecran a jocului",
139
+ "see_all": "vezi toate"
139
140
  },
140
141
  "other_campaigns": "Alte campanii",
141
142
  "available": "disponibil",
@@ -184,7 +185,23 @@
184
185
  "status_completed": "Cerere finalizată",
185
186
  "status_refused": "Cerere refuzată",
186
187
  "status_cancelled": "Cerere anulată",
187
- "refused_reason": "Motivul refuzului:"
188
+ "refused_reason": "Motivul refuzului:",
189
+ "good": "Super!",
190
+ "good_text": "Ai primit un cod de revendicare",
191
+ "approved_subtitle": "Urmează pașii și revendică-ți cheia.",
192
+ "cody_text": "Codul poate fi folosit o singură dată și expiră la finalul ofertei",
193
+ "redeem_description_1": "Deschide site-ul sau aplicația platformei corespunzătoare (Steam, PlayStation, Xbox etc.) și autentifică-te în contul tău.",
194
+ "redeem_description_2": "Accesează opțiunea „Redeem code”, lipește codul copiat și finalizează procesul.",
195
+ "redeem_description_3": "Conținutul va fi activat în contul tău.",
196
+ "redeem_at": "Revendică pe {platform}",
197
+ "no_redeemers": "Nimeni nu a revendicat această campanie încă.",
198
+ "required_fields": "Vă rugăm să completați toate câmpurile obligatorii",
199
+ "plan": "Planuri de acoperire",
200
+ "plan_placeholder": "Descrie planul tău de acoperire: unde vei face stream sau vei posta despre joc, publicul tău și data estimată de publicare",
201
+ "resources_title": "Resurse",
202
+ "press_kit_link": "Press Kit",
203
+ "review_guideline": "Ghid de recenzie",
204
+ "embargo_date": "Data embargo"
188
205
  },
189
206
  "materials": {
190
207
  "title": "Submit material",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.1.82",
3
+ "version": "1.1.84",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -10,7 +10,7 @@ import {
10
10
  revealKey,
11
11
  requestKey,
12
12
  } from "../../services/campaignService"
13
- import { fetchDetailGameImage } from "../../services/gamesService"
13
+ import { fetchDetailGame, fetchDetailGameImage } from "../../services/gamesService"
14
14
 
15
15
  // Injected by each front — provide in app.vue / a plugin
16
16
  const mgPlatform = inject<string>("mgPlatform", "MGTV")
@@ -136,6 +136,20 @@ const { data: gameDetailsImages } = await useAsyncData(
136
136
  { watch: [campaign] },
137
137
  )
138
138
 
139
+ const { data: gameDetails } = await useAsyncData(
140
+ `campaign-game-detail-${slug}`,
141
+ async () => {
142
+ if (!campaign.value?.game_slug) return null
143
+ try {
144
+ const response = await fetchDetailGame(locale.value, campaign.value.game_slug)
145
+ return response.data.data || null
146
+ } catch {
147
+ return null
148
+ }
149
+ },
150
+ { watch: [campaign] },
151
+ )
152
+
139
153
  const showVideoModal = ref(false)
140
154
  const selectedVideoUrl = ref("")
141
155
  const showImageModal = ref(false)
@@ -590,7 +604,9 @@ onMounted(async () => {
590
604
  </div>
591
605
  </div>
592
606
 
593
- <div class="content-key">
607
+ <div v-if="!campaign" class="state error">{{ $t("keys.campaigns.error_loading") }}</div>
608
+
609
+ <div v-else class="content-key">
594
610
  <div class="content-key-header">
595
611
  <h4>{{ campaign?.name }}</h4>
596
612
 
@@ -600,6 +616,17 @@ onMounted(async () => {
600
616
  {{ b }}
601
617
  </div>
602
618
  </div>
619
+
620
+ <div class="tags-items" v-if="gameDetails?.platforms?.length">
621
+ <div class="tag" v-for="platform in gameDetails.platforms" :key="platform.id">
622
+ {{ platform.name }}
623
+ </div>
624
+ </div>
625
+ <div class="tags-items" v-if="gameDetails?.genres?.length">
626
+ <div class="tag" v-for="genre in gameDetails.genres" :key="genre.id">
627
+ {{ genre.localized_name }}
628
+ </div>
629
+ </div>
603
630
  </div>
604
631
 
605
632
  <!-- Cover when no game details -->
@@ -804,6 +831,36 @@ onMounted(async () => {
804
831
  <span>+{{ redeemedUsers?.total ?? 0 }}</span>
805
832
  {{ $t("keys.campaigns.creators") }}
806
833
  </div>
834
+ <div class="see" v-if="(redeemedUsers?.users?.length ?? 0) > 0" @click="showUsersModal = true">
835
+ {{ $t("keys.campaigns.campaign.see_all") }}
836
+ </div>
837
+ </div>
838
+ </div>
839
+
840
+ <div class="users-modal" v-if="showUsersModal" @click.self="showUsersModal = false">
841
+ <div class="users-modal-content">
842
+ <div class="users-modal-header">
843
+ <span>{{ $t("keys.campaigns.campaign.who_redeem") }}</span>
844
+ <button class="close-button" @click="showUsersModal = false">
845
+ <MGIcon size="1x" icon="close" />
846
+ </button>
847
+ </div>
848
+ <div class="users-modal-body">
849
+ <div
850
+ v-if="!redeemedUsers?.users?.length"
851
+ class="users-modal-empty"
852
+ >
853
+ {{ $t("keys.campaigns.no_redeemers") }}
854
+ </div>
855
+ <div v-for="(u, index) in redeemedUsers?.users" :key="index" class="users-modal-row">
856
+ <img
857
+ :src="u.avatar_url || '/imgs/avatar-default.jpg'"
858
+ :alt="u.nickname"
859
+ @error="(e: Event) => { const t = e.target as HTMLImageElement; if (t && t.src !== '/imgs/avatar-default.jpg') t.src = '/imgs/avatar-default.jpg' }"
860
+ />
861
+ <span>{{ u.nickname || `${u.first_name || ''} ${u.last_name || ''}`.trim() }}</span>
862
+ </div>
863
+ </div>
807
864
  </div>
808
865
  </div>
809
866
 
@@ -976,6 +1033,13 @@ onMounted(async () => {
976
1033
  img { width: 100%; height: 400px; object-fit: cover; object-position: center top; }
977
1034
  }
978
1035
 
1036
+ .state.error {
1037
+ padding: 64px 0;
1038
+ text-align: center;
1039
+ font-size: 14px;
1040
+ color: var(--danger, #e53935);
1041
+ }
1042
+
979
1043
  .content-key {
980
1044
  display: flex;
981
1045
  flex-direction: column;
@@ -1008,6 +1072,24 @@ onMounted(async () => {
1008
1072
  i { font-size: 13px; }
1009
1073
  }
1010
1074
  }
1075
+
1076
+ .tags-items {
1077
+ display: flex;
1078
+ gap: 4px;
1079
+ flex-flow: wrap;
1080
+
1081
+ .tag {
1082
+ height: 32px;
1083
+ display: flex;
1084
+ font-size: 12px;
1085
+ color: var(--secondary-info-fg);
1086
+ font-weight: 400;
1087
+ background-color: var(--button-secondary-default-bg);
1088
+ justify-content: center;
1089
+ padding: 12px;
1090
+ align-items: center;
1091
+ }
1092
+ }
1011
1093
  }
1012
1094
 
1013
1095
  .cards {
@@ -1306,6 +1388,70 @@ onMounted(async () => {
1306
1388
  width: -webkit-fill-available;
1307
1389
  span { font-size: 16px; color: var(--key-accent, var(--primary, var(--key-accent, var(--primary, #D297FF)))); font-weight: 600; }
1308
1390
  }
1391
+
1392
+ .see {
1393
+ font-size: 12px;
1394
+ color: var(--key-accent, var(--primary, #D297FF));
1395
+ cursor: pointer;
1396
+ text-decoration: underline;
1397
+ white-space: nowrap;
1398
+ }
1399
+ }
1400
+
1401
+ .users-modal {
1402
+ position: fixed;
1403
+ inset: 0;
1404
+ z-index: 1000;
1405
+ display: flex;
1406
+ align-items: center;
1407
+ justify-content: center;
1408
+ background: var(--overlay-dark-strong, rgba(0, 0, 0, 0.75));
1409
+
1410
+ .users-modal-content {
1411
+ width: 100%;
1412
+ max-width: 360px;
1413
+ max-height: 70vh;
1414
+ background: var(--button-secondary-default-bg);
1415
+ display: flex;
1416
+ flex-direction: column;
1417
+ }
1418
+
1419
+ .users-modal-header {
1420
+ display: flex;
1421
+ align-items: center;
1422
+ justify-content: space-between;
1423
+ padding: 16px;
1424
+ font-size: 14px;
1425
+ font-weight: 600;
1426
+ color: var(--card-article-title);
1427
+ border-bottom: 1px solid var(--bg-app-badge);
1428
+
1429
+ .close-button { background: transparent; border: none; color: var(--secondary-info-fg); cursor: pointer; }
1430
+ }
1431
+
1432
+ .users-modal-body {
1433
+ overflow-y: auto;
1434
+ padding: 8px 16px;
1435
+ }
1436
+
1437
+ .users-modal-empty {
1438
+ padding: 24px 0;
1439
+ text-align: center;
1440
+ font-size: 13px;
1441
+ color: var(--secondary-info-fg);
1442
+ }
1443
+
1444
+ .users-modal-row {
1445
+ display: flex;
1446
+ align-items: center;
1447
+ gap: 10px;
1448
+ padding: 10px 0;
1449
+ border-bottom: 1px solid var(--bg-app-badge);
1450
+ &:last-child { border-bottom: none; }
1451
+
1452
+ img { width: 36px; height: 36px; border-radius: 0; }
1453
+ span { font-size: 13px; color: var(--card-article-title); }
1454
+ }
1309
1455
  }
1310
1456
 
1311
1457
  .company {
@@ -267,7 +267,11 @@
267
267
  </div>
268
268
  </div>
269
269
 
270
- <div v-if="!loading && filteredCards.length === 0" class="d-flex flex-column align-items-center">
270
+ <div v-if="!loading && loadError" class="d-flex flex-column align-items-center">
271
+ <div class="no-content"><MGIcon icon="eye-slash" /></div>
272
+ <h4>{{ $t("keys.campaigns.error_loading") }}</h4>
273
+ </div>
274
+ <div v-else-if="!loading && filteredCards.length === 0" class="d-flex flex-column align-items-center">
271
275
  <div class="no-content"><MGIcon icon="eye-slash" /></div>
272
276
  <h4>{{ $t("keys.campaigns.no_results") }}</h4>
273
277
  </div>
@@ -364,6 +368,7 @@ let slideInterval: ReturnType<typeof setInterval> | null = null
364
368
  const loading = ref(true)
365
369
  const loadingFeatured = ref(true)
366
370
  const loadingRequests = ref(false)
371
+ const loadError = ref(false)
367
372
 
368
373
  // ------------------------------------------------------------------
369
374
  // Filters
@@ -556,9 +561,11 @@ async function loadCampaigns(page = 1) {
556
561
  }
557
562
 
558
563
  currentPage.value = page
564
+ loadError.value = false
559
565
  applyUserRequestStatuses()
560
566
  } catch {
561
567
  hasMoreItems.value = false
568
+ if (page === 1) loadError.value = true
562
569
  } finally {
563
570
  loading.value = false
564
571
  loadingMore.value = false
@@ -20,6 +20,31 @@ const keyCode = ref<string | null>(null)
20
20
  const error = ref("")
21
21
  const requestData = ref<any>(null)
22
22
  const copied = ref(false)
23
+ const platform = ref("")
24
+
25
+ // Maps a platform name to its official key-activation page.
26
+ const REDEEM_URLS: Record<string, string> = {
27
+ steam: "https://store.steampowered.com/account/registerkey",
28
+ playstation: "https://store.playstation.com/redeem",
29
+ "playstation 4": "https://store.playstation.com/redeem",
30
+ "playstation 5": "https://store.playstation.com/redeem",
31
+ xbox: "https://redeem.microsoft.com",
32
+ "xbox one": "https://redeem.microsoft.com",
33
+ "xbox series x/s": "https://redeem.microsoft.com",
34
+ "pc (microsoft windows)": "https://store.steampowered.com/account/registerkey",
35
+ epic: "https://store.epicgames.com/redeem",
36
+ "epic games": "https://store.epicgames.com/redeem",
37
+ gog: "https://www.gog.com/redeem",
38
+ nintendo: "https://ecommerce.nintendo.com/redeem",
39
+ "nintendo switch": "https://ecommerce.nintendo.com/redeem",
40
+ "google stadia": "https://stadia.google.com/redeem",
41
+ }
42
+
43
+ const redeemUrl = computed(() => {
44
+ const key = (platform.value || "").trim().toLowerCase()
45
+ if (!key) return ""
46
+ return REDEEM_URLS[key] || ""
47
+ })
23
48
 
24
49
  onMounted(async () => {
25
50
  if (!currentUser) {
@@ -34,6 +59,9 @@ onMounted(async () => {
34
59
  try {
35
60
  const res = await fetchKeyRequestById(requestId)
36
61
  requestData.value = res?.data?.data || res?.data || null
62
+ // Platform the user chose at request time — shown as a fallback/preview
63
+ // even before reveal, in case the assigned key item has no platform set.
64
+ platform.value = requestData.value?.platforms?.[0]?.name || ""
37
65
  } catch {
38
66
  error.value = $i18n.t("keys.campaigns.error_loading")
39
67
  } finally {
@@ -47,7 +75,10 @@ async function reveal() {
47
75
  try {
48
76
  const res = await revealKey(requestId)
49
77
  const data = res?.data?.data || res?.data || null
50
- keyCode.value = data?.key || data?.code || data?.value || null
78
+ keyCode.value = data?.code || (typeof data?.key === "string" ? data.key : null) || data?.value || null
79
+ // Keep the pre-reveal platform (chosen at request time) as fallback if the
80
+ // revealed item has no platform of its own.
81
+ platform.value = data?.platform || data?.platforms?.[0]?.name || platform.value
51
82
  if (!keyCode.value) {
52
83
  error.value = $i18n.t("keys.campaigns.error_reveal")
53
84
  }
@@ -76,6 +107,13 @@ async function copyCode() {
76
107
  }
77
108
  }
78
109
 
110
+ function formatDate(dateString: string | null | undefined): string {
111
+ if (!dateString) return "-"
112
+ const date = new Date(dateString)
113
+ if (isNaN(date.getTime())) return "-"
114
+ return new Intl.DateTimeFormat(locale.value, { day: "numeric", month: "long", year: "numeric" }).format(date)
115
+ }
116
+
79
117
  function goBack() {
80
118
  if (typeof window !== "undefined" && window.history.length > 1) {
81
119
  window.history.back()
@@ -103,49 +141,98 @@ function goBack() {
103
141
 
104
142
  <div v-else class="content">
105
143
  <div class="title-bar">
106
- <MGIcon icon="version" size="2x" class="title-icon" />
107
- <div>
108
- <h3>{{ $t("keys.campaigns.status_key.reveal_key") }}</h3>
109
- <p v-if="requestData?.key?.name" class="campaign-name">{{ requestData.key.name }}</p>
110
- </div>
111
- </div>
112
-
113
- <!-- Key not yet revealed -->
114
- <div v-if="!keyCode" class="reveal-block">
115
- <p class="hint">{{ $t("keys.campaigns.reveal_hint") }}</p>
116
- <button
117
- class="btn"
118
- :class="{ disabled: revealing }"
119
- :disabled="revealing"
120
- @click="reveal"
121
- >
122
- {{ revealing ? $t("keys.campaigns.revealing") : $t("keys.campaigns.status_key.reveal_key") }}
123
- </button>
124
- <div v-if="error" class="req-error">{{ error }}</div>
144
+ <h3>
145
+ <span class="accent-text">{{ $t("keys.campaigns.good") }}</span>
146
+ {{ $t("keys.campaigns.good_text") }}
147
+ <span class="accent-text">{{ currentUser?.nickname || currentUser?.name }}</span>
148
+ </h3>
149
+ <p class="description">{{ $t("keys.campaigns.approved_subtitle") }}</p>
150
+ <p v-if="requestData?.key?.name" class="campaign-name">{{ requestData.key.name }}</p>
151
+ <p v-if="platform" class="platform-name">{{ $t("keys.campaigns.platform") }}: {{ platform }}</p>
125
152
  </div>
126
153
 
127
- <!-- Key revealed -->
128
- <div v-else class="key-block">
129
- <div class="key-label">{{ $t("keys.campaigns.your_key") }}</div>
130
- <div class="key-code-row">
131
- <span class="key-code">{{ keyCode }}</span>
132
- <button class="copy-btn" @click="copyCode">
133
- <MGIcon :icon="copied ? 'check' : 'copy'" size="1x" />
134
- {{ copied ? $t("keys.campaigns.copied") : $t("keys.campaigns.copy") }}
135
- </button>
136
- </div>
137
- <p class="redeem-info">{{ $t("keys.campaigns.redeem_on_platform") }}</p>
138
- </div>
139
-
140
- <!-- Request info -->
141
- <div v-if="requestData" class="info-grid">
142
- <div class="info-row" v-if="requestData.status_request?.name">
143
- <span class="info-label">{{ $t("keys.campaigns.status_label") }}</span>
144
- <span class="info-val">{{ requestData.status_request.name }}</span>
145
- </div>
146
- <div class="info-row" v-if="requestData.key?.name">
147
- <span class="info-label">{{ $t("keys.campaigns.campaign.title") }}</span>
148
- <span class="info-val">{{ requestData.key.name }}</span>
154
+ <div class="cards">
155
+ <div class="side">
156
+ <div class="code-list">
157
+ <div class="code">
158
+ <div class="number">1</div>
159
+ <div class="texts">
160
+ <div class="code-box" v-if="!keyCode">
161
+ <input type="text" value="••••••••••••••••" readonly class="code-masked" />
162
+ <button class="btn" :class="{ disabled: revealing }" :disabled="revealing" @click="reveal">
163
+ {{ revealing ? $t("keys.campaigns.revealing") : $t("keys.campaigns.status_key.reveal_key") }}
164
+ </button>
165
+ </div>
166
+
167
+ <div class="code-box" v-else>
168
+ <input type="text" :value="keyCode" readonly class="code-value" />
169
+ <button class="btn" @click="copyCode">
170
+ <MGIcon :icon="copied ? 'check' : 'copy'" size="1x" />
171
+ {{ copied ? $t("keys.campaigns.copied") : $t("keys.campaigns.copy") }}
172
+ </button>
173
+ </div>
174
+
175
+ <div v-if="error" class="req-error">{{ error }}</div>
176
+
177
+ <div class="info">
178
+ <MGIcon size="1x" icon="version" />
179
+ {{ $t("keys.campaigns.cody_text") }}
180
+ </div>
181
+ </div>
182
+ </div>
183
+
184
+ <div class="code">
185
+ <div class="number">2</div>
186
+ <div class="texts">
187
+ <div class="description">{{ $t("keys.campaigns.redeem_description_1") }}</div>
188
+ <a
189
+ v-if="keyCode && redeemUrl"
190
+ :href="redeemUrl"
191
+ target="_blank"
192
+ rel="noopener"
193
+ class="btn redeem-link"
194
+ >
195
+ {{ $t("keys.campaigns.redeem_at", { platform }) }}
196
+ </a>
197
+ </div>
198
+ </div>
199
+
200
+ <div class="code">
201
+ <div class="number">3</div>
202
+ <div class="texts">
203
+ <div class="description">
204
+ {{ $t("keys.campaigns.redeem_description_2") }}
205
+ <br />
206
+ {{ $t("keys.campaigns.redeem_description_3") }}
207
+ </div>
208
+ </div>
209
+ </div>
210
+ </div>
211
+
212
+ <div class="resources" v-if="requestData?.key?.press_kit_link || requestData?.key?.review_guideline || requestData?.key?.embargo_date">
213
+ <div class="resources-title">{{ $t("keys.campaigns.resources_title") }}</div>
214
+
215
+ <a
216
+ v-if="requestData?.key?.press_kit_link"
217
+ :href="requestData.key.press_kit_link"
218
+ target="_blank"
219
+ rel="noopener"
220
+ class="resource-link"
221
+ >
222
+ <MGIcon size="1x" icon="link" />
223
+ {{ $t("keys.campaigns.press_kit_link") }}
224
+ </a>
225
+
226
+ <div v-if="requestData?.key?.review_guideline" class="resource-item">
227
+ <div class="resource-label">{{ $t("keys.campaigns.review_guideline") }}</div>
228
+ <div class="resource-text">{{ requestData.key.review_guideline }}</div>
229
+ </div>
230
+
231
+ <div v-if="requestData?.key?.embargo_date" class="resource-item">
232
+ <div class="resource-label">{{ $t("keys.campaigns.embargo_date") }}</div>
233
+ <div class="resource-text">{{ formatDate(requestData.key.embargo_date) }}</div>
234
+ </div>
235
+ </div>
149
236
  </div>
150
237
  </div>
151
238
  </div>
@@ -196,119 +283,153 @@ function goBack() {
196
283
  & + & { margin-top: 8px; }
197
284
  }
198
285
 
199
- .content { display: flex; flex-direction: column; gap: 28px; }
286
+ .content { display: flex; flex-direction: column; gap: 42px; }
200
287
 
201
288
  .title-bar {
202
- display: flex;
203
- align-items: flex-start;
204
- gap: 14px;
205
- .title-icon { color: var(--key-accent, var(--primary, var(--key-accent, var(--primary, #D297FF)))); margin-top: 2px; }
206
- h3 { font-size: 20px; font-weight: 700; color: var(--card-article-title); margin: 0 0 4px; }
207
- .campaign-name { font-size: 13px; color: var(--secondary-info-fg); margin: 0; }
289
+ h3 {
290
+ font-size: 24px;
291
+ font-weight: 700;
292
+ color: var(--card-article-title);
293
+ margin: 0 0 8px;
294
+ }
295
+ .accent-text { color: var(--key-accent, var(--primary, #D297FF)); }
296
+ .description { font-size: 14px; color: var(--secondary-info-fg); margin: 0; }
297
+ .campaign-name { font-size: 13px; font-weight: 600; color: var(--card-article-title); margin: 4px 0 0; }
298
+ .platform-name { font-size: 12px; color: var(--secondary-info-fg); margin: 2px 0 0; }
208
299
  }
209
300
 
210
- .reveal-block {
301
+ .cards {
211
302
  display: flex;
212
- flex-direction: column;
213
- gap: 16px;
214
- padding: 24px;
215
- background: var(--bg-app-badge);
216
- border: 1px solid var(--button-secondary-default-bg);
217
-
218
- .hint { font-size: 14px; color: var(--secondary-info-fg); margin: 0; }
303
+ width: 100%;
219
304
  }
220
305
 
221
- .key-block {
306
+ .side {
222
307
  display: flex;
223
308
  flex-direction: column;
224
- gap: 12px;
225
- padding: 24px;
226
- background: var(--bg-app-badge);
227
- border: 1px solid var(--key-accent, var(--primary, var(--key-accent, var(--primary, #D297FF))));
309
+ gap: 42px;
228
310
  }
229
311
 
230
- .key-label {
231
- font-size: 12px;
232
- font-weight: 600;
233
- text-transform: uppercase;
234
- letter-spacing: 0.5px;
235
- color: var(--secondary-info-fg);
312
+ .code-list {
313
+ display: flex;
314
+ flex-direction: column;
315
+ gap: 42px;
236
316
  }
237
317
 
238
- .key-code-row {
318
+ .code {
239
319
  display: flex;
240
320
  align-items: center;
241
- gap: 12px;
242
- flex-wrap: wrap;
243
- }
321
+ gap: 24px;
322
+
323
+ .number {
324
+ display: flex;
325
+ width: 80px;
326
+ height: 80px;
327
+ padding: 24px;
328
+ flex-direction: column;
329
+ justify-content: center;
330
+ align-items: center;
331
+ background-color: var(--button-secondary-default-bg);
332
+ color: var(--key-accent, var(--primary, #D297FF));
333
+ text-align: center;
334
+ font-size: 28px;
335
+ font-weight: 700;
336
+ flex-shrink: 0;
337
+ }
244
338
 
245
- .key-code {
246
- font-size: 20px;
247
- font-weight: 700;
248
- font-family: monospace;
249
- letter-spacing: 2px;
250
- color: var(--card-article-title);
251
- word-break: break-all;
252
- }
339
+ .texts {
340
+ display: flex;
341
+ flex-direction: column;
342
+ gap: 8px;
343
+ }
253
344
 
254
- .copy-btn {
255
- display: inline-flex;
256
- align-items: center;
257
- gap: 6px;
258
- padding: 6px 14px;
259
- font-size: 13px;
260
- font-weight: 600;
261
- background: transparent;
262
- border: 1px solid var(--key-accent, var(--primary, var(--key-accent, var(--primary, #D297FF))));
263
- color: var(--key-accent, var(--primary, var(--key-accent, var(--primary, #D297FF))));
264
- cursor: pointer;
265
- transition: background 0.15s;
266
- &:hover { background: var(--key-accent-subtle-bg, rgba(210, 151, 255, 0.1)); }
267
- }
345
+ .code-box {
346
+ display: flex;
347
+ align-items: center;
348
+ gap: 6px;
349
+ width: 100%;
350
+
351
+ input {
352
+ display: flex;
353
+ padding: 12px;
354
+ background-color: transparent;
355
+ height: 48px;
356
+ border: 1px solid var(--bg-app-badge);
357
+ width: 100%;
358
+ font-size: 16px;
359
+ color: var(--secondary-info-fg);
360
+ &:focus { border-color: var(--key-accent, var(--primary, #D297FF)); outline: 0; }
361
+ }
362
+
363
+ button {
364
+ background-color: var(--key-accent, var(--primary, #D297FF));
365
+ color: var(--key-accent-ink, #13161C);
366
+ height: 48px;
367
+ padding: 0 16px;
368
+ border: none;
369
+ cursor: pointer;
370
+ white-space: nowrap;
371
+ display: inline-flex;
372
+ align-items: center;
373
+ gap: 6px;
374
+ &.disabled, &:disabled { opacity: 0.45; cursor: not-allowed; }
375
+ }
376
+ }
268
377
 
269
- .redeem-info {
270
- font-size: 12px;
271
- color: var(--secondary-info-fg);
272
- margin: 0;
273
- }
378
+ .code-masked { letter-spacing: 3px; color: rgba(128, 128, 128, 0.5) !important; }
274
379
 
275
- .req-error { font-size: 13px; color: var(--danger, #e53935); }
380
+ .info {
381
+ display: flex;
382
+ align-items: flex-end;
383
+ gap: 4px;
384
+ font-size: 12px;
385
+ color: var(--secondary-info-fg);
386
+ svg { color: var(--key-accent, var(--primary, #D297FF)); }
387
+ }
276
388
 
277
- .info-grid {
278
- display: flex;
279
- flex-direction: column;
280
- gap: 8px;
281
- padding-top: 8px;
282
- border-top: 1px solid var(--button-secondary-default-bg);
389
+ .description {
390
+ font-size: 12px;
391
+ color: var(--secondary-info-fg);
392
+ }
393
+
394
+ .redeem-link {
395
+ display: inline-flex;
396
+ width: fit-content;
397
+ margin-top: 8px;
398
+ padding: 8px 14px;
399
+ background: var(--key-accent, var(--primary, #D297FF));
400
+ color: var(--key-accent-ink, #13161C);
401
+ font-size: 13px;
402
+ text-decoration: none;
403
+ }
283
404
  }
284
405
 
285
- .info-row {
406
+ .req-error { font-size: 12px; color: var(--danger, #e53935); margin-top: 4px; }
407
+
408
+ .resources {
286
409
  display: flex;
410
+ flex-direction: column;
287
411
  gap: 12px;
288
- font-size: 13px;
412
+ padding: 20px;
413
+ background: var(--bg-app-badge);
414
+ border: 1px solid var(--button-secondary-default-bg);
289
415
  }
290
416
 
291
- .info-label {
292
- color: var(--secondary-info-fg);
293
- min-width: 100px;
294
- }
417
+ .resources-title { font-size: 13px; font-weight: 600; color: var(--card-article-title); }
295
418
 
296
- .info-val { color: var(--card-article-title); }
297
-
298
- .btn {
299
- align-self: flex-start;
300
- padding: 0 28px;
301
- height: 44px;
302
- font-size: 14px;
303
- font-weight: 700;
304
- background: var(--key-accent, var(--primary, var(--key-accent, var(--primary, #D297FF))));
305
- color: var(--key-accent-ink, #13161C);
306
- border: none;
307
- cursor: pointer;
308
- transition: opacity 0.15s;
309
- &.disabled, &:disabled { opacity: 0.45; cursor: not-allowed; }
310
- &:not(.disabled):hover { opacity: 0.85; }
419
+ .resource-link {
420
+ display: inline-flex;
421
+ align-items: center;
422
+ gap: 6px;
423
+ width: fit-content;
424
+ font-size: 13px;
425
+ color: var(--key-accent, var(--primary, #D297FF));
426
+ text-decoration: none;
427
+ &:hover { text-decoration: underline; }
311
428
  }
429
+
430
+ .resource-item { display: flex; flex-direction: column; gap: 2px; }
431
+ .resource-label { font-size: 12px; font-weight: 600; color: var(--card-article-title); }
432
+ .resource-text { font-size: 13px; color: var(--secondary-info-fg); }
312
433
  }
313
434
 
314
435
  @keyframes pulse {
@@ -71,9 +71,7 @@ onMounted(async () => {
71
71
  }
72
72
  })
73
73
 
74
- const canSubmit = computed(() =>
75
- !!selectedPlatformId.value && !!selectedRegionId.value && !!quantity.value && !submitting.value,
76
- )
74
+ const canSubmit = computed(() => !submitting.value)
77
75
 
78
76
  // Collapsible "Required data" box — matches tv's original toggle behavior
79
77
  const isBoxOpen = ref(true)
@@ -104,16 +102,30 @@ const refusedReason = computed(() => campaign.value?.request_admin_notes || "")
104
102
 
105
103
  async function submit() {
106
104
  if (!canSubmit.value) return
107
- submitting.value = true
108
105
  requestError.value = ""
106
+ if (!selectedPlatformId.value || !selectedRegionId.value || !quantity.value || !description.value?.trim()) {
107
+ requestError.value = $i18n.t("keys.campaigns.required_fields")
108
+ return
109
+ }
110
+ submitting.value = true
109
111
  try {
110
- await requestKey({
112
+ const response = await requestKey({
111
113
  key_id: keyId,
112
114
  platform_ids: [selectedPlatformId.value],
113
115
  region_ids: [selectedRegionId.value],
114
116
  description: description.value || "-",
115
117
  quantity_keys: Number(quantity.value) || 1,
116
118
  })
119
+ const created = response?.data?.data ?? response?.data ?? {}
120
+ // requires_approval=false campaigns (or Pro/Business TV subscribers) get
121
+ // auto-approved by the backend observer synchronously — status 3 already
122
+ // reflects that by the time this response comes back, so skip the
123
+ // "request sent" wait screen and go straight to reveal.
124
+ const autoApproved = created.key_status_request_id === 3
125
+ if (autoApproved && created.id) {
126
+ navigateTo(`/${locale.value}/key-campaigns/redeem-key-approved?requestId=${created.id}`)
127
+ return
128
+ }
117
129
  success.value = true
118
130
  setTimeout(() => {
119
131
  navigateTo(`/${locale.value}/key-campaigns`)
@@ -233,11 +245,11 @@ function goBack() {
233
245
  </div>
234
246
 
235
247
  <div class="form-row">
236
- <label class="label">{{ $t("keys.campaigns.description_optional") }}</label>
248
+ <label class="label">{{ $t("keys.campaigns.plan") }}</label>
237
249
  <textarea
238
250
  v-model="description"
239
251
  class="textarea"
240
- :placeholder="$t('keys.campaigns.description_placeholder')"
252
+ :placeholder="$t('keys.campaigns.plan_placeholder')"
241
253
  rows="3"
242
254
  />
243
255
  </div>
@@ -283,6 +283,9 @@ export const fetchCampaignBySlug = async (slug: string) => {
283
283
  max_keys_for_user: k.max_keys_for_user ?? 1,
284
284
  game_summary: k.game?.summary,
285
285
  game_videos: (k.game?.videos || []) as Array<{ external_id: string; name?: string }>,
286
+ press_kit_link: k.press_kit_link ?? null,
287
+ review_guideline: k.review_guideline ?? null,
288
+ embargo_date: k.embargo_date ?? null,
286
289
  request_id: k.requests?.[0]?.id,
287
290
  request_item_id: k.requests?.[0]?.key_item_id,
288
291
  request_admin_notes: k.requests?.[0]?.admin_notes ?? null,
@@ -44,5 +44,13 @@ export interface GameImage {
44
44
  url_image_src: string
45
45
  }
46
46
 
47
+ export interface GameDetail {
48
+ platforms?: Array<{ id: number; name: string }>
49
+ genres?: Array<{ id: number; localized_name: string }>
50
+ }
51
+
52
+ export const fetchDetailGame = (lang: string, slug: string) =>
53
+ client.get<{ data: GameDetail }>(`/${lang}/games/details/${slug}`)
54
+
47
55
  export const fetchDetailGameImage = (lang: string, slug: string) =>
48
56
  client.get<{ data: GameImage[] }>(`/${lang}/games/details/${slug}/images`)