@mundogamernetwork/shared-ui 1.8.15 → 1.8.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/locales/de.json +16 -7
- package/locales/en.json +11 -2
- package/locales/es.json +379 -0
- package/locales/pt-BR.json +16 -7
- package/locales/ro.json +16 -7
- package/package.json +1 -1
- package/pages/key-campaigns/[slug].vue +120 -3
- package/pages/key-campaigns/key-materials.vue +26 -2
- package/pages/key-campaigns/redeem-key-approved.vue +117 -11
- package/pages/key-campaigns/redeem-key.vue +103 -4
- package/plugins/seo-tag-priority.ts +67 -0
- package/services/campaignService.ts +92 -0
- package/utils/clearSession.ts +22 -0
- package/utils/embargo.ts +125 -0
|
@@ -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) {
|
|
@@ -306,7 +380,9 @@ export const fetchCampaignBySlug = async (slug: string) => {
|
|
|
306
380
|
request_status: k.request_user_status,
|
|
307
381
|
end: k.end_date,
|
|
308
382
|
start: k.start_date,
|
|
383
|
+
is_following: k.is_following ?? false,
|
|
309
384
|
company: k.company?.name,
|
|
385
|
+
company_type: k.company?.company_type ?? null,
|
|
310
386
|
email: k.company?.email,
|
|
311
387
|
twitter: k.company?.twitter,
|
|
312
388
|
logo: k.company?.thumbnail_url,
|
|
@@ -330,6 +406,8 @@ export const fetchCampaignBySlug = async (slug: string) => {
|
|
|
330
406
|
press_kit_link: k.press_kit_link ?? null,
|
|
331
407
|
review_guideline: k.review_guideline ?? null,
|
|
332
408
|
embargo_date: k.embargo_date ?? null,
|
|
409
|
+
embargo_timezone: k.embargo_timezone ?? null,
|
|
410
|
+
embargo_active: k.embargo_active ?? false,
|
|
333
411
|
request_id: k.requests?.[0]?.id,
|
|
334
412
|
request_item_id: k.requests?.[0]?.key_item_id,
|
|
335
413
|
request_admin_notes: k.requests?.[0]?.admin_notes ?? null,
|
|
@@ -448,6 +526,20 @@ export const fetchKeyActions = (keyId: number) => {
|
|
|
448
526
|
return authClient.get(`/public/keys/${keyId}/actions`)
|
|
449
527
|
}
|
|
450
528
|
|
|
529
|
+
/**
|
|
530
|
+
* Follow/unfollow a specific campaign — a personal reminder distinct from
|
|
531
|
+
* enableKeyCampaignNotifications() above (a one-time global opt-in to every
|
|
532
|
+
* eligible campaign). Most useful before a campaign with a future start
|
|
533
|
+
* date has opened: the user gets notified the moment it does.
|
|
534
|
+
*/
|
|
535
|
+
export const followCampaign = (keyId: number) => {
|
|
536
|
+
return authClient.post(`/public/keys/${keyId}/follow`)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export const unfollowCampaign = (keyId: number) => {
|
|
540
|
+
return authClient.delete(`/public/keys/${keyId}/follow`)
|
|
541
|
+
}
|
|
542
|
+
|
|
451
543
|
/**
|
|
452
544
|
* Submit a content material for a key request.
|
|
453
545
|
*/
|
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
|
+
}
|