@mundogamernetwork/shared-ui 1.12.1 → 1.13.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,239 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref } from "vue";
3
+ import { fetchGamificationSummary, type GamificationSummary } from "../../services/gamificationService";
4
+
5
+ const props = withDefaults(
6
+ defineProps<{
7
+ /**
8
+ * Where to send the user to see and claim their tasks. Only Community
9
+ * hosts those pages, so leaving this unset points at Community — which
10
+ * is the right destination from every other front. Community itself
11
+ * passes its own internal route to avoid a full page load.
12
+ */
13
+ missionsUrl?: string;
14
+ /** Locale segment for the default Community destination. */
15
+ locale?: string;
16
+ /** Renders without the title line, for tight spaces. */
17
+ compact?: boolean;
18
+ /**
19
+ * MGC balance from the host's auth store, which is the ecosystem's
20
+ * single source of truth for it. Left unset, the component falls back
21
+ * to the balance the dashboard call already returns.
22
+ */
23
+ mgcBalance?: number | null;
24
+ /** Hide the balance where the host already shows it elsewhere. */
25
+ showBalance?: boolean;
26
+ }>(),
27
+ { missionsUrl: "", locale: "", compact: false, mgcBalance: null, showBalance: true },
28
+ );
29
+
30
+ const emit = defineEmits<{ (e: "navigate"): void }>();
31
+
32
+ /** Missions and the reward store live on Community for the whole ecosystem. */
33
+ const COMMUNITY_URL = "https://mundogamer.community";
34
+
35
+ const summary = ref<GamificationSummary | null>(null);
36
+ const loaded = ref(false);
37
+
38
+ const level = computed(() => summary.value?.level ?? null);
39
+ const claimable = computed(() => summary.value?.missions?.claimable_count ?? 0);
40
+
41
+ /** Store discount this level grants; only some hosts surface it. */
42
+ const discount = computed(() => level.value?.reward_discount_pct ?? 0);
43
+
44
+ const destination = computed(() => {
45
+ if (props.missionsUrl) return props.missionsUrl;
46
+
47
+ const locale =
48
+ props.locale ||
49
+ (typeof document !== "undefined" ? document.documentElement.lang : "") ||
50
+ "en";
51
+
52
+ return `${COMMUNITY_URL}/${locale}/gamification/missions`;
53
+ });
54
+
55
+ const balance = computed(() => {
56
+ const fromHost = props.mgcBalance;
57
+ const value = fromHost !== null && fromHost !== undefined ? fromHost : summary.value?.wallet?.balance;
58
+ return typeof value === "number" ? Math.floor(value).toLocaleString() : null;
59
+ });
60
+
61
+ // progress_percent comes from the API; the local computation is a fallback for
62
+ // older api-main snapshots, which satellite APIs vendor and can lag by weeks.
63
+ const percent = computed(() => {
64
+ const l = level.value;
65
+ if (!l) return 0;
66
+ if (typeof l.progress_percent === "number") return Math.min(100, Math.max(0, l.progress_percent));
67
+ if (!l.next_level_xp) return 100;
68
+ return Math.min(100, Math.round((l.current_level_xp / l.next_level_xp) * 100));
69
+ });
70
+
71
+ onMounted(async () => {
72
+ try {
73
+ const response = await fetchGamificationSummary();
74
+ summary.value = response?.data?.data ?? response?.data ?? null;
75
+ } catch {
76
+ // Never let a gamification hiccup break the header it sits in.
77
+ summary.value = null;
78
+ } finally {
79
+ loaded.value = true;
80
+ }
81
+ });
82
+ </script>
83
+
84
+ <template>
85
+ <a
86
+ v-if="loaded && level"
87
+ :href="destination"
88
+ class="mg-xp-bar"
89
+ :class="{ 'mg-xp-bar--compact': props.compact }"
90
+ @click="emit('navigate')"
91
+ >
92
+ <div class="mg-xp-bar__top">
93
+ <span class="mg-xp-bar__badge">{{ level.current_level }}</span>
94
+
95
+ <div class="mg-xp-bar__meta">
96
+ <span v-if="!props.compact && level.title" class="mg-xp-bar__title">{{ level.title }}</span>
97
+ <span class="mg-xp-bar__xp">{{ level.current_level_xp }} / {{ level.next_level_xp }} XP</span>
98
+ </div>
99
+
100
+ <span v-if="discount > 0" class="mg-xp-bar__discount">-{{ discount }}%</span>
101
+
102
+ <span v-if="props.showBalance && balance" class="mg-xp-bar__balance">{{ balance }} MGC</span>
103
+
104
+ <span v-if="claimable > 0" class="mg-xp-bar__claimable">{{ claimable }}</span>
105
+ </div>
106
+
107
+ <div class="mg-xp-bar__track">
108
+ <div class="mg-xp-bar__fill" :style="{ width: percent + '%' }" />
109
+ </div>
110
+ </a>
111
+ </template>
112
+
113
+ <style lang="scss" scoped>
114
+ .mg-xp-bar {
115
+ display: block;
116
+ padding: 0.5rem 0.75rem;
117
+ // Own token layer, resolved per host.
118
+ //
119
+ // Each front names its theme differently and, more importantly, brands
120
+ // --bt-active differently (blue on Community, orange on Academy, yellow on
121
+ // Agency). Reading the token rather than a fixed colour is what makes the
122
+ // bar adopt each project's identity. Shop uses a separate vocabulary
123
+ // (--surface/--text/--muted/--green), so each token falls through to its
124
+ // equivalent there, and finally to currentColor — a monochrome bar that
125
+ // inherits the host is never wrong, a hardcoded cyan on a green UI is.
126
+ --mg-xp-accent: var(--bt-active, var(--green, currentColor));
127
+ --mg-xp-surface: var(--card-article-bg, var(--surface, transparent));
128
+ --mg-xp-text: var(--card-cover-title, var(--text, currentColor));
129
+ --mg-xp-muted: var(--inactive, var(--muted, currentColor));
130
+
131
+ background: var(--mg-xp-surface);
132
+ text-decoration: none;
133
+ color: inherit;
134
+
135
+ &__top {
136
+ display: flex;
137
+ align-items: center;
138
+ gap: 0.5rem;
139
+ margin-bottom: 0.4rem;
140
+ }
141
+
142
+ &__badge {
143
+ flex: 0 0 22px;
144
+ height: 22px;
145
+ display: flex;
146
+ align-items: center;
147
+ justify-content: center;
148
+ font-size: 11px;
149
+ font-weight: 700;
150
+ color: var(--mg-xp-accent);
151
+ border: 1px solid var(--mg-xp-accent);
152
+ }
153
+
154
+ &__meta {
155
+ flex: 1;
156
+ display: flex;
157
+ flex-direction: column;
158
+ min-width: 0;
159
+ }
160
+
161
+ &__title {
162
+ font-size: 11px;
163
+ font-weight: 600;
164
+ color: var(--mg-xp-text);
165
+ overflow: hidden;
166
+ text-overflow: ellipsis;
167
+ white-space: nowrap;
168
+ }
169
+
170
+ &__xp {
171
+ font-size: 10px;
172
+ color: var(--mg-xp-muted);
173
+ opacity: 0.8;
174
+ }
175
+
176
+ &__discount {
177
+ flex: 0 0 auto;
178
+ font-size: 10px;
179
+ font-weight: 700;
180
+ padding: 1px 4px;
181
+ color: var(--mg-xp-accent);
182
+ border: 1px solid var(--mg-xp-accent);
183
+ }
184
+
185
+ &__balance {
186
+ flex: 0 0 auto;
187
+ font-size: 11px;
188
+ font-weight: 600;
189
+ color: var(--mg-xp-text);
190
+ white-space: nowrap;
191
+ }
192
+
193
+ &__claimable {
194
+ flex: 0 0 auto;
195
+ min-width: 18px;
196
+ height: 18px;
197
+ padding: 0 5px;
198
+ display: flex;
199
+ align-items: center;
200
+ justify-content: center;
201
+ font-size: 10px;
202
+ font-weight: 700;
203
+ // Tinted rather than filled: a solid accent needs a contrasting ink
204
+ // colour, and there is no way to derive one for a host whose accent
205
+ // falls back to currentColor — that combination rendered white on
206
+ // light grey. Accent text over a wash of the same accent contrasts in
207
+ // every theme without needing to know the theme.
208
+ color: var(--mg-xp-accent);
209
+ background: rgba(128, 128, 128, 0.2);
210
+ background: color-mix(in srgb, var(--mg-xp-accent) 18%, transparent);
211
+ border: 1px solid var(--mg-xp-accent);
212
+ }
213
+
214
+ &__track {
215
+ height: 4px;
216
+ // Not --search-bar-bg: in the dark theme it resolves to the same value
217
+ // as --card-article-bg behind it, so the unfilled part of the bar
218
+ // vanished and a 0% bar rendered as nothing at all — worst for the new
219
+ // user the bar exists to orient. A translucent tint of the fill colour
220
+ // reads as "the rest of this bar" and holds up in both themes.
221
+ background: rgba(128, 128, 128, 0.35);
222
+ background: color-mix(in srgb, var(--mg-xp-accent) 22%, transparent);
223
+ }
224
+
225
+ &__fill {
226
+ height: 100%;
227
+ background: var(--mg-xp-accent);
228
+ transition: width 0.3s ease;
229
+ }
230
+
231
+ &--compact {
232
+ padding: 0.35rem 0.5rem;
233
+ }
234
+ }
235
+
236
+ a.mg-xp-bar:hover .mg-xp-bar__fill {
237
+ filter: brightness(1.15);
238
+ }
239
+ </style>
package/locales/de.json CHANGED
@@ -290,7 +290,7 @@
290
290
  "type_print": "Screenshot / Print",
291
291
  "deadline_days_left": "Noch {days} Tage, um dein Material einzureichen",
292
292
  "deadline_today": "Letzter Tag, um dein Material einzureichen",
293
- "deadline_overdue": "Die Einreichungsfrist ist abgelaufen",
293
+ "deadline_overdue": "Die empfohlene Frist ist abgelaufen, du kannst dein Material aber trotzdem einreichen",
294
294
  "required_fields": "Fill in all required fields",
295
295
  "url_error": "Invalid URL",
296
296
  "submit_error": "Error submitting. Try again.",
package/locales/en.json CHANGED
@@ -290,7 +290,7 @@
290
290
  "type_print": "Screenshot / Print",
291
291
  "deadline_days_left": "{days} days left to submit your material",
292
292
  "deadline_today": "Last day to submit your material",
293
- "deadline_overdue": "The submission deadline has passed",
293
+ "deadline_overdue": "The suggested deadline has passed, but you can still submit your material",
294
294
  "required_fields": "Fill in all required fields",
295
295
  "url_error": "Invalid URL",
296
296
  "submit_error": "Error submitting. Try again.",
package/locales/es.json CHANGED
@@ -290,7 +290,7 @@
290
290
  "type_print": "Impreso",
291
291
  "deadline_days_left": "Quedan {days} días para enviar tu material",
292
292
  "deadline_today": "Último día para enviar tu material",
293
- "deadline_overdue": "El plazo de envío ya venció",
293
+ "deadline_overdue": "El plazo sugerido ya venció, pero aún puedes enviar tu material",
294
294
  "required_fields": "¡Por favor, completa todos los campos obligatorios!",
295
295
  "url_error": "Introduce URLs válidas (deben comenzar con http:// o https://)",
296
296
  "submit_error": "Hubo un error al procesar tu material.",
@@ -290,7 +290,7 @@
290
290
  "type_print": "Screenshot / Print",
291
291
  "deadline_days_left": "Faltam {days} dias para enviar seu material",
292
292
  "deadline_today": "Último dia para enviar seu material",
293
- "deadline_overdue": "O prazo de envio já passou",
293
+ "deadline_overdue": "O prazo sugerido já passou, mas você ainda pode enviar seu material",
294
294
  "required_fields": "Fill in all required fields",
295
295
  "url_error": "Invalid URL",
296
296
  "submit_error": "Error submitting. Try again.",
package/locales/ro.json CHANGED
@@ -290,7 +290,7 @@
290
290
  "type_print": "Screenshot / Print",
291
291
  "deadline_days_left": "Mai ai {days} zile pentru a trimite materialul",
292
292
  "deadline_today": "Ultima zi pentru a trimite materialul",
293
- "deadline_overdue": "Termenul de trimitere a expirat",
293
+ "deadline_overdue": "Termenul sugerat a expirat, dar poți trimite materialul în continuare",
294
294
  "required_fields": "Fill in all required fields",
295
295
  "url_error": "Invalid URL",
296
296
  "submit_error": "Error submitting. Try again.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.12.1",
3
+ "version": "1.13.0",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -1511,7 +1511,14 @@ onUnmounted(() => {
1511
1511
  display: flex;
1512
1512
  align-items: center;
1513
1513
  justify-content: center;
1514
- img { width: 100%; height: auto; display: flex; }
1514
+ // height:auto here let the image fall short of the fixed
1515
+ // 174px box whenever its own aspect ratio was taller than
1516
+ // the box's realized width:height ratio, leaving the dark
1517
+ // --body-bg-card background showing as a band under the
1518
+ // cover. Same fix already used by .request-card below:
1519
+ // fill the box and crop via object-fit instead of guessing
1520
+ // a height that only sometimes matches.
1521
+ img { width: 100%; height: 100%; object-fit: cover; display: flex; }
1515
1522
  // The no-cover fallback is a small centered logo, not a
1516
1523
  // banner — stretching it to 100% width (the normal-cover
1517
1524
  // rule above) distorts it badly. Contain it instead.
@@ -345,7 +345,7 @@ const submitForm = async () => {
345
345
  })
346
346
 
347
347
  const hasEmptyFields = forms.value.some(
348
- (form) => form.content_types.length === 0 || !form.material_url || !form.description || !form.submitted_at,
348
+ (form) => form.content_types.length === 0 || !form.material_url || !form.submitted_at,
349
349
  )
350
350
 
351
351
  if (hasEmptyFields) {
@@ -500,12 +500,6 @@ onMounted(async () => {
500
500
  color: var(--card-cover-title);
501
501
  font-size: 13px;
502
502
  i { color: #d297ff; }
503
-
504
- &.overdue {
505
- background: rgba(248, 60, 60, 0.1);
506
- border-left-color: rgb(248, 60, 60);
507
- i { color: rgb(248, 60, 60); }
508
- }
509
503
  }
510
504
 
511
505
  .disabled {
@@ -0,0 +1,26 @@
1
+ import httpService from "./httpService";
2
+
3
+ /**
4
+ * Gamification lives in api-main's shared GamificationProvider, which every
5
+ * satellite API registers — so this hits whichever API the consuming front is
6
+ * already authenticated against, with no extra auth setup.
7
+ */
8
+ export interface XpLevel {
9
+ current_level: number;
10
+ total_xp: number;
11
+ current_level_xp: number;
12
+ next_level_xp: number;
13
+ progress_percent: number;
14
+ title: string | null;
15
+ }
16
+
17
+ export interface GamificationSummary {
18
+ level: XpLevel;
19
+ wallet: { balance: number; lifetime_earned: number };
20
+ missions: { active: any[]; claimable_count: number };
21
+ achievements: { total_unlocked: number; recent: any[] };
22
+ }
23
+
24
+ export const fetchGamificationSummary = async (): Promise<any> => {
25
+ return await httpService.get(`/public/user/gamification/dashboard`);
26
+ };