@mundogamernetwork/shared-ui 1.3.7 → 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.
@@ -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
@@ -207,7 +207,8 @@
207
207
  "resources_title": "Ressourcen",
208
208
  "press_kit_link": "Presse-Kit",
209
209
  "review_guideline": "Review-Richtlinie",
210
- "embargo_date": "Sperrfrist"
210
+ "embargo_date": "Sperrfrist",
211
+ "pr_contact_label": "PR-Kontakt"
211
212
  },
212
213
  "materials": {
213
214
  "title": "Submit material",
package/locales/en.json CHANGED
@@ -207,7 +207,8 @@
207
207
  "resources_title": "Resources",
208
208
  "press_kit_link": "Press Kit",
209
209
  "review_guideline": "Review guideline",
210
- "embargo_date": "Embargo date"
210
+ "embargo_date": "Embargo date",
211
+ "pr_contact_label": "PR contact"
211
212
  },
212
213
  "materials": {
213
214
  "title": "Submit material",
@@ -207,7 +207,8 @@
207
207
  "resources_title": "Recursos",
208
208
  "press_kit_link": "Press Kit",
209
209
  "review_guideline": "Diretriz de review",
210
- "embargo_date": "Data de embargo"
210
+ "embargo_date": "Data de embargo",
211
+ "pr_contact_label": "Contato de imprensa"
211
212
  },
212
213
  "materials": {
213
214
  "title": "Submit material",
package/locales/ro.json CHANGED
@@ -207,7 +207,8 @@
207
207
  "resources_title": "Resurse",
208
208
  "press_kit_link": "Press Kit",
209
209
  "review_guideline": "Ghid de recenzie",
210
- "embargo_date": "Data embargo"
210
+ "embargo_date": "Data embargo",
211
+ "pr_contact_label": "Contact PR"
211
212
  },
212
213
  "materials": {
213
214
  "title": "Submit material",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.3.7",
3
+ "version": "1.4.0",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -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 :src="campaign.cover" :alt="campaign.name" />
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 || campaign?.streamer_type === 1 }">
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 || campaign?.streamer_type === 1 }">
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; object-position: center top; }
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 :src="slide.cover" :alt="slide.name" />
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,23 @@
62
66
 
63
67
  <template v-if="platformList.length">
64
68
  <span class="filter-divider">|</span>
65
- <select
66
- class="platform-select"
67
- :value="activePlatformFilter ?? ''"
68
- @change="setPlatformFilter(($event.target as HTMLSelectElement).value ? Number(($event.target as HTMLSelectElement).value) : null)"
69
- >
70
- <option value="">{{ $t("keys.campaigns.all") }}</option>
71
- <option v-for="platform in platformList" :key="platform.id" :value="platform.id">
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
- <select
79
- class="platform-select sort-mode-select"
80
- :value="sortMode"
81
- @change="setSortMode(($event.target as HTMLSelectElement).value as 'recent' | 'popular')"
82
- >
83
- <option value="recent">{{ $t("keys.campaigns.sort_recent") }}</option>
84
- <option value="popular">{{ $t("keys.campaigns.sort_popular") }}</option>
85
- </select>
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"
85
+ />
86
86
  <div class="search-box">
87
87
  <input
88
88
  type="text"
@@ -531,6 +531,10 @@ async function loadFeaturedCampaigns() {
531
531
  .map((item: any) => ({
532
532
  id: item.id,
533
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),
534
538
  name: item.name,
535
539
  slug: item.slug,
536
540
  }))
@@ -1088,24 +1092,6 @@ onUnmounted(() => {
1088
1092
  color: var(--key-accent-ink, #13161C);
1089
1093
  }
1090
1094
 
1091
- .platform-select {
1092
- background: transparent;
1093
- border: 1px solid var(--secondary-info-fg);
1094
- color: var(--title-fg);
1095
- font-size: 0.875rem;
1096
- padding: 4px 6px;
1097
- height: 44px;
1098
- max-width: 140px;
1099
- cursor: pointer;
1100
-
1101
- option {
1102
- background: var(--bg-primary, var(--key-accent-ink, #13161C));
1103
- color: var(--title-fg);
1104
- }
1105
-
1106
- &:focus { outline: none; border-color: var(--key-accent, var(--key-accent, var(--primary, #D297FF))); }
1107
- }
1108
-
1109
1095
  .filter-divider {
1110
1096
  display: flex;
1111
1097
  align-items: center;
@@ -1119,11 +1105,6 @@ onUnmounted(() => {
1119
1105
  display: flex;
1120
1106
  align-items: center;
1121
1107
  gap: 8px;
1122
-
1123
- .sort-mode-select {
1124
- flex-shrink: 0;
1125
- height: 44px;
1126
- }
1127
1108
  }
1128
1109
 
1129
1110
  .search-box {
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { fetchPublicMediaKit, trackMediaKitEvent } from '../../services/mediaKitService';
3
- import { fetchStreamerActiveWalls } from '../../services/indieWallService';
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
- onMounted(async () => {
50
- try {
51
- const { data } = await fetchPublicMediaKit(slug.value);
52
- kit.value = data?.data || data;
53
- if (kit.value?.user_id) {
54
- trackMediaKitEvent(kit.value.user_id, { type: 'view', referrer: document.referrer || undefined }).catch(() => {});
55
- }
56
- } catch {
57
- error.value = true;
58
- } finally {
59
- loading.value = false;
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
- watch(userId, async (id) => {
64
- if (!id) return;
65
- try {
66
- const res = await fetchStreamerActiveWalls(id);
67
- walls.value = res.data?.data ?? res.data ?? [];
68
- } catch { walls.value = []; }
69
- }, { immediate: true });
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="mediaKitWall" class="kit-block" data-sec="support">
257
- <h2>{{ $t('kit.media.support') }}</h2>
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;
@@ -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
- useHead({
252
- title: computed(() => wall.value?.name ? `Mural · ${wall.value.name}` : 'Mural'),
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">{{ wall.currency }} {{ Number(wall.raised_amount || 0).toFixed(2) }}</span>
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
- {{ wall.currency }} {{ Number(goal.target_amount).toFixed(0) }}
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', { currency: wall.currency, amount: Math.max(0, goal.target_amount - (goal.current_amount || 0)).toFixed(0) }) }}
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 }} {{ Number(tier.price).toFixed(2) }}</div>
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">{{ wall.currency }} {{ Number(s.amount_paid || 0).toFixed(2) }}</span>
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">{{ wall.currency }} {{ Number(hoverTip.supporter.amount_paid || 0).toFixed(2) }}</span>
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
- <span v-if="wall">{{ wall.currency }}</span> {{ Number(detailPixel.amount_paid).toFixed(2) }}
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,