@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.
@@ -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">{{ $t("keys.campaigns.campaign.rp_label") }}</div>
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: -webkit-fill-available;
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 { submitMaterial, fetchMaterialsByRequest, fetchKeyRequestById } from "../../services/campaignService"
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
- formError.value = t("keys.materials.submit_error")
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 { revealKey, fetchKeyRequestById } from "../../services/campaignService"
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
- error.value = $i18n.t("keys.campaigns.error_loading")
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 (errorCode === "embargo_acknowledgment_required") {
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
- <div v-if="requestData && !keyCode" class="embargo-warning" :class="{ 'embargo-warning--none': !requestData?.key?.embargo_date }">
176
- <MGIcon size="1x" :icon="requestData?.key?.embargo_date ? 'lock' : 'unlock'" />
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="requestData?.key?.embargo_date">
230
+ <template v-if="embargoIsActive">
179
231
  <strong>{{ $t("keys.campaigns.embargo_warning_title") }}</strong>
180
- <p>{{ $t("keys.campaigns.embargo_warning_desc", { date: formatDate(requestData.key.embargo_date) }) }}</p>
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: formatDate(embargoAckDate) }) }}
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
- <div v-if="error" class="req-error">{{ error }}</div>
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 { font-size: 12px; color: var(--danger, #e53935); margin-top: 4px; }
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 (status === 409) {
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
- <div v-if="requestError" class="req-error">{{ requestError }}</div>
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
- .existing-request-notice {
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;