@mundogamernetwork/shared-ui 1.16.25 → 1.16.27

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.
@@ -7,17 +7,36 @@
7
7
  * instead of dead ends.
8
8
  */
9
9
 
10
+ // community-api's /feed/trending wraps the actual pulse/review/video fields
11
+ // in a nested `content` object — content_type/content_id/views are the only
12
+ // fields it puts at the top level (confirmed against the live response).
13
+ // A previous version of this component read title/slug/image/author off the
14
+ // top level directly, which is always undefined: every card silently fell
15
+ // back to the placeholder image and linked to ".../undefined".
10
16
  interface FeedItem {
11
- id: number;
12
- slug: string;
13
- title: string;
14
- url_image_src?: string;
15
- url_image?: string;
16
17
  content_type?: "pulse" | "review" | "video" | string;
17
- author?: { nickname?: string; name?: string };
18
- created_at_diff?: string;
18
+ content: {
19
+ id: number;
20
+ slug: string;
21
+ title: string;
22
+ url_image_src?: string;
23
+ url_image?: string;
24
+ author?: { nickname?: string; name?: string };
25
+ created_at_diff?: string;
26
+ };
19
27
  }
20
28
 
29
+ const CONTENT_TYPE_LABELS: Record<string, string> = {
30
+ pulse: "News",
31
+ review: "Review",
32
+ video: "Video",
33
+ };
34
+
35
+ const contentTypeLabel = (item: FeedItem): string => {
36
+ const type = item.content_type ?? "pulse";
37
+ return CONTENT_TYPE_LABELS[type] ?? type;
38
+ };
39
+
21
40
  const props = defineProps({
22
41
  limit: { type: Number, default: 4 },
23
42
  });
@@ -58,11 +77,11 @@ const articlePath = (item: FeedItem): string => {
58
77
  video: "videos",
59
78
  };
60
79
  const type = typeMap[item.content_type ?? "pulse"] ?? "articles";
61
- return `${communitySiteUrl.value}/${locale}/${type}/${item.slug}`;
80
+ return `${communitySiteUrl.value}/${locale}/${type}/${item.content.slug}`;
62
81
  };
63
82
 
64
83
  const imageUrl = (item: FeedItem): string =>
65
- item.url_image_src || item.url_image || "/imgs/default/no_img_large_dark.png";
84
+ item.content.url_image_src || item.content.url_image || "/imgs/default/no_img_large_dark.png";
66
85
 
67
86
  onMounted(async () => {
68
87
  try {
@@ -108,7 +127,7 @@ onMounted(async () => {
108
127
  <div v-else class="error-news-cta__grid">
109
128
  <a
110
129
  v-for="item in articles"
111
- :key="item.id"
130
+ :key="item.content.id"
112
131
  :href="articlePath(item)"
113
132
  target="_blank"
114
133
  class="error-news-cta__card"
@@ -119,11 +138,11 @@ onMounted(async () => {
119
138
  />
120
139
  <div class="error-news-cta__card-body">
121
140
  <span v-if="item.content_type" class="error-news-cta__card-tag">
122
- {{ item.content_type }}
141
+ {{ contentTypeLabel(item) }}
123
142
  </span>
124
- <p class="error-news-cta__card-title">{{ item.title }}</p>
125
- <span v-if="item.created_at_diff" class="error-news-cta__card-date">
126
- {{ item.created_at_diff }}
143
+ <p class="error-news-cta__card-title">{{ item.content.title }}</p>
144
+ <span v-if="item.content.created_at_diff" class="error-news-cta__card-date">
145
+ {{ item.content.created_at_diff }}
127
146
  </span>
128
147
  </div>
129
148
  </a>
@@ -21,6 +21,7 @@
21
21
  // loadAccessibilityCriteria) are cheap and idempotent to re-run client-side.
22
22
  import { ref, computed, onMounted, watch } from "vue";
23
23
  import { usePlaytestResponse } from "../../composables/usePlaytestResponse";
24
+ import type { PlaytestCampaign } from "../../services/playtestTesterService";
24
25
 
25
26
  import PlaytestBrief from "./PlaytestBrief.vue";
26
27
  import PlaytestPendingConfirmation from "./PlaytestPendingConfirmation.vue";
@@ -54,6 +55,19 @@ const props = defineProps<{
54
55
  // moderated: explicit session id when resuming/joining a specific
55
56
  // already-booked session (skips the slot-browse step)
56
57
  sessionId?: number;
58
+ // Studio "preview as tester" mode (agency-frontend's campaign hub Preview
59
+ // tab): a campaign owner can never appear in available() (it excludes
60
+ // their own campaigns) and would never pass real eligibility rules
61
+ // (certifications they don't hold) anyway. eligibilityFetcher supplies a
62
+ // synthetic eligible:true snapshot from the owner-only preview endpoint
63
+ // instead of the tester-only available() list. previewMode disables every
64
+ // interactive control so the owner sees exactly what a tester would see
65
+ // without being able to record a response — though every submit endpoint
66
+ // already independently rejects the campaign owner server-side
67
+ // regardless (see PlaytestService::submitResponse()), so this is UX
68
+ // polish on top of a real security boundary, not the boundary itself.
69
+ eligibilityFetcher?: () => Promise<PlaytestCampaign | null>;
70
+ previewMode?: boolean;
57
71
  }>();
58
72
 
59
73
  const emit = defineEmits<{
@@ -61,7 +75,7 @@ const emit = defineEmits<{
61
75
  (e: "browse-more"): void
62
76
  }>();
63
77
 
64
- const flow = usePlaytestResponse(props.campaignId);
78
+ const flow = usePlaytestResponse(props.campaignId, { eligibilityFetcher: props.eligibilityFetcher });
65
79
 
66
80
  const done = ref(false);
67
81
 
@@ -103,6 +117,7 @@ function browseMore() {
103
117
  const enabledSectionKeys = computed(() => flow.campaign.value?.enabled_sections ?? []);
104
118
 
105
119
  async function submitBaselineFlow() {
120
+ if (props.previewMode) return;
106
121
  const result = await flow.submitBaseline();
107
122
  if (result) handleDone();
108
123
  }
@@ -123,7 +138,7 @@ const conceptPollComponent = computed(() => {
123
138
  });
124
139
 
125
140
  async function submitConceptPollFlow(payload: { answer: any; reason: string }) {
126
- if (!props.pollId) return;
141
+ if (props.previewMode || !props.pollId) return;
127
142
  const result = await flow.submitConceptPollVote(props.pollId, payload.answer, payload.reason);
128
143
  if (result) handleDone();
129
144
  }
@@ -133,6 +148,7 @@ async function submitConceptPollFlow(payload: { answer: any; reason: string }) {
133
148
  const selectedLocaleId = ref<number | null>(null);
134
149
 
135
150
  async function submitIssueFlow(payload: Parameters<typeof flow.fileLocalizationIssue>[0]) {
151
+ if (props.previewMode) return;
136
152
  await flow.fileLocalizationIssue(payload);
137
153
  // LQA is a repeatable session (file multiple issues) — no auto "done"
138
154
  // transition; the consuming app decides when the tester is finished
@@ -157,6 +173,9 @@ const allCriteriaComplete = computed(
157
173
  );
158
174
 
159
175
  async function saveAccessibilityEvaluation(entry: Parameters<typeof flow.setAccessibilityEvaluation>[0]) {
176
+ // CriterionRow auto-saves per row with no busy/disabled prop to gate —
177
+ // this guard is the only stop for it in preview mode.
178
+ if (props.previewMode) return;
160
179
  flow.setAccessibilityEvaluation(entry);
161
180
  await flow.submitAccessibilityBatch([entry]);
162
181
  }
@@ -187,7 +206,7 @@ const complianceComplete = computed(() => {
187
206
  });
188
207
 
189
208
  async function submitComplianceFlow() {
190
- if (!activeChecklist.value) return;
209
+ if (props.previewMode || !activeChecklist.value) return;
191
210
  const result = await flow.submitComplianceChecklist(activeChecklist.value.id);
192
211
  if (result) handleDone();
193
212
  }
@@ -195,15 +214,18 @@ async function submitComplianceFlow() {
195
214
  // ── Moderated ─────────────────────────────────────────────────────────────
196
215
 
197
216
  async function claimSlotFlow(slotId: number) {
217
+ if (props.previewMode) return;
198
218
  const result = await flow.claimSlot(slotId);
199
219
  if (result) handleDone();
200
220
  }
201
221
 
202
222
  async function confirmSessionFlow() {
223
+ if (props.previewMode) return;
203
224
  if (flow.currentSession.value) await flow.confirmSession(flow.currentSession.value.id);
204
225
  }
205
226
 
206
227
  async function cancelSessionFlow() {
228
+ if (props.previewMode) return;
207
229
  if (flow.currentSession.value) await flow.cancelSession(flow.currentSession.value.id);
208
230
  }
209
231
  </script>
@@ -218,6 +240,10 @@ async function cancelSessionFlow() {
218
240
  />
219
241
 
220
242
  <template v-else>
243
+ <p v-if="previewMode" class="playtest-response-flow__preview-banner">
244
+ {{ $t("playtest.flow.preview_banner", "Preview only — this is exactly what a tester sees. Nothing here can be submitted.") }}
245
+ </p>
246
+
221
247
  <PlaytestBrief :campaign="flow.campaign.value" :loading="flow.loading.value">
222
248
  <p v-if="flow.genericError.value" class="playtest-response-flow__error">
223
249
  {{ flow.genericError.value }}
@@ -236,7 +262,7 @@ async function cancelSessionFlow() {
236
262
  <button
237
263
  type="button"
238
264
  class="btn primary playtest-response-flow__submit"
239
- :disabled="flow.submitting.value"
265
+ :disabled="previewMode || flow.submitting.value"
240
266
  @click="submitBaselineFlow"
241
267
  >
242
268
  {{ $t("playtest.flow.submit_response", "Submit response") }}
@@ -253,7 +279,7 @@ async function cancelSessionFlow() {
253
279
  :is="conceptPollComponent"
254
280
  v-if="conceptPollComponent"
255
281
  :options="flow.poll.value.options"
256
- :submitting="flow.submitting.value"
282
+ :submitting="previewMode || flow.submitting.value"
257
283
  @submit="submitConceptPollFlow"
258
284
  />
259
285
  </div>
@@ -263,7 +289,7 @@ async function cancelSessionFlow() {
263
289
  <LocaleSelector :locales="flow.locales.value" v-model="selectedLocaleId" />
264
290
  <FileIssueForm
265
291
  :campaign-locale-id="selectedLocaleId"
266
- :submitting="flow.submitting.value"
292
+ :submitting="previewMode || flow.submitting.value"
267
293
  :uploading="flow.uploading.value"
268
294
  @submit="submitIssueFlow"
269
295
  @upload-media="uploadLqaMedia"
@@ -300,7 +326,7 @@ async function cancelSessionFlow() {
300
326
  <button
301
327
  type="button"
302
328
  class="btn primary playtest-response-flow__submit"
303
- :disabled="!complianceComplete || flow.submitting.value"
329
+ :disabled="previewMode || !complianceComplete || flow.submitting.value"
304
330
  @click="submitComplianceFlow"
305
331
  >
306
332
  {{ $t("playtest.flow.submit_checklist", "Submit checklist") }}
@@ -308,6 +334,9 @@ async function cancelSessionFlow() {
308
334
  </div>
309
335
 
310
336
  <!-- ── Video Playtest ── -->
337
+ <div v-else-if="category === 'video_playtest' && previewMode" class="playtest-response-flow__video-preview-note">
338
+ {{ $t("playtest.flow.video_preview_unavailable", "Testers record their screen here while playing. Camera/mic capture is not available in preview.") }}
339
+ </div>
311
340
  <div v-else-if="category === 'video_playtest'">
312
341
  <VideoPlaytestCapture :campaign-id="campaignId" @submitted="handleDone" />
313
342
  </div>
@@ -317,7 +346,7 @@ async function cancelSessionFlow() {
317
346
  <JoinCallPanel
318
347
  v-if="sessionId && flow.currentSession.value"
319
348
  :session="flow.currentSession.value"
320
- :acting="flow.submitting.value"
349
+ :acting="previewMode || flow.submitting.value"
321
350
  @confirm="confirmSessionFlow"
322
351
  @cancel="cancelSessionFlow"
323
352
  />
@@ -327,7 +356,7 @@ async function cancelSessionFlow() {
327
356
  :key="slot.id"
328
357
  variant="slot"
329
358
  :slot="slot"
330
- :claiming="flow.submitting.value"
359
+ :claiming="previewMode || flow.submitting.value"
331
360
  @claim="claimSlotFlow"
332
361
  />
333
362
  </div>
@@ -373,5 +402,22 @@ async function cancelSessionFlow() {
373
402
  flex-direction: column;
374
403
  gap: 10px;
375
404
  }
405
+
406
+ &__preview-banner {
407
+ background: rgba(253, 178, 21, 0.1);
408
+ border: 1px solid rgba(253, 178, 21, 0.3);
409
+ color: var(--card-article-title, #fff);
410
+ font-size: 13px;
411
+ font-weight: 600;
412
+ padding: 10px 14px;
413
+ margin-bottom: 16px;
414
+ }
415
+
416
+ &__video-preview-note {
417
+ color: var(--secondary-info-fg, #aaa);
418
+ font-size: 13px;
419
+ padding: 16px;
420
+ border: 1px dashed var(--card-border, #333);
421
+ }
376
422
  }
377
423
  </style>
@@ -47,8 +47,18 @@ import {
47
47
  *
48
48
  * One instance per campaign visit — instantiate fresh per mount
49
49
  * (PlaytestResponseFlow.vue owns the instance), not as a global singleton.
50
+ *
51
+ * `options.eligibilityFetcher` lets a caller supply the campaign+eligibility
52
+ * snapshot from a different source than the tester-only available() list —
53
+ * used by the studio "preview as tester" mode, which cannot appear in
54
+ * available() (it excludes the caller's own campaigns) and needs a
55
+ * synthetic eligible:true snapshot instead. Omit it for every normal
56
+ * tester-facing caller; behavior is unchanged from before this existed.
50
57
  */
51
- export function usePlaytestResponse(campaignId: number) {
58
+ export function usePlaytestResponse(
59
+ campaignId: number,
60
+ options?: { eligibilityFetcher?: () => Promise<PlaytestCampaign | null> },
61
+ ) {
52
62
  // ── Shared/global state ────────────────────────────────────────────────
53
63
  const loading = ref(false);
54
64
  const submitting = ref(false);
@@ -121,8 +131,9 @@ export function usePlaytestResponse(campaignId: number) {
121
131
  async function loadCampaignEligibility() {
122
132
  loading.value = true;
123
133
  try {
124
- const res = await fetchAvailablePlaytestCampaigns();
125
- const found = res.data.find((c) => c.id === campaignId) ?? null;
134
+ const found = options?.eligibilityFetcher
135
+ ? await options.eligibilityFetcher()
136
+ : (await fetchAvailablePlaytestCampaigns()).data.find((c) => c.id === campaignId) ?? null;
126
137
  campaign.value = found;
127
138
  if (found && found.eligible === false) {
128
139
  ineligible.value = true;
package/locales/de.json CHANGED
@@ -84,8 +84,10 @@
84
84
  },
85
85
  "flow": {
86
86
  "criteria_completed": "Kriterien abgeschlossen",
87
+ "preview_banner": "Nur Vorschau — genau das sieht ein Tester. Hier kann nichts gesendet werden.",
87
88
  "submit_checklist": "Checkliste absenden",
88
- "submit_response": "Antwort absenden"
89
+ "submit_response": "Antwort absenden",
90
+ "video_preview_unavailable": "Hier zeichnen Tester beim Spielen ihren Bildschirm auf. Kamera-/Mikrofonaufnahme ist in der Vorschau nicht verfügbar."
89
91
  },
90
92
  "lqa": {
91
93
  "actual_text": "Tatsächlicher Text",
package/locales/en.json CHANGED
@@ -84,8 +84,10 @@
84
84
  },
85
85
  "flow": {
86
86
  "criteria_completed": "criteria completed",
87
+ "preview_banner": "Preview only — this is exactly what a tester sees. Nothing here can be submitted.",
87
88
  "submit_checklist": "Submit checklist",
88
- "submit_response": "Submit response"
89
+ "submit_response": "Submit response",
90
+ "video_preview_unavailable": "Testers record their screen here while playing. Camera/mic capture is not available in preview."
89
91
  },
90
92
  "lqa": {
91
93
  "actual_text": "Actual text",
package/locales/es.json CHANGED
@@ -84,8 +84,10 @@
84
84
  },
85
85
  "flow": {
86
86
  "criteria_completed": "criterios completados",
87
+ "preview_banner": "Solo vista previa — esto es exactamente lo que ve un tester. Nada aquí se puede enviar.",
87
88
  "submit_checklist": "Enviar checklist",
88
- "submit_response": "Enviar respuesta"
89
+ "submit_response": "Enviar respuesta",
90
+ "video_preview_unavailable": "Aquí es donde los testers graban su pantalla mientras juegan. La captura de cámara/micrófono no está disponible en la vista previa."
89
91
  },
90
92
  "lqa": {
91
93
  "actual_text": "Texto actual",
@@ -84,8 +84,10 @@
84
84
  },
85
85
  "flow": {
86
86
  "criteria_completed": "critérios concluídos",
87
+ "preview_banner": "Somente pré-visualização — é exatamente isso que um tester vê. Nada aqui pode ser enviado.",
87
88
  "submit_checklist": "Enviar checklist",
88
- "submit_response": "Enviar resposta"
89
+ "submit_response": "Enviar resposta",
90
+ "video_preview_unavailable": "Aqui é onde os testers gravam a tela enquanto jogam. A captura de câmera/microfone não está disponível na pré-visualização."
89
91
  },
90
92
  "lqa": {
91
93
  "actual_text": "Texto atual",
package/locales/ro.json CHANGED
@@ -84,8 +84,10 @@
84
84
  },
85
85
  "flow": {
86
86
  "criteria_completed": "criterii finalizate",
87
+ "preview_banner": "Doar previzualizare — exact așa vede un tester. Nimic de aici nu poate fi trimis.",
87
88
  "submit_checklist": "Trimite checklist-ul",
88
- "submit_response": "Trimite răspunsul"
89
+ "submit_response": "Trimite răspunsul",
90
+ "video_preview_unavailable": "Aici testerii își înregistrează ecranul în timp ce joacă. Captura camerei/microfonului nu este disponibilă în previzualizare."
89
91
  },
90
92
  "lqa": {
91
93
  "actual_text": "Text real",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.16.25",
3
+ "version": "1.16.27",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -242,14 +242,6 @@ interface FormData {
242
242
  submitted_at: string
243
243
  }
244
244
 
245
- const contentTypesMap: Record<ContentType, number> = {
246
- article: 1,
247
- video: 2,
248
- stream: 3,
249
- web: 4,
250
- print: 5,
251
- }
252
-
253
245
  const forms = ref<FormData[]>([
254
246
  { content_types: [], material_url: "", description: "", submitted_at: "" },
255
247
  ])
@@ -363,7 +355,12 @@ const submitForm = async () => {
363
355
  await submitMaterial({
364
356
  key_request_id: requestId.value,
365
357
  user_id: unref(currentUserId),
366
- content_types: form.content_types.map((type) => contentTypesMap[type]),
358
+ // Slugs, not guessed ids. article/video/stream/web/print already
359
+ // match content_types.slug (Spatie\Sluggable on the model's own
360
+ // `name`) — the backend resolves them, which is what makes this
361
+ // work regardless of what numeric ids that table happens to hold
362
+ // in a given environment.
363
+ content_types: form.content_types,
367
364
  material_url: form.material_url,
368
365
  description: form.description,
369
366
  submitted_at: new Date(form.submitted_at).toISOString(),