@mundogamernetwork/shared-ui 1.4.0 → 1.4.1

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.
@@ -6,6 +6,15 @@ import SupportStepper from './SupportStepper.vue'
6
6
  const props = defineProps<{ userId: number | string }>()
7
7
  const localePath = useLocalePath()
8
8
  const authStore = useAuthStore()
9
+ const { locale } = useI18n()
10
+
11
+ function formatMoney(amount: number, currency?: string | null) {
12
+ try {
13
+ return new Intl.NumberFormat(locale.value, { style: 'currency', currency: currency || 'USD' }).format(amount)
14
+ } catch {
15
+ return `${currency || ''} ${Number(amount).toFixed(2)}`.trim()
16
+ }
17
+ }
9
18
 
10
19
  const wall = ref<any>(null)
11
20
  const goals = ref<any[]>([])
@@ -64,14 +73,14 @@ async function reloadSupporters() {
64
73
 
65
74
  const hoverTip = ref<{ supporter: any; x: number; y: number } | null>(null)
66
75
 
67
- const supporterToast = ref<{ name: string; amount: string; currency: string } | null>(null)
76
+ const supporterToast = ref<{ name: string; amount: number; currency: string } | null>(null)
68
77
  let toastDismissTimer: ReturnType<typeof setTimeout> | null = null
69
78
 
70
79
  function showSupporterToast(pixel: any) {
71
80
  if (toastDismissTimer) clearTimeout(toastDismissTimer)
72
81
  const name = pixel.is_anonymous ? 'Anonymous' : (pixel.title || pixel.user?.name || 'Anonymous')
73
82
  const currency = wall.value?.currency ?? 'USD'
74
- const amount = Number(pixel.amount_paid || 0).toFixed(2)
83
+ const amount = Number(pixel.amount_paid || 0)
75
84
  supporterToast.value = { name, amount, currency }
76
85
  toastDismissTimer = setTimeout(() => { supporterToast.value = null }, 6000)
77
86
  }
@@ -171,10 +180,10 @@ onUnmounted(() => {
171
180
 
172
181
  <div class="mkw-progress-row">
173
182
  <span class="mkw-raised">
174
- {{ wall.currency }} {{ Number(wall.raised_amount || 0).toFixed(2) }} {{ $t('kit.media.raised') }}
183
+ {{ formatMoney(Number(wall.raised_amount || 0), wall.currency) }} {{ $t('kit.media.raised') }}
175
184
  </span>
176
185
  <span v-if="totalPotential > 0" class="mkw-total">
177
- / {{ wall.currency }} {{ totalPotential.toFixed(2) }}
186
+ / {{ formatMoney(totalPotential, wall.currency) }}
178
187
  </span>
179
188
  <span class="mkw-pct">{{ raisedPercent.toFixed(0) }}%</span>
180
189
  </div>
@@ -206,7 +215,7 @@ onUnmounted(() => {
206
215
  <span class="mkw-toast-icon">★</span>
207
216
  <div class="mkw-toast-text">
208
217
  <strong>{{ supporterToast.name }}</strong>
209
- <span>{{ $t('tv.dashboard.indie_wall.just_supported') }} · {{ supporterToast.currency }} {{ supporterToast.amount }}</span>
218
+ <span>{{ $t('tv.dashboard.indie_wall.just_supported') }} · {{ formatMoney(supporterToast.amount, supporterToast.currency) }}</span>
210
219
  </div>
211
220
  <button class="mkw-toast-close" type="button" @click="supporterToast = null">×</button>
212
221
  </div>
@@ -227,7 +236,7 @@ onUnmounted(() => {
227
236
  <div class="mkw-tip-info">
228
237
  <strong>{{ supporterDisplayName(hoverTip.supporter) }}</strong>
229
238
  <span v-if="hoverTip.supporter.supporter_message">"{{ hoverTip.supporter.supporter_message }}"</span>
230
- <small>{{ wall.currency }} {{ Number(hoverTip.supporter.amount_paid || 0).toFixed(2) }} · {{ hoverTip.supporter.pixel_count }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}</small>
239
+ <small>{{ formatMoney(Number(hoverTip.supporter.amount_paid || 0), wall.currency) }} · {{ hoverTip.supporter.pixel_count }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}</small>
231
240
  </div>
232
241
  </div>
233
242
  </Transition>
@@ -259,7 +268,7 @@ onUnmounted(() => {
259
268
  <div class="mkw-detail-meta">
260
269
  <span>{{ $t('tv.dashboard.indie_wall.mural_position') }}: ({{ detailPixel.x }}, {{ detailPixel.y }})</span>
261
270
  <span>{{ detailPixel.pixel_count }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}</span>
262
- <span>{{ wall.currency }} {{ Number(detailPixel.amount_paid).toFixed(2) }}</span>
271
+ <span>{{ formatMoney(Number(detailPixel.amount_paid), wall.currency) }}</span>
263
272
  </div>
264
273
  <p v-if="detailPixel.link">
265
274
  <a :href="detailPixel.link" target="_blank" rel="noopener" class="mkw-detail-link">{{ detailPixel.link }}</a>
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ const { t } = useI18n()
3
+
2
4
  const props = withDefaults(defineProps<{
3
5
  form: {
4
6
  title: string
@@ -36,6 +38,12 @@ const messageError = ref('')
36
38
  const imageMissingError = ref('')
37
39
  const emailError = ref('')
38
40
 
41
+ // Errors are (re)computed on submit in next(); clear them as soon as the user
42
+ // edits the field so a corrected value doesn't keep showing a stale error.
43
+ watch(() => local.guest_email, () => { emailError.value = '' })
44
+ watch(() => local.title, () => { titleError.value = '' })
45
+ watch(() => local.message, () => { messageError.value = '' })
46
+
39
47
  const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
40
48
  const URL_REGEX = /^https?:\/\/.+\..+/
41
49
 
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ const { locale } = useI18n()
3
+
2
4
  defineProps<{
3
5
  goals: any[]
4
6
  selectedGoalId: number | null
@@ -14,6 +16,14 @@ function pick(goalId: number | null) {
14
16
  emit('select', goalId)
15
17
  emit('next')
16
18
  }
19
+
20
+ function formatMoney(amount: number, currency?: string | null) {
21
+ try {
22
+ return new Intl.NumberFormat(locale.value, { style: 'currency', currency: currency || 'USD', maximumFractionDigits: 0 }).format(amount)
23
+ } catch {
24
+ return `${currency || ''} ${Number(amount).toFixed(0)}`.trim()
25
+ }
26
+ }
17
27
  </script>
18
28
 
19
29
  <template>
@@ -34,7 +44,7 @@ function pick(goalId: number | null) {
34
44
  {{ $t('tv.dashboard.indie_wall.reached') }}
35
45
  </span>
36
46
  <span v-else class="sw-goal-amount">
37
- {{ currency }} {{ Number(goal.target_amount || 0).toFixed(0) }}
47
+ {{ formatMoney(Number(goal.target_amount || 0), currency) }}
38
48
  </span>
39
49
  </header>
40
50
 
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ const { locale } = useI18n()
3
+
2
4
  const props = defineProps<{
3
5
  tiers: any[]
4
6
  selectedTierId: number | null
@@ -7,6 +9,14 @@ const props = defineProps<{
7
9
  currency: string
8
10
  }>()
9
11
 
12
+ function formatMoney(amount: number, currency?: string | null) {
13
+ try {
14
+ return new Intl.NumberFormat(locale.value, { style: 'currency', currency: currency || 'USD' }).format(amount)
15
+ } catch {
16
+ return `${currency || ''} ${Number(amount).toFixed(2)}`.trim()
17
+ }
18
+ }
19
+
10
20
  const emit = defineEmits<{
11
21
  (e: 'select-tier', tierId: number | null, pixelCount: number): void
12
22
  (e: 'set-custom', qty: number): void
@@ -51,7 +61,7 @@ function tierUnavailable(tier: any) {
51
61
  >
52
62
  <img v-if="tier.image_url" :src="tier.image_url" alt="" class="sw-tier-img" />
53
63
  <span class="sw-card-title">{{ tier.name }}</span>
54
- <span class="sw-card-price">{{ currency }} {{ Number(tier.price).toFixed(2) }}</span>
64
+ <span class="sw-card-price">{{ formatMoney(Number(tier.price), tier.currency || currency) }}</span>
55
65
  <span class="sw-card-pixels">{{ tier.pixel_count }} {{ $t('tv.dashboard.indie_wall.spots_unit') }}</span>
56
66
  <p v-if="tier.description" class="sw-card-desc">{{ tier.description }}</p>
57
67
  <span v-if="tier.max_quantity" class="sw-card-stock">
@@ -65,10 +75,10 @@ function tierUnavailable(tier: any) {
65
75
  <div class="sw-custom-row">
66
76
  <input v-model.number="localQty" type="number" min="1" class="sw-custom-input" />
67
77
  <span class="sw-custom-x">×</span>
68
- <span class="sw-custom-price">{{ currency }} {{ Number(pixelPrice).toFixed(2) }}</span>
78
+ <span class="sw-custom-price">{{ formatMoney(Number(pixelPrice), currency) }}</span>
69
79
  </div>
70
80
  <div class="sw-custom-total">
71
- = {{ currency }} {{ (Math.max(1, localQty) * pixelPrice).toFixed(2) }}
81
+ = {{ formatMoney(Math.max(1, localQty) * pixelPrice, currency) }}
72
82
  </div>
73
83
  <button type="button" class="sw-custom-cta" @click="pickCustom">
74
84
  {{ $t('tv.dashboard.indie_wall.step_package_custom_cta') }}
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ const { locale } = useI18n()
3
+
2
4
  const props = defineProps<{
3
5
  wall: any
4
6
  selectedGoal: any | null
@@ -30,6 +32,14 @@ const total = computed(() => {
30
32
  })
31
33
  const agreed = ref(false)
32
34
  const agreedRights = ref(false)
35
+
36
+ function formatMoney(amount: number, currency?: string | null) {
37
+ try {
38
+ return new Intl.NumberFormat(locale.value, { style: 'currency', currency: currency || 'USD' }).format(amount)
39
+ } catch {
40
+ return `${currency || ''} ${Number(amount).toFixed(2)}`.trim()
41
+ }
42
+ }
33
43
  </script>
34
44
 
35
45
  <template>
@@ -96,7 +106,7 @@ const agreedRights = ref(false)
96
106
 
97
107
  <div class="sw-total">
98
108
  <span>{{ $t('tv.dashboard.indie_wall.step_pay_total') }}</span>
99
- <strong>{{ wall?.currency }} {{ total.toFixed(2) }}</strong>
109
+ <strong>{{ formatMoney(total, selectedTier?.currency || wall?.currency) }}</strong>
100
110
  </div>
101
111
 
102
112
  <label class="sw-agree">
@@ -204,7 +204,11 @@ async function confirm() {
204
204
  </script>
205
205
 
206
206
  <template>
207
- <div :class="['ss-overlay', { 'ss-overlay--drawer': drawer }]" @click.self="close">
207
+ <!-- No @click.self="close" on the backdrop: this is a checkout flow, and on
208
+ mobile a stray tap near the edges (e.g. reaching past a keyboard-shifted
209
+ viewport for the Continue button) must not silently discard a guest's
210
+ in-progress entry. Closing is only via the explicit × button. -->
211
+ <div :class="['ss-overlay', { 'ss-overlay--drawer': drawer }]">
208
212
  <div :class="['ss-modal', { 'ss-modal--drawer': drawer }]">
209
213
  <button class="ss-close" type="button" aria-label="Close" @click="close">×</button>
210
214
 
@@ -318,6 +322,10 @@ async function confirm() {
318
322
  display: flex; align-items: center; gap: 6px;
319
323
  font-size: 0.78rem; color: var(--secondary-info-fg, #aaa);
320
324
  background: #13161C; padding: 6px 10px; border: 1px solid #1e2028;
325
+ // .ss-close is absolutely positioned at top:10/right:10 over the modal's
326
+ // padded corner; this bar starts at the padding edge (top:24) so without
327
+ // clearance its top ~14px sits directly under the close button.
328
+ margin-top: 20px;
321
329
  strong { color: var(--chip-text, #D297FF); font-weight: 600; }
322
330
  }
323
331
 
@@ -40,6 +40,15 @@ const login = () => {
40
40
  window.location.href = `${accountsBaseUrl}/login?redirect_to=${redirectTo}`;
41
41
  };
42
42
 
43
+ // Computed in script rather than read inline in the template: a bare `window`
44
+ // reference in the template crashes the render (it's not part of the
45
+ // script-setup bindings the compiled render function has access to, and it's
46
+ // undefined during SSR), which took down this whole dropdown for every
47
+ // signed-out visitor.
48
+ const registerHref = computed(
49
+ () => `${accountsBaseUrl}/register?redirect_to=${encodeURIComponent(import.meta.client ? window.location.href : "")}`,
50
+ );
51
+
43
52
  const handleLogout = async () => {
44
53
  await performLogout(`${accountsBaseUrl}/login`);
45
54
  };
@@ -60,7 +69,7 @@ const handleLogout = async () => {
60
69
  <div>
61
70
  <p class="d-flex">
62
71
  {{ $t("header.login.register_message") }}
63
- <a :href="`${accountsBaseUrl}/register?redirect_to=${encodeURIComponent(window.location.href)}`" class="ms-1">
72
+ <a :href="registerHref" class="ms-1">
64
73
  {{ $t("header.login.btn_register") }}
65
74
  </a>
66
75
  </p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -190,23 +190,22 @@
190
190
  <span class="lock-cta">{{ $t("keys.campaigns.exclusive_upgrade_cta") }}</span>
191
191
  </div>
192
192
 
193
- <div class="title">
194
- <div class="days" v-if="card.time > 0 && card.user_can_access">
195
- <span>{{ card.time }}</span> {{ $t("keys.campaigns.days") }}
196
- </div>
197
- <div class="days" v-else></div>
198
- <span>
199
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="17" viewBox="0 0 16 17" fill="none">
200
- <path d="M13.3333 4.1112L12.8666 3.64453L11.9333 4.57786L10.9333 3.64453L8.33329 4.1112L2.66663 9.77786L7.19996 14.3112L12.8666 8.64453L13.3333 6.04453L12.4 5.1112L13.3333 4.1112ZM12.6 6.24453L12.2666 8.3112L7.19996 13.3779L3.59996 9.77786L8.66663 4.7112L10.7333 4.3112L11.4666 5.04453L11 5.5112L10.5333 5.04453L9.99996 5.57786L11.4 6.97786L11.8666 6.5112L11.4 6.04453L11.8666 5.57786L12.6 6.24453Z" fill="var(--key-accent, var(--key-accent, var(--primary, #D297FF)))" />
201
- </svg>
202
- {{ card.type }}
203
- </span>
204
- </div>
205
193
  </div>
206
194
  </nuxt-link>
207
195
  </div>
208
196
 
209
197
  <div class="text">
198
+ <div class="card-meta-row">
199
+ <div class="days" v-if="card.time > 0 && card.user_can_access">
200
+ <span>{{ card.time }}</span> {{ $t("keys.campaigns.days") }}
201
+ </div>
202
+ <span>
203
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="17" viewBox="0 0 16 17" fill="none">
204
+ <path d="M13.3333 4.1112L12.8666 3.64453L11.9333 4.57786L10.9333 3.64453L8.33329 4.1112L2.66663 9.77786L7.19996 14.3112L12.8666 8.64453L13.3333 6.04453L12.4 5.1112L13.3333 4.1112ZM12.6 6.24453L12.2666 8.3112L7.19996 13.3779L3.59996 9.77786L8.66663 4.7112L10.7333 4.3112L11.4666 5.04453L11 5.5112L10.5333 5.04453L9.99996 5.57786L11.4 6.97786L11.8666 6.5112L11.4 6.04453L11.8666 5.57786L12.6 6.24453Z" fill="var(--key-accent, var(--key-accent, var(--primary, #D297FF)))" />
205
+ </svg>
206
+ {{ card.type }}
207
+ </span>
208
+ </div>
210
209
  <div v-if="card.platforms && card.platforms.length" class="card-platforms">
211
210
  <span v-for="p in card.platforms" :key="p" class="card-platform-tag">
212
211
  {{ platformAbbr(p) }}
@@ -1078,13 +1077,24 @@ onUnmounted(() => {
1078
1077
  .header {
1079
1078
  width: 100%;
1080
1079
  display: flex;
1080
+ align-items: center;
1081
1081
  padding: 24px 0;
1082
1082
  justify-content: space-between;
1083
+ flex-wrap: wrap;
1084
+ row-gap: 12px;
1083
1085
 
1084
1086
  .header-left {
1085
1087
  display: flex;
1088
+ align-items: center;
1086
1089
  gap: 12px;
1087
1090
  button { height: 44px; }
1091
+
1092
+ // FormAppSelect ships at 32px (its own default look on other
1093
+ // pages) — bump to match the 44px filter pills/search box so
1094
+ // the toolbar reads as one consistent row instead of controls
1095
+ // bobbing at two different heights.
1096
+ :deep(.select-wrapper) { display: flex; align-items: center; height: 44px; }
1097
+ :deep(.select-wrapper select) { height: 44px; }
1088
1098
  }
1089
1099
 
1090
1100
  .header-left .btn.tertiary.active {
@@ -1104,7 +1114,11 @@ onUnmounted(() => {
1104
1114
  position: relative;
1105
1115
  display: flex;
1106
1116
  align-items: center;
1107
- gap: 8px;
1117
+ gap: 12px;
1118
+
1119
+ // Same 32px→44px fix as the platform select, so the sort
1120
+ // control lines up with the search box beside it.
1121
+ :deep(.sort-input) { height: 44px; padding-top: 0; }
1108
1122
  }
1109
1123
 
1110
1124
  .search-box {
@@ -1182,39 +1196,31 @@ onUnmounted(() => {
1182
1196
  }
1183
1197
  }
1184
1198
 
1185
- .title {
1186
- position: absolute;
1187
- left: 0;
1188
- right: 0;
1189
- bottom: 0;
1190
- display: flex;
1191
- padding: 8px 16px;
1192
- align-items: center;
1193
- background: rgba(13, 13, 13, 0.82);
1194
- backdrop-filter: blur(2px);
1195
- width: 100%;
1196
- justify-content: space-between;
1197
- z-index: 2;
1198
-
1199
- .days {
1200
- font-size: 12px;
1201
- color: var(--title-fg);
1199
+ .text {
1200
+ .card-meta-row {
1202
1201
  display: flex;
1203
1202
  align-items: center;
1204
- gap: 4px;
1205
- span { color: var(--key-accent, var(--key-accent, var(--primary, #D297FF))); font-size: 16px; font-weight: 600; }
1206
- }
1203
+ justify-content: space-between;
1204
+ gap: 8px;
1207
1205
 
1208
- span {
1209
- font-size: 12px;
1210
- color: var(--secondary-info-fg);
1211
- display: flex;
1212
- align-items: center;
1213
- gap: 4px;
1206
+ .days {
1207
+ font-size: 12px;
1208
+ color: var(--title-fg);
1209
+ display: flex;
1210
+ align-items: center;
1211
+ gap: 4px;
1212
+ span { color: var(--key-accent, var(--key-accent, var(--primary, #D297FF))); font-size: 16px; font-weight: 600; }
1213
+ }
1214
+
1215
+ span {
1216
+ font-size: 12px;
1217
+ color: var(--secondary-info-fg);
1218
+ display: flex;
1219
+ align-items: center;
1220
+ gap: 4px;
1221
+ }
1214
1222
  }
1215
- }
1216
1223
 
1217
- .text {
1218
1224
  color: var(--title-fg);
1219
1225
  font-size: 16px;
1220
1226
  font-weight: 600;
@@ -209,6 +209,13 @@ async function loadWall() {
209
209
  stepperStartStep.value = 1
210
210
  showStepper.value = true
211
211
  }
212
+
213
+ // Deep-link from a supporter's own share link (?pixel=123): open their
214
+ // pixel detail automatically so the shared link lands on their spot.
215
+ if (route.query.pixel) {
216
+ const shared = supporters.value.find((s: any) => String(s.id) === String(route.query.pixel))
217
+ if (shared) detailPixel.value = shared
218
+ }
212
219
  } catch (err) {
213
220
  console.error(err)
214
221
  } finally {
@@ -232,8 +239,19 @@ function onCanvasTapEmpty(coord: { x: number; y: number }) {
232
239
  })
233
240
  }
234
241
 
235
- function onCanvasTapSupporter(s: any) {
242
+ function openPixelDetail(s: any) {
236
243
  detailPixel.value = s
244
+ router.replace({ query: { ...route.query, pixel: s.id } })
245
+ }
246
+
247
+ function closePixelDetail() {
248
+ detailPixel.value = null
249
+ const { pixel: _omit, ...rest } = route.query
250
+ router.replace({ query: rest })
251
+ }
252
+
253
+ function onCanvasTapSupporter(s: any) {
254
+ openPixelDetail(s)
237
255
  }
238
256
 
239
257
  const hoverTip = ref<{ supporter: any; x: number; y: number } | null>(null)
@@ -267,10 +285,63 @@ function supporterDisplayName(s: any): string {
267
285
  }
268
286
 
269
287
  const requestUrl = useRequestURL()
270
- 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) || '')
288
+
289
+ // When a supporter's own pixel is open (either they just clicked it, or a
290
+ // visitor followed that supporter's share link), the page's own title/
291
+ // description swap to their name+message so *their* share card, not just
292
+ // the wall's, reads correctly. Image stays the wall's own cover — pixel
293
+ // images are inline base64, not real URLs, so crawlers can't fetch them.
294
+ const seoTitle = computed(() => {
295
+ if (detailPixel.value && wall.value) {
296
+ return t('tv.dashboard.indie_wall.share_pixel_title', { name: supporterDisplayName(detailPixel.value), wall: wall.value.name })
297
+ }
298
+ return wall.value?.name ? `${wall.value.name} · Mundo Gamer Wall` : 'Mundo Gamer Wall'
299
+ })
300
+ const seoDescription = computed(() => {
301
+ if (detailPixel.value) {
302
+ return detailPixel.value.supporter_message
303
+ || (wall.value?.name ? t('tv.dashboard.indie_wall.share_pixel_default_desc', { wall: wall.value.name }) : '')
304
+ }
305
+ return wall.value?.description || wall.value?.long_description?.replace(/\n+/g, ' ').slice(0, 160) || ''
306
+ })
272
307
  const seoImage = computed(() => wall.value?.cover_image_url || wall.value?.logo_url || '')
273
308
 
309
+ // ── Per-supporter share link ────────────────────────────────────────────────
310
+ const sharePixelUrl = computed(() => {
311
+ if (!detailPixel.value) return requestUrl.href
312
+ const base = `${requestUrl.origin}${route.path}`
313
+ return `${base}?pixel=${detailPixel.value.id}`
314
+ })
315
+ const shareText = computed(() => {
316
+ if (!detailPixel.value || !wall.value) return ''
317
+ return t('tv.dashboard.indie_wall.share_pixel_text', { name: supporterDisplayName(detailPixel.value), wall: wall.value.name })
318
+ })
319
+ const shareTwitterUrl = computed(() => `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText.value)}&url=${encodeURIComponent(sharePixelUrl.value)}`)
320
+ const shareWhatsappUrl = computed(() => `https://wa.me/?text=${encodeURIComponent(`${shareText.value} ${sharePixelUrl.value}`)}`)
321
+ const shareFacebookUrl = computed(() => `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(sharePixelUrl.value)}`)
322
+
323
+ const linkCopied = ref(false)
324
+ async function copyShareLink() {
325
+ try {
326
+ await navigator.clipboard.writeText(sharePixelUrl.value)
327
+ linkCopied.value = true
328
+ setTimeout(() => { linkCopied.value = false }, 2000)
329
+ } catch { /* clipboard unavailable */ }
330
+ }
331
+
332
+ // "Create your own wall" footer CTA — same host map as the backend's
333
+ // IndieWallController::resolverResponse, so it always points at whichever
334
+ // platform (TV/Agency) actually owns this wall rather than a hardcoded host.
335
+ const CREATE_WALL_HOST_MAP: Record<string, string> = {
336
+ MGTV: 'https://mundogamer.tv',
337
+ MGAG: 'https://mundogamer.agency',
338
+ }
339
+ const createWallUrl = computed(() => {
340
+ const shortName = wall.value?.origin_system?.short_name
341
+ const host = (shortName && CREATE_WALL_HOST_MAP[shortName]) || CREATE_WALL_HOST_MAP.MGTV
342
+ return `${host}/${locale.value}/dashboard/wall`
343
+ })
344
+
274
345
  // Raw useHead meta-array form — matches the exact mechanism the site-wide
275
346
  // defaults in nuxt.config.ts use, and is proven to reliably override by
276
347
  // name/property key in this app. useSeoMeta was tried first but its og:*/
@@ -523,7 +594,7 @@ onUnmounted(() => {
523
594
  <section v-if="supporters.length" class="section">
524
595
  <h2 class="section-title">{{ $t('tv.dashboard.indie_wall.recent_supporters') }}</h2>
525
596
  <div class="supporters-grid">
526
- <div v-for="s in supporters.slice(0, 24)" :key="s.id" class="supp-card" @click="detailPixel = s">
597
+ <div v-for="s in supporters.slice(0, 24)" :key="s.id" class="supp-card" @click="openPixelDetail(s)">
527
598
  <div class="supp-pixel">
528
599
  <img v-if="s.image_url" :src="s.image_url" class="supp-pixel-img" alt="" />
529
600
  <div v-else class="supp-pixel-swatch" :style="{ background: s.background_color || '#272930' }" />
@@ -549,6 +620,16 @@ onUnmounted(() => {
549
620
  <IndieWallLeaderboard :supporters="supporters" />
550
621
  </section>
551
622
 
623
+ <!-- ── Footer credit ─────────────────────────────────────────────────── -->
624
+ <footer class="mural-footer">
625
+ <p class="mural-footer-text">
626
+ {{ $t('tv.dashboard.indie_wall.footer_made_with') }} <strong>Mundo Gamer Wall</strong>
627
+ </p>
628
+ <a :href="createWallUrl" target="_blank" rel="noopener" class="mural-footer-cta">
629
+ {{ $t('tv.dashboard.indie_wall.footer_create_cta') }}
630
+ </a>
631
+ </footer>
632
+
552
633
  </div>
553
634
 
554
635
  <!-- ── Overlays ────────────────────────────────────────────────────────── -->
@@ -628,9 +709,9 @@ onUnmounted(() => {
628
709
  </Transition>
629
710
 
630
711
  <Transition name="fade">
631
- <div v-if="detailPixel" class="detail-overlay" @click.self="detailPixel = null">
712
+ <div v-if="detailPixel" class="detail-overlay" @click.self="closePixelDetail">
632
713
  <div class="detail-box">
633
- <button class="overlay-close" type="button" @click="detailPixel = null">×</button>
714
+ <button class="overlay-close" type="button" @click="closePixelDetail">×</button>
634
715
  <div
635
716
  class="detail-hero"
636
717
  :style="{ background: detailPixel.background_color || '#272930' }"
@@ -654,6 +735,24 @@ onUnmounted(() => {
654
735
  <a v-if="detailPixel.link" :href="detailPixel.link" target="_blank" rel="noopener" class="detail-link">
655
736
  {{ detailPixel.link }}
656
737
  </a>
738
+
739
+ <!-- ── Share this pixel ─────────────────────────────────────── -->
740
+ <div class="detail-share">
741
+ <p class="detail-share-title">{{ $t('tv.dashboard.indie_wall.share_pixel_cta') }}</p>
742
+ <div class="detail-share-row">
743
+ <a :href="shareTwitterUrl" target="_blank" rel="noopener" class="detail-share-btn" :aria-label="$t('tv.dashboard.indie_wall.share_via_twitter')">𝕏</a>
744
+ <a :href="shareWhatsappUrl" target="_blank" rel="noopener" class="detail-share-btn" :aria-label="$t('tv.dashboard.indie_wall.share_via_whatsapp')">
745
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12.04 2C6.58 2 2.13 6.45 2.13 11.91c0 1.75.46 3.45 1.32 4.95L2.05 22l5.25-1.38a9.9 9.9 0 0 0 4.74 1.2h.01c5.46 0 9.9-4.45 9.9-9.91 0-2.65-1.03-5.14-2.9-7.01A9.82 9.82 0 0 0 12.04 2Z" opacity="0"/><path d="M9.2 6.8c-.2-.45-.42-.46-.62-.47h-.53c-.18 0-.48.07-.73.34-.25.27-.96.93-.96 2.28s.98 2.65 1.12 2.83c.14.18 1.9 3.03 4.7 4.14 2.32.92 2.8.74 3.3.7.5-.05 1.62-.66 1.85-1.3.23-.63.23-1.17.16-1.29-.07-.11-.25-.18-.53-.32-.27-.14-1.62-.8-1.87-.89-.25-.09-.43-.14-.62.14-.18.27-.7.89-.86 1.07-.16.18-.32.2-.59.07-.27-.14-1.15-.42-2.19-1.35-.81-.72-1.36-1.62-1.52-1.89-.16-.27-.02-.42.12-.55.13-.13.27-.33.4-.5.13-.16.18-.27.27-.45.09-.18.05-.34-.02-.48-.07-.14-.6-1.5-.85-2.05Z"/></svg>
746
+ </a>
747
+ <a :href="shareFacebookUrl" target="_blank" rel="noopener" class="detail-share-btn" :aria-label="$t('tv.dashboard.indie_wall.share_via_facebook')">f</a>
748
+ <button type="button" class="detail-share-btn" @click="copyShareLink" :aria-label="$t('tv.dashboard.indie_wall.share_copy_link')">
749
+ {{ linkCopied ? '✓' : '🔗' }}
750
+ </button>
751
+ </div>
752
+ <button type="button" class="detail-share-cta" @click="closePixelDetail(); openStepper({ startStep: 1 })">
753
+ {{ $t('tv.dashboard.indie_wall.share_join_cta') }}
754
+ </button>
755
+ </div>
657
756
  </div>
658
757
  </div>
659
758
  </div>
@@ -985,6 +1084,32 @@ onUnmounted(() => {
985
1084
  &:hover { text-decoration: underline; }
986
1085
  }
987
1086
 
1087
+ .detail-share {
1088
+ margin-top: 16px; padding-top: 16px;
1089
+ border-top: 1px solid #272930;
1090
+ }
1091
+ .detail-share-title {
1092
+ margin: 0 0 8px; font-size: 0.78rem; color: var(--secondary-info-fg, #aaa);
1093
+ }
1094
+ .detail-share-row {
1095
+ display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
1096
+ }
1097
+ .detail-share-btn {
1098
+ width: 34px; height: 34px; flex-shrink: 0;
1099
+ display: flex; align-items: center; justify-content: center;
1100
+ background: rgba(255,255,255,0.06); border: 1px solid #272930;
1101
+ color: var(--title-fg, #fff); font-size: 0.9rem; font-weight: 700;
1102
+ cursor: pointer; text-decoration: none; transition: border-color 0.15s;
1103
+ &:hover { border-color: var(--chip-text, #D297FF); }
1104
+ }
1105
+ .detail-share-cta {
1106
+ width: 100%;
1107
+ background: var(--chip-text, #D297FF); color: #13161C;
1108
+ border: none; padding: 10px 14px; font-weight: 700; font-size: 0.85rem;
1109
+ cursor: pointer; transition: opacity 0.15s;
1110
+ &:hover { opacity: 0.88; }
1111
+ }
1112
+
988
1113
  .fade-enter-active, .fade-leave-active { transition: opacity 0.18s; }
989
1114
  .fade-enter-from, .fade-leave-to { opacity: 0; }
990
1115
 
@@ -1102,6 +1227,28 @@ onUnmounted(() => {
1102
1227
  background: rgba(255,255,255,0.06); padding: 2px 6px;
1103
1228
  }
1104
1229
 
1230
+ // ── Footer credit ─────────────────────────────────────────────────────────
1231
+ .mural-footer {
1232
+ display: flex; flex-direction: column; align-items: center; gap: 12px;
1233
+ padding: 48px 16px; margin-top: 8px;
1234
+ border-top: 1px solid #1e2028;
1235
+ text-align: center;
1236
+ }
1237
+ .mural-footer-text {
1238
+ font-size: 0.8rem; color: var(--secondary-info-fg, #888); margin: 0;
1239
+ strong { color: var(--title-fg, #fff); }
1240
+ }
1241
+ .mural-footer-cta {
1242
+ display: inline-block;
1243
+ padding: 8px 20px;
1244
+ border: 1px solid var(--chip-text, #D297FF);
1245
+ color: var(--chip-text, #D297FF);
1246
+ font-size: 0.8rem; font-weight: 600;
1247
+ text-decoration: none;
1248
+ transition: background 0.15s, color 0.15s;
1249
+ &:hover { background: var(--chip-text, #D297FF); color: #13161C; }
1250
+ }
1251
+
1105
1252
  @media (max-width: 640px) {
1106
1253
  .hero-title { font-size: 1.5rem; }
1107
1254
  .hero-stats { gap: 12px; }
@@ -86,6 +86,7 @@ const layout = computed(() => kit.value?.layout || 'classic');
86
86
  const allowDownload = computed(() => kit.value?.allow_asset_download !== false);
87
87
  const hasDownloadableAssets = computed(() => assets.value.length > 0);
88
88
  const canDownload = computed(() => allowDownload.value && hasDownloadableAssets.value);
89
+ const zipDownloadUrl = computed(() => `${apiBase}/public/press-kits/${slug}/download${previewToken ? `?preview_token=${previewToken}` : ''}`);
89
90
 
90
91
  const keyPoolsVisible = ref(false);
91
92
  function onPoolsReady(count: number) {
@@ -189,6 +190,11 @@ const seoImage = computed(() => kit.value?.hero_image_url || kit.value?.cover_im
189
190
  useHead(() => ({
190
191
  title: seoTitle.value,
191
192
  link: [{ rel: 'canonical', href: requestUrl.href }],
193
+ // Studio's own GA4 tag, opt-in per kit — free feature, no plan gate.
194
+ script: kit.value?.analytics_ga_id ? [
195
+ { src: `https://www.googletagmanager.com/gtag/js?id=${kit.value.analytics_ga_id}`, async: true },
196
+ { innerHTML: `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${kit.value.analytics_ga_id}');` },
197
+ ] : [],
192
198
  meta: [
193
199
  { name: 'description', content: seoDescription.value },
194
200
  { property: 'og:title', content: seoTitle.value },
@@ -252,7 +258,7 @@ useHead(() => ({
252
258
  <p v-if="kit.developer" class="kit-kicker">{{ kit.developer }}</p>
253
259
  <div class="kit-actions">
254
260
  <template v-if="allowDownload">
255
- <a v-if="canDownload" class="kit-btn kit-btn-primary" @click="track('download')">{{ $t('kit.press.download_assets') }}</a>
261
+ <a v-if="canDownload" class="kit-btn kit-btn-primary" :href="zipDownloadUrl" @click="track('download')">{{ $t('kit.press.download_assets') }}</a>
256
262
  <span v-else class="kit-btn kit-btn-primary kit-btn-disabled" :title="$t('kit.press.no_assets')">{{ $t('kit.press.no_assets') }}</span>
257
263
  </template>
258
264
  <a v-if="kit.press_contact_email" class="kit-btn kit-btn-ghost" :href="`mailto:${kit.press_contact_email}`" @click="track('contact')">{{ $t('kit.press.request_key') }}</a>
@@ -279,7 +285,7 @@ useHead(() => ({
279
285
  <span class="kit-stickybar-name">{{ kit.name }}</span>
280
286
  <div class="kit-stickybar-actions">
281
287
  <template v-if="allowDownload">
282
- <a v-if="canDownload" class="kit-btn kit-btn-primary kit-btn-sm" @click="track('download')">{{ $t('kit.press.download_assets') }}</a>
288
+ <a v-if="canDownload" class="kit-btn kit-btn-primary kit-btn-sm" :href="zipDownloadUrl" @click="track('download')">{{ $t('kit.press.download_assets') }}</a>
283
289
  <span v-else class="kit-btn kit-btn-primary kit-btn-sm kit-btn-disabled" :title="$t('kit.press.no_assets')">{{ $t('kit.press.no_assets') }}</span>
284
290
  </template>
285
291
  <a v-if="kit.press_contact_email" class="kit-btn kit-btn-ghost kit-btn-sm" :href="`mailto:${kit.press_contact_email}`" @click="track('contact')">{{ $t('kit.press.request_key') }}</a>
@@ -455,7 +461,7 @@ useHead(() => ({
455
461
  </section>
456
462
 
457
463
  <footer class="kit-footer">
458
- <p class="kit-footer__text">{{ $t('kit.press.made_with') }} <strong>Mundo Gamer Agency</strong></p>
464
+ <p v-if="!kit.is_premium" class="kit-footer__text">{{ $t('kit.press.made_with') }} <strong>Mundo Gamer Agency</strong></p>
459
465
  <a :href="kit.create_kit_url || `${agencyBase}/presskit`" target="_blank" rel="noopener" class="kit-footer__cta">{{ $t('kit.press.create_yours') }}</a>
460
466
  </footer>
461
467
  </template>