@mundogamernetwork/shared-ui 1.8.16 → 1.8.18
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/components/MediaKit/MgRateCardBundleEditor.vue +2 -2
- package/components/MediaKit/MgRateCardEditor.vue +12 -12
- package/components/PressKit/AssetGallery.vue +2 -2
- package/components/PressKit/Awards.vue +1 -1
- package/components/PressKit/Hero.vue +1 -1
- package/components/PressKit/VideoPlayer.vue +1 -1
- package/components/keys/KeyCampaignDashboardCard.vue +8 -8
- package/components/playtest/PlaytestBrief.vue +1 -1
- package/components/playtest/PlaytestPendingConfirmation.vue +2 -2
- package/components/playtest/concept-poll/RankedChoiceVote.vue +2 -2
- package/components/playtest/moderated/JoinCallPanel.vue +1 -1
- package/components/ui/MgHeaderUIUser.vue +1 -1
- package/components/ui/MgWalletPlanBadge.vue +1 -1
- package/locales/de.json +16 -6
- package/locales/en.json +11 -1
- package/locales/es.json +381 -0
- package/locales/pt-BR.json +16 -6
- package/locales/ro.json +16 -6
- package/package.json +1 -1
- package/pages/icons.vue +41 -6
- package/pages/key-campaigns/[slug].vue +51 -2
- package/pages/key-campaigns/index.vue +48 -11
- package/pages/key-campaigns/key-materials.vue +27 -3
- package/pages/key-campaigns/redeem-key-approved.vue +117 -11
- package/pages/key-campaigns/redeem-key.vue +105 -6
- package/plugins/seo-tag-priority.ts +67 -0
- package/services/campaignService.ts +76 -0
- package/utils/clearSession.ts +22 -0
- package/utils/embargo.ts +125 -0
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
unfollowCampaign,
|
|
14
14
|
} from "../../services/campaignService"
|
|
15
15
|
import { fetchDetailGame, fetchDetailGameImage } from "../../services/gamesService"
|
|
16
|
+
import { formatEmbargoDateTime } from "../../utils/embargo"
|
|
16
17
|
|
|
17
18
|
// Injected by each front — provide in app.vue / a plugin
|
|
18
19
|
const mgPlatform = inject<string>("mgPlatform", "MGTV")
|
|
@@ -278,6 +279,13 @@ const updateCountdown = () => {
|
|
|
278
279
|
}, 1000)
|
|
279
280
|
}
|
|
280
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
|
+
|
|
281
289
|
const formatDate = (dateString: string | null | undefined): string => {
|
|
282
290
|
if (!dateString) return "-"
|
|
283
291
|
const date = new Date(dateString)
|
|
@@ -870,6 +878,20 @@ onMounted(async () => {
|
|
|
870
878
|
{{ following ? $t("keys.campaigns.campaign.following") : $t("keys.campaigns.campaign.follow_campaign") }}
|
|
871
879
|
</button>
|
|
872
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
|
+
|
|
873
895
|
<div class="requirements">
|
|
874
896
|
{{ $t("keys.campaigns.campaign.requirements") }}
|
|
875
897
|
<div class="tags">
|
|
@@ -975,7 +997,7 @@ onMounted(async () => {
|
|
|
975
997
|
<div class="actions">
|
|
976
998
|
{{ $t("keys.campaigns.campaign.created_by") }}
|
|
977
999
|
<div class="company">
|
|
978
|
-
<div class="badge-tag">
|
|
1000
|
+
<div class="badge-tag badge-tag--company">
|
|
979
1001
|
<div class="image">
|
|
980
1002
|
<img :src="campaign?.logo || '/imgs/no-cover-img.jpg'" alt="Logo" />
|
|
981
1003
|
</div>
|
|
@@ -1477,6 +1499,26 @@ onMounted(async () => {
|
|
|
1477
1499
|
}
|
|
1478
1500
|
}
|
|
1479
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
|
+
|
|
1480
1522
|
.actions {
|
|
1481
1523
|
display: flex;
|
|
1482
1524
|
flex-direction: column;
|
|
@@ -1573,6 +1615,7 @@ onMounted(async () => {
|
|
|
1573
1615
|
|
|
1574
1616
|
.company {
|
|
1575
1617
|
display: flex;
|
|
1618
|
+
flex-wrap: wrap;
|
|
1576
1619
|
gap: 12px;
|
|
1577
1620
|
.w-48 { max-width: 48px; }
|
|
1578
1621
|
.badge-tag {
|
|
@@ -1583,7 +1626,8 @@ onMounted(async () => {
|
|
|
1583
1626
|
gap: 12px;
|
|
1584
1627
|
font-size: 12px;
|
|
1585
1628
|
color: var(--secondary-info-fg);
|
|
1586
|
-
width: -
|
|
1629
|
+
width: fit-content;
|
|
1630
|
+
flex: 0 0 auto;
|
|
1587
1631
|
.image { width: 32px; height: 32px; img { width: 100%; border-radius: 100%; } }
|
|
1588
1632
|
.texts {
|
|
1589
1633
|
display: flex;
|
|
@@ -1594,6 +1638,11 @@ onMounted(async () => {
|
|
|
1594
1638
|
.small { color: var(--secondary-info-fg); }
|
|
1595
1639
|
}
|
|
1596
1640
|
}
|
|
1641
|
+
.badge-tag--company {
|
|
1642
|
+
padding: 6px 12px 6px 6px;
|
|
1643
|
+
gap: 8px;
|
|
1644
|
+
.image { width: 24px; height: 24px; }
|
|
1645
|
+
}
|
|
1597
1646
|
}
|
|
1598
1647
|
}
|
|
1599
1648
|
}
|
|
@@ -127,44 +127,49 @@
|
|
|
127
127
|
@error="(e) => { (e.target as HTMLImageElement).src = '/imgs/no-cover-img.jpg' }"
|
|
128
128
|
/>
|
|
129
129
|
|
|
130
|
+
<!-- Scarcity only. The plain count now lives in the card's
|
|
131
|
+
info row, where card metadata is actually read — a bare
|
|
132
|
+
number floating on the artwork said nothing, and the icon
|
|
133
|
+
that was supposed to explain it never rendered. Up here it
|
|
134
|
+
earns its place only when stock is running out. -->
|
|
130
135
|
<div
|
|
131
136
|
class="keys-badge"
|
|
132
|
-
v-if="card
|
|
137
|
+
v-if="showsLowStock(card)"
|
|
133
138
|
:title="$t('keys.campaigns.keys_left', { n: card.available_count })"
|
|
134
139
|
>
|
|
135
|
-
<
|
|
136
|
-
{{ card.available_count }}
|
|
140
|
+
<MGIcon size="1x" icon="giveaway" />
|
|
141
|
+
{{ $t("keys.campaigns.keys_low_stock", { n: card.available_count }) }}
|
|
137
142
|
</div>
|
|
138
143
|
|
|
139
144
|
<div
|
|
140
145
|
v-if="card.is_exclusive && !card.user_can_access"
|
|
141
146
|
class="exclusive-badge exclusive-lock"
|
|
142
147
|
>
|
|
143
|
-
<
|
|
148
|
+
<MGIcon size="1x" icon="reward" /> {{ $t("keys.campaigns.exclusive_upgrade") }}
|
|
144
149
|
</div>
|
|
145
150
|
<div v-else-if="card.is_exclusive" class="exclusive-badge">
|
|
146
|
-
<
|
|
151
|
+
<MGIcon size="1x" icon="reward" /> {{ $t("keys.campaigns.exclusive") }}
|
|
147
152
|
</div>
|
|
148
153
|
|
|
149
154
|
<div
|
|
150
155
|
v-else-if="card.is_tier_locked && card.access_tier !== 'free' && !card.user_can_access"
|
|
151
156
|
class="exclusive-badge"
|
|
152
157
|
>
|
|
153
|
-
<
|
|
158
|
+
<MGIcon size="1x" icon="reward" /> {{ cardTierLabel(card.access_tier) }}
|
|
154
159
|
</div>
|
|
155
160
|
|
|
156
161
|
<div
|
|
157
162
|
v-else-if="card.early_access_hours > 0 && card.user_can_access"
|
|
158
163
|
class="early-access-badge"
|
|
159
164
|
>
|
|
160
|
-
<
|
|
165
|
+
<MGIcon size="1x" icon="clock" /> {{ $t("keys.campaigns.early_access") }}
|
|
161
166
|
</div>
|
|
162
167
|
|
|
163
168
|
<div
|
|
164
169
|
v-if="card.streamer_type === 2 || card.streamer_type === 3"
|
|
165
170
|
class="exclusive-badge level-badge"
|
|
166
171
|
>
|
|
167
|
-
<
|
|
172
|
+
<MGIcon size="1x" icon="verified" />
|
|
168
173
|
{{
|
|
169
174
|
card.streamer_type === 2
|
|
170
175
|
? $t("keys.campaigns.restriction.level_official")
|
|
@@ -176,7 +181,7 @@
|
|
|
176
181
|
v-if="!card.user_can_access && card.available_at_for_user"
|
|
177
182
|
class="early-access-lock-overlay"
|
|
178
183
|
>
|
|
179
|
-
<
|
|
184
|
+
<MGIcon size="1x" icon="lock" />
|
|
180
185
|
<span class="lock-label">{{ $t("keys.campaigns.early_access_lock_title") }}</span>
|
|
181
186
|
<span class="lock-countdown">
|
|
182
187
|
{{ $t("keys.campaigns.early_access_opens_in") }}
|
|
@@ -188,7 +193,7 @@
|
|
|
188
193
|
v-else-if="!card.user_can_access && card.is_exclusive"
|
|
189
194
|
class="exclusive-lock-overlay"
|
|
190
195
|
>
|
|
191
|
-
<
|
|
196
|
+
<MGIcon size="1x" icon="reward" />
|
|
192
197
|
<span class="lock-label">{{ $t("keys.campaigns.exclusive_lock_title") }}</span>
|
|
193
198
|
<span class="lock-cta">{{ $t("keys.campaigns.exclusive_upgrade_cta") }}</span>
|
|
194
199
|
</div>
|
|
@@ -208,6 +213,15 @@
|
|
|
208
213
|
<div class="days" v-if="card.time > 0 && card.user_can_access">
|
|
209
214
|
<span>{{ card.time }}</span> {{ $t("keys.campaigns.days") }}
|
|
210
215
|
</div>
|
|
216
|
+
<!-- Labelled, next to the deadline: the two numbers a creator
|
|
217
|
+
actually decides on are "how long left" and "how many left". -->
|
|
218
|
+
<span
|
|
219
|
+
v-if="card.available_count > 0 && card.user_can_access && !card.isExpired && !card.isUpcoming"
|
|
220
|
+
class="card-keys-left"
|
|
221
|
+
>
|
|
222
|
+
<MGIcon size="1x" icon="giveaway" />
|
|
223
|
+
{{ $t("keys.campaigns.keys_left", { n: card.available_count }) }}
|
|
224
|
+
</span>
|
|
211
225
|
<span>
|
|
212
226
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="17" viewBox="0 0 16 17" fill="none">
|
|
213
227
|
<path d="M13.3333 4.1112L12.8666 3.64453L11.9333 4.57786L10.9333 3.64453L8.33329 4.1112L2.66663 9.77786L7.19996 14.3112L12.8666 8.64453L13.3333 6.04453L12.4 5.1112L13.3333 4.1112ZM12.6 6.24453L12.2666 8.3112L7.19996 13.3779L3.59996 9.77786L8.66663 4.7112L10.7333 4.3112L11.4666 5.04453L11 5.5112L10.5333 5.04453L9.99996 5.57786L11.4 6.97786L11.8666 6.5112L11.4 6.04453L11.8666 5.57786L12.6 6.24453Z" fill="var(--key-accent, var(--key-accent, var(--primary, #D297FF)))" />
|
|
@@ -231,7 +245,7 @@
|
|
|
231
245
|
</nuxt-link>
|
|
232
246
|
<nuxt-link :to="upgradeRoute">
|
|
233
247
|
<button class="btn secondary upgrade-btn">
|
|
234
|
-
<
|
|
248
|
+
<MGIcon size="1x" icon="chevron-up" />
|
|
235
249
|
{{ $t("keys.campaigns.upgrade_plan") }}
|
|
236
250
|
</button>
|
|
237
251
|
</nuxt-link>
|
|
@@ -473,6 +487,20 @@ function platformAbbr(name: string): string {
|
|
|
473
487
|
return PLATFORM_ABBR[name.toLowerCase()] ?? name
|
|
474
488
|
}
|
|
475
489
|
|
|
490
|
+
/**
|
|
491
|
+
* At or below this many keys left, the cover badge appears as a scarcity
|
|
492
|
+
* signal. Above it, the count is just a fact and lives in the info row.
|
|
493
|
+
*/
|
|
494
|
+
const LOW_STOCK_THRESHOLD = 5
|
|
495
|
+
|
|
496
|
+
function showsLowStock(card: any): boolean {
|
|
497
|
+
return card.available_count > 0
|
|
498
|
+
&& card.available_count <= LOW_STOCK_THRESHOLD
|
|
499
|
+
&& card.user_can_access
|
|
500
|
+
&& !card.isExpired
|
|
501
|
+
&& !card.isUpcoming
|
|
502
|
+
}
|
|
503
|
+
|
|
476
504
|
function cardTierLabel(tier: string): string {
|
|
477
505
|
const labels: Record<string, string> = { free: "Free", pro: "PRO", business: "Business", official: "Official" }
|
|
478
506
|
return $i18n.t("keys.campaigns.restriction.tier_locked", { tier: labels[tier] || tier })
|
|
@@ -874,6 +902,15 @@ onUnmounted(() => {
|
|
|
874
902
|
opacity: 0.5;
|
|
875
903
|
}
|
|
876
904
|
|
|
905
|
+
.card-keys-left {
|
|
906
|
+
display: inline-flex;
|
|
907
|
+
align-items: center;
|
|
908
|
+
gap: 4px;
|
|
909
|
+
white-space: nowrap;
|
|
910
|
+
|
|
911
|
+
i.icon { color: var(--key-accent, var(--primary, #D297FF)); }
|
|
912
|
+
}
|
|
913
|
+
|
|
877
914
|
.keys-badge {
|
|
878
915
|
position: absolute;
|
|
879
916
|
top: 8px;
|
|
@@ -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"
|
|
@@ -146,7 +150,7 @@
|
|
|
146
150
|
<!-- Success modal -->
|
|
147
151
|
<div v-if="showSuccessModal" class="success-modal-overlay" @click.self="handleSuccessClose">
|
|
148
152
|
<div class="success-modal">
|
|
149
|
-
<MGIcon size="2x" icon="
|
|
153
|
+
<MGIcon size="2x" icon="verified" style="color: #42FF00" />
|
|
150
154
|
<p>{{ $t("keys.materials.success_message") }}</p>
|
|
151
155
|
<button class="btn button-submit" @click="handleSuccessClose">
|
|
152
156
|
{{ $t("keys.materials.success_close") }}
|
|
@@ -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;
|