@mundogamernetwork/shared-ui 1.4.2 → 1.5.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.
@@ -48,6 +48,7 @@ function gatewayKey(m: { id: number; name: string }): string {
48
48
  const n = (m.name ?? "").trim().toLowerCase();
49
49
  if (n === "paypal" || m.id === 2) return "paypal";
50
50
  if (n === "stripe" || m.id === 3) return "stripe";
51
+ if (n === "pix" || m.id === 4) return "pix";
51
52
  return n;
52
53
  }
53
54
 
@@ -60,11 +61,12 @@ function getMethodId(m: { id: number; name: string }): number {
60
61
  const key = gatewayKey(m);
61
62
  if (key === "paypal") return 2;
62
63
  if (key === "stripe") return 3;
64
+ if (key === "pix") return 4;
63
65
  return m.id;
64
66
  }
65
67
 
66
68
  function getLabel(m: { id: number; name: string }): string {
67
- const labels: Record<string, string> = { paypal: "PayPal", stripe: "Stripe" };
69
+ const labels: Record<string, string> = { paypal: "PayPal", stripe: "Stripe", pix: "Pix" };
68
70
  return labels[gatewayKey(m)] ?? m.name;
69
71
  }
70
72
  </script>
@@ -149,7 +149,7 @@ function handleRequestKey(pool: KeyPool) {
149
149
 
150
150
  async function _checkNotifPrompt() {
151
151
  try {
152
- const res = await httpService.get('/user/notification-preferences', {
152
+ const res = await httpService.get('/public/user/notification-preferences', {
153
153
  params: { category: 'key_campaigns' },
154
154
  })
155
155
  if (res.data?.data === null) {
@@ -163,7 +163,7 @@ async function _checkNotifPrompt() {
163
163
  async function enableNotifAlerts() {
164
164
  notifSaving.value = true
165
165
  try {
166
- await httpService.put('/user/notification-preferences', {
166
+ await httpService.put('/public/user/notification-preferences', {
167
167
  category: 'key_campaigns',
168
168
  enabled: true,
169
169
  channel_email: true,
@@ -39,8 +39,9 @@ import CriterionRow from "./accessibility/CriterionRow.vue";
39
39
  import ChecklistItemRow from "./compliance/ChecklistItemRow.vue";
40
40
  import SessionCard from "./moderated/SessionCard.vue";
41
41
  import JoinCallPanel from "./moderated/JoinCallPanel.vue";
42
+ import VideoPlaytestCapture from "./video/VideoPlaytestCapture.vue";
42
43
 
43
- type PlaytestCategory = "baseline" | "concept_poll" | "localization_qa" | "accessibility_audit" | "compliance" | "moderated";
44
+ type PlaytestCategory = "baseline" | "concept_poll" | "localization_qa" | "accessibility_audit" | "compliance" | "moderated" | "video_playtest";
44
45
 
45
46
  const props = defineProps<{
46
47
  campaignId: number;
@@ -306,6 +307,11 @@ async function cancelSessionFlow() {
306
307
  </button>
307
308
  </div>
308
309
 
310
+ <!-- ── Video Playtest ── -->
311
+ <div v-else-if="category === 'video_playtest'">
312
+ <VideoPlaytestCapture :campaign-id="campaignId" @submitted="handleDone" />
313
+ </div>
314
+
309
315
  <!-- ── Moderated ── -->
310
316
  <div v-else-if="category === 'moderated'">
311
317
  <JoinCallPanel
@@ -0,0 +1,80 @@
1
+ <script setup lang="ts">
2
+ // Single orchestrator for an accepted response's game-access grant — the
3
+ // component both agency-frontend (mirrored /testing section) and
4
+ // community-frontend (My Tests) actually mount, one per accepted response.
5
+ // Mirrors PlaytestResponseFlow.vue's role for the submission side: owns the
6
+ // composable instance, dispatches to AccessGrantWaiver (not yet signed) or
7
+ // AccessGrantReveal (signed), and renders nothing if the response has no
8
+ // configured access grant.
9
+ import { onMounted } from "vue";
10
+ import { usePlaytestAccessGrant } from "../../../composables/usePlaytestAccessGrant";
11
+ import AccessGrantWaiver from "./AccessGrantWaiver.vue";
12
+ import AccessGrantReveal from "./AccessGrantReveal.vue";
13
+
14
+ const props = defineProps<{
15
+ responseId: number;
16
+ }>();
17
+
18
+ const grantState = usePlaytestAccessGrant(props.responseId);
19
+
20
+ onMounted(() => {
21
+ grantState.loadAccessGrant();
22
+ });
23
+
24
+ function sendCode() {
25
+ grantState.initiateWaiver();
26
+ }
27
+
28
+ function verifyCode(otpCode: string) {
29
+ grantState.verifyWaiver(otpCode);
30
+ }
31
+ </script>
32
+
33
+ <template>
34
+ <div v-if="grantState.loading.value" class="access-grant-panel access-grant-panel--loading">
35
+ <div class="access-grant-panel__skeleton" />
36
+ </div>
37
+
38
+ <div v-else-if="grantState.grant.value" class="access-grant-panel">
39
+ <AccessGrantWaiver
40
+ v-if="!grantState.grant.value.revealed"
41
+ :submitting="grantState.submitting.value"
42
+ :waiver-initiated="grantState.waiverInitiated.value"
43
+ :error="grantState.genericError.value"
44
+ @send-code="sendCode"
45
+ @verify="verifyCode"
46
+ />
47
+ <AccessGrantReveal
48
+ v-else
49
+ :access-type="grantState.grant.value.access_type"
50
+ :key-code="grantState.grant.value.key_code"
51
+ :external-link="grantState.grant.value.external_link"
52
+ />
53
+ </div>
54
+ </template>
55
+
56
+ <style scoped lang="scss">
57
+ .access-grant-panel {
58
+ padding: 16px;
59
+ background: var(--bg-app-badge);
60
+ border: 1px solid var(--button-secondary-default-bg);
61
+
62
+ &--loading {
63
+ padding: 0;
64
+ border: none;
65
+ background: none;
66
+ }
67
+
68
+ &__skeleton {
69
+ width: 100%;
70
+ height: 64px;
71
+ background: var(--bg-app-badge);
72
+ animation: access-grant-panel-pulse 1.4s ease-in-out infinite;
73
+ }
74
+ }
75
+
76
+ @keyframes access-grant-panel-pulse {
77
+ 0%, 100% { opacity: 1; }
78
+ 50% { opacity: 0.4; }
79
+ }
80
+ </style>
@@ -0,0 +1,132 @@
1
+ <script setup lang="ts">
2
+ // Masked-reveal-then-copy for a signed access grant. Unlike the key-campaigns
3
+ // redeem flow, the real value already arrived in the API response the moment
4
+ // the waiver was verified — "reveal" here is a deliberate client-side
5
+ // unmask, not a second network round-trip. Mirrors
6
+ // pages/key-campaigns/redeem-key-approved.vue's code-box UI.
7
+ import { ref } from "vue";
8
+
9
+ const props = defineProps<{
10
+ accessType: "key" | "link";
11
+ keyCode?: string | null;
12
+ externalLink?: string | null;
13
+ }>();
14
+
15
+ const unmasked = ref(false);
16
+ const copied = ref(false);
17
+
18
+ const value = props.accessType === "key" ? (props.keyCode ?? "") : (props.externalLink ?? "");
19
+
20
+ async function copyValue() {
21
+ if (!value) return;
22
+ try {
23
+ await navigator.clipboard.writeText(value);
24
+ copied.value = true;
25
+ setTimeout(() => { copied.value = false; }, 2000);
26
+ } catch {
27
+ // clipboard API unavailable — the value is still selectable in the input
28
+ }
29
+ }
30
+ </script>
31
+
32
+ <template>
33
+ <div class="access-grant-reveal">
34
+ <h3 class="access-grant-reveal__title">
35
+ {{ accessType === "key"
36
+ ? $t("playtest.access_grant.your_key", "Your game key")
37
+ : $t("playtest.access_grant.your_link", "Your download link") }}
38
+ </h3>
39
+
40
+ <div class="access-grant-reveal__box">
41
+ <input
42
+ v-if="!unmasked"
43
+ type="text"
44
+ value="••••••••••••••••"
45
+ readonly
46
+ class="access-grant-reveal__input access-grant-reveal__input--masked"
47
+ />
48
+ <input
49
+ v-else
50
+ type="text"
51
+ :value="value"
52
+ readonly
53
+ class="access-grant-reveal__input"
54
+ />
55
+
56
+ <button
57
+ v-if="!unmasked"
58
+ type="button"
59
+ class="btn primary"
60
+ @click="unmasked = true"
61
+ >
62
+ {{ $t("playtest.access_grant.reveal", "Reveal") }}
63
+ </button>
64
+ <button
65
+ v-else
66
+ type="button"
67
+ class="btn primary"
68
+ @click="copyValue"
69
+ >
70
+ <MGIcon :icon="copied ? 'check' : 'copy'" size="1x" />
71
+ {{ copied ? $t("playtest.access_grant.copied", "Copied") : $t("playtest.access_grant.copy", "Copy") }}
72
+ </button>
73
+ </div>
74
+
75
+ <a
76
+ v-if="unmasked && accessType === 'link'"
77
+ :href="value"
78
+ target="_blank"
79
+ rel="noopener"
80
+ class="access-grant-reveal__open-link"
81
+ >
82
+ {{ $t("playtest.access_grant.open_link", "Open link") }}
83
+ </a>
84
+ </div>
85
+ </template>
86
+
87
+ <style scoped lang="scss">
88
+ .access-grant-reveal {
89
+ display: flex;
90
+ flex-direction: column;
91
+ gap: 10px;
92
+
93
+ &__title {
94
+ font-size: 15px;
95
+ font-weight: 700;
96
+ color: var(--card-article-title, #fff);
97
+ margin: 0;
98
+ }
99
+
100
+ &__box {
101
+ display: flex;
102
+ align-items: center;
103
+ gap: 6px;
104
+ }
105
+
106
+ &__input {
107
+ display: flex;
108
+ padding: 12px;
109
+ background-color: transparent;
110
+ height: 44px;
111
+ border: 1px solid var(--bg-app-badge);
112
+ width: 100%;
113
+ font-size: 14px;
114
+ color: var(--card-article-title, #fff);
115
+
116
+ &--masked {
117
+ letter-spacing: 3px;
118
+ color: rgba(128, 128, 128, 0.5);
119
+ }
120
+ }
121
+
122
+ &__open-link {
123
+ align-self: flex-start;
124
+ font-size: 12px;
125
+ color: var(--primary, #d297ff);
126
+
127
+ &:hover {
128
+ text-decoration: underline;
129
+ }
130
+ }
131
+ }
132
+ </style>
@@ -0,0 +1,170 @@
1
+ <script setup lang="ts">
2
+ // Liability-waiver signing step, gating an accepted response's game-access
3
+ // grant. Two-step UI: request the OTP code (sent to the tester's account
4
+ // email via the shared Documents module), then submit it. Purely
5
+ // presentational — AccessGrantPanel.vue owns the actual API calls.
6
+ import { ref } from "vue";
7
+
8
+ defineProps<{
9
+ submitting: boolean;
10
+ waiverInitiated: boolean;
11
+ error?: string | null;
12
+ }>();
13
+
14
+ const emit = defineEmits<{
15
+ (e: "send-code"): void;
16
+ (e: "verify", otpCode: string): void;
17
+ }>();
18
+
19
+ const otpCode = ref("");
20
+
21
+ function submitCode() {
22
+ if (!otpCode.value) return;
23
+ emit("verify", otpCode.value);
24
+ }
25
+ </script>
26
+
27
+ <template>
28
+ <div class="access-grant-waiver">
29
+ <h3 class="access-grant-waiver__title">
30
+ {{ $t("playtest.access_grant.waiver_title", "Sign the liability waiver to unlock your game access") }}
31
+ </h3>
32
+ <p class="access-grant-waiver__body">
33
+ {{ $t(
34
+ "playtest.access_grant.waiver_body",
35
+ "The studio requires you to accept a liability waiver before revealing your key or download link. We'll send a verification code to your account email to confirm it's really you."
36
+ ) }}
37
+ </p>
38
+
39
+ <p v-if="error" class="access-grant-waiver__error">{{ error }}</p>
40
+
41
+ <div v-if="!waiverInitiated" class="access-grant-waiver__actions">
42
+ <button
43
+ type="button"
44
+ class="btn primary"
45
+ :disabled="submitting"
46
+ @click="emit('send-code')"
47
+ >
48
+ {{ submitting
49
+ ? $t("playtest.access_grant.sending_code", "Sending code…")
50
+ : $t("playtest.access_grant.send_code", "Send verification code") }}
51
+ </button>
52
+ </div>
53
+
54
+ <div v-else class="access-grant-waiver__otp">
55
+ <label class="access-grant-waiver__label" for="access-grant-otp">
56
+ {{ $t("playtest.access_grant.enter_code", "Enter the 6-digit code we emailed you") }}
57
+ </label>
58
+ <div class="access-grant-waiver__otp-row">
59
+ <input
60
+ id="access-grant-otp"
61
+ v-model="otpCode"
62
+ type="text"
63
+ inputmode="numeric"
64
+ maxlength="6"
65
+ class="access-grant-waiver__otp-input"
66
+ :disabled="submitting"
67
+ @keyup.enter="submitCode"
68
+ />
69
+ <button
70
+ type="button"
71
+ class="btn primary"
72
+ :disabled="submitting || otpCode.length === 0"
73
+ @click="submitCode"
74
+ >
75
+ {{ submitting
76
+ ? $t("playtest.access_grant.verifying", "Verifying…")
77
+ : $t("playtest.access_grant.verify", "Verify") }}
78
+ </button>
79
+ </div>
80
+ <button
81
+ type="button"
82
+ class="access-grant-waiver__resend"
83
+ :disabled="submitting"
84
+ @click="emit('send-code')"
85
+ >
86
+ {{ $t("playtest.access_grant.resend_code", "Resend code") }}
87
+ </button>
88
+ </div>
89
+ </div>
90
+ </template>
91
+
92
+ <style scoped lang="scss">
93
+ .access-grant-waiver {
94
+ display: flex;
95
+ flex-direction: column;
96
+ gap: 12px;
97
+
98
+ &__title {
99
+ font-size: 15px;
100
+ font-weight: 700;
101
+ color: var(--card-article-title, #fff);
102
+ margin: 0;
103
+ }
104
+
105
+ &__body {
106
+ font-size: 13px;
107
+ color: var(--secondary-info-fg, #aaa);
108
+ margin: 0;
109
+ }
110
+
111
+ &__error {
112
+ font-size: 12px;
113
+ color: var(--danger, #e53935);
114
+ margin: 0;
115
+ }
116
+
117
+ &__actions {
118
+ display: flex;
119
+ }
120
+
121
+ &__otp {
122
+ display: flex;
123
+ flex-direction: column;
124
+ gap: 8px;
125
+ }
126
+
127
+ &__label {
128
+ font-size: 12px;
129
+ color: var(--secondary-info-fg, #aaa);
130
+ }
131
+
132
+ &__otp-row {
133
+ display: flex;
134
+ align-items: center;
135
+ gap: 8px;
136
+ }
137
+
138
+ &__otp-input {
139
+ width: 140px;
140
+ height: 44px;
141
+ padding: 0 12px;
142
+ background-color: transparent;
143
+ border: 1px solid var(--bg-app-badge);
144
+ color: var(--card-article-title, #fff);
145
+ font-size: 18px;
146
+ letter-spacing: 4px;
147
+ text-align: center;
148
+
149
+ &:focus {
150
+ border-color: var(--primary, #d297ff);
151
+ outline: 0;
152
+ }
153
+ }
154
+
155
+ &__resend {
156
+ align-self: flex-start;
157
+ background: none;
158
+ border: none;
159
+ padding: 0;
160
+ font-size: 12px;
161
+ color: var(--primary, #d297ff);
162
+ cursor: pointer;
163
+
164
+ &:disabled {
165
+ opacity: 0.45;
166
+ cursor: not-allowed;
167
+ }
168
+ }
169
+ }
170
+ </style>
@@ -0,0 +1,162 @@
1
+ <script setup lang="ts">
2
+ // Playback + clickable marker timeline for a Video Playtest recording.
3
+ // Native <video> — plays HLS natively in Safari; other browsers need an
4
+ // hls.js integration this first version doesn't include (documented
5
+ // limitation, not silently broken: shows a note when playback fails).
6
+ import { ref } from "vue";
7
+
8
+ const props = defineProps<{
9
+ playbackUrl: string | null;
10
+ durationSeconds: number | null;
11
+ markers: { id: number; timestamp_seconds: number; comment: string; severity?: string | null }[];
12
+ }>();
13
+
14
+ const videoEl = ref<HTMLVideoElement | null>(null);
15
+ const playbackError = ref(false);
16
+
17
+ function seekTo(seconds: number) {
18
+ if (videoEl.value) {
19
+ videoEl.value.currentTime = seconds;
20
+ videoEl.value.play();
21
+ }
22
+ }
23
+
24
+ function markerPosition(seconds: number): string {
25
+ if (!props.durationSeconds) return "0%";
26
+ return `${Math.min(100, (seconds / props.durationSeconds) * 100)}%`;
27
+ }
28
+
29
+ function formatSeconds(s: number): string {
30
+ const m = Math.floor(s / 60);
31
+ const sec = s % 60;
32
+ return `${m}:${sec.toString().padStart(2, "0")}`;
33
+ }
34
+
35
+ function severityClass(severity?: string | null): string {
36
+ return severity ? `recorded-session-player__marker--${severity}` : "";
37
+ }
38
+ </script>
39
+
40
+ <template>
41
+ <div class="recorded-session-player">
42
+ <div v-if="!playbackUrl" class="recorded-session-player__pending">
43
+ {{ $t("playtest.video_player.processing", "Recording is still processing — check back shortly.") }}
44
+ </div>
45
+
46
+ <template v-else>
47
+ <video
48
+ ref="videoEl"
49
+ :src="playbackUrl"
50
+ controls
51
+ class="recorded-session-player__video"
52
+ @error="playbackError = true"
53
+ />
54
+ <p v-if="playbackError" class="recorded-session-player__playback-error">
55
+ {{ $t("playtest.video_player.playback_error", "This browser can't play the recording directly — try Safari, or open the file link below.") }}
56
+ <a :href="playbackUrl" target="_blank" rel="noopener">{{ $t("playtest.video_player.open_direct", "Open recording") }}</a>
57
+ </p>
58
+
59
+ <div v-if="durationSeconds" class="recorded-session-player__timeline">
60
+ <button
61
+ v-for="marker in markers"
62
+ :key="marker.id"
63
+ type="button"
64
+ class="recorded-session-player__marker"
65
+ :class="severityClass(marker.severity)"
66
+ :style="{ left: markerPosition(marker.timestamp_seconds) }"
67
+ :title="`${formatSeconds(marker.timestamp_seconds)} — ${marker.comment}`"
68
+ @click="seekTo(marker.timestamp_seconds)"
69
+ />
70
+ </div>
71
+ </template>
72
+
73
+ <ul v-if="markers.length" class="recorded-session-player__marker-list">
74
+ <li v-for="marker in markers" :key="marker.id">
75
+ <button type="button" class="recorded-session-player__marker-jump" @click="seekTo(marker.timestamp_seconds)">
76
+ {{ formatSeconds(marker.timestamp_seconds) }}
77
+ </button>
78
+ <span :class="severityClass(marker.severity)">{{ marker.comment }}</span>
79
+ </li>
80
+ </ul>
81
+ </div>
82
+ </template>
83
+
84
+ <style scoped lang="scss">
85
+ .recorded-session-player {
86
+ display: flex;
87
+ flex-direction: column;
88
+ gap: 10px;
89
+
90
+ &__pending {
91
+ padding: 24px;
92
+ text-align: center;
93
+ font-size: 13px;
94
+ color: var(--secondary-info-fg, #aaa);
95
+ background: var(--bg-app-badge);
96
+ }
97
+
98
+ &__video {
99
+ width: 100%;
100
+ background: #000;
101
+ }
102
+
103
+ &__playback-error {
104
+ font-size: 12px;
105
+ color: var(--secondary-info-fg, #aaa);
106
+
107
+ a {
108
+ color: var(--primary, #d297ff);
109
+ }
110
+ }
111
+
112
+ &__timeline {
113
+ position: relative;
114
+ height: 16px;
115
+ background: var(--bg-app-badge);
116
+ }
117
+
118
+ &__marker {
119
+ position: absolute;
120
+ top: 2px;
121
+ width: 12px;
122
+ height: 12px;
123
+ border-radius: 50%;
124
+ background: var(--primary, #d297ff);
125
+ border: none;
126
+ cursor: pointer;
127
+ transform: translateX(-50%);
128
+
129
+ &--blocker { background: var(--danger, #e53935); }
130
+ &--major { background: #f39c12; }
131
+ &--minor { background: #95a5a6; }
132
+ &--note { background: var(--primary, #d297ff); }
133
+ }
134
+
135
+ &__marker-list {
136
+ list-style: none;
137
+ margin: 0;
138
+ padding: 0;
139
+ display: flex;
140
+ flex-direction: column;
141
+ gap: 6px;
142
+ font-size: 12px;
143
+
144
+ li {
145
+ display: flex;
146
+ align-items: center;
147
+ gap: 8px;
148
+ color: var(--secondary-info-fg, #aaa);
149
+ }
150
+ }
151
+
152
+ &__marker-jump {
153
+ background: var(--bg-app-badge);
154
+ border: none;
155
+ color: var(--primary, #d297ff);
156
+ font-weight: 700;
157
+ padding: 2px 8px;
158
+ cursor: pointer;
159
+ flex-shrink: 0;
160
+ }
161
+ }
162
+ </style>