@mundogamernetwork/shared-ui 1.8.23 → 1.9.0

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.
@@ -8,7 +8,12 @@ import {
8
8
  isSessionExpired,
9
9
  buildReLoginUrl,
10
10
  } from "../../services/campaignService"
11
- import { formatEmbargoDateTime, formatEmbargoCountdown, isEmbargoActive } from "../../utils/embargo"
11
+ import {
12
+ formatEmbargoDateTime,
13
+ formatEmbargoCountdown,
14
+ isEmbargoActive,
15
+ type EmbargoDisplay,
16
+ } from "../../utils/embargo"
12
17
 
13
18
  const route = useRoute()
14
19
  const { $i18n } = useNuxtApp()
@@ -38,7 +43,7 @@ const platform = ref("")
38
43
  // (needsEmbargoAck) — a real consent step, not just the neutral, unenforced
39
44
  // date line this page used to show.
40
45
  const needsEmbargoAck = ref(false)
41
- const embargoAckDate = ref<string | null>(null)
46
+ const embargoAckDisplay = ref<EmbargoDisplay | null>(null)
42
47
  const embargoAckChecked = ref(false)
43
48
 
44
49
  // Maps a platform name to its official key-activation page.
@@ -120,7 +125,7 @@ async function reveal(acknowledgeEmbargo = false) {
120
125
  error.value = $i18n.t("keys.campaigns.error_session_expired")
121
126
  } else if (errorCode === "embargo_acknowledgment_required") {
122
127
  needsEmbargoAck.value = true
123
- embargoAckDate.value = body.embargo_date || null
128
+ embargoAckDisplay.value = body.embargo_display || null
124
129
  } else if (code === "NO_KEYS_AVAILABLE" || code === "KEY_ITEM_NOT_FOUND") {
125
130
  error.value = $i18n.t("keys.campaigns.error_no_keys")
126
131
  } else if (code === "KEY_NOT_APPROVED") {
@@ -150,11 +155,11 @@ async function copyCode() {
150
155
  }
151
156
 
152
157
  /**
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.
158
+ * Embargo shown with day AND time, on the clock the campaign creator set it in
159
+ * — a date alone is not something a creator can comply with.
155
160
  */
156
- function formatEmbargo(dateString: string | null | undefined): string {
157
- return formatEmbargoDateTime(dateString, requestData.value?.key?.embargo_timezone, locale.value)
161
+ function formatEmbargo(display: EmbargoDisplay | null | undefined): string {
162
+ return formatEmbargoDateTime(display, locale.value)
158
163
  }
159
164
 
160
165
  // Embargo state stays on screen AFTER the code is revealed and on every later
@@ -172,12 +177,16 @@ const prContact = computed(() =>
172
177
  .join(" · "),
173
178
  )
174
179
 
175
- const embargoDate = computed<string | null>(() => requestData.value?.key?.embargo_date ?? null)
176
- const embargoIsActive = computed(() => isEmbargoActive(embargoDate.value))
177
- const embargoMoment = computed(() => formatEmbargo(embargoDate.value))
180
+ const embargoDisplay = computed<EmbargoDisplay | null>(
181
+ () => requestData.value?.key?.embargo_display ?? null,
182
+ )
183
+ const embargoIsActive = computed(() => isEmbargoActive(embargoDisplay.value))
184
+ const embargoMoment = computed(() => formatEmbargo(embargoDisplay.value))
178
185
  // Recomputed on every reveal/visit; a live ticker would be noise on a page the
179
186
  // user opens, copies from and leaves.
180
- const embargoCountdown = computed(() => formatEmbargoCountdown(embargoDate.value, locale.value))
187
+ const embargoCountdown = computed(() =>
188
+ formatEmbargoCountdown(embargoDisplay.value, locale.value),
189
+ )
181
190
 
182
191
  function formatDate(dateString: string | null | undefined): string {
183
192
  if (!dateString) return "-"
@@ -241,7 +250,7 @@ function goBack() {
241
250
  {{ $t("keys.campaigns.embargo_countdown", { relative: embargoCountdown }) }}
242
251
  </p>
243
252
  </template>
244
- <template v-else-if="embargoDate">
253
+ <template v-else-if="embargoDisplay">
245
254
  <strong>{{ $t("keys.campaigns.embargo_lifted_title") }}</strong>
246
255
  <p>{{ $t("keys.campaigns.no_embargo_desc") }}</p>
247
256
  </template>
@@ -261,7 +270,7 @@ function goBack() {
261
270
  <div class="embargo-ack" v-if="!keyCode && needsEmbargoAck">
262
271
  <label class="embargo-ack-label">
263
272
  <input type="checkbox" v-model="embargoAckChecked" />
264
- {{ $t("keys.campaigns.embargo_ack_label", { date: formatEmbargo(embargoAckDate) }) }}
273
+ {{ $t("keys.campaigns.embargo_ack_label", { date: formatEmbargo(embargoAckDisplay) }) }}
265
274
  </label>
266
275
  <button
267
276
  class="btn"
@@ -59,7 +59,7 @@ const campaignPath = computed(
59
59
  let redirectTimer: ReturnType<typeof setTimeout> | null = null
60
60
 
61
61
  const embargoDateTime = computed(() =>
62
- formatEmbargoDateTime(campaign.value?.embargo_date, campaign.value?.embargo_timezone, locale.value),
62
+ formatEmbargoDateTime(campaign.value?.embargo_display, locale.value),
63
63
  )
64
64
  const hasEmbargo = computed(() => !!embargoDateTime.value)
65
65
 
@@ -125,11 +125,38 @@ watch(
125
125
  },
126
126
  )
127
127
 
128
- // True once options finished loading and either list came back empty — every
129
- // platform/region combo this campaign ever had is now fully redeemed.
130
- const soldOut = computed(() => !loading.value && (platforms.value.length === 0 || regions.value.length === 0))
128
+ // `available_count` is what the API actually reports as unclaimed. The
129
+ // platform/region lists only describe how those keys are TAGGED, so inferring
130
+ // "sold out" from an empty list told creators every key had been claimed on
131
+ // campaigns where nothing had been requested at all — any key stored without a
132
+ // region (bulk import, seed, legacy row, a campaign with no regional split)
133
+ // made the whole campaign vanish. Fall back to the old inference only when the
134
+ // API omits the field.
135
+ const availableCount = computed(() => {
136
+ const raw = campaign.value?.available_count
137
+ return typeof raw === "number" ? raw : null
138
+ })
139
+
140
+ const soldOut = computed(() => {
141
+ if (loading.value) return false
142
+ if (availableCount.value !== null) return availableCount.value <= 0
143
+ return platforms.value.length === 0 || regions.value.length === 0
144
+ })
131
145
 
132
- const canSubmit = computed(() => !submitting.value && !soldOut.value)
146
+ // A pool with no regions is legitimate — the studio-side upload offers
147
+ // "Global" and stores region_id = null (KeyPoolController::store). So only a
148
+ // missing PLATFORM makes a pool impossible to request from; the request
149
+ // endpoint requires a platform always, and a region only when the pool has
150
+ // region-locked keys. Calling the global case "sold out" blamed other creators
151
+ // for a campaign nobody had touched.
152
+ const missingOptions = computed(
153
+ () => !loading.value && !soldOut.value && platforms.value.length === 0,
154
+ )
155
+
156
+ // Drives both the client-side guard and whether region_ids goes on the wire.
157
+ const regionRequired = computed(() => regions.value.length > 0)
158
+
159
+ const canSubmit = computed(() => !submitting.value && !soldOut.value && !missingOptions.value)
133
160
 
134
161
  // Collapsible "Required data" box — matches tv's original toggle behavior
135
162
  const isBoxOpen = ref(true)
@@ -166,7 +193,15 @@ async function submit() {
166
193
  if (!canSubmit.value) return
167
194
  requestError.value = ""
168
195
  sessionExpired.value = false
169
- if (!selectedPlatformId.value || !selectedRegionId.value || !quantity.value || !description.value?.trim()) {
196
+ // Region is only demanded when the pool actually offers one — a global pool
197
+ // (region_id null on every key item) has nothing for the creator to pick,
198
+ // and requiring it here left the submit button dead with no explanation.
199
+ if (
200
+ !selectedPlatformId.value
201
+ || (regionRequired.value && !selectedRegionId.value)
202
+ || !quantity.value
203
+ || !description.value?.trim()
204
+ ) {
170
205
  requestError.value = $i18n.t("keys.campaigns.required_fields")
171
206
  return
172
207
  }
@@ -179,7 +214,9 @@ async function submit() {
179
214
  const response = await requestKey({
180
215
  key_id: keyId,
181
216
  platform_ids: [selectedPlatformId.value],
182
- region_ids: [selectedRegionId.value],
217
+ // Omitted entirely on a global pool — sending [null] would fail
218
+ // `region_ids.*` => integer.
219
+ ...(selectedRegionId.value ? { region_ids: [selectedRegionId.value] } : {}),
183
220
  description: description.value || "-",
184
221
  quantity_keys: Number(quantity.value) || 1,
185
222
  // Recorded on the request so the acknowledgment is provable, not just
@@ -284,7 +321,7 @@ onUnmounted(() => {
284
321
  <p v-if="campaign?.name" class="campaign-name">{{ campaign.name }}</p>
285
322
  </div>
286
323
 
287
- <!-- All platform/region combos are fully redeemed -->
324
+ <!-- Every key really has been claimed -->
288
325
  <div v-if="!hasExistingRequest && soldOut" class="existing-request-notice">
289
326
  <MGIcon size="1x" icon="version" class="notice-icon" />
290
327
  <div class="notice-texts">
@@ -295,6 +332,17 @@ onUnmounted(() => {
295
332
  </button>
296
333
  </div>
297
334
 
335
+ <!-- Keys left, but no platform/region to request them against -->
336
+ <div v-if="!hasExistingRequest && missingOptions" class="existing-request-notice">
337
+ <MGIcon size="1x" icon="version" class="notice-icon" />
338
+ <div class="notice-texts">
339
+ <div class="notice-title">{{ $t("keys.campaigns.options_unavailable") }}</div>
340
+ </div>
341
+ <button class="btn notice-back" @click="goBack">
342
+ {{ $t("keys.campaigns.back_to_campaign") }}
343
+ </button>
344
+ </div>
345
+
298
346
  <!-- Already has a request -->
299
347
  <div v-else-if="hasExistingRequest" class="existing-request-notice">
300
348
  <MGIcon size="1x" icon="version" class="notice-icon" />
@@ -32,7 +32,7 @@ const lang = SUPPORTED_LOCALES.find(l => routePath.includes(l)) || 'en';
32
32
  // kits render on the same public page without needing to be published first.
33
33
  const previewToken = route.query.preview_token as string | undefined;
34
34
 
35
- const { data: kitData, error: fetchError, pending } = await useAsyncData(
35
+ const { data: kitData, error: fetchError, pending, refresh: refreshKit } = await useAsyncData(
36
36
  `press-kit-${slug}`,
37
37
  () => $fetch<any>(`${apiBase}/public/press-kits/${slug}`, {
38
38
  params: {
@@ -55,7 +55,30 @@ const { data: moreKitsData } = await useAsyncData(
55
55
 
56
56
  const kit = computed(() => kitData.value?.data || kitData.value || null);
57
57
  const loading = pending;
58
- const error = computed(() => !!fetchError.value || (!pending.value && !kit.value));
58
+
59
+ // A request that FAILED is not the same as a kit that does not EXIST. Collapsing
60
+ // both into one "not found" state meant any hiccup — a 5xx, a network blip, SSR
61
+ // unable to reach the API — told the studio its published kit had been removed,
62
+ // on the exact link it sends to journalists, with HTTP 200 and no way to retry.
63
+ // 404/410 is a real absence; everything else is a fault worth retrying.
64
+ const fetchStatus = computed<number | null>(() => {
65
+ const e = fetchError.value as any;
66
+ return e?.statusCode ?? e?.status ?? e?.response?.status ?? null;
67
+ });
68
+ const loadFailed = computed(
69
+ () => !!fetchError.value && !(fetchStatus.value === 404 || fetchStatus.value === 410),
70
+ );
71
+ const notFound = computed(() => !pending.value && !loadFailed.value && !kit.value);
72
+
73
+ const retrying = ref(false);
74
+ async function retryKit() {
75
+ retrying.value = true;
76
+ try {
77
+ await refreshKit();
78
+ } finally {
79
+ retrying.value = false;
80
+ }
81
+ }
59
82
 
60
83
  onMounted(() => {
61
84
  if (kit.value && !previewToken) {
@@ -323,7 +346,14 @@ useHead(() => ({
323
346
  <template>
324
347
  <div class="kit-press" :data-tpl="tpl" :data-layout="layout">
325
348
  <div v-if="loading" class="kit-state">{{ $t('kit.loading') }}</div>
326
- <div v-else-if="error || !kit" class="kit-state kit-state--not-found">
349
+ <div v-else-if="loadFailed" class="kit-state kit-state--not-found">
350
+ <h2>{{ $t('kit.press.load_failed') }}</h2>
351
+ <p class="kit-state__desc">{{ $t('kit.press.load_failed_desc') }}</p>
352
+ <button type="button" class="kit-footer__cta" :disabled="retrying" @click="retryKit">
353
+ {{ retrying ? $t('kit.loading') : $t('kit.press.retry') }}
354
+ </button>
355
+ </div>
356
+ <div v-else-if="notFound" class="kit-state kit-state--not-found">
327
357
  <h2>{{ $t('kit.press.not_found') }}</h2>
328
358
  <p class="kit-state__desc">{{ $t('kit.press.not_found_desc') }}</p>
329
359
  <a :href="`${agencyBase}/presskit`" target="_blank" rel="noopener" class="kit-footer__cta">
@@ -376,6 +376,15 @@ export const fetchCampaignBySlug = async (slug: string) => {
376
376
  cover_focal_y: k.cover_focal_y ?? 0.5,
377
377
  game_cover: k.game?.url_cover_src,
378
378
  game_slug: k.game?.slug,
379
+ game_name: k.game?.name ?? null,
380
+ // Creator feedback. The can_* flags are computed server-side by
381
+ // KeyTransformer (detail endpoint only) so the wall and the rating form
382
+ // never offer an action the API would reject.
383
+ allow_comments: k.allow_comments !== false,
384
+ comments_count: k.comments_count ?? 0,
385
+ can_comment: k.can_comment ?? false,
386
+ can_rate: k.can_rate ?? false,
387
+ can_moderate_comments: k.can_moderate_comments ?? false,
379
388
  game_genres: ((k.game?.genres || []) as Array<{ name?: string }>)
380
389
  .map((g) => g.name)
381
390
  .filter(Boolean) as string[],
@@ -408,8 +417,11 @@ export const fetchCampaignBySlug = async (slug: string) => {
408
417
  game_videos: (k.game?.videos || []) as Array<{ external_id: string; name?: string }>,
409
418
  press_kit_link: k.press_kit_link ?? null,
410
419
  review_guideline: k.review_guideline ?? null,
411
- embargo_date: k.embargo_date ?? null,
412
- embargo_timezone: k.embargo_timezone ?? null,
420
+ // The instant plus a zone the server has already resolved. Replaces the
421
+ // raw embargo_date/embargo_timezone pair, which nothing read once the
422
+ // formatter moved onto this block — the campaign write forms use those
423
+ // names too, but against their own payload, not this mapping.
424
+ embargo_display: k.embargo_display ?? null,
413
425
  embargo_active: k.embargo_active ?? false,
414
426
  request_id: k.requests?.[0]?.id,
415
427
  request_item_id: k.requests?.[0]?.key_item_id,
@@ -474,6 +486,34 @@ export const fetchMyRequests = (params: Record<string, any> = {}) => {
474
486
  return authClient.get(`/key-requests-me${queryString ? `?${queryString}` : ""}`)
475
487
  }
476
488
 
489
+ /**
490
+ * Download the creator's content calendar as a file.
491
+ *
492
+ * Fetched through the same authenticated client rather than dropped into a
493
+ * plain <a download> link: the endpoint is session-authenticated, and a bare
494
+ * anchor navigation would not carry the client's auth headers or the `lang`
495
+ * param the API needs to localise the event titles.
496
+ *
497
+ * The response is a file, so responseType has to be "blob" — axios would
498
+ * otherwise decode it as text and, for the CSV, strip the byte-order mark that
499
+ * makes Excel read it as UTF-8.
500
+ */
501
+ export const downloadContentCalendar = async (format: "ics" | "csv") => {
502
+ const response = await authClient.get(`/public/key-requests-me/calendar.${format}`, {
503
+ responseType: "blob",
504
+ })
505
+
506
+ const url = URL.createObjectURL(response.data as Blob)
507
+ const link = document.createElement("a")
508
+ link.href = url
509
+ link.download = `mundogamer-content-calendar.${format}`
510
+ document.body.appendChild(link)
511
+ link.click()
512
+ link.remove()
513
+ // Revoking immediately can cancel the download in Safari; a tick is enough.
514
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
515
+ }
516
+
477
517
  /**
478
518
  * Fetch users who have redeemed a specific campaign key.
479
519
  */
@@ -563,3 +603,110 @@ export const fetchMaterialsByRequest = (requestId: number) => {
563
603
  export const fetchKeyRequestById = (requestId: number) => {
564
604
  return authClient.get(`/public/key-requests/${requestId}`)
565
605
  }
606
+
607
+ // ---------------------------------------------------------------------------
608
+ // Creator feedback: comments + the game rating a creator leaves after playing.
609
+ //
610
+ // Served by api-main's Shared\Comments module, which is registered in api-main,
611
+ // community-api, agency-api and tv-api alike — so, unlike a route declared in
612
+ // api-main's own routes/api.php, authClient reaches it from every front.
613
+ // ---------------------------------------------------------------------------
614
+
615
+ export type CampaignCommentAuthor = {
616
+ uuid: string | null
617
+ name: string | null
618
+ nickname: string | null
619
+ avatar: string | null
620
+ }
621
+
622
+ export type CampaignComment = {
623
+ uuid: string
624
+ body: string
625
+ edited: boolean
626
+ parent_uuid: string | null
627
+ created_at: string | null
628
+ updated_at: string | null
629
+ replies_count: number
630
+ replies?: CampaignComment[]
631
+ author: CampaignCommentAuthor | null
632
+ is_author: boolean
633
+ can_edit: boolean
634
+ can_delete: boolean
635
+ is_hidden: boolean
636
+ hidden_reason?: string | null
637
+ }
638
+
639
+ export type CampaignCommentsResponse = {
640
+ data: CampaignComment[]
641
+ meta: { current_page: number; last_page: number; per_page: number; total: number }
642
+ can_comment: boolean
643
+ can_moderate: boolean
644
+ }
645
+
646
+ /**
647
+ * Reading the wall is public, so it goes through mainClient — a signed-out
648
+ * visitor deciding whether to request a key should still see what other
649
+ * creators said.
650
+ */
651
+ export const fetchCampaignComments = (keyId: number, page = 1) => {
652
+ return mainClient.get<CampaignCommentsResponse>(
653
+ `/public/commentables/key/${keyId}/comments`,
654
+ { params: { page } },
655
+ )
656
+ }
657
+
658
+ export const postCampaignComment = (keyId: number, body: string) => {
659
+ return authClient.post(`/public/commentables/key/${keyId}/comments`, { body })
660
+ }
661
+
662
+ export const replyCampaignComment = (uuid: string, body: string) => {
663
+ return authClient.post(`/public/comments/${uuid}/reply`, { body })
664
+ }
665
+
666
+ export const updateCampaignComment = (uuid: string, body: string) => {
667
+ return authClient.put(`/public/comments/${uuid}`, { body })
668
+ }
669
+
670
+ export const deleteCampaignComment = (uuid: string) => {
671
+ return authClient.delete(`/public/comments/${uuid}`)
672
+ }
673
+
674
+ export const fetchCampaignCommentReplies = (uuid: string) => {
675
+ return mainClient.get(`/public/comments/${uuid}/replies`)
676
+ }
677
+
678
+ export const reportCampaignComment = (
679
+ uuid: string,
680
+ payload: { subcategory_id: number; description?: string },
681
+ ) => {
682
+ return authClient.post(`/public/comments/${uuid}/report`, payload)
683
+ }
684
+
685
+ /** Studio-side moderation. Hiding is reversible and never deletes the comment. */
686
+ export const hideCampaignComment = (uuid: string, reason?: string) => {
687
+ return authClient.post(`/public/comments/${uuid}/hide`, { reason })
688
+ }
689
+
690
+ export const unhideCampaignComment = (uuid: string) => {
691
+ return authClient.delete(`/public/comments/${uuid}/hide`)
692
+ }
693
+
694
+ /**
695
+ * Rate the campaign's game as a creator who received a key.
696
+ *
697
+ * This writes an ordinary rating against the *game*, so it lands in the game
698
+ * page's ratings tab and counts towards the public average — which is only
699
+ * defensible because disclosure_acknowledged is recorded and shown as a badge.
700
+ * The API rejects the request outright if it is not accepted.
701
+ */
702
+ export const submitCampaignGameRating = (
703
+ keyId: number,
704
+ payload: {
705
+ rating: number
706
+ comment_title?: string
707
+ user_comment?: string
708
+ disclosure_acknowledged: boolean
709
+ },
710
+ ) => {
711
+ return authClient.post(`/public/keys/${keyId}/game-rating`, payload)
712
+ }
package/utils/embargo.ts CHANGED
@@ -3,20 +3,49 @@
3
3
  *
4
4
  * An embargo is a day AND a time: "August 12" alone is not something a creator
5
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.
6
+ * different acts.
8
7
  *
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.
8
+ * Both clocks matter to the creator, so both are shown: the campaign's own
9
+ * wall-clock (how the studio communicates the embargo everywhere else) and,
10
+ * when the viewer is somewhere else, the same instant on their own clock — so
11
+ * nobody has to do timezone arithmetic to know when they may publish.
12
+ *
13
+ * The *instant and the zone* come from the API's `embargo_display` block rather
14
+ * than from the raw `embargo_date`/`embargo_timezone` columns, so every client
15
+ * agrees on which moment is being described — the mobile app cannot name a zone
16
+ * itself (Dart ships no timezone database) and reads the server's rendering.
17
+ * `campaign_zone` there is already resolved: an unusable stored value has been
18
+ * collapsed to UTC server-side instead of reaching the formatter.
19
+ *
20
+ * The *formatting* stays here on purpose. Rendering the server's pre-built
21
+ * string would freeze the text in whatever locale the payload was fetched
22
+ * under, so switching language would leave a stale line until a refetch;
23
+ * Intl re-runs on every locale change.
13
24
  */
14
25
 
26
+ /** The API's `embargo_display` block. Null/absent means: no embargo. */
27
+ export interface EmbargoDisplay {
28
+ utc: string
29
+ campaign_zone: string
30
+ campaign_zone_label?: string | null
31
+ campaign_offset?: string | null
32
+ campaign_wall_clock?: string | null
33
+ locale?: string | null
34
+ }
35
+
36
+ type MaybeEmbargo = EmbargoDisplay | null | undefined
37
+
38
+ /** The embargo instant, or null when there is none / it is unparseable. */
39
+ function instantOf(display: MaybeEmbargo): Date | null {
40
+ if (!display?.utc) return null
41
+ const date = new Date(display.utc)
42
+ return isNaN(date.getTime()) ? null : date
43
+ }
44
+
15
45
  /** 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()
46
+ export function isEmbargoActive(display: MaybeEmbargo): boolean {
47
+ const date = instantOf(display)
48
+ return date !== null && date.getTime() > Date.now()
20
49
  }
21
50
 
22
51
  /**
@@ -27,11 +56,12 @@ export function isEmbargoActive(dateString: string | null | undefined): boolean
27
56
  * Returns "" once the embargo has lifted (or when there is none).
28
57
  */
29
58
  export function formatEmbargoCountdown(
30
- dateString: string | null | undefined,
59
+ display: MaybeEmbargo,
31
60
  locale: string,
32
61
  ): string {
33
- if (!isEmbargoActive(dateString)) return ""
34
- const diffMs = new Date(dateString as string).getTime() - Date.now()
62
+ const date = instantOf(display)
63
+ if (!date || date.getTime() <= Date.now()) return ""
64
+ const diffMs = date.getTime() - Date.now()
35
65
 
36
66
  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" })
37
67
  const minutes = Math.round(diffMs / 60000)
@@ -88,14 +118,13 @@ function dayIn(date: Date, timeZone?: string): string {
88
118
  * Returns "" for a missing/invalid date so callers can `v-if` on it.
89
119
  */
90
120
  export function formatEmbargoDateTime(
91
- dateString: string | null | undefined,
92
- timezone: string | null | undefined,
121
+ display: MaybeEmbargo,
93
122
  locale: string,
94
123
  ): string {
95
- if (!dateString) return ""
96
- const date = new Date(dateString)
97
- if (isNaN(date.getTime())) return ""
124
+ const date = instantOf(display)
125
+ if (!date) return ""
98
126
 
127
+ const timezone = display?.campaign_zone
99
128
  const viewerZone = Intl.DateTimeFormat().resolvedOptions().timeZone
100
129
 
101
130
  if (!timezone || timezone === viewerZone) {