@mundogamernetwork/shared-ui 1.7.0 → 1.8.1

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.
@@ -147,12 +147,16 @@ function handleRequestKey(pool: KeyPool) {
147
147
 
148
148
  // ─── Notification prompt ──────────────────────────────────────────────────────
149
149
 
150
+ // account-management/notification-preferences is the canonical, cross-product
151
+ // preferences endpoint (network-accounts, community-frontend, mobile all use
152
+ // it). Called with no mg_network_system_id so the row is global — one opt-in
153
+ // covers key_campaigns alerts on TV/community/agency alike, matching how the
154
+ // rest of the notification-preferences system already behaves.
150
155
  async function _checkNotifPrompt() {
151
156
  try {
152
- const res = await httpService.get('/public/user/notification-preferences', {
153
- params: { category: 'key_campaigns' },
154
- })
155
- if (res.data?.data === null) {
157
+ const res = await httpService.get('/account-management/notification-preferences')
158
+ const category = res.data?.data?.categories?.find((c: any) => c.category === 'key_campaigns')
159
+ if (category && category.is_set === false) {
156
160
  notifPromptVisible.value = true
157
161
  }
158
162
  } catch {
@@ -163,12 +167,14 @@ async function _checkNotifPrompt() {
163
167
  async function enableNotifAlerts() {
164
168
  notifSaving.value = true
165
169
  try {
166
- await httpService.put('/public/user/notification-preferences', {
167
- category: 'key_campaigns',
168
- enabled: true,
169
- channel_email: true,
170
- channel_push: true,
171
- channel_in_app: true,
170
+ await httpService.post('/account-management/notification-preferences', {
171
+ preferences: [{
172
+ category: 'key_campaigns',
173
+ enabled: true,
174
+ channel_email: true,
175
+ channel_push: true,
176
+ channel_in_app: true,
177
+ }],
172
178
  })
173
179
  } catch {
174
180
  // ignore — user can configure later in settings
@@ -1,9 +1,10 @@
1
1
  <script setup lang="ts">
2
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";
3
+ // Safari plays HLS natively; every other browser needs hls.js (MediaSource
4
+ // Extensions) falls back to a direct-link note only if neither path works
5
+ // (very old browsers without MSE support).
6
+ import { ref, onMounted, onBeforeUnmount, watch } from "vue";
7
+ import Hls from "hls.js";
7
8
 
8
9
  const props = defineProps<{
9
10
  playbackUrl: string | null;
@@ -14,6 +15,41 @@ const props = defineProps<{
14
15
  const videoEl = ref<HTMLVideoElement | null>(null);
15
16
  const playbackError = ref(false);
16
17
 
18
+ let hls: Hls | null = null;
19
+
20
+ function destroyPlayer() {
21
+ if (hls) {
22
+ hls.destroy();
23
+ hls = null;
24
+ }
25
+ }
26
+
27
+ function setupPlayback() {
28
+ destroyPlayer();
29
+ playbackError.value = false;
30
+
31
+ const video = videoEl.value;
32
+ if (!video || !props.playbackUrl) return;
33
+
34
+ if (Hls.isSupported()) {
35
+ hls = new Hls();
36
+ hls.on(Hls.Events.ERROR, (_event, data) => {
37
+ if (data.fatal) playbackError.value = true;
38
+ });
39
+ hls.loadSource(props.playbackUrl);
40
+ hls.attachMedia(video);
41
+ } else if (video.canPlayType("application/vnd.apple.mpegurl")) {
42
+ // Safari and other browsers with native HLS support.
43
+ video.src = props.playbackUrl;
44
+ } else {
45
+ playbackError.value = true;
46
+ }
47
+ }
48
+
49
+ onMounted(setupPlayback);
50
+ watch(() => props.playbackUrl, setupPlayback);
51
+ onBeforeUnmount(destroyPlayer);
52
+
17
53
  function seekTo(seconds: number) {
18
54
  if (videoEl.value) {
19
55
  videoEl.value.currentTime = seconds;
@@ -46,7 +82,6 @@ function severityClass(severity?: string | null): string {
46
82
  <template v-else>
47
83
  <video
48
84
  ref="videoEl"
49
- :src="playbackUrl"
50
85
  controls
51
86
  class="recorded-session-player__video"
52
87
  @error="playbackError = true"
@@ -16,6 +16,8 @@ const emit = defineEmits<{
16
16
  const rec = usePlaytestVideoRecording(props.campaignId);
17
17
 
18
18
  const markerDraft = ref("");
19
+ const submitted = ref(false);
20
+ const retryingMarkers = ref(false);
19
21
  const step = computed<"record" | "review" | "upload" | "done">(() => {
20
22
  if (rec.recordedBlob.value && !rec.recordingId.value) return "review";
21
23
  if (rec.recordingId.value) return "upload";
@@ -30,7 +32,23 @@ function addMarker() {
30
32
 
31
33
  async function submit() {
32
34
  const result = await rec.submitVideoResponse();
33
- if (result) emit("submitted");
35
+ if (!result) return;
36
+ submitted.value = true;
37
+ if (rec.markerSubmitErrors.value === 0) emit("submitted");
38
+ }
39
+
40
+ async function retryMarkers() {
41
+ retryingMarkers.value = true;
42
+ try {
43
+ await rec.retryPendingMarkers();
44
+ if (rec.markerSubmitErrors.value === 0) emit("submitted");
45
+ } finally {
46
+ retryingMarkers.value = false;
47
+ }
48
+ }
49
+
50
+ function continueAfterSubmit() {
51
+ emit("submitted");
34
52
  }
35
53
 
36
54
  function formatSeconds(s: number): string {
@@ -97,7 +115,7 @@ function formatSeconds(s: number): string {
97
115
  </template>
98
116
 
99
117
  <!-- Upload done, waiting on provider processing — submit -->
100
- <template v-else-if="step === 'upload'">
118
+ <template v-else-if="step === 'upload' && !submitted">
101
119
  <p class="video-capture__hint">
102
120
  {{ $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
121
  </p>
@@ -107,6 +125,23 @@ function formatSeconds(s: number): string {
107
125
  : $t("playtest.video_capture.submit", "Submit response") }}
108
126
  </button>
109
127
  </template>
128
+
129
+ <!-- Response submitted, but one or more markers failed to save — offer a retry instead of silently dropping them -->
130
+ <template v-else-if="submitted && rec.markerSubmitErrors.value > 0">
131
+ <p class="video-capture__hint video-capture__error">
132
+ {{ $t("playtest.video_capture.marker_save_failed", "Response submitted, but {v} note(s) failed to save.", { v: rec.markerSubmitErrors.value }) }}
133
+ </p>
134
+ <div class="video-capture__actions">
135
+ <button type="button" class="btn primary" :disabled="retryingMarkers" @click="retryMarkers">
136
+ {{ retryingMarkers
137
+ ? $t("playtest.video_capture.retrying", "Retrying…")
138
+ : $t("playtest.video_capture.retry_markers", "Retry saving notes") }}
139
+ </button>
140
+ <button type="button" class="btn" @click="continueAfterSubmit">
141
+ {{ $t("playtest.video_capture.continue_anyway", "Continue anyway") }}
142
+ </button>
143
+ </div>
144
+ </template>
110
145
  </div>
111
146
  </template>
112
147
 
@@ -130,6 +165,7 @@ function formatSeconds(s: number): string {
130
165
 
131
166
  &__actions {
132
167
  display: flex;
168
+ gap: 8px;
133
169
  }
134
170
 
135
171
  &__recording {
@@ -18,6 +18,8 @@ import {
18
18
  * audio only — a real limitation, not an oversight), duration capped
19
19
  * server-side (config playtest.video.max_duration_seconds).
20
20
  */
21
+ type PendingMarker = { timestamp_seconds: number; comment: string; severity?: string };
22
+
21
23
  export function usePlaytestVideoRecording(campaignId: number) {
22
24
  const capturing = ref(false);
23
25
  const uploading = ref(false);
@@ -29,12 +31,45 @@ export function usePlaytestVideoRecording(campaignId: number) {
29
31
  const recording = ref<PlaytestRecording | null>(null);
30
32
  const recordedBlob = ref<Blob | null>(null);
31
33
  const recordedDurationSeconds = ref(0);
32
- const markers = ref<{ timestamp_seconds: number; comment: string; severity?: string }[]>([]);
34
+ const markers = ref<PendingMarker[]>([]);
35
+ // Markers a submitted response still needs posted — set when the post-submit
36
+ // loop hits an error partway through, so the UI can offer a retry instead of
37
+ // silently dropping the remainder (see storeRecordingMarker loop below).
38
+ const markerSubmitErrors = ref(0);
33
39
 
34
40
  let mediaRecorder: MediaRecorder | null = null;
35
41
  let chunks: BlobPart[] = [];
36
42
  let captureStartedAt = 0;
37
43
 
44
+ // Markers only ever live in this Vue ref + localStorage until they're
45
+ // posted — a crash between adding a marker and a successful submit loses
46
+ // nothing beyond that point (the recording Blob itself is still
47
+ // memory-only and can't survive a reload; that's a separate, harder
48
+ // problem this doesn't attempt to solve).
49
+ const storageKey = `mgn_playtest_pending_markers_${campaignId}`;
50
+
51
+ function persistMarkers() {
52
+ try {
53
+ if (markers.value.length) {
54
+ localStorage.setItem(storageKey, JSON.stringify(markers.value));
55
+ } else {
56
+ localStorage.removeItem(storageKey);
57
+ }
58
+ } catch {
59
+ // localStorage unavailable (private mode / quota) — best-effort only.
60
+ }
61
+ }
62
+
63
+ /** Restores markers left over from a previous capture on this campaign that never made it to a submit. */
64
+ function restorePendingMarkers(): PendingMarker[] {
65
+ try {
66
+ const raw = localStorage.getItem(storageKey);
67
+ return raw ? (JSON.parse(raw) as PendingMarker[]) : [];
68
+ } catch {
69
+ return [];
70
+ }
71
+ }
72
+
38
73
  function resetError() {
39
74
  genericError.value = null;
40
75
  }
@@ -50,6 +85,12 @@ export function usePlaytestVideoRecording(campaignId: number) {
50
85
 
51
86
  async function startCapture() {
52
87
  resetError();
88
+ // A previous capture on this campaign may have left markers behind
89
+ // (crash/reload before a successful submit) — recover them rather than
90
+ // silently starting from an empty list.
91
+ const leftover = restorePendingMarkers();
92
+ if (leftover.length) markers.value = leftover;
93
+
53
94
  try {
54
95
  const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
55
96
  chunks = [];
@@ -80,6 +121,7 @@ export function usePlaytestVideoRecording(campaignId: number) {
80
121
  /** Adds a marker anchored to the current elapsed recording time. */
81
122
  function markNow(comment: string, severity?: string) {
82
123
  markers.value.push({ timestamp_seconds: elapsedSeconds(), comment, severity });
124
+ persistMarkers();
83
125
  }
84
126
 
85
127
  /** Requests the upload slot and uploads the recorded blob directly to the video provider. */
@@ -118,6 +160,60 @@ export function usePlaytestVideoRecording(campaignId: number) {
118
160
  }
119
161
  }
120
162
 
163
+ let lastSubmittedResponseId: number | null = null;
164
+
165
+ /**
166
+ * Marker timestamps are wall-clock elapsed time from when capture started,
167
+ * not the real position in the final encoded file — any delay before
168
+ * MediaRecorder actually starts encoding, or a hiccup mid-recording, drifts
169
+ * them apart. Once the provider confirms the real duration (post-submit,
170
+ * since submit only succeeds once the recording is 'ready'), scale every
171
+ * marker proportionally against the wall-clock duration we measured — a
172
+ * practical correction, not frame-perfect, but far closer than raw elapsed
173
+ * seconds once the two durations diverge.
174
+ */
175
+ function correctForDrift(realDurationSeconds: number | null | undefined) {
176
+ if (!realDurationSeconds || !recordedDurationSeconds.value) return;
177
+ const scale = realDurationSeconds / recordedDurationSeconds.value;
178
+ if (!isFinite(scale) || scale <= 0 || scale === 1) return;
179
+ markers.value = markers.value.map((m) => ({
180
+ ...m,
181
+ timestamp_seconds: Math.min(realDurationSeconds, Math.max(0, Math.round(m.timestamp_seconds * scale))),
182
+ }));
183
+ persistMarkers();
184
+ }
185
+
186
+ /**
187
+ * Posts whatever's left in markers.value for responseId, one at a time.
188
+ * A failure on one marker does NOT abort the rest — each is independent
189
+ * feedback, so losing one shouldn't cost the others. Successfully-posted
190
+ * markers are removed from the pending list (and localStorage) as they
191
+ * succeed; anything left after this call can be retried via
192
+ * retryPendingMarkers() instead of being silently dropped.
193
+ */
194
+ async function postPendingMarkers(responseId: number) {
195
+ lastSubmittedResponseId = responseId;
196
+ const remaining: PendingMarker[] = [];
197
+ let failures = 0;
198
+ for (const m of markers.value) {
199
+ try {
200
+ await storeRecordingMarker(responseId, m as any);
201
+ } catch {
202
+ remaining.push(m);
203
+ failures++;
204
+ }
205
+ }
206
+ markers.value = remaining;
207
+ markerSubmitErrors.value = failures;
208
+ persistMarkers();
209
+ }
210
+
211
+ /** Retries any markers that failed to post during submitVideoResponse(). */
212
+ async function retryPendingMarkers() {
213
+ if (!lastSubmittedResponseId || !markers.value.length) return;
214
+ await postPendingMarkers(lastSubmittedResponseId);
215
+ }
216
+
121
217
  /**
122
218
  * Submits the response once the backend confirms the recording is ready.
123
219
  * Processing is async (the provider's webhook updates our DB after
@@ -135,6 +231,7 @@ export function usePlaytestVideoRecording(campaignId: number) {
135
231
  return null;
136
232
  }
137
233
  resetError();
234
+ markerSubmitErrors.value = 0;
138
235
  submitting.value = true;
139
236
  try {
140
237
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -144,9 +241,13 @@ export function usePlaytestVideoRecording(campaignId: number) {
144
241
  recording_id: recordingId.value,
145
242
  ...meta,
146
243
  });
147
- for (const m of markers.value) {
148
- await storeRecordingMarker(data.id, m as any);
244
+ try {
245
+ const fetched = await fetchRecording(data.id);
246
+ correctForDrift(fetched.data.data?.duration_seconds ?? null);
247
+ } catch {
248
+ // Non-fatal — fall back to uncorrected (wall-clock) timestamps.
149
249
  }
250
+ await postPendingMarkers(data.id);
150
251
  return data;
151
252
  } catch (e: any) {
152
253
  if (attempt === maxAttempts) throw e;
@@ -179,11 +280,13 @@ export function usePlaytestVideoRecording(campaignId: number) {
179
280
  recordedBlob,
180
281
  recordedDurationSeconds,
181
282
  markers,
283
+ markerSubmitErrors,
182
284
  startCapture,
183
285
  stopCapture,
184
286
  markNow,
185
287
  uploadRecording,
186
288
  submitVideoResponse,
289
+ retryPendingMarkers,
187
290
  loadRecording,
188
291
  };
189
292
  }
package/error.vue CHANGED
@@ -51,13 +51,13 @@ onUnmounted(() => {
51
51
 
52
52
  <template>
53
53
  <NuxtLayout>
54
- <errors401 v-if="statusCodeError === HttpStatusCode.Unauthorized" />
55
- <errors403 v-else-if="statusCodeError === HttpStatusCode.Forbidden" />
56
- <errors404 v-else-if="statusCodeError === HttpStatusCode.NotFound" />
57
- <errors500 v-else-if="statusCodeError === HttpStatusCode.InternalServerError" />
58
- <errors503 v-else-if="statusCodeError === HttpStatusCode.ServiceUnavailable" />
59
- <errors504 v-else-if="statusCodeError === HttpStatusCode.GatewayTimeout" />
60
- <errors500 v-else />
54
+ <Error401 v-if="statusCodeError === HttpStatusCode.Unauthorized" />
55
+ <Error403 v-else-if="statusCodeError === HttpStatusCode.Forbidden" />
56
+ <Error404 v-else-if="statusCodeError === HttpStatusCode.NotFound" />
57
+ <Error500 v-else-if="statusCodeError === HttpStatusCode.InternalServerError" />
58
+ <Error503 v-else-if="statusCodeError === HttpStatusCode.ServiceUnavailable" />
59
+ <Error504 v-else-if="statusCodeError === HttpStatusCode.GatewayTimeout" />
60
+ <Error500 v-else />
61
61
 
62
62
  <p v-if="isRecoverableError" class="recovery-line">
63
63
  {{ $t("errors.recovery_hint") }}
package/locales/de.json CHANGED
@@ -151,7 +151,7 @@
151
151
  "description_optional": "Beschreibung (optional)",
152
152
  "description_placeholder": "Erzähl uns etwas über deinen Kanal...",
153
153
  "sending": "Wird gesendet...",
154
- "request_sent": "Anfrage gesendet! Weiterleitung...",
154
+ "request_sent": "Anfrage gesendet! Wir informieren dich per E-Mail, sobald sie geprüft wurde.",
155
155
  "error_loading": "Kampagnendaten konnten nicht geladen werden.",
156
156
  "error_request": "Anfrage konnte nicht gesendet werden. Bitte erneut versuchen.",
157
157
  "error_reveal": "Schlüssel konnte nicht enthüllt werden.",
package/locales/en.json CHANGED
@@ -151,7 +151,7 @@
151
151
  "description_optional": "Description (optional)",
152
152
  "description_placeholder": "Tell us a bit about your channel or content plans...",
153
153
  "sending": "Sending...",
154
- "request_sent": "Request sent! Redirecting...",
154
+ "request_sent": "Request sent! We'll email you once it's reviewed.",
155
155
  "error_loading": "Failed to load campaign data.",
156
156
  "error_request": "Could not submit request. Please try again.",
157
157
  "error_reveal": "Could not reveal key. Please try again.",
@@ -151,7 +151,7 @@
151
151
  "description_optional": "Descrição (opcional)",
152
152
  "description_placeholder": "Conte um pouco sobre seu canal ou planos de conteúdo...",
153
153
  "sending": "Enviando...",
154
- "request_sent": "Solicitação enviada! Redirecionando...",
154
+ "request_sent": "Solicitação enviada! Você será avisado por e-mail assim que for revisada.",
155
155
  "error_loading": "Falha ao carregar dados da campanha.",
156
156
  "error_request": "Não foi possível enviar a solicitação. Tente novamente.",
157
157
  "error_reveal": "Não foi possível revelar a chave. Tente novamente.",
package/locales/ro.json CHANGED
@@ -151,7 +151,7 @@
151
151
  "description_optional": "Descriere (opțional)",
152
152
  "description_placeholder": "Spune-ne puțin despre canalul tău...",
153
153
  "sending": "Se trimite...",
154
- "request_sent": "Cerere trimisă! Redirecționare...",
154
+ "request_sent": "Cerere trimisă! Te vom anunța prin e-mail după ce este analizată.",
155
155
  "error_loading": "Eroare la încărcarea datelor campaniei.",
156
156
  "error_request": "Nu s-a putut trimite cererea. Încearcă din nou.",
157
157
  "error_reveal": "Nu s-a putut dezvălui cheia.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -31,6 +31,7 @@
31
31
  "@pinia-plugin-persistedstate/nuxt": ">=1.0.0",
32
32
  "@pinia/nuxt": ">=0.5.0",
33
33
  "axios": ">=1.0.0",
34
+ "hls.js": ">=1.5.0",
34
35
  "laravel-echo": ">=1.15.0",
35
36
  "nuxt": ">=3.13.0",
36
37
  "pinia": ">=2.1.0",
@@ -41,6 +42,7 @@
41
42
  "@pinia-plugin-persistedstate/nuxt": "^1.2.1",
42
43
  "@pinia/nuxt": "^0.5.5",
43
44
  "axios": "^1.18.1",
45
+ "hls.js": "^1.6.16",
44
46
  "laravel-echo": "^2.3.7",
45
47
  "nuxt": "^3.15.4",
46
48
  "pinia": "^2.3.1",
@@ -203,6 +203,9 @@ function goBack() {
203
203
  <div v-else-if="success" class="state success">
204
204
  <MGIcon icon="check-circle" size="3x" />
205
205
  <p>{{ $t("keys.campaigns.request_sent") }}</p>
206
+ <button class="btn" @click="goBack">
207
+ {{ $t("keys.campaigns.back_to_campaign") }}
208
+ </button>
206
209
  </div>
207
210
 
208
211
  <div v-else class="form-content">
@@ -214,6 +214,7 @@ const navSections = computed(() => {
214
214
  if (awards.value.length) s.push({ id: 'awards', label: 'kit.press.awards' });
215
215
  if (team.value.length) s.push({ id: 'team', label: 'kit.press.team' });
216
216
  if (hasEcosystem.value) s.push({ id: 'events', label: 'kit.press.showcases' });
217
+ if (k.game_id && keyPoolsVisible.value) s.push({ id: 'review-key', label: 'kit.press.request_review_key' });
217
218
  if (k.press_contact_email) s.push({ id: 'contact', label: 'kit.press.contact' });
218
219
  return s;
219
220
  });
@@ -338,6 +339,11 @@ useHead(() => ({
338
339
  <span v-else class="kit-btn kit-btn-primary kit-btn-disabled" :title="$t('kit.press.no_assets')">{{ $t('kit.press.no_assets') }}</span>
339
340
  </template>
340
341
  <a v-if="kit.press_contact_email" class="kit-btn kit-btn-ghost" :href="`mailto:${kit.press_contact_email}`" @click="track('contact')">{{ $t('kit.press.request_key') }}</a>
342
+ <!-- Real UnifiedKeyRequestController-backed flow (KeyBrowser below) — only
343
+ offered when the kit is actually linked to a game with open pools, same
344
+ gate as the section itself, so this never links to an empty/broken state. -->
345
+ <a v-if="kit.game_id" class="kit-btn kit-btn-ghost" href="#review-key" @click.prevent="scrollToSection('review-key')">{{ $t('kit.press.request_review_key') }}</a>
346
+ <a class="kit-btn kit-btn-ghost" :href="`${agencyBase}/podcast-guest`" target="_blank" rel="noopener" @click="track('click')">{{ $t('kit.press.request_interview') }}</a>
341
347
  </div>
342
348
  </div>
343
349
  </div>
@@ -492,7 +498,7 @@ useHead(() => ({
492
498
  </section>
493
499
 
494
500
  <!-- Key pools embed — shown when game has active pools -->
495
- <section v-if="kit.game_id" v-show="keyPoolsVisible" class="kit-block kit-keys-section">
501
+ <section v-if="kit.game_id" v-show="keyPoolsVisible" id="review-key" class="kit-block kit-keys-section">
496
502
  <h2 class="kit-keys-title">{{ $t('kit.press.get_key_title') }}</h2>
497
503
  <p class="kit-keys-desc">{{ $t('kit.press.get_key_desc') }}</p>
498
504
  <KeyBrowser
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes