@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,152 @@
1
+ <script setup lang="ts">
2
+ // Shared chrome: campaign brief header shown atop every category flow —
3
+ // title, category/engine badges, reward disclaimer ("paid on acceptance, not
4
+ // on submission" — matches PlaytestService::acceptResponse() being the only
5
+ // place that releases a reward), and a slot for category-specific teaser
6
+ // content (poll question, locales in scope, accessibility criteria list —
7
+ // supplied by the consuming app/orchestrator, not hardcoded here).
8
+ import { computed } from "vue";
9
+ import type { PlaytestCampaign } from "../../services/playtestTesterService";
10
+
11
+ const props = defineProps<{
12
+ campaign: PlaytestCampaign | null;
13
+ loading?: boolean;
14
+ }>();
15
+
16
+ const rewardLabel = computed(() => {
17
+ const c = props.campaign;
18
+ if (!c) return "";
19
+ const amount = c.reward_amount;
20
+ return c.reward_type === "cash"
21
+ ? `${amount} ${c.reward_currency ?? "USD"}`
22
+ : `${amount} MGC`;
23
+ });
24
+
25
+ const categoryLabel = computed(() => {
26
+ const map: Record<string, string> = {
27
+ concept_poll: "Concept Poll",
28
+ localization_qa: "Localization QA",
29
+ accessibility_audit: "Accessibility Audit",
30
+ };
31
+ return props.campaign?.category ? map[props.campaign.category] ?? props.campaign.category : "Baseline Playtest";
32
+ });
33
+ </script>
34
+
35
+ <template>
36
+ <div class="pt-brief">
37
+ <div v-if="loading" class="pt-brief__skeleton" />
38
+
39
+ <template v-else-if="campaign">
40
+ <div class="pt-brief__badges">
41
+ <span class="pt-brief__badge pt-brief__badge--category">{{ categoryLabel }}</span>
42
+ <span class="pt-brief__badge">{{ campaign.engine }}</span>
43
+ </div>
44
+
45
+ <h2 class="pt-brief__title">{{ campaign.title }}</h2>
46
+
47
+ <div class="pt-brief__meta">
48
+ <span class="pt-brief__reward">
49
+ {{ $t("playtest.brief.reward", "Reward") }}: <strong>{{ rewardLabel }}</strong>
50
+ </span>
51
+ <span class="pt-brief__disclaimer">
52
+ {{ $t("playtest.brief.paid_on_acceptance", "Paid when the studio accepts your response, not on submission.") }}
53
+ </span>
54
+ </div>
55
+
56
+ <div v-if="campaign.eligible === false" class="pt-brief__ineligible">
57
+ <i class="fa-solid fa-lock" />
58
+ <span>{{ campaign.ineligibility_reason || $t("playtest.brief.ineligible_generic", "You don't currently qualify for this playtest.") }}</span>
59
+ </div>
60
+
61
+ <div class="pt-brief__slot">
62
+ <slot />
63
+ </div>
64
+ </template>
65
+
66
+ <div v-else class="pt-brief__empty">
67
+ {{ $t("playtest.brief.not_found", "This playtest campaign could not be found.") }}
68
+ </div>
69
+ </div>
70
+ </template>
71
+
72
+ <style scoped lang="scss">
73
+ .pt-brief {
74
+ border: 1px solid var(--button-secondary-default-bg, #2a2a2a);
75
+ padding: 20px;
76
+ margin-bottom: 20px;
77
+
78
+ &__skeleton {
79
+ height: 120px;
80
+ background: var(--bg-app-badge, #1a1a1a);
81
+ animation: pt-pulse 1.4s ease-in-out infinite;
82
+ }
83
+
84
+ &__badges {
85
+ display: flex;
86
+ gap: 8px;
87
+ margin-bottom: 10px;
88
+ }
89
+
90
+ &__badge {
91
+ font-size: 11px;
92
+ font-weight: 700;
93
+ text-transform: uppercase;
94
+ letter-spacing: 0.4px;
95
+ padding: 3px 8px;
96
+ border: 1px solid var(--border-color, rgba(255, 255, 255, 0.2));
97
+ color: var(--secondary-info-fg, #aaa);
98
+
99
+ &--category {
100
+ color: var(--primary, #D297FF);
101
+ border-color: var(--primary, #D297FF);
102
+ }
103
+ }
104
+
105
+ &__title {
106
+ font-size: 1.3rem;
107
+ font-weight: 700;
108
+ color: var(--card-article-title, #fff);
109
+ margin: 0 0 12px;
110
+ }
111
+
112
+ &__meta {
113
+ display: flex;
114
+ flex-direction: column;
115
+ gap: 4px;
116
+ margin-bottom: 12px;
117
+ }
118
+
119
+ &__reward {
120
+ font-size: 14px;
121
+ color: var(--text-primary, #fff);
122
+ }
123
+
124
+ &__disclaimer {
125
+ font-size: 12px;
126
+ color: var(--secondary-info-fg, #888);
127
+ }
128
+
129
+ &__ineligible {
130
+ display: flex;
131
+ align-items: center;
132
+ gap: 8px;
133
+ padding: 10px 12px;
134
+ background: rgba(229, 57, 53, 0.1);
135
+ border: 1px solid rgba(229, 57, 53, 0.35);
136
+ color: var(--danger, #e53935);
137
+ font-size: 13px;
138
+ margin-bottom: 12px;
139
+ }
140
+
141
+ &__empty {
142
+ padding: 40px 0;
143
+ text-align: center;
144
+ color: var(--secondary-info-fg, #888);
145
+ }
146
+ }
147
+
148
+ @keyframes pt-pulse {
149
+ 0%, 100% { opacity: 1; }
150
+ 50% { opacity: 0.4; }
151
+ }
152
+ </style>
@@ -0,0 +1,84 @@
1
+ <script setup lang="ts">
2
+ // Shared chrome: terminal "you're done" state shown after a successful
3
+ // submit — every category ends here (no cross-category variance needed,
4
+ // since acceptance/rejection always happens later, asynchronously, via the
5
+ // studio's own review dashboard in agency-frontend).
6
+ withDefaults(defineProps<{
7
+ title?: string;
8
+ message?: string;
9
+ ineligible?: boolean;
10
+ ineligibilityReason?: string | null;
11
+ }>(), {
12
+ title: undefined,
13
+ message: undefined,
14
+ ineligible: false,
15
+ ineligibilityReason: null,
16
+ });
17
+
18
+ const emit = defineEmits<{
19
+ (e: "browse-more"): void;
20
+ }>();
21
+ </script>
22
+
23
+ <template>
24
+ <div class="pt-pending" :class="{ 'pt-pending--ineligible': ineligible }">
25
+ <div class="pt-pending__icon">
26
+ <i v-if="ineligible" class="fa-solid fa-circle-exclamation" />
27
+ <i v-else class="fa-solid fa-circle-check" />
28
+ </div>
29
+
30
+ <h3 class="pt-pending__title">
31
+ {{ ineligible
32
+ ? (title ?? $t("playtest.pending.ineligible_title", "You no longer qualify for this playtest"))
33
+ : (title ?? $t("playtest.pending.title", "Response submitted")) }}
34
+ </h3>
35
+
36
+ <p class="pt-pending__message">
37
+ {{ ineligible
38
+ ? (ineligibilityReason ?? message ?? $t("playtest.pending.ineligible_message", "Something changed since you started — this campaign's requirements are no longer met."))
39
+ : (message ?? $t("playtest.pending.message", "Thanks for testing! The studio will review your response — you'll be paid if it's accepted.")) }}
40
+ </p>
41
+
42
+ <button type="button" class="btn primary pt-pending__cta" @click="emit('browse-more')">
43
+ {{ $t("playtest.pending.browse_more", "Browse more playtests") }}
44
+ </button>
45
+ </div>
46
+ </template>
47
+
48
+ <style scoped lang="scss">
49
+ .pt-pending {
50
+ display: flex;
51
+ flex-direction: column;
52
+ align-items: center;
53
+ text-align: center;
54
+ padding: 48px 24px;
55
+ gap: 12px;
56
+
57
+ &__icon {
58
+ font-size: 2.4rem;
59
+ color: var(--primary, #D297FF);
60
+ }
61
+
62
+ &--ineligible &__icon {
63
+ color: var(--danger, #e53935);
64
+ }
65
+
66
+ &__title {
67
+ font-size: 1.2rem;
68
+ font-weight: 700;
69
+ color: var(--card-article-title, #fff);
70
+ margin: 0;
71
+ }
72
+
73
+ &__message {
74
+ font-size: 14px;
75
+ color: var(--secondary-info-fg, #aaa);
76
+ max-width: 420px;
77
+ margin: 0;
78
+ }
79
+
80
+ &__cta {
81
+ margin-top: 12px;
82
+ }
83
+ }
84
+ </style>
@@ -0,0 +1,371 @@
1
+ <script setup lang="ts">
2
+ // Top-level orchestrator — the single component both agency-frontend and
3
+ // community-frontend actually mount. Dispatches to the right per-category
4
+ // flow given {campaignId, category}. `category` mirrors
5
+ // PlaytestCampaign.category: null (baseline quick/deep), 'concept_poll',
6
+ // 'localization_qa', 'accessibility_audit'. `moderated` is a distinct mode
7
+ // (not a campaign.category — moderated sessions layer on top of any
8
+ // campaign) selected explicitly via the `mode` prop when the consuming app
9
+ // is showing a specific booked/available session rather than a
10
+ // submission form.
11
+ //
12
+ // SSR-safety: campaign eligibility/catalog data is fetched via
13
+ // usePlaytestResponse's load*() methods inside onMounted — this flow is
14
+ // gated behind auth (every route requires a logged-in tester with real
15
+ // eligibility state, which cannot be resolved during SSR without the
16
+ // request's auth cookies plumbed through a server-side composable this repo
17
+ // doesn't currently have for this module) — same posture as
18
+ // KeyBrowser/key-materials flows in this repo, which are also
19
+ // onMounted-driven for their authenticated, highly-interactive parts. The
20
+ // purely public catalog/criteria lookups (loadCatalog,
21
+ // loadAccessibilityCriteria) are cheap and idempotent to re-run client-side.
22
+ import { ref, computed, onMounted, watch } from "vue";
23
+ import { usePlaytestResponse } from "../../composables/usePlaytestResponse";
24
+
25
+ import PlaytestBrief from "./PlaytestBrief.vue";
26
+ import PlaytestPendingConfirmation from "./PlaytestPendingConfirmation.vue";
27
+ import BaselineSectionStep from "./baseline/BaselineSectionStep.vue";
28
+ import HeadToHeadVote from "./concept-poll/HeadToHeadVote.vue";
29
+ import SingleSelectVote from "./concept-poll/SingleSelectVote.vue";
30
+ import MultiSelectVote from "./concept-poll/MultiSelectVote.vue";
31
+ import RankedChoiceVote from "./concept-poll/RankedChoiceVote.vue";
32
+ import FiveSecondTestVote from "./concept-poll/FiveSecondTestVote.vue";
33
+ import StarRatingVote from "./concept-poll/StarRatingVote.vue";
34
+ import EmojiReactionVote from "./concept-poll/EmojiReactionVote.vue";
35
+ import LocaleSelector from "./lqa/LocaleSelector.vue";
36
+ import IssueList from "./lqa/IssueList.vue";
37
+ import FileIssueForm from "./lqa/FileIssueForm.vue";
38
+ import CriterionRow from "./accessibility/CriterionRow.vue";
39
+ import ChecklistItemRow from "./compliance/ChecklistItemRow.vue";
40
+ import SessionCard from "./moderated/SessionCard.vue";
41
+ import JoinCallPanel from "./moderated/JoinCallPanel.vue";
42
+
43
+ type PlaytestCategory = "baseline" | "concept_poll" | "localization_qa" | "accessibility_audit" | "compliance" | "moderated";
44
+
45
+ const props = defineProps<{
46
+ campaignId: number;
47
+ category: PlaytestCategory;
48
+ // concept_poll: id of the poll to vote on (campaign can host one poll)
49
+ pollId?: number;
50
+ // compliance: id of the checklist being answered (a campaign can have
51
+ // multiple checklists — consuming app picks which one to mount, or loops)
52
+ checklistId?: number;
53
+ // moderated: explicit session id when resuming/joining a specific
54
+ // already-booked session (skips the slot-browse step)
55
+ sessionId?: number;
56
+ }>();
57
+
58
+ const emit = defineEmits<{
59
+ (e: "completed"): void
60
+ (e: "browse-more"): void
61
+ }>();
62
+
63
+ const flow = usePlaytestResponse(props.campaignId);
64
+
65
+ const done = ref(false);
66
+
67
+ onMounted(async () => {
68
+ await flow.loadCampaignEligibility();
69
+
70
+ if (props.category === "baseline") {
71
+ await flow.loadCatalog();
72
+ } else if (props.category === "concept_poll" && props.pollId) {
73
+ await flow.loadConceptPoll(props.pollId);
74
+ } else if (props.category === "localization_qa") {
75
+ await flow.loadCampaignLocales();
76
+ } else if (props.category === "accessibility_audit") {
77
+ await flow.loadAccessibilityCriteria();
78
+ } else if (props.category === "compliance") {
79
+ await flow.loadComplianceChecklists();
80
+ } else if (props.category === "moderated") {
81
+ if (props.sessionId) {
82
+ await flow.loadSession(props.sessionId);
83
+ } else {
84
+ await flow.loadAvailableModeratedSlots();
85
+ }
86
+ }
87
+ });
88
+
89
+ function handleDone() {
90
+ done.value = true;
91
+ emit("completed");
92
+ }
93
+
94
+ function browseMore() {
95
+ done.value = false;
96
+ flow.resetErrors();
97
+ emit("browse-more");
98
+ }
99
+
100
+ // ── Baseline ──────────────────────────────────────────────────────────────
101
+
102
+ const enabledSectionKeys = computed(() => flow.campaign.value?.enabled_sections ?? []);
103
+
104
+ async function submitBaselineFlow() {
105
+ const result = await flow.submitBaseline();
106
+ if (result) handleDone();
107
+ }
108
+
109
+ // ── Concept poll dispatch ───────────────────────────────────────────────────
110
+
111
+ const conceptPollComponent = computed(() => {
112
+ switch (flow.poll.value?.poll_type) {
113
+ case "head_to_head": return HeadToHeadVote;
114
+ case "single_select": return SingleSelectVote;
115
+ case "multi_select": return MultiSelectVote;
116
+ case "ranked_choice": return RankedChoiceVote;
117
+ case "five_second_test": return FiveSecondTestVote;
118
+ case "star_rating": return StarRatingVote;
119
+ case "emoji_reaction": return EmojiReactionVote;
120
+ default: return null;
121
+ }
122
+ });
123
+
124
+ async function submitConceptPollFlow(payload: { answer: any; reason: string }) {
125
+ if (!props.pollId) return;
126
+ const result = await flow.submitConceptPollVote(props.pollId, payload.answer, payload.reason);
127
+ if (result) handleDone();
128
+ }
129
+
130
+ // ── Localization QA ─────────────────────────────────────────────────────────
131
+
132
+ const selectedLocaleId = ref<number | null>(null);
133
+
134
+ async function submitIssueFlow(payload: Parameters<typeof flow.fileLocalizationIssue>[0]) {
135
+ await flow.fileLocalizationIssue(payload);
136
+ // LQA is a repeatable session (file multiple issues) — no auto "done"
137
+ // transition; the consuming app decides when the tester is finished
138
+ // (e.g. an explicit "Finish session" button of its own).
139
+ }
140
+
141
+ async function uploadLqaMedia(file: File) {
142
+ return flow.uploadMedia(file);
143
+ }
144
+
145
+ // ── Accessibility ────────────────────────────────────────────────────────────
146
+
147
+ const accessibilityKeys = computed(() => {
148
+ const enabled = flow.campaign.value?.accessibility_criteria;
149
+ const all = Object.keys(flow.accessibilityCriteria.value);
150
+ return enabled && enabled.length > 0 ? all.filter((k) => enabled.includes(k)) : all;
151
+ });
152
+
153
+ const completedCriteriaCount = computed(() => Object.keys(flow.accessibilityEvaluations).length);
154
+ const allCriteriaComplete = computed(
155
+ () => accessibilityKeys.value.length > 0 && completedCriteriaCount.value >= accessibilityKeys.value.length
156
+ );
157
+
158
+ async function saveAccessibilityEvaluation(entry: Parameters<typeof flow.setAccessibilityEvaluation>[0]) {
159
+ flow.setAccessibilityEvaluation(entry);
160
+ await flow.submitAccessibilityBatch([entry]);
161
+ }
162
+
163
+ watch(allCriteriaComplete, (complete) => {
164
+ if (complete) handleDone();
165
+ });
166
+
167
+ // ── Compliance ────────────────────────────────────────────────────────────
168
+
169
+ const activeChecklist = computed(() =>
170
+ flow.complianceChecklists.value.find((c) => c.id === props.checklistId) ?? flow.complianceChecklists.value[0] ?? null
171
+ );
172
+
173
+ function saveComplianceResult(entry: Parameters<typeof flow.setComplianceResult>[1]) {
174
+ if (!activeChecklist.value) return;
175
+ flow.setComplianceResult(activeChecklist.value.id, entry);
176
+ }
177
+
178
+ async function uploadComplianceEvidence(file: File) {
179
+ return flow.uploadMedia(file);
180
+ }
181
+
182
+ const complianceComplete = computed(() => {
183
+ if (!activeChecklist.value) return false;
184
+ const results = flow.complianceResults[activeChecklist.value.id] ?? {};
185
+ return activeChecklist.value.items.every((i) => !!results[i.key]);
186
+ });
187
+
188
+ async function submitComplianceFlow() {
189
+ if (!activeChecklist.value) return;
190
+ const result = await flow.submitComplianceChecklist(activeChecklist.value.id);
191
+ if (result) handleDone();
192
+ }
193
+
194
+ // ── Moderated ─────────────────────────────────────────────────────────────
195
+
196
+ async function claimSlotFlow(slotId: number) {
197
+ const result = await flow.claimSlot(slotId);
198
+ if (result) handleDone();
199
+ }
200
+
201
+ async function confirmSessionFlow() {
202
+ if (flow.currentSession.value) await flow.confirmSession(flow.currentSession.value.id);
203
+ }
204
+
205
+ async function cancelSessionFlow() {
206
+ if (flow.currentSession.value) await flow.cancelSession(flow.currentSession.value.id);
207
+ }
208
+ </script>
209
+
210
+ <template>
211
+ <div class="playtest-response-flow">
212
+ <PlaytestPendingConfirmation
213
+ v-if="done || flow.ineligible.value"
214
+ :ineligible="flow.ineligible.value"
215
+ :ineligibility-reason="flow.ineligibilityReason.value"
216
+ @browse-more="browseMore"
217
+ />
218
+
219
+ <template v-else>
220
+ <PlaytestBrief :campaign="flow.campaign.value" :loading="flow.loading.value">
221
+ <p v-if="flow.genericError.value" class="playtest-response-flow__error">
222
+ {{ flow.genericError.value }}
223
+ </p>
224
+
225
+ <!-- ── Baseline ── -->
226
+ <div v-if="category === 'baseline' && flow.catalog.value">
227
+ <BaselineSectionStep
228
+ v-for="key in enabledSectionKeys"
229
+ :key="key"
230
+ :section-key="key"
231
+ :meta="flow.catalog.value.sections[key]"
232
+ :model-value="flow.baselineAnswers[key] ?? {}"
233
+ @update:model-value="(v) => flow.setBaselineAnswer(key, v)"
234
+ />
235
+ <button
236
+ type="button"
237
+ class="btn primary playtest-response-flow__submit"
238
+ :disabled="flow.submitting.value"
239
+ @click="submitBaselineFlow"
240
+ >
241
+ {{ $t("playtest.flow.submit_response", "Submit response") }}
242
+ </button>
243
+ </div>
244
+
245
+ <!-- ── Concept poll ── -->
246
+ <div v-else-if="category === 'concept_poll' && flow.poll.value">
247
+ <h3 class="playtest-response-flow__question">{{ flow.poll.value.question }}</h3>
248
+ <p v-if="flow.poll.value.description" class="playtest-response-flow__description">
249
+ {{ flow.poll.value.description }}
250
+ </p>
251
+ <component
252
+ :is="conceptPollComponent"
253
+ v-if="conceptPollComponent"
254
+ :options="flow.poll.value.options"
255
+ :submitting="flow.submitting.value"
256
+ @submit="submitConceptPollFlow"
257
+ />
258
+ </div>
259
+
260
+ <!-- ── Localization QA ── -->
261
+ <div v-else-if="category === 'localization_qa'">
262
+ <LocaleSelector :locales="flow.locales.value" v-model="selectedLocaleId" />
263
+ <FileIssueForm
264
+ :campaign-locale-id="selectedLocaleId"
265
+ :submitting="flow.submitting.value"
266
+ :uploading="flow.uploading.value"
267
+ @submit="submitIssueFlow"
268
+ @upload-media="uploadLqaMedia"
269
+ />
270
+ <IssueList :issues="flow.filedIssues.value" />
271
+ </div>
272
+
273
+ <!-- ── Accessibility ── -->
274
+ <div v-else-if="category === 'accessibility_audit'">
275
+ <p class="playtest-response-flow__progress">
276
+ {{ completedCriteriaCount }} / {{ accessibilityKeys.length }}
277
+ {{ $t("playtest.flow.criteria_completed", "criteria completed") }}
278
+ </p>
279
+ <CriterionRow
280
+ v-for="key in accessibilityKeys"
281
+ :key="key"
282
+ :criterion-key="key"
283
+ :meta="flow.accessibilityCriteria.value[key]"
284
+ @save="saveAccessibilityEvaluation"
285
+ />
286
+ </div>
287
+
288
+ <!-- ── Compliance ── -->
289
+ <div v-else-if="category === 'compliance' && activeChecklist">
290
+ <h3 class="playtest-response-flow__question">{{ activeChecklist.name }}</h3>
291
+ <ChecklistItemRow
292
+ v-for="item in activeChecklist.items"
293
+ :key="item.key"
294
+ :item="item"
295
+ :uploading="flow.uploading.value"
296
+ @change="saveComplianceResult"
297
+ @upload-evidence="uploadComplianceEvidence"
298
+ />
299
+ <button
300
+ type="button"
301
+ class="btn primary playtest-response-flow__submit"
302
+ :disabled="!complianceComplete || flow.submitting.value"
303
+ @click="submitComplianceFlow"
304
+ >
305
+ {{ $t("playtest.flow.submit_checklist", "Submit checklist") }}
306
+ </button>
307
+ </div>
308
+
309
+ <!-- ── Moderated ── -->
310
+ <div v-else-if="category === 'moderated'">
311
+ <JoinCallPanel
312
+ v-if="sessionId && flow.currentSession.value"
313
+ :session="flow.currentSession.value"
314
+ :acting="flow.submitting.value"
315
+ @confirm="confirmSessionFlow"
316
+ @cancel="cancelSessionFlow"
317
+ />
318
+ <div v-else class="playtest-response-flow__slots">
319
+ <SessionCard
320
+ v-for="slot in flow.moderatedSlots.value"
321
+ :key="slot.id"
322
+ variant="slot"
323
+ :slot="slot"
324
+ :claiming="flow.submitting.value"
325
+ @claim="claimSlotFlow"
326
+ />
327
+ </div>
328
+ </div>
329
+ </PlaytestBrief>
330
+ </template>
331
+ </div>
332
+ </template>
333
+
334
+ <style scoped lang="scss">
335
+ .playtest-response-flow {
336
+ &__error {
337
+ color: var(--danger, #e53935);
338
+ font-size: 13px;
339
+ margin-bottom: 12px;
340
+ }
341
+
342
+ &__question {
343
+ font-size: 16px;
344
+ font-weight: 700;
345
+ color: var(--card-article-title, #fff);
346
+ margin: 0 0 8px;
347
+ }
348
+
349
+ &__description {
350
+ font-size: 13px;
351
+ color: var(--secondary-info-fg, #aaa);
352
+ margin: 0 0 16px;
353
+ }
354
+
355
+ &__progress {
356
+ font-size: 12px;
357
+ color: var(--secondary-info-fg, #aaa);
358
+ margin: 0 0 10px;
359
+ }
360
+
361
+ &__submit {
362
+ margin-top: 20px;
363
+ }
364
+
365
+ &__slots {
366
+ display: flex;
367
+ flex-direction: column;
368
+ gap: 10px;
369
+ }
370
+ }
371
+ </style>