@mundogamernetwork/shared-ui 1.3.7 → 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.
@@ -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/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.1",
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"
@@ -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) }}
@@ -531,6 +530,10 @@ async function loadFeaturedCampaigns() {
531
530
  .map((item: any) => ({
532
531
  id: item.id,
533
532
  cover: item.game?.url_banner_src || item.cover_src,
533
+ // Only applies to the cover_src fallback — the game's own
534
+ // url_banner_src is already banner-shaped and needs no repositioning.
535
+ coverFocalX: item.game?.url_banner_src ? 0.5 : (item.cover_focal_x ?? 0.5),
536
+ coverFocalY: item.game?.url_banner_src ? 0.5 : (item.cover_focal_y ?? 0.5),
534
537
  name: item.name,
535
538
  slug: item.slug,
536
539
  }))
@@ -1074,13 +1077,24 @@ onUnmounted(() => {
1074
1077
  .header {
1075
1078
  width: 100%;
1076
1079
  display: flex;
1080
+ align-items: center;
1077
1081
  padding: 24px 0;
1078
1082
  justify-content: space-between;
1083
+ flex-wrap: wrap;
1084
+ row-gap: 12px;
1079
1085
 
1080
1086
  .header-left {
1081
1087
  display: flex;
1088
+ align-items: center;
1082
1089
  gap: 12px;
1083
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; }
1084
1098
  }
1085
1099
 
1086
1100
  .header-left .btn.tertiary.active {
@@ -1088,24 +1102,6 @@ onUnmounted(() => {
1088
1102
  color: var(--key-accent-ink, #13161C);
1089
1103
  }
1090
1104
 
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
1105
  .filter-divider {
1110
1106
  display: flex;
1111
1107
  align-items: center;
@@ -1118,12 +1114,11 @@ onUnmounted(() => {
1118
1114
  position: relative;
1119
1115
  display: flex;
1120
1116
  align-items: center;
1121
- gap: 8px;
1117
+ gap: 12px;
1122
1118
 
1123
- .sort-mode-select {
1124
- flex-shrink: 0;
1125
- height: 44px;
1126
- }
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; }
1127
1122
  }
1128
1123
 
1129
1124
  .search-box {
@@ -1201,39 +1196,31 @@ onUnmounted(() => {
1201
1196
  }
1202
1197
  }
1203
1198
 
1204
- .title {
1205
- position: absolute;
1206
- left: 0;
1207
- right: 0;
1208
- bottom: 0;
1209
- display: flex;
1210
- padding: 8px 16px;
1211
- align-items: center;
1212
- background: rgba(13, 13, 13, 0.82);
1213
- backdrop-filter: blur(2px);
1214
- width: 100%;
1215
- justify-content: space-between;
1216
- z-index: 2;
1217
-
1218
- .days {
1219
- font-size: 12px;
1220
- color: var(--title-fg);
1199
+ .text {
1200
+ .card-meta-row {
1221
1201
  display: flex;
1222
1202
  align-items: center;
1223
- gap: 4px;
1224
- span { color: var(--key-accent, var(--key-accent, var(--primary, #D297FF))); font-size: 16px; font-weight: 600; }
1225
- }
1203
+ justify-content: space-between;
1204
+ gap: 8px;
1226
1205
 
1227
- span {
1228
- font-size: 12px;
1229
- color: var(--secondary-info-fg);
1230
- display: flex;
1231
- align-items: center;
1232
- 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
+ }
1233
1222
  }
1234
- }
1235
1223
 
1236
- .text {
1237
1224
  color: var(--title-fg);
1238
1225
  font-size: 16px;
1239
1226
  font-weight: 600;
@@ -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;