@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,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",
@@ -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
- const packageRoot = dirname(require.resolve("@mundogamernetwork/shared-ui/package.json"));
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
- "@mundogamernetwork/shared-ui/assets/kit/kit-base.css",
96
- "@mundogamernetwork/shared-ui/assets/kit/kit-theme.css",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.4.2",
3
+ "version": "1.6.0",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -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.cover_src,
533
- // Only applies to the cover_src fallback — the game's own
534
- // url_banner_src is already banner-shaped and needs no repositioning.
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;