@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.
@@ -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")
@@ -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="check-circle" size="3x" />
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="check" :style="{ color: isBoxOpen ? 'var(--key-accent, var(--primary, #D297FF))' : 'var(--inactive)' }" />
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
- <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;
@@ -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,
@@ -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
  /**
@@ -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
+ }