@mundogamernetwork/shared-ui 1.1.44 → 1.1.45

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,550 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ fetchAvailablePools,
4
+ fetchMyRequests,
5
+ fetchPlatforms,
6
+ requestKey as apiRequestKey,
7
+ revealKey as apiRevealKey,
8
+ type KeyPool,
9
+ type KeyRequest,
10
+ type RevealResult,
11
+ } from '../../services/keyService'
12
+ import { mgApiUrl } from '../../services/httpService'
13
+
14
+ const { t } = useI18n()
15
+
16
+ const props = defineProps<{
17
+ requesterContext: 'media_partner' | 'campaign_creator' | 'user'
18
+ accessToken?: string
19
+ showcaseId?: number
20
+ contextType?: string
21
+ contextId?: number
22
+ gameId?: number
23
+ /** If set, the browser will redirect here on 401 instead of building the URL automatically */
24
+ loginUrl?: string
25
+ }>()
26
+
27
+ const emit = defineEmits<{
28
+ 'pools-ready': [count: number]
29
+ }>()
30
+
31
+ // ─── State ────────────────────────────────────────────────────────────────────
32
+
33
+ const pools = ref<KeyPool[]>([])
34
+ const myRequests = ref<KeyRequest[]>([])
35
+ const platforms = ref<{ id: number; name: string }[]>([])
36
+ const loadingPools = ref(true)
37
+ const loadingRequests = ref(true)
38
+ const requesting = ref<number | null>(null)
39
+ const activeTab = ref<'available' | 'mine'>('available')
40
+ const isAuthenticated = ref(true) // assume true; set false on 401
41
+
42
+ const requestForms = ref<Record<number, { platform_ids: number[]; description: string }>>({})
43
+ const revealingId = ref<number | null>(null)
44
+ const revealResult = ref<RevealResult | null>(null)
45
+
46
+ // ─── Params ───────────────────────────────────────────────────────────────────
47
+
48
+ function queryParams(): Record<string, any> {
49
+ const p: Record<string, any> = {}
50
+ if (props.accessToken) p.access_token = props.accessToken
51
+ if (props.showcaseId) p.showcase_id = props.showcaseId
52
+ if (props.contextType) p.context = props.contextType
53
+ if (props.contextId) p.context_id = props.contextId
54
+ if (props.gameId) p.game_id = props.gameId
55
+ if (!props.contextType && !props.gameId) p['filter[mg_platform]'] = 'MGAG'
56
+ return p
57
+ }
58
+
59
+ // ─── Load ─────────────────────────────────────────────────────────────────────
60
+
61
+ async function loadPools() {
62
+ loadingPools.value = true
63
+ try {
64
+ const res = await fetchAvailablePools(queryParams())
65
+ pools.value = (res as any)?.data ?? []
66
+ pools.value.forEach(p => {
67
+ if (!requestForms.value[p.id]) {
68
+ requestForms.value[p.id] = { platform_ids: [], description: '' }
69
+ }
70
+ })
71
+ emit('pools-ready', pools.value.length)
72
+ } catch (err: any) {
73
+ if (err?.response?.status === 401) isAuthenticated.value = false
74
+ emit('pools-ready', 0)
75
+ } finally {
76
+ loadingPools.value = false
77
+ }
78
+ }
79
+
80
+ async function loadMyRequests() {
81
+ loadingRequests.value = true
82
+ try {
83
+ const res = await fetchMyRequests()
84
+ myRequests.value = (res as any)?.data ?? []
85
+ } catch (err: any) {
86
+ if (err?.response?.status === 401) isAuthenticated.value = false
87
+ } finally {
88
+ loadingRequests.value = false
89
+ }
90
+ }
91
+
92
+ async function loadPlatformsData() {
93
+ try {
94
+ const res = await fetchPlatforms()
95
+ platforms.value = (res as any)?.data ?? []
96
+ } catch {}
97
+ }
98
+
99
+ onMounted(async () => {
100
+ await Promise.all([loadPools(), loadMyRequests(), loadPlatformsData()])
101
+ })
102
+
103
+ // ─── Auth ─────────────────────────────────────────────────────────────────────
104
+
105
+ function redirectToLogin() {
106
+ const url = props.loginUrl ?? mgApiUrl('login') + '?redirect_to=' + encodeURIComponent(window.location.href)
107
+ window.location.href = url
108
+ }
109
+
110
+ // ─── Request ──────────────────────────────────────────────────────────────────
111
+
112
+ async function doRequestKey(pool: KeyPool) {
113
+ requesting.value = pool.id
114
+ try {
115
+ const form = requestForms.value[pool.id] ?? { platform_ids: [], description: '' }
116
+ const body: Record<string, any> = {
117
+ platform_ids: form.platform_ids,
118
+ description: form.description || undefined,
119
+ }
120
+ if (props.accessToken) body.access_token = props.accessToken
121
+ await apiRequestKey(pool.id, body)
122
+ pool.already_requested = true
123
+ await loadMyRequests()
124
+ } catch (err: any) {
125
+ if (err?.response?.status === 401) {
126
+ isAuthenticated.value = false
127
+ }
128
+ } finally {
129
+ requesting.value = null
130
+ }
131
+ }
132
+
133
+ function handleRequestKey(pool: KeyPool) {
134
+ if (!isAuthenticated.value) {
135
+ redirectToLogin()
136
+ return
137
+ }
138
+ doRequestKey(pool)
139
+ }
140
+
141
+ // ─── Reveal ───────────────────────────────────────────────────────────────────
142
+
143
+ async function revealCode(reqId: number) {
144
+ revealingId.value = reqId
145
+ revealResult.value = null
146
+ try {
147
+ const body: Record<string, any> = {}
148
+ if (props.accessToken) body.access_token = props.accessToken
149
+ const res = await apiRevealKey(reqId, body)
150
+ revealResult.value = (res as any)?.data ?? null
151
+ } catch (err: any) {
152
+ if (err?.response?.status === 401) isAuthenticated.value = false
153
+ } finally {
154
+ revealingId.value = null
155
+ }
156
+ }
157
+
158
+ function closeReveal() {
159
+ revealResult.value = null
160
+ }
161
+
162
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
163
+
164
+ function statusColor(slug: string): string {
165
+ return { pending: 'orange', approved: 'green', refused: 'red', cancelled: 'gray' }[slug] ?? 'gray'
166
+ }
167
+ </script>
168
+
169
+ <template>
170
+ <div class="kb">
171
+ <!-- Auth wall -->
172
+ <div v-if="!isAuthenticated" class="kb__auth-wall">
173
+ <p>{{ $t('keys.browser.login_required') }}</p>
174
+ <button class="kb__login-btn" @click="redirectToLogin">
175
+ {{ $t('keys.browser.login_btn') }}
176
+ </button>
177
+ </div>
178
+
179
+ <template v-else>
180
+ <!-- Tabs -->
181
+ <div class="kb__tabs">
182
+ <button
183
+ class="kb__tab"
184
+ :class="{ 'kb__tab--active': activeTab === 'available' }"
185
+ @click="activeTab = 'available'"
186
+ >
187
+ {{ $t('keys.browser.tab_available') }}
188
+ <span class="kb__badge">{{ pools.filter(p => !p.already_requested && p.available_count > 0).length }}</span>
189
+ </button>
190
+ <button
191
+ class="kb__tab"
192
+ :class="{ 'kb__tab--active': activeTab === 'mine' }"
193
+ @click="activeTab = 'mine'"
194
+ >
195
+ {{ $t('keys.browser.tab_mine') }}
196
+ <span class="kb__badge">{{ myRequests.length }}</span>
197
+ </button>
198
+ </div>
199
+
200
+ <!-- Reveal result modal -->
201
+ <KeyRevealModal
202
+ v-if="revealResult"
203
+ :code="revealResult.code"
204
+ :platform="revealResult.platform"
205
+ :revealed-at="revealResult.revealed_at"
206
+ @close="closeReveal"
207
+ />
208
+
209
+ <!-- ── Available pools ── -->
210
+ <div v-if="activeTab === 'available'">
211
+ <div v-if="loadingPools" class="kb__loading">{{ $t('common.loading') }}</div>
212
+
213
+ <div v-else-if="pools.length === 0" class="kb__empty">
214
+ {{ $t('keys.browser.no_pools') }}
215
+ </div>
216
+
217
+ <div v-else class="kb__pool-grid">
218
+ <div
219
+ v-for="pool in pools"
220
+ :key="pool.id"
221
+ class="kb__pool-card"
222
+ :class="{ 'kb__pool-card--exhausted': pool.available_count === 0 }"
223
+ >
224
+ <div class="kb__pool-name">{{ pool.name }}</div>
225
+
226
+ <div class="kb__pool-availability">
227
+ <span v-if="pool.available_count > 0" class="kb__avail-ok">
228
+ {{ $t('keys.browser.keys_available', { n: pool.available_count }) }}
229
+ </span>
230
+ <span v-else class="kb__avail-empty">{{ $t('keys.browser.no_keys') }}</span>
231
+ </div>
232
+
233
+ <div v-if="!pool.already_requested && pool.available_count > 0 && platforms.length" class="kb__platform-sel">
234
+ <label class="kb__label">{{ $t('keys.browser.select_platform') }}</label>
235
+ <div class="kb__platform-chips">
236
+ <label
237
+ v-for="p in platforms"
238
+ :key="p.id"
239
+ class="kb__platform-chip"
240
+ :class="{ 'kb__platform-chip--active': requestForms[pool.id]?.platform_ids.includes(p.id) }"
241
+ >
242
+ <input
243
+ type="checkbox"
244
+ :value="p.id"
245
+ v-model="requestForms[pool.id].platform_ids"
246
+ style="display:none"
247
+ />
248
+ {{ p.name }}
249
+ </label>
250
+ </div>
251
+ </div>
252
+
253
+ <div v-if="!pool.already_requested && pool.available_count > 0" class="kb__description-field">
254
+ <label class="kb__label">{{ $t('keys.browser.description_label') }}</label>
255
+ <textarea
256
+ v-model="requestForms[pool.id].description"
257
+ class="kb__textarea"
258
+ :placeholder="$t('keys.browser.description_placeholder')"
259
+ rows="2"
260
+ />
261
+ </div>
262
+
263
+ <div class="kb__pool-footer">
264
+ <span v-if="pool.already_requested" class="kb__requested-label">
265
+ ✓ {{ $t('keys.browser.already_requested') }}
266
+ </span>
267
+ <button
268
+ v-else
269
+ class="kb__request-btn"
270
+ :disabled="pool.available_count === 0 || requesting === pool.id"
271
+ @click="handleRequestKey(pool)"
272
+ >
273
+ {{ requesting === pool.id ? $t('common.sending') : $t('keys.browser.request_btn') }}
274
+ </button>
275
+ </div>
276
+ </div>
277
+ </div>
278
+ </div>
279
+
280
+ <!-- ── My requests ── -->
281
+ <div v-else-if="activeTab === 'mine'">
282
+ <div v-if="loadingRequests" class="kb__loading">{{ $t('common.loading') }}</div>
283
+
284
+ <div v-else-if="myRequests.length === 0" class="kb__empty">
285
+ {{ $t('keys.browser.no_requests') }}
286
+ </div>
287
+
288
+ <div v-else class="kb__request-list">
289
+ <div v-for="req in myRequests" :key="req.id" class="kb__request-row">
290
+ <div class="kb__request-info">
291
+ <span class="kb__request-pool">{{ req.pool_name }}</span>
292
+ <span v-if="req.platforms?.length" class="kb__request-platform">
293
+ {{ req.platforms.join(', ') }}
294
+ </span>
295
+ </div>
296
+
297
+ <span
298
+ class="kb__status-chip"
299
+ :style="{ '--chip-color': `var(--${statusColor(req.status_slug)})` }"
300
+ >
301
+ {{ req.status_name || req.status_slug }}
302
+ </span>
303
+
304
+ <div class="kb__request-action">
305
+ <span v-if="req.revealed_at" class="kb__revealed-label">
306
+ {{ $t('keys.browser.revealed') }}
307
+ </span>
308
+ <button
309
+ v-else-if="req.status_slug === 'approved'"
310
+ class="kb__reveal-btn"
311
+ :disabled="revealingId === req.id"
312
+ @click="revealCode(req.id)"
313
+ >
314
+ {{ revealingId === req.id ? $t('common.loading') : $t('keys.browser.reveal_btn') }}
315
+ </button>
316
+ <span v-else-if="req.status_slug === 'pending'" class="kb__pending-label">
317
+ {{ $t('keys.browser.pending_approval') }}
318
+ </span>
319
+ </div>
320
+ </div>
321
+ </div>
322
+ </div>
323
+ </template>
324
+ </div>
325
+ </template>
326
+
327
+ <style scoped lang="scss">
328
+ .kb {
329
+ &__auth-wall {
330
+ padding: 40px;
331
+ text-align: center;
332
+ color: var(--text-secondary, #888);
333
+ display: flex;
334
+ flex-direction: column;
335
+ align-items: center;
336
+ gap: 16px;
337
+ }
338
+
339
+ &__login-btn {
340
+ padding: 10px 24px;
341
+ background: var(--accent, #00d4ff);
342
+ color: #000;
343
+ border: none;
344
+ font-weight: 600;
345
+ font-size: 0.9rem;
346
+ cursor: pointer;
347
+ transition: opacity 0.15s;
348
+ &:hover { opacity: 0.85; }
349
+ }
350
+
351
+ &__tabs {
352
+ display: flex;
353
+ gap: 4px;
354
+ border-bottom: 1px solid var(--border-color, #2a2a2a);
355
+ margin-bottom: 20px;
356
+ }
357
+
358
+ &__tab {
359
+ padding: 10px 18px;
360
+ background: none;
361
+ border: none;
362
+ border-bottom: 2px solid transparent;
363
+ color: var(--text-secondary, #888);
364
+ cursor: pointer;
365
+ display: flex;
366
+ align-items: center;
367
+ gap: 6px;
368
+ font-size: 0.9rem;
369
+ transition: color 0.15s, border-color 0.15s;
370
+
371
+ &--active {
372
+ color: var(--text-primary, #fff);
373
+ border-bottom-color: var(--accent, #00d4ff);
374
+ }
375
+ }
376
+
377
+ &__badge {
378
+ background: var(--surface-raised, #1e1e1e);
379
+ border-radius: 10px;
380
+ padding: 2px 7px;
381
+ font-size: 0.75rem;
382
+ }
383
+
384
+ &__loading,
385
+ &__empty {
386
+ padding: 40px;
387
+ text-align: center;
388
+ color: var(--text-secondary, #888);
389
+ font-size: 0.9rem;
390
+ }
391
+
392
+ &__pool-grid {
393
+ display: grid;
394
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
395
+ gap: 14px;
396
+ }
397
+
398
+ &__pool-card {
399
+ border: 1px solid var(--border-color, #2a2a2a);
400
+ padding: 16px;
401
+ background: var(--surface-raised, #111);
402
+ display: flex;
403
+ flex-direction: column;
404
+ gap: 10px;
405
+
406
+ &--exhausted { opacity: 0.6; }
407
+ }
408
+
409
+ &__pool-name {
410
+ font-weight: 600;
411
+ font-size: 0.95rem;
412
+ color: var(--text-primary, #fff);
413
+ }
414
+
415
+ &__pool-availability { font-size: 0.82rem; }
416
+ &__avail-ok { color: var(--success, #22c55e); }
417
+ &__avail-empty { color: var(--text-secondary, #888); }
418
+
419
+ &__platform-sel { display: flex; flex-direction: column; gap: 6px; }
420
+
421
+ &__label {
422
+ font-size: 0.75rem;
423
+ color: var(--text-secondary, #888);
424
+ }
425
+
426
+ &__platform-chips {
427
+ display: flex;
428
+ flex-wrap: wrap;
429
+ gap: 6px;
430
+ }
431
+
432
+ &__platform-chip {
433
+ padding: 4px 10px;
434
+ font-size: 0.78rem;
435
+ border: 1px solid var(--border-color, #2a2a2a);
436
+ color: var(--text-secondary, #888);
437
+ cursor: pointer;
438
+ transition: border-color 0.15s, color 0.15s;
439
+
440
+ &--active {
441
+ border-color: var(--accent, #00d4ff);
442
+ color: var(--accent, #00d4ff);
443
+ }
444
+ }
445
+
446
+ &__description-field { display: flex; flex-direction: column; gap: 4px; }
447
+
448
+ &__textarea {
449
+ background: var(--surface, #0a0a0a);
450
+ border: 1px solid var(--border-color, #2a2a2a);
451
+ color: var(--text-primary, #fff);
452
+ font-size: 0.82rem;
453
+ padding: 8px 10px;
454
+ resize: vertical;
455
+ width: 100%;
456
+ outline: none;
457
+ font-family: inherit;
458
+
459
+ &:focus { border-color: var(--accent, #00d4ff); }
460
+ }
461
+
462
+ &__pool-footer { margin-top: auto; display: flex; align-items: center; }
463
+
464
+ &__request-btn {
465
+ padding: 8px 16px;
466
+ background: var(--accent, #00d4ff);
467
+ color: #000;
468
+ border: none;
469
+ font-weight: 600;
470
+ font-size: 0.85rem;
471
+ cursor: pointer;
472
+ width: 100%;
473
+ transition: opacity 0.15s;
474
+
475
+ &:hover { opacity: 0.85; }
476
+ &:disabled { opacity: 0.4; cursor: not-allowed; }
477
+ }
478
+
479
+ &__requested-label {
480
+ font-size: 0.82rem;
481
+ color: var(--success, #22c55e);
482
+ }
483
+
484
+ &__request-list {
485
+ display: flex;
486
+ flex-direction: column;
487
+ gap: 8px;
488
+ }
489
+
490
+ &__request-row {
491
+ display: grid;
492
+ grid-template-columns: 1fr auto auto;
493
+ align-items: center;
494
+ gap: 16px;
495
+ border: 1px solid var(--border-color, #2a2a2a);
496
+ padding: 12px 16px;
497
+ background: var(--surface-raised, #111);
498
+ }
499
+
500
+ &__request-info { display: flex; flex-direction: column; gap: 2px; }
501
+
502
+ &__request-pool {
503
+ font-weight: 500;
504
+ font-size: 0.9rem;
505
+ color: var(--text-primary, #fff);
506
+ }
507
+
508
+ &__request-platform {
509
+ font-size: 0.78rem;
510
+ color: var(--text-secondary, #888);
511
+ }
512
+
513
+ &__status-chip {
514
+ font-size: 0.73rem;
515
+ padding: 3px 10px;
516
+ border: 1px solid var(--chip-color, #888);
517
+ color: var(--chip-color, #888);
518
+ text-transform: uppercase;
519
+ letter-spacing: 0.05em;
520
+ white-space: nowrap;
521
+ }
522
+
523
+ &__request-action { display: flex; align-items: center; }
524
+
525
+ &__reveal-btn {
526
+ padding: 7px 14px;
527
+ background: var(--warning-subtle, rgba(245,158,11,0.15));
528
+ border: 1px solid var(--warning, #f59e0b);
529
+ color: var(--warning, #f59e0b);
530
+ font-weight: 600;
531
+ font-size: 0.82rem;
532
+ cursor: pointer;
533
+ transition: opacity 0.15s;
534
+
535
+ &:hover { opacity: 0.8; }
536
+ &:disabled { opacity: 0.4; cursor: not-allowed; }
537
+ }
538
+
539
+ &__revealed-label {
540
+ font-size: 0.82rem;
541
+ color: var(--success, #22c55e);
542
+ }
543
+
544
+ &__pending-label {
545
+ font-size: 0.8rem;
546
+ color: var(--text-secondary, #888);
547
+ font-style: italic;
548
+ }
549
+ }
550
+ </style>
@@ -0,0 +1,142 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * One-time key reveal modal.
4
+ * Shows a warning that the code will be displayed once, then shows it.
5
+ */
6
+ const props = defineProps<{
7
+ code: string
8
+ platform: string | null
9
+ revealedAt: string
10
+ }>()
11
+
12
+ const emit = defineEmits<{ (e: 'close'): void }>()
13
+
14
+ function copyCode() {
15
+ navigator.clipboard?.writeText(props.code)
16
+ }
17
+ </script>
18
+
19
+ <template>
20
+ <div class="krm__overlay" @click.self="emit('close')">
21
+ <div class="krm__panel">
22
+ <div class="krm__icon">🔑</div>
23
+ <h2 class="krm__title">{{ $t('keys.reveal.title') }}</h2>
24
+ <p class="krm__sub">{{ $t('keys.reveal.sub') }}</p>
25
+
26
+ <div v-if="platform" class="krm__platform">{{ platform }}</div>
27
+
28
+ <div class="krm__code-box">
29
+ <span class="krm__code">{{ code }}</span>
30
+ <button class="krm__copy" @click="copyCode" :title="$t('common.copy')">
31
+ &#x2398;
32
+ </button>
33
+ </div>
34
+
35
+ <p class="krm__warning">{{ $t('keys.reveal.warning') }}</p>
36
+
37
+ <button class="krm__close-btn" @click="emit('close')">
38
+ {{ $t('common.close') }}
39
+ </button>
40
+ </div>
41
+ </div>
42
+ </template>
43
+
44
+ <style scoped lang="scss">
45
+ .krm {
46
+ &__overlay {
47
+ position: fixed;
48
+ inset: 0;
49
+ background: rgba(0, 0, 0, 0.75);
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: center;
53
+ z-index: 1000;
54
+ }
55
+
56
+ &__panel {
57
+ background: var(--surface, #141414);
58
+ border: 1px solid var(--border-color, #2a2a2a);
59
+ padding: 40px 32px;
60
+ max-width: 440px;
61
+ width: 100%;
62
+ text-align: center;
63
+ display: flex;
64
+ flex-direction: column;
65
+ align-items: center;
66
+ gap: 14px;
67
+ }
68
+
69
+ &__icon { font-size: 2rem; }
70
+
71
+ &__title {
72
+ font-size: 1.2rem;
73
+ font-weight: 700;
74
+ color: var(--text-primary, #fff);
75
+ margin: 0;
76
+ }
77
+
78
+ &__sub {
79
+ font-size: 0.88rem;
80
+ color: var(--text-secondary, #888);
81
+ margin: 0;
82
+ }
83
+
84
+ &__platform {
85
+ font-size: 0.8rem;
86
+ padding: 3px 12px;
87
+ border: 1px solid var(--accent, #00d4ff);
88
+ color: var(--accent, #00d4ff);
89
+ text-transform: uppercase;
90
+ letter-spacing: 0.08em;
91
+ }
92
+
93
+ &__code-box {
94
+ display: flex;
95
+ align-items: center;
96
+ gap: 10px;
97
+ background: var(--surface-raised, #1a1a1a);
98
+ border: 1px solid var(--border-color, #2a2a2a);
99
+ padding: 14px 20px;
100
+ width: 100%;
101
+ justify-content: space-between;
102
+ }
103
+
104
+ &__code {
105
+ font-family: monospace;
106
+ font-size: 1.05rem;
107
+ color: var(--success, #22c55e);
108
+ letter-spacing: 0.1em;
109
+ word-break: break-all;
110
+ }
111
+
112
+ &__copy {
113
+ background: none;
114
+ border: none;
115
+ color: var(--text-secondary, #888);
116
+ cursor: pointer;
117
+ font-size: 1.1rem;
118
+ padding: 4px;
119
+ transition: color 0.15s;
120
+ flex-shrink: 0;
121
+ &:hover { color: var(--text-primary, #fff); }
122
+ }
123
+
124
+ &__warning {
125
+ font-size: 0.78rem;
126
+ color: var(--warning, #f59e0b);
127
+ margin: 0;
128
+ }
129
+
130
+ &__close-btn {
131
+ margin-top: 6px;
132
+ padding: 10px 28px;
133
+ background: var(--surface-raised, #1e1e1e);
134
+ border: 1px solid var(--border-color, #2a2a2a);
135
+ color: var(--text-primary, #fff);
136
+ cursor: pointer;
137
+ font-size: 0.9rem;
138
+ transition: background 0.15s;
139
+ &:hover { background: var(--surface-hover, #2a2a2a); }
140
+ }
141
+ }
142
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.1.44",
3
+ "version": "1.1.45",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -0,0 +1,125 @@
1
+ <script setup lang="ts">
2
+ // Public, shareable standalone key-campaign page. Reached from broadcast links
3
+ // (Discord/Telegram/WhatsApp) and from anywhere — no need to enter a dashboard.
4
+ // Anyone can view; requesting a key uses KeyBrowser's hybrid auth (it redirects
5
+ // to login on 401 and returns).
6
+ definePageMeta({ layout: 'blank' });
7
+
8
+ const route = useRoute();
9
+ const slug = route.params.slug as string;
10
+
11
+ const runtimeConfig = useRuntimeConfig();
12
+ const cfg = (runtimeConfig.public?.mgSharedUi || {}) as Record<string, any>;
13
+ const apiBase: string =
14
+ cfg.apiBaseURL ||
15
+ (runtimeConfig.public as any)?.apiBaseURL ||
16
+ '';
17
+
18
+ const SUPPORTED_LOCALES = ['en', 'pt-BR', 'es', 'de', 'ro', 'ar-AE'];
19
+ const routePath = route.path.split('/');
20
+ const lang = SUPPORTED_LOCALES.find(l => routePath.includes(l)) || 'en';
21
+
22
+ const { data: keyData, error: fetchError, pending } = await useAsyncData(
23
+ `public-key-${slug}`,
24
+ () => $fetch<any>(`${apiBase}/public/keys/${slug}`, { params: { lang } }),
25
+ { lazy: false },
26
+ );
27
+
28
+ const key = computed(() => keyData.value?.data || keyData.value || null);
29
+ const loading = pending;
30
+ const error = computed(() => !!fetchError.value || (!pending.value && !key.value));
31
+
32
+ const restriction = computed(() => {
33
+ const k = key.value;
34
+ if (!k) return '';
35
+ if (k.is_exclusive) return 'Exclusiva para streamers oficiais';
36
+ if (k.is_tier_locked && k.access_tier && k.access_tier !== 'free') return `Exclusiva para o plano ${String(k.access_tier).toUpperCase()} ou superior`;
37
+ if (k.streamer_type_id === 2) return 'Exclusiva para streamers oficiais';
38
+ if (k.streamer_type_id === 3) return 'Exclusiva para afiliados';
39
+ return '';
40
+ });
41
+ </script>
42
+
43
+ <template>
44
+ <div class="kc">
45
+ <div v-if="loading" class="kc__state">Carregando…</div>
46
+ <div v-else-if="error" class="kc__state">Campanha não encontrada.</div>
47
+
48
+ <template v-else>
49
+ <div class="kc__hero">
50
+ <img v-if="key.cover_src" :src="key.cover_src" :alt="key.name" class="kc__cover">
51
+ <div class="kc__hero-body">
52
+ <h1 class="kc__title">{{ key.name }}</h1>
53
+ <div v-if="restriction" class="kc__badge">🔒 {{ restriction }}</div>
54
+ <div class="kc__avail">
55
+ <span v-if="key.available_count > 0">{{ key.available_count }} chave(s) pra resgatar</span>
56
+ <span v-else>Chaves esgotadas</span>
57
+ </div>
58
+ </div>
59
+ </div>
60
+
61
+ <div class="kc__browser">
62
+ <KeyBrowser
63
+ requester-context="user"
64
+ :game-id="key.game?.id || key.game_id"
65
+ />
66
+ </div>
67
+ </template>
68
+ </div>
69
+ </template>
70
+
71
+ <style scoped lang="scss">
72
+ .kc {
73
+ max-width: 960px;
74
+ margin: 0 auto;
75
+ padding: 24px 16px 64px;
76
+
77
+ &__state {
78
+ padding: 80px 0;
79
+ text-align: center;
80
+ color: var(--text-secondary, #888);
81
+ }
82
+
83
+ &__hero {
84
+ display: flex;
85
+ gap: 20px;
86
+ align-items: flex-start;
87
+ margin-bottom: 28px;
88
+ flex-wrap: wrap;
89
+ }
90
+
91
+ &__cover {
92
+ width: 260px;
93
+ max-width: 100%;
94
+ height: auto;
95
+ object-fit: cover;
96
+ border: 1px solid var(--border-color, #2a2a2a);
97
+ }
98
+
99
+ &__hero-body { flex: 1; min-width: 240px; display: flex; flex-direction: column; gap: 10px; }
100
+
101
+ &__title {
102
+ font-size: 1.6rem;
103
+ font-weight: 700;
104
+ color: var(--text-primary, #fff);
105
+ margin: 0;
106
+ }
107
+
108
+ &__badge {
109
+ display: inline-flex;
110
+ align-items: center;
111
+ gap: 6px;
112
+ width: fit-content;
113
+ padding: 4px 10px;
114
+ font-size: 0.8rem;
115
+ font-weight: 600;
116
+ color: #d297ff;
117
+ border: 1px solid rgba(210, 151, 255, 0.35);
118
+ background: rgba(210, 151, 255, 0.12);
119
+ }
120
+
121
+ &__avail { font-size: 0.9rem; color: var(--text-secondary, #aaa); }
122
+
123
+ &__browser { margin-top: 8px; }
124
+ }
125
+ </style>
@@ -52,7 +52,7 @@ const locale = useNuxtApp().$i18n.locale;
52
52
  const slug = computed(() => route.params.slug as string);
53
53
 
54
54
  const magazineStore = useMagazineStore();
55
- const { magazine, magazines, magazinePages } = magazineStore;
55
+ const { magazine, magazines, magazinePages } = storeToRefs(magazineStore);
56
56
  const institutionalStore = useInstitutionalStore();
57
57
  const { language } = institutionalStore;
58
58
 
@@ -124,7 +124,7 @@ function restoreReadingProgress() {
124
124
  return;
125
125
  }
126
126
 
127
- const totalPages = magazinePages.data?.length ?? 0;
127
+ const totalPages = magazinePages.value.data?.length ?? 0;
128
128
  if (!totalPages) {
129
129
  startPage.value = 0;
130
130
  return;
@@ -148,15 +148,15 @@ const showDownloadGateMessage = ref("");
148
148
 
149
149
  // Flipbook pages mapped for MgnFlipbook
150
150
  const flipbookPages = computed<PageData[]>(() => {
151
- if (!magazinePages.data || magazinePages.data.length === 0) return [];
152
- return magazinePages.data.map((page: MagazinePage) => ({
151
+ if (!magazinePages.value.data || magazinePages.value.data.length === 0) return [];
152
+ return magazinePages.value.data.map((page: MagazinePage) => ({
153
153
  index: page.page_number - 1,
154
154
  thumbnailUrl: page.thumbnail_url,
155
155
  imageUrl: page.image_url,
156
156
  hiresUrl: page.hires_url,
157
157
  hotspots: getMagazinePageHotspots({
158
158
  page,
159
- flipbookCode: magazine.data?.flipbook_code,
159
+ flipbookCode: magazine.value.data?.flipbook_code,
160
160
  }),
161
161
  }));
162
162
  });
@@ -164,14 +164,14 @@ const flipbookPages = computed<PageData[]>(() => {
164
164
  // Determine if lead capture modal should show
165
165
  const requiresLeadCapture = computed(() => {
166
166
  if (leadCaptured.value) return false;
167
- if (!magazinePages.meta) return false;
168
- return magazinePages.meta.requires_lead || magazinePages.meta.capture_leads;
167
+ if (!magazinePages.value.meta) return false;
168
+ return magazinePages.value.meta.requires_lead || magazinePages.value.meta.capture_leads;
169
169
  });
170
170
 
171
171
  async function getMagazineData() {
172
172
  try {
173
173
  await magazineStore.getMagazine(slug.value);
174
- status.value = magazine.status;
174
+ status.value = magazine.value.status;
175
175
  } catch (error) {
176
176
  console.error("Error fetching magazine:", error);
177
177
  }
@@ -195,8 +195,8 @@ async function loadFlipbookPages() {
195
195
  }
196
196
 
197
197
  function downloadPdf() {
198
- if (magazine.data.pdf_url_src) {
199
- window.open(magazine.data.pdf_url_src, "_blank");
198
+ if (magazine.value.data.pdf_url_src) {
199
+ window.open(magazine.value.data.pdf_url_src, "_blank");
200
200
  magazineStore.trackAnalytic(slug.value, "download");
201
201
  }
202
202
  }
@@ -210,13 +210,13 @@ function downloadPdf() {
210
210
  */
211
211
  function handleFlipbookDownload() {
212
212
  // Gate 1: Premium magazine - need active subscription
213
- if (magazine.data.premium === true) {
213
+ if (magazine.value.data.premium === true) {
214
214
  showDownloadGateMessage.value = "premium";
215
215
  return;
216
216
  }
217
217
 
218
218
  // Gate 2: Non-public magazine - must be registered/signed in
219
- if (magazine.data.public === false && signedIn.value === false) {
219
+ if (magazine.value.data.public === false && signedIn.value === false) {
220
220
  showDownloadGateMessage.value = "signin";
221
221
  return;
222
222
  }
@@ -307,7 +307,7 @@ async function fetchMagazine() {
307
307
  await magazineStore.fetchMagazine({
308
308
  filter: {
309
309
  status: 1,
310
- edition_number: magazine.data.edition_number,
310
+ edition_number: magazine.value.data.edition_number,
311
311
  platform_id: import.meta.env.VITE_SYSTEM_ID,
312
312
  },
313
313
  sort: "id",
@@ -331,16 +331,16 @@ async function fetchLanguage() {
331
331
  }
332
332
 
333
333
  function filterLanguages() {
334
- if (magazine.data && magazines.data && magazines.data.length > 0) {
335
- languageId.value = magazine.data.language.id;
336
- const languageIds = magazines.data.map(
334
+ if (magazine.value.data && magazines.value.data && magazines.value.data.length > 0) {
335
+ languageId.value = magazine.value.data.language.id;
336
+ const languageIds = magazines.value.data.map(
337
337
  (item: MagazineItem) => item.language.id,
338
338
  );
339
339
  languages.value = language.data.filter((language: LanguageItem) =>
340
340
  languageIds.includes(language.id),
341
341
  );
342
342
  languages.value.forEach((language: LanguageItem) => {
343
- const magazineItem = magazines.data.find(
343
+ const magazineItem = magazines.value.data.find(
344
344
  (item: MagazineItem) => item.language.id === language.id,
345
345
  );
346
346
  if (magazineItem) {
@@ -68,6 +68,11 @@ const tpl = computed(() => kit.value?.theme || null);
68
68
  const layout = computed(() => kit.value?.layout || 'classic');
69
69
  const allowDownload = computed(() => kit.value?.allow_asset_download !== false);
70
70
 
71
+ const keyPoolsVisible = ref(false);
72
+ function onPoolsReady(count: number) {
73
+ keyPoolsVisible.value = count > 0;
74
+ }
75
+
71
76
  const socials = computed(() => {
72
77
  const s = kit.value?.social_links || {};
73
78
  return Object.entries(s).filter(([, v]) => v).map(([k, v]) => ({ platform: k, url: v as string }));
@@ -255,11 +260,22 @@ useHead(() => ({
255
260
  </div>
256
261
  </section>
257
262
 
263
+ <!-- Key pools embed — shown when game has active pools -->
264
+ <section v-if="kit.game_id" v-show="keyPoolsVisible" class="kit-block kit-keys-section">
265
+ <h2 class="kit-keys-title">{{ $t('kit.press.get_key_title') }}</h2>
266
+ <p class="kit-keys-desc">{{ $t('kit.press.get_key_desc') }}</p>
267
+ <KeyBrowser
268
+ requester-context="user"
269
+ :game-id="kit.game_id"
270
+ @pools-ready="onPoolsReady"
271
+ />
272
+ </section>
273
+
258
274
  <section v-if="kit.press_contact_email" class="kit-block">
259
275
  <div class="kit-cta">
260
276
  <h2>{{ $t('kit.press.covering', { name: kit.name }) }}</h2>
261
277
  <p>{{ $t('kit.press.cta_text') }}</p>
262
- <a class="kit-btn kit-btn-primary" :href="`mailto:${kit.press_contact_email}`" @click="track('contact')">{{ $t('kit.press.request_key') }}</a>
278
+ <a class="kit-btn kit-btn-primary" :href="`mailto:${kit.press_contact_email}`" @click="track('contact')">{{ $t('kit.press.contact_btn') }}</a>
263
279
  </div>
264
280
  </section>
265
281
 
@@ -55,7 +55,7 @@ export default defineNuxtPlugin({
55
55
  authorize: (socketId: any, callback: any) => {
56
56
  axios
57
57
  .post(
58
- import.meta.env.VITE_PUSHER_AUTH,
58
+ (import.meta.env.VITE_PUSHER_AUTH ?? '').replace(/^["']|["']$/g, ''),
59
59
  {
60
60
  socket_id: socketId,
61
61
  channel_name: channel.name,
@@ -0,0 +1,45 @@
1
+ import httpService from './httpService'
2
+
3
+ export type KeyPool = {
4
+ id: number
5
+ name: string
6
+ slug: string
7
+ access_policy: string
8
+ available_count: number
9
+ already_requested: boolean
10
+ game: { id: number; name: string } | null
11
+ cover: string | null
12
+ }
13
+
14
+ export type KeyRequest = {
15
+ id: number
16
+ pool_id: number
17
+ pool_name: string
18
+ pool_slug: string
19
+ status_slug: string
20
+ status_name: string
21
+ platforms: string[]
22
+ revealed_at: string | null
23
+ created_at: string
24
+ }
25
+
26
+ export type RevealResult = {
27
+ code: string
28
+ platform: string | null
29
+ revealed_at: string
30
+ }
31
+
32
+ export const fetchAvailablePools = (params: Record<string, any> = {}) =>
33
+ httpService.get<{ data: KeyPool[] }>('/key-pools/available', { params })
34
+
35
+ export const fetchMyRequests = () =>
36
+ httpService.get<{ data: KeyRequest[] }>('/key-requests/mine')
37
+
38
+ export const requestKey = (poolId: number, body: Record<string, any>) =>
39
+ httpService.post(`/key-pools/${poolId}/request`, body)
40
+
41
+ export const revealKey = (requestId: number, body: Record<string, any> = {}) =>
42
+ httpService.post<{ data: RevealResult }>(`/key-requests/${requestId}/reveal`, body)
43
+
44
+ export const fetchPlatforms = () =>
45
+ httpService.get<{ data: { id: number; name: string }[] }>('/showcase-studio-dashboard/platforms')