@mundogamernetwork/shared-ui 1.8.15 → 1.8.17
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 +16 -7
- package/locales/en.json +11 -2
- package/locales/es.json +379 -0
- package/locales/pt-BR.json +16 -7
- package/locales/ro.json +16 -7
- package/package.json +1 -1
- package/pages/key-campaigns/[slug].vue +120 -3
- package/pages/key-campaigns/key-materials.vue +26 -2
- package/pages/key-campaigns/redeem-key-approved.vue +117 -11
- package/pages/key-campaigns/redeem-key.vue +103 -4
- package/plugins/seo-tag-priority.ts +67 -0
- package/services/campaignService.ts +92 -0
- package/utils/clearSession.ts +22 -0
- package/utils/embargo.ts +125 -0
|
@@ -9,8 +9,11 @@ import {
|
|
|
9
9
|
fetchKeyRequestById,
|
|
10
10
|
revealKey,
|
|
11
11
|
requestKey,
|
|
12
|
+
followCampaign,
|
|
13
|
+
unfollowCampaign,
|
|
12
14
|
} from "../../services/campaignService"
|
|
13
15
|
import { fetchDetailGame, fetchDetailGameImage } from "../../services/gamesService"
|
|
16
|
+
import { formatEmbargoDateTime } from "../../utils/embargo"
|
|
14
17
|
|
|
15
18
|
// Injected by each front — provide in app.vue / a plugin
|
|
16
19
|
const mgPlatform = inject<string>("mgPlatform", "MGTV")
|
|
@@ -201,6 +204,35 @@ const campaignNotYetOpen = computed(() => {
|
|
|
201
204
|
return !!start && new Date(start).getTime() > Date.now()
|
|
202
205
|
})
|
|
203
206
|
|
|
207
|
+
// Personal "notify me when this opens" toggle — distinct from the global,
|
|
208
|
+
// one-time key_campaigns alert opt-in (enableKeyCampaignNotifications()).
|
|
209
|
+
// Most useful while campaignNotYetOpen, since that's the only case where
|
|
210
|
+
// KeyCampaignOpenedNotification will ever actually fire for this campaign.
|
|
211
|
+
const following = ref(!!campaign.value?.is_following)
|
|
212
|
+
const followBusy = ref(false)
|
|
213
|
+
|
|
214
|
+
async function toggleFollow() {
|
|
215
|
+
if (!currentUser) {
|
|
216
|
+
return navigateTo(`${loginUrl}?redirect_to=${encodeURIComponent(route.fullPath)}`, { external: true })
|
|
217
|
+
}
|
|
218
|
+
if (!campaign.value?.id || followBusy.value) return
|
|
219
|
+
|
|
220
|
+
followBusy.value = true
|
|
221
|
+
const wasFollowing = following.value
|
|
222
|
+
following.value = !wasFollowing
|
|
223
|
+
try {
|
|
224
|
+
if (wasFollowing) {
|
|
225
|
+
await unfollowCampaign(campaign.value.id)
|
|
226
|
+
} else {
|
|
227
|
+
await followCampaign(campaign.value.id)
|
|
228
|
+
}
|
|
229
|
+
} catch {
|
|
230
|
+
following.value = wasFollowing
|
|
231
|
+
} finally {
|
|
232
|
+
followBusy.value = false
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
204
236
|
let countdownInterval: ReturnType<typeof setInterval> | null = null
|
|
205
237
|
|
|
206
238
|
const updateCountdown = () => {
|
|
@@ -247,6 +279,13 @@ const updateCountdown = () => {
|
|
|
247
279
|
}, 1000)
|
|
248
280
|
}
|
|
249
281
|
|
|
282
|
+
// Embargo is shown with day AND time in the timezone the campaign creator set
|
|
283
|
+
// it in — formatDate() below renders in the viewer's own zone, which turns one
|
|
284
|
+
// deadline into a different one for every country.
|
|
285
|
+
const embargoDateTime = computed(() =>
|
|
286
|
+
formatEmbargoDateTime(campaign.value?.embargo_date, campaign.value?.embargo_timezone, locale.value),
|
|
287
|
+
)
|
|
288
|
+
|
|
250
289
|
const formatDate = (dateString: string | null | undefined): string => {
|
|
251
290
|
if (!dateString) return "-"
|
|
252
291
|
const date = new Date(dateString)
|
|
@@ -827,6 +866,32 @@ onMounted(async () => {
|
|
|
827
866
|
</div>
|
|
828
867
|
</div>
|
|
829
868
|
|
|
869
|
+
<button
|
|
870
|
+
v-if="campaignNotYetOpen"
|
|
871
|
+
type="button"
|
|
872
|
+
class="follow-btn"
|
|
873
|
+
:class="{ following }"
|
|
874
|
+
:disabled="followBusy"
|
|
875
|
+
@click="toggleFollow"
|
|
876
|
+
>
|
|
877
|
+
<MGIcon :icon="following ? 'check' : 'bell'" />
|
|
878
|
+
{{ following ? $t("keys.campaigns.campaign.following") : $t("keys.campaigns.campaign.follow_campaign") }}
|
|
879
|
+
</button>
|
|
880
|
+
|
|
881
|
+
<div class="embargo-notice" v-if="campaign?.embargo_active">
|
|
882
|
+
<MGIcon size="1x" icon="lock" />
|
|
883
|
+
<div class="embargo-notice-text">
|
|
884
|
+
<strong>{{ $t("keys.campaigns.embargo_warning_title") }}</strong>
|
|
885
|
+
<!-- Client-only: this page is server-rendered, and the line
|
|
886
|
+
includes the viewer's own timezone. Rendered on the
|
|
887
|
+
server it would be formatted in the SERVER's zone and
|
|
888
|
+
then disagree with the client on hydration. -->
|
|
889
|
+
<ClientOnly>
|
|
890
|
+
<p>{{ $t("keys.campaigns.embargo_warning_desc", { date: embargoDateTime }) }}</p>
|
|
891
|
+
</ClientOnly>
|
|
892
|
+
</div>
|
|
893
|
+
</div>
|
|
894
|
+
|
|
830
895
|
<div class="requirements">
|
|
831
896
|
{{ $t("keys.campaigns.campaign.requirements") }}
|
|
832
897
|
<div class="tags">
|
|
@@ -932,13 +997,13 @@ onMounted(async () => {
|
|
|
932
997
|
<div class="actions">
|
|
933
998
|
{{ $t("keys.campaigns.campaign.created_by") }}
|
|
934
999
|
<div class="company">
|
|
935
|
-
<div class="badge-tag">
|
|
1000
|
+
<div class="badge-tag badge-tag--company">
|
|
936
1001
|
<div class="image">
|
|
937
1002
|
<img :src="campaign?.logo || '/imgs/no-cover-img.jpg'" alt="Logo" />
|
|
938
1003
|
</div>
|
|
939
1004
|
<div class="texts">
|
|
940
1005
|
{{ campaign?.company || "" }}
|
|
941
|
-
<div class="small">{{
|
|
1006
|
+
<div v-if="campaign?.company_type" class="small">{{ campaign.company_type }}</div>
|
|
942
1007
|
</div>
|
|
943
1008
|
</div>
|
|
944
1009
|
<div class="badge-tag" v-if="campaign?.email">
|
|
@@ -1364,6 +1429,31 @@ onMounted(async () => {
|
|
|
1364
1429
|
}
|
|
1365
1430
|
}
|
|
1366
1431
|
|
|
1432
|
+
.follow-btn {
|
|
1433
|
+
display: flex;
|
|
1434
|
+
align-items: center;
|
|
1435
|
+
justify-content: center;
|
|
1436
|
+
gap: 8px;
|
|
1437
|
+
width: 100%;
|
|
1438
|
+
padding: 10px;
|
|
1439
|
+
border: 1px solid var(--key-accent, var(--primary, #D297FF));
|
|
1440
|
+
background: transparent;
|
|
1441
|
+
color: var(--key-accent, var(--primary, #D297FF));
|
|
1442
|
+
font-size: 13px;
|
|
1443
|
+
font-weight: 600;
|
|
1444
|
+
font-family: inherit;
|
|
1445
|
+
cursor: pointer;
|
|
1446
|
+
transition: background-color 0.15s, opacity 0.15s;
|
|
1447
|
+
|
|
1448
|
+
&:hover:not(:disabled) { background-color: rgba(210, 151, 255, 0.1); }
|
|
1449
|
+
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
1450
|
+
|
|
1451
|
+
&.following {
|
|
1452
|
+
background-color: var(--key-accent, var(--primary, #D297FF));
|
|
1453
|
+
color: var(--body-bg, #101012);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1367
1457
|
.requirements {
|
|
1368
1458
|
display: flex;
|
|
1369
1459
|
flex-direction: column;
|
|
@@ -1409,6 +1499,26 @@ onMounted(async () => {
|
|
|
1409
1499
|
}
|
|
1410
1500
|
}
|
|
1411
1501
|
|
|
1502
|
+
.embargo-notice {
|
|
1503
|
+
display: flex;
|
|
1504
|
+
align-items: flex-start;
|
|
1505
|
+
gap: 10px;
|
|
1506
|
+
padding: 12px;
|
|
1507
|
+
background-color: var(--bg-app-badge);
|
|
1508
|
+
border: 1px solid var(--button-secondary-default-bg);
|
|
1509
|
+
color: var(--key-accent, var(--primary, #D297FF));
|
|
1510
|
+
|
|
1511
|
+
.embargo-notice-text {
|
|
1512
|
+
display: flex;
|
|
1513
|
+
flex-direction: column;
|
|
1514
|
+
gap: 4px;
|
|
1515
|
+
font-size: 13px;
|
|
1516
|
+
|
|
1517
|
+
strong { color: var(--card-article-title); }
|
|
1518
|
+
p { margin: 0; color: var(--secondary-info-fg); font-weight: 400; }
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1412
1522
|
.actions {
|
|
1413
1523
|
display: flex;
|
|
1414
1524
|
flex-direction: column;
|
|
@@ -1505,6 +1615,7 @@ onMounted(async () => {
|
|
|
1505
1615
|
|
|
1506
1616
|
.company {
|
|
1507
1617
|
display: flex;
|
|
1618
|
+
flex-wrap: wrap;
|
|
1508
1619
|
gap: 12px;
|
|
1509
1620
|
.w-48 { max-width: 48px; }
|
|
1510
1621
|
.badge-tag {
|
|
@@ -1515,7 +1626,8 @@ onMounted(async () => {
|
|
|
1515
1626
|
gap: 12px;
|
|
1516
1627
|
font-size: 12px;
|
|
1517
1628
|
color: var(--secondary-info-fg);
|
|
1518
|
-
width: -
|
|
1629
|
+
width: fit-content;
|
|
1630
|
+
flex: 0 0 auto;
|
|
1519
1631
|
.image { width: 32px; height: 32px; img { width: 100%; border-radius: 100%; } }
|
|
1520
1632
|
.texts {
|
|
1521
1633
|
display: flex;
|
|
@@ -1526,6 +1638,11 @@ onMounted(async () => {
|
|
|
1526
1638
|
.small { color: var(--secondary-info-fg); }
|
|
1527
1639
|
}
|
|
1528
1640
|
}
|
|
1641
|
+
.badge-tag--company {
|
|
1642
|
+
padding: 6px 12px 6px 6px;
|
|
1643
|
+
gap: 8px;
|
|
1644
|
+
.image { width: 24px; height: 24px; }
|
|
1645
|
+
}
|
|
1529
1646
|
}
|
|
1530
1647
|
}
|
|
1531
1648
|
}
|
|
@@ -134,7 +134,11 @@
|
|
|
134
134
|
<div class="bottom">
|
|
135
135
|
<div class="buttons">
|
|
136
136
|
<div v-if="formError" class="form-error">{{ formError }}</div>
|
|
137
|
+
<div v-if="sessionExpired" class="btn button-submit" @click="goToLogin">
|
|
138
|
+
<span>{{ $t("keys.campaigns.login_again") }}</span>
|
|
139
|
+
</div>
|
|
137
140
|
<div
|
|
141
|
+
v-else
|
|
138
142
|
class="btn button-submit"
|
|
139
143
|
:class="{ disabled: submitting }"
|
|
140
144
|
@click="submitForm"
|
|
@@ -195,7 +199,13 @@
|
|
|
195
199
|
import { ref, computed, inject, onMounted, unref } from "vue"
|
|
196
200
|
import { useRoute, useRouter } from "vue-router"
|
|
197
201
|
import { useI18n } from "#imports"
|
|
198
|
-
import {
|
|
202
|
+
import {
|
|
203
|
+
submitMaterial,
|
|
204
|
+
fetchMaterialsByRequest,
|
|
205
|
+
fetchKeyRequestById,
|
|
206
|
+
isSessionExpired,
|
|
207
|
+
buildReLoginUrl,
|
|
208
|
+
} from "../../services/campaignService"
|
|
199
209
|
|
|
200
210
|
definePageMeta({
|
|
201
211
|
middleware: [
|
|
@@ -211,6 +221,11 @@ definePageMeta({
|
|
|
211
221
|
|
|
212
222
|
// Consuming front injects current user id (or null if not logged in)
|
|
213
223
|
const currentUserId = inject<number | null>("currentUserId", null)
|
|
224
|
+
const loginUrl = inject<string>("loginUrl", "/")
|
|
225
|
+
|
|
226
|
+
function goToLogin() {
|
|
227
|
+
navigateTo(buildReLoginUrl(loginUrl), { external: true })
|
|
228
|
+
}
|
|
214
229
|
|
|
215
230
|
const props = defineProps<{ goBackPage?: string | null }>()
|
|
216
231
|
|
|
@@ -244,6 +259,9 @@ const submitting = ref(false)
|
|
|
244
259
|
const submittedMaterials = ref<any[]>([])
|
|
245
260
|
const urlError = ref<string | null>(null)
|
|
246
261
|
const formError = ref<string | null>(null)
|
|
262
|
+
// The session died between page load and submit (persisted auth state kept the
|
|
263
|
+
// UI looking logged in) — show a re-login CTA instead of a generic failure.
|
|
264
|
+
const sessionExpired = ref(false)
|
|
247
265
|
const showSuccessModal = ref(false)
|
|
248
266
|
|
|
249
267
|
// Submission deadline — counts down from the request's approval date, using
|
|
@@ -316,6 +334,7 @@ const submitForm = async () => {
|
|
|
316
334
|
submitting.value = true
|
|
317
335
|
urlError.value = null
|
|
318
336
|
formError.value = null
|
|
337
|
+
sessionExpired.value = false
|
|
319
338
|
|
|
320
339
|
try {
|
|
321
340
|
forms.value.forEach((form) => {
|
|
@@ -360,7 +379,12 @@ const submitForm = async () => {
|
|
|
360
379
|
showSuccessModal.value = true
|
|
361
380
|
} catch (error) {
|
|
362
381
|
console.error("[key-materials] Submit error:", error)
|
|
363
|
-
|
|
382
|
+
if (isSessionExpired(error)) {
|
|
383
|
+
sessionExpired.value = true
|
|
384
|
+
formError.value = t("keys.campaigns.error_session_expired")
|
|
385
|
+
} else {
|
|
386
|
+
formError.value = t("keys.materials.submit_error")
|
|
387
|
+
}
|
|
364
388
|
} finally {
|
|
365
389
|
submitting.value = false
|
|
366
390
|
}
|
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
import { ref, inject, onMounted, unref } from "vue"
|
|
3
3
|
import { useRoute, navigateTo } from "#app"
|
|
4
4
|
import { useNuxtApp } from "#app"
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
revealKey,
|
|
7
|
+
fetchKeyRequestById,
|
|
8
|
+
isSessionExpired,
|
|
9
|
+
buildReLoginUrl,
|
|
10
|
+
} from "../../services/campaignService"
|
|
11
|
+
import { formatEmbargoDateTime, formatEmbargoCountdown, isEmbargoActive } from "../../utils/embargo"
|
|
6
12
|
|
|
7
13
|
const route = useRoute()
|
|
8
14
|
const { $i18n } = useNuxtApp()
|
|
@@ -18,6 +24,9 @@ const loading = ref(true)
|
|
|
18
24
|
const revealing = ref(false)
|
|
19
25
|
const keyCode = ref<string | null>(null)
|
|
20
26
|
const error = ref("")
|
|
27
|
+
// The session died while the page was open (persisted auth state kept the UI
|
|
28
|
+
// looking logged in) — show a re-login CTA instead of a raw API message.
|
|
29
|
+
const sessionExpired = ref(false)
|
|
21
30
|
const requestData = ref<any>(null)
|
|
22
31
|
const copied = ref(false)
|
|
23
32
|
const platform = ref("")
|
|
@@ -72,8 +81,13 @@ onMounted(async () => {
|
|
|
72
81
|
// Platform the user chose at request time — shown as a fallback/preview
|
|
73
82
|
// even before reveal, in case the assigned key item has no platform set.
|
|
74
83
|
platform.value = requestData.value?.platforms?.[0]?.name || ""
|
|
75
|
-
} catch {
|
|
76
|
-
|
|
84
|
+
} catch (e: any) {
|
|
85
|
+
if (isSessionExpired(e)) {
|
|
86
|
+
sessionExpired.value = true
|
|
87
|
+
error.value = $i18n.t("keys.campaigns.error_session_expired")
|
|
88
|
+
} else {
|
|
89
|
+
error.value = $i18n.t("keys.campaigns.error_loading")
|
|
90
|
+
}
|
|
77
91
|
} finally {
|
|
78
92
|
loading.value = false
|
|
79
93
|
}
|
|
@@ -82,6 +96,7 @@ onMounted(async () => {
|
|
|
82
96
|
async function reveal(acknowledgeEmbargo = false) {
|
|
83
97
|
revealing.value = true
|
|
84
98
|
error.value = ""
|
|
99
|
+
sessionExpired.value = false
|
|
85
100
|
try {
|
|
86
101
|
const res = await revealKey(requestId, acknowledgeEmbargo)
|
|
87
102
|
const data = res?.data?.data || res?.data || null
|
|
@@ -97,7 +112,13 @@ async function reveal(acknowledgeEmbargo = false) {
|
|
|
97
112
|
const body = e?.response?.data || {}
|
|
98
113
|
const code = body.message || ""
|
|
99
114
|
const errorCode = body.error_code || ""
|
|
100
|
-
if (
|
|
115
|
+
if (isSessionExpired(e)) {
|
|
116
|
+
// Never surface the backend's own "Unauthorized" here: it is
|
|
117
|
+
// untranslated and reads as a permission problem when it is really an
|
|
118
|
+
// expired session.
|
|
119
|
+
sessionExpired.value = true
|
|
120
|
+
error.value = $i18n.t("keys.campaigns.error_session_expired")
|
|
121
|
+
} else if (errorCode === "embargo_acknowledgment_required") {
|
|
101
122
|
needsEmbargoAck.value = true
|
|
102
123
|
embargoAckDate.value = body.embargo_date || null
|
|
103
124
|
} else if (code === "NO_KEYS_AVAILABLE" || code === "KEY_ITEM_NOT_FOUND") {
|
|
@@ -128,6 +149,25 @@ async function copyCode() {
|
|
|
128
149
|
}
|
|
129
150
|
}
|
|
130
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Embargo shown with day AND time, in the timezone the campaign creator set it
|
|
154
|
+
* in — a date alone is not something a creator can comply with.
|
|
155
|
+
*/
|
|
156
|
+
function formatEmbargo(dateString: string | null | undefined): string {
|
|
157
|
+
return formatEmbargoDateTime(dateString, requestData.value?.key?.embargo_timezone, locale.value)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Embargo state stays on screen AFTER the code is revealed and on every later
|
|
161
|
+
// visit to this page. Creators routinely request a key days before release, so
|
|
162
|
+
// the acknowledgment they gave once is not enough — the deadline has to be
|
|
163
|
+
// where they come back to fetch the code.
|
|
164
|
+
const embargoDate = computed<string | null>(() => requestData.value?.key?.embargo_date ?? null)
|
|
165
|
+
const embargoIsActive = computed(() => isEmbargoActive(embargoDate.value))
|
|
166
|
+
const embargoMoment = computed(() => formatEmbargo(embargoDate.value))
|
|
167
|
+
// Recomputed on every reveal/visit; a live ticker would be noise on a page the
|
|
168
|
+
// user opens, copies from and leaves.
|
|
169
|
+
const embargoCountdown = computed(() => formatEmbargoCountdown(embargoDate.value, locale.value))
|
|
170
|
+
|
|
131
171
|
function formatDate(dateString: string | null | undefined): string {
|
|
132
172
|
if (!dateString) return "-"
|
|
133
173
|
const date = new Date(dateString)
|
|
@@ -135,6 +175,10 @@ function formatDate(dateString: string | null | undefined): string {
|
|
|
135
175
|
return new Intl.DateTimeFormat(locale.value, { day: "numeric", month: "long", year: "numeric" }).format(date)
|
|
136
176
|
}
|
|
137
177
|
|
|
178
|
+
function goToLogin() {
|
|
179
|
+
navigateTo(buildReLoginUrl(loginUrl), { external: true })
|
|
180
|
+
}
|
|
181
|
+
|
|
138
182
|
function goBack() {
|
|
139
183
|
if (typeof window !== "undefined" && window.history.length > 1) {
|
|
140
184
|
window.history.back()
|
|
@@ -158,6 +202,12 @@ function goBack() {
|
|
|
158
202
|
<div class="skeleton" v-for="i in 2" :key="i" />
|
|
159
203
|
</div>
|
|
160
204
|
|
|
205
|
+
<div v-else-if="sessionExpired && !requestData" class="state error">
|
|
206
|
+
<p>{{ $t("keys.campaigns.error_session_expired") }}</p>
|
|
207
|
+
<p class="session-expired-help">{{ $t("keys.campaigns.error_session_expired_help") }}</p>
|
|
208
|
+
<button class="btn" @click="goToLogin">{{ $t("keys.campaigns.login_again") }}</button>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
161
211
|
<div v-else-if="error && !requestData" class="state error">{{ error }}</div>
|
|
162
212
|
|
|
163
213
|
<div v-else class="content">
|
|
@@ -172,12 +222,21 @@ function goBack() {
|
|
|
172
222
|
<p v-if="platform" class="platform-name">{{ $t("keys.campaigns.platform") }}: {{ platform }}</p>
|
|
173
223
|
</div>
|
|
174
224
|
|
|
175
|
-
|
|
176
|
-
|
|
225
|
+
<!-- Stays visible after the code is revealed and on every later
|
|
226
|
+
visit: the key is often taken days before the embargo lifts. -->
|
|
227
|
+
<div v-if="requestData" class="embargo-warning" :class="{ 'embargo-warning--none': !embargoIsActive }">
|
|
228
|
+
<MGIcon size="1x" :icon="embargoIsActive ? 'lock' : 'unlock'" />
|
|
177
229
|
<div class="embargo-warning-text">
|
|
178
|
-
<template v-if="
|
|
230
|
+
<template v-if="embargoIsActive">
|
|
179
231
|
<strong>{{ $t("keys.campaigns.embargo_warning_title") }}</strong>
|
|
180
|
-
<p>{{ $t("keys.campaigns.embargo_warning_desc", { date:
|
|
232
|
+
<p>{{ $t("keys.campaigns.embargo_warning_desc", { date: embargoMoment }) }}</p>
|
|
233
|
+
<p v-if="embargoCountdown" class="embargo-countdown">
|
|
234
|
+
{{ $t("keys.campaigns.embargo_countdown", { relative: embargoCountdown }) }}
|
|
235
|
+
</p>
|
|
236
|
+
</template>
|
|
237
|
+
<template v-else-if="embargoDate">
|
|
238
|
+
<strong>{{ $t("keys.campaigns.embargo_lifted_title") }}</strong>
|
|
239
|
+
<p>{{ $t("keys.campaigns.no_embargo_desc") }}</p>
|
|
181
240
|
</template>
|
|
182
241
|
<template v-else>
|
|
183
242
|
<strong>{{ $t("keys.campaigns.no_embargo_title") }}</strong>
|
|
@@ -195,7 +254,7 @@ function goBack() {
|
|
|
195
254
|
<div class="embargo-ack" v-if="!keyCode && needsEmbargoAck">
|
|
196
255
|
<label class="embargo-ack-label">
|
|
197
256
|
<input type="checkbox" v-model="embargoAckChecked" />
|
|
198
|
-
{{ $t("keys.campaigns.embargo_ack_label", { date:
|
|
257
|
+
{{ $t("keys.campaigns.embargo_ack_label", { date: formatEmbargo(embargoAckDate) }) }}
|
|
199
258
|
</label>
|
|
200
259
|
<button
|
|
201
260
|
class="btn"
|
|
@@ -222,7 +281,19 @@ function goBack() {
|
|
|
222
281
|
</button>
|
|
223
282
|
</div>
|
|
224
283
|
|
|
225
|
-
|
|
284
|
+
<!-- Right where the code is copied: the one moment the
|
|
285
|
+
creator is guaranteed to be looking at this page. -->
|
|
286
|
+
<div v-if="keyCode && embargoIsActive" class="embargo-reminder">
|
|
287
|
+
<MGIcon size="1x" icon="lock" />
|
|
288
|
+
<span>{{ $t("keys.campaigns.embargo_reminder_short", { date: embargoMoment }) }}</span>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
<div v-if="error" class="req-error">
|
|
292
|
+
{{ error }}
|
|
293
|
+
<button v-if="sessionExpired" type="button" class="btn login-again" @click="goToLogin">
|
|
294
|
+
{{ $t("keys.campaigns.login_again") }}
|
|
295
|
+
</button>
|
|
296
|
+
</div>
|
|
226
297
|
|
|
227
298
|
<div class="info">
|
|
228
299
|
<MGIcon size="1x" icon="version" />
|
|
@@ -448,7 +519,20 @@ function goBack() {
|
|
|
448
519
|
}
|
|
449
520
|
}
|
|
450
521
|
|
|
451
|
-
.req-error {
|
|
522
|
+
.req-error {
|
|
523
|
+
font-size: 12px;
|
|
524
|
+
color: var(--danger, #e53935);
|
|
525
|
+
margin-top: 4px;
|
|
526
|
+
|
|
527
|
+
.login-again {
|
|
528
|
+
display: block;
|
|
529
|
+
margin-top: 8px;
|
|
530
|
+
font-size: 13px;
|
|
531
|
+
padding: 8px 16px;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
.session-expired-help { font-size: 13px; color: var(--secondary-info-fg); margin: 8px 0 16px; }
|
|
452
536
|
|
|
453
537
|
.embargo-warning {
|
|
454
538
|
display: flex;
|
|
@@ -464,6 +548,12 @@ function goBack() {
|
|
|
464
548
|
.embargo-warning-text {
|
|
465
549
|
strong { display: block; color: var(--danger, #e53935); font-size: 14px; margin-bottom: 4px; }
|
|
466
550
|
p { margin: 0; font-size: 13px; color: var(--text-secondary, #ccc); }
|
|
551
|
+
|
|
552
|
+
.embargo-countdown {
|
|
553
|
+
margin-top: 4px;
|
|
554
|
+
font-weight: 600;
|
|
555
|
+
color: var(--danger, #e53935);
|
|
556
|
+
}
|
|
467
557
|
}
|
|
468
558
|
|
|
469
559
|
&--none {
|
|
@@ -475,6 +565,22 @@ function goBack() {
|
|
|
475
565
|
}
|
|
476
566
|
}
|
|
477
567
|
|
|
568
|
+
.embargo-reminder {
|
|
569
|
+
display: flex;
|
|
570
|
+
align-items: flex-start;
|
|
571
|
+
gap: 8px;
|
|
572
|
+
margin-top: 10px;
|
|
573
|
+
padding: 10px 12px;
|
|
574
|
+
font-size: 12px;
|
|
575
|
+
font-weight: 600;
|
|
576
|
+
line-height: 1.4;
|
|
577
|
+
color: var(--danger, #e53935);
|
|
578
|
+
background: rgba(229, 57, 53, 0.1);
|
|
579
|
+
border: 1px solid var(--danger, #e53935);
|
|
580
|
+
|
|
581
|
+
svg { flex-shrink: 0; margin-top: 1px; }
|
|
582
|
+
}
|
|
583
|
+
|
|
478
584
|
.embargo-ack {
|
|
479
585
|
display: flex;
|
|
480
586
|
flex-direction: column;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts" setup>
|
|
2
|
-
import { ref, computed, inject, onMounted, unref } from "vue"
|
|
2
|
+
import { ref, computed, inject, onMounted, unref, watch } from "vue"
|
|
3
3
|
import { useRoute, navigateTo } from "#app"
|
|
4
4
|
import { useNuxtApp } from "#app"
|
|
5
5
|
import {
|
|
@@ -9,7 +9,10 @@ import {
|
|
|
9
9
|
fetchAvailableOptions,
|
|
10
10
|
requestKey,
|
|
11
11
|
enableKeyCampaignNotifications,
|
|
12
|
+
isSessionExpired,
|
|
13
|
+
buildReLoginUrl,
|
|
12
14
|
} from "../../services/campaignService"
|
|
15
|
+
import { formatEmbargoDateTime } from "../../utils/embargo"
|
|
13
16
|
|
|
14
17
|
const route = useRoute()
|
|
15
18
|
const { $i18n } = useNuxtApp()
|
|
@@ -33,15 +36,26 @@ const submitting = ref(false)
|
|
|
33
36
|
const error = ref("")
|
|
34
37
|
const success = ref(false)
|
|
35
38
|
const requestError = ref("")
|
|
39
|
+
// The session died between page load and submit (persisted auth state kept the
|
|
40
|
+
// UI looking logged in). Shows a re-login CTA instead of a raw API message.
|
|
41
|
+
const sessionExpired = ref(false)
|
|
36
42
|
|
|
37
43
|
const selectedPlatformId = ref<number | null>(null)
|
|
38
44
|
const selectedRegionId = ref<number | null>(null)
|
|
39
45
|
const description = ref("")
|
|
40
46
|
const quantity = ref<number | null>(1)
|
|
41
47
|
const notifyKeyCampaigns = ref(true)
|
|
48
|
+
// Embargoed campaigns: the creator must confirm they saw the day/time the
|
|
49
|
+
// embargo lifts BEFORE asking for the key, not only at reveal.
|
|
50
|
+
const embargoAckChecked = ref(false)
|
|
42
51
|
|
|
43
52
|
const maxKeysForUser = computed(() => campaign.value?.max_keys_for_user ?? 1)
|
|
44
53
|
|
|
54
|
+
const embargoDateTime = computed(() =>
|
|
55
|
+
formatEmbargoDateTime(campaign.value?.embargo_date, campaign.value?.embargo_timezone, locale.value),
|
|
56
|
+
)
|
|
57
|
+
const hasEmbargo = computed(() => !!embargoDateTime.value)
|
|
58
|
+
|
|
45
59
|
function clampQuantity() {
|
|
46
60
|
if (quantity.value === null) return
|
|
47
61
|
if (quantity.value < 1) quantity.value = 1
|
|
@@ -89,6 +103,21 @@ onMounted(async () => {
|
|
|
89
103
|
}
|
|
90
104
|
})
|
|
91
105
|
|
|
106
|
+
// The guard above runs on mount, when the auth store has usually only been
|
|
107
|
+
// hydrated from localStorage (`signedIn` is persisted) — the app's own
|
|
108
|
+
// /auth/user probe resolves a moment later. If that probe comes back
|
|
109
|
+
// unauthenticated, the user is sitting on a form that will be rejected on
|
|
110
|
+
// submit; surface it right away instead of waiting for the POST to fail.
|
|
111
|
+
watch(
|
|
112
|
+
() => unref(currentUser),
|
|
113
|
+
(user, previous) => {
|
|
114
|
+
if (previous && !user) {
|
|
115
|
+
sessionExpired.value = true
|
|
116
|
+
requestError.value = $i18n.t("keys.campaigns.error_session_expired")
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
|
|
92
121
|
// True once options finished loading and either list came back empty — every
|
|
93
122
|
// platform/region combo this campaign ever had is now fully redeemed.
|
|
94
123
|
const soldOut = computed(() => !loading.value && (platforms.value.length === 0 || regions.value.length === 0))
|
|
@@ -122,13 +151,22 @@ const existingRequestStatus = computed(() => {
|
|
|
122
151
|
const isRefused = computed(() => campaign.value?.request_status === 6)
|
|
123
152
|
const refusedReason = computed(() => campaign.value?.request_admin_notes || "")
|
|
124
153
|
|
|
154
|
+
function goToLogin() {
|
|
155
|
+
navigateTo(buildReLoginUrl(loginUrl), { external: true })
|
|
156
|
+
}
|
|
157
|
+
|
|
125
158
|
async function submit() {
|
|
126
159
|
if (!canSubmit.value) return
|
|
127
160
|
requestError.value = ""
|
|
161
|
+
sessionExpired.value = false
|
|
128
162
|
if (!selectedPlatformId.value || !selectedRegionId.value || !quantity.value || !description.value?.trim()) {
|
|
129
163
|
requestError.value = $i18n.t("keys.campaigns.required_fields")
|
|
130
164
|
return
|
|
131
165
|
}
|
|
166
|
+
if (hasEmbargo.value && !embargoAckChecked.value) {
|
|
167
|
+
requestError.value = $i18n.t("keys.campaigns.embargo_ack_required")
|
|
168
|
+
return
|
|
169
|
+
}
|
|
132
170
|
submitting.value = true
|
|
133
171
|
try {
|
|
134
172
|
const response = await requestKey({
|
|
@@ -137,6 +175,9 @@ async function submit() {
|
|
|
137
175
|
region_ids: [selectedRegionId.value],
|
|
138
176
|
description: description.value || "-",
|
|
139
177
|
quantity_keys: Number(quantity.value) || 1,
|
|
178
|
+
// Recorded on the request so the acknowledgment is provable, not just
|
|
179
|
+
// a checkbox the browser forgot about.
|
|
180
|
+
acknowledge_embargo: hasEmbargo.value && embargoAckChecked.value,
|
|
140
181
|
})
|
|
141
182
|
const created = response?.data?.data ?? response?.data ?? {}
|
|
142
183
|
|
|
@@ -158,7 +199,13 @@ async function submit() {
|
|
|
158
199
|
} catch (e: any) {
|
|
159
200
|
const status = e?.response?.status
|
|
160
201
|
const errorCode = e?.response?.data?.error_code
|
|
161
|
-
if (
|
|
202
|
+
if (isSessionExpired(e)) {
|
|
203
|
+
// The API rejected the request as unauthenticated. Never surface the
|
|
204
|
+
// backend's own wording here ("Unauthorized"): it is untranslated and
|
|
205
|
+
// reads as a permission problem when it is really an expired session.
|
|
206
|
+
sessionExpired.value = true
|
|
207
|
+
requestError.value = $i18n.t("keys.campaigns.error_session_expired")
|
|
208
|
+
} else if (status === 409) {
|
|
162
209
|
requestError.value = $i18n.t("keys.campaigns.error_duplicate")
|
|
163
210
|
} else if (status === 403 && errorCode === "user_key_blocked") {
|
|
164
211
|
requestError.value = $i18n.t("keys.campaigns.error_user_blocked")
|
|
@@ -302,9 +349,39 @@ function goBack() {
|
|
|
302
349
|
</div>
|
|
303
350
|
</div>
|
|
304
351
|
|
|
305
|
-
|
|
352
|
+
<!-- Embargo: the creator confirms the day/time before asking
|
|
353
|
+
for the key, not only when revealing it. -->
|
|
354
|
+
<div v-if="hasEmbargo" class="embargo-box">
|
|
355
|
+
<div class="embargo-box-head">
|
|
356
|
+
<MGIcon size="1x" icon="lock" class="notice-icon" />
|
|
357
|
+
<div class="notice-texts">
|
|
358
|
+
<div class="notice-title">{{ $t("keys.campaigns.embargo_warning_title") }}</div>
|
|
359
|
+
<div class="notice-status">
|
|
360
|
+
{{ $t("keys.campaigns.embargo_warning_desc", { date: embargoDateTime }) }}
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
<label class="checkbox-label embargo-ack">
|
|
365
|
+
<input type="checkbox" v-model="embargoAckChecked" />
|
|
366
|
+
{{ $t("keys.campaigns.embargo_ack_label", { date: embargoDateTime }) }}
|
|
367
|
+
</label>
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
<div v-if="sessionExpired" class="session-expired">
|
|
371
|
+
<MGIcon size="1x" icon="version" class="notice-icon" />
|
|
372
|
+
<div class="notice-texts">
|
|
373
|
+
<div class="notice-title">{{ $t("keys.campaigns.error_session_expired") }}</div>
|
|
374
|
+
<div class="notice-status">{{ $t("keys.campaigns.error_session_expired_help") }}</div>
|
|
375
|
+
</div>
|
|
376
|
+
<button type="button" class="btn notice-back" @click="goToLogin">
|
|
377
|
+
{{ $t("keys.campaigns.login_again") }}
|
|
378
|
+
</button>
|
|
379
|
+
</div>
|
|
380
|
+
|
|
381
|
+
<div v-else-if="requestError" class="req-error">{{ requestError }}</div>
|
|
306
382
|
|
|
307
383
|
<button
|
|
384
|
+
v-if="!sessionExpired"
|
|
308
385
|
type="submit"
|
|
309
386
|
class="btn"
|
|
310
387
|
:class="{ disabled: !canSubmit }"
|
|
@@ -410,7 +487,29 @@ function goBack() {
|
|
|
410
487
|
svg.rotate { transform: rotate(180deg); }
|
|
411
488
|
}
|
|
412
489
|
|
|
413
|
-
.
|
|
490
|
+
.embargo-box {
|
|
491
|
+
display: flex;
|
|
492
|
+
flex-direction: column;
|
|
493
|
+
gap: 14px;
|
|
494
|
+
padding: 20px;
|
|
495
|
+
margin-top: 16px;
|
|
496
|
+
background: var(--bg-app-badge);
|
|
497
|
+
border: 1px solid var(--key-accent, var(--primary, #D297FF));
|
|
498
|
+
|
|
499
|
+
.embargo-box-head { display: flex; align-items: flex-start; gap: 14px; }
|
|
500
|
+
|
|
501
|
+
.embargo-ack {
|
|
502
|
+
display: flex;
|
|
503
|
+
align-items: flex-start;
|
|
504
|
+
gap: 8px;
|
|
505
|
+
font-size: 13px;
|
|
506
|
+
line-height: 1.4;
|
|
507
|
+
cursor: pointer;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
.existing-request-notice,
|
|
512
|
+
.session-expired {
|
|
414
513
|
display: flex;
|
|
415
514
|
align-items: flex-start;
|
|
416
515
|
gap: 14px;
|