@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.
@@ -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
- } else {
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 { 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 "-"
@@ -191,11 +200,7 @@ function goToLogin() {
191
200
  }
192
201
 
193
202
  function goBack() {
194
- if (typeof window !== "undefined" && window.history.length > 1) {
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="embargoDate">
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(embargoAckDate) }) }}
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(--text-secondary, #ccc); }
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
- // Inherited nothing usable and rendered near-black on the dark
632
- // background the one thing the user has to read before agreeing
633
- // to it. Same token the warning above uses, which is legible in
634
- // both themes.
635
- color: var(--text-secondary, #ccc);
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?.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
+ })
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
- 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
@@ -246,11 +283,7 @@ function goBack() {
246
283
  navigateTo(campaignPath.value)
247
284
  return
248
285
  }
249
- if (typeof window !== "undefined" && window.history.length > 1) {
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
- <!-- All platform/region combos are fully redeemed -->
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(--text-secondary, #ccc);
582
+ color: var(--secondary-info-fg);
539
583
 
540
584
  input {
541
585
  flex-shrink: 0;
@@ -121,7 +121,7 @@ onMounted(() => fetchCampaigns())
121
121
 
122
122
  &__loading, &__empty {
123
123
  text-align: center;
124
- color: var(--text-secondary, #aaa);
124
+ color: var(--secondary-info-fg);
125
125
  padding: 3rem 0;
126
126
  font-size: 0.9rem;
127
127
  }
@@ -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
+ }