@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
|
@@ -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")
|
|
@@ -201,7 +248,7 @@ function goBack() {
|
|
|
201
248
|
<div v-else-if="error" class="state error">{{ error }}</div>
|
|
202
249
|
|
|
203
250
|
<div v-else-if="success" class="state success">
|
|
204
|
-
<MGIcon icon="
|
|
251
|
+
<MGIcon icon="verified-filled" size="3x" />
|
|
205
252
|
<p>{{ $t("keys.campaigns.request_sent") }}</p>
|
|
206
253
|
<button class="btn" @click="goBack">
|
|
207
254
|
{{ $t("keys.campaigns.back_to_campaign") }}
|
|
@@ -245,7 +292,7 @@ function goBack() {
|
|
|
245
292
|
<div class="required-data">
|
|
246
293
|
<div class="required-data-header" @click="toggleBox">
|
|
247
294
|
<div class="required-data-header-left">
|
|
248
|
-
<MGIcon size="1x" icon="
|
|
295
|
+
<MGIcon size="1x" icon="verified" :style="{ color: isBoxOpen ? 'var(--key-accent, var(--primary, #D297FF))' : 'var(--inactive)' }" />
|
|
249
296
|
<span>{{ $t("keys.campaigns.required_data") }}</span>
|
|
250
297
|
</div>
|
|
251
298
|
<button type="button" class="box-toggler" :aria-expanded="isBoxOpen">
|
|
@@ -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;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Hoists the social/SEO tags to the top of <head> on every front that extends
|
|
2
|
+
// this layer.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists (measured in production 2026-08-02): unhead 2.x applies Capo
|
|
5
|
+
// sorting by default, and its weight table (unhead/dist/shared/*.mjs) gives
|
|
6
|
+
// sync <style> a weight of 60 while a generic <meta> gets the default 100 —
|
|
7
|
+
// so *every* inline stylesheet is emitted before any og:*/twitter:* tag. With
|
|
8
|
+
// Nuxt's `features.inlineStyles` on (its default) that meant ~545KB of inlined
|
|
9
|
+
// CSS ahead of the OG tags, pushing them to byte 384.000-553.000 across all six
|
|
10
|
+
// fronts. Link-preview scrapers (WhatsApp, Facebook, X, LinkedIn, Telegram)
|
|
11
|
+
// truncate the HTML they fetch well before that and stop at </head>, so none of
|
|
12
|
+
// them ever saw an og:image — only Discord, which reads the full document, did.
|
|
13
|
+
//
|
|
14
|
+
// `features.inlineStyles: false` in each app's nuxt.config.ts is the primary
|
|
15
|
+
// fix; this plugin is the belt-and-braces so the problem cannot silently come
|
|
16
|
+
// back if some future component or config re-introduces a large inline <style>.
|
|
17
|
+
// Doing it here rather than per-page also covers the 100+ pages across the six
|
|
18
|
+
// fronts that set their own og tags, plus every page added from now on.
|
|
19
|
+
//
|
|
20
|
+
// -5 sits above <style> (60), <link rel=stylesheet> (60) and <title> (10), but
|
|
21
|
+
// still below <meta charset> (-20), <meta name=viewport> (-15) and the CSP meta
|
|
22
|
+
// (-30), so charset stays inside the first 1024 bytes as the spec requires.
|
|
23
|
+
const SEO_TAG_PRIORITY = -5;
|
|
24
|
+
|
|
25
|
+
function isSeoTag(tag: { tag: string; props?: Record<string, any> }): boolean {
|
|
26
|
+
const props = tag.props || {};
|
|
27
|
+
|
|
28
|
+
if (tag.tag === "meta") {
|
|
29
|
+
const key = props.property ?? props.name;
|
|
30
|
+
return (
|
|
31
|
+
typeof key === "string"
|
|
32
|
+
&& (key.startsWith("og:") || key.startsWith("twitter:") || key === "description")
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The canonical rides along: it is cheap and some scrapers resolve the
|
|
37
|
+
// preview against it rather than against the requested URL.
|
|
38
|
+
return tag.tag === "link" && props.rel === "canonical";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default defineNuxtPlugin({
|
|
42
|
+
name: "shared-ui-seo-tag-priority",
|
|
43
|
+
// Must run before any page's useHead/useSeoMeta entry is normalized.
|
|
44
|
+
enforce: "pre",
|
|
45
|
+
setup(nuxtApp) {
|
|
46
|
+
const head = injectHead(nuxtApp as any);
|
|
47
|
+
if (!head) return;
|
|
48
|
+
|
|
49
|
+
head.use({
|
|
50
|
+
key: "shared-ui-seo-tag-priority",
|
|
51
|
+
hooks: {
|
|
52
|
+
// `entries:normalize` is the correct seam: unhead computes each
|
|
53
|
+
// tag's weight (`t._w = tagWeight(head, t)`) immediately *after*
|
|
54
|
+
// this hook returns, and `tagWeight` short-circuits on a numeric
|
|
55
|
+
// `tagPriority`. Mutating in `tags:resolve` instead would be too
|
|
56
|
+
// late — the sort has already run by then.
|
|
57
|
+
"entries:normalize": ({ tags }: { tags: any[] }) => {
|
|
58
|
+
for (const tag of tags) {
|
|
59
|
+
// Never override an explicit priority set by a page.
|
|
60
|
+
if (tag.tagPriority !== undefined) continue;
|
|
61
|
+
if (isSeoTag(tag)) tag.tagPriority = SEO_TAG_PRIORITY;
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import axios, { type AxiosInstance } from "axios"
|
|
2
|
+
import { clearAuthCredentials } from "../utils/clearSession"
|
|
2
3
|
|
|
3
4
|
// Campaign service wraps the key-campaigns API used by TV, community, and agency.
|
|
4
5
|
// Each frontend backend registers the shared key routes through its own provider.
|
|
@@ -8,6 +9,29 @@ import axios, { type AxiosInstance } from "axios"
|
|
|
8
9
|
let _mainClient: AxiosInstance | null = null
|
|
9
10
|
let _authClient: AxiosInstance | null = null
|
|
10
11
|
|
|
12
|
+
// Debounce flag so a burst of failing requests only triggers one recovery.
|
|
13
|
+
let _sessionLossHandled = false
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether the client currently presents the user as logged in. Read from the
|
|
17
|
+
* auth store, falling back to the persisted copy the store hydrates from — the
|
|
18
|
+
* whole problem being solved here is that this can be true while the server no
|
|
19
|
+
* longer accepts the session.
|
|
20
|
+
*/
|
|
21
|
+
function clientThinksItIsSignedIn(): boolean {
|
|
22
|
+
try {
|
|
23
|
+
const authStore = useAuthStore() as any
|
|
24
|
+
if (typeof authStore?.signedIn === "boolean") return authStore.signedIn
|
|
25
|
+
} catch (_) {
|
|
26
|
+
// store not available in this context — fall through to localStorage
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
return !!JSON.parse(localStorage.getItem("auth-store") || "{}")?.signedIn
|
|
30
|
+
} catch (_) {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
11
35
|
const SUPPORTED_LOCALES = ["en", "pt-BR", "es", "de", "ro", "ar-AE"]
|
|
12
36
|
|
|
13
37
|
function resolveLang(): string {
|
|
@@ -104,9 +128,59 @@ function getAuthClient(): AxiosInstance {
|
|
|
104
128
|
return config
|
|
105
129
|
})
|
|
106
130
|
|
|
131
|
+
// Session-loss handling. The auth store persists `signedIn`/`user` in
|
|
132
|
+
// localStorage, so the UI keeps rendering a logged-in user (and every
|
|
133
|
+
// key-campaign CTA stays enabled) even after the httpOnly `oauth_token`
|
|
134
|
+
// cookie is gone or no longer accepted. The first thing the user notices is
|
|
135
|
+
// a raw backend "Unauthorized" under the submit button. Clearing the
|
|
136
|
+
// credentials here puts the client back into a real guest state so the page
|
|
137
|
+
// can offer a re-login instead of an untranslated dead end.
|
|
138
|
+
_authClient.interceptors.response.use(
|
|
139
|
+
(response) => response,
|
|
140
|
+
(error) => {
|
|
141
|
+
const status = error?.response?.status
|
|
142
|
+
if (typeof window !== "undefined" && (status === 401 || status === 419)) {
|
|
143
|
+
error.isSessionExpired = true
|
|
144
|
+
// Only tear down a session that the client still believes in. A
|
|
145
|
+
// guest browsing the campaign list also gets 401s from the
|
|
146
|
+
// auth-only endpoints on this client (campaign types, my
|
|
147
|
+
// requests), and running the whole recovery — cookie wipe plus a
|
|
148
|
+
// POST /logout — on every one of those is pure noise.
|
|
149
|
+
if (!_sessionLossHandled && clientThinksItIsSignedIn()) {
|
|
150
|
+
_sessionLossHandled = true
|
|
151
|
+
try {
|
|
152
|
+
clearAuthCredentials()
|
|
153
|
+
} catch (_) {
|
|
154
|
+
// store/runtime config not available in this context
|
|
155
|
+
}
|
|
156
|
+
// Allow another recovery later (covers a genuinely new failure)
|
|
157
|
+
setTimeout(() => {
|
|
158
|
+
_sessionLossHandled = false
|
|
159
|
+
}, 10000)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return Promise.reject(error)
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
|
|
107
166
|
return _authClient
|
|
108
167
|
}
|
|
109
168
|
|
|
169
|
+
/** True when a caught error came back as an expired/rejected session. */
|
|
170
|
+
export function isSessionExpired(error: any): boolean {
|
|
171
|
+
const status = error?.response?.status
|
|
172
|
+
return error?.isSessionExpired === true || status === 401 || status === 419
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Login URL that returns the user to the page they were on. `loginUrl` is the
|
|
177
|
+
* per-app value injected by TV/community/agency.
|
|
178
|
+
*/
|
|
179
|
+
export function buildReLoginUrl(loginUrl: string): string {
|
|
180
|
+
const returnTo = typeof window !== "undefined" ? window.location.href : ""
|
|
181
|
+
return `${loginUrl}?redirect_to=${encodeURIComponent(returnTo)}`
|
|
182
|
+
}
|
|
183
|
+
|
|
110
184
|
// Lazy proxies so the clients are only initialised when first used
|
|
111
185
|
const mainClient = new Proxy({} as AxiosInstance, {
|
|
112
186
|
get(_t, prop) {
|
|
@@ -332,6 +406,8 @@ export const fetchCampaignBySlug = async (slug: string) => {
|
|
|
332
406
|
press_kit_link: k.press_kit_link ?? null,
|
|
333
407
|
review_guideline: k.review_guideline ?? null,
|
|
334
408
|
embargo_date: k.embargo_date ?? null,
|
|
409
|
+
embargo_timezone: k.embargo_timezone ?? null,
|
|
410
|
+
embargo_active: k.embargo_active ?? false,
|
|
335
411
|
request_id: k.requests?.[0]?.id,
|
|
336
412
|
request_item_id: k.requests?.[0]?.key_item_id,
|
|
337
413
|
request_admin_notes: k.requests?.[0]?.admin_notes ?? null,
|
package/utils/clearSession.ts
CHANGED
|
@@ -74,6 +74,28 @@ export function clearAuthCredentials() {
|
|
|
74
74
|
} catch (_) {
|
|
75
75
|
// store not available in this context
|
|
76
76
|
}
|
|
77
|
+
|
|
78
|
+
// The main session cookie (oauth_token) is httpOnly — the expireCookie()
|
|
79
|
+
// loop above can never touch it (that's the whole point of httpOnly, XSS
|
|
80
|
+
// protection), so a stale/invalid token kept being resent on every
|
|
81
|
+
// request and the user stayed stuck in a 401 loop until a MANUAL logout,
|
|
82
|
+
// which works only because it round-trips through the server and gets a
|
|
83
|
+
// real Set-Cookie expiry back. Fire that same call here, best-effort:
|
|
84
|
+
// even if it fails (network hiccup, cross-domain Set-Cookie stripped by
|
|
85
|
+
// a CDN — a known risk, see file docblock above), the cookie-loop +
|
|
86
|
+
// authStore.clearUser() already got the client into a clean guest state.
|
|
87
|
+
try {
|
|
88
|
+
const runtimeConfig = useRuntimeConfig();
|
|
89
|
+
const apiBaseURL = runtimeConfig.public.mgSharedUi?.apiBaseURL || runtimeConfig.public.apiBaseURL;
|
|
90
|
+
if (apiBaseURL) {
|
|
91
|
+
fetch(`${String(apiBaseURL).replace(/\/$/, "")}/logout`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
credentials: "include",
|
|
94
|
+
}).catch(() => {});
|
|
95
|
+
}
|
|
96
|
+
} catch (_) {
|
|
97
|
+
// runtime config not available in this context (e.g. outside a Nuxt app)
|
|
98
|
+
}
|
|
77
99
|
}
|
|
78
100
|
|
|
79
101
|
/**
|
package/utils/embargo.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embargo formatting for key campaigns.
|
|
3
|
+
*
|
|
4
|
+
* An embargo is a day AND a time: "August 12" alone is not something a creator
|
|
5
|
+
* can comply with — publishing at 09:00 or at 23:00 on that day are very
|
|
6
|
+
* different acts. The API stores `embargo_date` as an instant (UTC) plus the
|
|
7
|
+
* `embargo_timezone` the campaign creator confirmed it in.
|
|
8
|
+
*
|
|
9
|
+
* Both matter to the creator, so both are shown: the campaign's own wall-clock
|
|
10
|
+
* (how the studio communicates the embargo everywhere else) and, when the
|
|
11
|
+
* viewer is somewhere else, the same instant on their own clock — so nobody has
|
|
12
|
+
* to do timezone arithmetic to know when they may publish.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** True while the embargo is set and has not lifted yet. */
|
|
16
|
+
export function isEmbargoActive(dateString: string | null | undefined): boolean {
|
|
17
|
+
if (!dateString) return false
|
|
18
|
+
const date = new Date(dateString)
|
|
19
|
+
return !isNaN(date.getTime()) && date.getTime() > Date.now()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* How long until the embargo lifts, localized: "em 12 dias", "amanhã",
|
|
24
|
+
* "em 5 horas". Creators request keys days before release, so a bare date is
|
|
25
|
+
* easy to lose track of — the countdown is what actually sticks.
|
|
26
|
+
*
|
|
27
|
+
* Returns "" once the embargo has lifted (or when there is none).
|
|
28
|
+
*/
|
|
29
|
+
export function formatEmbargoCountdown(
|
|
30
|
+
dateString: string | null | undefined,
|
|
31
|
+
locale: string,
|
|
32
|
+
): string {
|
|
33
|
+
if (!isEmbargoActive(dateString)) return ""
|
|
34
|
+
const diffMs = new Date(dateString as string).getTime() - Date.now()
|
|
35
|
+
|
|
36
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" })
|
|
37
|
+
const minutes = Math.round(diffMs / 60000)
|
|
38
|
+
if (minutes < 60) return rtf.format(Math.max(minutes, 1), "minute")
|
|
39
|
+
|
|
40
|
+
const hours = Math.round(diffMs / 3600000)
|
|
41
|
+
if (hours < 24) return rtf.format(hours, "hour")
|
|
42
|
+
|
|
43
|
+
return rtf.format(Math.round(diffMs / 86400000), "day")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function format(
|
|
47
|
+
date: Date,
|
|
48
|
+
locale: string,
|
|
49
|
+
timeZone?: string,
|
|
50
|
+
timeZoneName: "short" | "long" = "short",
|
|
51
|
+
): string {
|
|
52
|
+
return new Intl.DateTimeFormat(locale, {
|
|
53
|
+
day: "numeric",
|
|
54
|
+
month: "long",
|
|
55
|
+
year: "numeric",
|
|
56
|
+
hour: "2-digit",
|
|
57
|
+
minute: "2-digit",
|
|
58
|
+
timeZoneName,
|
|
59
|
+
...(timeZone ? { timeZone } : {}),
|
|
60
|
+
}).format(date)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Time only — used for the local equivalent when it lands on the same day. */
|
|
64
|
+
function formatTime(date: Date, locale: string, timeZone?: string): string {
|
|
65
|
+
return new Intl.DateTimeFormat(locale, {
|
|
66
|
+
hour: "2-digit",
|
|
67
|
+
minute: "2-digit",
|
|
68
|
+
timeZoneName: "short",
|
|
69
|
+
...(timeZone ? { timeZone } : {}),
|
|
70
|
+
}).format(date)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Calendar day in a given zone, as a comparable "en-CA" YYYY-MM-DD string. */
|
|
74
|
+
function dayIn(date: Date, timeZone?: string): string {
|
|
75
|
+
return new Intl.DateTimeFormat("en-CA", {
|
|
76
|
+
year: "numeric",
|
|
77
|
+
month: "2-digit",
|
|
78
|
+
day: "2-digit",
|
|
79
|
+
...(timeZone ? { timeZone } : {}),
|
|
80
|
+
}).format(date)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Full embargo moment: the campaign's own wall-clock, named in the viewer's
|
|
85
|
+
* language, plus the same instant on the viewer's clock when they differ.
|
|
86
|
+
* e.g. "12 de agosto de 2026 às 14:00 Horário Padrão de Brasília — 19:00 GMT+2"
|
|
87
|
+
*
|
|
88
|
+
* Returns "" for a missing/invalid date so callers can `v-if` on it.
|
|
89
|
+
*/
|
|
90
|
+
export function formatEmbargoDateTime(
|
|
91
|
+
dateString: string | null | undefined,
|
|
92
|
+
timezone: string | null | undefined,
|
|
93
|
+
locale: string,
|
|
94
|
+
): string {
|
|
95
|
+
if (!dateString) return ""
|
|
96
|
+
const date = new Date(dateString)
|
|
97
|
+
if (isNaN(date.getTime())) return ""
|
|
98
|
+
|
|
99
|
+
const viewerZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
100
|
+
|
|
101
|
+
if (!timezone || timezone === viewerZone) {
|
|
102
|
+
return format(date, locale)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
// "long" names the zone in the viewer's own language ("Horário Padrão de
|
|
107
|
+
// Brasília", "Ora standard a Brasiliei"). Deriving a label from the IANA
|
|
108
|
+
// id instead would print ASCII city names ("Sao Paulo", no tilde) in
|
|
109
|
+
// every language.
|
|
110
|
+
const campaign = format(date, locale, timezone, "long")
|
|
111
|
+
// Two zones on the same offset right now — the local line would be a
|
|
112
|
+
// verbatim repeat and just look like a bug.
|
|
113
|
+
if (formatTime(date, locale, timezone) === formatTime(date, locale)) {
|
|
114
|
+
return campaign
|
|
115
|
+
}
|
|
116
|
+
const local = dayIn(date, timezone) === dayIn(date)
|
|
117
|
+
? formatTime(date, locale)
|
|
118
|
+
: format(date, locale)
|
|
119
|
+
return `${campaign} — ${local}`
|
|
120
|
+
} catch {
|
|
121
|
+
// Unknown/invalid IANA zone recorded on the campaign: never let the
|
|
122
|
+
// embargo line disappear because of it.
|
|
123
|
+
return format(date, locale)
|
|
124
|
+
}
|
|
125
|
+
}
|