@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,105 @@
1
+ <script setup lang="ts">
2
+ // Reusable 1–5 star rating input. No star-rating component existed anywhere
3
+ // in shared-ui before this (community-frontend currently copy-pastes inline
4
+ // star SVG per usage per this session's research) — built fresh here so
5
+ // every playtest flow (baseline "scale" sections, concept-poll star_rating
6
+ // votes) shares one implementation.
7
+ const props = withDefaults(defineProps<{
8
+ modelValue: number | null
9
+ max?: number
10
+ disabled?: boolean
11
+ size?: number
12
+ }>(), {
13
+ max: 5,
14
+ disabled: false,
15
+ size: 22,
16
+ });
17
+
18
+ const emit = defineEmits<{
19
+ (e: "update:modelValue", value: number): void
20
+ }>();
21
+
22
+ import { ref } from "vue";
23
+
24
+ const hovered = ref<number | null>(null);
25
+
26
+ function select(n: number) {
27
+ if (props.disabled) return;
28
+ emit("update:modelValue", n);
29
+ }
30
+
31
+ function starClass(n: number): string {
32
+ const active = hovered.value ?? props.modelValue ?? 0;
33
+ return n <= active ? "star-rating__star star-rating__star--active" : "star-rating__star";
34
+ }
35
+ </script>
36
+
37
+ <template>
38
+ <div
39
+ class="star-rating"
40
+ :class="{ 'star-rating--disabled': disabled }"
41
+ role="radiogroup"
42
+ :aria-label="`Rating out of ${max}`"
43
+ >
44
+ <button
45
+ v-for="n in max"
46
+ :key="n"
47
+ type="button"
48
+ class="star-rating__btn"
49
+ :class="starClass(n)"
50
+ :disabled="disabled"
51
+ :aria-checked="modelValue === n"
52
+ role="radio"
53
+ @click="select(n)"
54
+ @mouseenter="hovered = n"
55
+ @mouseleave="hovered = null"
56
+ >
57
+ <svg
58
+ :width="size"
59
+ :height="size"
60
+ viewBox="0 0 24 24"
61
+ :fill="n <= (hovered ?? modelValue ?? 0) ? 'currentColor' : 'none'"
62
+ stroke="currentColor"
63
+ stroke-width="1.5"
64
+ >
65
+ <path
66
+ d="M12 2.5l2.9 6.06 6.6.77-4.85 4.6 1.24 6.57L12 17.3l-5.89 3.2 1.24-6.57-4.85-4.6 6.6-.77L12 2.5z"
67
+ stroke-linejoin="round"
68
+ />
69
+ </svg>
70
+ </button>
71
+ </div>
72
+ </template>
73
+
74
+ <style scoped lang="scss">
75
+ .star-rating {
76
+ display: inline-flex;
77
+ gap: 4px;
78
+
79
+ &--disabled {
80
+ opacity: 0.6;
81
+ }
82
+
83
+ &__btn {
84
+ background: none;
85
+ border: none;
86
+ padding: 2px;
87
+ cursor: pointer;
88
+ line-height: 0;
89
+ color: var(--secondary-info-fg, #666);
90
+ transition: color 0.12s, transform 0.12s;
91
+
92
+ &:hover:not(:disabled) {
93
+ transform: scale(1.08);
94
+ }
95
+
96
+ &:disabled {
97
+ cursor: not-allowed;
98
+ }
99
+ }
100
+
101
+ &__star--active {
102
+ color: var(--primary, #D297FF);
103
+ }
104
+ }
105
+ </style>
@@ -0,0 +1,167 @@
1
+ <script setup lang="ts">
2
+ // One accessibility criterion's evaluation row — result enum + conditional
3
+ // notes (required when result==='fail', per StoreAccessibilityEvaluationRequest.php)
4
+ // + tester_uses_accommodation checkbox (weights testers who actually rely on
5
+ // the accommodation). Server upserts per-criterion — safe to auto-save each
6
+ // row as the tester completes it, not just at the end.
7
+ import { ref, computed, watch } from "vue";
8
+ import type { AccessibilityEvaluationResult, PlaytestAccessibilityCriterionMeta } from "../../../services/playtestTesterService";
9
+
10
+ const props = defineProps<{
11
+ criterionKey: string;
12
+ meta: PlaytestAccessibilityCriterionMeta;
13
+ autoSave?: boolean;
14
+ }>();
15
+
16
+ const emit = defineEmits<{
17
+ (e: "save", payload: { criterion: string; result: AccessibilityEvaluationResult; notes?: string | null; tester_uses_accommodation?: boolean }): void
18
+ }>();
19
+
20
+ const RESULTS: AccessibilityEvaluationResult[] = ["pass", "fail", "partial", "not_applicable"];
21
+
22
+ const result = ref<AccessibilityEvaluationResult | "">("");
23
+ const notes = ref("");
24
+ const usesAccommodation = ref(false);
25
+
26
+ const notesRequired = computed(() => result.value === "fail");
27
+ const isComplete = computed(() => !!result.value && (!notesRequired.value || notes.value.trim().length > 0));
28
+
29
+ function save() {
30
+ if (!result.value) return;
31
+ if (notesRequired.value && !notes.value.trim()) return;
32
+ emit("save", {
33
+ criterion: props.criterionKey,
34
+ result: result.value,
35
+ notes: notes.value || null,
36
+ tester_uses_accommodation: usesAccommodation.value,
37
+ });
38
+ }
39
+
40
+ // Auto-save whenever the row becomes complete (per-criterion upsert is safe
41
+ // server-side) — opt-out via autoSave=false if the orchestrator wants an
42
+ // explicit "save" button flow instead.
43
+ watch([result, notes, usesAccommodation], () => {
44
+ if (props.autoSave !== false && isComplete.value) save();
45
+ });
46
+
47
+ defineExpose({ isComplete, save });
48
+ </script>
49
+
50
+ <template>
51
+ <div class="criterion-row">
52
+ <div class="criterion-row__header">
53
+ <h4 class="criterion-row__label">{{ meta.label }}</h4>
54
+ <label class="criterion-row__accommodation">
55
+ <input type="checkbox" v-model="usesAccommodation">
56
+ {{ $t("playtest.accessibility.uses_accommodation", "I personally rely on this accommodation") }}
57
+ </label>
58
+ </div>
59
+
60
+ <div class="criterion-row__results">
61
+ <label
62
+ v-for="r in RESULTS"
63
+ :key="r"
64
+ class="criterion-row__result"
65
+ :class="{ 'criterion-row__result--selected': result === r }"
66
+ >
67
+ <input type="radio" :value="r" v-model="result" :name="`criterion-${criterionKey}`">
68
+ {{ r.replace('_', ' ') }}
69
+ </label>
70
+ </div>
71
+
72
+ <div v-if="notesRequired" class="criterion-row__notes">
73
+ <label class="criterion-row__notes-label">
74
+ {{ $t("playtest.accessibility.notes_required", "Notes (required when failing)") }}
75
+ </label>
76
+ <textarea v-model="notes" rows="2" class="criterion-row__textarea" />
77
+ </div>
78
+ </div>
79
+ </template>
80
+
81
+ <style scoped lang="scss">
82
+ .criterion-row {
83
+ padding: 14px 0;
84
+ border-bottom: 1px solid var(--button-secondary-default-bg, #2a2a2a);
85
+
86
+ &:last-child {
87
+ border-bottom: none;
88
+ }
89
+
90
+ &__header {
91
+ display: flex;
92
+ align-items: center;
93
+ justify-content: space-between;
94
+ flex-wrap: wrap;
95
+ gap: 8px;
96
+ margin-bottom: 10px;
97
+ }
98
+
99
+ &__label {
100
+ font-size: 14px;
101
+ font-weight: 700;
102
+ color: var(--card-article-title, #fff);
103
+ margin: 0;
104
+ }
105
+
106
+ &__accommodation {
107
+ display: flex;
108
+ align-items: center;
109
+ gap: 6px;
110
+ font-size: 11px;
111
+ color: var(--secondary-info-fg, #aaa);
112
+
113
+ input {
114
+ accent-color: var(--primary, #D297FF);
115
+ }
116
+ }
117
+
118
+ &__results {
119
+ display: flex;
120
+ flex-wrap: wrap;
121
+ gap: 8px;
122
+ }
123
+
124
+ &__result {
125
+ display: flex;
126
+ align-items: center;
127
+ gap: 6px;
128
+ padding: 6px 12px;
129
+ background: var(--bg-app-badge, #1a1a1a);
130
+ border: 1px solid var(--button-secondary-default-bg, #2a2a2a);
131
+ cursor: pointer;
132
+ font-size: 12px;
133
+ text-transform: capitalize;
134
+ color: var(--text-primary, #fff);
135
+
136
+ &--selected {
137
+ border-color: var(--primary, #D297FF);
138
+ background: rgba(210, 151, 255, 0.08);
139
+ }
140
+
141
+ input {
142
+ accent-color: var(--primary, #D297FF);
143
+ }
144
+ }
145
+
146
+ &__notes {
147
+ margin-top: 10px;
148
+
149
+ &-label {
150
+ display: block;
151
+ font-size: 11px;
152
+ color: var(--danger, #e53935);
153
+ margin-bottom: 6px;
154
+ }
155
+ }
156
+
157
+ &__textarea {
158
+ width: 100%;
159
+ background: var(--bg-app-badge, #1a1a1a);
160
+ border: 1px solid var(--button-secondary-default-bg, #2a2a2a);
161
+ color: var(--text-primary, #fff);
162
+ padding: 8px;
163
+ font-size: 12px;
164
+ resize: vertical;
165
+ }
166
+ }
167
+ </style>
@@ -0,0 +1,106 @@
1
+ <script setup lang="ts">
2
+ // One baseline section's rating+text inputs, driven entirely by the
3
+ // section's catalog metadata (scale/text requirement) from
4
+ // GET /playtest/catalog — never hardcodes per-section behavior, since the
5
+ // catalog is the single source of truth (PlaytestFormSection::all() on the
6
+ // backend, mirrored here).
7
+ import { computed } from "vue";
8
+ import StarRatingInput from "../StarRatingInput.vue";
9
+ import type { PlaytestFormSectionMeta, PlaytestAnswerEntry } from "../../../services/playtestTesterService";
10
+
11
+ const props = defineProps<{
12
+ sectionKey: string;
13
+ meta: PlaytestFormSectionMeta;
14
+ modelValue: PlaytestAnswerEntry;
15
+ }>();
16
+
17
+ const emit = defineEmits<{
18
+ (e: "update:modelValue", value: PlaytestAnswerEntry): void
19
+ }>();
20
+
21
+ const rating = computed({
22
+ get: () => props.modelValue.rating ?? null,
23
+ set: (v: number | null) => emit("update:modelValue", { ...props.modelValue, rating: v ?? undefined }),
24
+ });
25
+
26
+ const text = computed({
27
+ get: () => props.modelValue.text ?? "",
28
+ set: (v: string) => emit("update:modelValue", { ...props.modelValue, text: v }),
29
+ });
30
+
31
+ const textRequired = computed(() => props.meta.text === "required");
32
+ const textOptional = computed(() => props.meta.text === "optional");
33
+ const showText = computed(() => props.meta.text !== "none");
34
+
35
+ const isValid = computed(() => {
36
+ if (props.meta.scale && !rating.value) return false;
37
+ if (textRequired.value && !text.value.trim()) return false;
38
+ return true;
39
+ });
40
+
41
+ defineExpose({ isValid });
42
+ </script>
43
+
44
+ <template>
45
+ <div class="baseline-step">
46
+ <h4 class="baseline-step__label">{{ meta.label }}</h4>
47
+
48
+ <div v-if="meta.scale" class="baseline-step__rating">
49
+ <StarRatingInput v-model="rating as any" />
50
+ </div>
51
+
52
+ <div v-if="showText" class="baseline-step__text">
53
+ <label :for="`baseline-text-${sectionKey}`" class="baseline-step__text-label">
54
+ {{ textRequired
55
+ ? $t("playtest.baseline.feedback_required", "Your feedback (required)")
56
+ : $t("playtest.baseline.feedback_optional", "Your feedback (optional)") }}
57
+ </label>
58
+ <textarea
59
+ :id="`baseline-text-${sectionKey}`"
60
+ v-model="text"
61
+ class="baseline-step__textarea"
62
+ rows="4"
63
+ :required="textRequired"
64
+ />
65
+ </div>
66
+ </div>
67
+ </template>
68
+
69
+ <style scoped lang="scss">
70
+ .baseline-step {
71
+ padding: 16px 0;
72
+ border-bottom: 1px solid var(--button-secondary-default-bg, #2a2a2a);
73
+
74
+ &:last-child {
75
+ border-bottom: none;
76
+ }
77
+
78
+ &__label {
79
+ font-size: 15px;
80
+ font-weight: 700;
81
+ color: var(--card-article-title, #fff);
82
+ margin: 0 0 10px;
83
+ }
84
+
85
+ &__rating {
86
+ margin-bottom: 12px;
87
+ }
88
+
89
+ &__text-label {
90
+ display: block;
91
+ font-size: 12px;
92
+ color: var(--secondary-info-fg, #aaa);
93
+ margin-bottom: 6px;
94
+ }
95
+
96
+ &__textarea {
97
+ width: 100%;
98
+ background: var(--bg-app-badge, #1a1a1a);
99
+ border: 1px solid var(--button-secondary-default-bg, #2a2a2a);
100
+ color: var(--text-primary, #fff);
101
+ padding: 10px;
102
+ font-size: 13px;
103
+ resize: vertical;
104
+ }
105
+ }
106
+ </style>
@@ -0,0 +1,200 @@
1
+ <script setup lang="ts">
2
+ // One compliance checklist item's result row — status enum (only 3: no
3
+ // 'partial', unlike accessibility) + conditional notes (required when
4
+ // status==='fail', per SubmitComplianceResultRequest.php) + optional
5
+ // evidence_url (populated via POST /playtest/media beforehand). IMPORTANT:
6
+ // unlike accessibility's per-criterion upsert, the parent submit endpoint
7
+ // REPLACES the whole results array for the checklist on every call — this
8
+ // row only emits its own entry; the orchestrator/composable is responsible
9
+ // for merging it into the full accumulated set before calling submit.
10
+ import { ref, computed, watch } from "vue";
11
+ import type { ComplianceResultStatus, PlaytestComplianceChecklistItem } from "../../../services/playtestTesterService";
12
+
13
+ const props = defineProps<{
14
+ item: PlaytestComplianceChecklistItem;
15
+ uploading?: boolean;
16
+ }>();
17
+
18
+ const emit = defineEmits<{
19
+ (e: "change", payload: { key: string; status: ComplianceResultStatus; notes?: string | null; evidence_url?: string | null }): void
20
+ (e: "upload-evidence", file: File): void
21
+ }>();
22
+
23
+ const STATUSES: ComplianceResultStatus[] = ["pass", "fail", "not_applicable"];
24
+
25
+ const status = ref<ComplianceResultStatus | "">("");
26
+ const notes = ref("");
27
+ const evidenceUrl = ref<string | null>(null);
28
+
29
+ const notesRequired = computed(() => status.value === "fail");
30
+ const isComplete = computed(() => !!status.value && (!notesRequired.value || notes.value.trim().length > 0));
31
+
32
+ function emitChange() {
33
+ if (!status.value) return;
34
+ if (notesRequired.value && !notes.value.trim()) return;
35
+ emit("change", {
36
+ key: props.item.key,
37
+ status: status.value,
38
+ notes: notes.value || null,
39
+ evidence_url: evidenceUrl.value,
40
+ });
41
+ }
42
+
43
+ watch([status, notes, evidenceUrl], emitChange);
44
+
45
+ function onFileChange(e: Event) {
46
+ const file = (e.target as HTMLInputElement).files?.[0];
47
+ if (file) emit("upload-evidence", file);
48
+ }
49
+
50
+ function setEvidenceUrl(url: string | null) {
51
+ evidenceUrl.value = url;
52
+ }
53
+
54
+ defineExpose({ isComplete, setEvidenceUrl });
55
+ </script>
56
+
57
+ <template>
58
+ <div class="checklist-item-row">
59
+ <div class="checklist-item-row__header">
60
+ <h4 class="checklist-item-row__label">{{ item.label }}</h4>
61
+ <span v-if="item.severity" class="checklist-item-row__severity">{{ item.severity }}</span>
62
+ </div>
63
+
64
+ <p v-if="item.description" class="checklist-item-row__description">{{ item.description }}</p>
65
+
66
+ <div class="checklist-item-row__statuses">
67
+ <label
68
+ v-for="s in STATUSES"
69
+ :key="s"
70
+ class="checklist-item-row__status"
71
+ :class="{ 'checklist-item-row__status--selected': status === s }"
72
+ >
73
+ <input type="radio" :value="s" v-model="status" :name="`compliance-${item.key}`">
74
+ {{ s.replace('_', ' ') }}
75
+ </label>
76
+ </div>
77
+
78
+ <div v-if="notesRequired" class="checklist-item-row__notes">
79
+ <label class="checklist-item-row__notes-label">
80
+ {{ $t("playtest.compliance.notes_required", "Notes (required when failing)") }}
81
+ </label>
82
+ <textarea v-model="notes" rows="2" class="checklist-item-row__textarea" />
83
+ </div>
84
+
85
+ <div v-if="item.evidence_required || status === 'fail'" class="checklist-item-row__evidence">
86
+ <label class="checklist-item-row__evidence-label">
87
+ {{ $t("playtest.compliance.attach_evidence", "Attach evidence (screenshot/video)") }}
88
+ </label>
89
+ <input type="file" accept="image/*,video/mp4,video/quicktime,video/webm" @change="onFileChange">
90
+ <span v-if="uploading" class="checklist-item-row__uploading">{{ $t("playtest.compliance.uploading", "Uploading…") }}</span>
91
+ <span v-else-if="evidenceUrl" class="checklist-item-row__uploaded">{{ $t("playtest.compliance.uploaded", "Attached") }}</span>
92
+ </div>
93
+ </div>
94
+ </template>
95
+
96
+ <style scoped lang="scss">
97
+ .checklist-item-row {
98
+ padding: 14px 0;
99
+ border-bottom: 1px solid var(--button-secondary-default-bg, #2a2a2a);
100
+
101
+ &:last-child {
102
+ border-bottom: none;
103
+ }
104
+
105
+ &__header {
106
+ display: flex;
107
+ align-items: center;
108
+ justify-content: space-between;
109
+ margin-bottom: 6px;
110
+ }
111
+
112
+ &__label {
113
+ font-size: 14px;
114
+ font-weight: 700;
115
+ color: var(--card-article-title, #fff);
116
+ margin: 0;
117
+ }
118
+
119
+ &__severity {
120
+ font-size: 10px;
121
+ font-weight: 700;
122
+ text-transform: uppercase;
123
+ color: var(--secondary-info-fg, #aaa);
124
+ border: 1px solid var(--border-color, rgba(255, 255, 255, 0.2));
125
+ padding: 2px 8px;
126
+ }
127
+
128
+ &__description {
129
+ font-size: 12px;
130
+ color: var(--secondary-info-fg, #aaa);
131
+ margin: 0 0 10px;
132
+ }
133
+
134
+ &__statuses {
135
+ display: flex;
136
+ gap: 8px;
137
+ }
138
+
139
+ &__status {
140
+ display: flex;
141
+ align-items: center;
142
+ gap: 6px;
143
+ padding: 6px 12px;
144
+ background: var(--bg-app-badge, #1a1a1a);
145
+ border: 1px solid var(--button-secondary-default-bg, #2a2a2a);
146
+ cursor: pointer;
147
+ font-size: 12px;
148
+ text-transform: capitalize;
149
+ color: var(--text-primary, #fff);
150
+
151
+ &--selected {
152
+ border-color: var(--primary, #D297FF);
153
+ background: rgba(210, 151, 255, 0.08);
154
+ }
155
+
156
+ input {
157
+ accent-color: var(--primary, #D297FF);
158
+ }
159
+ }
160
+
161
+ &__notes,
162
+ &__evidence {
163
+ margin-top: 10px;
164
+ }
165
+
166
+ &__notes-label,
167
+ &__evidence-label {
168
+ display: block;
169
+ font-size: 11px;
170
+ color: var(--secondary-info-fg, #aaa);
171
+ margin-bottom: 6px;
172
+ }
173
+
174
+ &__notes-label {
175
+ color: var(--danger, #e53935);
176
+ }
177
+
178
+ &__textarea {
179
+ width: 100%;
180
+ background: var(--bg-app-badge, #1a1a1a);
181
+ border: 1px solid var(--button-secondary-default-bg, #2a2a2a);
182
+ color: var(--text-primary, #fff);
183
+ padding: 8px;
184
+ font-size: 12px;
185
+ resize: vertical;
186
+ }
187
+
188
+ &__uploading,
189
+ &__uploaded {
190
+ display: inline-block;
191
+ margin-left: 8px;
192
+ font-size: 11px;
193
+ color: var(--secondary-info-fg, #888);
194
+ }
195
+
196
+ &__uploaded {
197
+ color: var(--primary, #D297FF);
198
+ }
199
+ }
200
+ </style>