@mundogamernetwork/shared-ui 1.13.2 → 1.13.4

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.
@@ -32,6 +32,27 @@ const emit = defineEmits<{ (e: "navigate"): void }>();
32
32
  /** Missions and the reward store live on Community for the whole ecosystem. */
33
33
  const COMMUNITY_URL = "https://mundogamer.community";
34
34
 
35
+ /**
36
+ * Dig out the payload regardless of how many envelopes wrap it.
37
+ *
38
+ * The API nests deeper than its own sendResponse() suggests — the gateway adds
39
+ * a second {message,data,status_code} layer on top, so the payload sits at
40
+ * response.data.data.data rather than response.data.data. Reading a fixed
41
+ * depth silently returned the inner envelope, which has no `level`, so the bar
42
+ * loaded and vanished on a perfectly good 200. Walking down until the shape
43
+ * appears survives either nesting.
44
+ */
45
+ function unwrapSummary(payload: any): GamificationSummary | null {
46
+ let node = payload;
47
+
48
+ for (let depth = 0; depth < 4 && node && typeof node === "object"; depth += 1) {
49
+ if (node.level || node.wallet || node.missions) return node as GamificationSummary;
50
+ node = node.data;
51
+ }
52
+
53
+ return null;
54
+ }
55
+
35
56
  const summary = ref<GamificationSummary | null>(null);
36
57
  const loaded = ref(false);
37
58
 
@@ -55,7 +76,12 @@ const destination = computed(() => {
55
76
  const balance = computed(() => {
56
77
  const fromHost = props.mgcBalance;
57
78
  const value = fromHost !== null && fromHost !== undefined ? fromHost : summary.value?.wallet?.balance;
58
- return typeof value === "number" ? Math.floor(value).toLocaleString() : null;
79
+ // The API sends the balance as a decimal string ("1801.50"), not a number.
80
+ const numeric = typeof value === "string" ? Number(value) : value;
81
+
82
+ return typeof numeric === "number" && Number.isFinite(numeric)
83
+ ? Math.floor(numeric).toLocaleString()
84
+ : null;
59
85
  });
60
86
 
61
87
  // progress_percent comes from the API; the local computation is a fallback for
@@ -71,7 +97,7 @@ const percent = computed(() => {
71
97
  onMounted(async () => {
72
98
  try {
73
99
  const response = await fetchGamificationSummary();
74
- summary.value = response?.data?.data ?? response?.data ?? null;
100
+ summary.value = unwrapSummary(response?.data);
75
101
  } catch (error) {
76
102
  // Never let a gamification hiccup break the header it sits in — but say
77
103
  // so. Swallowing this silently made a deployed bar that renders nothing
@@ -86,13 +112,24 @@ onMounted(async () => {
86
112
  </script>
87
113
 
88
114
  <template>
115
+ <!--
116
+ The root renders unconditionally so the server and the client agree on
117
+ the node at hydration time. A v-if here produced a comment on the server
118
+ and an <a> on the client, and Vue bails out of hydrating a subtree whose
119
+ node type disagrees — leaving the server markup in place and never
120
+ mounting the component, so onMounted never ran and the bar was silently
121
+ dead. The skeleton is what both sides render before the fetch resolves.
122
+ -->
89
123
  <a
90
- v-if="loaded && level"
91
124
  :href="destination"
92
125
  class="mg-xp-bar"
93
- :class="{ 'mg-xp-bar--compact': props.compact }"
126
+ :class="{
127
+ 'mg-xp-bar--compact': props.compact,
128
+ 'mg-xp-bar--empty': loaded && !level,
129
+ }"
94
130
  @click="emit('navigate')"
95
131
  >
132
+ <template v-if="level">
96
133
  <div class="mg-xp-bar__top">
97
134
  <span class="mg-xp-bar__badge">{{ level.current_level }}</span>
98
135
 
@@ -111,6 +148,17 @@ onMounted(async () => {
111
148
  <div class="mg-xp-bar__track">
112
149
  <div class="mg-xp-bar__fill" :style="{ width: percent + '%' }" />
113
150
  </div>
151
+ </template>
152
+
153
+ <template v-else>
154
+ <div class="mg-xp-bar__top">
155
+ <span class="mg-xp-bar__badge mg-xp-bar__skeleton" />
156
+ <div class="mg-xp-bar__meta">
157
+ <span class="mg-xp-bar__skeleton mg-xp-bar__skeleton--line" />
158
+ </div>
159
+ </div>
160
+ <div class="mg-xp-bar__track" />
161
+ </template>
114
162
  </a>
115
163
  </template>
116
164
 
@@ -232,6 +280,22 @@ onMounted(async () => {
232
280
  transition: width 0.3s ease;
233
281
  }
234
282
 
283
+ // Hidden only after the fetch resolves with nothing — a client-side change
284
+ // that happens well after hydration, so it cannot cause a mismatch.
285
+ &--empty {
286
+ display: none;
287
+ }
288
+
289
+ &__skeleton {
290
+ background: rgba(128, 128, 128, 0.25);
291
+ border-color: transparent;
292
+
293
+ &--line {
294
+ height: 9px;
295
+ width: 70%;
296
+ }
297
+ }
298
+
235
299
  &--compact {
236
300
  padding: 0.35rem 0.5rem;
237
301
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.13.2",
3
+ "version": "1.13.4",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -18,7 +18,8 @@ export interface XpLevel {
18
18
 
19
19
  export interface GamificationSummary {
20
20
  level: XpLevel;
21
- wallet: { balance: number; lifetime_earned: number };
21
+ /** The API serialises these as decimal strings, not numbers. */
22
+ wallet: { balance: number | string; lifetime_earned: number | string };
22
23
  missions: { active: any[]; claimable_count: number };
23
24
  achievements: { total_unlocked: number; recent: any[] };
24
25
  }