@mundogamernetwork/shared-ui 1.4.2 → 1.6.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.
@@ -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>
@@ -0,0 +1,195 @@
1
+ <script setup lang="ts">
2
+ // Tester-facing capture step for the "Video Playtest" category — record,
3
+ // upload, optionally drop timestamped markers, then submit. Mounted by
4
+ // PlaytestResponseFlow.vue as the video_playtest category branch.
5
+ import { ref, computed } from "vue";
6
+ import { usePlaytestVideoRecording } from "../../../composables/usePlaytestVideoRecording";
7
+
8
+ const props = defineProps<{
9
+ campaignId: number;
10
+ }>();
11
+
12
+ const emit = defineEmits<{
13
+ (e: "submitted"): void;
14
+ }>();
15
+
16
+ const rec = usePlaytestVideoRecording(props.campaignId);
17
+
18
+ const markerDraft = ref("");
19
+ const step = computed<"record" | "review" | "upload" | "done">(() => {
20
+ if (rec.recordedBlob.value && !rec.recordingId.value) return "review";
21
+ if (rec.recordingId.value) return "upload";
22
+ return "record";
23
+ });
24
+
25
+ function addMarker() {
26
+ if (!markerDraft.value.trim()) return;
27
+ rec.markNow(markerDraft.value.trim());
28
+ markerDraft.value = "";
29
+ }
30
+
31
+ async function submit() {
32
+ const result = await rec.submitVideoResponse();
33
+ if (result) emit("submitted");
34
+ }
35
+
36
+ function formatSeconds(s: number): string {
37
+ const m = Math.floor(s / 60);
38
+ const sec = s % 60;
39
+ return `${m}:${sec.toString().padStart(2, "0")}`;
40
+ }
41
+ </script>
42
+
43
+ <template>
44
+ <div class="video-capture">
45
+ <p v-if="rec.genericError.value" class="video-capture__error">{{ rec.genericError.value }}</p>
46
+
47
+ <!-- Record -->
48
+ <template v-if="step === 'record'">
49
+ <p class="video-capture__hint">
50
+ {{ $t("playtest.video_capture.hint", "Record your screen while you play. You can drop timestamped notes as you go.") }}
51
+ </p>
52
+
53
+ <div v-if="!rec.capturing.value" class="video-capture__actions">
54
+ <button type="button" class="btn primary" @click="rec.startCapture">
55
+ {{ $t("playtest.video_capture.start", "Start recording") }}
56
+ </button>
57
+ </div>
58
+
59
+ <div v-else class="video-capture__recording">
60
+ <span class="video-capture__rec-dot" />
61
+ {{ $t("playtest.video_capture.recording", "Recording…") }}
62
+
63
+ <div class="video-capture__marker-form">
64
+ <input
65
+ v-model="markerDraft"
66
+ type="text"
67
+ :placeholder="$t('playtest.video_capture.marker_placeholder', 'Note something at this moment…')"
68
+ @keyup.enter="addMarker"
69
+ />
70
+ <button type="button" class="btn" @click="addMarker">
71
+ {{ $t("playtest.video_capture.mark", "Mark") }}
72
+ </button>
73
+ </div>
74
+
75
+ <ul v-if="rec.markers.value.length" class="video-capture__marker-list">
76
+ <li v-for="(m, i) in rec.markers.value" :key="i">
77
+ <strong>{{ formatSeconds(m.timestamp_seconds) }}</strong> — {{ m.comment }}
78
+ </li>
79
+ </ul>
80
+
81
+ <button type="button" class="btn danger" @click="rec.stopCapture">
82
+ {{ $t("playtest.video_capture.stop", "Stop recording") }}
83
+ </button>
84
+ </div>
85
+ </template>
86
+
87
+ <!-- Review before upload -->
88
+ <template v-else-if="step === 'review'">
89
+ <p class="video-capture__hint">
90
+ {{ $t("playtest.video_capture.review_hint", "Recording captured ({v}). Upload it to submit your response.", { v: formatSeconds(rec.recordedDurationSeconds.value) }) }}
91
+ </p>
92
+ <button type="button" class="btn primary" :disabled="rec.uploading.value" @click="rec.uploadRecording">
93
+ {{ rec.uploading.value
94
+ ? $t("playtest.video_capture.uploading", "Uploading… {v}%", { v: rec.uploadProgress.value })
95
+ : $t("playtest.video_capture.upload", "Upload recording") }}
96
+ </button>
97
+ </template>
98
+
99
+ <!-- Upload done, waiting on provider processing — submit -->
100
+ <template v-else-if="step === 'upload'">
101
+ <p class="video-capture__hint">
102
+ {{ $t("playtest.video_capture.processing_hint", "Your recording is uploaded and processing. Submit when you're ready — we'll finish confirming it's ready in the background.") }}
103
+ </p>
104
+ <button type="button" class="btn primary" :disabled="rec.submitting.value" @click="submit">
105
+ {{ rec.submitting.value
106
+ ? $t("playtest.video_capture.submitting", "Submitting…")
107
+ : $t("playtest.video_capture.submit", "Submit response") }}
108
+ </button>
109
+ </template>
110
+ </div>
111
+ </template>
112
+
113
+ <style scoped lang="scss">
114
+ .video-capture {
115
+ display: flex;
116
+ flex-direction: column;
117
+ gap: 12px;
118
+
119
+ &__hint {
120
+ font-size: 13px;
121
+ color: var(--secondary-info-fg, #aaa);
122
+ margin: 0;
123
+ }
124
+
125
+ &__error {
126
+ font-size: 12px;
127
+ color: var(--danger, #e53935);
128
+ margin: 0;
129
+ }
130
+
131
+ &__actions {
132
+ display: flex;
133
+ }
134
+
135
+ &__recording {
136
+ display: flex;
137
+ flex-direction: column;
138
+ gap: 10px;
139
+ align-items: flex-start;
140
+ font-size: 13px;
141
+ color: var(--card-article-title, #fff);
142
+ }
143
+
144
+ &__rec-dot {
145
+ display: inline-block;
146
+ width: 8px;
147
+ height: 8px;
148
+ border-radius: 50%;
149
+ background: var(--danger, #e53935);
150
+ margin-right: 6px;
151
+ animation: video-capture-pulse 1.2s ease-in-out infinite;
152
+ }
153
+
154
+ &__marker-form {
155
+ display: flex;
156
+ gap: 8px;
157
+ width: 100%;
158
+
159
+ input {
160
+ flex: 1;
161
+ height: 40px;
162
+ padding: 0 10px;
163
+ background: transparent;
164
+ border: 1px solid var(--bg-app-badge);
165
+ color: var(--card-article-title, #fff);
166
+ font-size: 13px;
167
+ }
168
+ }
169
+
170
+ &__marker-list {
171
+ list-style: none;
172
+ margin: 0;
173
+ padding: 0;
174
+ font-size: 12px;
175
+ color: var(--secondary-info-fg, #aaa);
176
+ display: flex;
177
+ flex-direction: column;
178
+ gap: 4px;
179
+
180
+ strong {
181
+ color: var(--primary, #d297ff);
182
+ }
183
+ }
184
+ }
185
+
186
+ .btn.danger {
187
+ background: var(--danger, #e53935);
188
+ color: #fff;
189
+ }
190
+
191
+ @keyframes video-capture-pulse {
192
+ 0%, 100% { opacity: 1; }
193
+ 50% { opacity: 0.3; }
194
+ }
195
+ </style>
@@ -119,6 +119,26 @@
119
119
  />
120
120
  </svg>
121
121
  </div>
122
+ <div v-if="method === 4">
123
+ <svg width="52" height="18" viewBox="0 0 52 18" fill="none" xmlns="http://www.w3.org/2000/svg">
124
+ <path
125
+ d="M9.5 2.2L11.8 4.5C12.5 5.2 12.5 6.3 11.8 7L9.5 9.3C8.8 10 7.7 10 7 9.3L4.7 7C4 6.3 4 5.2 4.7 4.5L7 2.2C7.7 1.5 8.8 1.5 9.5 2.2Z"
126
+ :fill="theme === 'dark' ? 'white' : '#32BCAD'"
127
+ />
128
+ <path
129
+ d="M9.5 8.7L11.8 11C12.5 11.7 12.5 12.8 11.8 13.5L9.5 15.8C8.8 16.5 7.7 16.5 7 15.8L4.7 13.5C4 12.8 4 11.7 4.7 11L7 8.7C7.7 8 8.8 8 9.5 8.7Z"
130
+ :fill="theme === 'dark' ? 'white' : '#32BCAD'"
131
+ />
132
+ <text
133
+ x="17"
134
+ y="13.5"
135
+ font-family="Arial, Helvetica, sans-serif"
136
+ font-size="13"
137
+ font-weight="700"
138
+ :fill="theme === 'dark' ? 'white' : '#111827'"
139
+ >Pix</text>
140
+ </svg>
141
+ </div>
122
142
  </template>
123
143
  <script setup lang="ts">
124
144
  const props = defineProps({
@@ -9,40 +9,71 @@ export interface PaymentMethod {
9
9
  /**
10
10
  * Payment gateways shown in the checkout UI.
11
11
  *
12
- * These are FIXED to PayPal + Stripe the only gateways live across the
13
- * ecosystem. We do NOT read the `payment_methods` table: that table holds
14
- * generic legacy methods (Cash, Credit Card, Transfer, Bitcoin...) and has no
15
- * notion of PayPal/Stripe.
12
+ * These are FIXED across the ecosystemwe do NOT read the
13
+ * `payment_methods` table: that table holds generic legacy methods (Cash,
14
+ * Credit Card, Transfer, Bitcoin...) and has no notion of PayPal/Stripe/Pix.
16
15
  *
17
16
  * The actual gateway *account* (which api_credential to charge) is resolved by
18
17
  * the backend at checkout time, honouring the existing active / default /
19
18
  * production flags on `api_credentials`. That lets admins switch accounts
20
19
  * without any frontend change. The frontend only sends
21
- * `payment_gateway: 'paypal' | 'stripe'`.
20
+ * `payment_gateway: 'paypal' | 'stripe' | 'pix'`.
22
21
  *
23
- * The numeric `id` here (PayPal = 2, Stripe = 3) is the convention used by
24
- * <MgPaymentMethods> to pick which inline SVG logo to render. It is unrelated
25
- * to the payment_methods table ids and to api ids (PayPal = 4, Stripe = 5).
22
+ * The numeric `id` here (PayPal = 2, Stripe = 3, Pix = 4) is the convention
23
+ * used by <MgPaymentMethods> to pick which inline SVG logo to render. It is
24
+ * unrelated to the payment_methods table ids and to api ids.
26
25
  */
27
- const GATEWAYS: PaymentMethod[] = [
26
+ const ALL_GATEWAYS: PaymentMethod[] = [
28
27
  { id: 2, name: "paypal" },
29
28
  { id: 3, name: "stripe" },
29
+ { id: 4, name: "pix" },
30
30
  ];
31
31
 
32
+ /**
33
+ * Emergency/operational kill-switch: pulling a gateway from every checkout
34
+ * across the whole ecosystem is a name added here (ecosystem-wide default,
35
+ * requires bump + republish) or via a consuming app's
36
+ * NUXT_PUBLIC_DISABLED_PAYMENT_GATEWAYS runtime config (e.g. a provider
37
+ * outage), no per-app code change needed either way.
38
+ */
39
+ const DISABLED_BY_DEFAULT: string[] = [];
40
+
41
+ function disabledGatewayNames(): string[] {
42
+ let fromRuntimeConfig: string[] = [];
43
+ try {
44
+ // useRuntimeConfig is auto-imported by Nuxt; guarded for non-Nuxt/test contexts.
45
+ const raw = useRuntimeConfig?.()?.public?.disabledPaymentGateways;
46
+ if (Array.isArray(raw)) {
47
+ fromRuntimeConfig = raw;
48
+ } else if (typeof raw === "string" && raw.length) {
49
+ fromRuntimeConfig = raw.split(",").map((n) => n.trim());
50
+ }
51
+ } catch {
52
+ // Not inside a Nuxt context (e.g. unit test) — fall back to the default list.
53
+ }
54
+
55
+ return [...DISABLED_BY_DEFAULT, ...fromRuntimeConfig].map((n) => n.toLowerCase());
56
+ }
57
+
58
+ function enabledGateways(): PaymentMethod[] {
59
+ const disabled = disabledGatewayNames();
60
+ return ALL_GATEWAYS.filter((g) => !disabled.includes(g.name));
61
+ }
62
+
32
63
  export function usePaymentMethods(_httpService?: AxiosInstance) {
33
- const methods = ref<PaymentMethod[]>([...GATEWAYS]);
64
+ const methods = ref<PaymentMethod[]>(enabledGateways());
34
65
  const selectedMethod = ref<string>("");
35
66
  const loading = ref(false);
36
67
  const error = ref<string | null>(null);
37
68
 
38
69
  // No HTTP call. Kept async + the context arg for call-site compatibility.
39
70
  async function fetchMethods(_context: "checkout" | "subscription" = "checkout") {
40
- methods.value = [...GATEWAYS];
71
+ methods.value = enabledGateways();
41
72
  }
42
73
 
43
74
  const isStripe = computed(() => selectedMethod.value.toLowerCase() === "stripe");
44
75
  const isPaypal = computed(() => selectedMethod.value.toLowerCase() === "paypal");
45
- const isPix = computed(() => false); // Pix not enabled yet
76
+ const isPix = computed(() => selectedMethod.value.toLowerCase() === "pix");
46
77
 
47
78
  function selectMethod(name: string) {
48
79
  selectedMethod.value = name.toLowerCase();