@mundogamernetwork/shared-ui 1.1.43 → 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.
- package/components/keys/KeyBrowser.vue +550 -0
- package/components/keys/KeyRevealModal.vue +142 -0
- package/package.json +1 -1
- package/pages/key/[slug].vue +125 -0
- package/pages/magazine/[slug]/preview.vue +17 -17
- package/pages/presskit/[slug].vue +17 -1
- package/plugins/echo.client.ts +1 -1
- package/services/institutionalService.ts +11 -2
- package/services/keyService.ts +45 -0
|
@@ -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
|
+
⎘
|
|
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
|
@@ -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.
|
|
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
|
|
package/plugins/echo.client.ts
CHANGED
|
@@ -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,
|
|
@@ -10,8 +10,17 @@ interface SerializeObject {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
const getNetworkBaseUrl = () => {
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// useRuntimeConfig() needs the active Nuxt instance and throws when called
|
|
14
|
+
// outside a setup context — these service functions run from async Pinia
|
|
15
|
+
// actions (loadPreviewData -> store -> service), where on SSR the context
|
|
16
|
+
// is already gone. Fall back to the build-time env so the fetch still works.
|
|
17
|
+
try {
|
|
18
|
+
const fromConfig = useRuntimeConfig().public.mgSharedUi?.networkBaseUrl;
|
|
19
|
+
if (fromConfig) return fromConfig as string;
|
|
20
|
+
} catch (_) {
|
|
21
|
+
// outside Nuxt context — use the env fallback below
|
|
22
|
+
}
|
|
23
|
+
return import.meta.env.VITE_BASE_URL_NETWORK;
|
|
15
24
|
};
|
|
16
25
|
|
|
17
26
|
export const complexSerialize = function (obj: SerializeObject): string {
|
|
@@ -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')
|