@mundogamernetwork/shared-ui 1.8.16 → 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 +14 -6
- package/locales/en.json +9 -1
- package/locales/es.json +379 -0
- package/locales/pt-BR.json +14 -6
- package/locales/ro.json +14 -6
- package/package.json +1 -1
- package/pages/key-campaigns/[slug].vue +51 -2
- 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 +76 -0
- package/utils/clearSession.ts +22 -0
- package/utils/embargo.ts +125 -0
|
@@ -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
|
+
}
|