@mundogamernetwork/shared-ui 1.13.4 → 1.13.6

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.
@@ -40,6 +40,12 @@ body:has(.kit-press) {
40
40
  padding-top: 15vh;
41
41
  width: 100%;
42
42
  }
43
+ .kit-state--locked {
44
+ min-height: 100vh;
45
+ justify-content: flex-start;
46
+ padding-top: 20vh;
47
+ width: 100%;
48
+ }
43
49
  .kit-state__desc {
44
50
  max-width: 420px;
45
51
  text-align: center;
@@ -1092,6 +1098,27 @@ body:has(.kit-press) {
1092
1098
  .kit-keys-form .kit-btn {
1093
1099
  align-self: flex-start;
1094
1100
  }
1101
+ .kit-unlock-form {
1102
+ max-width: 320px;
1103
+ width: 100%;
1104
+ align-items: stretch;
1105
+ }
1106
+ .kit-unlock-form input {
1107
+ background: var(--kit-card, #1a1e27);
1108
+ border: 1px solid var(--kit-line, #252a34);
1109
+ color: var(--kit-text, #f4f6fa);
1110
+ padding: 10px 12px;
1111
+ font-size: 0.9rem;
1112
+ font-family: inherit;
1113
+ width: 100%;
1114
+ }
1115
+ .kit-unlock-form input:focus {
1116
+ outline: none;
1117
+ border-color: var(--kit-accent, #FDB215);
1118
+ }
1119
+ .kit-unlock-form .kit-btn {
1120
+ align-self: center;
1121
+ }
1095
1122
 
1096
1123
  /* ── Responsive ─────────────────────────────────────────────────────*/
1097
1124
  @media (max-width: 900px) {
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ import Hls from 'hls.js';
3
+
2
4
  interface Video {
3
5
  id: number;
4
6
  title: string;
@@ -9,6 +11,11 @@ interface Video {
9
11
  sort_order?: number;
10
12
  /** Spoken/subtitled language, for studios shipping a dubbed cut per market. */
11
13
  language?: { name?: string; native_name?: string; name_abrev?: string; flag_url?: string } | null;
14
+ /** null = a pasted link (YouTube/Vimeo/external), always playable. Set =
15
+ * hosted by us (Cloudflare Stream); only status 'ready' has a real url. */
16
+ provider?: string | null;
17
+ status?: string | null;
18
+ duration_seconds?: number | null;
12
19
  }
13
20
 
14
21
  interface Props {
@@ -17,13 +24,19 @@ interface Props {
17
24
 
18
25
  const props = defineProps<Props>();
19
26
 
27
+ // A provider-hosted video that's still uploading/processing/failed has no
28
+ // real playback URL yet — filtering it out here is simpler and safer than
29
+ // rendering a broken player for it. It reappears on its own once a later
30
+ // page load picks up the 'ready' status from the webhook.
31
+ const playableVideos = computed(() => props.videos.filter(v => !v.provider || v.status === 'ready'));
32
+
20
33
  // Initialize synchronously so direct public-page loads also have a player
21
34
  // during SSR, instead of waiting for client hydration.
22
35
  const selectedVideoId = ref<number | null>(
23
- (props.videos.find(v => v.is_primary) || props.videos[0])?.id ?? null,
36
+ (playableVideos.value.find(v => v.is_primary) || playableVideos.value[0])?.id ?? null,
24
37
  );
25
38
  const activeVideo = computed(() =>
26
- props.videos.find(v => v.id === selectedVideoId.value) || props.videos[0] || null,
39
+ playableVideos.value.find(v => v.id === selectedVideoId.value) || playableVideos.value[0] || null,
27
40
  );
28
41
  const playing = ref(false);
29
42
 
@@ -44,18 +57,63 @@ function isDirectVideo(url: string) {
44
57
  return /\.(mp4|webm|ogg)(?:[?#].*)?$/i.test(url);
45
58
  }
46
59
 
60
+ // Cloudflare Stream's playback URL is an HLS manifest (.m3u8) — Safari plays
61
+ // it natively via the plain <video> tag, every other browser needs hls.js
62
+ // (MediaSource Extensions). Same split as Playtest's RecordedSessionPlayer.
63
+ function isHlsVideo(video: Video) {
64
+ return video.provider === 'cloudflare_stream' || /\.m3u8(?:[?#].*)?$/i.test(video.url);
65
+ }
66
+
47
67
  function formatType(type?: string) {
48
68
  if (!type) return '';
49
69
  return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
50
70
  }
71
+
72
+ const hlsVideoEl = ref<HTMLVideoElement | null>(null);
73
+ let hls: Hls | null = null;
74
+
75
+ function destroyHls() {
76
+ if (hls) {
77
+ hls.destroy();
78
+ hls = null;
79
+ }
80
+ }
81
+
82
+ function setupHls() {
83
+ destroyHls();
84
+
85
+ const video = hlsVideoEl.value;
86
+ const source = activeVideo.value;
87
+ if (!video || !source || !isHlsVideo(source) || !source.url) return;
88
+
89
+ if (Hls.isSupported()) {
90
+ hls = new Hls();
91
+ hls.loadSource(source.url);
92
+ hls.attachMedia(video);
93
+ } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
94
+ // Safari and other browsers with native HLS support.
95
+ video.src = source.url;
96
+ }
97
+ }
98
+
99
+ onMounted(setupHls);
100
+ watch(activeVideo, () => nextTick(setupHls));
101
+ onBeforeUnmount(destroyHls);
51
102
  </script>
52
103
 
53
104
  <template>
54
- <div v-if="videos.length" class="video-player">
105
+ <div v-if="playableVideos.length" class="video-player">
55
106
  <h3 class="section-title">{{ $t('press_kit.videos') }}</h3>
56
107
 
57
108
  <div v-if="activeVideo" class="video-embed">
58
- <video v-if="isDirectVideo(activeVideo.url)" :src="activeVideo.url" :poster="activeVideo.thumbnail_url || undefined" controls preload="metadata" />
109
+ <video
110
+ v-if="isHlsVideo(activeVideo)"
111
+ ref="hlsVideoEl"
112
+ :poster="activeVideo.thumbnail_url || undefined"
113
+ controls
114
+ preload="metadata"
115
+ />
116
+ <video v-else-if="isDirectVideo(activeVideo.url)" :src="activeVideo.url" :poster="activeVideo.thumbnail_url || undefined" controls preload="metadata" />
59
117
  <button
60
118
  v-else-if="activeVideo.thumbnail_url && !playing"
61
119
  type="button"
@@ -75,9 +133,9 @@ function formatType(type?: string) {
75
133
  />
76
134
  </div>
77
135
 
78
- <div v-if="videos.length > 1" class="video-list">
136
+ <div v-if="playableVideos.length > 1" class="video-list">
79
137
  <div
80
- v-for="video in videos"
138
+ v-for="video in playableVideos"
81
139
  :key="video.id"
82
140
  :class="['video-item', { active: activeVideo?.id === video.id }]"
83
141
  @click="selectVideo(video.id)"
@@ -5,13 +5,15 @@ import { fetchGamificationSummary, type GamificationSummary } from "../../servic
5
5
  const props = withDefaults(
6
6
  defineProps<{
7
7
  /**
8
- * Where to send the user to see and claim their tasks. Only Community
9
- * hosts those pages, so leaving this unset points at Community — which
10
- * is the right destination from every other front. Community itself
11
- * passes its own internal route to avoid a full page load.
8
+ * Where to send the user to see and claim their tasks. The missions
9
+ * list is centralized on network-accounts (one page, every platform,
10
+ * with cross-vertical filters) leaving this unset points there,
11
+ * which is the right destination from every front. A front that still
12
+ * has its own local missions page should pass its own internal route
13
+ * here to avoid a full page load; most no longer do.
12
14
  */
13
15
  missionsUrl?: string;
14
- /** Locale segment for the default Community destination. */
16
+ /** Locale segment for the default network-accounts destination. */
15
17
  locale?: string;
16
18
  /** Renders without the title line, for tight spaces. */
17
19
  compact?: boolean;
@@ -23,14 +25,25 @@ const props = withDefaults(
23
25
  mgcBalance?: number | null;
24
26
  /** Hide the balance where the host already shows it elsewhere. */
25
27
  showBalance?: boolean;
28
+ /**
29
+ * Pre-fetched summary from a call the host already makes elsewhere
30
+ * (e.g. its own dashboard composable, through its own configured HTTP
31
+ * client). When set — even to `null` while the host's own fetch is
32
+ * still resolving — this component skips its internal fetch entirely,
33
+ * avoiding a second request to the same endpoint through a second,
34
+ * independently-configured base URL that can drift from the host's
35
+ * own over time. Leave unset (`undefined`) to keep the original
36
+ * self-fetching behaviour.
37
+ */
38
+ summary?: GamificationSummary | null;
26
39
  }>(),
27
- { missionsUrl: "", locale: "", compact: false, mgcBalance: null, showBalance: true },
40
+ { missionsUrl: "", locale: "", compact: false, mgcBalance: null, showBalance: true, summary: undefined },
28
41
  );
29
42
 
30
43
  const emit = defineEmits<{ (e: "navigate"): void }>();
31
44
 
32
- /** Missions and the reward store live on Community for the whole ecosystem. */
33
- const COMMUNITY_URL = "https://mundogamer.community";
45
+ /** The centralized missions page lives on network-accounts for the whole ecosystem. */
46
+ const ACCOUNTS_URL = "https://accounts.mundogamer.network";
34
47
 
35
48
  /**
36
49
  * Dig out the payload regardless of how many envelopes wrap it.
@@ -53,8 +66,15 @@ function unwrapSummary(payload: any): GamificationSummary | null {
53
66
  return null;
54
67
  }
55
68
 
56
- const summary = ref<GamificationSummary | null>(null);
57
- const loaded = ref(false);
69
+ const fetchedSummary = ref<GamificationSummary | null>(null);
70
+ const selfLoaded = ref(false);
71
+
72
+ /** The host manages this prop as soon as it's bound, even before its own fetch resolves — `undefined` only when the prop was never passed at all. */
73
+ const isHostManaged = computed(() => props.summary !== undefined);
74
+ const summary = computed(() => (isHostManaged.value ? props.summary ?? null : fetchedSummary.value));
75
+ // Host-managed: loaded once the host's data actually arrives (an unresolved
76
+ // host fetch must keep showing the skeleton, not the empty/hidden state).
77
+ const loaded = computed(() => (isHostManaged.value ? summary.value !== null : selfLoaded.value));
58
78
 
59
79
  const level = computed(() => summary.value?.level ?? null);
60
80
  const claimable = computed(() => summary.value?.missions?.claimable_count ?? 0);
@@ -70,7 +90,7 @@ const destination = computed(() => {
70
90
  (typeof document !== "undefined" ? document.documentElement.lang : "") ||
71
91
  "en";
72
92
 
73
- return `${COMMUNITY_URL}/${locale}/gamification/missions`;
93
+ return `${ACCOUNTS_URL}/${locale}/account/missions`;
74
94
  });
75
95
 
76
96
  const balance = computed(() => {
@@ -95,18 +115,20 @@ const percent = computed(() => {
95
115
  });
96
116
 
97
117
  onMounted(async () => {
118
+ if (isHostManaged.value) return; // host supplies (and reactively updates) props.summary itself
119
+
98
120
  try {
99
121
  const response = await fetchGamificationSummary();
100
- summary.value = unwrapSummary(response?.data);
122
+ fetchedSummary.value = unwrapSummary(response?.data);
101
123
  } catch (error) {
102
124
  // Never let a gamification hiccup break the header it sits in — but say
103
125
  // so. Swallowing this silently made a deployed bar that renders nothing
104
126
  // indistinguishable from a bar that was never deployed, and cost hours
105
127
  // of bundle forensics to tell apart.
106
128
  console.warn("[MgXpLevelBar] gamification dashboard unavailable", error);
107
- summary.value = null;
129
+ fetchedSummary.value = null;
108
130
  } finally {
109
- loaded.value = true;
131
+ selfLoaded.value = true;
110
132
  }
111
133
  });
112
134
  </script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.13.4",
3
+ "version": "1.13.6",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -32,6 +32,13 @@ const lang = SUPPORTED_LOCALES.find(l => routePath.includes(l)) || 'en';
32
32
  // kits render on the same public page without needing to be published first.
33
33
  const previewToken = route.query.preview_token as string | undefined;
34
34
 
35
+ // Proof a visitor already unlocked a password-protected kit, once obtained
36
+ // from POST .../unlock. Starts null on every render (including SSR, which has
37
+ // no localStorage) — a returning visitor's token is picked up client-side in
38
+ // the onMounted below and triggers one silent refresh.
39
+ const accessToken = ref<string | null>(null);
40
+ const ACCESS_TOKEN_STORAGE_KEY = `kit_access_${slug}`;
41
+
35
42
  const { data: kitData, error: fetchError, pending, refresh: refreshKit } = await useAsyncData(
36
43
  `press-kit-${slug}`,
37
44
  () => $fetch<any>(`${apiBase}/public/press-kits/${slug}`, {
@@ -39,6 +46,7 @@ const { data: kitData, error: fetchError, pending, refresh: refreshKit } = await
39
46
  include: 'assets,videos,credits,awards,quotes',
40
47
  lang,
41
48
  ...(previewToken ? { preview_token: previewToken } : {}),
49
+ ...(accessToken.value ? { access_token: accessToken.value } : {}),
42
50
  },
43
51
  }),
44
52
  { lazy: false },
@@ -70,6 +78,53 @@ const loadFailed = computed(
70
78
  );
71
79
  const notFound = computed(() => !pending.value && !loadFailed.value && !kit.value);
72
80
 
81
+ // The public controller returns { slug, name, is_password_protected, locked }
82
+ // with everything else stripped when a visitor hasn't unlocked the kit yet.
83
+ const locked = computed(() => !!kit.value?.locked);
84
+
85
+ const unlockPasswordInput = ref('');
86
+ const unlocking = ref(false);
87
+ const unlockError = ref('');
88
+
89
+ async function submitUnlock() {
90
+ if (!unlockPasswordInput.value || unlocking.value) return;
91
+ unlocking.value = true;
92
+ unlockError.value = '';
93
+ try {
94
+ const res = await $fetch<any>(`${apiBase}/public/press-kits/${slug}/unlock`, {
95
+ method: 'POST',
96
+ body: { password: unlockPasswordInput.value },
97
+ });
98
+ const token = res?.data?.access_token;
99
+ if (!token) throw new Error('no token');
100
+ accessToken.value = token;
101
+ try {
102
+ localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, token);
103
+ } catch {
104
+ // Private browsing / storage disabled — the unlock still works for
105
+ // this page load, it just won't be remembered on the next visit.
106
+ }
107
+ await refreshKit();
108
+ } catch {
109
+ unlockError.value = t('kit.press.unlock_error');
110
+ } finally {
111
+ unlocking.value = false;
112
+ }
113
+ }
114
+
115
+ onMounted(() => {
116
+ if (!locked.value) return;
117
+ try {
118
+ const stored = localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY);
119
+ if (stored) {
120
+ accessToken.value = stored;
121
+ refreshKit();
122
+ }
123
+ } catch {
124
+ // No stored grant, or storage unavailable — visitor re-enters the password.
125
+ }
126
+ });
127
+
73
128
  const retrying = ref(false);
74
129
  async function retryKit() {
75
130
  retrying.value = true;
@@ -85,6 +140,7 @@ onMounted(() => {
85
140
  trackPressKitEvent(slug, {
86
141
  event_type: 'view',
87
142
  referrer: document.referrer || undefined,
143
+ experiments: experimentTags.value,
88
144
  }).catch(() => {});
89
145
  }
90
146
  });
@@ -334,9 +390,16 @@ const moreKits = computed(() => {
334
390
  return items.filter((k: any) => k.slug !== slug).slice(0, 10);
335
391
  });
336
392
 
393
+ // Tags for whichever running experiments assigned this visitor a variant —
394
+ // echoed back on every event so a click/download/contact attributes to the
395
+ // right variant. See PublicPressKitController::applyExperiments().
396
+ const experimentTags = computed(() =>
397
+ (kit.value?.active_experiments || []).map((e: any) => ({ experiment_id: e.experiment_id, variant_id: e.variant_id })),
398
+ );
399
+
337
400
  function track(type: 'click' | 'download' | 'contact') {
338
401
  if (previewToken) return;
339
- trackPressKitEvent(slug, { event_type: type, referrer: document.referrer || undefined }).catch(() => {});
402
+ trackPressKitEvent(slug, { event_type: type, referrer: document.referrer || undefined, experiments: experimentTags.value }).catch(() => {});
340
403
  }
341
404
 
342
405
  const requestUrl = useRequestURL();
@@ -413,6 +476,21 @@ useHead(() => ({
413
476
  </section>
414
477
  </div>
415
478
 
479
+ <div v-else-if="locked" class="kit-state kit-state--locked">
480
+ <h2>{{ kit.name }}</h2>
481
+ <p class="kit-state__desc">{{ $t('kit.press.locked_desc') }}</p>
482
+ <form class="kit-keys-form kit-unlock-form" @submit.prevent="submitUnlock">
483
+ <div class="kit-keys-field">
484
+ <label>{{ $t('kit.press.locked_placeholder') }}</label>
485
+ <input v-model="unlockPasswordInput" type="password" autocomplete="off" />
486
+ </div>
487
+ <p v-if="unlockError" class="kit-keys-error">{{ unlockError }}</p>
488
+ <button type="submit" class="kit-btn kit-btn-primary" :disabled="unlocking">
489
+ {{ unlocking ? $t('kit.loading') : $t('kit.press.unlock_cta') }}
490
+ </button>
491
+ </form>
492
+ </div>
493
+
416
494
  <template v-else>
417
495
  <!-- CAPA / COVER -->
418
496
  <header class="kit-cover">
@@ -37,6 +37,19 @@ export function getHttpService(): AxiosInstance {
37
37
  withCredentials: true,
38
38
  });
39
39
 
40
+ // Dev-only bearer token injection lives on `defaults.headers.common` rather
41
+ // than inside the request interceptor below: axios 1.19's `mergeConfig`
42
+ // (invoked on every dispatch via resolveConfig.js) converts an AxiosHeaders
43
+ // instance to a plain object via `{ ...thing }` before merging, and an
44
+ // Authorization value set inside a request interceptor does not survive
45
+ // that spread — it never reaches the server. Already discovered and fixed
46
+ // the same way in jobs-frontend's own httpService.ts; this file had the
47
+ // same bug independently. `defaults.headers.common` goes through axios's
48
+ // own default-header merge path instead, which does not hit it.
49
+ if (import.meta.env.VITE_APP_ENV !== "production" && import.meta.env.VITE_BEARER_TOKEN) {
50
+ _httpService.defaults.headers.common.Authorization = `Bearer ${import.meta.env.VITE_BEARER_TOKEN}`;
51
+ }
52
+
40
53
  _httpService.interceptors.response.use(
41
54
  (response) => response,
42
55
  (error) => {
@@ -88,9 +101,6 @@ export function getHttpService(): AxiosInstance {
88
101
  config.params = { ...config.params, lang };
89
102
 
90
103
  if (import.meta.env.VITE_APP_ENV !== "production" && typeof window !== "undefined") {
91
- if (import.meta.env.VITE_BEARER_TOKEN) {
92
- config.headers.Authorization = `Bearer ${import.meta.env.VITE_BEARER_TOKEN}`;
93
- }
94
104
  config.headers.set("X-Timezone", Intl.DateTimeFormat().resolvedOptions().timeZone);
95
105
  }
96
106
 
@@ -44,8 +44,19 @@ const pk = new Proxy({} as AxiosInstance, {
44
44
 
45
45
  export default pk
46
46
 
47
- export const fetchPublicPressKit = (slug: string) =>
48
- pk.get(`/public/press-kits/${slug}?include=assets,videos,credits,awards,quotes`)
47
+ export const fetchPublicPressKit = (slug: string, accessToken?: string | null) =>
48
+ pk.get(`/public/press-kits/${slug}`, {
49
+ params: {
50
+ include: "assets,videos,credits,awards,quotes",
51
+ ...(accessToken ? { access_token: accessToken } : {}),
52
+ },
53
+ })
54
+
55
+ // Unlocks a password-protected kit. On success the response carries
56
+ // { access_token, expires_at } — the caller persists the token (localStorage,
57
+ // keyed by slug) and replays it on the next fetchPublicPressKit call.
58
+ export const unlockPressKit = (slug: string, password: string) =>
59
+ pk.post(`/public/press-kits/${slug}/unlock`, { password })
49
60
 
50
61
  export const trackPressKitEvent = (
51
62
  slug: string,
@@ -53,5 +64,8 @@ export const trackPressKitEvent = (
53
64
  event_type: "view" | "click" | "download" | "contact"
54
65
  referrer?: string
55
66
  metadata?: Record<string, any>
67
+ // Echoed from the kit's own active_experiments (see fetchPublicPressKit)
68
+ // so a view/conversion attributes back to the variant this visitor saw.
69
+ experiments?: { experiment_id: number; variant_id: number }[]
56
70
  },
57
71
  ) => pk.post(`/public/press-kits/${slug}/analytics`, data)