@mundogamernetwork/shared-ui 1.8.3 → 1.8.5

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.
@@ -4,6 +4,7 @@ import MuralCanvas from './MuralCanvas.vue'
4
4
  import SupportStepper from './SupportStepper.vue'
5
5
 
6
6
  const props = defineProps<{ userId: number | string }>()
7
+ const emit = defineEmits<{ (e: 'wall-status', hasWall: boolean): void }>()
7
8
  const localePath = useLocalePath()
8
9
  const authStore = useAuthStore()
9
10
  const { locale } = useI18n()
@@ -102,8 +103,12 @@ async function load() {
102
103
  const res = await fetchStreamerActiveWalls(props.userId)
103
104
  const list = res.data?.data ?? res.data ?? []
104
105
  const first = Array.isArray(list) ? list.find((w: any) => w.show_on_media_kit) ?? list[0] : null
105
- if (!first) return
106
+ if (!first) {
107
+ emit('wall-status', false)
108
+ return
109
+ }
106
110
  wall.value = first
111
+ emit('wall-status', true)
107
112
  const [goalsRes, supRes] = await Promise.all([
108
113
  fetchPublicWallGoals(first.slug || first.id).catch(() => ({ data: { data: [] } })),
109
114
  getWallSupporters(first.slug || first.id, { per_page: 500 }),
@@ -112,6 +117,7 @@ async function load() {
112
117
  supporters.value = (supRes.data?.data ?? []).filter((p: any) => p.status)
113
118
  } catch {
114
119
  // no wall — renders nothing
120
+ emit('wall-status', false)
115
121
  } finally {
116
122
  loading.value = false
117
123
  }
@@ -7,6 +7,8 @@ const props = defineProps<{
7
7
  slug: string;
8
8
  name: string;
9
9
  cover: string | null;
10
+ cover_focal_x?: number | null;
11
+ cover_focal_y?: number | null;
10
12
  game_cover?: string | null;
11
13
  available_count: number;
12
14
  is_exclusive?: boolean;
@@ -34,6 +36,17 @@ const cardCover = computed(() => {
34
36
  return c.cover || c.cover_src || c.game?.url_cover_src || c.game_cover || "/imgs/no-cover-img.jpg";
35
37
  });
36
38
 
39
+ // Only apply the campaign's own focal point to its own cover — a game-cover/no-cover
40
+ // fallback wasn't framed against that point and would crop wrong if we reused it.
41
+ const cardCoverPosition = computed(() => {
42
+ const c = props.card as any;
43
+ const hasOwnCover = Boolean(c.cover || c.cover_src);
44
+ if (!hasOwnCover) return "50% 50%";
45
+ const fx = c.cover_focal_x ?? 0.5;
46
+ const fy = c.cover_focal_y ?? 0.5;
47
+ return `${fx * 100}% ${fy * 100}%`;
48
+ });
49
+
37
50
  const userCanAccess = computed(() => props.publicMode || props.card.user_can_access !== false);
38
51
  const isExpired = computed(() => props.card.isExpired ?? false);
39
52
  const isUpcoming = computed(() => props.card.isUpcoming ?? false);
@@ -99,6 +112,7 @@ const detailRoute = computed(() =>
99
112
  <img
100
113
  :src="cardCover"
101
114
  :alt="card.name"
115
+ :style="{ objectPosition: cardCoverPosition }"
102
116
  :class="{ 'gray-out': isExpired || isUpcoming || !userCanAccess }"
103
117
  @error="(e: Event) => { (e.target as HTMLImageElement).src = '/imgs/no-cover-img.jpg' }"
104
118
  />
@@ -1,10 +1,13 @@
1
1
  <script lang="ts" setup>
2
2
  import { ref, onMounted, inject } from "vue"
3
3
  import { fetchCampaigns } from "../../services/campaignService"
4
+ import KeyCampaignDashboardCard from "./KeyCampaignDashboardCard.vue"
4
5
 
5
6
  const props = defineProps<{
6
7
  excludeId?: number | null
7
8
  mgPlatform?: string
9
+ publicMode?: boolean
10
+ baseRoute?: string
8
11
  }>()
9
12
 
10
13
  const mgPlatform = props.mgPlatform || (inject<string>("mgPlatform", "MGTV"))
@@ -46,31 +49,13 @@ onMounted(async () => {
46
49
  </div>
47
50
 
48
51
  <div v-else class="kc-carousel__track">
49
- <NuxtLink
50
- v-for="item in items"
51
- :key="item.id"
52
- :to="`/key-campaigns/${item.slug}`"
53
- class="kc-carousel__card"
54
- >
55
- <div class="kc-carousel__thumb">
56
- <img
57
- v-if="item.cover_src || item.cover"
58
- :src="item.cover_src || item.cover"
59
- :alt="item.name"
60
- />
61
- <div v-else class="kc-carousel__thumb-placeholder">
62
- <i class="mg-icon mg-icon-game-key" />
63
- </div>
64
- <span v-if="!item.is_open" class="kc-carousel__closed">{{ $t("keys.campaigns.closed") }}</span>
65
- </div>
66
- <div class="kc-carousel__info">
67
- <div class="kc-carousel__name">{{ item.name }}</div>
68
- <div class="kc-carousel__avail">
69
- {{ item.available_count ?? 0 }}/{{ item.total_count ?? item.quantity ?? "?" }}
70
- {{ $t("keys.campaigns.available") }}
71
- </div>
72
- </div>
73
- </NuxtLink>
52
+ <div v-for="item in items" :key="item.id" class="kc-carousel__card">
53
+ <KeyCampaignDashboardCard
54
+ :card="{ ...item, isExpired: item.is_open === false }"
55
+ :public-mode="publicMode"
56
+ :base-route="baseRoute ?? 'key-campaigns'"
57
+ />
58
+ </div>
74
59
  </div>
75
60
  </div>
76
61
  </template>
@@ -105,81 +90,14 @@ onMounted(async () => {
105
90
  }
106
91
 
107
92
  &__skeleton {
108
- flex: 0 0 180px;
109
- height: 140px;
93
+ flex: 0 0 200px;
94
+ height: 280px;
110
95
  background: var(--bg-app-badge);
111
96
  animation: pulse 1.4s ease-in-out infinite;
112
97
  }
113
98
 
114
99
  &__card {
115
- flex: 0 0 180px;
116
- display: flex;
117
- flex-direction: column;
118
- text-decoration: none;
119
- color: inherit;
120
- border: 1px solid var(--button-secondary-default-bg);
121
- transition: border-color 0.15s;
122
-
123
- &:hover { border-color: var(--key-accent, var(--primary, #D297FF)); }
124
- }
125
-
126
- &__thumb {
127
- position: relative;
128
- width: 100%;
129
- height: 101px;
130
- overflow: hidden;
131
- background: var(--bg-app-badge);
132
-
133
- img {
134
- width: 100%;
135
- height: 100%;
136
- object-fit: cover;
137
- }
138
- }
139
-
140
- &__thumb-placeholder {
141
- width: 100%;
142
- height: 100%;
143
- display: flex;
144
- align-items: center;
145
- justify-content: center;
146
- color: var(--secondary-info-fg);
147
- font-size: 24px;
148
- }
149
-
150
- &__closed {
151
- position: absolute;
152
- inset: 0;
153
- display: flex;
154
- align-items: center;
155
- justify-content: center;
156
- background: var(--overlay-dark, rgba(0, 0, 0, 0.55));
157
- font-size: 11px;
158
- font-weight: 700;
159
- color: var(--card-article-title, #fff);
160
- letter-spacing: 0.5px;
161
- text-transform: uppercase;
162
- }
163
-
164
- &__info {
165
- padding: 8px 10px;
166
- display: flex;
167
- flex-direction: column;
168
- gap: 4px;
169
- }
170
-
171
- &__name {
172
- font-size: 12px;
173
- font-weight: 600;
174
- color: var(--card-article-title);
175
- overflow: hidden;
176
- text-overflow: ellipsis;
177
- white-space: nowrap;
178
- }
179
-
180
- &__avail {
181
- font-size: 11px;
182
- color: var(--secondary-info-fg);
100
+ flex: 0 0 200px;
183
101
  }
184
102
  }
185
103
 
@@ -107,7 +107,7 @@ const handleLogout = async () => {
107
107
  <div v-if="features.wallet && user?.coins !== null" class="row info-row">
108
108
  <span class="coins">
109
109
  <MGIcon icon="coin" />
110
- {{ user?.coins ?? 0 }}
110
+ {{ Math.floor(user?.coins ?? 0).toLocaleString() }}
111
111
  </span>
112
112
  </div>
113
113
 
@@ -44,7 +44,7 @@ const isSubscriber = computed(() => !!(user.value as any)?.subscription?.is_subs
44
44
  <div class="mg-wallet-plan">
45
45
  <span v-if="showWallet" class="mg-wallet-plan__coins">
46
46
  <MGIcon icon="coin" />
47
- {{ walletBalance.toLocaleString() }}
47
+ {{ Math.floor(walletBalance).toLocaleString() }}
48
48
  <span class="mg-wallet-plan__currency">MGC</span>
49
49
  </span>
50
50
  <span
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.8.3",
3
+ "version": "1.8.5",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -78,7 +78,13 @@ const restriction = computed(() => {
78
78
 
79
79
  <template v-else>
80
80
  <div class="kc__hero">
81
- <img v-if="key.cover_src" :src="key.cover_src" :alt="key.name" class="kc__cover">
81
+ <img
82
+ v-if="key.cover_src"
83
+ :src="key.cover_src"
84
+ :alt="key.name"
85
+ class="kc__cover"
86
+ :style="{ objectPosition: `${(key.cover_focal_x ?? 0.5) * 100}% ${(key.cover_focal_y ?? 0.5) * 100}%` }"
87
+ >
82
88
  <div class="kc__hero-body">
83
89
  <h1 class="kc__title">{{ key.name }}</h1>
84
90
  <div v-if="restriction" class="kc__badge">🔒 {{ restriction }}</div>
@@ -96,7 +102,7 @@ const restriction = computed(() => {
96
102
  />
97
103
  </div>
98
104
 
99
- <KeyCampaignsCarousel :exclude-id="key.id ?? null" />
105
+ <KeyCampaignsCarousel :exclude-id="key.id ?? null" :public-mode="true" base-route="key-campaign" />
100
106
  </template>
101
107
  </div>
102
108
  </template>
@@ -840,9 +840,9 @@ onMounted(async () => {
840
840
  <img
841
841
  v-for="(u, index) in redeemedUsers?.users?.slice(0, 4)"
842
842
  :key="index"
843
- :src="u.avatar_url || '/imgs/avatar-default.jpg'"
843
+ :src="u.avatar_url || '/imgs/default-avatar.png'"
844
844
  :alt="u.nickname"
845
- @error="(e: Event) => { const t = e.target as HTMLImageElement; if (t && t.src !== '/imgs/avatar-default.jpg') t.src = '/imgs/avatar-default.jpg' }"
845
+ @error="(e: Event) => { const t = e.target as HTMLImageElement; if (t) { t.onerror = null; t.src = '/imgs/default-avatar.png' } }"
846
846
  />
847
847
  </div>
848
848
  <div class="text">
@@ -872,9 +872,9 @@ onMounted(async () => {
872
872
  </div>
873
873
  <div v-for="(u, index) in redeemedUsers?.users" :key="index" class="users-modal-row">
874
874
  <img
875
- :src="u.avatar_url || '/imgs/avatar-default.jpg'"
875
+ :src="u.avatar_url || '/imgs/default-avatar.png'"
876
876
  :alt="u.nickname"
877
- @error="(e: Event) => { const t = e.target as HTMLImageElement; if (t && t.src !== '/imgs/avatar-default.jpg') t.src = '/imgs/avatar-default.jpg' }"
877
+ @error="(e: Event) => { const t = e.target as HTMLImageElement; if (t) { t.onerror = null; t.src = '/imgs/default-avatar.png' } }"
878
878
  />
879
879
  <span>{{ u.nickname || `${u.first_name || ''} ${u.last_name || ''}`.trim() }}</span>
880
880
  </div>
@@ -926,6 +926,8 @@ onMounted(async () => {
926
926
  <KeyCampaignsCarousel
927
927
  :exclude-id="campaign?.id ?? null"
928
928
  :mg-platform="mgPlatform"
929
+ :public-mode="true"
930
+ base-route="key-campaigns"
929
931
  class="container"
930
932
  />
931
933
  </div>
@@ -1482,6 +1484,16 @@ onMounted(async () => {
1482
1484
  .time { gap: 6px; .card-time { height: auto; padding: 8px; font-size: 24px; } }
1483
1485
  .actions .data { flex-direction: column; }
1484
1486
  .status { flex-direction: column; align-items: start; gap: 8px; }
1487
+ .requirements .tags {
1488
+ flex-wrap: wrap;
1489
+ .tag {
1490
+ height: auto;
1491
+ min-height: 32px;
1492
+ padding: 8px;
1493
+ flex: 1 1 calc(50% - 6px);
1494
+ text-align: center;
1495
+ }
1496
+ }
1485
1497
  }
1486
1498
  }
1487
1499
  }
@@ -196,6 +196,12 @@
196
196
  </div>
197
197
 
198
198
  <div class="text">
199
+ <div class="card-name">{{ card.gameName || card.text }}</div>
200
+ <div v-if="card.studioName || card.regions.length" class="card-studio-row">
201
+ <span v-if="card.studioName" class="card-studio-name">{{ card.studioName }}</span>
202
+ <span v-if="card.studioName && card.regions.length" class="card-studio-sep">·</span>
203
+ <span v-if="card.regions.length">{{ card.regions.slice(0, 2).join(", ") }}</span>
204
+ </div>
199
205
  <div class="card-meta-row">
200
206
  <div class="days" v-if="card.time > 0 && card.user_can_access">
201
207
  <span>{{ card.time }}</span> {{ $t("keys.campaigns.days") }}
@@ -208,11 +214,17 @@
208
214
  </span>
209
215
  </div>
210
216
  <div v-if="card.platforms && card.platforms.length" class="card-platforms">
211
- <span v-for="p in card.platforms" :key="p" class="card-platform-tag">
217
+ <span v-for="p in card.platforms.slice(0, 4)" :key="p" class="card-platform-tag">
212
218
  {{ platformAbbr(p) }}
213
219
  </span>
220
+ <span v-if="card.platforms.length > 4" class="card-platform-tag card-platform-more">
221
+ +{{ card.platforms.length - 4 }}
222
+ </span>
214
223
  </div>
215
- {{ card.text }}
224
+ <div v-if="card.available_count > 0 && card.user_can_access && !card.isExpired && !card.isUpcoming" class="card-availability">
225
+ {{ $t("keys.campaigns.keys_left", { n: card.available_count }) }}
226
+ </div>
227
+ <div v-if="card.gameName && card.gameName !== card.text" class="card-campaign-name">{{ card.text }}</div>
216
228
 
217
229
  <div class="buttons">
218
230
  <template v-if="!card.user_can_access">
@@ -609,6 +621,8 @@ async function loadCampaigns(page = 1) {
609
621
  max_keys_for_user: item.max_keys_for_user || 1,
610
622
  platforms: (item.platforms || []).map((p: any) => p.name),
611
623
  gameName: item.game?.name || null,
624
+ studioName: item.company?.name || null,
625
+ regions: (item.regions || []).map((r: any) => r.name).filter(Boolean),
612
626
  userRequestSlug: null,
613
627
  }
614
628
  })
@@ -1090,12 +1104,24 @@ onUnmounted(() => {
1090
1104
  gap: 12px;
1091
1105
  button { height: 44px; }
1092
1106
 
1093
- // FormAppSelect ships at 32px (its own default look on other
1094
- // pages) bump to match the 44px filter pills/search box so
1095
- // the toolbar reads as one consistent row instead of controls
1096
- // bobbing at two different heights.
1097
- :deep(.select-wrapper) { display: flex; align-items: center; height: 44px; }
1098
- :deep(.select-wrapper select) { height: 44px; }
1107
+ // FormAppSelect ships at 32px with its own light --input-bg
1108
+ // (its default look on other pages, e.g. account settings
1109
+ // forms on a card background) bump to match the 44px
1110
+ // filter pills/search box height, and repaint it to match
1111
+ // the dark toolbar instead of the light default, so it
1112
+ // reads as one consistent row instead of a native-looking
1113
+ // light select stranded next to dark pills.
1114
+ :deep(.select-wrapper) {
1115
+ display: flex;
1116
+ align-items: center;
1117
+ height: 44px;
1118
+ border-bottom-color: var(--secondary-info-fg);
1119
+ }
1120
+ :deep(.select-wrapper select) {
1121
+ height: 44px;
1122
+ background: transparent;
1123
+ color: var(--secondary-info-fg);
1124
+ }
1099
1125
  }
1100
1126
 
1101
1127
  .header-left .btn.tertiary.active {
@@ -1192,12 +1218,54 @@ onUnmounted(() => {
1192
1218
  max-height: 174px;
1193
1219
  min-height: 174px;
1194
1220
  overflow: hidden;
1221
+ background: var(--body-bg-card, #101012);
1222
+ display: flex;
1223
+ align-items: center;
1224
+ justify-content: center;
1195
1225
  img { width: 100%; height: auto; display: flex; }
1226
+ // The no-cover fallback is a small centered logo, not a
1227
+ // banner — stretching it to 100% width (the normal-cover
1228
+ // rule above) distorts it badly. Contain it instead.
1229
+ &:has(img[src$="no-cover-img.jpg"]) img {
1230
+ width: 40%;
1231
+ height: auto;
1232
+ max-height: 60%;
1233
+ object-fit: contain;
1234
+ }
1196
1235
  }
1197
1236
  }
1198
1237
  }
1199
1238
 
1200
1239
  .text {
1240
+ min-height: 210px;
1241
+
1242
+ .card-name {
1243
+ font-size: 16px;
1244
+ font-weight: 700;
1245
+ color: var(--title-fg);
1246
+ }
1247
+
1248
+ .card-studio-row {
1249
+ font-size: 12px;
1250
+ color: var(--secondary-info-fg);
1251
+ font-weight: 400;
1252
+ display: flex;
1253
+ align-items: center;
1254
+ gap: 6px;
1255
+ }
1256
+
1257
+ .card-availability {
1258
+ font-size: 12px;
1259
+ color: var(--key-accent, var(--primary, #D297FF));
1260
+ font-weight: 600;
1261
+ }
1262
+
1263
+ .card-campaign-name {
1264
+ font-size: 13px;
1265
+ font-weight: 400;
1266
+ color: var(--secondary-info-fg);
1267
+ }
1268
+
1201
1269
  .card-meta-row {
1202
1270
  display: flex;
1203
1271
  align-items: center;
@@ -1242,6 +1310,10 @@ onUnmounted(() => {
1242
1310
  color: var(--secondary-info-fg);
1243
1311
  text-transform: uppercase;
1244
1312
  }
1313
+ .card-platform-more {
1314
+ border-style: dashed;
1315
+ opacity: 0.8;
1316
+ }
1245
1317
  }
1246
1318
 
1247
1319
  .buttons {