@mundogamernetwork/shared-ui 1.1.96 → 1.2.1

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.
@@ -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/nuxt.config.ts CHANGED
@@ -1,7 +1,45 @@
1
+ import { addComponentsDir, createResolver, defineNuxtModule } from "@nuxt/kit";
2
+
3
+ // Nuxt's built-in `components` module resolves cross-layer name collisions by
4
+ // picking the highest `priority` directory (see `scanComponents` in
5
+ // nuxt/dist/index.mjs: `if (newPriority > existingPriority) { ...replace... }`).
6
+ // Before Nuxt 3.20.2, layer priority was binary (root app = 1, every extended
7
+ // layer = 0), so shared-ui's components happened to win ties via array order.
8
+ // Nuxt 3.20.2+ (nuxt/nuxt#33654) computes `priority = layerCount - i` from each
9
+ // layer's position in `_layers`, which is NOT reversible via config and no
10
+ // longer guarantees shared-ui wins — depending on how many layers a consuming
11
+ // app extends, shared-ui's implicit priority can end up lower than the
12
+ // consuming app's own `~/components` priority, causing its components
13
+ // (MgBanners, MgLoginModal, MgAppInstallBanner, Playtest components, etc.) to
14
+ // silently fail to resolve.
15
+ //
16
+ // Fix: register shared-ui's own component directory explicitly via
17
+ // `addComponentsDir`, with an explicit `priority` far above anything
18
+ // `layerCount - i` could ever produce (realistic layer counts are single
19
+ // digits), so shared-ui always wins regardless of the consuming app's layer
20
+ // ordering/count.
21
+ const SHARED_UI_COMPONENT_PRIORITY = 1000;
22
+
23
+ const registerSharedUiComponents = defineNuxtModule({
24
+ meta: {
25
+ name: "shared-ui-components",
26
+ },
27
+ setup() {
28
+ // Resolve relative to this file (shared-ui's package root), not the
29
+ // consuming app's `srcDir`/`~` alias — this is the standard Nuxt Kit
30
+ // pattern (`createResolver(import.meta.url)`) and keeps registration
31
+ // correct no matter which app extends this layer.
32
+ const resolver = createResolver(import.meta.url);
33
+ addComponentsDir({
34
+ path: resolver.resolve("./components"),
35
+ pathPrefix: false,
36
+ priority: SHARED_UI_COMPONENT_PRIORITY,
37
+ });
38
+ },
39
+ });
40
+
1
41
  export default defineNuxtConfig({
2
- components: [
3
- { path: "~/components", pathPrefix: false },
4
- ],
42
+ modules: [registerSharedUiComponents],
5
43
  css: [
6
44
  "@mundogamernetwork/shared-ui/assets/kit/kit-base.css",
7
45
  "@mundogamernetwork/shared-ui/assets/kit/kit-theme.css",
@@ -13,7 +51,7 @@ export default defineNuxtConfig({
13
51
  systemId: "", // VITE_SYSTEM_ID for notification filtering
14
52
  apiBaseURL: "", // VITE_API_BASE_URL
15
53
  pressKitApiUrl: "", // VITE_PRESS_KIT_API_URL — override per-service; falls back to apiBaseURL
16
- agencyBaseUrl: "", // VITE_BASE_URL_AGENCY — used for "create your press kit" CTA link
54
+ agencyBaseUrl: "", // VITE_BASE_URL_AGENCY — used for "create your press kit" CTA link
17
55
  accountsBaseUrl: "", // VITE_BASE_ACCOUNTS_URL
18
56
  networkBaseUrl: "", // VITE_BASE_URL_NETWORK (institutional APIs)
19
57
  features: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.1.96",
3
+ "version": "1.2.1",
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
  }