@mundogamernetwork/shared-ui 1.4.0 → 1.4.2
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 +88 -0
- package/components/indie-wall/MediaKitWallBlock.vue +16 -7
- package/components/indie-wall/StepCustomize.vue +8 -0
- package/components/indie-wall/StepGoal.vue +11 -1
- package/components/indie-wall/StepPackage.vue +13 -3
- package/components/indie-wall/StepPay.vue +11 -1
- package/components/indie-wall/SupportStepper.vue +9 -1
- package/components/ui/MgHeaderUIUser.vue +10 -1
- package/package.json +1 -1
- package/pages/key-campaigns/index.vue +47 -41
- package/pages/mural/[slug].vue +153 -6
- package/pages/presskit/game/[slug].vue +155 -3
package/assets/kit/kit-base.css
CHANGED
|
@@ -890,6 +890,94 @@ body:has(.kit-press) {
|
|
|
890
890
|
margin-top: 32px;
|
|
891
891
|
}
|
|
892
892
|
|
|
893
|
+
/* ── Press Kit's own key request flow (separate from the KeyBrowser
|
|
894
|
+
ecosystem widget) — auth wall, status states, and the request form. ── */
|
|
895
|
+
.kit-keys-mode-note {
|
|
896
|
+
font-size: 0.78rem;
|
|
897
|
+
color: var(--kit-muted, #8b92a0);
|
|
898
|
+
margin: -8px 0 0;
|
|
899
|
+
}
|
|
900
|
+
.kit-keys-loading {
|
|
901
|
+
height: 80px;
|
|
902
|
+
background: var(--kit-card, #1a1e27);
|
|
903
|
+
animation: kit-pulse 1.5s ease-in-out infinite;
|
|
904
|
+
}
|
|
905
|
+
@keyframes kit-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
|
906
|
+
.kit-keys-authwall,
|
|
907
|
+
.kit-keys-status {
|
|
908
|
+
display: flex;
|
|
909
|
+
flex-direction: column;
|
|
910
|
+
gap: 12px;
|
|
911
|
+
padding: 20px;
|
|
912
|
+
background: var(--kit-card, #1a1e27);
|
|
913
|
+
border: 1px solid var(--kit-line, #252a34);
|
|
914
|
+
}
|
|
915
|
+
.kit-keys-status p { margin: 0; color: var(--kit-text, #f4f6fa); font-size: 0.9rem; }
|
|
916
|
+
.kit-keys-status.status-rejected p { color: var(--kit-muted, #8b92a0); }
|
|
917
|
+
.kit-keys-code {
|
|
918
|
+
display: flex;
|
|
919
|
+
align-items: center;
|
|
920
|
+
gap: 12px;
|
|
921
|
+
flex-wrap: wrap;
|
|
922
|
+
}
|
|
923
|
+
.kit-keys-code code {
|
|
924
|
+
padding: 10px 16px;
|
|
925
|
+
background: var(--kit-bg, #13161c);
|
|
926
|
+
border: 1px dashed var(--kit-accent, #FDB215);
|
|
927
|
+
color: var(--kit-accent, #FDB215);
|
|
928
|
+
font-size: 0.95rem;
|
|
929
|
+
letter-spacing: 1px;
|
|
930
|
+
}
|
|
931
|
+
.kit-keys-form {
|
|
932
|
+
display: flex;
|
|
933
|
+
flex-direction: column;
|
|
934
|
+
gap: 14px;
|
|
935
|
+
}
|
|
936
|
+
.kit-keys-row {
|
|
937
|
+
display: flex;
|
|
938
|
+
gap: 14px;
|
|
939
|
+
flex-wrap: wrap;
|
|
940
|
+
}
|
|
941
|
+
.kit-keys-field {
|
|
942
|
+
display: flex;
|
|
943
|
+
flex-direction: column;
|
|
944
|
+
gap: 6px;
|
|
945
|
+
flex: 1;
|
|
946
|
+
min-width: 160px;
|
|
947
|
+
}
|
|
948
|
+
.kit-keys-field label {
|
|
949
|
+
font-size: 0.75rem;
|
|
950
|
+
text-transform: uppercase;
|
|
951
|
+
letter-spacing: 1px;
|
|
952
|
+
color: var(--kit-muted, #8b92a0);
|
|
953
|
+
}
|
|
954
|
+
.kit-keys-field select,
|
|
955
|
+
.kit-keys-field textarea {
|
|
956
|
+
background: var(--kit-card, #1a1e27);
|
|
957
|
+
border: 1px solid var(--kit-line, #252a34);
|
|
958
|
+
color: var(--kit-text, #f4f6fa);
|
|
959
|
+
padding: 10px 12px;
|
|
960
|
+
font-size: 0.9rem;
|
|
961
|
+
font-family: inherit;
|
|
962
|
+
resize: vertical;
|
|
963
|
+
}
|
|
964
|
+
.kit-keys-field select:focus,
|
|
965
|
+
.kit-keys-field textarea:focus {
|
|
966
|
+
outline: none;
|
|
967
|
+
border-color: var(--kit-accent, #FDB215);
|
|
968
|
+
}
|
|
969
|
+
.kit-keys-hint {
|
|
970
|
+
font-size: 0.75rem;
|
|
971
|
+
color: var(--kit-muted, #8b92a0);
|
|
972
|
+
}
|
|
973
|
+
.kit-keys-error {
|
|
974
|
+
font-size: 0.82rem;
|
|
975
|
+
color: #e74c3c;
|
|
976
|
+
}
|
|
977
|
+
.kit-keys-form .kit-btn {
|
|
978
|
+
align-self: flex-start;
|
|
979
|
+
}
|
|
980
|
+
|
|
893
981
|
/* ── Responsive ─────────────────────────────────────────────────────*/
|
|
894
982
|
@media (max-width: 900px) {
|
|
895
983
|
.kit-layout {
|
|
@@ -6,6 +6,15 @@ import SupportStepper from './SupportStepper.vue'
|
|
|
6
6
|
const props = defineProps<{ userId: number | string }>()
|
|
7
7
|
const localePath = useLocalePath()
|
|
8
8
|
const authStore = useAuthStore()
|
|
9
|
+
const { locale } = useI18n()
|
|
10
|
+
|
|
11
|
+
function formatMoney(amount: number, currency?: string | null) {
|
|
12
|
+
try {
|
|
13
|
+
return new Intl.NumberFormat(locale.value, { style: 'currency', currency: currency || 'USD' }).format(amount)
|
|
14
|
+
} catch {
|
|
15
|
+
return `${currency || ''} ${Number(amount).toFixed(2)}`.trim()
|
|
16
|
+
}
|
|
17
|
+
}
|
|
9
18
|
|
|
10
19
|
const wall = ref<any>(null)
|
|
11
20
|
const goals = ref<any[]>([])
|
|
@@ -64,14 +73,14 @@ async function reloadSupporters() {
|
|
|
64
73
|
|
|
65
74
|
const hoverTip = ref<{ supporter: any; x: number; y: number } | null>(null)
|
|
66
75
|
|
|
67
|
-
const supporterToast = ref<{ name: string; amount:
|
|
76
|
+
const supporterToast = ref<{ name: string; amount: number; currency: string } | null>(null)
|
|
68
77
|
let toastDismissTimer: ReturnType<typeof setTimeout> | null = null
|
|
69
78
|
|
|
70
79
|
function showSupporterToast(pixel: any) {
|
|
71
80
|
if (toastDismissTimer) clearTimeout(toastDismissTimer)
|
|
72
81
|
const name = pixel.is_anonymous ? 'Anonymous' : (pixel.title || pixel.user?.name || 'Anonymous')
|
|
73
82
|
const currency = wall.value?.currency ?? 'USD'
|
|
74
|
-
const amount = Number(pixel.amount_paid || 0)
|
|
83
|
+
const amount = Number(pixel.amount_paid || 0)
|
|
75
84
|
supporterToast.value = { name, amount, currency }
|
|
76
85
|
toastDismissTimer = setTimeout(() => { supporterToast.value = null }, 6000)
|
|
77
86
|
}
|
|
@@ -171,10 +180,10 @@ onUnmounted(() => {
|
|
|
171
180
|
|
|
172
181
|
<div class="mkw-progress-row">
|
|
173
182
|
<span class="mkw-raised">
|
|
174
|
-
{{
|
|
183
|
+
{{ formatMoney(Number(wall.raised_amount || 0), wall.currency) }} {{ $t('kit.media.raised') }}
|
|
175
184
|
</span>
|
|
176
185
|
<span v-if="totalPotential > 0" class="mkw-total">
|
|
177
|
-
/ {{ wall.currency
|
|
186
|
+
/ {{ formatMoney(totalPotential, wall.currency) }}
|
|
178
187
|
</span>
|
|
179
188
|
<span class="mkw-pct">{{ raisedPercent.toFixed(0) }}%</span>
|
|
180
189
|
</div>
|
|
@@ -206,7 +215,7 @@ onUnmounted(() => {
|
|
|
206
215
|
<span class="mkw-toast-icon">★</span>
|
|
207
216
|
<div class="mkw-toast-text">
|
|
208
217
|
<strong>{{ supporterToast.name }}</strong>
|
|
209
|
-
<span>{{ $t('tv.dashboard.indie_wall.just_supported') }} · {{ supporterToast.
|
|
218
|
+
<span>{{ $t('tv.dashboard.indie_wall.just_supported') }} · {{ formatMoney(supporterToast.amount, supporterToast.currency) }}</span>
|
|
210
219
|
</div>
|
|
211
220
|
<button class="mkw-toast-close" type="button" @click="supporterToast = null">×</button>
|
|
212
221
|
</div>
|
|
@@ -227,7 +236,7 @@ onUnmounted(() => {
|
|
|
227
236
|
<div class="mkw-tip-info">
|
|
228
237
|
<strong>{{ supporterDisplayName(hoverTip.supporter) }}</strong>
|
|
229
238
|
<span v-if="hoverTip.supporter.supporter_message">"{{ hoverTip.supporter.supporter_message }}"</span>
|
|
230
|
-
<small>{{
|
|
239
|
+
<small>{{ formatMoney(Number(hoverTip.supporter.amount_paid || 0), wall.currency) }} · {{ hoverTip.supporter.pixel_count }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}</small>
|
|
231
240
|
</div>
|
|
232
241
|
</div>
|
|
233
242
|
</Transition>
|
|
@@ -259,7 +268,7 @@ onUnmounted(() => {
|
|
|
259
268
|
<div class="mkw-detail-meta">
|
|
260
269
|
<span>{{ $t('tv.dashboard.indie_wall.mural_position') }}: ({{ detailPixel.x }}, {{ detailPixel.y }})</span>
|
|
261
270
|
<span>{{ detailPixel.pixel_count }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}</span>
|
|
262
|
-
<span>{{
|
|
271
|
+
<span>{{ formatMoney(Number(detailPixel.amount_paid), wall.currency) }}</span>
|
|
263
272
|
</div>
|
|
264
273
|
<p v-if="detailPixel.link">
|
|
265
274
|
<a :href="detailPixel.link" target="_blank" rel="noopener" class="mkw-detail-link">{{ detailPixel.link }}</a>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
const { t } = useI18n()
|
|
3
|
+
|
|
2
4
|
const props = withDefaults(defineProps<{
|
|
3
5
|
form: {
|
|
4
6
|
title: string
|
|
@@ -36,6 +38,12 @@ const messageError = ref('')
|
|
|
36
38
|
const imageMissingError = ref('')
|
|
37
39
|
const emailError = ref('')
|
|
38
40
|
|
|
41
|
+
// Errors are (re)computed on submit in next(); clear them as soon as the user
|
|
42
|
+
// edits the field so a corrected value doesn't keep showing a stale error.
|
|
43
|
+
watch(() => local.guest_email, () => { emailError.value = '' })
|
|
44
|
+
watch(() => local.title, () => { titleError.value = '' })
|
|
45
|
+
watch(() => local.message, () => { messageError.value = '' })
|
|
46
|
+
|
|
39
47
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
40
48
|
const URL_REGEX = /^https?:\/\/.+\..+/
|
|
41
49
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
const { locale } = useI18n()
|
|
3
|
+
|
|
2
4
|
defineProps<{
|
|
3
5
|
goals: any[]
|
|
4
6
|
selectedGoalId: number | null
|
|
@@ -14,6 +16,14 @@ function pick(goalId: number | null) {
|
|
|
14
16
|
emit('select', goalId)
|
|
15
17
|
emit('next')
|
|
16
18
|
}
|
|
19
|
+
|
|
20
|
+
function formatMoney(amount: number, currency?: string | null) {
|
|
21
|
+
try {
|
|
22
|
+
return new Intl.NumberFormat(locale.value, { style: 'currency', currency: currency || 'USD', maximumFractionDigits: 0 }).format(amount)
|
|
23
|
+
} catch {
|
|
24
|
+
return `${currency || ''} ${Number(amount).toFixed(0)}`.trim()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
17
27
|
</script>
|
|
18
28
|
|
|
19
29
|
<template>
|
|
@@ -34,7 +44,7 @@ function pick(goalId: number | null) {
|
|
|
34
44
|
{{ $t('tv.dashboard.indie_wall.reached') }}
|
|
35
45
|
</span>
|
|
36
46
|
<span v-else class="sw-goal-amount">
|
|
37
|
-
{{
|
|
47
|
+
{{ formatMoney(Number(goal.target_amount || 0), currency) }}
|
|
38
48
|
</span>
|
|
39
49
|
</header>
|
|
40
50
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
const { locale } = useI18n()
|
|
3
|
+
|
|
2
4
|
const props = defineProps<{
|
|
3
5
|
tiers: any[]
|
|
4
6
|
selectedTierId: number | null
|
|
@@ -7,6 +9,14 @@ const props = defineProps<{
|
|
|
7
9
|
currency: string
|
|
8
10
|
}>()
|
|
9
11
|
|
|
12
|
+
function formatMoney(amount: number, currency?: string | null) {
|
|
13
|
+
try {
|
|
14
|
+
return new Intl.NumberFormat(locale.value, { style: 'currency', currency: currency || 'USD' }).format(amount)
|
|
15
|
+
} catch {
|
|
16
|
+
return `${currency || ''} ${Number(amount).toFixed(2)}`.trim()
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
const emit = defineEmits<{
|
|
11
21
|
(e: 'select-tier', tierId: number | null, pixelCount: number): void
|
|
12
22
|
(e: 'set-custom', qty: number): void
|
|
@@ -51,7 +61,7 @@ function tierUnavailable(tier: any) {
|
|
|
51
61
|
>
|
|
52
62
|
<img v-if="tier.image_url" :src="tier.image_url" alt="" class="sw-tier-img" />
|
|
53
63
|
<span class="sw-card-title">{{ tier.name }}</span>
|
|
54
|
-
<span class="sw-card-price">{{
|
|
64
|
+
<span class="sw-card-price">{{ formatMoney(Number(tier.price), tier.currency || currency) }}</span>
|
|
55
65
|
<span class="sw-card-pixels">{{ tier.pixel_count }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}</span>
|
|
56
66
|
<p v-if="tier.description" class="sw-card-desc">{{ tier.description }}</p>
|
|
57
67
|
<span v-if="tier.max_quantity" class="sw-card-stock">
|
|
@@ -65,10 +75,10 @@ function tierUnavailable(tier: any) {
|
|
|
65
75
|
<div class="sw-custom-row">
|
|
66
76
|
<input v-model.number="localQty" type="number" min="1" class="sw-custom-input" />
|
|
67
77
|
<span class="sw-custom-x">×</span>
|
|
68
|
-
<span class="sw-custom-price">{{
|
|
78
|
+
<span class="sw-custom-price">{{ formatMoney(Number(pixelPrice), currency) }}</span>
|
|
69
79
|
</div>
|
|
70
80
|
<div class="sw-custom-total">
|
|
71
|
-
= {{
|
|
81
|
+
= {{ formatMoney(Math.max(1, localQty) * pixelPrice, currency) }}
|
|
72
82
|
</div>
|
|
73
83
|
<button type="button" class="sw-custom-cta" @click="pickCustom">
|
|
74
84
|
{{ $t('tv.dashboard.indie_wall.step_package_custom_cta') }}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
const { locale } = useI18n()
|
|
3
|
+
|
|
2
4
|
const props = defineProps<{
|
|
3
5
|
wall: any
|
|
4
6
|
selectedGoal: any | null
|
|
@@ -30,6 +32,14 @@ const total = computed(() => {
|
|
|
30
32
|
})
|
|
31
33
|
const agreed = ref(false)
|
|
32
34
|
const agreedRights = ref(false)
|
|
35
|
+
|
|
36
|
+
function formatMoney(amount: number, currency?: string | null) {
|
|
37
|
+
try {
|
|
38
|
+
return new Intl.NumberFormat(locale.value, { style: 'currency', currency: currency || 'USD' }).format(amount)
|
|
39
|
+
} catch {
|
|
40
|
+
return `${currency || ''} ${Number(amount).toFixed(2)}`.trim()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
33
43
|
</script>
|
|
34
44
|
|
|
35
45
|
<template>
|
|
@@ -96,7 +106,7 @@ const agreedRights = ref(false)
|
|
|
96
106
|
|
|
97
107
|
<div class="sw-total">
|
|
98
108
|
<span>{{ $t('tv.dashboard.indie_wall.step_pay_total') }}</span>
|
|
99
|
-
<strong>{{
|
|
109
|
+
<strong>{{ formatMoney(total, selectedTier?.currency || wall?.currency) }}</strong>
|
|
100
110
|
</div>
|
|
101
111
|
|
|
102
112
|
<label class="sw-agree">
|
|
@@ -204,7 +204,11 @@ async function confirm() {
|
|
|
204
204
|
</script>
|
|
205
205
|
|
|
206
206
|
<template>
|
|
207
|
-
|
|
207
|
+
<!-- No @click.self="close" on the backdrop: this is a checkout flow, and on
|
|
208
|
+
mobile a stray tap near the edges (e.g. reaching past a keyboard-shifted
|
|
209
|
+
viewport for the Continue button) must not silently discard a guest's
|
|
210
|
+
in-progress entry. Closing is only via the explicit × button. -->
|
|
211
|
+
<div :class="['ss-overlay', { 'ss-overlay--drawer': drawer }]">
|
|
208
212
|
<div :class="['ss-modal', { 'ss-modal--drawer': drawer }]">
|
|
209
213
|
<button class="ss-close" type="button" aria-label="Close" @click="close">×</button>
|
|
210
214
|
|
|
@@ -318,6 +322,10 @@ async function confirm() {
|
|
|
318
322
|
display: flex; align-items: center; gap: 6px;
|
|
319
323
|
font-size: 0.78rem; color: var(--secondary-info-fg, #aaa);
|
|
320
324
|
background: #13161C; padding: 6px 10px; border: 1px solid #1e2028;
|
|
325
|
+
// .ss-close is absolutely positioned at top:10/right:10 over the modal's
|
|
326
|
+
// padded corner; this bar starts at the padding edge (top:24) so without
|
|
327
|
+
// clearance its top ~14px sits directly under the close button.
|
|
328
|
+
margin-top: 20px;
|
|
321
329
|
strong { color: var(--chip-text, #D297FF); font-weight: 600; }
|
|
322
330
|
}
|
|
323
331
|
|
|
@@ -40,6 +40,15 @@ const login = () => {
|
|
|
40
40
|
window.location.href = `${accountsBaseUrl}/login?redirect_to=${redirectTo}`;
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
+
// Computed in script rather than read inline in the template: a bare `window`
|
|
44
|
+
// reference in the template crashes the render (it's not part of the
|
|
45
|
+
// script-setup bindings the compiled render function has access to, and it's
|
|
46
|
+
// undefined during SSR), which took down this whole dropdown for every
|
|
47
|
+
// signed-out visitor.
|
|
48
|
+
const registerHref = computed(
|
|
49
|
+
() => `${accountsBaseUrl}/register?redirect_to=${encodeURIComponent(import.meta.client ? window.location.href : "")}`,
|
|
50
|
+
);
|
|
51
|
+
|
|
43
52
|
const handleLogout = async () => {
|
|
44
53
|
await performLogout(`${accountsBaseUrl}/login`);
|
|
45
54
|
};
|
|
@@ -60,7 +69,7 @@ const handleLogout = async () => {
|
|
|
60
69
|
<div>
|
|
61
70
|
<p class="d-flex">
|
|
62
71
|
{{ $t("header.login.register_message") }}
|
|
63
|
-
<a :href="
|
|
72
|
+
<a :href="registerHref" class="ms-1">
|
|
64
73
|
{{ $t("header.login.btn_register") }}
|
|
65
74
|
</a>
|
|
66
75
|
</p>
|
package/package.json
CHANGED
|
@@ -190,23 +190,22 @@
|
|
|
190
190
|
<span class="lock-cta">{{ $t("keys.campaigns.exclusive_upgrade_cta") }}</span>
|
|
191
191
|
</div>
|
|
192
192
|
|
|
193
|
-
<div class="title">
|
|
194
|
-
<div class="days" v-if="card.time > 0 && card.user_can_access">
|
|
195
|
-
<span>{{ card.time }}</span> {{ $t("keys.campaigns.days") }}
|
|
196
|
-
</div>
|
|
197
|
-
<div class="days" v-else></div>
|
|
198
|
-
<span>
|
|
199
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="17" viewBox="0 0 16 17" fill="none">
|
|
200
|
-
<path d="M13.3333 4.1112L12.8666 3.64453L11.9333 4.57786L10.9333 3.64453L8.33329 4.1112L2.66663 9.77786L7.19996 14.3112L12.8666 8.64453L13.3333 6.04453L12.4 5.1112L13.3333 4.1112ZM12.6 6.24453L12.2666 8.3112L7.19996 13.3779L3.59996 9.77786L8.66663 4.7112L10.7333 4.3112L11.4666 5.04453L11 5.5112L10.5333 5.04453L9.99996 5.57786L11.4 6.97786L11.8666 6.5112L11.4 6.04453L11.8666 5.57786L12.6 6.24453Z" fill="var(--key-accent, var(--key-accent, var(--primary, #D297FF)))" />
|
|
201
|
-
</svg>
|
|
202
|
-
{{ card.type }}
|
|
203
|
-
</span>
|
|
204
|
-
</div>
|
|
205
193
|
</div>
|
|
206
194
|
</nuxt-link>
|
|
207
195
|
</div>
|
|
208
196
|
|
|
209
197
|
<div class="text">
|
|
198
|
+
<div class="card-meta-row">
|
|
199
|
+
<div class="days" v-if="card.time > 0 && card.user_can_access">
|
|
200
|
+
<span>{{ card.time }}</span> {{ $t("keys.campaigns.days") }}
|
|
201
|
+
</div>
|
|
202
|
+
<span>
|
|
203
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="17" viewBox="0 0 16 17" fill="none">
|
|
204
|
+
<path d="M13.3333 4.1112L12.8666 3.64453L11.9333 4.57786L10.9333 3.64453L8.33329 4.1112L2.66663 9.77786L7.19996 14.3112L12.8666 8.64453L13.3333 6.04453L12.4 5.1112L13.3333 4.1112ZM12.6 6.24453L12.2666 8.3112L7.19996 13.3779L3.59996 9.77786L8.66663 4.7112L10.7333 4.3112L11.4666 5.04453L11 5.5112L10.5333 5.04453L9.99996 5.57786L11.4 6.97786L11.8666 6.5112L11.4 6.04453L11.8666 5.57786L12.6 6.24453Z" fill="var(--key-accent, var(--key-accent, var(--primary, #D297FF)))" />
|
|
205
|
+
</svg>
|
|
206
|
+
{{ card.type }}
|
|
207
|
+
</span>
|
|
208
|
+
</div>
|
|
210
209
|
<div v-if="card.platforms && card.platforms.length" class="card-platforms">
|
|
211
210
|
<span v-for="p in card.platforms" :key="p" class="card-platform-tag">
|
|
212
211
|
{{ platformAbbr(p) }}
|
|
@@ -1078,13 +1077,24 @@ onUnmounted(() => {
|
|
|
1078
1077
|
.header {
|
|
1079
1078
|
width: 100%;
|
|
1080
1079
|
display: flex;
|
|
1080
|
+
align-items: center;
|
|
1081
1081
|
padding: 24px 0;
|
|
1082
1082
|
justify-content: space-between;
|
|
1083
|
+
flex-wrap: wrap;
|
|
1084
|
+
row-gap: 12px;
|
|
1083
1085
|
|
|
1084
1086
|
.header-left {
|
|
1085
1087
|
display: flex;
|
|
1088
|
+
align-items: center;
|
|
1086
1089
|
gap: 12px;
|
|
1087
1090
|
button { height: 44px; }
|
|
1091
|
+
|
|
1092
|
+
// FormAppSelect ships at 32px (its own default look on other
|
|
1093
|
+
// pages) — bump to match the 44px filter pills/search box so
|
|
1094
|
+
// the toolbar reads as one consistent row instead of controls
|
|
1095
|
+
// bobbing at two different heights.
|
|
1096
|
+
:deep(.select-wrapper) { display: flex; align-items: center; height: 44px; }
|
|
1097
|
+
:deep(.select-wrapper select) { height: 44px; }
|
|
1088
1098
|
}
|
|
1089
1099
|
|
|
1090
1100
|
.header-left .btn.tertiary.active {
|
|
@@ -1104,7 +1114,11 @@ onUnmounted(() => {
|
|
|
1104
1114
|
position: relative;
|
|
1105
1115
|
display: flex;
|
|
1106
1116
|
align-items: center;
|
|
1107
|
-
gap:
|
|
1117
|
+
gap: 12px;
|
|
1118
|
+
|
|
1119
|
+
// Same 32px→44px fix as the platform select, so the sort
|
|
1120
|
+
// control lines up with the search box beside it.
|
|
1121
|
+
:deep(.sort-input) { height: 44px; padding-top: 0; }
|
|
1108
1122
|
}
|
|
1109
1123
|
|
|
1110
1124
|
.search-box {
|
|
@@ -1182,39 +1196,31 @@ onUnmounted(() => {
|
|
|
1182
1196
|
}
|
|
1183
1197
|
}
|
|
1184
1198
|
|
|
1185
|
-
.
|
|
1186
|
-
|
|
1187
|
-
left: 0;
|
|
1188
|
-
right: 0;
|
|
1189
|
-
bottom: 0;
|
|
1190
|
-
display: flex;
|
|
1191
|
-
padding: 8px 16px;
|
|
1192
|
-
align-items: center;
|
|
1193
|
-
background: rgba(13, 13, 13, 0.82);
|
|
1194
|
-
backdrop-filter: blur(2px);
|
|
1195
|
-
width: 100%;
|
|
1196
|
-
justify-content: space-between;
|
|
1197
|
-
z-index: 2;
|
|
1198
|
-
|
|
1199
|
-
.days {
|
|
1200
|
-
font-size: 12px;
|
|
1201
|
-
color: var(--title-fg);
|
|
1199
|
+
.text {
|
|
1200
|
+
.card-meta-row {
|
|
1202
1201
|
display: flex;
|
|
1203
1202
|
align-items: center;
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
}
|
|
1203
|
+
justify-content: space-between;
|
|
1204
|
+
gap: 8px;
|
|
1207
1205
|
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1206
|
+
.days {
|
|
1207
|
+
font-size: 12px;
|
|
1208
|
+
color: var(--title-fg);
|
|
1209
|
+
display: flex;
|
|
1210
|
+
align-items: center;
|
|
1211
|
+
gap: 4px;
|
|
1212
|
+
span { color: var(--key-accent, var(--key-accent, var(--primary, #D297FF))); font-size: 16px; font-weight: 600; }
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
span {
|
|
1216
|
+
font-size: 12px;
|
|
1217
|
+
color: var(--secondary-info-fg);
|
|
1218
|
+
display: flex;
|
|
1219
|
+
align-items: center;
|
|
1220
|
+
gap: 4px;
|
|
1221
|
+
}
|
|
1214
1222
|
}
|
|
1215
|
-
}
|
|
1216
1223
|
|
|
1217
|
-
.text {
|
|
1218
1224
|
color: var(--title-fg);
|
|
1219
1225
|
font-size: 16px;
|
|
1220
1226
|
font-weight: 600;
|
package/pages/mural/[slug].vue
CHANGED
|
@@ -209,6 +209,13 @@ async function loadWall() {
|
|
|
209
209
|
stepperStartStep.value = 1
|
|
210
210
|
showStepper.value = true
|
|
211
211
|
}
|
|
212
|
+
|
|
213
|
+
// Deep-link from a supporter's own share link (?pixel=123): open their
|
|
214
|
+
// pixel detail automatically so the shared link lands on their spot.
|
|
215
|
+
if (route.query.pixel) {
|
|
216
|
+
const shared = supporters.value.find((s: any) => String(s.id) === String(route.query.pixel))
|
|
217
|
+
if (shared) detailPixel.value = shared
|
|
218
|
+
}
|
|
212
219
|
} catch (err) {
|
|
213
220
|
console.error(err)
|
|
214
221
|
} finally {
|
|
@@ -232,8 +239,19 @@ function onCanvasTapEmpty(coord: { x: number; y: number }) {
|
|
|
232
239
|
})
|
|
233
240
|
}
|
|
234
241
|
|
|
235
|
-
function
|
|
242
|
+
function openPixelDetail(s: any) {
|
|
236
243
|
detailPixel.value = s
|
|
244
|
+
router.replace({ query: { ...route.query, pixel: s.id } })
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function closePixelDetail() {
|
|
248
|
+
detailPixel.value = null
|
|
249
|
+
const { pixel: _omit, ...rest } = route.query
|
|
250
|
+
router.replace({ query: rest })
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function onCanvasTapSupporter(s: any) {
|
|
254
|
+
openPixelDetail(s)
|
|
237
255
|
}
|
|
238
256
|
|
|
239
257
|
const hoverTip = ref<{ supporter: any; x: number; y: number } | null>(null)
|
|
@@ -267,10 +285,63 @@ function supporterDisplayName(s: any): string {
|
|
|
267
285
|
}
|
|
268
286
|
|
|
269
287
|
const requestUrl = useRequestURL()
|
|
270
|
-
|
|
271
|
-
|
|
288
|
+
|
|
289
|
+
// When a supporter's own pixel is open (either they just clicked it, or a
|
|
290
|
+
// visitor followed that supporter's share link), the page's own title/
|
|
291
|
+
// description swap to their name+message so *their* share card, not just
|
|
292
|
+
// the wall's, reads correctly. Image stays the wall's own cover — pixel
|
|
293
|
+
// images are inline base64, not real URLs, so crawlers can't fetch them.
|
|
294
|
+
const seoTitle = computed(() => {
|
|
295
|
+
if (detailPixel.value && wall.value) {
|
|
296
|
+
return t('tv.dashboard.indie_wall.share_pixel_title', { name: supporterDisplayName(detailPixel.value), wall: wall.value.name })
|
|
297
|
+
}
|
|
298
|
+
return wall.value?.name ? `${wall.value.name} · Mundo Gamer Wall` : 'Mundo Gamer Wall'
|
|
299
|
+
})
|
|
300
|
+
const seoDescription = computed(() => {
|
|
301
|
+
if (detailPixel.value) {
|
|
302
|
+
return detailPixel.value.supporter_message
|
|
303
|
+
|| (wall.value?.name ? t('tv.dashboard.indie_wall.share_pixel_default_desc', { wall: wall.value.name }) : '')
|
|
304
|
+
}
|
|
305
|
+
return wall.value?.description || wall.value?.long_description?.replace(/\n+/g, ' ').slice(0, 160) || ''
|
|
306
|
+
})
|
|
272
307
|
const seoImage = computed(() => wall.value?.cover_image_url || wall.value?.logo_url || '')
|
|
273
308
|
|
|
309
|
+
// ── Per-supporter share link ────────────────────────────────────────────────
|
|
310
|
+
const sharePixelUrl = computed(() => {
|
|
311
|
+
if (!detailPixel.value) return requestUrl.href
|
|
312
|
+
const base = `${requestUrl.origin}${route.path}`
|
|
313
|
+
return `${base}?pixel=${detailPixel.value.id}`
|
|
314
|
+
})
|
|
315
|
+
const shareText = computed(() => {
|
|
316
|
+
if (!detailPixel.value || !wall.value) return ''
|
|
317
|
+
return t('tv.dashboard.indie_wall.share_pixel_text', { name: supporterDisplayName(detailPixel.value), wall: wall.value.name })
|
|
318
|
+
})
|
|
319
|
+
const shareTwitterUrl = computed(() => `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText.value)}&url=${encodeURIComponent(sharePixelUrl.value)}`)
|
|
320
|
+
const shareWhatsappUrl = computed(() => `https://wa.me/?text=${encodeURIComponent(`${shareText.value} ${sharePixelUrl.value}`)}`)
|
|
321
|
+
const shareFacebookUrl = computed(() => `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(sharePixelUrl.value)}`)
|
|
322
|
+
|
|
323
|
+
const linkCopied = ref(false)
|
|
324
|
+
async function copyShareLink() {
|
|
325
|
+
try {
|
|
326
|
+
await navigator.clipboard.writeText(sharePixelUrl.value)
|
|
327
|
+
linkCopied.value = true
|
|
328
|
+
setTimeout(() => { linkCopied.value = false }, 2000)
|
|
329
|
+
} catch { /* clipboard unavailable */ }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// "Create your own wall" footer CTA — same host map as the backend's
|
|
333
|
+
// IndieWallController::resolverResponse, so it always points at whichever
|
|
334
|
+
// platform (TV/Agency) actually owns this wall rather than a hardcoded host.
|
|
335
|
+
const CREATE_WALL_HOST_MAP: Record<string, string> = {
|
|
336
|
+
MGTV: 'https://mundogamer.tv',
|
|
337
|
+
MGAG: 'https://mundogamer.agency',
|
|
338
|
+
}
|
|
339
|
+
const createWallUrl = computed(() => {
|
|
340
|
+
const shortName = wall.value?.origin_system?.short_name
|
|
341
|
+
const host = (shortName && CREATE_WALL_HOST_MAP[shortName]) || CREATE_WALL_HOST_MAP.MGTV
|
|
342
|
+
return `${host}/${locale.value}/dashboard/wall`
|
|
343
|
+
})
|
|
344
|
+
|
|
274
345
|
// Raw useHead meta-array form — matches the exact mechanism the site-wide
|
|
275
346
|
// defaults in nuxt.config.ts use, and is proven to reliably override by
|
|
276
347
|
// name/property key in this app. useSeoMeta was tried first but its og:*/
|
|
@@ -523,7 +594,7 @@ onUnmounted(() => {
|
|
|
523
594
|
<section v-if="supporters.length" class="section">
|
|
524
595
|
<h2 class="section-title">{{ $t('tv.dashboard.indie_wall.recent_supporters') }}</h2>
|
|
525
596
|
<div class="supporters-grid">
|
|
526
|
-
<div v-for="s in supporters.slice(0, 24)" :key="s.id" class="supp-card" @click="
|
|
597
|
+
<div v-for="s in supporters.slice(0, 24)" :key="s.id" class="supp-card" @click="openPixelDetail(s)">
|
|
527
598
|
<div class="supp-pixel">
|
|
528
599
|
<img v-if="s.image_url" :src="s.image_url" class="supp-pixel-img" alt="" />
|
|
529
600
|
<div v-else class="supp-pixel-swatch" :style="{ background: s.background_color || '#272930' }" />
|
|
@@ -549,6 +620,16 @@ onUnmounted(() => {
|
|
|
549
620
|
<IndieWallLeaderboard :supporters="supporters" />
|
|
550
621
|
</section>
|
|
551
622
|
|
|
623
|
+
<!-- ── Footer credit ─────────────────────────────────────────────────── -->
|
|
624
|
+
<footer class="mural-footer">
|
|
625
|
+
<p class="mural-footer-text">
|
|
626
|
+
{{ $t('tv.dashboard.indie_wall.footer_made_with') }} <strong>Mundo Gamer Wall</strong>
|
|
627
|
+
</p>
|
|
628
|
+
<a :href="createWallUrl" target="_blank" rel="noopener" class="mural-footer-cta">
|
|
629
|
+
{{ $t('tv.dashboard.indie_wall.footer_create_cta') }}
|
|
630
|
+
</a>
|
|
631
|
+
</footer>
|
|
632
|
+
|
|
552
633
|
</div>
|
|
553
634
|
|
|
554
635
|
<!-- ── Overlays ────────────────────────────────────────────────────────── -->
|
|
@@ -628,9 +709,9 @@ onUnmounted(() => {
|
|
|
628
709
|
</Transition>
|
|
629
710
|
|
|
630
711
|
<Transition name="fade">
|
|
631
|
-
<div v-if="detailPixel" class="detail-overlay" @click.self="
|
|
712
|
+
<div v-if="detailPixel" class="detail-overlay" @click.self="closePixelDetail">
|
|
632
713
|
<div class="detail-box">
|
|
633
|
-
<button class="overlay-close" type="button" @click="
|
|
714
|
+
<button class="overlay-close" type="button" @click="closePixelDetail">×</button>
|
|
634
715
|
<div
|
|
635
716
|
class="detail-hero"
|
|
636
717
|
:style="{ background: detailPixel.background_color || '#272930' }"
|
|
@@ -654,6 +735,24 @@ onUnmounted(() => {
|
|
|
654
735
|
<a v-if="detailPixel.link" :href="detailPixel.link" target="_blank" rel="noopener" class="detail-link">
|
|
655
736
|
{{ detailPixel.link }}
|
|
656
737
|
</a>
|
|
738
|
+
|
|
739
|
+
<!-- ── Share this pixel ─────────────────────────────────────── -->
|
|
740
|
+
<div class="detail-share">
|
|
741
|
+
<p class="detail-share-title">{{ $t('tv.dashboard.indie_wall.share_pixel_cta') }}</p>
|
|
742
|
+
<div class="detail-share-row">
|
|
743
|
+
<a :href="shareTwitterUrl" target="_blank" rel="noopener" class="detail-share-btn" :aria-label="$t('tv.dashboard.indie_wall.share_via_twitter')">𝕏</a>
|
|
744
|
+
<a :href="shareWhatsappUrl" target="_blank" rel="noopener" class="detail-share-btn" :aria-label="$t('tv.dashboard.indie_wall.share_via_whatsapp')">
|
|
745
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12.04 2C6.58 2 2.13 6.45 2.13 11.91c0 1.75.46 3.45 1.32 4.95L2.05 22l5.25-1.38a9.9 9.9 0 0 0 4.74 1.2h.01c5.46 0 9.9-4.45 9.9-9.91 0-2.65-1.03-5.14-2.9-7.01A9.82 9.82 0 0 0 12.04 2Z" opacity="0"/><path d="M9.2 6.8c-.2-.45-.42-.46-.62-.47h-.53c-.18 0-.48.07-.73.34-.25.27-.96.93-.96 2.28s.98 2.65 1.12 2.83c.14.18 1.9 3.03 4.7 4.14 2.32.92 2.8.74 3.3.7.5-.05 1.62-.66 1.85-1.3.23-.63.23-1.17.16-1.29-.07-.11-.25-.18-.53-.32-.27-.14-1.62-.8-1.87-.89-.25-.09-.43-.14-.62.14-.18.27-.7.89-.86 1.07-.16.18-.32.2-.59.07-.27-.14-1.15-.42-2.19-1.35-.81-.72-1.36-1.62-1.52-1.89-.16-.27-.02-.42.12-.55.13-.13.27-.33.4-.5.13-.16.18-.27.27-.45.09-.18.05-.34-.02-.48-.07-.14-.6-1.5-.85-2.05Z"/></svg>
|
|
746
|
+
</a>
|
|
747
|
+
<a :href="shareFacebookUrl" target="_blank" rel="noopener" class="detail-share-btn" :aria-label="$t('tv.dashboard.indie_wall.share_via_facebook')">f</a>
|
|
748
|
+
<button type="button" class="detail-share-btn" @click="copyShareLink" :aria-label="$t('tv.dashboard.indie_wall.share_copy_link')">
|
|
749
|
+
{{ linkCopied ? '✓' : '🔗' }}
|
|
750
|
+
</button>
|
|
751
|
+
</div>
|
|
752
|
+
<button type="button" class="detail-share-cta" @click="closePixelDetail(); openStepper({ startStep: 1 })">
|
|
753
|
+
{{ $t('tv.dashboard.indie_wall.share_join_cta') }}
|
|
754
|
+
</button>
|
|
755
|
+
</div>
|
|
657
756
|
</div>
|
|
658
757
|
</div>
|
|
659
758
|
</div>
|
|
@@ -985,6 +1084,32 @@ onUnmounted(() => {
|
|
|
985
1084
|
&:hover { text-decoration: underline; }
|
|
986
1085
|
}
|
|
987
1086
|
|
|
1087
|
+
.detail-share {
|
|
1088
|
+
margin-top: 16px; padding-top: 16px;
|
|
1089
|
+
border-top: 1px solid #272930;
|
|
1090
|
+
}
|
|
1091
|
+
.detail-share-title {
|
|
1092
|
+
margin: 0 0 8px; font-size: 0.78rem; color: var(--secondary-info-fg, #aaa);
|
|
1093
|
+
}
|
|
1094
|
+
.detail-share-row {
|
|
1095
|
+
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
|
|
1096
|
+
}
|
|
1097
|
+
.detail-share-btn {
|
|
1098
|
+
width: 34px; height: 34px; flex-shrink: 0;
|
|
1099
|
+
display: flex; align-items: center; justify-content: center;
|
|
1100
|
+
background: rgba(255,255,255,0.06); border: 1px solid #272930;
|
|
1101
|
+
color: var(--title-fg, #fff); font-size: 0.9rem; font-weight: 700;
|
|
1102
|
+
cursor: pointer; text-decoration: none; transition: border-color 0.15s;
|
|
1103
|
+
&:hover { border-color: var(--chip-text, #D297FF); }
|
|
1104
|
+
}
|
|
1105
|
+
.detail-share-cta {
|
|
1106
|
+
width: 100%;
|
|
1107
|
+
background: var(--chip-text, #D297FF); color: #13161C;
|
|
1108
|
+
border: none; padding: 10px 14px; font-weight: 700; font-size: 0.85rem;
|
|
1109
|
+
cursor: pointer; transition: opacity 0.15s;
|
|
1110
|
+
&:hover { opacity: 0.88; }
|
|
1111
|
+
}
|
|
1112
|
+
|
|
988
1113
|
.fade-enter-active, .fade-leave-active { transition: opacity 0.18s; }
|
|
989
1114
|
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
|
990
1115
|
|
|
@@ -1102,6 +1227,28 @@ onUnmounted(() => {
|
|
|
1102
1227
|
background: rgba(255,255,255,0.06); padding: 2px 6px;
|
|
1103
1228
|
}
|
|
1104
1229
|
|
|
1230
|
+
// ── Footer credit ─────────────────────────────────────────────────────────
|
|
1231
|
+
.mural-footer {
|
|
1232
|
+
display: flex; flex-direction: column; align-items: center; gap: 12px;
|
|
1233
|
+
padding: 48px 16px; margin-top: 8px;
|
|
1234
|
+
border-top: 1px solid #1e2028;
|
|
1235
|
+
text-align: center;
|
|
1236
|
+
}
|
|
1237
|
+
.mural-footer-text {
|
|
1238
|
+
font-size: 0.8rem; color: var(--secondary-info-fg, #888); margin: 0;
|
|
1239
|
+
strong { color: var(--title-fg, #fff); }
|
|
1240
|
+
}
|
|
1241
|
+
.mural-footer-cta {
|
|
1242
|
+
display: inline-block;
|
|
1243
|
+
padding: 8px 20px;
|
|
1244
|
+
border: 1px solid var(--chip-text, #D297FF);
|
|
1245
|
+
color: var(--chip-text, #D297FF);
|
|
1246
|
+
font-size: 0.8rem; font-weight: 600;
|
|
1247
|
+
text-decoration: none;
|
|
1248
|
+
transition: background 0.15s, color 0.15s;
|
|
1249
|
+
&:hover { background: var(--chip-text, #D297FF); color: #13161C; }
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1105
1252
|
@media (max-width: 640px) {
|
|
1106
1253
|
.hero-title { font-size: 1.5rem; }
|
|
1107
1254
|
.hero-stats { gap: 12px; }
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { trackPressKitEvent } from '../../../services/pressKitService';
|
|
3
|
+
import { mgApiUrl } from '../../../services/httpService';
|
|
3
4
|
|
|
4
5
|
definePageMeta({ layout: 'blank' });
|
|
5
6
|
|
|
6
7
|
const route = useRoute();
|
|
7
8
|
const slug = route.params.slug as string;
|
|
9
|
+
const { t } = useI18n();
|
|
8
10
|
|
|
9
11
|
const runtimeConfig = useRuntimeConfig();
|
|
10
12
|
const cfg = (runtimeConfig.public?.mgSharedUi || {}) as Record<string, any>;
|
|
@@ -86,12 +88,87 @@ const layout = computed(() => kit.value?.layout || 'classic');
|
|
|
86
88
|
const allowDownload = computed(() => kit.value?.allow_asset_download !== false);
|
|
87
89
|
const hasDownloadableAssets = computed(() => assets.value.length > 0);
|
|
88
90
|
const canDownload = computed(() => allowDownload.value && hasDownloadableAssets.value);
|
|
91
|
+
const zipDownloadUrl = computed(() => `${apiBase}/public/press-kits/${slug}/download${previewToken ? `?preview_token=${previewToken}` : ''}`);
|
|
89
92
|
|
|
90
93
|
const keyPoolsVisible = ref(false);
|
|
91
94
|
function onPoolsReady(count: number) {
|
|
92
95
|
keyPoolsVisible.value = count > 0;
|
|
93
96
|
}
|
|
94
97
|
|
|
98
|
+
// ── Press Kit's own key request flow ────────────────────────────────────────
|
|
99
|
+
// Deliberately separate from the KeyBrowser widget above (that's the
|
|
100
|
+
// ecosystem-wide Key Campaigns product). This talks to the Press-Kit-specific
|
|
101
|
+
// pool/request endpoints and respects the studio's own `keys_public` setting
|
|
102
|
+
// (instant auto-delivery vs. manual review, configured in the dashboard).
|
|
103
|
+
const KEY_REQUEST_PLATFORMS = ['steam', 'epic', 'gog', 'xbox', 'psn', 'other'];
|
|
104
|
+
const KEY_REQUEST_REGIONS = ['worldwide', 'NA', 'EU', 'BR', 'LATAM', 'APAC', 'other'];
|
|
105
|
+
|
|
106
|
+
const requestForm = ref({ platform: 'steam', region: 'worldwide', objectives: '' });
|
|
107
|
+
const myKeyRequest = ref<any>(null);
|
|
108
|
+
const keyRequestLoaded = ref(false);
|
|
109
|
+
const keyRequestAuthed = ref(true);
|
|
110
|
+
const keyRequestSubmitting = ref(false);
|
|
111
|
+
const keyRequestError = ref('');
|
|
112
|
+
const keyCopied = ref(false);
|
|
113
|
+
|
|
114
|
+
const showOwnKeySection = computed(() => !!kit.value?.has_available_keys || !!myKeyRequest.value);
|
|
115
|
+
|
|
116
|
+
async function loadMyKeyRequest() {
|
|
117
|
+
try {
|
|
118
|
+
const res: any = await $fetch(`${apiBase}/public/press-kits/${slug}/my-request`, { credentials: 'include' });
|
|
119
|
+
myKeyRequest.value = res?.data || null;
|
|
120
|
+
} catch (err: any) {
|
|
121
|
+
if (err?.statusCode === 401 || err?.response?.status === 401) keyRequestAuthed.value = false;
|
|
122
|
+
} finally {
|
|
123
|
+
keyRequestLoaded.value = true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function redirectToKeyLogin() {
|
|
128
|
+
window.location.href = mgApiUrl('login') + '?redirect_to=' + encodeURIComponent(window.location.href);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function submitOwnKeyRequest() {
|
|
132
|
+
if (!keyRequestAuthed.value) {
|
|
133
|
+
redirectToKeyLogin();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
keyRequestError.value = '';
|
|
137
|
+
keyRequestSubmitting.value = true;
|
|
138
|
+
try {
|
|
139
|
+
await $fetch(`${apiBase}/public/press-kits/${slug}/request-key`, {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
credentials: 'include',
|
|
142
|
+
body: {
|
|
143
|
+
platform: requestForm.value.platform,
|
|
144
|
+
region: requestForm.value.region,
|
|
145
|
+
objectives: requestForm.value.objectives,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
await loadMyKeyRequest();
|
|
149
|
+
} catch (err: any) {
|
|
150
|
+
if (err?.statusCode === 401 || err?.response?.status === 401) {
|
|
151
|
+
keyRequestAuthed.value = false;
|
|
152
|
+
} else {
|
|
153
|
+
keyRequestError.value = err?.data?.message || t('kit.press.request_own_key_error');
|
|
154
|
+
}
|
|
155
|
+
} finally {
|
|
156
|
+
keyRequestSubmitting.value = false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function copyKeyCode(code: string) {
|
|
161
|
+
try {
|
|
162
|
+
await navigator.clipboard.writeText(code);
|
|
163
|
+
keyCopied.value = true;
|
|
164
|
+
setTimeout(() => { keyCopied.value = false; }, 2000);
|
|
165
|
+
} catch {}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
onMounted(() => {
|
|
169
|
+
if (kit.value?.id) loadMyKeyRequest();
|
|
170
|
+
});
|
|
171
|
+
|
|
95
172
|
// Scroll state for sticky CTA + back-to-top
|
|
96
173
|
const scrolled = ref(false);
|
|
97
174
|
let heroHeight = 0;
|
|
@@ -189,6 +266,11 @@ const seoImage = computed(() => kit.value?.hero_image_url || kit.value?.cover_im
|
|
|
189
266
|
useHead(() => ({
|
|
190
267
|
title: seoTitle.value,
|
|
191
268
|
link: [{ rel: 'canonical', href: requestUrl.href }],
|
|
269
|
+
// Studio's own GA4 tag, opt-in per kit — free feature, no plan gate.
|
|
270
|
+
script: kit.value?.analytics_ga_id ? [
|
|
271
|
+
{ src: `https://www.googletagmanager.com/gtag/js?id=${kit.value.analytics_ga_id}`, async: true },
|
|
272
|
+
{ innerHTML: `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${kit.value.analytics_ga_id}');` },
|
|
273
|
+
] : [],
|
|
192
274
|
meta: [
|
|
193
275
|
{ name: 'description', content: seoDescription.value },
|
|
194
276
|
{ property: 'og:title', content: seoTitle.value },
|
|
@@ -252,7 +334,7 @@ useHead(() => ({
|
|
|
252
334
|
<p v-if="kit.developer" class="kit-kicker">{{ kit.developer }}</p>
|
|
253
335
|
<div class="kit-actions">
|
|
254
336
|
<template v-if="allowDownload">
|
|
255
|
-
<a v-if="canDownload" class="kit-btn kit-btn-primary" @click="track('download')">{{ $t('kit.press.download_assets') }}</a>
|
|
337
|
+
<a v-if="canDownload" class="kit-btn kit-btn-primary" :href="zipDownloadUrl" @click="track('download')">{{ $t('kit.press.download_assets') }}</a>
|
|
256
338
|
<span v-else class="kit-btn kit-btn-primary kit-btn-disabled" :title="$t('kit.press.no_assets')">{{ $t('kit.press.no_assets') }}</span>
|
|
257
339
|
</template>
|
|
258
340
|
<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>
|
|
@@ -279,7 +361,7 @@ useHead(() => ({
|
|
|
279
361
|
<span class="kit-stickybar-name">{{ kit.name }}</span>
|
|
280
362
|
<div class="kit-stickybar-actions">
|
|
281
363
|
<template v-if="allowDownload">
|
|
282
|
-
<a v-if="canDownload" class="kit-btn kit-btn-primary kit-btn-sm" @click="track('download')">{{ $t('kit.press.download_assets') }}</a>
|
|
364
|
+
<a v-if="canDownload" class="kit-btn kit-btn-primary kit-btn-sm" :href="zipDownloadUrl" @click="track('download')">{{ $t('kit.press.download_assets') }}</a>
|
|
283
365
|
<span v-else class="kit-btn kit-btn-primary kit-btn-sm kit-btn-disabled" :title="$t('kit.press.no_assets')">{{ $t('kit.press.no_assets') }}</span>
|
|
284
366
|
</template>
|
|
285
367
|
<a v-if="kit.press_contact_email" class="kit-btn kit-btn-ghost kit-btn-sm" :href="`mailto:${kit.press_contact_email}`" @click="track('contact')">{{ $t('kit.press.request_key') }}</a>
|
|
@@ -420,6 +502,76 @@ useHead(() => ({
|
|
|
420
502
|
/>
|
|
421
503
|
</section>
|
|
422
504
|
|
|
505
|
+
<!-- Press Kit's own key request flow — separate product from the
|
|
506
|
+
KeyBrowser ecosystem widget above. Respects the studio's own
|
|
507
|
+
keys_public setting (instant auto-delivery vs. manual review). -->
|
|
508
|
+
<section v-if="showOwnKeySection" id="request-key" class="kit-block kit-keys-section">
|
|
509
|
+
<h2 class="kit-keys-title">{{ $t('kit.press.request_own_key_title') }}</h2>
|
|
510
|
+
<p class="kit-keys-desc">{{ $t('kit.press.request_own_key_desc') }}</p>
|
|
511
|
+
<p class="kit-keys-mode-note">
|
|
512
|
+
{{ kit.keys_public ? $t('kit.press.request_own_key_public_note') : $t('kit.press.request_own_key_moderated_note') }}
|
|
513
|
+
</p>
|
|
514
|
+
|
|
515
|
+
<div v-if="!keyRequestLoaded" class="kit-keys-loading" />
|
|
516
|
+
|
|
517
|
+
<template v-else>
|
|
518
|
+
<div v-if="!keyRequestAuthed && !myKeyRequest" class="kit-keys-authwall">
|
|
519
|
+
<p>{{ $t('kit.press.request_own_key_login_required') }}</p>
|
|
520
|
+
<button type="button" class="kit-btn kit-btn-primary" @click="redirectToKeyLogin">
|
|
521
|
+
{{ $t('kit.press.request_own_key_login_btn') }}
|
|
522
|
+
</button>
|
|
523
|
+
</div>
|
|
524
|
+
|
|
525
|
+
<div v-else-if="myKeyRequest" class="kit-keys-status" :class="`status-${myKeyRequest.status}`">
|
|
526
|
+
<p v-if="myKeyRequest.status === 'pending'">{{ $t('kit.press.request_own_key_status_pending') }}</p>
|
|
527
|
+
<template v-else-if="myKeyRequest.status === 'approved'">
|
|
528
|
+
<p>{{ $t('kit.press.request_own_key_status_approved') }}</p>
|
|
529
|
+
<div class="kit-keys-code">
|
|
530
|
+
<code>{{ myKeyRequest.key }}</code>
|
|
531
|
+
<button type="button" class="kit-btn kit-btn-ghost kit-btn-sm" @click="copyKeyCode(myKeyRequest.key)">
|
|
532
|
+
{{ keyCopied ? $t('kit.press.request_own_key_copied') : $t('kit.press.request_own_key_copy') }}
|
|
533
|
+
</button>
|
|
534
|
+
</div>
|
|
535
|
+
</template>
|
|
536
|
+
<p v-else-if="myKeyRequest.status === 'rejected'">{{ $t('kit.press.request_own_key_status_rejected') }}</p>
|
|
537
|
+
</div>
|
|
538
|
+
|
|
539
|
+
<form v-else class="kit-keys-form" @submit.prevent="submitOwnKeyRequest">
|
|
540
|
+
<div class="kit-keys-row">
|
|
541
|
+
<div class="kit-keys-field">
|
|
542
|
+
<label>{{ $t('kit.press.request_own_key_platform') }}</label>
|
|
543
|
+
<select v-model="requestForm.platform">
|
|
544
|
+
<option v-for="p in KEY_REQUEST_PLATFORMS" :key="p" :value="p">{{ p }}</option>
|
|
545
|
+
</select>
|
|
546
|
+
</div>
|
|
547
|
+
<div class="kit-keys-field">
|
|
548
|
+
<label>{{ $t('kit.press.request_own_key_region') }}</label>
|
|
549
|
+
<select v-model="requestForm.region">
|
|
550
|
+
<option v-for="r in KEY_REQUEST_REGIONS" :key="r" :value="r">{{ r }}</option>
|
|
551
|
+
</select>
|
|
552
|
+
</div>
|
|
553
|
+
</div>
|
|
554
|
+
<div class="kit-keys-field">
|
|
555
|
+
<label>{{ $t('kit.press.request_own_key_objectives') }}</label>
|
|
556
|
+
<textarea
|
|
557
|
+
v-model="requestForm.objectives"
|
|
558
|
+
rows="3"
|
|
559
|
+
:placeholder="$t('kit.press.request_own_key_objectives_placeholder')"
|
|
560
|
+
/>
|
|
561
|
+
<span class="kit-keys-hint">{{ $t('kit.press.request_own_key_objectives_hint') }}</span>
|
|
562
|
+
</div>
|
|
563
|
+
<div v-if="keyRequestError" class="kit-keys-error">{{ keyRequestError }}</div>
|
|
564
|
+
<button
|
|
565
|
+
type="submit"
|
|
566
|
+
class="kit-btn kit-btn-primary"
|
|
567
|
+
:disabled="keyRequestSubmitting || requestForm.objectives.trim().length < 20"
|
|
568
|
+
>
|
|
569
|
+
{{ keyRequestSubmitting ? $t('kit.press.request_own_key_submitting') : $t('kit.press.request_own_key_submit') }}
|
|
570
|
+
</button>
|
|
571
|
+
</form>
|
|
572
|
+
</template>
|
|
573
|
+
</section>
|
|
574
|
+
|
|
423
575
|
<section v-if="kit.press_contact_email" id="contact" class="kit-block">
|
|
424
576
|
<div class="kit-cta">
|
|
425
577
|
<h2>{{ $t('kit.press.covering', { name: kit.name }) }}</h2>
|
|
@@ -455,7 +607,7 @@ useHead(() => ({
|
|
|
455
607
|
</section>
|
|
456
608
|
|
|
457
609
|
<footer class="kit-footer">
|
|
458
|
-
<p class="kit-footer__text">{{ $t('kit.press.made_with') }} <strong>Mundo Gamer Agency</strong></p>
|
|
610
|
+
<p v-if="!kit.is_premium" class="kit-footer__text">{{ $t('kit.press.made_with') }} <strong>Mundo Gamer Agency</strong></p>
|
|
459
611
|
<a :href="kit.create_kit_url || `${agencyBase}/presskit`" target="_blank" rel="noopener" class="kit-footer__cta">{{ $t('kit.press.create_yours') }}</a>
|
|
460
612
|
</footer>
|
|
461
613
|
</template>
|