@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,88 @@
|
|
|
1
|
+
import { ref } from "vue";
|
|
2
|
+
import {
|
|
3
|
+
fetchAccessGrant,
|
|
4
|
+
initiateAccessGrantWaiver,
|
|
5
|
+
verifyAccessGrantWaiver,
|
|
6
|
+
type PlaytestAccessGrant,
|
|
7
|
+
} from "../services/playtestTesterService";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Game-access grant state (key/link + liability waiver) for a single
|
|
11
|
+
* accepted response. Only meaningful once the response's status is
|
|
12
|
+
* 'accepted' — a submitted/rejected response has no grant to load.
|
|
13
|
+
* One instance per response — instantiate fresh per mount, mirroring
|
|
14
|
+
* usePlaytestResponse().
|
|
15
|
+
*/
|
|
16
|
+
export function usePlaytestAccessGrant(responseId: number) {
|
|
17
|
+
const loading = ref(false);
|
|
18
|
+
const submitting = ref(false);
|
|
19
|
+
const grant = ref<PlaytestAccessGrant | null>(null);
|
|
20
|
+
const genericError = ref<string | null>(null);
|
|
21
|
+
// True once an OTP has been sent for the current signing attempt — lets
|
|
22
|
+
// the waiver UI switch from "send code" to "enter code" without waiting
|
|
23
|
+
// on a grant re-fetch (the grant itself only changes once verified).
|
|
24
|
+
const waiverInitiated = ref(false);
|
|
25
|
+
|
|
26
|
+
function resetErrors() {
|
|
27
|
+
genericError.value = null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function errorMessage(e: any, fallback: string): string {
|
|
31
|
+
return e?.response?.data?.message ?? e?.message ?? fallback;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function loadAccessGrant() {
|
|
35
|
+
loading.value = true;
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetchAccessGrant(responseId);
|
|
38
|
+
grant.value = res.data.data;
|
|
39
|
+
return grant.value;
|
|
40
|
+
} finally {
|
|
41
|
+
loading.value = false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function initiateWaiver() {
|
|
46
|
+
resetErrors();
|
|
47
|
+
submitting.value = true;
|
|
48
|
+
try {
|
|
49
|
+
await initiateAccessGrantWaiver(responseId);
|
|
50
|
+
waiverInitiated.value = true;
|
|
51
|
+
return true;
|
|
52
|
+
} catch (e: any) {
|
|
53
|
+
genericError.value = errorMessage(e, "Could not send the verification code. Please try again.");
|
|
54
|
+
return false;
|
|
55
|
+
} finally {
|
|
56
|
+
submitting.value = false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function verifyWaiver(otpCode: string) {
|
|
61
|
+
resetErrors();
|
|
62
|
+
submitting.value = true;
|
|
63
|
+
try {
|
|
64
|
+
const res = await verifyAccessGrantWaiver(responseId, otpCode);
|
|
65
|
+
grant.value = res.data.data;
|
|
66
|
+
return grant.value;
|
|
67
|
+
} catch (e: any) {
|
|
68
|
+
genericError.value = errorMessage(e, "Invalid or expired code.");
|
|
69
|
+
return null;
|
|
70
|
+
} finally {
|
|
71
|
+
submitting.value = false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
loading,
|
|
77
|
+
submitting,
|
|
78
|
+
grant,
|
|
79
|
+
genericError,
|
|
80
|
+
waiverInitiated,
|
|
81
|
+
loadAccessGrant,
|
|
82
|
+
initiateWaiver,
|
|
83
|
+
verifyWaiver,
|
|
84
|
+
resetErrors,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type PlaytestAccessGrantComposable = ReturnType<typeof usePlaytestAccessGrant>;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { ref } from "vue";
|
|
2
|
+
import {
|
|
3
|
+
requestRecordingUploadUrl,
|
|
4
|
+
fetchRecording,
|
|
5
|
+
storeRecordingMarker,
|
|
6
|
+
submitPlaytestResponse,
|
|
7
|
+
type PlaytestRecording,
|
|
8
|
+
type PlaytestRecordingMarker,
|
|
9
|
+
} from "../services/playtestTesterService";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Browser-side capture (MediaRecorder over getDisplayMedia — screen + audio)
|
|
13
|
+
* and upload lifecycle for a "Video Playtest" ("minutagem") response. One
|
|
14
|
+
* instance per campaign visit, mirroring usePlaytestResponse().
|
|
15
|
+
*
|
|
16
|
+
* Deliberately minimal per the game-access/minutagem plan's cost controls:
|
|
17
|
+
* no client-side trimming/editing, no separate mic-track mixing (system/tab
|
|
18
|
+
* audio only — a real limitation, not an oversight), duration capped
|
|
19
|
+
* server-side (config playtest.video.max_duration_seconds).
|
|
20
|
+
*/
|
|
21
|
+
export function usePlaytestVideoRecording(campaignId: number) {
|
|
22
|
+
const capturing = ref(false);
|
|
23
|
+
const uploading = ref(false);
|
|
24
|
+
const uploadProgress = ref(0);
|
|
25
|
+
const submitting = ref(false);
|
|
26
|
+
const genericError = ref<string | null>(null);
|
|
27
|
+
|
|
28
|
+
const recordingId = ref<number | null>(null);
|
|
29
|
+
const recording = ref<PlaytestRecording | null>(null);
|
|
30
|
+
const recordedBlob = ref<Blob | null>(null);
|
|
31
|
+
const recordedDurationSeconds = ref(0);
|
|
32
|
+
const markers = ref<{ timestamp_seconds: number; comment: string; severity?: string }[]>([]);
|
|
33
|
+
|
|
34
|
+
let mediaRecorder: MediaRecorder | null = null;
|
|
35
|
+
let chunks: BlobPart[] = [];
|
|
36
|
+
let captureStartedAt = 0;
|
|
37
|
+
|
|
38
|
+
function resetError() {
|
|
39
|
+
genericError.value = null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function errorMessage(e: any, fallback: string): string {
|
|
43
|
+
return e?.response?.data?.message ?? e?.message ?? fallback;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Also used to add a marker mid-recording — timestamp is relative to capture start. */
|
|
47
|
+
function elapsedSeconds(): number {
|
|
48
|
+
return captureStartedAt ? Math.floor((Date.now() - captureStartedAt) / 1000) : 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function startCapture() {
|
|
52
|
+
resetError();
|
|
53
|
+
try {
|
|
54
|
+
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
|
|
55
|
+
chunks = [];
|
|
56
|
+
mediaRecorder = new MediaRecorder(stream, { mimeType: "video/webm" });
|
|
57
|
+
mediaRecorder.ondataavailable = (e) => {
|
|
58
|
+
if (e.data.size > 0) chunks.push(e.data);
|
|
59
|
+
};
|
|
60
|
+
mediaRecorder.onstop = () => {
|
|
61
|
+
recordedBlob.value = new Blob(chunks, { type: "video/webm" });
|
|
62
|
+
recordedDurationSeconds.value = elapsedSeconds();
|
|
63
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
64
|
+
};
|
|
65
|
+
mediaRecorder.start(1000);
|
|
66
|
+
captureStartedAt = Date.now();
|
|
67
|
+
capturing.value = true;
|
|
68
|
+
} catch (e: any) {
|
|
69
|
+
genericError.value = errorMessage(e, "Could not start screen recording. Please allow screen-share permission and try again.");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function stopCapture() {
|
|
74
|
+
if (mediaRecorder && mediaRecorder.state !== "inactive") {
|
|
75
|
+
mediaRecorder.stop();
|
|
76
|
+
}
|
|
77
|
+
capturing.value = false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Adds a marker anchored to the current elapsed recording time. */
|
|
81
|
+
function markNow(comment: string, severity?: string) {
|
|
82
|
+
markers.value.push({ timestamp_seconds: elapsedSeconds(), comment, severity });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Requests the upload slot and uploads the recorded blob directly to the video provider. */
|
|
86
|
+
async function uploadRecording(): Promise<boolean> {
|
|
87
|
+
if (!recordedBlob.value) {
|
|
88
|
+
genericError.value = "No recording to upload yet.";
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
resetError();
|
|
92
|
+
uploading.value = true;
|
|
93
|
+
uploadProgress.value = 0;
|
|
94
|
+
try {
|
|
95
|
+
const { data } = await requestRecordingUploadUrl(campaignId);
|
|
96
|
+
recordingId.value = data.data.recording_id;
|
|
97
|
+
|
|
98
|
+
const form = new FormData();
|
|
99
|
+
form.append("file", recordedBlob.value, "recording.webm");
|
|
100
|
+
|
|
101
|
+
await new Promise<void>((resolve, reject) => {
|
|
102
|
+
const xhr = new XMLHttpRequest();
|
|
103
|
+
xhr.open("POST", data.data.upload_url);
|
|
104
|
+
xhr.upload.onprogress = (e) => {
|
|
105
|
+
if (e.lengthComputable) uploadProgress.value = Math.round((e.loaded / e.total) * 100);
|
|
106
|
+
};
|
|
107
|
+
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`Upload failed (${xhr.status})`)));
|
|
108
|
+
xhr.onerror = () => reject(new Error("Upload failed — network error."));
|
|
109
|
+
xhr.send(form);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return true;
|
|
113
|
+
} catch (e: any) {
|
|
114
|
+
genericError.value = errorMessage(e, "Could not upload the recording. Please try again.");
|
|
115
|
+
return false;
|
|
116
|
+
} finally {
|
|
117
|
+
uploading.value = false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Submits the response once the backend confirms the recording is ready.
|
|
123
|
+
* Processing is async (the provider's webhook updates our DB after
|
|
124
|
+
* upload), so this retries a few times with a short delay instead of
|
|
125
|
+
* failing on the first "not ready yet" — the backend (not this client)
|
|
126
|
+
* is the source of truth for readiness.
|
|
127
|
+
*/
|
|
128
|
+
async function submitVideoResponse(
|
|
129
|
+
meta: { time_on_task_seconds?: number; device?: string; os?: string } = {},
|
|
130
|
+
maxAttempts = 5,
|
|
131
|
+
delayMs = 4000,
|
|
132
|
+
) {
|
|
133
|
+
if (!recordingId.value) {
|
|
134
|
+
genericError.value = "Upload the recording before submitting.";
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
resetError();
|
|
138
|
+
submitting.value = true;
|
|
139
|
+
try {
|
|
140
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
141
|
+
try {
|
|
142
|
+
const { data } = await submitPlaytestResponse(campaignId, {
|
|
143
|
+
answers: {},
|
|
144
|
+
recording_id: recordingId.value,
|
|
145
|
+
...meta,
|
|
146
|
+
});
|
|
147
|
+
for (const m of markers.value) {
|
|
148
|
+
await storeRecordingMarker(data.id, m as any);
|
|
149
|
+
}
|
|
150
|
+
return data;
|
|
151
|
+
} catch (e: any) {
|
|
152
|
+
if (attempt === maxAttempts) throw e;
|
|
153
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
} catch (e: any) {
|
|
158
|
+
genericError.value = errorMessage(e, "The recording isn't ready yet — please wait a moment and try again.");
|
|
159
|
+
return null;
|
|
160
|
+
} finally {
|
|
161
|
+
submitting.value = false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function loadRecording(responseId: number) {
|
|
166
|
+
const { data } = await fetchRecording(responseId);
|
|
167
|
+
recording.value = data.data;
|
|
168
|
+
return recording.value;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
capturing,
|
|
173
|
+
uploading,
|
|
174
|
+
uploadProgress,
|
|
175
|
+
submitting,
|
|
176
|
+
genericError,
|
|
177
|
+
recordingId,
|
|
178
|
+
recording,
|
|
179
|
+
recordedBlob,
|
|
180
|
+
recordedDurationSeconds,
|
|
181
|
+
markers,
|
|
182
|
+
startCapture,
|
|
183
|
+
stopCapture,
|
|
184
|
+
markNow,
|
|
185
|
+
uploadRecording,
|
|
186
|
+
submitVideoResponse,
|
|
187
|
+
loadRecording,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export type PlaytestVideoRecordingComposable = ReturnType<typeof usePlaytestVideoRecording>;
|
package/locales/de.json
CHANGED
|
@@ -204,6 +204,7 @@
|
|
|
204
204
|
"required_fields": "Bitte alle Pflichtfelder ausfüllen",
|
|
205
205
|
"plan": "Deckungspläne",
|
|
206
206
|
"plan_placeholder": "Beschreibe deinen Berichterstattungsplan: wo du über das Spiel streamen oder posten wirst, dein Publikum und das geplante Veröffentlichungsdatum",
|
|
207
|
+
"notify_opt_in": "Benachrichtige mich über neue Key-Kampagnen",
|
|
207
208
|
"resources_title": "Ressourcen",
|
|
208
209
|
"press_kit_link": "Presse-Kit",
|
|
209
210
|
"review_guideline": "Review-Richtlinie",
|
package/locales/en.json
CHANGED
|
@@ -204,6 +204,7 @@
|
|
|
204
204
|
"required_fields": "Please fill in all required fields",
|
|
205
205
|
"plan": "Coverage plans",
|
|
206
206
|
"plan_placeholder": "Describe your coverage plan: where you will stream or post about this game, your audience, and expected publish date",
|
|
207
|
+
"notify_opt_in": "Notify me about new key campaigns",
|
|
207
208
|
"resources_title": "Resources",
|
|
208
209
|
"press_kit_link": "Press Kit",
|
|
209
210
|
"review_guideline": "Review guideline",
|
package/locales/pt-BR.json
CHANGED
|
@@ -204,6 +204,7 @@
|
|
|
204
204
|
"required_fields": "Por favor, preencha todos os campos obrigatórios",
|
|
205
205
|
"plan": " Planos de cobertura",
|
|
206
206
|
"plan_placeholder": "Descreva seu plano de cobertura: onde vai fazer stream ou publicar sobre o jogo, seu público e a data prevista de publicação",
|
|
207
|
+
"notify_opt_in": "Quero ser notificado sobre novas campanhas de chaves",
|
|
207
208
|
"resources_title": "Recursos",
|
|
208
209
|
"press_kit_link": "Press Kit",
|
|
209
210
|
"review_guideline": "Diretriz de review",
|
package/locales/ro.json
CHANGED
|
@@ -204,6 +204,7 @@
|
|
|
204
204
|
"required_fields": "Vă rugăm să completați toate câmpurile obligatorii",
|
|
205
205
|
"plan": "Planuri de acoperire",
|
|
206
206
|
"plan_placeholder": "Descrie planul tău de acoperire: unde vei face stream sau vei posta despre joc, publicul tău și data estimată de publicare",
|
|
207
|
+
"notify_opt_in": "Anunță-mă despre noile campanii de chei",
|
|
207
208
|
"resources_title": "Resurse",
|
|
208
209
|
"press_kit_link": "Press Kit",
|
|
209
210
|
"review_guideline": "Ghid de recenzie",
|
package/nuxt.config.ts
CHANGED
|
@@ -51,7 +51,14 @@ import { dirname } from "node:path";
|
|
|
51
51
|
const SHARED_UI_COMPONENT_PRIORITY = 1000;
|
|
52
52
|
|
|
53
53
|
const require = createRequire(`${process.cwd()}/`);
|
|
54
|
-
|
|
54
|
+
let packageRoot: string;
|
|
55
|
+
try {
|
|
56
|
+
packageRoot = dirname(require.resolve("@mundogamernetwork/shared-ui/package.json"));
|
|
57
|
+
} catch {
|
|
58
|
+
// The package does not resolve by its own published name while running the
|
|
59
|
+
// local playground directly from this repository.
|
|
60
|
+
packageRoot = process.cwd();
|
|
61
|
+
}
|
|
55
62
|
|
|
56
63
|
export default defineNuxtConfig({
|
|
57
64
|
components: [
|
|
@@ -92,8 +99,8 @@ export default defineNuxtConfig({
|
|
|
92
99
|
],
|
|
93
100
|
},
|
|
94
101
|
css: [
|
|
95
|
-
|
|
96
|
-
|
|
102
|
+
`${packageRoot}/assets/kit/kit-base.css`,
|
|
103
|
+
`${packageRoot}/assets/kit/kit-theme.css`,
|
|
97
104
|
],
|
|
98
105
|
runtimeConfig: {
|
|
99
106
|
public: {
|
package/package.json
CHANGED
|
@@ -123,6 +123,7 @@
|
|
|
123
123
|
:src="card.cover"
|
|
124
124
|
:alt="card.text"
|
|
125
125
|
:class="{ 'gray-out': card.isExpired || card.isUpcoming || !card.user_can_access }"
|
|
126
|
+
:style="{ objectPosition: `${card.coverFocalX * 100}% ${card.coverFocalY * 100}%` }"
|
|
126
127
|
@error="(e) => { (e.target as HTMLImageElement).src = '/imgs/no-cover-img.jpg' }"
|
|
127
128
|
/>
|
|
128
129
|
|
|
@@ -419,7 +420,7 @@ const sortMode = ref<"recent" | "popular">("recent")
|
|
|
419
420
|
// ------------------------------------------------------------------
|
|
420
421
|
// Data
|
|
421
422
|
// ------------------------------------------------------------------
|
|
422
|
-
const sliderImages = ref<Array<{ slug: string; cover: string; name: string; id: number }>>([])
|
|
423
|
+
const sliderImages = ref<Array<{ slug: string; cover: string; name: string; id: number; coverFocalX: number; coverFocalY: number }>>([])
|
|
423
424
|
const cards = ref<Array<any>>([])
|
|
424
425
|
const redeemedCards = ref<Array<any>>([])
|
|
425
426
|
|
|
@@ -529,11 +530,9 @@ async function loadFeaturedCampaigns() {
|
|
|
529
530
|
.filter((item: any) => (truthyFlag(item.featured) || truthyFlag(item.highlight)) && truthyFlag(item.is_open))
|
|
530
531
|
.map((item: any) => ({
|
|
531
532
|
id: item.id,
|
|
532
|
-
cover: item.game?.url_banner_src || item.
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
coverFocalX: item.game?.url_banner_src ? 0.5 : (item.cover_focal_x ?? 0.5),
|
|
536
|
-
coverFocalY: item.game?.url_banner_src ? 0.5 : (item.cover_focal_y ?? 0.5),
|
|
533
|
+
cover: item.cover_src || item.game?.url_banner_src || item.game?.url_cover_src || "/imgs/no-cover-img.jpg",
|
|
534
|
+
coverFocalX: item.cover_src ? (item.cover_focal_x ?? 0.5) : 0.5,
|
|
535
|
+
coverFocalY: item.cover_src ? (item.cover_focal_y ?? 0.5) : 0.5,
|
|
537
536
|
name: item.name,
|
|
538
537
|
slug: item.slug,
|
|
539
538
|
}))
|
|
@@ -592,6 +591,8 @@ async function loadCampaigns(page = 1) {
|
|
|
592
591
|
type: item.type?.name,
|
|
593
592
|
typeId: item.type?.id,
|
|
594
593
|
cover: item.cover_src || item.game?.url_cover_src || item.game?.url_banner_src || "/imgs/no-cover-img.jpg",
|
|
594
|
+
coverFocalX: item.cover_src ? (item.cover_focal_x ?? 0.5) : 0.5,
|
|
595
|
+
coverFocalY: item.cover_src ? (item.cover_focal_y ?? 0.5) : 0.5,
|
|
595
596
|
text: item.name,
|
|
596
597
|
time: item.time_to_expire ? item.time_to_expire.split(":")[0] : "0",
|
|
597
598
|
slug: item.slug,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
fetchKeyRegions,
|
|
9
9
|
fetchAvailableOptions,
|
|
10
10
|
requestKey,
|
|
11
|
+
enableKeyCampaignNotifications,
|
|
11
12
|
} from "../../services/campaignService"
|
|
12
13
|
|
|
13
14
|
const route = useRoute()
|
|
@@ -37,6 +38,7 @@ const selectedPlatformId = ref<number | null>(null)
|
|
|
37
38
|
const selectedRegionId = ref<number | null>(null)
|
|
38
39
|
const description = ref("")
|
|
39
40
|
const quantity = ref<number | null>(1)
|
|
41
|
+
const notifyKeyCampaigns = ref(true)
|
|
40
42
|
|
|
41
43
|
const maxKeysForUser = computed(() => campaign.value?.max_keys_for_user ?? 1)
|
|
42
44
|
|
|
@@ -137,6 +139,12 @@ async function submit() {
|
|
|
137
139
|
quantity_keys: Number(quantity.value) || 1,
|
|
138
140
|
})
|
|
139
141
|
const created = response?.data?.data ?? response?.data ?? {}
|
|
142
|
+
|
|
143
|
+
if (notifyKeyCampaigns.value) {
|
|
144
|
+
// Fire-and-forget — never block the request/reveal flow on this.
|
|
145
|
+
enableKeyCampaignNotifications().catch(() => {})
|
|
146
|
+
}
|
|
147
|
+
|
|
140
148
|
// requires_approval=false campaigns (or Pro/Business TV subscribers) get
|
|
141
149
|
// auto-approved by the backend observer synchronously — status 3 already
|
|
142
150
|
// reflects that by the time this response comes back, so skip the
|
|
@@ -281,6 +289,13 @@ function goBack() {
|
|
|
281
289
|
rows="3"
|
|
282
290
|
/>
|
|
283
291
|
</div>
|
|
292
|
+
|
|
293
|
+
<div class="form-row form-row--checkbox">
|
|
294
|
+
<label class="checkbox-label">
|
|
295
|
+
<input type="checkbox" v-model="notifyKeyCampaigns" />
|
|
296
|
+
{{ $t("keys.campaigns.notify_opt_in") }}
|
|
297
|
+
</label>
|
|
298
|
+
</div>
|
|
284
299
|
</div>
|
|
285
300
|
</div>
|
|
286
301
|
|
|
@@ -445,6 +460,27 @@ function goBack() {
|
|
|
445
460
|
color: var(--card-article-title);
|
|
446
461
|
}
|
|
447
462
|
|
|
463
|
+
.form-row--checkbox {
|
|
464
|
+
flex-direction: row;
|
|
465
|
+
align-items: center;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
.checkbox-label {
|
|
469
|
+
display: flex;
|
|
470
|
+
align-items: center;
|
|
471
|
+
gap: 8px;
|
|
472
|
+
font-size: 13px;
|
|
473
|
+
color: var(--card-article-title);
|
|
474
|
+
cursor: pointer;
|
|
475
|
+
|
|
476
|
+
input[type="checkbox"] {
|
|
477
|
+
width: 16px;
|
|
478
|
+
height: 16px;
|
|
479
|
+
accent-color: var(--key-accent, var(--primary, #D297FF));
|
|
480
|
+
cursor: pointer;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
448
484
|
.select, .input, .textarea {
|
|
449
485
|
width: 100%;
|
|
450
486
|
background-color: transparent;
|