@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.
- 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/locales/de.json +1 -0
- package/locales/en.json +1 -0
- package/locales/pt-BR.json +1 -0
- package/locales/ro.json +1 -0
- package/package.json +10 -3
- package/pages/key-campaigns/redeem-key.vue +36 -5
- package/services/campaignService.ts +10 -0
- package/services/playtestTesterService.ts +416 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// poll_type=single_select — answer shape {option_id}, same as head_to_head
|
|
3
|
+
// but rendered as a list (arbitrary option count) rather than a 2-up compare.
|
|
4
|
+
import { ref, computed } from "vue";
|
|
5
|
+
import VoteReasonField from "./VoteReasonField.vue";
|
|
6
|
+
import type { PlaytestConceptPollOption } from "../../../services/playtestTesterService";
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
options: PlaytestConceptPollOption[];
|
|
10
|
+
submitting?: boolean;
|
|
11
|
+
}>();
|
|
12
|
+
|
|
13
|
+
const emit = defineEmits<{
|
|
14
|
+
(e: "submit", payload: { answer: { option_id: number }; reason: string }): void
|
|
15
|
+
}>();
|
|
16
|
+
|
|
17
|
+
const selected = ref<number | null>(null);
|
|
18
|
+
const reason = ref("");
|
|
19
|
+
|
|
20
|
+
const canSubmit = computed(() => selected.value !== null && reason.value.trim().length >= 3 && !props.submitting);
|
|
21
|
+
|
|
22
|
+
function submit() {
|
|
23
|
+
if (!canSubmit.value || selected.value === null) return;
|
|
24
|
+
emit("submit", { answer: { option_id: selected.value }, reason: reason.value.trim() });
|
|
25
|
+
}
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<div class="single-select-vote">
|
|
30
|
+
<div class="single-select-vote__options">
|
|
31
|
+
<label
|
|
32
|
+
v-for="opt in options"
|
|
33
|
+
:key="opt.id"
|
|
34
|
+
class="single-select-vote__option"
|
|
35
|
+
:class="{ 'single-select-vote__option--selected': selected === opt.id }"
|
|
36
|
+
>
|
|
37
|
+
<input type="radio" :value="opt.id" v-model="selected" name="single-select-option">
|
|
38
|
+
<img v-if="opt.media_url" :src="opt.media_url" :alt="opt.label" class="single-select-vote__media">
|
|
39
|
+
<span class="single-select-vote__label">{{ opt.label }}</span>
|
|
40
|
+
</label>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<VoteReasonField v-model="reason" />
|
|
44
|
+
|
|
45
|
+
<button type="button" class="btn primary single-select-vote__submit" :disabled="!canSubmit" @click="submit">
|
|
46
|
+
{{ $t("playtest.concept_poll.submit_vote", "Submit vote") }}
|
|
47
|
+
</button>
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
50
|
+
|
|
51
|
+
<style scoped lang="scss">
|
|
52
|
+
.single-select-vote {
|
|
53
|
+
&__options {
|
|
54
|
+
display: flex;
|
|
55
|
+
flex-direction: column;
|
|
56
|
+
gap: 8px;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
&__option {
|
|
60
|
+
display: flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
gap: 10px;
|
|
63
|
+
padding: 10px 14px;
|
|
64
|
+
background: var(--bg-app-badge, #1a1a1a);
|
|
65
|
+
border: 2px solid var(--button-secondary-default-bg, #2a2a2a);
|
|
66
|
+
cursor: pointer;
|
|
67
|
+
color: var(--text-primary, #fff);
|
|
68
|
+
transition: border-color 0.15s;
|
|
69
|
+
|
|
70
|
+
&:hover {
|
|
71
|
+
border-color: var(--primary, #D297FF);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
&--selected {
|
|
75
|
+
border-color: var(--primary, #D297FF);
|
|
76
|
+
background: rgba(210, 151, 255, 0.08);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
input {
|
|
80
|
+
accent-color: var(--primary, #D297FF);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
&__media {
|
|
85
|
+
width: 48px;
|
|
86
|
+
height: 48px;
|
|
87
|
+
object-fit: cover;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
&__label {
|
|
91
|
+
font-size: 14px;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
&__submit {
|
|
95
|
+
margin-top: 16px;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
</style>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// poll_type=star_rating — answer shape {option_id, stars} (stars 1-5).
|
|
3
|
+
// Reuses the shared StarRatingInput.vue rather than reimplementing stars.
|
|
4
|
+
import { ref, computed } from "vue";
|
|
5
|
+
import StarRatingInput from "../StarRatingInput.vue";
|
|
6
|
+
import VoteReasonField from "./VoteReasonField.vue";
|
|
7
|
+
import type { PlaytestConceptPollOption } from "../../../services/playtestTesterService";
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
options: PlaytestConceptPollOption[];
|
|
11
|
+
submitting?: boolean;
|
|
12
|
+
}>();
|
|
13
|
+
|
|
14
|
+
const emit = defineEmits<{
|
|
15
|
+
(e: "submit", payload: { answer: { option_id: number; stars: number }; reason: string }): void
|
|
16
|
+
}>();
|
|
17
|
+
|
|
18
|
+
const selected = ref<number | null>(null);
|
|
19
|
+
const stars = ref<number | null>(null);
|
|
20
|
+
const reason = ref("");
|
|
21
|
+
|
|
22
|
+
const canSubmit = computed(() =>
|
|
23
|
+
selected.value !== null && !!stars.value && reason.value.trim().length >= 3 && !props.submitting
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
function submit() {
|
|
27
|
+
if (!canSubmit.value || selected.value === null || !stars.value) return;
|
|
28
|
+
emit("submit", { answer: { option_id: selected.value, stars: stars.value }, reason: reason.value.trim() });
|
|
29
|
+
}
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<div class="star-vote">
|
|
34
|
+
<div class="star-vote__options">
|
|
35
|
+
<label
|
|
36
|
+
v-for="opt in options"
|
|
37
|
+
:key="opt.id"
|
|
38
|
+
class="star-vote__option"
|
|
39
|
+
:class="{ 'star-vote__option--selected': selected === opt.id }"
|
|
40
|
+
>
|
|
41
|
+
<input type="radio" :value="opt.id" v-model="selected" name="star-vote-option">
|
|
42
|
+
<img v-if="opt.media_url" :src="opt.media_url" :alt="opt.label" class="star-vote__media">
|
|
43
|
+
<span>{{ opt.label }}</span>
|
|
44
|
+
</label>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div v-if="selected !== null" class="star-vote__rating">
|
|
48
|
+
<span class="star-vote__rating-label">{{ $t("playtest.concept_poll.stars_label", "Your rating") }}</span>
|
|
49
|
+
<StarRatingInput v-model="stars as any" />
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<VoteReasonField v-model="reason" />
|
|
53
|
+
|
|
54
|
+
<button type="button" class="btn primary star-vote__submit" :disabled="!canSubmit" @click="submit">
|
|
55
|
+
{{ $t("playtest.concept_poll.submit_vote", "Submit vote") }}
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
</template>
|
|
59
|
+
|
|
60
|
+
<style scoped lang="scss">
|
|
61
|
+
.star-vote {
|
|
62
|
+
&__options {
|
|
63
|
+
display: flex;
|
|
64
|
+
flex-direction: column;
|
|
65
|
+
gap: 8px;
|
|
66
|
+
margin-bottom: 16px;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
&__option {
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
gap: 10px;
|
|
73
|
+
padding: 10px 14px;
|
|
74
|
+
background: var(--bg-app-badge, #1a1a1a);
|
|
75
|
+
border: 2px solid var(--button-secondary-default-bg, #2a2a2a);
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
color: var(--text-primary, #fff);
|
|
78
|
+
|
|
79
|
+
&--selected {
|
|
80
|
+
border-color: var(--primary, #D297FF);
|
|
81
|
+
background: rgba(210, 151, 255, 0.08);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
input {
|
|
85
|
+
accent-color: var(--primary, #D297FF);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
&__media {
|
|
90
|
+
width: 40px;
|
|
91
|
+
height: 40px;
|
|
92
|
+
object-fit: cover;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
&__rating {
|
|
96
|
+
margin-bottom: 16px;
|
|
97
|
+
|
|
98
|
+
&-label {
|
|
99
|
+
display: block;
|
|
100
|
+
font-size: 12px;
|
|
101
|
+
color: var(--secondary-info-fg, #aaa);
|
|
102
|
+
margin-bottom: 6px;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
&__submit {
|
|
107
|
+
margin-top: 16px;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
</style>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// `reason` is UNCONDITIONALLY required on every concept-poll vote, regardless
|
|
3
|
+
// of poll_type — verified against VoteConceptPollRequest.php this session
|
|
4
|
+
// (min:3 chars, max:2000). Every *Vote.vue component mounts this at the
|
|
5
|
+
// bottom of its own template — not conditionally.
|
|
6
|
+
import { computed } from "vue";
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
modelValue: string;
|
|
10
|
+
}>();
|
|
11
|
+
|
|
12
|
+
const emit = defineEmits<{
|
|
13
|
+
(e: "update:modelValue", value: string): void
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
const value = computed({
|
|
17
|
+
get: () => props.modelValue,
|
|
18
|
+
set: (v: string) => emit("update:modelValue", v),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const isValid = computed(() => value.value.trim().length >= 3);
|
|
22
|
+
|
|
23
|
+
defineExpose({ isValid });
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<div class="vote-reason">
|
|
28
|
+
<label for="vote-reason-input" class="vote-reason__label">
|
|
29
|
+
{{ $t("playtest.concept_poll.reason_label", "Why did you choose this? (required)") }}
|
|
30
|
+
</label>
|
|
31
|
+
<textarea
|
|
32
|
+
id="vote-reason-input"
|
|
33
|
+
v-model="value"
|
|
34
|
+
class="vote-reason__textarea"
|
|
35
|
+
rows="3"
|
|
36
|
+
minlength="3"
|
|
37
|
+
maxlength="2000"
|
|
38
|
+
required
|
|
39
|
+
/>
|
|
40
|
+
<span v-if="value.length > 0 && !isValid" class="vote-reason__hint">
|
|
41
|
+
{{ $t("playtest.concept_poll.reason_min_length", "Please write at least 3 characters.") }}
|
|
42
|
+
</span>
|
|
43
|
+
</div>
|
|
44
|
+
</template>
|
|
45
|
+
|
|
46
|
+
<style scoped lang="scss">
|
|
47
|
+
.vote-reason {
|
|
48
|
+
margin-top: 16px;
|
|
49
|
+
|
|
50
|
+
&__label {
|
|
51
|
+
display: block;
|
|
52
|
+
font-size: 12px;
|
|
53
|
+
color: var(--secondary-info-fg, #aaa);
|
|
54
|
+
margin-bottom: 6px;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
&__textarea {
|
|
58
|
+
width: 100%;
|
|
59
|
+
background: var(--bg-app-badge, #1a1a1a);
|
|
60
|
+
border: 1px solid var(--button-secondary-default-bg, #2a2a2a);
|
|
61
|
+
color: var(--text-primary, #fff);
|
|
62
|
+
padding: 10px;
|
|
63
|
+
font-size: 13px;
|
|
64
|
+
resize: vertical;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
&__hint {
|
|
68
|
+
display: block;
|
|
69
|
+
font-size: 11px;
|
|
70
|
+
color: var(--danger, #e53935);
|
|
71
|
+
margin-top: 4px;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
</style>
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Files one localization defect — issue_type/severity enums verified against
|
|
3
|
+
// StoreLocalizationIssueRequest.php this session. media_url is optional and
|
|
4
|
+
// populated via POST /playtest/media (uploadPlaytestMedia) BEFORE this form
|
|
5
|
+
// submits — the parent (usePlaytestResponse's uploadMedia) owns the actual
|
|
6
|
+
// upload call; this component just emits the file and displays progress.
|
|
7
|
+
import { ref, computed } from "vue";
|
|
8
|
+
import type { LocalizationIssueType, LocalizationIssueSeverity, StoreLocalizationIssuePayload } from "../../../services/playtestTesterService";
|
|
9
|
+
|
|
10
|
+
const props = defineProps<{
|
|
11
|
+
campaignLocaleId: number | null;
|
|
12
|
+
submitting?: boolean;
|
|
13
|
+
uploading?: boolean;
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
const emit = defineEmits<{
|
|
17
|
+
(e: "submit", payload: StoreLocalizationIssuePayload): void
|
|
18
|
+
(e: "upload-media", file: File): void
|
|
19
|
+
}>();
|
|
20
|
+
|
|
21
|
+
const ISSUE_TYPES: LocalizationIssueType[] = [
|
|
22
|
+
"mistranslation", "text_truncation", "missing_string", "cultural_inappropriateness",
|
|
23
|
+
"audio_subtitle_desync", "untranslated_string", "grammar_punctuation", "inconsistent_terminology",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const SEVERITIES: LocalizationIssueSeverity[] = ["blocker", "critical", "major", "minor", "cosmetic"];
|
|
27
|
+
|
|
28
|
+
const issueType = ref<LocalizationIssueType | "">("");
|
|
29
|
+
const severity = ref<LocalizationIssueSeverity | "">("");
|
|
30
|
+
const location = ref("");
|
|
31
|
+
const description = ref("");
|
|
32
|
+
const expectedText = ref("");
|
|
33
|
+
const actualText = ref("");
|
|
34
|
+
const mediaUrl = ref<string | null>(null);
|
|
35
|
+
|
|
36
|
+
const canSubmit = computed(() =>
|
|
37
|
+
!!props.campaignLocaleId &&
|
|
38
|
+
!!issueType.value &&
|
|
39
|
+
!!severity.value &&
|
|
40
|
+
description.value.trim().length > 0 &&
|
|
41
|
+
!props.submitting
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
function onFileChange(e: Event) {
|
|
45
|
+
const file = (e.target as HTMLInputElement).files?.[0];
|
|
46
|
+
if (file) emit("upload-media", file);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function setMediaUrl(url: string | null) {
|
|
50
|
+
mediaUrl.value = url;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
defineExpose({ setMediaUrl });
|
|
54
|
+
|
|
55
|
+
function submit() {
|
|
56
|
+
if (!canSubmit.value || !props.campaignLocaleId || !issueType.value || !severity.value) return;
|
|
57
|
+
emit("submit", {
|
|
58
|
+
campaign_locale_id: props.campaignLocaleId,
|
|
59
|
+
issue_type: issueType.value,
|
|
60
|
+
severity: severity.value,
|
|
61
|
+
location: location.value || null,
|
|
62
|
+
description: description.value.trim(),
|
|
63
|
+
expected_text: expectedText.value || null,
|
|
64
|
+
actual_text: actualText.value || null,
|
|
65
|
+
media_url: mediaUrl.value,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// reset form for the next issue
|
|
69
|
+
issueType.value = "";
|
|
70
|
+
severity.value = "";
|
|
71
|
+
location.value = "";
|
|
72
|
+
description.value = "";
|
|
73
|
+
expectedText.value = "";
|
|
74
|
+
actualText.value = "";
|
|
75
|
+
mediaUrl.value = null;
|
|
76
|
+
}
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<template>
|
|
80
|
+
<div class="file-issue-form">
|
|
81
|
+
<div class="file-issue-form__row">
|
|
82
|
+
<label class="file-issue-form__label">{{ $t("playtest.lqa.issue_type", "Issue type") }}</label>
|
|
83
|
+
<select v-model="issueType" class="file-issue-form__select">
|
|
84
|
+
<option value="" disabled>{{ $t("playtest.lqa.select_type_placeholder", "Select…") }}</option>
|
|
85
|
+
<option v-for="t in ISSUE_TYPES" :key="t" :value="t">{{ t }}</option>
|
|
86
|
+
</select>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div class="file-issue-form__row">
|
|
90
|
+
<label class="file-issue-form__label">{{ $t("playtest.lqa.severity", "Severity") }}</label>
|
|
91
|
+
<select v-model="severity" class="file-issue-form__select">
|
|
92
|
+
<option value="" disabled>{{ $t("playtest.lqa.select_severity_placeholder", "Select…") }}</option>
|
|
93
|
+
<option v-for="s in SEVERITIES" :key="s" :value="s">{{ s }}</option>
|
|
94
|
+
</select>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div class="file-issue-form__row">
|
|
98
|
+
<label class="file-issue-form__label">{{ $t("playtest.lqa.location", "Location (screen/scene, optional)") }}</label>
|
|
99
|
+
<input v-model="location" type="text" class="file-issue-form__input">
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<div class="file-issue-form__row">
|
|
103
|
+
<label class="file-issue-form__label">{{ $t("playtest.lqa.description", "Description (required)") }}</label>
|
|
104
|
+
<textarea v-model="description" rows="3" class="file-issue-form__textarea" />
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div class="file-issue-form__row-pair">
|
|
108
|
+
<div class="file-issue-form__row">
|
|
109
|
+
<label class="file-issue-form__label">{{ $t("playtest.lqa.expected_text", "Expected text") }}</label>
|
|
110
|
+
<textarea v-model="expectedText" rows="2" class="file-issue-form__textarea" />
|
|
111
|
+
</div>
|
|
112
|
+
<div class="file-issue-form__row">
|
|
113
|
+
<label class="file-issue-form__label">{{ $t("playtest.lqa.actual_text", "Actual text") }}</label>
|
|
114
|
+
<textarea v-model="actualText" rows="2" class="file-issue-form__textarea" />
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div class="file-issue-form__row">
|
|
119
|
+
<label class="file-issue-form__label">{{ $t("playtest.lqa.attach_screenshot", "Attach screenshot/video (optional)") }}</label>
|
|
120
|
+
<input type="file" accept="image/*,video/mp4,video/quicktime,video/webm" @change="onFileChange">
|
|
121
|
+
<span v-if="uploading" class="file-issue-form__uploading">{{ $t("playtest.lqa.uploading", "Uploading…") }}</span>
|
|
122
|
+
<span v-else-if="mediaUrl" class="file-issue-form__uploaded">{{ $t("playtest.lqa.uploaded", "Attached") }}</span>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<button type="button" class="btn primary file-issue-form__submit" :disabled="!canSubmit" @click="submit">
|
|
126
|
+
{{ $t("playtest.lqa.file_issue", "File issue") }}
|
|
127
|
+
</button>
|
|
128
|
+
</div>
|
|
129
|
+
</template>
|
|
130
|
+
|
|
131
|
+
<style scoped lang="scss">
|
|
132
|
+
.file-issue-form {
|
|
133
|
+
&__row {
|
|
134
|
+
margin-bottom: 14px;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
&__row-pair {
|
|
138
|
+
display: grid;
|
|
139
|
+
grid-template-columns: 1fr 1fr;
|
|
140
|
+
gap: 12px;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
&__label {
|
|
144
|
+
display: block;
|
|
145
|
+
font-size: 12px;
|
|
146
|
+
color: var(--secondary-info-fg, #aaa);
|
|
147
|
+
margin-bottom: 6px;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
&__select,
|
|
151
|
+
&__input,
|
|
152
|
+
&__textarea {
|
|
153
|
+
width: 100%;
|
|
154
|
+
background: var(--bg-app-badge, #1a1a1a);
|
|
155
|
+
border: 1px solid var(--button-secondary-default-bg, #2a2a2a);
|
|
156
|
+
color: var(--text-primary, #fff);
|
|
157
|
+
padding: 10px;
|
|
158
|
+
font-size: 13px;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
&__textarea {
|
|
162
|
+
resize: vertical;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
&__uploading,
|
|
166
|
+
&__uploaded {
|
|
167
|
+
display: inline-block;
|
|
168
|
+
margin-left: 8px;
|
|
169
|
+
font-size: 11px;
|
|
170
|
+
color: var(--secondary-info-fg, #888);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
&__uploaded {
|
|
174
|
+
color: var(--primary, #D297FF);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
&__submit {
|
|
178
|
+
margin-top: 4px;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
</style>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Read-only list of localization issues the tester has already filed this
|
|
3
|
+
// session — filedIssues from usePlaytestResponse (client-accumulated, since
|
|
4
|
+
// there's no per-tester "my issues" filter on the backend's indexIssues()).
|
|
5
|
+
import type { PlaytestLocalizationIssue } from "../../../services/playtestTesterService";
|
|
6
|
+
|
|
7
|
+
defineProps<{
|
|
8
|
+
issues: PlaytestLocalizationIssue[];
|
|
9
|
+
}>();
|
|
10
|
+
|
|
11
|
+
const SEVERITY_COLOR: Record<string, string> = {
|
|
12
|
+
blocker: "#e53935",
|
|
13
|
+
critical: "#e5793d",
|
|
14
|
+
major: "#e5c53d",
|
|
15
|
+
minor: "#8ac53d",
|
|
16
|
+
cosmetic: "#3d8ae5",
|
|
17
|
+
};
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<div class="issue-list">
|
|
22
|
+
<h4 class="issue-list__title">
|
|
23
|
+
{{ $t("playtest.lqa.filed_issues", "Issues filed this session") }} ({{ issues.length }})
|
|
24
|
+
</h4>
|
|
25
|
+
|
|
26
|
+
<p v-if="issues.length === 0" class="issue-list__empty">
|
|
27
|
+
{{ $t("playtest.lqa.no_issues_yet", "No issues filed yet.") }}
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
<ul v-else class="issue-list__items">
|
|
31
|
+
<li v-for="issue in issues" :key="issue.id" class="issue-list__item">
|
|
32
|
+
<span
|
|
33
|
+
class="issue-list__severity"
|
|
34
|
+
:style="{ color: SEVERITY_COLOR[issue.severity] ?? '#888' }"
|
|
35
|
+
>
|
|
36
|
+
{{ issue.severity }}
|
|
37
|
+
</span>
|
|
38
|
+
<span class="issue-list__type">{{ issue.issue_type }}</span>
|
|
39
|
+
<span class="issue-list__desc">{{ issue.description }}</span>
|
|
40
|
+
</li>
|
|
41
|
+
</ul>
|
|
42
|
+
</div>
|
|
43
|
+
</template>
|
|
44
|
+
|
|
45
|
+
<style scoped lang="scss">
|
|
46
|
+
.issue-list {
|
|
47
|
+
margin-top: 24px;
|
|
48
|
+
|
|
49
|
+
&__title {
|
|
50
|
+
font-size: 13px;
|
|
51
|
+
font-weight: 700;
|
|
52
|
+
color: var(--card-article-title, #fff);
|
|
53
|
+
margin: 0 0 10px;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
&__empty {
|
|
57
|
+
font-size: 12px;
|
|
58
|
+
color: var(--secondary-info-fg, #888);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
&__items {
|
|
62
|
+
list-style: none;
|
|
63
|
+
margin: 0;
|
|
64
|
+
padding: 0;
|
|
65
|
+
display: flex;
|
|
66
|
+
flex-direction: column;
|
|
67
|
+
gap: 6px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
&__item {
|
|
71
|
+
display: flex;
|
|
72
|
+
align-items: baseline;
|
|
73
|
+
gap: 8px;
|
|
74
|
+
padding: 8px 10px;
|
|
75
|
+
background: var(--bg-app-badge, #1a1a1a);
|
|
76
|
+
border: 1px solid var(--button-secondary-default-bg, #2a2a2a);
|
|
77
|
+
font-size: 12px;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
&__severity {
|
|
81
|
+
font-weight: 700;
|
|
82
|
+
text-transform: uppercase;
|
|
83
|
+
font-size: 10px;
|
|
84
|
+
flex-shrink: 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
&__type {
|
|
88
|
+
color: var(--secondary-info-fg, #aaa);
|
|
89
|
+
flex-shrink: 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
&__desc {
|
|
93
|
+
color: var(--text-primary, #fff);
|
|
94
|
+
overflow: hidden;
|
|
95
|
+
text-overflow: ellipsis;
|
|
96
|
+
white-space: nowrap;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
</style>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Locale/build picker for a localization_qa campaign — GET
|
|
3
|
+
// /playtest/campaigns/{id}/locales (indexLocales controller action).
|
|
4
|
+
import { computed } from "vue";
|
|
5
|
+
import type { PlaytestCampaignLocale } from "../../../services/playtestTesterService";
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
locales: PlaytestCampaignLocale[];
|
|
9
|
+
modelValue: number | null;
|
|
10
|
+
}>();
|
|
11
|
+
|
|
12
|
+
const emit = defineEmits<{
|
|
13
|
+
(e: "update:modelValue", value: number | null): void
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
const selected = computed({
|
|
17
|
+
get: () => props.modelValue,
|
|
18
|
+
set: (v: number | null) => emit("update:modelValue", v),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function remaining(locale: PlaytestCampaignLocale): number {
|
|
22
|
+
return Math.max(0, locale.responses_target - locale.responses_received);
|
|
23
|
+
}
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<div class="locale-selector">
|
|
28
|
+
<label for="locale-select" class="locale-selector__label">
|
|
29
|
+
{{ $t("playtest.lqa.select_locale", "Locale / build in scope") }}
|
|
30
|
+
</label>
|
|
31
|
+
<select id="locale-select" v-model="selected" class="locale-selector__select">
|
|
32
|
+
<option :value="null" disabled>{{ $t("playtest.lqa.select_locale_placeholder", "Select a locale…") }}</option>
|
|
33
|
+
<option v-for="locale in locales" :key="locale.id" :value="locale.id">
|
|
34
|
+
{{ locale.locale }}
|
|
35
|
+
<template v-if="locale.build_reference"> — {{ locale.build_reference }}</template>
|
|
36
|
+
</option>
|
|
37
|
+
</select>
|
|
38
|
+
|
|
39
|
+
<div v-if="selected" class="locale-selector__meta">
|
|
40
|
+
{{ $t("playtest.lqa.responses_progress", "Responses so far") }}:
|
|
41
|
+
{{ locales.find((l) => l.id === selected)?.responses_received ?? 0 }}
|
|
42
|
+
/ {{ locales.find((l) => l.id === selected)?.responses_target ?? 0 }}
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</template>
|
|
46
|
+
|
|
47
|
+
<style scoped lang="scss">
|
|
48
|
+
.locale-selector {
|
|
49
|
+
margin-bottom: 20px;
|
|
50
|
+
|
|
51
|
+
&__label {
|
|
52
|
+
display: block;
|
|
53
|
+
font-size: 12px;
|
|
54
|
+
color: var(--secondary-info-fg, #aaa);
|
|
55
|
+
margin-bottom: 6px;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
&__select {
|
|
59
|
+
width: 100%;
|
|
60
|
+
background: var(--bg-app-badge, #1a1a1a);
|
|
61
|
+
border: 1px solid var(--button-secondary-default-bg, #2a2a2a);
|
|
62
|
+
color: var(--text-primary, #fff);
|
|
63
|
+
padding: 10px;
|
|
64
|
+
font-size: 13px;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
&__meta {
|
|
68
|
+
font-size: 11px;
|
|
69
|
+
color: var(--secondary-info-fg, #888);
|
|
70
|
+
margin-top: 6px;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
</style>
|