@mundogamernetwork/shared-ui 1.3.6 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/form/AppSelect.vue +111 -0
- package/components/form/SortInput.vue +98 -0
- package/components/indie-wall/MediaKitWallBlock.vue +399 -0
- package/locales/de.json +4 -1
- package/locales/en.json +4 -1
- package/locales/pt-BR.json +4 -1
- package/locales/ro.json +4 -1
- package/package.json +1 -1
- package/pages/key-campaigns/[slug].vue +22 -4
- package/pages/key-campaigns/index.vue +50 -36
- package/pages/media-kit/[slug].vue +44 -53
- package/pages/mural/[slug].vue +66 -11
- package/pages/presskit/game/[slug].vue +20 -15
- package/public/imgs/sort-active.png +0 -0
- package/services/campaignService.ts +4 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { defineEmits, defineProps, ref, watch } from "vue";
|
|
3
|
+
|
|
4
|
+
interface SelectProps {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
options: Array<any[] | { value: any; label: string }>;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
model: any;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const emit = defineEmits(["update:model"]);
|
|
13
|
+
const props = defineProps<SelectProps>();
|
|
14
|
+
const selectModel = ref(props.model);
|
|
15
|
+
|
|
16
|
+
watch(selectModel, () => {
|
|
17
|
+
emit("update:model", selectModel.value);
|
|
18
|
+
});
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<div
|
|
23
|
+
:class="{
|
|
24
|
+
'select-wrapper': true,
|
|
25
|
+
disabled: typeof props?.disabled !== 'undefined' ? props.disabled : false,
|
|
26
|
+
}"
|
|
27
|
+
>
|
|
28
|
+
<span class="before"></span>
|
|
29
|
+
<select
|
|
30
|
+
:id="props.id"
|
|
31
|
+
v-model="selectModel"
|
|
32
|
+
:name="props.id"
|
|
33
|
+
:class="{ filled: props.model !== '' && props.model !== null }"
|
|
34
|
+
:aria-label="$t(props.label)"
|
|
35
|
+
:placeholder="$t(props.label)"
|
|
36
|
+
:disabled="
|
|
37
|
+
typeof props?.disabled !== 'undefined' ? props.disabled : false
|
|
38
|
+
"
|
|
39
|
+
>
|
|
40
|
+
<option value="" disabled>
|
|
41
|
+
{{ $t(props.label) }}
|
|
42
|
+
</option>
|
|
43
|
+
|
|
44
|
+
<option
|
|
45
|
+
v-for="(opt, key) in props.options"
|
|
46
|
+
:key="`select-${id}-option-${key}`"
|
|
47
|
+
:value="Array.isArray(opt) ? opt[0] : opt.value"
|
|
48
|
+
>
|
|
49
|
+
{{ Array.isArray(opt) ? opt[1] : opt.label }}
|
|
50
|
+
</option>
|
|
51
|
+
</select>
|
|
52
|
+
</div>
|
|
53
|
+
</template>
|
|
54
|
+
|
|
55
|
+
<style lang="scss" scoped>
|
|
56
|
+
.select-wrapper {
|
|
57
|
+
position: relative;
|
|
58
|
+
border-bottom: 1px solid var(--input-border-color);
|
|
59
|
+
cursor: pointer;
|
|
60
|
+
|
|
61
|
+
&.disabled {
|
|
62
|
+
cursor: default;
|
|
63
|
+
filter: contrast(0.3) brightness(0.7);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
&:not(.disabled) .before {
|
|
67
|
+
position: absolute;
|
|
68
|
+
bottom: -1px;
|
|
69
|
+
left: 0;
|
|
70
|
+
display: inline-block;
|
|
71
|
+
width: 0;
|
|
72
|
+
height: 2px;
|
|
73
|
+
background-color: var(--input-active-border-color);
|
|
74
|
+
z-index: 2;
|
|
75
|
+
transition: 0.2s ease-in-out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
&:has(select:focus):not(.disabled) .before,
|
|
79
|
+
&:has(select:focus-visible):not(.disabled) .before,
|
|
80
|
+
&:has(select:active):not(.disabled) .before {
|
|
81
|
+
width: 100%;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
select {
|
|
85
|
+
height: 32px;
|
|
86
|
+
padding: 0.25rem 0.75rem;
|
|
87
|
+
padding-left: 0;
|
|
88
|
+
padding-bottom: 0;
|
|
89
|
+
border: 0;
|
|
90
|
+
background: var(--input-bg);
|
|
91
|
+
font-size: 0.75rem;
|
|
92
|
+
font-weight: 400;
|
|
93
|
+
color: var(--input-fg);
|
|
94
|
+
|
|
95
|
+
&:not(.filled) {
|
|
96
|
+
color: var(--input-placeholder-fg);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
&:focus,
|
|
100
|
+
&:focus-visible,
|
|
101
|
+
&:active {
|
|
102
|
+
outline: 0;
|
|
103
|
+
box-shadow: none;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
option{
|
|
107
|
+
color: #000;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
</style>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { defineEmits, defineProps, ref, watch } from "vue";
|
|
3
|
+
|
|
4
|
+
interface SortInputProps {
|
|
5
|
+
id: string;
|
|
6
|
+
ascLabel: string;
|
|
7
|
+
descLabel: string;
|
|
8
|
+
model: "asc" | "desc";
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const emit = defineEmits(["update:model"]);
|
|
13
|
+
const props = defineProps<SortInputProps>();
|
|
14
|
+
const sortModel = ref(props.model);
|
|
15
|
+
|
|
16
|
+
watch(sortModel, () => {
|
|
17
|
+
emit("update:model", sortModel);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const toggleSort = () => {
|
|
21
|
+
if (!props?.disabled) {
|
|
22
|
+
sortModel.value = sortModel.value === "desc" ? "asc" : "desc";
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<span
|
|
29
|
+
:id="props.id"
|
|
30
|
+
:class="{
|
|
31
|
+
'sort-input': true,
|
|
32
|
+
disabled: typeof props?.disabled !== 'undefined' ? props.disabled : false,
|
|
33
|
+
}"
|
|
34
|
+
@click="toggleSort"
|
|
35
|
+
>
|
|
36
|
+
<span class="before" />
|
|
37
|
+
|
|
38
|
+
{{ $t(sortModel === "asc" ? props.ascLabel : props.descLabel) }}
|
|
39
|
+
<img
|
|
40
|
+
src="/imgs/sort-active.png"
|
|
41
|
+
width="20"
|
|
42
|
+
height="20"
|
|
43
|
+
aria-hidden="true"
|
|
44
|
+
:class="{ 'ms-auto': true, reversed: sortModel === 'asc' }"
|
|
45
|
+
/>
|
|
46
|
+
</span>
|
|
47
|
+
</template>
|
|
48
|
+
|
|
49
|
+
<style lang="scss" scoped>
|
|
50
|
+
.sort-input {
|
|
51
|
+
position: relative;
|
|
52
|
+
display: flex;
|
|
53
|
+
justify-content: flex-start;
|
|
54
|
+
align-items: center;
|
|
55
|
+
height: 32px;
|
|
56
|
+
min-width: 116px;
|
|
57
|
+
padding-top: 0.25rem;
|
|
58
|
+
|
|
59
|
+
border-bottom: 1px solid var(--input-border-color);
|
|
60
|
+
color: var(--input-placeholder-fg);
|
|
61
|
+
font-size: 0.75rem;
|
|
62
|
+
font-weight: 400;
|
|
63
|
+
cursor: pointer;
|
|
64
|
+
|
|
65
|
+
&.disabled {
|
|
66
|
+
cursor: default;
|
|
67
|
+
filter: contrast(0.3) brightness(0.7);
|
|
68
|
+
pointer-events: none;
|
|
69
|
+
user-select: none;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
&:not(.disabled) .before {
|
|
73
|
+
position: absolute;
|
|
74
|
+
bottom: -1px;
|
|
75
|
+
left: 0;
|
|
76
|
+
display: inline-block;
|
|
77
|
+
width: 0;
|
|
78
|
+
height: 2px;
|
|
79
|
+
background-color: var(--input-active-border-color);
|
|
80
|
+
z-index: 2;
|
|
81
|
+
transition: 0.2s ease-in-out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
&:active:not(.disabled) .before,
|
|
85
|
+
&:focus:not(.disabled) .before,
|
|
86
|
+
&:focus-visible:not(.disabled) .before {
|
|
87
|
+
width: 100%;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
img {
|
|
91
|
+
transition: 0.15s ease-in-out;
|
|
92
|
+
|
|
93
|
+
&.reversed {
|
|
94
|
+
transform: rotate(180deg);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
</style>
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { fetchStreamerActiveWalls, fetchPublicWallGoals, getWallSupporters } from '../../services/indieWallService'
|
|
3
|
+
import MuralCanvas from './MuralCanvas.vue'
|
|
4
|
+
import SupportStepper from './SupportStepper.vue'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{ userId: number | string }>()
|
|
7
|
+
const localePath = useLocalePath()
|
|
8
|
+
const authStore = useAuthStore()
|
|
9
|
+
|
|
10
|
+
const wall = ref<any>(null)
|
|
11
|
+
const goals = ref<any[]>([])
|
|
12
|
+
const supporters = ref<any[]>([])
|
|
13
|
+
const loading = ref(true)
|
|
14
|
+
|
|
15
|
+
const showStepper = ref(false)
|
|
16
|
+
const stepperStartStep = ref<1 | 2 | 3 | 4 | 5>(1)
|
|
17
|
+
const stepperInitialSelection = ref<{ x: number; y: number; w: number; h: number } | null>(null)
|
|
18
|
+
|
|
19
|
+
const detailPixel = ref<any | null>(null)
|
|
20
|
+
|
|
21
|
+
const totalPotential = computed(() =>
|
|
22
|
+
(wall.value?.width ?? 0) * (wall.value?.height ?? 0) * (Number(wall.value?.pixel_price) || 0)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const raisedPercent = computed(() => {
|
|
26
|
+
if (!totalPotential.value) return 0
|
|
27
|
+
return Math.min(100, (Number(wall.value?.raised_amount || 0) / totalPotential.value) * 100)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const tiersData = computed<any[]>(() => wall.value?.tiers?.data ?? wall.value?.tiers ?? [])
|
|
31
|
+
const mgcBalance = computed(() => Number(authStore.user?.mgc_balance ?? 0))
|
|
32
|
+
|
|
33
|
+
const wallUrl = computed(() => {
|
|
34
|
+
if (!wall.value) return '#'
|
|
35
|
+
return localePath(`/${wall.value.url_prefix || 'mural'}/${wall.value.slug}`)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
function onTapEmpty(coord: { x: number; y: number }) {
|
|
39
|
+
stepperInitialSelection.value = { x: coord.x, y: coord.y, w: 1, h: 1 }
|
|
40
|
+
stepperStartStep.value = 1
|
|
41
|
+
showStepper.value = true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function onTapSupporter(s: any) {
|
|
45
|
+
detailPixel.value = s
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function openStepper() {
|
|
49
|
+
stepperInitialSelection.value = null
|
|
50
|
+
stepperStartStep.value = 1
|
|
51
|
+
showStepper.value = true
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function onStepperSuccess() {
|
|
55
|
+
showStepper.value = false
|
|
56
|
+
await reloadSupporters()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function reloadSupporters() {
|
|
60
|
+
if (!wall.value) return
|
|
61
|
+
const supRes = await getWallSupporters(wall.value.slug || wall.value.id, { per_page: 500 })
|
|
62
|
+
supporters.value = (supRes.data?.data ?? []).filter((p: any) => p.status)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const hoverTip = ref<{ supporter: any; x: number; y: number } | null>(null)
|
|
66
|
+
|
|
67
|
+
const supporterToast = ref<{ name: string; amount: string; currency: string } | null>(null)
|
|
68
|
+
let toastDismissTimer: ReturnType<typeof setTimeout> | null = null
|
|
69
|
+
|
|
70
|
+
function showSupporterToast(pixel: any) {
|
|
71
|
+
if (toastDismissTimer) clearTimeout(toastDismissTimer)
|
|
72
|
+
const name = pixel.is_anonymous ? 'Anonymous' : (pixel.title || pixel.user?.name || 'Anonymous')
|
|
73
|
+
const currency = wall.value?.currency ?? 'USD'
|
|
74
|
+
const amount = Number(pixel.amount_paid || 0).toFixed(2)
|
|
75
|
+
supporterToast.value = { name, amount, currency }
|
|
76
|
+
toastDismissTimer = setTimeout(() => { supporterToast.value = null }, 6000)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function onHoverSupporter(payload: { supporter: any | null; clientX: number; clientY: number }) {
|
|
80
|
+
hoverTip.value = payload.supporter
|
|
81
|
+
? { supporter: payload.supporter, x: payload.clientX, y: payload.clientY }
|
|
82
|
+
: null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function supporterDisplayName(s: any): string {
|
|
86
|
+
if (s.is_anonymous) return 'Anonymous'
|
|
87
|
+
return s.title || s.user?.name || 'Anonymous'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function load() {
|
|
91
|
+
loading.value = true
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetchStreamerActiveWalls(props.userId)
|
|
94
|
+
const list = res.data?.data ?? res.data ?? []
|
|
95
|
+
const first = Array.isArray(list) ? list.find((w: any) => w.show_on_media_kit) ?? list[0] : null
|
|
96
|
+
if (!first) return
|
|
97
|
+
wall.value = first
|
|
98
|
+
const [goalsRes, supRes] = await Promise.all([
|
|
99
|
+
fetchPublicWallGoals(first.slug || first.id).catch(() => ({ data: { data: [] } })),
|
|
100
|
+
getWallSupporters(first.slug || first.id, { per_page: 500 }),
|
|
101
|
+
])
|
|
102
|
+
goals.value = goalsRes.data?.data || []
|
|
103
|
+
supporters.value = (supRes.data?.data ?? []).filter((p: any) => p.status)
|
|
104
|
+
} catch {
|
|
105
|
+
// no wall — renders nothing
|
|
106
|
+
} finally {
|
|
107
|
+
loading.value = false
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function onPixelConfirmed(payload: any) {
|
|
112
|
+
const pixel = payload?.pixel
|
|
113
|
+
if (!pixel || !wall.value || Number(pixel.indie_wall_id) !== Number(wall.value.id)) return
|
|
114
|
+
|
|
115
|
+
if (payload?.wall) {
|
|
116
|
+
wall.value.raised_amount = payload.wall.raised_amount ?? wall.value.raised_amount
|
|
117
|
+
wall.value.used_pixels = payload.wall.used_pixels ?? wall.value.used_pixels
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
showSupporterToast(pixel)
|
|
121
|
+
|
|
122
|
+
// Re-fetch full supporter list to get image_url — the WS payload omits it
|
|
123
|
+
// intentionally to stay under Pusher's 10 KB message limit.
|
|
124
|
+
reloadSupporters()
|
|
125
|
+
}
|
|
126
|
+
let echoChannel: any = null
|
|
127
|
+
function subscribeWall(wallId: number | string) {
|
|
128
|
+
const echo = (window as any).Echo
|
|
129
|
+
if (!echo) return
|
|
130
|
+
try {
|
|
131
|
+
echoChannel = echo.channel(`indie-wall.${wallId}`)
|
|
132
|
+
echoChannel.listen('.pixel.confirmed', onPixelConfirmed)
|
|
133
|
+
} catch { /* ignore */ }
|
|
134
|
+
}
|
|
135
|
+
function unsubscribeWall(wallId: number | string) {
|
|
136
|
+
try { (window as any).Echo?.leave?.(`indie-wall.${wallId}`) } catch { /* ignore */ }
|
|
137
|
+
echoChannel = null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
watch(wall, (newWall, oldWall) => {
|
|
141
|
+
if (oldWall?.id && oldWall.id !== newWall?.id) unsubscribeWall(oldWall.id)
|
|
142
|
+
if (newWall?.id) subscribeWall(newWall.id)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
onMounted(load)
|
|
146
|
+
onUnmounted(() => {
|
|
147
|
+
if (wall.value?.id) unsubscribeWall(wall.value.id)
|
|
148
|
+
if (toastDismissTimer) clearTimeout(toastDismissTimer)
|
|
149
|
+
})
|
|
150
|
+
</script>
|
|
151
|
+
|
|
152
|
+
<template>
|
|
153
|
+
<div v-if="!loading && wall" class="mkw-block">
|
|
154
|
+
<h2 class="mkw-section-title">{{ $t('kit.media.support') }}</h2>
|
|
155
|
+
|
|
156
|
+
<div class="mkw-card">
|
|
157
|
+
<div class="mkw-meta">
|
|
158
|
+
<div class="mkw-info">
|
|
159
|
+
<span class="mkw-name">{{ wall.name }}</span>
|
|
160
|
+
<p v-if="wall.description" class="mkw-desc">{{ wall.description }}</p>
|
|
161
|
+
</div>
|
|
162
|
+
<div class="mkw-meta-actions">
|
|
163
|
+
<button type="button" class="mkw-cta" @click="openStepper">
|
|
164
|
+
{{ $t('kit.media.support') }}
|
|
165
|
+
</button>
|
|
166
|
+
<NuxtLink :to="wallUrl" class="mkw-link">
|
|
167
|
+
{{ $t('kit.media.view_wall') }}
|
|
168
|
+
</NuxtLink>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div class="mkw-progress-row">
|
|
173
|
+
<span class="mkw-raised">
|
|
174
|
+
{{ wall.currency }} {{ Number(wall.raised_amount || 0).toFixed(2) }} {{ $t('kit.media.raised') }}
|
|
175
|
+
</span>
|
|
176
|
+
<span v-if="totalPotential > 0" class="mkw-total">
|
|
177
|
+
/ {{ wall.currency }} {{ totalPotential.toFixed(2) }}
|
|
178
|
+
</span>
|
|
179
|
+
<span class="mkw-pct">{{ raisedPercent.toFixed(0) }}%</span>
|
|
180
|
+
</div>
|
|
181
|
+
<div class="mkw-bar">
|
|
182
|
+
<div class="mkw-bar-fill" :style="{ width: raisedPercent + '%' }" />
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<div class="mkw-canvas-area">
|
|
186
|
+
<div class="mkw-hint-row">
|
|
187
|
+
<span class="mkw-hint">{{ $t('tv.dashboard.indie_wall.mural_click_hint') }}</span>
|
|
188
|
+
<span class="mkw-availability">
|
|
189
|
+
{{ Math.max(0, (wall.total_pixels || 0) - (wall.used_pixels || 0)) }}
|
|
190
|
+
/ {{ wall.total_pixels || 0 }}
|
|
191
|
+
{{ $t('tv.dashboard.indie_wall.pixels_available') }}
|
|
192
|
+
</span>
|
|
193
|
+
</div>
|
|
194
|
+
<MuralCanvas
|
|
195
|
+
:wall="wall"
|
|
196
|
+
:supporters="supporters"
|
|
197
|
+
:selection="null"
|
|
198
|
+
:interactive="true"
|
|
199
|
+
:height="300"
|
|
200
|
+
@tap-empty="onTapEmpty"
|
|
201
|
+
@tap-supporter="onTapSupporter"
|
|
202
|
+
@hover-supporter="onHoverSupporter"
|
|
203
|
+
/>
|
|
204
|
+
<Transition name="mkw-toast">
|
|
205
|
+
<div v-if="supporterToast" class="mkw-supporter-toast">
|
|
206
|
+
<span class="mkw-toast-icon">★</span>
|
|
207
|
+
<div class="mkw-toast-text">
|
|
208
|
+
<strong>{{ supporterToast.name }}</strong>
|
|
209
|
+
<span>{{ $t('tv.dashboard.indie_wall.just_supported') }} · {{ supporterToast.currency }} {{ supporterToast.amount }}</span>
|
|
210
|
+
</div>
|
|
211
|
+
<button class="mkw-toast-close" type="button" @click="supporterToast = null">×</button>
|
|
212
|
+
</div>
|
|
213
|
+
</Transition>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<Teleport to="body">
|
|
218
|
+
<Transition name="mkw-fade">
|
|
219
|
+
<div
|
|
220
|
+
v-if="hoverTip"
|
|
221
|
+
class="mkw-hover-tip"
|
|
222
|
+
:style="{ left: (hoverTip.x + 14) + 'px', top: (hoverTip.y + 14) + 'px' }"
|
|
223
|
+
>
|
|
224
|
+
<div class="mkw-tip-swatch" :style="{ background: hoverTip.supporter.background_color || '#D297FF' }">
|
|
225
|
+
<img v-if="hoverTip.supporter.image_url" :src="hoverTip.supporter.image_url" alt="" />
|
|
226
|
+
</div>
|
|
227
|
+
<div class="mkw-tip-info">
|
|
228
|
+
<strong>{{ supporterDisplayName(hoverTip.supporter) }}</strong>
|
|
229
|
+
<span v-if="hoverTip.supporter.supporter_message">"{{ hoverTip.supporter.supporter_message }}"</span>
|
|
230
|
+
<small>{{ wall.currency }} {{ Number(hoverTip.supporter.amount_paid || 0).toFixed(2) }} · {{ hoverTip.supporter.pixel_count }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}</small>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
</Transition>
|
|
234
|
+
|
|
235
|
+
<Transition name="mkw-fade">
|
|
236
|
+
<SupportStepper
|
|
237
|
+
v-if="showStepper && wall"
|
|
238
|
+
:wall="wall"
|
|
239
|
+
:goals="goals"
|
|
240
|
+
:tiers="tiersData"
|
|
241
|
+
:supporters="supporters"
|
|
242
|
+
:mgc-balance="mgcBalance"
|
|
243
|
+
:start-step="stepperStartStep"
|
|
244
|
+
:initial-selection="stepperInitialSelection"
|
|
245
|
+
@close="showStepper = false"
|
|
246
|
+
@support-success="onStepperSuccess"
|
|
247
|
+
/>
|
|
248
|
+
</Transition>
|
|
249
|
+
|
|
250
|
+
<Transition name="mkw-fade">
|
|
251
|
+
<div v-if="detailPixel" class="mkw-detail-overlay" @click.self="detailPixel = null">
|
|
252
|
+
<div class="mkw-detail-box">
|
|
253
|
+
<button class="mkw-detail-close" type="button" @click="detailPixel = null">×</button>
|
|
254
|
+
<div class="mkw-detail-swatch" :style="{ background: detailPixel.background_color || '#D297FF' }">
|
|
255
|
+
<img v-if="detailPixel.image_url" :src="detailPixel.image_url" alt="" />
|
|
256
|
+
</div>
|
|
257
|
+
<p class="mkw-detail-name">{{ supporterDisplayName(detailPixel) }}</p>
|
|
258
|
+
<p v-if="detailPixel.supporter_message" class="mkw-detail-msg">"{{ detailPixel.supporter_message }}"</p>
|
|
259
|
+
<div class="mkw-detail-meta">
|
|
260
|
+
<span>{{ $t('tv.dashboard.indie_wall.mural_position') }}: ({{ detailPixel.x }}, {{ detailPixel.y }})</span>
|
|
261
|
+
<span>{{ detailPixel.pixel_count }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}</span>
|
|
262
|
+
<span>{{ wall.currency }} {{ Number(detailPixel.amount_paid).toFixed(2) }}</span>
|
|
263
|
+
</div>
|
|
264
|
+
<p v-if="detailPixel.link">
|
|
265
|
+
<a :href="detailPixel.link" target="_blank" rel="noopener" class="mkw-detail-link">{{ detailPixel.link }}</a>
|
|
266
|
+
</p>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
</Transition>
|
|
270
|
+
</Teleport>
|
|
271
|
+
</div>
|
|
272
|
+
</template>
|
|
273
|
+
|
|
274
|
+
<style scoped lang="scss">
|
|
275
|
+
.mkw-block { margin-bottom: 16px; }
|
|
276
|
+
|
|
277
|
+
.mkw-section-title {
|
|
278
|
+
font-size: 13px; letter-spacing: 3px; text-transform: uppercase;
|
|
279
|
+
color: var(--kit-muted); font-weight: 700; margin-bottom: 16px;
|
|
280
|
+
display: flex; align-items: center; gap: 12px;
|
|
281
|
+
&::after { content: ""; flex: 1; height: 1px; background: var(--kit-line); }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.mkw-card {
|
|
285
|
+
background: var(--kit-surface, #191B20);
|
|
286
|
+
border: 1px solid var(--kit-line, rgba(128, 128, 128, 0.15));
|
|
287
|
+
padding: 16px;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.mkw-meta {
|
|
291
|
+
display: flex; align-items: flex-start; justify-content: space-between;
|
|
292
|
+
gap: 12px; margin-bottom: 12px;
|
|
293
|
+
@media (max-width: 576px) { flex-direction: column; }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.mkw-info { flex: 1; min-width: 0; }
|
|
297
|
+
.mkw-name {
|
|
298
|
+
font-family: 'Rajdhani', sans-serif; font-weight: 700; font-size: 19px;
|
|
299
|
+
color: var(--kit-text, #fff); display: block;
|
|
300
|
+
}
|
|
301
|
+
.mkw-desc {
|
|
302
|
+
font-size: 13px; color: var(--kit-muted, #888); margin: 4px 0 0;
|
|
303
|
+
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.mkw-meta-actions { display: flex; flex-direction: column; gap: 6px; flex-shrink: 0; align-items: stretch; }
|
|
307
|
+
|
|
308
|
+
.mkw-cta {
|
|
309
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
310
|
+
background: var(--kit-accent, #D297FF); color: var(--kit-accent-ink, #13161C);
|
|
311
|
+
font-weight: 700; font-size: 13px; padding: 8px 14px; border: none;
|
|
312
|
+
white-space: nowrap; cursor: pointer; text-transform: uppercase;
|
|
313
|
+
&:hover { filter: brightness(1.1); }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.mkw-link {
|
|
317
|
+
font-size: 11px; color: var(--kit-accent, #D297FF);
|
|
318
|
+
text-decoration: none; text-align: center;
|
|
319
|
+
&:hover { text-decoration: underline; }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.mkw-progress-row {
|
|
323
|
+
display: flex; align-items: baseline; gap: 6px; font-size: 12px; margin-bottom: 5px;
|
|
324
|
+
}
|
|
325
|
+
.mkw-raised { color: var(--kit-accent, #D297FF); font-weight: 700; }
|
|
326
|
+
.mkw-total { color: var(--kit-muted, #888); font-size: 11px; }
|
|
327
|
+
.mkw-pct { color: var(--kit-muted, #888); font-size: 11px; margin-left: auto; }
|
|
328
|
+
|
|
329
|
+
.mkw-bar { height: 3px; background: var(--kit-surface-2, rgba(128, 128, 128, 0.15)); margin-bottom: 14px; overflow: hidden; }
|
|
330
|
+
.mkw-bar-fill { height: 100%; background: var(--kit-accent, #D297FF); transition: width 0.5s ease; min-width: 2px; }
|
|
331
|
+
|
|
332
|
+
.mkw-canvas-area { display: flex; flex-direction: column; gap: 6px; }
|
|
333
|
+
.mkw-hint-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
334
|
+
.mkw-hint { font-size: 11px; color: var(--kit-muted, #888); }
|
|
335
|
+
.mkw-availability { font-size: 11px; color: var(--kit-accent, #D297FF); font-weight: 600; }
|
|
336
|
+
|
|
337
|
+
.mkw-hover-tip {
|
|
338
|
+
position: fixed; z-index: 9999; pointer-events: none;
|
|
339
|
+
background: #191B20; border: 1px solid #272930; padding: 8px 10px;
|
|
340
|
+
display: flex; gap: 8px; max-width: 260px; box-shadow: 0 4px 14px rgba(0,0,0,0.5);
|
|
341
|
+
}
|
|
342
|
+
.mkw-tip-swatch {
|
|
343
|
+
width: 26px; height: 26px; flex-shrink: 0; overflow: hidden;
|
|
344
|
+
img { width: 100%; height: 100%; object-fit: cover; image-rendering: pixelated; }
|
|
345
|
+
}
|
|
346
|
+
.mkw-tip-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
347
|
+
.mkw-tip-info strong { color: #fff; font-size: 12px; }
|
|
348
|
+
.mkw-tip-info span {
|
|
349
|
+
color: #888; font-size: 11px; font-style: italic;
|
|
350
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
351
|
+
}
|
|
352
|
+
.mkw-tip-info small { color: var(--kit-accent, #D297FF); font-size: 11px; margin-top: 2px; }
|
|
353
|
+
|
|
354
|
+
.mkw-detail-overlay {
|
|
355
|
+
position: fixed; inset: 0; background: rgba(0,0,0,0.72);
|
|
356
|
+
display: flex; align-items: center; justify-content: center; z-index: 9998; padding: 16px;
|
|
357
|
+
}
|
|
358
|
+
.mkw-detail-box { background: #191B20; border: 1px solid #272930; padding: 24px; width: 100%; max-width: 320px; position: relative; }
|
|
359
|
+
.mkw-detail-close {
|
|
360
|
+
position: absolute; top: 10px; right: 10px; background: #272930; border: none; color: #fff;
|
|
361
|
+
width: 26px; height: 26px; display: flex; align-items: center; justify-content: center;
|
|
362
|
+
cursor: pointer; font-size: 1rem; line-height: 1;
|
|
363
|
+
}
|
|
364
|
+
.mkw-detail-swatch {
|
|
365
|
+
width: 44px; height: 44px; margin-bottom: 12px; overflow: hidden;
|
|
366
|
+
img { width: 100%; height: 100%; object-fit: cover; image-rendering: pixelated; }
|
|
367
|
+
}
|
|
368
|
+
.mkw-detail-name { font-size: 1rem; font-weight: 600; color: #fff; margin-bottom: 6px; }
|
|
369
|
+
.mkw-detail-msg { color: #aaa; font-size: 0.85rem; font-style: italic; margin-bottom: 12px; line-height: 1.4; }
|
|
370
|
+
.mkw-detail-meta { display: flex; flex-direction: column; gap: 3px; color: #666; font-size: 0.75rem; margin-bottom: 8px; }
|
|
371
|
+
.mkw-detail-link {
|
|
372
|
+
color: var(--kit-accent, #D297FF); font-size: 0.75rem; word-break: break-all; text-decoration: none;
|
|
373
|
+
&:hover { text-decoration: underline; }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
.mkw-fade-enter-active, .mkw-fade-leave-active { transition: opacity 0.12s; }
|
|
377
|
+
.mkw-fade-enter-from, .mkw-fade-leave-to { opacity: 0; }
|
|
378
|
+
|
|
379
|
+
.mkw-supporter-toast {
|
|
380
|
+
display: flex; align-items: center; gap: 10px;
|
|
381
|
+
background: #1a1200; border: 1px solid var(--kit-accent, #D297FF); padding: 10px 12px;
|
|
382
|
+
margin-top: 10px; position: relative;
|
|
383
|
+
}
|
|
384
|
+
.mkw-toast-icon { color: var(--kit-accent, #D297FF); font-size: 1rem; flex-shrink: 0; line-height: 1; }
|
|
385
|
+
.mkw-toast-text {
|
|
386
|
+
display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0;
|
|
387
|
+
strong { color: #fff; font-size: 0.82rem; font-weight: 700; }
|
|
388
|
+
span { color: var(--kit-accent, #D297FF); font-size: 0.75rem; }
|
|
389
|
+
}
|
|
390
|
+
.mkw-toast-close {
|
|
391
|
+
background: none; border: none; color: #888; cursor: pointer; font-size: 1rem;
|
|
392
|
+
line-height: 1; padding: 0; flex-shrink: 0;
|
|
393
|
+
&:hover { color: #fff; }
|
|
394
|
+
}
|
|
395
|
+
.mkw-toast-enter-active { transition: opacity 0.2s, transform 0.2s; }
|
|
396
|
+
.mkw-toast-leave-active { transition: opacity 0.3s; }
|
|
397
|
+
.mkw-toast-enter-from { opacity: 0; transform: translateY(-6px); }
|
|
398
|
+
.mkw-toast-leave-to { opacity: 0; }
|
|
399
|
+
</style>
|
package/locales/de.json
CHANGED
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
"upcoming": "Coming soon",
|
|
46
46
|
"back": "Back",
|
|
47
47
|
"search_placeholder": "Search campaigns...",
|
|
48
|
+
"sort_recent": "Neueste",
|
|
49
|
+
"sort_popular": "Beliebteste",
|
|
48
50
|
"seo": {
|
|
49
51
|
"title_generic": "Key Campaigns"
|
|
50
52
|
},
|
|
@@ -205,7 +207,8 @@
|
|
|
205
207
|
"resources_title": "Ressourcen",
|
|
206
208
|
"press_kit_link": "Presse-Kit",
|
|
207
209
|
"review_guideline": "Review-Richtlinie",
|
|
208
|
-
"embargo_date": "Sperrfrist"
|
|
210
|
+
"embargo_date": "Sperrfrist",
|
|
211
|
+
"pr_contact_label": "PR-Kontakt"
|
|
209
212
|
},
|
|
210
213
|
"materials": {
|
|
211
214
|
"title": "Submit material",
|
package/locales/en.json
CHANGED
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
"upcoming": "Coming soon",
|
|
46
46
|
"back": "Back",
|
|
47
47
|
"search_placeholder": "Search campaigns...",
|
|
48
|
+
"sort_recent": "Most recent",
|
|
49
|
+
"sort_popular": "Most popular",
|
|
48
50
|
"seo": {
|
|
49
51
|
"title_generic": "Key Campaigns"
|
|
50
52
|
},
|
|
@@ -205,7 +207,8 @@
|
|
|
205
207
|
"resources_title": "Resources",
|
|
206
208
|
"press_kit_link": "Press Kit",
|
|
207
209
|
"review_guideline": "Review guideline",
|
|
208
|
-
"embargo_date": "Embargo date"
|
|
210
|
+
"embargo_date": "Embargo date",
|
|
211
|
+
"pr_contact_label": "PR contact"
|
|
209
212
|
},
|
|
210
213
|
"materials": {
|
|
211
214
|
"title": "Submit material",
|
package/locales/pt-BR.json
CHANGED
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
"upcoming": "Coming soon",
|
|
46
46
|
"back": "Back",
|
|
47
47
|
"search_placeholder": "Search campaigns...",
|
|
48
|
+
"sort_recent": "Mais recentes",
|
|
49
|
+
"sort_popular": "Mais populares",
|
|
48
50
|
"seo": {
|
|
49
51
|
"title_generic": "Key Campaigns"
|
|
50
52
|
},
|
|
@@ -205,7 +207,8 @@
|
|
|
205
207
|
"resources_title": "Recursos",
|
|
206
208
|
"press_kit_link": "Press Kit",
|
|
207
209
|
"review_guideline": "Diretriz de review",
|
|
208
|
-
"embargo_date": "Data de embargo"
|
|
210
|
+
"embargo_date": "Data de embargo",
|
|
211
|
+
"pr_contact_label": "Contato de imprensa"
|
|
209
212
|
},
|
|
210
213
|
"materials": {
|
|
211
214
|
"title": "Submit material",
|
package/locales/ro.json
CHANGED
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
"upcoming": "Coming soon",
|
|
46
46
|
"back": "Back",
|
|
47
47
|
"search_placeholder": "Search campaigns...",
|
|
48
|
+
"sort_recent": "Cele mai recente",
|
|
49
|
+
"sort_popular": "Cele mai populare",
|
|
48
50
|
"seo": {
|
|
49
51
|
"title_generic": "Key Campaigns"
|
|
50
52
|
},
|
|
@@ -205,7 +207,8 @@
|
|
|
205
207
|
"resources_title": "Resurse",
|
|
206
208
|
"press_kit_link": "Press Kit",
|
|
207
209
|
"review_guideline": "Ghid de recenzie",
|
|
208
|
-
"embargo_date": "Data embargo"
|
|
210
|
+
"embargo_date": "Data embargo",
|
|
211
|
+
"pr_contact_label": "Contact PR"
|
|
209
212
|
},
|
|
210
213
|
"materials": {
|
|
211
214
|
"title": "Submit material",
|
package/package.json
CHANGED
|
@@ -631,7 +631,11 @@ onMounted(async () => {
|
|
|
631
631
|
|
|
632
632
|
<!-- Cover when no game details -->
|
|
633
633
|
<div class="featured-cover" v-if="campaign?.cover">
|
|
634
|
-
<img
|
|
634
|
+
<img
|
|
635
|
+
:src="campaign.cover"
|
|
636
|
+
:alt="campaign.name"
|
|
637
|
+
:style="{ objectPosition: `${(campaign.cover_focal_x ?? 0.5) * 100}% ${(campaign.cover_focal_y ?? 0.5) * 100}%` }"
|
|
638
|
+
/>
|
|
635
639
|
</div>
|
|
636
640
|
|
|
637
641
|
<div class="cards">
|
|
@@ -782,10 +786,10 @@ onMounted(async () => {
|
|
|
782
786
|
<div class="tag" :class="{ active: campaign?.streamer_type === 1 }">
|
|
783
787
|
<MGIcon icon="community" /> {{ $t("keys.campaigns.campaign.requirement.all") }}
|
|
784
788
|
</div>
|
|
785
|
-
<div class="tag" :class="{ active: campaign?.streamer_type === 2
|
|
789
|
+
<div class="tag" :class="{ active: campaign?.streamer_type === 2 }">
|
|
786
790
|
<MGIcon icon="verified" /> {{ $t("keys.campaigns.campaign.requirement.official") }}
|
|
787
791
|
</div>
|
|
788
|
-
<div class="tag" :class="{ active: campaign?.streamer_type === 3
|
|
792
|
+
<div class="tag" :class="{ active: campaign?.streamer_type === 3 }">
|
|
789
793
|
<MGIcon icon="partner" /> {{ $t("keys.campaigns.campaign.requirement.affiliate") }}
|
|
790
794
|
</div>
|
|
791
795
|
</div>
|
|
@@ -896,6 +900,20 @@ onMounted(async () => {
|
|
|
896
900
|
{{ campaign.email }}
|
|
897
901
|
</div>
|
|
898
902
|
</div>
|
|
903
|
+
<div class="badge-tag" v-if="campaign?.pr_contact_name || campaign?.pr_contact_email">
|
|
904
|
+
<div class="texts">
|
|
905
|
+
<div class="small">{{ $t("keys.campaigns.pr_contact_label") }}</div>
|
|
906
|
+
<template v-if="campaign.pr_contact_name && campaign.pr_contact_email">
|
|
907
|
+
{{ campaign.pr_contact_name }} — {{ campaign.pr_contact_email }}
|
|
908
|
+
</template>
|
|
909
|
+
<template v-else>{{ campaign.pr_contact_name || campaign.pr_contact_email }}</template>
|
|
910
|
+
</div>
|
|
911
|
+
</div>
|
|
912
|
+
<a v-if="campaign?.press_kit_link" :href="campaign.press_kit_link" target="_blank" rel="noopener noreferrer">
|
|
913
|
+
<div class="badge-tag">
|
|
914
|
+
<div class="texts">{{ $t("keys.campaigns.press_kit_link") }}</div>
|
|
915
|
+
</div>
|
|
916
|
+
</a>
|
|
899
917
|
<a v-if="campaign?.twitter" :href="campaign.twitter" target="_blank" rel="noopener noreferrer">
|
|
900
918
|
<div class="badge-tag w-48"><MGIcon icon="twitter-v2" /></div>
|
|
901
919
|
</a>
|
|
@@ -1044,7 +1062,7 @@ onMounted(async () => {
|
|
|
1044
1062
|
max-height: 400px;
|
|
1045
1063
|
overflow: hidden;
|
|
1046
1064
|
margin-bottom: 24px;
|
|
1047
|
-
img { width: 100%; height: 400px; object-fit: cover;
|
|
1065
|
+
img { width: 100%; height: 400px; object-fit: cover; }
|
|
1048
1066
|
}
|
|
1049
1067
|
|
|
1050
1068
|
.state.error {
|
|
@@ -27,7 +27,11 @@
|
|
|
27
27
|
<div class="texts">{{ slide.name }}</div>
|
|
28
28
|
<nuxt-link :to="`/${locale}/key-campaigns/${slide.slug}`">
|
|
29
29
|
<button class="btn primary">{{ $t("keys.campaigns.details") }}</button>
|
|
30
|
-
<img
|
|
30
|
+
<img
|
|
31
|
+
:src="slide.cover"
|
|
32
|
+
:alt="slide.name"
|
|
33
|
+
:style="{ objectPosition: `${slide.coverFocalX * 100}% ${slide.coverFocalY * 100}%` }"
|
|
34
|
+
/>
|
|
31
35
|
</nuxt-link>
|
|
32
36
|
</div>
|
|
33
37
|
</div>
|
|
@@ -62,27 +66,33 @@
|
|
|
62
66
|
|
|
63
67
|
<template v-if="platformList.length">
|
|
64
68
|
<span class="filter-divider">|</span>
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
:
|
|
68
|
-
@
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
{{ platformAbbr(platform.name) }}
|
|
73
|
-
</option>
|
|
74
|
-
</select>
|
|
69
|
+
<FormAppSelect
|
|
70
|
+
id="keyCampaignsPlatformFilter"
|
|
71
|
+
:model="activePlatformFilter ?? ''"
|
|
72
|
+
@update:model="(val) => setPlatformFilter(val ? Number(val) : null)"
|
|
73
|
+
:label="$t('keys.campaigns.all')"
|
|
74
|
+
:options="platformList.map((p) => [p.id, platformAbbr(p.name)])"
|
|
75
|
+
/>
|
|
75
76
|
</template>
|
|
76
77
|
</div>
|
|
77
78
|
<div class="bar">
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
:
|
|
79
|
+
<FormSortInput
|
|
80
|
+
id="keyCampaignsSortFilter"
|
|
81
|
+
:model="sortMode === 'popular' ? 'desc' : 'asc'"
|
|
82
|
+
@update:model="(val: 'asc' | 'desc') => setSortMode(val === 'desc' ? 'popular' : 'recent')"
|
|
83
|
+
asc-label="keys.campaigns.sort_recent"
|
|
84
|
+
desc-label="keys.campaigns.sort_popular"
|
|
82
85
|
/>
|
|
83
|
-
<
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
+
<div class="search-box">
|
|
87
|
+
<input
|
|
88
|
+
type="text"
|
|
89
|
+
v-model="searchTerm"
|
|
90
|
+
:placeholder="$t('keys.campaigns.search_placeholder')"
|
|
91
|
+
/>
|
|
92
|
+
<button type="button">
|
|
93
|
+
<MGIcon icon="search" />
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
86
96
|
</div>
|
|
87
97
|
</div>
|
|
88
98
|
|
|
@@ -405,6 +415,7 @@ const platformList = ref<Array<{ id: number; name: string; slug: string }>>([])
|
|
|
405
415
|
const activeFilter = ref<number | null>(null)
|
|
406
416
|
const activePlatformFilter = ref<number | null>(null)
|
|
407
417
|
const redeemedStatusFilter = ref<number | null>(null)
|
|
418
|
+
const sortMode = ref<"recent" | "popular">("recent")
|
|
408
419
|
|
|
409
420
|
// ------------------------------------------------------------------
|
|
410
421
|
// Data
|
|
@@ -520,6 +531,10 @@ async function loadFeaturedCampaigns() {
|
|
|
520
531
|
.map((item: any) => ({
|
|
521
532
|
id: item.id,
|
|
522
533
|
cover: item.game?.url_banner_src || item.cover_src,
|
|
534
|
+
// Only applies to the cover_src fallback — the game's own
|
|
535
|
+
// url_banner_src is already banner-shaped and needs no repositioning.
|
|
536
|
+
coverFocalX: item.game?.url_banner_src ? 0.5 : (item.cover_focal_x ?? 0.5),
|
|
537
|
+
coverFocalY: item.game?.url_banner_src ? 0.5 : (item.cover_focal_y ?? 0.5),
|
|
523
538
|
name: item.name,
|
|
524
539
|
slug: item.slug,
|
|
525
540
|
}))
|
|
@@ -545,6 +560,7 @@ async function loadCampaigns(page = 1) {
|
|
|
545
560
|
per_page: perPage.value,
|
|
546
561
|
sort: "id",
|
|
547
562
|
order: "desc",
|
|
563
|
+
sort_mode: sortMode.value,
|
|
548
564
|
}
|
|
549
565
|
|
|
550
566
|
if (activeFilter.value !== null) params["filter[type_id]"] = activeFilter.value
|
|
@@ -685,6 +701,14 @@ function setPlatformFilter(platformId: number | null) {
|
|
|
685
701
|
loadCampaigns(1)
|
|
686
702
|
}
|
|
687
703
|
|
|
704
|
+
function setSortMode(mode: "recent" | "popular") {
|
|
705
|
+
if (sortMode.value === mode) return
|
|
706
|
+
sortMode.value = mode
|
|
707
|
+
currentPage.value = 1
|
|
708
|
+
hasMoreItems.value = true
|
|
709
|
+
loadCampaigns(1)
|
|
710
|
+
}
|
|
711
|
+
|
|
688
712
|
const loadMoreItems = async () => {
|
|
689
713
|
if (!hasMoreItems.value || loading.value || loadingMore.value) return
|
|
690
714
|
await loadCampaigns(currentPage.value + 1)
|
|
@@ -1068,24 +1092,6 @@ onUnmounted(() => {
|
|
|
1068
1092
|
color: var(--key-accent-ink, #13161C);
|
|
1069
1093
|
}
|
|
1070
1094
|
|
|
1071
|
-
.platform-select {
|
|
1072
|
-
background: transparent;
|
|
1073
|
-
border: 1px solid var(--secondary-info-fg);
|
|
1074
|
-
color: var(--title-fg);
|
|
1075
|
-
font-size: 0.875rem;
|
|
1076
|
-
padding: 4px 6px;
|
|
1077
|
-
height: 44px;
|
|
1078
|
-
max-width: 140px;
|
|
1079
|
-
cursor: pointer;
|
|
1080
|
-
|
|
1081
|
-
option {
|
|
1082
|
-
background: var(--bg-primary, var(--key-accent-ink, #13161C));
|
|
1083
|
-
color: var(--title-fg);
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
&:focus { outline: none; border-color: var(--key-accent, var(--key-accent, var(--primary, #D297FF))); }
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
1095
|
.filter-divider {
|
|
1090
1096
|
display: flex;
|
|
1091
1097
|
align-items: center;
|
|
@@ -1095,6 +1101,13 @@ onUnmounted(() => {
|
|
|
1095
1101
|
}
|
|
1096
1102
|
|
|
1097
1103
|
.bar {
|
|
1104
|
+
position: relative;
|
|
1105
|
+
display: flex;
|
|
1106
|
+
align-items: center;
|
|
1107
|
+
gap: 8px;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
.search-box {
|
|
1098
1111
|
position: relative;
|
|
1099
1112
|
display: flex;
|
|
1100
1113
|
width: 339px;
|
|
@@ -1273,6 +1286,7 @@ onUnmounted(() => {
|
|
|
1273
1286
|
flex-direction: column;
|
|
1274
1287
|
gap: 12px;
|
|
1275
1288
|
.bar { width: 100%; }
|
|
1289
|
+
.search-box { flex: 1; width: auto; }
|
|
1276
1290
|
}
|
|
1277
1291
|
.cards {
|
|
1278
1292
|
flex-direction: column;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { fetchPublicMediaKit, trackMediaKitEvent } from '../../services/mediaKitService';
|
|
3
|
-
import
|
|
3
|
+
import MediaKitWallBlock from '../../components/indie-wall/MediaKitWallBlock.vue';
|
|
4
4
|
|
|
5
5
|
const route = useRoute();
|
|
6
6
|
const slug = computed(() => route.params.slug as string);
|
|
@@ -8,7 +8,6 @@ const slug = computed(() => route.params.slug as string);
|
|
|
8
8
|
const kit = ref<Record<string, any> | null>(null);
|
|
9
9
|
const loading = ref(true);
|
|
10
10
|
const error = ref(false);
|
|
11
|
-
const walls = ref<any[]>([]);
|
|
12
11
|
|
|
13
12
|
const userId = computed(() => kit.value?.user_id);
|
|
14
13
|
const streamer = computed(() => kit.value?.streamer ?? {});
|
|
@@ -26,7 +25,6 @@ const template = computed(() => kit.value?.template || 'creator');
|
|
|
26
25
|
const platforms = computed(() => kit.value?.platforms ?? []);
|
|
27
26
|
const totalFollowers = computed(() => kit.value?.total_followers ?? 0);
|
|
28
27
|
const isPremium = computed(() => kit.value?.is_premium ?? false);
|
|
29
|
-
const mediaKitWall = computed(() => walls.value.find((w: any) => w.show_on_media_kit) ?? null);
|
|
30
28
|
|
|
31
29
|
const PLATFORM_LABEL: Record<string, string> = {
|
|
32
30
|
instagram: 'Instagram', tiktok: 'TikTok', youtube: 'YouTube', twitch: 'Twitch', x: 'X',
|
|
@@ -46,27 +44,49 @@ const scheduleByDay = computed(() => {
|
|
|
46
44
|
return [0, 1, 2, 3, 4, 5, 6].map((d) => ({ d, key: DOW[d], slot: map[d] || null }));
|
|
47
45
|
});
|
|
48
46
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
47
|
+
// SSR-safe fetch so bots/link-preview scrapers (which don't run JS) get the
|
|
48
|
+
// real creator data for the SEO tags below, instead of the site-wide default.
|
|
49
|
+
const { data: ssrKitData } = await useAsyncData(
|
|
50
|
+
`media-kit-${slug.value}`,
|
|
51
|
+
() => fetchPublicMediaKit(slug.value).then(r => r.data?.data || r.data).catch(() => null),
|
|
52
|
+
);
|
|
53
|
+
if (ssrKitData.value) kit.value = ssrKitData.value;
|
|
54
|
+
else error.value = true;
|
|
55
|
+
loading.value = false;
|
|
56
|
+
|
|
57
|
+
onMounted(() => {
|
|
58
|
+
if (kit.value?.user_id) {
|
|
59
|
+
trackMediaKitEvent(kit.value.user_id, { type: 'view', referrer: document.referrer || undefined }).catch(() => {});
|
|
60
60
|
}
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
const requestUrl = useRequestURL();
|
|
64
|
+
const seoTitle = computed(() => streamer.value?.name ? `${streamer.value.name} — Mundo Gamer Media Kit` : 'Mundo Gamer Media Kit');
|
|
65
|
+
const seoDescription = computed(() => streamer.value?.bio || '');
|
|
66
|
+
const seoImage = computed(() => streamer.value?.avatar || '');
|
|
67
|
+
|
|
68
|
+
// Raw useHead meta-array form, not useSeoMeta — see the matching comment in
|
|
69
|
+
// shared-ui/pages/mural/[slug].vue for why (useSeoMeta's og:*/twitter:*/
|
|
70
|
+
// description overrides were silently dropped in this app).
|
|
71
|
+
useHead(() => ({
|
|
72
|
+
title: seoTitle.value,
|
|
73
|
+
link: [{ rel: 'canonical', href: requestUrl.href }],
|
|
74
|
+
meta: [
|
|
75
|
+
{ name: 'description', content: seoDescription.value },
|
|
76
|
+
{ property: 'og:title', content: seoTitle.value },
|
|
77
|
+
{ property: 'og:description', content: seoDescription.value },
|
|
78
|
+
// Only override the image when the creator actually has an avatar —
|
|
79
|
+
// an empty string would replace (not fall back to) the site default.
|
|
80
|
+
...(seoImage.value ? [{ property: 'og:image', content: seoImage.value }] : []),
|
|
81
|
+
{ property: 'og:type', content: 'profile' },
|
|
82
|
+
{ property: 'og:url', content: requestUrl.href },
|
|
83
|
+
{ property: 'og:site_name', content: 'Mundo Gamer Media Kit' },
|
|
84
|
+
{ name: 'twitter:card', content: 'summary_large_image' },
|
|
85
|
+
{ name: 'twitter:title', content: seoTitle.value },
|
|
86
|
+
{ name: 'twitter:description', content: seoDescription.value },
|
|
87
|
+
...(seoImage.value ? [{ name: 'twitter:image', content: seoImage.value }] : []),
|
|
88
|
+
],
|
|
89
|
+
}));
|
|
70
90
|
|
|
71
91
|
function fmt(n: number) {
|
|
72
92
|
if (!n) return '0';
|
|
@@ -252,20 +272,9 @@ function onDownload() {
|
|
|
252
272
|
</div>
|
|
253
273
|
</section>
|
|
254
274
|
|
|
255
|
-
<!-- IndieWall / mural -->
|
|
256
|
-
<section v-if="
|
|
257
|
-
<
|
|
258
|
-
<div class="mk-indiewall">
|
|
259
|
-
<div>
|
|
260
|
-
<div class="iw-name">{{ mediaKitWall.name }}</div>
|
|
261
|
-
<div v-if="mediaKitWall.description" class="iw-desc">{{ mediaKitWall.description }}</div>
|
|
262
|
-
<div class="iw-stats">
|
|
263
|
-
<div class="s"><div class="v">{{ mediaKitWall.currency }} {{ Number(mediaKitWall.raised_amount || 0).toFixed(2) }}</div><div class="l">{{ $t('kit.media.raised') }}</div></div>
|
|
264
|
-
<div class="s"><div class="v">{{ mediaKitWall.supporters_count ?? 0 }}</div><div class="l">{{ $t('kit.media.supporters') }}</div></div>
|
|
265
|
-
</div>
|
|
266
|
-
</div>
|
|
267
|
-
<a :href="mediaKitWall.mural_url" target="_blank" rel="noopener" class="kit-btn kit-btn-primary" @click="track('click')">{{ $t('kit.media.view_wall') }}</a>
|
|
268
|
-
</div>
|
|
275
|
+
<!-- IndieWall / mural — self-contained widget (own fetch, realtime, stepper) -->
|
|
276
|
+
<section v-if="userId" class="kit-block" data-sec="support">
|
|
277
|
+
<MediaKitWallBlock :user-id="userId" />
|
|
269
278
|
</section>
|
|
270
279
|
|
|
271
280
|
<footer class="mk-footer">
|
|
@@ -701,24 +710,6 @@ function onDownload() {
|
|
|
701
710
|
&:hover { border-color: var(--kit-accent); color: var(--kit-accent); }
|
|
702
711
|
}
|
|
703
712
|
|
|
704
|
-
/* IndieWall */
|
|
705
|
-
.mk-indiewall {
|
|
706
|
-
background: linear-gradient(120deg, rgba(168, 85, 247, .1), rgba(107, 21, 172, .05));
|
|
707
|
-
border: 1px solid var(--kit-accent);
|
|
708
|
-
padding: 24px;
|
|
709
|
-
display: flex;
|
|
710
|
-
justify-content: space-between;
|
|
711
|
-
align-items: center;
|
|
712
|
-
gap: 20px;
|
|
713
|
-
flex-wrap: wrap;
|
|
714
|
-
|
|
715
|
-
.iw-name { font-family: 'Rajdhani', sans-serif; font-weight: 700; font-size: 19px; }
|
|
716
|
-
.iw-desc { color: #b4b4c2; font-size: 13px; margin-top: 4px; }
|
|
717
|
-
.iw-stats { display: flex; gap: 26px; margin-top: 10px; }
|
|
718
|
-
.iw-stats .v { font-family: 'Rajdhani', sans-serif; font-weight: 700; font-size: 20px; color: var(--kit-accent); }
|
|
719
|
-
.iw-stats .l { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--kit-muted); }
|
|
720
|
-
}
|
|
721
|
-
|
|
722
713
|
/* Reach / platform breakdown */
|
|
723
714
|
.mk-total-reach {
|
|
724
715
|
font-family: 'Rajdhani', sans-serif;
|
package/pages/mural/[slug].vue
CHANGED
|
@@ -4,7 +4,7 @@ import SupportStepper from '../../components/indie-wall/SupportStepper.vue'
|
|
|
4
4
|
import MuralCanvas from '../../components/indie-wall/MuralCanvas.vue'
|
|
5
5
|
import IndieWallLeaderboard from '../../components/indie-wall/IndieWallLeaderboard.vue'
|
|
6
6
|
|
|
7
|
-
const { t } = useI18n()
|
|
7
|
+
const { t, locale } = useI18n()
|
|
8
8
|
const route = useRoute()
|
|
9
9
|
const router = useRouter()
|
|
10
10
|
const wallSlug = route.params.slug as string
|
|
@@ -142,6 +142,15 @@ const goals = ref<any[]>([])
|
|
|
142
142
|
const supporters = ref<any[]>([])
|
|
143
143
|
const loading = ref(true)
|
|
144
144
|
|
|
145
|
+
// SSR-safe fetch so bots/link-preview scrapers (which don't run JS) get the
|
|
146
|
+
// real wall data for the SEO tags below, without waiting for the client-side
|
|
147
|
+
// onMounted → loadWall() refresh (which still runs for goals/supporters/WS).
|
|
148
|
+
const { data: ssrWallData } = await useAsyncData(
|
|
149
|
+
`mural-wall-${wallSlug}`,
|
|
150
|
+
() => fetchPublicWall(wallSlug).then(r => r.data?.data || r.data).catch(() => null),
|
|
151
|
+
)
|
|
152
|
+
if (ssrWallData.value) wall.value = ssrWallData.value
|
|
153
|
+
|
|
145
154
|
// ── Stepper state ──────────────────────────────────────────────────────────
|
|
146
155
|
const showStepper = ref(false)
|
|
147
156
|
const stepperStartStep = ref<1 | 2 | 3 | 4 | 5>(1)
|
|
@@ -163,6 +172,15 @@ const progressPercent = computed(() => {
|
|
|
163
172
|
return total > 0 ? Math.min(100, ((wall.value.used_pixels || 0) / total) * 100) : 0
|
|
164
173
|
})
|
|
165
174
|
|
|
175
|
+
// Locale-aware currency symbol + consistent decimals (was raw "BRL 500" string concat).
|
|
176
|
+
function formatMoney(amount: number, currency?: string | null) {
|
|
177
|
+
try {
|
|
178
|
+
return new Intl.NumberFormat(locale.value, { style: 'currency', currency: currency || 'USD' }).format(amount)
|
|
179
|
+
} catch {
|
|
180
|
+
return `${currency || ''} ${Number(amount).toFixed(2)}`.trim()
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
166
184
|
// ── Load ────────────────────────────────────────────────────────────────────
|
|
167
185
|
async function loadWall() {
|
|
168
186
|
loading.value = true
|
|
@@ -248,9 +266,39 @@ function supporterDisplayName(s: any): string {
|
|
|
248
266
|
return s.title || s.user?.name || t('tv.dashboard.indie_wall.anonymous')
|
|
249
267
|
}
|
|
250
268
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
269
|
+
const requestUrl = useRequestURL()
|
|
270
|
+
const seoTitle = computed(() => wall.value?.name ? `${wall.value.name} · Mundo Gamer Wall` : 'Mundo Gamer Wall')
|
|
271
|
+
const seoDescription = computed(() => wall.value?.description || wall.value?.long_description?.replace(/\n+/g, ' ').slice(0, 160) || '')
|
|
272
|
+
const seoImage = computed(() => wall.value?.cover_image_url || wall.value?.logo_url || '')
|
|
273
|
+
|
|
274
|
+
// Raw useHead meta-array form — matches the exact mechanism the site-wide
|
|
275
|
+
// defaults in nuxt.config.ts use, and is proven to reliably override by
|
|
276
|
+
// name/property key in this app. useSeoMeta was tried first but its og:*/
|
|
277
|
+
// twitter:*/description overrides were silently dropped here (stray
|
|
278
|
+
// duplicate `unhead`/`@vueuse/head` deps in community-frontend/package.json
|
|
279
|
+
// likely cause a resolution conflict) — only `title` and `ogUrl` came
|
|
280
|
+
// through. Do not switch this back to useSeoMeta without first removing
|
|
281
|
+
// those stray deps and re-verifying against raw SSR HTML (curl), not just
|
|
282
|
+
// the browser, since hydration can mask a server-side-only failure.
|
|
283
|
+
useHead(() => ({
|
|
284
|
+
title: seoTitle.value,
|
|
285
|
+
link: [{ rel: 'canonical', href: requestUrl.href }],
|
|
286
|
+
meta: [
|
|
287
|
+
{ name: 'description', content: seoDescription.value },
|
|
288
|
+
{ property: 'og:title', content: seoTitle.value },
|
|
289
|
+
{ property: 'og:description', content: seoDescription.value },
|
|
290
|
+
// Only override the image when the wall actually has one — an empty
|
|
291
|
+
// string would replace (not fall back to) the site default image.
|
|
292
|
+
...(seoImage.value ? [{ property: 'og:image', content: seoImage.value }] : []),
|
|
293
|
+
{ property: 'og:type', content: 'website' },
|
|
294
|
+
{ property: 'og:url', content: requestUrl.href },
|
|
295
|
+
{ property: 'og:site_name', content: 'Mundo Gamer Wall' },
|
|
296
|
+
{ name: 'twitter:card', content: 'summary_large_image' },
|
|
297
|
+
{ name: 'twitter:title', content: seoTitle.value },
|
|
298
|
+
{ name: 'twitter:description', content: seoDescription.value },
|
|
299
|
+
...(seoImage.value ? [{ name: 'twitter:image', content: seoImage.value }] : []),
|
|
300
|
+
],
|
|
301
|
+
}))
|
|
254
302
|
|
|
255
303
|
onMounted(async () => {
|
|
256
304
|
await loadWall()
|
|
@@ -318,11 +366,15 @@ onUnmounted(() => {
|
|
|
318
366
|
<div class="hero-overlay">
|
|
319
367
|
<img v-if="wall.logo_url" :src="wall.logo_url" class="hero-logo" alt="" />
|
|
320
368
|
<h1 class="hero-title">{{ wall.name }}</h1>
|
|
369
|
+
<div v-if="wall.owner_nickname || wall.owner_name" class="hero-owner">
|
|
370
|
+
<img v-if="wall.owner_avatar_url" :src="wall.owner_avatar_url" class="hero-owner-avatar" alt="" />
|
|
371
|
+
<span class="hero-owner-name">{{ $t('tv.dashboard.indie_wall.mural_by', { name: wall.owner_nickname || wall.owner_name }) }}</span>
|
|
372
|
+
</div>
|
|
321
373
|
<p v-if="wall.description" class="hero-desc">{{ wall.description }}</p>
|
|
322
374
|
|
|
323
375
|
<div class="hero-stats">
|
|
324
376
|
<div class="hero-stat">
|
|
325
|
-
<span class="hero-stat-value">{{
|
|
377
|
+
<span class="hero-stat-value">{{ formatMoney(Number(wall.raised_amount || 0), wall.currency) }}</span>
|
|
326
378
|
<span class="hero-stat-label">{{ $t('tv.dashboard.indie_wall.raised') }}</span>
|
|
327
379
|
</div>
|
|
328
380
|
<div class="hero-stat-divider" />
|
|
@@ -372,7 +424,7 @@ onUnmounted(() => {
|
|
|
372
424
|
{{ $t('tv.dashboard.indie_wall.reached') }}
|
|
373
425
|
</span>
|
|
374
426
|
<span v-else-if="goal.target_amount > 0" class="goal-amount">
|
|
375
|
-
{{
|
|
427
|
+
{{ formatMoney(Number(goal.target_amount), wall.currency) }}
|
|
376
428
|
</span>
|
|
377
429
|
<span v-else class="goal-amount">
|
|
378
430
|
{{ goal.target_pixels }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}
|
|
@@ -396,7 +448,7 @@ onUnmounted(() => {
|
|
|
396
448
|
<div class="goal-progress-fill" :style="{ width: (goal.progress_percent || 0) + '%' }" />
|
|
397
449
|
<span class="goal-progress-text">
|
|
398
450
|
<template v-if="goal.target_amount > 0">
|
|
399
|
-
{{ $t('tv.dashboard.indie_wall.goal_card_missing_amount', {
|
|
451
|
+
{{ $t('tv.dashboard.indie_wall.goal_card_missing_amount', { amount: formatMoney(Math.max(0, goal.target_amount - (goal.current_amount || 0)), wall.currency) }) }}
|
|
400
452
|
</template>
|
|
401
453
|
<template v-else>
|
|
402
454
|
{{ $t('tv.dashboard.indie_wall.goal_card_missing', { count: Math.max(0, (goal.target_pixels || 0) - (goal.current_pixels || 0)) }) }}
|
|
@@ -433,7 +485,7 @@ onUnmounted(() => {
|
|
|
433
485
|
>
|
|
434
486
|
<img v-if="tier.image_url" :src="tier.image_url" class="tier-img" alt="" />
|
|
435
487
|
<h3 class="tier-name">{{ tier.name }}</h3>
|
|
436
|
-
<div class="tier-price">{{ tier.currency || wall.currency
|
|
488
|
+
<div class="tier-price">{{ formatMoney(Number(tier.price), tier.currency || wall.currency) }}</div>
|
|
437
489
|
<p class="tier-pixels">{{ tier.pixel_count }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}</p>
|
|
438
490
|
<p v-if="tier.description" class="tier-desc">{{ tier.description }}</p>
|
|
439
491
|
<div v-if="tier.max_quantity" class="tier-stock">
|
|
@@ -484,7 +536,7 @@ onUnmounted(() => {
|
|
|
484
536
|
<span class="supp-name">{{ supporterDisplayName(s) }}</span>
|
|
485
537
|
<span v-if="!s.is_anonymous && !s.user && s.guest_email" class="supp-email">{{ s.guest_email }}</span>
|
|
486
538
|
</div>
|
|
487
|
-
<span class="supp-amount">{{
|
|
539
|
+
<span class="supp-amount">{{ formatMoney(Number(s.amount_paid || 0), wall.currency) }}</span>
|
|
488
540
|
</div>
|
|
489
541
|
<p v-if="s.message || s.supporter_message" class="supp-msg">"{{ s.message || s.supporter_message }}"</p>
|
|
490
542
|
</div>
|
|
@@ -568,7 +620,7 @@ onUnmounted(() => {
|
|
|
568
620
|
<p class="hover-tip-name">{{ supporterDisplayName(hoverTip.supporter) }}</p>
|
|
569
621
|
<p v-if="hoverTip.supporter.supporter_message" class="hover-tip-msg">"{{ hoverTip.supporter.supporter_message }}"</p>
|
|
570
622
|
<div class="hover-tip-badges">
|
|
571
|
-
<span class="hover-tip-amount">{{
|
|
623
|
+
<span class="hover-tip-amount">{{ formatMoney(Number(hoverTip.supporter.amount_paid || 0), wall.currency) }}</span>
|
|
572
624
|
<span class="hover-tip-pixels">{{ hoverTip.supporter.pixel_count }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}</span>
|
|
573
625
|
</div>
|
|
574
626
|
</div>
|
|
@@ -592,7 +644,7 @@ onUnmounted(() => {
|
|
|
592
644
|
<div class="detail-amount-row">
|
|
593
645
|
<span class="detail-amount-label">{{ $t('tv.dashboard.indie_wall.spots_unit') }}</span>
|
|
594
646
|
<span class="detail-amount-value">
|
|
595
|
-
|
|
647
|
+
{{ formatMoney(Number(detailPixel.amount_paid), wall?.currency) }}
|
|
596
648
|
</span>
|
|
597
649
|
</div>
|
|
598
650
|
<div class="detail-chips">
|
|
@@ -647,6 +699,9 @@ onUnmounted(() => {
|
|
|
647
699
|
margin-bottom: 16px; border: 2px solid rgba(255,255,255,0.1);
|
|
648
700
|
}
|
|
649
701
|
.hero-title { font-size: 2rem; font-weight: 800; color: var(--title-fg, #fff); margin: 0 0 12px; letter-spacing: -0.5px; }
|
|
702
|
+
.hero-owner { display: flex; align-items: center; gap: 8px; margin: -6px 0 12px; }
|
|
703
|
+
.hero-owner-avatar { width: 24px; height: 24px; border-radius: 50%; object-fit: cover; }
|
|
704
|
+
.hero-owner-name { color: rgba(255,255,255,0.75); font-size: 0.9rem; font-weight: 500; }
|
|
650
705
|
.hero-desc { color: rgba(255,255,255,0.65); font-size: 1rem; max-width: 600px; line-height: 1.6; margin: 0 0 24px; }
|
|
651
706
|
.hero-stats { display: flex; align-items: center; gap: 24px; margin-bottom: 16px; }
|
|
652
707
|
.hero-stat { display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
|
@@ -179,27 +179,32 @@ function track(type: 'click' | 'download' | 'contact') {
|
|
|
179
179
|
|
|
180
180
|
const requestUrl = useRequestURL();
|
|
181
181
|
|
|
182
|
-
const seoTitle = computed(() => kit.value ? `${kit.value.name} — Press Kit` : 'Press Kit');
|
|
182
|
+
const seoTitle = computed(() => kit.value ? `${kit.value.name} — Mundo Gamer Press Kit` : 'Mundo Gamer Press Kit');
|
|
183
183
|
const seoDescription = computed(() => kit.value?.tagline || kit.value?.description?.replace(/<[^>]*>/g, '').slice(0, 160) || '');
|
|
184
184
|
const seoImage = computed(() => kit.value?.hero_image_url || kit.value?.cover_image_url || kit.value?.logo_url || '');
|
|
185
185
|
|
|
186
|
+
// Raw useHead meta-array form, not useSeoMeta — see the matching comment in
|
|
187
|
+
// shared-ui/pages/mural/[slug].vue for why (useSeoMeta's og:*/twitter:*/
|
|
188
|
+
// description overrides get silently dropped in this app).
|
|
186
189
|
useHead(() => ({
|
|
187
190
|
title: seoTitle.value,
|
|
191
|
+
link: [{ rel: 'canonical', href: requestUrl.href }],
|
|
192
|
+
meta: [
|
|
193
|
+
{ name: 'description', content: seoDescription.value },
|
|
194
|
+
{ property: 'og:title', content: seoTitle.value },
|
|
195
|
+
{ property: 'og:description', content: seoDescription.value },
|
|
196
|
+
// Only override the image when the kit actually has one — an empty
|
|
197
|
+
// string would replace (not fall back to) the site default image.
|
|
198
|
+
...(seoImage.value ? [{ property: 'og:image', content: seoImage.value }] : []),
|
|
199
|
+
{ property: 'og:type', content: 'article' },
|
|
200
|
+
{ property: 'og:url', content: requestUrl.href },
|
|
201
|
+
{ property: 'og:site_name', content: 'Mundo Gamer Press Kit' },
|
|
202
|
+
{ name: 'twitter:card', content: 'summary_large_image' },
|
|
203
|
+
{ name: 'twitter:title', content: seoTitle.value },
|
|
204
|
+
{ name: 'twitter:description', content: seoDescription.value },
|
|
205
|
+
...(seoImage.value ? [{ name: 'twitter:image', content: seoImage.value }] : []),
|
|
206
|
+
],
|
|
188
207
|
}));
|
|
189
|
-
|
|
190
|
-
useSeoMeta({
|
|
191
|
-
title: () => seoTitle.value,
|
|
192
|
-
description: () => seoDescription.value,
|
|
193
|
-
ogTitle: () => seoTitle.value,
|
|
194
|
-
ogDescription: () => seoDescription.value,
|
|
195
|
-
ogImage: () => seoImage.value,
|
|
196
|
-
ogType: 'article',
|
|
197
|
-
ogUrl: () => requestUrl.href,
|
|
198
|
-
twitterCard: 'summary_large_image',
|
|
199
|
-
twitterTitle: () => seoTitle.value,
|
|
200
|
-
twitterDescription: () => seoDescription.value,
|
|
201
|
-
twitterImage: () => seoImage.value,
|
|
202
|
-
});
|
|
203
208
|
</script>
|
|
204
209
|
|
|
205
210
|
<template>
|
|
Binary file
|
|
@@ -297,6 +297,8 @@ export const fetchCampaignBySlug = async (slug: string) => {
|
|
|
297
297
|
name: k.name,
|
|
298
298
|
slug: k.slug,
|
|
299
299
|
cover: k.cover_src,
|
|
300
|
+
cover_focal_x: k.cover_focal_x ?? 0.5,
|
|
301
|
+
cover_focal_y: k.cover_focal_y ?? 0.5,
|
|
300
302
|
game_cover: k.game?.url_cover_src,
|
|
301
303
|
game_slug: k.game?.slug,
|
|
302
304
|
time_to_expire: k.time_to_expire,
|
|
@@ -307,6 +309,8 @@ export const fetchCampaignBySlug = async (slug: string) => {
|
|
|
307
309
|
email: k.company?.email,
|
|
308
310
|
twitter: k.company?.twitter,
|
|
309
311
|
logo: k.company?.thumbnail_url,
|
|
312
|
+
pr_contact_name: k.supplier_pr?.name ?? null,
|
|
313
|
+
pr_contact_email: k.supplier_pr?.email ?? null,
|
|
310
314
|
status: k.status,
|
|
311
315
|
quantity: k.quantity,
|
|
312
316
|
available_count: k.available_count ?? 0,
|