@mundogamernetwork/shared-ui 1.13.5 → 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.
package/assets/kit/kit-base.css
CHANGED
|
@@ -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
|
-
(
|
|
36
|
+
(playableVideos.value.find(v => v.is_primary) || playableVideos.value[0])?.id ?? null,
|
|
24
37
|
);
|
|
25
38
|
const activeVideo = computed(() =>
|
|
26
|
-
|
|
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="
|
|
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
|
|
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="
|
|
136
|
+
<div v-if="playableVideos.length > 1" class="video-list">
|
|
79
137
|
<div
|
|
80
|
-
v-for="video in
|
|
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)"
|
package/package.json
CHANGED
|
@@ -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">
|
|
@@ -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}
|
|
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)
|