@mundogamernetwork/shared-ui 1.8.22 → 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 +41 -11
- package/components/keys/KeyCampaignCommentItem.vue +303 -0
- package/components/keys/KeyCampaignComments.vue +355 -0
- package/components/keys/KeyCampaignDashboardCard.vue +2 -2
- package/components/keys/KeyCampaignFeedback.vue +250 -0
- package/components/keys/KeyRevealModal.vue +2 -2
- 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/key-materials.vue +2 -2
- package/pages/key-campaigns/redeem-key-approved.vue +30 -24
- package/pages/key-campaigns/redeem-key.vue +58 -14
- package/pages/keys/index.vue +1 -1
- package/pages/presskit/game/[slug].vue +33 -3
- package/services/campaignService.ts +149 -2
- package/utils/embargo.ts +47 -18
- package/utils/navigation.ts +26 -0
|
@@ -343,6 +343,32 @@
|
|
|
343
343
|
</div>
|
|
344
344
|
</div>
|
|
345
345
|
|
|
346
|
+
<!-- Deadlines a creator has to hit live in their own calendar, not
|
|
347
|
+
in a tab they have to remember to open. Both formats come from
|
|
348
|
+
the same endpoint; .ics imports as events, .csv is for people
|
|
349
|
+
who plan in a spreadsheet. -->
|
|
350
|
+
<div class="calendar-export">
|
|
351
|
+
<span class="calendar-export__label">
|
|
352
|
+
{{ $t("keys.campaigns.calendar_export_label") }}
|
|
353
|
+
</span>
|
|
354
|
+
<button
|
|
355
|
+
class="btn tertiary"
|
|
356
|
+
:disabled="calendarBusy !== null"
|
|
357
|
+
@click="exportCalendar('ics')"
|
|
358
|
+
>
|
|
359
|
+
<MGIcon size="1x" icon="calendar" />
|
|
360
|
+
{{ calendarBusy === "ics" ? $t("keys.campaigns.calendar_export_busy") : $t("keys.campaigns.calendar_export_ics") }}
|
|
361
|
+
</button>
|
|
362
|
+
<button
|
|
363
|
+
class="btn tertiary"
|
|
364
|
+
:disabled="calendarBusy !== null"
|
|
365
|
+
@click="exportCalendar('csv')"
|
|
366
|
+
>
|
|
367
|
+
{{ calendarBusy === "csv" ? $t("keys.campaigns.calendar_export_busy") : $t("keys.campaigns.calendar_export_csv") }}
|
|
368
|
+
</button>
|
|
369
|
+
<p v-if="calendarError" class="calendar-export__error">{{ calendarError }}</p>
|
|
370
|
+
</div>
|
|
371
|
+
|
|
346
372
|
<div class="cards mt-5">
|
|
347
373
|
<template v-if="loadingRequests">
|
|
348
374
|
<div class="card skeleton" v-for="n in 3" :key="n"></div>
|
|
@@ -382,7 +408,7 @@
|
|
|
382
408
|
import { ref, computed, onMounted, onUnmounted, watch, inject, unref } from "vue"
|
|
383
409
|
import { useNuxtApp, navigateTo } from "#app"
|
|
384
410
|
import { useCampaignsStore } from "../../stores/campaigns"
|
|
385
|
-
import { fetchMyRequests, fetchRedeemedUsers } from "../../services/campaignService"
|
|
411
|
+
import { fetchMyRequests, fetchRedeemedUsers, downloadContentCalendar } from "../../services/campaignService"
|
|
386
412
|
import { useLoginStore } from "../../stores/login"
|
|
387
413
|
|
|
388
414
|
// Injected by each consuming front — provide these in app.vue / a plugin
|
|
@@ -685,6 +711,30 @@ function applyUserRequestStatuses() {
|
|
|
685
711
|
}))
|
|
686
712
|
}
|
|
687
713
|
|
|
714
|
+
|
|
715
|
+
const calendarBusy = ref<"ics" | "csv" | null>(null)
|
|
716
|
+
const calendarError = ref<string | null>(null)
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* The endpoint returns a file, so there is no response body to read an error
|
|
720
|
+
* message out of — a failure surfaces as a blob like any success. The status is
|
|
721
|
+
* all there is to go on, and 401 is the one a creator can act on themselves.
|
|
722
|
+
*/
|
|
723
|
+
async function exportCalendar(format: "ics" | "csv") {
|
|
724
|
+
if (calendarBusy.value) return
|
|
725
|
+
calendarBusy.value = format
|
|
726
|
+
calendarError.value = null
|
|
727
|
+
try {
|
|
728
|
+
await downloadContentCalendar(format)
|
|
729
|
+
} catch (e: any) {
|
|
730
|
+
calendarError.value = e?.response?.status === 401
|
|
731
|
+
? $i18n.t("keys.campaigns.error_session_expired")
|
|
732
|
+
: $i18n.t("keys.campaigns.calendar_export_error")
|
|
733
|
+
} finally {
|
|
734
|
+
calendarBusy.value = null
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
688
738
|
async function loadMyRequests() {
|
|
689
739
|
if (!hasCurrentUser()) return
|
|
690
740
|
loadingRequests.value = true
|
|
@@ -866,6 +916,27 @@ onUnmounted(() => {
|
|
|
866
916
|
</script>
|
|
867
917
|
|
|
868
918
|
<style lang="scss" scoped>
|
|
919
|
+
.calendar-export {
|
|
920
|
+
display: flex;
|
|
921
|
+
align-items: center;
|
|
922
|
+
flex-wrap: wrap;
|
|
923
|
+
gap: 8px;
|
|
924
|
+
margin-bottom: 16px;
|
|
925
|
+
|
|
926
|
+
&__label {
|
|
927
|
+
font-size: 13px;
|
|
928
|
+
opacity: 0.75;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
&__error {
|
|
932
|
+
// Takes the whole row so a long message never squeezes the buttons.
|
|
933
|
+
flex-basis: 100%;
|
|
934
|
+
margin: 0;
|
|
935
|
+
font-size: 13px;
|
|
936
|
+
color: var(--danger, #e53935);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
869
940
|
.spinner {
|
|
870
941
|
display: inline-block;
|
|
871
942
|
width: 20px;
|
|
@@ -306,9 +306,9 @@ function toggleBox(boxNumber: number) {
|
|
|
306
306
|
function goBack() {
|
|
307
307
|
if (props.goBackPage) {
|
|
308
308
|
window.location.href = props.goBackPage
|
|
309
|
-
|
|
310
|
-
window.history.back()
|
|
309
|
+
return
|
|
311
310
|
}
|
|
311
|
+
goBackWithin("/key-campaigns")
|
|
312
312
|
}
|
|
313
313
|
|
|
314
314
|
function handleSuccessClose() {
|
|
@@ -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 "-"
|
|
@@ -191,11 +200,7 @@ function goToLogin() {
|
|
|
191
200
|
}
|
|
192
201
|
|
|
193
202
|
function goBack() {
|
|
194
|
-
|
|
195
|
-
window.history.back()
|
|
196
|
-
} else {
|
|
197
|
-
navigateTo(`/${locale.value}/key-campaigns`)
|
|
198
|
-
}
|
|
203
|
+
goBackWithin(`/${locale.value}/key-campaigns`)
|
|
199
204
|
}
|
|
200
205
|
</script>
|
|
201
206
|
|
|
@@ -245,7 +250,7 @@ function goBack() {
|
|
|
245
250
|
{{ $t("keys.campaigns.embargo_countdown", { relative: embargoCountdown }) }}
|
|
246
251
|
</p>
|
|
247
252
|
</template>
|
|
248
|
-
<template v-else-if="
|
|
253
|
+
<template v-else-if="embargoDisplay">
|
|
249
254
|
<strong>{{ $t("keys.campaigns.embargo_lifted_title") }}</strong>
|
|
250
255
|
<p>{{ $t("keys.campaigns.no_embargo_desc") }}</p>
|
|
251
256
|
</template>
|
|
@@ -265,7 +270,7 @@ function goBack() {
|
|
|
265
270
|
<div class="embargo-ack" v-if="!keyCode && needsEmbargoAck">
|
|
266
271
|
<label class="embargo-ack-label">
|
|
267
272
|
<input type="checkbox" v-model="embargoAckChecked" />
|
|
268
|
-
{{ $t("keys.campaigns.embargo_ack_label", { date: formatEmbargo(
|
|
273
|
+
{{ $t("keys.campaigns.embargo_ack_label", { date: formatEmbargo(embargoAckDisplay) }) }}
|
|
269
274
|
</label>
|
|
270
275
|
<button
|
|
271
276
|
class="btn"
|
|
@@ -580,7 +585,7 @@ function goBack() {
|
|
|
580
585
|
|
|
581
586
|
.embargo-warning-text {
|
|
582
587
|
strong { display: block; color: var(--danger, #e53935); font-size: 14px; margin-bottom: 4px; }
|
|
583
|
-
p { margin: 0; font-size: 13px; color: var(--
|
|
588
|
+
p { margin: 0; font-size: 13px; color: var(--secondary-info-fg); }
|
|
584
589
|
|
|
585
590
|
.embargo-countdown {
|
|
586
591
|
margin-top: 4px;
|
|
@@ -628,11 +633,12 @@ function goBack() {
|
|
|
628
633
|
gap: 8px;
|
|
629
634
|
font-size: 13px;
|
|
630
635
|
cursor: pointer;
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
|
|
636
|
+
// The one thing the user has to read before agreeing to it. This
|
|
637
|
+
// used to reference --text-secondary, which is defined nowhere in
|
|
638
|
+
// the ecosystem, so it always fell back to #ccc — fine on dark,
|
|
639
|
+
// invisible on the light-mode pink. --secondary-info-fg is the real
|
|
640
|
+
// token and is themed in both _light.scss and _dark.scss.
|
|
641
|
+
color: var(--secondary-info-fg);
|
|
636
642
|
|
|
637
643
|
input {
|
|
638
644
|
margin-top: 2px;
|
|
@@ -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
|
+
})
|
|
145
|
+
|
|
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)
|
|
131
158
|
|
|
132
|
-
const canSubmit = computed(() => !submitting.value && !soldOut.value)
|
|
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
|
|
@@ -246,11 +283,7 @@ function goBack() {
|
|
|
246
283
|
navigateTo(campaignPath.value)
|
|
247
284
|
return
|
|
248
285
|
}
|
|
249
|
-
|
|
250
|
-
window.history.back()
|
|
251
|
-
} else {
|
|
252
|
-
navigateTo(`/${locale.value}/key-campaigns`)
|
|
253
|
-
}
|
|
286
|
+
goBackWithin(`/${locale.value}/key-campaigns`)
|
|
254
287
|
}
|
|
255
288
|
|
|
256
289
|
onUnmounted(() => {
|
|
@@ -288,7 +321,7 @@ onUnmounted(() => {
|
|
|
288
321
|
<p v-if="campaign?.name" class="campaign-name">{{ campaign.name }}</p>
|
|
289
322
|
</div>
|
|
290
323
|
|
|
291
|
-
<!--
|
|
324
|
+
<!-- Every key really has been claimed -->
|
|
292
325
|
<div v-if="!hasExistingRequest && soldOut" class="existing-request-notice">
|
|
293
326
|
<MGIcon size="1x" icon="version" class="notice-icon" />
|
|
294
327
|
<div class="notice-texts">
|
|
@@ -299,6 +332,17 @@ onUnmounted(() => {
|
|
|
299
332
|
</button>
|
|
300
333
|
</div>
|
|
301
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
|
+
|
|
302
346
|
<!-- Already has a request -->
|
|
303
347
|
<div v-else-if="hasExistingRequest" class="existing-request-notice">
|
|
304
348
|
<MGIcon size="1x" icon="version" class="notice-icon" />
|
|
@@ -535,7 +579,7 @@ onUnmounted(() => {
|
|
|
535
579
|
// Same omission as the reveal screen: no colour meant the text the
|
|
536
580
|
// user has to agree to inherited whatever the surrounding card left
|
|
537
581
|
// behind, and came out unreadable on dark.
|
|
538
|
-
color: var(--
|
|
582
|
+
color: var(--secondary-info-fg);
|
|
539
583
|
|
|
540
584
|
input {
|
|
541
585
|
flex-shrink: 0;
|
package/pages/keys/index.vue
CHANGED
|
@@ -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
|
+
}
|