@mundogamernetwork/shared-ui 1.1.95 → 1.2.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.
Files changed (29) hide show
  1. package/components/playtest/PlaytestBrief.vue +152 -0
  2. package/components/playtest/PlaytestPendingConfirmation.vue +84 -0
  3. package/components/playtest/PlaytestResponseFlow.vue +371 -0
  4. package/components/playtest/StarRatingInput.vue +105 -0
  5. package/components/playtest/accessibility/CriterionRow.vue +167 -0
  6. package/components/playtest/baseline/BaselineSectionStep.vue +106 -0
  7. package/components/playtest/compliance/ChecklistItemRow.vue +200 -0
  8. package/components/playtest/concept-poll/EmojiReactionVote.vue +145 -0
  9. package/components/playtest/concept-poll/FiveSecondTestVote.vue +197 -0
  10. package/components/playtest/concept-poll/HeadToHeadVote.vue +98 -0
  11. package/components/playtest/concept-poll/MultiSelectVote.vue +104 -0
  12. package/components/playtest/concept-poll/RankedChoiceVote.vue +169 -0
  13. package/components/playtest/concept-poll/SingleSelectVote.vue +98 -0
  14. package/components/playtest/concept-poll/StarRatingVote.vue +110 -0
  15. package/components/playtest/concept-poll/VoteReasonField.vue +74 -0
  16. package/components/playtest/lqa/FileIssueForm.vue +181 -0
  17. package/components/playtest/lqa/IssueList.vue +99 -0
  18. package/components/playtest/lqa/LocaleSelector.vue +73 -0
  19. package/components/playtest/moderated/JoinCallPanel.vue +135 -0
  20. package/components/playtest/moderated/SessionCard.vue +146 -0
  21. package/composables/usePlaytestResponse.ts +446 -0
  22. package/locales/de.json +1 -0
  23. package/locales/en.json +1 -0
  24. package/locales/pt-BR.json +1 -0
  25. package/locales/ro.json +1 -0
  26. package/package.json +10 -3
  27. package/pages/key-campaigns/redeem-key.vue +36 -5
  28. package/services/campaignService.ts +10 -0
  29. package/services/playtestTesterService.ts +416 -0
@@ -0,0 +1,446 @@
1
+ import { ref, reactive, computed } from "vue";
2
+ import {
3
+ fetchPlaytestCatalog,
4
+ fetchAvailablePlaytestCampaigns,
5
+ submitPlaytestResponse,
6
+ fetchConceptPoll,
7
+ voteConceptPoll,
8
+ fetchCampaignLocales,
9
+ storeLocalizationIssue,
10
+ fetchAccessibilityCriteriaCatalog,
11
+ submitAccessibilityEvaluations,
12
+ fetchComplianceChecklists,
13
+ submitComplianceResult,
14
+ fetchAvailableModeratedSlots,
15
+ claimModeratedSlot,
16
+ fetchMyModeratedSessions,
17
+ fetchModeratedSession,
18
+ confirmModeratedSession,
19
+ markModeratedSessionNoShow,
20
+ cancelModeratedSession,
21
+ uploadPlaytestMedia,
22
+ fetchMyPlaytestResponses,
23
+ type PlaytestCampaign,
24
+ type PlaytestCatalog,
25
+ type PlaytestAnswerEntry,
26
+ type SubmitBaselineResponsePayload,
27
+ type PlaytestConceptPoll,
28
+ type ConceptPollAnswer,
29
+ type PlaytestCampaignLocale,
30
+ type StoreLocalizationIssuePayload,
31
+ type PlaytestLocalizationIssue,
32
+ type AccessibilityCriteriaCatalog,
33
+ type AccessibilityEvaluationEntry,
34
+ type PlaytestComplianceChecklist,
35
+ type ComplianceResultEntry,
36
+ type PlaytestModeratedSlot,
37
+ type PlaytestModeratedSession,
38
+ } from "../services/playtestTesterService";
39
+
40
+ /**
41
+ * Accumulates client-side answer/vote/issue/evaluation/result state for a
42
+ * single campaign visit, and centralizes the "you no longer qualify" (422)
43
+ * handling every submit endpoint can independently throw — every submit path
44
+ * re-checks PlaytestService::assertEligible() server-side, even when a prior
45
+ * screen already confirmed eligibility (a campaign's audience rules, or the
46
+ * tester's own certifications, can change between page-load and submit).
47
+ *
48
+ * One instance per campaign visit — instantiate fresh per mount
49
+ * (PlaytestResponseFlow.vue owns the instance), not as a global singleton.
50
+ */
51
+ export function usePlaytestResponse(campaignId: number) {
52
+ // ── Shared/global state ────────────────────────────────────────────────
53
+ const loading = ref(false);
54
+ const submitting = ref(false);
55
+
56
+ // Structured ineligibility state — distinct from generic errors so the UI
57
+ // can render a dedicated "you no longer qualify" panel instead of a toast.
58
+ const ineligible = ref(false);
59
+ const ineligibilityReason = ref<string | null>(null);
60
+ const genericError = ref<string | null>(null);
61
+
62
+ function resetErrors() {
63
+ ineligible.value = false;
64
+ ineligibilityReason.value = null;
65
+ genericError.value = null;
66
+ }
67
+
68
+ /**
69
+ * Every submit endpoint returns a 422 (via each controller's guarded()
70
+ * wrapper around PlaytestService::assertEligible()) when the tester no
71
+ * longer qualifies — must be handled gracefully on every call site, not
72
+ * just assumed-passed because a previous screen displayed `eligible: true`.
73
+ */
74
+ function handleError(e: any): never {
75
+ const status = e?.response?.status;
76
+ const message: string = e?.response?.data?.message ?? "";
77
+
78
+ if (status === 422) {
79
+ ineligible.value = true;
80
+ ineligibilityReason.value = message || null;
81
+ } else {
82
+ genericError.value = message || e?.message || "Something went wrong. Please try again.";
83
+ }
84
+
85
+ throw e;
86
+ }
87
+
88
+ async function withGuard<T>(fn: () => Promise<T>): Promise<T | null> {
89
+ resetErrors();
90
+ submitting.value = true;
91
+ try {
92
+ return await fn();
93
+ } catch (e) {
94
+ try {
95
+ handleError(e);
96
+ } catch {
97
+ return null;
98
+ }
99
+ return null;
100
+ } finally {
101
+ submitting.value = false;
102
+ }
103
+ }
104
+
105
+ // ── Catalog (baseline sections) ────────────────────────────────────────
106
+ const catalog = ref<PlaytestCatalog | null>(null);
107
+
108
+ async function loadCatalog() {
109
+ loading.value = true;
110
+ try {
111
+ const res = await fetchPlaytestCatalog();
112
+ catalog.value = res.data;
113
+ } finally {
114
+ loading.value = false;
115
+ }
116
+ }
117
+
118
+ // ── Campaign (available() list entry, carries eligibility) ────────────
119
+ const campaign = ref<PlaytestCampaign | null>(null);
120
+
121
+ async function loadCampaignEligibility() {
122
+ loading.value = true;
123
+ try {
124
+ const res = await fetchAvailablePlaytestCampaigns();
125
+ const found = res.data.find((c) => c.id === campaignId) ?? null;
126
+ campaign.value = found;
127
+ if (found && found.eligible === false) {
128
+ ineligible.value = true;
129
+ ineligibilityReason.value = found.ineligibility_reason ?? null;
130
+ }
131
+ return found;
132
+ } finally {
133
+ loading.value = false;
134
+ }
135
+ }
136
+
137
+ // ── Baseline answers accumulator ───────────────────────────────────────
138
+ const baselineAnswers = reactive<Record<string, PlaytestAnswerEntry>>({});
139
+
140
+ function setBaselineAnswer(sectionKey: string, entry: PlaytestAnswerEntry) {
141
+ baselineAnswers[sectionKey] = { ...baselineAnswers[sectionKey], ...entry };
142
+ }
143
+
144
+ async function submitBaseline(meta: Omit<SubmitBaselineResponsePayload, "answers"> = {}) {
145
+ return withGuard(async () => {
146
+ const res = await submitPlaytestResponse(campaignId, {
147
+ answers: { ...baselineAnswers },
148
+ ...meta,
149
+ });
150
+ return res.data;
151
+ });
152
+ }
153
+
154
+ // ── Concept poll ────────────────────────────────────────────────────────
155
+ const poll = ref<PlaytestConceptPoll | null>(null);
156
+
157
+ async function loadConceptPoll(pollId: number) {
158
+ loading.value = true;
159
+ try {
160
+ const res = await fetchConceptPoll(pollId);
161
+ poll.value = res.data;
162
+ } finally {
163
+ loading.value = false;
164
+ }
165
+ }
166
+
167
+ async function submitConceptPollVote(pollId: number, answer: ConceptPollAnswer, reason: string) {
168
+ return withGuard(async () => {
169
+ const res = await voteConceptPoll(pollId, { answer, reason });
170
+ return res.data;
171
+ });
172
+ }
173
+
174
+ // ── Localization QA ────────────────────────────────────────────────────
175
+ const locales = ref<PlaytestCampaignLocale[]>([]);
176
+ const filedIssues = ref<PlaytestLocalizationIssue[]>([]);
177
+
178
+ async function loadCampaignLocales() {
179
+ loading.value = true;
180
+ try {
181
+ const res = await fetchCampaignLocales(campaignId);
182
+ locales.value = res.data;
183
+ } finally {
184
+ loading.value = false;
185
+ }
186
+ }
187
+
188
+ async function fileLocalizationIssue(payload: StoreLocalizationIssuePayload) {
189
+ return withGuard(async () => {
190
+ const res = await storeLocalizationIssue(campaignId, payload);
191
+ filedIssues.value.push(res.data);
192
+ return res.data;
193
+ });
194
+ }
195
+
196
+ // ── Accessibility ───────────────────────────────────────────────────────
197
+ const accessibilityCriteria = ref<AccessibilityCriteriaCatalog>({});
198
+ // criterion key -> completed evaluation entry, so the UI can auto-save
199
+ // each criterion independently (server upserts per-criterion — safe).
200
+ const accessibilityEvaluations = reactive<Record<string, AccessibilityEvaluationEntry>>({});
201
+
202
+ async function loadAccessibilityCriteria() {
203
+ loading.value = true;
204
+ try {
205
+ const res = await fetchAccessibilityCriteriaCatalog();
206
+ accessibilityCriteria.value = res.data;
207
+ } finally {
208
+ loading.value = false;
209
+ }
210
+ }
211
+
212
+ function setAccessibilityEvaluation(entry: AccessibilityEvaluationEntry) {
213
+ accessibilityEvaluations[entry.criterion] = entry;
214
+ }
215
+
216
+ /**
217
+ * Submits one (or more) criterion evaluations. Safe to call per-criterion
218
+ * as the tester completes each one (upsert semantics server-side) — pass
219
+ * just the newly-completed entry, or omit `entries` to flush everything
220
+ * accumulated so far.
221
+ */
222
+ async function submitAccessibilityBatch(entries?: AccessibilityEvaluationEntry[]) {
223
+ return withGuard(async () => {
224
+ const payload = entries ?? Object.values(accessibilityEvaluations);
225
+ const res = await submitAccessibilityEvaluations(campaignId, { evaluations: payload });
226
+ return res.data;
227
+ });
228
+ }
229
+
230
+ // ── Compliance checklists ──────────────────────────────────────────────
231
+ const complianceChecklists = ref<PlaytestComplianceChecklist[]>([]);
232
+ // checklistId -> (key -> result entry). Kept per-checklist because the
233
+ // submit endpoint fully REPLACES the whole `results` array per checklist —
234
+ // callers must always send the complete accumulated set for that
235
+ // checklist, never a diff.
236
+ const complianceResults = reactive<Record<number, Record<string, ComplianceResultEntry>>>({});
237
+
238
+ async function loadComplianceChecklists() {
239
+ loading.value = true;
240
+ try {
241
+ const res = await fetchComplianceChecklists(campaignId);
242
+ complianceChecklists.value = res.data;
243
+ res.data.forEach((c) => {
244
+ if (!complianceResults[c.id]) complianceResults[c.id] = {};
245
+ });
246
+ } finally {
247
+ loading.value = false;
248
+ }
249
+ }
250
+
251
+ function setComplianceResult(checklistId: number, entry: ComplianceResultEntry) {
252
+ if (!complianceResults[checklistId]) complianceResults[checklistId] = {};
253
+ complianceResults[checklistId][entry.key] = entry;
254
+ }
255
+
256
+ /**
257
+ * Always sends the FULL accumulated results array for this checklist —
258
+ * never a diff — since the backend replaces the whole array on every call.
259
+ */
260
+ async function submitComplianceChecklist(checklistId: number) {
261
+ return withGuard(async () => {
262
+ const results = Object.values(complianceResults[checklistId] ?? {});
263
+ const res = await submitComplianceResult(checklistId, { results });
264
+ return res.data;
265
+ });
266
+ }
267
+
268
+ // ── Moderated sessions ──────────────────────────────────────────────────
269
+ const moderatedSlots = ref<PlaytestModeratedSlot[]>([]);
270
+ const mySessions = ref<PlaytestModeratedSession[]>([]);
271
+ const currentSession = ref<PlaytestModeratedSession | null>(null);
272
+
273
+ async function loadAvailableModeratedSlots() {
274
+ loading.value = true;
275
+ try {
276
+ const res = await fetchAvailableModeratedSlots(campaignId);
277
+ // availableSlots() has no server-side eligibility pre-filtering
278
+ // (documented gap) — the client is expected to work around it by
279
+ // still attempting the claim, which re-checks eligibility server-
280
+ // side and surfaces a 422 gracefully via withGuard() if it fails.
281
+ moderatedSlots.value = res.data;
282
+ } finally {
283
+ loading.value = false;
284
+ }
285
+ }
286
+
287
+ async function claimSlot(slotId: number) {
288
+ return withGuard(async () => {
289
+ const res = await claimModeratedSlot(slotId);
290
+ currentSession.value = res.data;
291
+ return res.data;
292
+ });
293
+ }
294
+
295
+ async function loadMySessions() {
296
+ loading.value = true;
297
+ try {
298
+ const res = await fetchMyModeratedSessions();
299
+ mySessions.value = res.data;
300
+ } finally {
301
+ loading.value = false;
302
+ }
303
+ }
304
+
305
+ async function loadSession(sessionId: number) {
306
+ loading.value = true;
307
+ try {
308
+ const res = await fetchModeratedSession(sessionId);
309
+ currentSession.value = res.data;
310
+ return res.data;
311
+ } finally {
312
+ loading.value = false;
313
+ }
314
+ }
315
+
316
+ async function confirmSession(sessionId: number) {
317
+ return withGuard(async () => {
318
+ const res = await confirmModeratedSession(sessionId);
319
+ currentSession.value = res.data;
320
+ return res.data;
321
+ });
322
+ }
323
+
324
+ async function noShowSession(sessionId: number) {
325
+ return withGuard(async () => {
326
+ const res = await markModeratedSessionNoShow(sessionId);
327
+ currentSession.value = res.data;
328
+ return res.data;
329
+ });
330
+ }
331
+
332
+ async function cancelSession(sessionId: number, reason?: string) {
333
+ return withGuard(async () => {
334
+ const res = await cancelModeratedSession(sessionId, reason);
335
+ currentSession.value = res.data;
336
+ return res.data;
337
+ });
338
+ }
339
+
340
+ // ── Media upload ────────────────────────────────────────────────────────
341
+ const uploading = ref(false);
342
+
343
+ async function uploadMedia(file: File): Promise<string | null> {
344
+ uploading.value = true;
345
+ try {
346
+ const res = await uploadPlaytestMedia(file);
347
+ return res.data?.data?.url ?? null;
348
+ } catch (e) {
349
+ try {
350
+ handleError(e);
351
+ } catch {
352
+ return null;
353
+ }
354
+ return null;
355
+ } finally {
356
+ uploading.value = false;
357
+ }
358
+ }
359
+
360
+ // ── My responses (fetch exposed for consuming-app dashboards) ─────────
361
+ const myResponses = ref<any[]>([]);
362
+
363
+ async function loadMyResponses(status?: "submitted" | "accepted" | "rejected") {
364
+ loading.value = true;
365
+ try {
366
+ const res = await fetchMyPlaytestResponses(status);
367
+ myResponses.value = res.data?.data ?? [];
368
+ return res.data;
369
+ } finally {
370
+ loading.value = false;
371
+ }
372
+ }
373
+
374
+ const hasError = computed(() => ineligible.value || !!genericError.value);
375
+
376
+ return {
377
+ // state
378
+ loading,
379
+ submitting,
380
+ uploading,
381
+ ineligible,
382
+ ineligibilityReason,
383
+ genericError,
384
+ hasError,
385
+
386
+ // catalog / discovery
387
+ catalog,
388
+ campaign,
389
+ loadCatalog,
390
+ loadCampaignEligibility,
391
+
392
+ // baseline
393
+ baselineAnswers,
394
+ setBaselineAnswer,
395
+ submitBaseline,
396
+
397
+ // concept poll
398
+ poll,
399
+ loadConceptPoll,
400
+ submitConceptPollVote,
401
+
402
+ // localization QA
403
+ locales,
404
+ filedIssues,
405
+ loadCampaignLocales,
406
+ fileLocalizationIssue,
407
+
408
+ // accessibility
409
+ accessibilityCriteria,
410
+ accessibilityEvaluations,
411
+ loadAccessibilityCriteria,
412
+ setAccessibilityEvaluation,
413
+ submitAccessibilityBatch,
414
+
415
+ // compliance
416
+ complianceChecklists,
417
+ complianceResults,
418
+ loadComplianceChecklists,
419
+ setComplianceResult,
420
+ submitComplianceChecklist,
421
+
422
+ // moderated sessions
423
+ moderatedSlots,
424
+ mySessions,
425
+ currentSession,
426
+ loadAvailableModeratedSlots,
427
+ claimSlot,
428
+ loadMySessions,
429
+ loadSession,
430
+ confirmSession,
431
+ noShowSession,
432
+ cancelSession,
433
+
434
+ // media
435
+ uploadMedia,
436
+
437
+ // my responses
438
+ myResponses,
439
+ loadMyResponses,
440
+
441
+ // helpers
442
+ resetErrors,
443
+ };
444
+ }
445
+
446
+ export type PlaytestResponseComposable = ReturnType<typeof usePlaytestResponse>;
package/locales/de.json CHANGED
@@ -181,6 +181,7 @@
181
181
  "select_option": "Option auswählen",
182
182
  "required_data": "Erforderliche Angaben",
183
183
  "already_requested": "Sie haben bereits eine Anfrage für diese Kampagne gestellt.",
184
+ "sold_out": "Alle Keys für diese Kampagne wurden bereits eingelöst.",
184
185
  "back_to_campaign": "Zur Kampagne zurückkehren",
185
186
  "status_pending": "Anfrage ausstehend — bitte warten Sie auf die Genehmigung",
186
187
  "status_approved": "Anfrage genehmigt — Sie können jetzt Ihren Schlüssel abrufen",
package/locales/en.json CHANGED
@@ -181,6 +181,7 @@
181
181
  "select_option": "Select an option",
182
182
  "required_data": "Required data",
183
183
  "already_requested": "You have already submitted a request for this campaign.",
184
+ "sold_out": "All keys for this campaign have already been claimed.",
184
185
  "back_to_campaign": "Back to campaign",
185
186
  "status_pending": "Request pending — waiting for approval",
186
187
  "status_approved": "Request approved — you can now retrieve your key",
@@ -181,6 +181,7 @@
181
181
  "select_option": "Selecione uma opção",
182
182
  "required_data": "Dados obrigatórios",
183
183
  "already_requested": "Você já enviou uma solicitação para esta campanha.",
184
+ "sold_out": "Todas as chaves desta campanha já foram resgatadas.",
184
185
  "back_to_campaign": "Voltar para a campanha",
185
186
  "status_pending": "Solicitação pendente — aguardando aprovação",
186
187
  "status_approved": "Solicitação aprovada — você pode resgatar sua chave",
package/locales/ro.json CHANGED
@@ -181,6 +181,7 @@
181
181
  "select_option": "Selectează o opțiune",
182
182
  "required_data": "Date obligatorii",
183
183
  "already_requested": "Ați trimis deja o cerere pentru această campanie.",
184
+ "sold_out": "Toate cheile pentru această campanie au fost deja revendicate.",
184
185
  "back_to_campaign": "Înapoi la campanie",
185
186
  "status_pending": "Cerere în așteptare — așteptați aprobarea",
186
187
  "status_approved": "Cerere aprobată — puteți prelua cheia",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.1.95",
3
+ "version": "1.2.0",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -28,8 +28,8 @@
28
28
  "build": "nuxi build .playground"
29
29
  },
30
30
  "peerDependencies": {
31
- "@pinia/nuxt": ">=0.5.0",
32
31
  "@pinia-plugin-persistedstate/nuxt": ">=1.0.0",
32
+ "@pinia/nuxt": ">=0.5.0",
33
33
  "axios": ">=1.0.0",
34
34
  "laravel-echo": ">=1.15.0",
35
35
  "nuxt": ">=3.13.0",
@@ -38,7 +38,14 @@
38
38
  "vue": ">=3.5.0"
39
39
  },
40
40
  "devDependencies": {
41
+ "@pinia-plugin-persistedstate/nuxt": "^1.2.1",
42
+ "@pinia/nuxt": "^0.5.5",
43
+ "axios": "^1.18.1",
44
+ "laravel-echo": "^2.3.7",
41
45
  "nuxt": "^3.15.4",
42
- "typescript": "^5.0.0"
46
+ "pinia": "^2.3.1",
47
+ "pusher-js": "^8.5.0",
48
+ "typescript": "^5.0.0",
49
+ "vue-tsc": "^2.2.0"
43
50
  }
44
51
  }
@@ -6,6 +6,7 @@ import {
6
6
  fetchCampaignBySlug,
7
7
  fetchKeyPlatforms,
8
8
  fetchKeyRegions,
9
+ fetchAvailableOptions,
9
10
  requestKey,
10
11
  } from "../../services/campaignService"
11
12
 
@@ -52,14 +53,29 @@ onMounted(async () => {
52
53
  return
53
54
  }
54
55
  try {
55
- const [campaignRes, platformsRes, regionsRes] = await Promise.all([
56
+ const [campaignRes, platformsRes, regionsRes, availableRes] = await Promise.all([
56
57
  fetchCampaignBySlug(String(keyId)),
57
58
  fetchKeyPlatforms(keyId),
58
59
  fetchKeyRegions(keyId),
60
+ fetchAvailableOptions(keyId).catch(() => null),
59
61
  ])
60
62
  campaign.value = campaignRes?.data?.data || campaignRes?.data || null
61
- platforms.value = platformsRes?.data?.data || platformsRes?.data || []
62
- regions.value = regionsRes?.data?.data || regionsRes?.data || []
63
+ const allPlatforms = platformsRes?.data?.data || platformsRes?.data || []
64
+ const allRegions = regionsRes?.data?.data || regionsRes?.data || []
65
+
66
+ // Only offer platforms/regions that still have an unredeemed key item —
67
+ // fetchKeyPlatforms/fetchKeyRegions return every one the campaign has
68
+ // EVER had items for, including combos that are fully sold out.
69
+ const availableData = availableRes?.data ?? null
70
+ const availablePlatformIds: number[] | null = availableData?.available_platform_ids ?? null
71
+ const availableRegionIds: number[] | null = availableData?.available_region_ids ?? null
72
+
73
+ platforms.value = availablePlatformIds
74
+ ? allPlatforms.filter((p: any) => availablePlatformIds.includes(p.id))
75
+ : allPlatforms
76
+ regions.value = availableRegionIds
77
+ ? allRegions.filter((r: any) => availableRegionIds.includes(r.id))
78
+ : allRegions
63
79
 
64
80
  // Pre-select if only one option
65
81
  if (platforms.value.length === 1) selectedPlatformId.value = platforms.value[0].id
@@ -71,7 +87,11 @@ onMounted(async () => {
71
87
  }
72
88
  })
73
89
 
74
- const canSubmit = computed(() => !submitting.value)
90
+ // True once options finished loading and either list came back empty — every
91
+ // platform/region combo this campaign ever had is now fully redeemed.
92
+ const soldOut = computed(() => !loading.value && (platforms.value.length === 0 || regions.value.length === 0))
93
+
94
+ const canSubmit = computed(() => !submitting.value && !soldOut.value)
75
95
 
76
96
  // Collapsible "Required data" box — matches tv's original toggle behavior
77
97
  const isBoxOpen = ref(true)
@@ -186,8 +206,19 @@ function goBack() {
186
206
  <p v-if="campaign?.name" class="campaign-name">{{ campaign.name }}</p>
187
207
  </div>
188
208
 
209
+ <!-- All platform/region combos are fully redeemed -->
210
+ <div v-if="!hasExistingRequest && soldOut" class="existing-request-notice">
211
+ <MGIcon size="1x" icon="version" class="notice-icon" />
212
+ <div class="notice-texts">
213
+ <div class="notice-title">{{ $t("keys.campaigns.sold_out") }}</div>
214
+ </div>
215
+ <button class="btn notice-back" @click="goBack">
216
+ {{ $t("keys.campaigns.back_to_campaign") }}
217
+ </button>
218
+ </div>
219
+
189
220
  <!-- Already has a request -->
190
- <div v-if="hasExistingRequest" class="existing-request-notice">
221
+ <div v-else-if="hasExistingRequest" class="existing-request-notice">
191
222
  <MGIcon size="1x" icon="version" class="notice-icon" />
192
223
  <div class="notice-texts">
193
224
  <div class="notice-title">{{ $t("keys.campaigns.already_requested") }}</div>
@@ -308,6 +308,16 @@ export const fetchKeyRegions = (keyId: number, params: Record<string, any> = {})
308
308
  return mainClient.get(`/public/keys/${keyId}/regions`, { params })
309
309
  }
310
310
 
311
+ /**
312
+ * Which platform/region combos still have unredeemed key items. fetchKeyPlatforms/
313
+ * fetchKeyRegions return every platform/region the campaign has EVER had items
314
+ * for (including fully redeemed ones) — this is the live-stock check needed to
315
+ * only offer options that can actually still be requested.
316
+ */
317
+ export const fetchAvailableOptions = (keyId: number) => {
318
+ return mainClient.get(`/public/keys/${keyId}/available-options`)
319
+ }
320
+
311
321
  // ---------------------------------------------------------------------------
312
322
  // Auth-required endpoints (authClient — withCredentials)
313
323
  // ---------------------------------------------------------------------------