@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.
- package/components/MediaKit/MgMediaKitLinks.vue +121 -0
- package/components/MediaKit/MgMediaKitStore.vue +107 -0
- package/components/checkout/MgPaymentMethodSelector.vue +3 -1
- package/components/form/SortInput.vue +9 -2
- package/components/keys/KeyBrowser.vue +2 -2
- package/components/playtest/PlaytestResponseFlow.vue +7 -1
- package/components/playtest/access-grant/AccessGrantPanel.vue +80 -0
- package/components/playtest/access-grant/AccessGrantReveal.vue +132 -0
- package/components/playtest/access-grant/AccessGrantWaiver.vue +170 -0
- package/components/playtest/video/RecordedSessionPlayer.vue +162 -0
- package/components/playtest/video/VideoPlaytestCapture.vue +195 -0
- package/components/ui/MgPaymentMethods.vue +20 -0
- package/composables/usePaymentMethods.ts +43 -12
- package/composables/usePlaytestAccessGrant.ts +88 -0
- package/composables/usePlaytestVideoRecording.ts +191 -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/nuxt.config.ts +10 -3
- package/package.json +1 -1
- package/pages/key-campaigns/index.vue +7 -6
- package/pages/key-campaigns/redeem-key.vue +36 -0
- package/pages/media-kit/[slug].vue +180 -39
- package/services/campaignService.ts +14 -0
- package/services/playtestTesterService.ts +63 -0
|
@@ -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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* notion of PayPal/Stripe.
|
|
12
|
+
* These are FIXED across the ecosystem — we 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
|
|
24
|
-
* <MgPaymentMethods> to pick which inline SVG logo to render. It is
|
|
25
|
-
* to the payment_methods table ids and to api ids
|
|
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
|
|
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[]>(
|
|
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 =
|
|
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(() =>
|
|
76
|
+
const isPix = computed(() => selectedMethod.value.toLowerCase() === "pix");
|
|
46
77
|
|
|
47
78
|
function selectMethod(name: string) {
|
|
48
79
|
selectedMethod.value = name.toLowerCase();
|