@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,416 @@
1
+ import httpService from './httpService'
2
+
3
+ // Playtest tester-facing service — every endpoint under /playtest/* consumed
4
+ // by the tester-facing response-taking flows (baseline, concept polls,
5
+ // localization QA, accessibility audits, compliance checklists, moderated
6
+ // sessions). Mirrors keyService.ts's flat function-export style: thin
7
+ // wrappers over httpService (cookie-auth axios instance, no manual token
8
+ // plumbing — see services/httpService.ts).
9
+ //
10
+ // All routes verified directly against api-main's PlaytestProvider route
11
+ // registration and Request/Controller classes this session (not assumed from
12
+ // the plan doc) — see docs/playtest/00-feature-map.md in api-main.
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export type PlaytestEngine = 'quick' | 'deep'
19
+
20
+ export interface PlaytestFormSectionMeta {
21
+ label: string
22
+ engines: PlaytestEngine[]
23
+ scale: boolean
24
+ text: 'none' | 'optional' | 'required'
25
+ default: boolean
26
+ }
27
+
28
+ export interface PlaytestCatalog {
29
+ engines: PlaytestEngine[]
30
+ sections: Record<string, PlaytestFormSectionMeta>
31
+ }
32
+
33
+ export interface PlaytestCampaign {
34
+ id: number
35
+ user_id: number
36
+ title: string
37
+ game_ref: string | null
38
+ engine: PlaytestEngine
39
+ category: 'concept_poll' | 'localization_qa' | 'accessibility_audit' | null
40
+ status: string
41
+ enabled_sections: string[]
42
+ audience: Record<string, any> | null
43
+ accessibility_criteria: string[] | null
44
+ targeting_level: 'open' | 'matched_only' | 'basic' | string
45
+ responses_requested: number
46
+ responses_accepted: number
47
+ reward_type: 'mgc' | 'cash'
48
+ reward_amount: string | number
49
+ reward_currency: string | null
50
+ closes_at: string | null
51
+ eligible?: boolean
52
+ ineligibility_reason?: string | null
53
+ [key: string]: any
54
+ }
55
+
56
+ export interface PlaytestAnswerEntry {
57
+ rating?: number
58
+ text?: string
59
+ }
60
+
61
+ export interface SubmitBaselineResponsePayload {
62
+ answers: Record<string, PlaytestAnswerEntry>
63
+ time_on_task_seconds?: number
64
+ device?: string
65
+ os?: string
66
+ }
67
+
68
+ export interface PlaytestResponse {
69
+ id: number
70
+ playtest_campaign_id: number
71
+ user_id: number
72
+ status: 'submitted' | 'accepted' | 'rejected'
73
+ answers: Record<string, PlaytestAnswerEntry> | null
74
+ quality_score: number | null
75
+ time_on_task_seconds: number | null
76
+ device: string | null
77
+ os: string | null
78
+ reward_status: string | null
79
+ target_locale: string | null
80
+ campaign?: PlaytestCampaign
81
+ payout?: any
82
+ [key: string]: any
83
+ }
84
+
85
+ export type ConceptPollType =
86
+ | 'head_to_head'
87
+ | 'single_select'
88
+ | 'multi_select'
89
+ | 'ranked_choice'
90
+ | 'five_second_test'
91
+ | 'star_rating'
92
+ | 'emoji_reaction'
93
+
94
+ export interface PlaytestConceptPollOption {
95
+ id: number
96
+ playtest_concept_poll_id: number
97
+ label: string
98
+ value: string | null
99
+ media_url: string | null
100
+ position: number
101
+ votes: number
102
+ }
103
+
104
+ export interface PlaytestConceptPoll {
105
+ id: number
106
+ playtest_campaign_id: number
107
+ poll_type: ConceptPollType
108
+ asset_kind: string | null
109
+ question: string
110
+ description: string | null
111
+ status: string
112
+ closes_at: string | null
113
+ options: PlaytestConceptPollOption[]
114
+ campaign?: PlaytestCampaign
115
+ }
116
+
117
+ // `answer` shape strictly depends on poll_type — see VoteConceptPollRequest.php,
118
+ // verified this session. `reason` is UNCONDITIONALLY required regardless of
119
+ // poll_type (min 3 chars) — a PickFu-style convention, not optional anywhere.
120
+ export type ConceptPollAnswer =
121
+ | { option_id: number } // head_to_head | single_select
122
+ | { option_id: number; recall_text: string } // five_second_test
123
+ | { option_id: number; stars: number } // star_rating (1-5)
124
+ | { option_id: number; emoji: string } // emoji_reaction
125
+ | { option_ids: number[] } // multi_select
126
+ | { ranking: number[] } // ranked_choice
127
+
128
+ export interface VoteConceptPollPayload {
129
+ answer: ConceptPollAnswer
130
+ reason: string
131
+ }
132
+
133
+ export interface PlaytestCampaignLocale {
134
+ id: number
135
+ playtest_campaign_id: number
136
+ locale: string
137
+ build_reference: string | null
138
+ responses_target: number
139
+ responses_received: number
140
+ }
141
+
142
+ export type LocalizationIssueType =
143
+ | 'mistranslation'
144
+ | 'text_truncation'
145
+ | 'missing_string'
146
+ | 'cultural_inappropriateness'
147
+ | 'audio_subtitle_desync'
148
+ | 'untranslated_string'
149
+ | 'grammar_punctuation'
150
+ | 'inconsistent_terminology'
151
+
152
+ export type LocalizationIssueSeverity = 'blocker' | 'critical' | 'major' | 'minor' | 'cosmetic'
153
+
154
+ export interface StoreLocalizationIssuePayload {
155
+ campaign_locale_id: number
156
+ issue_type: LocalizationIssueType
157
+ severity: LocalizationIssueSeverity
158
+ location?: string | null
159
+ description: string
160
+ expected_text?: string | null
161
+ actual_text?: string | null
162
+ media_url?: string | null
163
+ }
164
+
165
+ export interface PlaytestLocalizationIssue extends StoreLocalizationIssuePayload {
166
+ id: number
167
+ playtest_campaign_id: number
168
+ playtest_response_id: number
169
+ user_id: number
170
+ status: string
171
+ created_at: string
172
+ }
173
+
174
+ export interface PlaytestAccessibilityCriterionMeta {
175
+ label: string
176
+ accommodation: string
177
+ }
178
+
179
+ export type AccessibilityCriteriaCatalog = Record<string, PlaytestAccessibilityCriterionMeta>
180
+
181
+ export type AccessibilityEvaluationResult = 'pass' | 'fail' | 'partial' | 'not_applicable'
182
+
183
+ export interface AccessibilityEvaluationEntry {
184
+ criterion: string
185
+ result: AccessibilityEvaluationResult
186
+ notes?: string | null
187
+ tester_uses_accommodation?: boolean
188
+ }
189
+
190
+ export interface SubmitAccessibilityEvaluationsPayload {
191
+ evaluations: AccessibilityEvaluationEntry[]
192
+ }
193
+
194
+ export interface PlaytestComplianceChecklistItem {
195
+ key: string
196
+ label: string
197
+ description?: string | null
198
+ severity?: string | null
199
+ evidence_required?: boolean
200
+ }
201
+
202
+ export interface PlaytestComplianceChecklist {
203
+ id: number
204
+ playtest_campaign_id: number
205
+ name: string
206
+ category: string
207
+ items: PlaytestComplianceChecklistItem[]
208
+ campaign?: PlaytestCampaign
209
+ }
210
+
211
+ export type ComplianceResultStatus = 'pass' | 'fail' | 'not_applicable'
212
+
213
+ export interface ComplianceResultEntry {
214
+ key: string
215
+ status: ComplianceResultStatus
216
+ notes?: string | null
217
+ evidence_url?: string | null
218
+ }
219
+
220
+ // This endpoint FULLY REPLACES the whole `results` array server-side on every
221
+ // call (SubmitComplianceResultRequest + PlaytestComplianceRepository::submitResult
222
+ // upsert the checklist's full result set) — callers must always send the
223
+ // complete accumulated array, never a diff, or previously-completed items are
224
+ // silently dropped. Enforced client-side by usePlaytestResponse's accumulator.
225
+ export interface SubmitComplianceResultPayload {
226
+ results: ComplianceResultEntry[]
227
+ }
228
+
229
+ export interface PlaytestModeratedSlot {
230
+ id: number
231
+ playtest_campaign_id: number
232
+ moderator_id: number
233
+ moderator_type: 'studio' | 'mgn_staff'
234
+ starts_at: string
235
+ duration_minutes: number
236
+ is_booked: boolean
237
+ notes: string | null
238
+ }
239
+
240
+ export type PlaytestModeratedSessionStatus = 'scheduled' | 'completed' | 'no_show' | 'cancelled'
241
+
242
+ export interface PlaytestModeratedSession {
243
+ id: number
244
+ playtest_campaign_id: number
245
+ playtest_moderated_slot_id: number
246
+ tester_id: number
247
+ moderator_id: number
248
+ moderator_type: 'studio' | 'mgn_staff'
249
+ meeting_id: number | null
250
+ scheduled_at: string
251
+ duration_minutes: number
252
+ status: PlaytestModeratedSessionStatus
253
+ playtest_response_id: number | null
254
+ moderator_summary: string | null
255
+ recording_url: string | null
256
+ completed_at: string | null
257
+ cancelled_at: string | null
258
+ cancellation_reason: string | null
259
+ tester_confirmed_at: string | null
260
+ campaign?: PlaytestCampaign
261
+ slot?: PlaytestModeratedSlot
262
+ // Eager-loaded by PlaytestModeratedSessionRepository::findWithRelations()
263
+ // (used by GET /playtest/moderated-sessions/{id}) — meeting_link is the
264
+ // actual join-call URL (Meeting model, app/Models/Meeting.php).
265
+ meeting?: { id: number; meeting_link: string | null; meeting_provider_id: string | null; platform: string | null } | null
266
+ [key: string]: any
267
+ }
268
+
269
+ export interface UploadPlaytestMediaResult {
270
+ data: { url: string }
271
+ }
272
+
273
+ export interface PaginatedResponse<T> {
274
+ data: T[]
275
+ meta?: { pagination?: Record<string, any> }
276
+ current_page?: number
277
+ last_page?: number
278
+ total?: number
279
+ [key: string]: any
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // Catalog / discovery
284
+ // ---------------------------------------------------------------------------
285
+
286
+ /** Public: engines + modular baseline section catalog. */
287
+ export const fetchPlaytestCatalog = () =>
288
+ httpService.get<PlaytestCatalog>('/playtest/catalog')
289
+
290
+ /** Authenticated: active campaigns open to the tester, with eligibility. */
291
+ export const fetchAvailablePlaytestCampaigns = () =>
292
+ httpService.get<PlaytestCampaign[]>('/playtest/available')
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Baseline (quick/deep, category=null)
296
+ // ---------------------------------------------------------------------------
297
+
298
+ /**
299
+ * Submit (or, on repeat call, no-op — firstOrCreate is idempotent server-side)
300
+ * a baseline response. Creates the underlying PlaytestResponse lazily.
301
+ */
302
+ export const submitPlaytestResponse = (campaignId: number, payload: SubmitBaselineResponsePayload) =>
303
+ httpService.post<PlaytestResponse>(`/playtest/campaigns/${campaignId}/responses`, payload)
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // Concept polls (category=concept_poll, engine=quick)
307
+ // ---------------------------------------------------------------------------
308
+
309
+ export const fetchConceptPoll = (pollId: number) =>
310
+ httpService.get<PlaytestConceptPoll>(`/playtest/concept-polls/${pollId}`)
311
+
312
+ /** reason is unconditionally required (min 3 chars) for every poll_type. */
313
+ export const voteConceptPoll = (pollId: number, payload: VoteConceptPollPayload) =>
314
+ httpService.post(`/playtest/concept-polls/${pollId}/vote`, payload)
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Localization QA (category=localization_qa)
318
+ // ---------------------------------------------------------------------------
319
+
320
+ export const fetchCampaignLocales = (campaignId: number) =>
321
+ httpService.get<PlaytestCampaignLocale[]>(`/playtest/campaigns/${campaignId}/locales`)
322
+
323
+ /**
324
+ * Files a localization defect. The first issue filed on a campaign lazily
325
+ * creates the tester's PlaytestResponse server-side — no separate "start"
326
+ * call exists or is needed.
327
+ */
328
+ export const storeLocalizationIssue = (campaignId: number, payload: StoreLocalizationIssuePayload) =>
329
+ httpService.post<PlaytestLocalizationIssue>(`/playtest/campaigns/${campaignId}/localization-issues`, payload)
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // Accessibility audits (category=accessibility_audit)
333
+ // ---------------------------------------------------------------------------
334
+
335
+ /** Public: fixed catalog of 7 accessibility criteria. */
336
+ export const fetchAccessibilityCriteriaCatalog = () =>
337
+ httpService.get<AccessibilityCriteriaCatalog>('/playtest/accessibility-criteria')
338
+
339
+ /**
340
+ * Batch-submits criterion evaluations. Server-side this upserts per-criterion
341
+ * (safe to auto-save each criterion as completed as the tester progresses,
342
+ * not just at the end) — unlike compliance results below, this is NOT a
343
+ * full-replace endpoint.
344
+ */
345
+ export const submitAccessibilityEvaluations = (campaignId: number, payload: SubmitAccessibilityEvaluationsPayload) =>
346
+ httpService.post(`/playtest/campaigns/${campaignId}/accessibility-evaluations`, payload)
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Compliance / technical QA checklists (capability layered on any campaign)
350
+ // ---------------------------------------------------------------------------
351
+
352
+ export const fetchComplianceChecklists = (campaignId: number) =>
353
+ httpService.get<PlaytestComplianceChecklist[]>(`/playtest/campaigns/${campaignId}/compliance-checklists`)
354
+
355
+ /**
356
+ * Submits results for one checklist. IMPORTANT: this fully REPLACES the whole
357
+ * `results` array server-side on every call — always send the complete
358
+ * accumulated array (every item answered so far), never a diff, or
359
+ * previously-completed items are silently dropped.
360
+ */
361
+ export const submitComplianceResult = (checklistId: number, payload: SubmitComplianceResultPayload) =>
362
+ httpService.post(`/playtest/compliance-checklists/${checklistId}/results`, payload)
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Moderated / live sessions
366
+ // ---------------------------------------------------------------------------
367
+
368
+ export const fetchAvailableModeratedSlots = (campaignId: number) =>
369
+ httpService.get<PlaytestModeratedSlot[]>(`/playtest/campaigns/${campaignId}/moderated-slots/available`)
370
+
371
+ export const claimModeratedSlot = (slotId: number) =>
372
+ httpService.post<PlaytestModeratedSession>(`/playtest/moderated-slots/${slotId}/claim`)
373
+
374
+ export const fetchMyModeratedSessions = () =>
375
+ httpService.get<PlaytestModeratedSession[]>('/playtest/my-moderated-sessions')
376
+
377
+ export const fetchModeratedSession = (sessionId: number) =>
378
+ httpService.get<PlaytestModeratedSession>(`/playtest/moderated-sessions/${sessionId}`)
379
+
380
+ /** Additive tester signal (sets tester_confirmed_at) — does not change `status`. */
381
+ export const confirmModeratedSession = (sessionId: number) =>
382
+ httpService.post<PlaytestModeratedSession>(`/playtest/moderated-sessions/${sessionId}/confirm`)
383
+
384
+ export const markModeratedSessionNoShow = (sessionId: number) =>
385
+ httpService.post<PlaytestModeratedSession>(`/playtest/moderated-sessions/${sessionId}/no-show`)
386
+
387
+ export const cancelModeratedSession = (sessionId: number, cancellationReason?: string) =>
388
+ httpService.post<PlaytestModeratedSession>(`/playtest/moderated-sessions/${sessionId}/cancel`, {
389
+ cancellation_reason: cancellationReason ?? null,
390
+ })
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // Media upload (LQA media_url / compliance evidence_url)
394
+ // ---------------------------------------------------------------------------
395
+
396
+ /**
397
+ * Generic tester media upload — jpg/jpeg/gif/png/webp/mp4/mov/webm up to
398
+ * 20MB. Returns {data:{url}}. Use the resulting url as LQA's media_url or
399
+ * compliance's evidence_url.
400
+ */
401
+ export const uploadPlaytestMedia = (file: File) => {
402
+ const form = new FormData()
403
+ form.append('file', file)
404
+ return httpService.post<UploadPlaytestMediaResult>('/playtest/media', form, {
405
+ headers: { 'Content-Type': 'multipart/form-data' },
406
+ })
407
+ }
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // My responses (tester-scoped; consuming app builds the actual list UI)
411
+ // ---------------------------------------------------------------------------
412
+
413
+ export const fetchMyPlaytestResponses = (status?: 'submitted' | 'accepted' | 'rejected', perPage = 20, page = 1) =>
414
+ httpService.get<PaginatedResponse<PlaytestResponse>>('/playtest/my-responses', {
415
+ params: { status, per_page: perPage, page },
416
+ })