@mundogamernetwork/shared-ui 1.1.96 → 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.
- package/components/playtest/PlaytestBrief.vue +152 -0
- package/components/playtest/PlaytestPendingConfirmation.vue +84 -0
- package/components/playtest/PlaytestResponseFlow.vue +371 -0
- package/components/playtest/StarRatingInput.vue +105 -0
- package/components/playtest/accessibility/CriterionRow.vue +167 -0
- package/components/playtest/baseline/BaselineSectionStep.vue +106 -0
- package/components/playtest/compliance/ChecklistItemRow.vue +200 -0
- package/components/playtest/concept-poll/EmojiReactionVote.vue +145 -0
- package/components/playtest/concept-poll/FiveSecondTestVote.vue +197 -0
- package/components/playtest/concept-poll/HeadToHeadVote.vue +98 -0
- package/components/playtest/concept-poll/MultiSelectVote.vue +104 -0
- package/components/playtest/concept-poll/RankedChoiceVote.vue +169 -0
- package/components/playtest/concept-poll/SingleSelectVote.vue +98 -0
- package/components/playtest/concept-poll/StarRatingVote.vue +110 -0
- package/components/playtest/concept-poll/VoteReasonField.vue +74 -0
- package/components/playtest/lqa/FileIssueForm.vue +181 -0
- package/components/playtest/lqa/IssueList.vue +99 -0
- package/components/playtest/lqa/LocaleSelector.vue +73 -0
- package/components/playtest/moderated/JoinCallPanel.vue +135 -0
- package/components/playtest/moderated/SessionCard.vue +146 -0
- package/composables/usePlaytestResponse.ts +446 -0
- package/package.json +10 -3
- 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>
|