@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.
- package/components/keys/KeyBrowser.vue +31 -1
- package/components/keys/KeyCampaignCommentItem.vue +303 -0
- package/components/keys/KeyCampaignComments.vue +355 -0
- package/components/keys/KeyCampaignFeedback.vue +250 -0
- package/locales/de.json +45 -3
- package/locales/en.json +45 -3
- package/locales/es.json +45 -3
- package/locales/pt-BR.json +45 -3
- package/locales/ro.json +45 -3
- package/package.json +1 -1
- package/pages/key-campaigns/[slug].vue +86 -34
- package/pages/key-campaigns/index.vue +72 -1
- package/pages/key-campaigns/redeem-key-approved.vue +22 -13
- package/pages/key-campaigns/redeem-key.vue +56 -8
- package/pages/presskit/game/[slug].vue +33 -3
- package/services/campaignService.ts +149 -2
- package/utils/embargo.ts +47 -18
|
@@ -8,7 +8,12 @@ import {
|
|
|
8
8
|
isSessionExpired,
|
|
9
9
|
buildReLoginUrl,
|
|
10
10
|
} from "../../services/campaignService"
|
|
11
|
-
import {
|
|
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
|
|
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
|
-
|
|
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,
|
|
154
|
-
*
|
|
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(
|
|
157
|
-
return formatEmbargoDateTime(
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
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(() =>
|
|
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="
|
|
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(
|
|
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?.
|
|
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
|
-
//
|
|
129
|
-
// platform/region
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<!--
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
412
|
-
|
|
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.
|
|
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
|
|
10
|
-
* (how the studio communicates the embargo everywhere else) and,
|
|
11
|
-
* viewer is somewhere else, the same instant on their own clock — so
|
|
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(
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
59
|
+
display: MaybeEmbargo,
|
|
31
60
|
locale: string,
|
|
32
61
|
): string {
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
92
|
-
timezone: string | null | undefined,
|
|
121
|
+
display: MaybeEmbargo,
|
|
93
122
|
locale: string,
|
|
94
123
|
): string {
|
|
95
|
-
|
|
96
|
-
|
|
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) {
|