@mundogamernetwork/shared-ui 1.1.58 → 1.1.60

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.
@@ -9,7 +9,7 @@ import {
9
9
  type KeyRequest,
10
10
  type RevealResult,
11
11
  } from '../../services/keyService'
12
- import { mgApiUrl } from '../../services/httpService'
12
+ import httpService, { mgApiUrl } from '../../services/httpService'
13
13
 
14
14
  const { t } = useI18n()
15
15
 
@@ -43,6 +43,9 @@ const requestForms = ref<Record<number, { platform_ids: number[]; description: s
43
43
  const revealingId = ref<number | null>(null)
44
44
  const revealResult = ref<RevealResult | null>(null)
45
45
 
46
+ const notifPromptVisible = ref(false)
47
+ const notifSaving = ref(false)
48
+
46
49
  // ─── Params ───────────────────────────────────────────────────────────────────
47
50
 
48
51
  function queryParams(): Record<string, any> {
@@ -121,6 +124,10 @@ async function doRequestKey(pool: KeyPool) {
121
124
  await apiRequestKey(pool.id, body)
122
125
  pool.already_requested = true
123
126
  await loadMyRequests()
127
+ // Prompt once per browser session
128
+ if (!localStorage.getItem('key_campaigns_notif_prompted')) {
129
+ _checkNotifPrompt()
130
+ }
124
131
  } catch (err: any) {
125
132
  if (err?.response?.status === 401) {
126
133
  isAuthenticated.value = false
@@ -138,6 +145,45 @@ function handleRequestKey(pool: KeyPool) {
138
145
  doRequestKey(pool)
139
146
  }
140
147
 
148
+ // ─── Notification prompt ──────────────────────────────────────────────────────
149
+
150
+ async function _checkNotifPrompt() {
151
+ try {
152
+ const res = await httpService.get('/user/notification-preferences', {
153
+ params: { category: 'key_campaigns' },
154
+ })
155
+ if (res.data?.data === null) {
156
+ notifPromptVisible.value = true
157
+ }
158
+ } catch {
159
+ // silently ignore — don't show prompt if API fails
160
+ }
161
+ }
162
+
163
+ async function enableNotifAlerts() {
164
+ notifSaving.value = true
165
+ try {
166
+ await httpService.put('/user/notification-preferences', {
167
+ category: 'key_campaigns',
168
+ enabled: true,
169
+ channel_email: true,
170
+ channel_push: true,
171
+ channel_in_app: true,
172
+ })
173
+ } catch {
174
+ // ignore — user can configure later in settings
175
+ } finally {
176
+ notifSaving.value = false
177
+ notifPromptVisible.value = false
178
+ localStorage.setItem('key_campaigns_notif_prompted', '1')
179
+ }
180
+ }
181
+
182
+ function dismissNotifPrompt() {
183
+ notifPromptVisible.value = false
184
+ localStorage.setItem('key_campaigns_notif_prompted', '1')
185
+ }
186
+
141
187
  // ─── Reveal ───────────────────────────────────────────────────────────────────
142
188
 
143
189
  async function revealCode(reqId: number) {
@@ -320,6 +366,24 @@ function statusColor(slug: string): string {
320
366
  </div>
321
367
  </div>
322
368
  </div>
369
+ <!-- Notification opt-in prompt -->
370
+ <transition name="fade">
371
+ <div v-if="notifPromptVisible" class="kb__notif-prompt">
372
+ <div class="kb__notif-icon">🎮</div>
373
+ <div class="kb__notif-content">
374
+ <strong>{{ $t('keys.notif_prompt_title') }}</strong>
375
+ <p>{{ $t('keys.notif_prompt_desc') }}</p>
376
+ <div class="kb__notif-actions">
377
+ <button class="kb__notif-btn kb__notif-btn--primary" :disabled="notifSaving" @click="enableNotifAlerts">
378
+ {{ $t('keys.notif_prompt_enable') }}
379
+ </button>
380
+ <button class="kb__notif-btn" @click="dismissNotifPrompt">
381
+ {{ $t('keys.notif_prompt_dismiss') }}
382
+ </button>
383
+ </div>
384
+ </div>
385
+ </div>
386
+ </transition>
323
387
  </template>
324
388
  </div>
325
389
  </template>
@@ -546,5 +610,77 @@ function statusColor(slug: string): string {
546
610
  color: var(--text-secondary, #888);
547
611
  font-style: italic;
548
612
  }
613
+
614
+ &__notif-prompt {
615
+ display: flex;
616
+ align-items: flex-start;
617
+ gap: 14px;
618
+ margin-top: 20px;
619
+ padding: 16px;
620
+ border: 1px solid var(--accent, #00d4ff);
621
+ background: var(--surface-raised, #111);
622
+ }
623
+
624
+ &__notif-icon {
625
+ font-size: 1.4rem;
626
+ line-height: 1;
627
+ flex-shrink: 0;
628
+ margin-top: 2px;
629
+ }
630
+
631
+ &__notif-content {
632
+ display: flex;
633
+ flex-direction: column;
634
+ gap: 6px;
635
+
636
+ strong {
637
+ font-size: 0.9rem;
638
+ color: var(--text-primary, #fff);
639
+ }
640
+
641
+ p {
642
+ margin: 0;
643
+ font-size: 0.82rem;
644
+ color: var(--text-secondary, #888);
645
+ line-height: 1.4;
646
+ }
647
+ }
648
+
649
+ &__notif-actions {
650
+ display: flex;
651
+ gap: 8px;
652
+ margin-top: 4px;
653
+ }
654
+
655
+ &__notif-btn {
656
+ padding: 7px 16px;
657
+ font-size: 0.82rem;
658
+ font-weight: 600;
659
+ border: 1px solid var(--border-color, #2a2a2a);
660
+ background: none;
661
+ color: var(--text-secondary, #888);
662
+ cursor: pointer;
663
+ transition: opacity 0.15s, border-color 0.15s, color 0.15s;
664
+
665
+ &:hover { opacity: 0.8; }
666
+ &:disabled { opacity: 0.4; cursor: not-allowed; }
667
+
668
+ &--primary {
669
+ background: var(--accent, #00d4ff);
670
+ border-color: var(--accent, #00d4ff);
671
+ color: #000;
672
+
673
+ &:hover { opacity: 0.85; }
674
+ }
675
+ }
676
+ }
677
+
678
+ .fade-enter-active,
679
+ .fade-leave-active {
680
+ transition: opacity 0.25s ease;
681
+ }
682
+ .fade-enter-from,
683
+ .fade-leave-to {
684
+ opacity: 0;
549
685
  }
550
686
  </style>
@@ -0,0 +1,402 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+
4
+ const props = defineProps<{
5
+ card: {
6
+ id: number;
7
+ slug: string;
8
+ name: string;
9
+ cover: string | null;
10
+ game_cover?: string | null;
11
+ available_count: number;
12
+ is_exclusive?: boolean;
13
+ is_tier_locked?: boolean;
14
+ access_tier?: string | null;
15
+ early_access_hours?: number;
16
+ user_can_access?: boolean;
17
+ meets_streamer_level?: boolean;
18
+ available_at_for_user?: string | null;
19
+ isExpired?: boolean;
20
+ isUpcoming?: boolean;
21
+ streamer_type?: number | null;
22
+ type?: string | null;
23
+ platforms?: Array<{ id: number; name: string; slug?: string | null } | string>;
24
+ };
25
+ publicMode?: boolean;
26
+ baseRoute?: string;
27
+ }>();
28
+
29
+ const { $i18n } = useNuxtApp();
30
+ const locale = $i18n.locale;
31
+
32
+ const cardCover = computed(() => {
33
+ const c = props.card as any;
34
+ return c.cover || c.cover_src || c.game?.url_cover_src || c.game_cover || "/imgs/no-cover-img.jpg";
35
+ });
36
+
37
+ const userCanAccess = computed(() => props.publicMode || props.card.user_can_access !== false);
38
+ const isExpired = computed(() => props.card.isExpired ?? false);
39
+ const isUpcoming = computed(() => props.card.isUpcoming ?? false);
40
+ const availCount = computed(() => props.card.available_count ?? 0);
41
+
42
+ const PLATFORM_ABBR: Record<string, string> = {
43
+ "playstation 5": "PS5", "playstation 4": "PS4", "playstation 3": "PS3",
44
+ "playstation 2": "PS2", "playstation 1": "PS1",
45
+ "xbox series x": "Series X", "xbox series s": "Series S",
46
+ "xbox series x/s": "Series X/S", "xbox one": "Xbox One", "xbox 360": "Xbox 360",
47
+ "nintendo switch": "Switch", "nintendo switch 2": "Switch 2",
48
+ "epic games store": "Epic", "epic games": "Epic",
49
+ "google stadia": "Stadia", "amazon luna": "Luna",
50
+ "oculus quest": "Quest", "meta quest": "Quest",
51
+ "steam": "Steam", "pc": "PC", "mac": "Mac", "macos": "Mac",
52
+ "linux": "Linux", "ios": "iOS", "android": "Android", "gog": "GOG",
53
+ };
54
+
55
+ function platformAbbr(p: { name: string } | string): string {
56
+ const name = typeof p === "string" ? p : p.name;
57
+ return PLATFORM_ABBR[name.toLowerCase()] ?? name;
58
+ }
59
+
60
+ function cardTierLabel(tier: string | null | undefined): string {
61
+ if (!tier) return "";
62
+ const labels: Record<string, string> = { free: "Free", pro: "PRO", business: "Business", official: "Official" };
63
+ return $i18n.t("keys.card.tier_locked", { tier: labels[tier] || tier });
64
+ }
65
+
66
+ function formatCountdown(availableAt: string | null | undefined): string {
67
+ if (!availableAt) return "";
68
+ const diff = new Date(availableAt).getTime() - Date.now();
69
+ if (diff <= 0) return "";
70
+ const h = Math.floor(diff / 3600000);
71
+ const m = Math.floor((diff % 3600000) / 60000);
72
+ if (h >= 24) { const d = Math.floor(h / 24); return `${d}d ${h % 24}h`; }
73
+ return h > 0 ? `${h}h ${m}m` : `${m}m`;
74
+ }
75
+
76
+ const detailRoute = computed(() =>
77
+ props.publicMode
78
+ ? `/${locale.value}/${props.baseRoute ?? 'key-campaign'}/${props.card.slug}`
79
+ : `/${locale.value}/dashboard/${props.baseRoute ?? 'key-campaign'}/${props.card.slug}`
80
+ );
81
+ </script>
82
+
83
+ <template>
84
+ <div
85
+ class="kc-card"
86
+ :class="{
87
+ unavailable: isExpired,
88
+ 'no-keys': !isExpired && !isUpcoming && availCount < 1,
89
+ upcoming: isUpcoming,
90
+ locked: !userCanAccess,
91
+ }"
92
+ >
93
+ <div class="kc-card__header">
94
+ <NuxtLink :to="detailRoute">
95
+ <div
96
+ class="kc-card__img"
97
+ :class="{ unavailable: isExpired || (!isUpcoming && availCount < 1) }"
98
+ >
99
+ <img
100
+ :src="cardCover"
101
+ :alt="card.name"
102
+ :class="{ 'gray-out': isExpired || isUpcoming || !userCanAccess }"
103
+ />
104
+
105
+ <!-- Available keys count badge -->
106
+ <div
107
+ v-if="availCount > 0 && userCanAccess && !isExpired && !isUpcoming"
108
+ class="kc-card__keys-badge"
109
+ >
110
+ {{ availCount }}
111
+ </div>
112
+
113
+ <!-- Exclusive badge -->
114
+ <div v-if="card.is_exclusive && !userCanAccess" class="kc-card__exclusive-badge kc-card__exclusive-lock">
115
+ <i class="fa-solid fa-crown" /> {{ $t("keys.card.exclusive_upgrade") }}
116
+ </div>
117
+ <div v-else-if="card.is_exclusive" class="kc-card__exclusive-badge">
118
+ <i class="fa-solid fa-crown" /> {{ $t("keys.card.exclusive") }}
119
+ </div>
120
+
121
+ <!-- Tier-locked badge -->
122
+ <div v-else-if="card.is_tier_locked && card.access_tier !== 'free'" class="kc-card__exclusive-badge">
123
+ <i class="fa-solid fa-crown" /> {{ cardTierLabel(card.access_tier) }}
124
+ </div>
125
+
126
+ <!-- Early access badge -->
127
+ <div v-else-if="(card.early_access_hours ?? 0) > 0 && userCanAccess" class="kc-card__early-access-badge">
128
+ <i class="fa-solid fa-bolt" /> {{ $t("keys.card.early_access") }}
129
+ </div>
130
+
131
+ <!-- Streamer level badge -->
132
+ <div v-if="card.streamer_type === 2 || card.streamer_type === 3" class="kc-card__exclusive-badge kc-card__level-badge">
133
+ <i class="fa-solid fa-user-check" />
134
+ {{ card.streamer_type === 2 ? $t("keys.card.level_official") : $t("keys.card.level_affiliate") }}
135
+ </div>
136
+
137
+ <!-- Early-access lock overlay -->
138
+ <div v-if="!userCanAccess && card.available_at_for_user" class="kc-card__lock-overlay">
139
+ <i class="fa-solid fa-lock" />
140
+ <span class="lock-label">{{ $t("keys.card.early_access_lock_title") }}</span>
141
+ <span class="lock-countdown">{{ $t("keys.card.early_access_opens_in") }} {{ formatCountdown(card.available_at_for_user) }}</span>
142
+ </div>
143
+
144
+ <!-- Exclusive lock overlay -->
145
+ <div v-else-if="!userCanAccess && card.is_exclusive" class="kc-card__lock-overlay kc-card__lock-overlay--exclusive">
146
+ <i class="fa-solid fa-crown" />
147
+ <span class="lock-label">{{ $t("keys.card.exclusive_lock_title") }}</span>
148
+ <span class="lock-cta">{{ $t("keys.card.exclusive_upgrade_cta") }}</span>
149
+ </div>
150
+ </div>
151
+ </NuxtLink>
152
+
153
+ <div class="kc-card__meta-bar">
154
+ <div class="kc-card__days" v-if="card.early_access_hours && card.early_access_hours > 0 && userCanAccess">
155
+ <span>{{ card.early_access_hours }}</span>h {{ $t("keys.card.days") }}
156
+ </div>
157
+ <div class="kc-card__days" v-else>&nbsp;</div>
158
+ <span v-if="card.type" class="kc-card__type">
159
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="13" viewBox="0 0 16 17" fill="none">
160
+ <path d="M13.3333 4.1112L12.8666 3.64453L11.9333 4.57786L10.9333 3.64453L8.33329 4.1112L2.66663 9.77786L7.19996 14.3112L12.8666 8.64453L13.3333 6.04453L12.4 5.1112L13.3333 4.1112ZM12.6 6.24453L12.2666 8.3112L7.19996 13.3779L3.59996 9.77786L8.66663 4.7112L10.7333 4.3112L11.4666 5.04453L11 5.5112L10.5333 5.04453L9.99996 5.57786L11.4 6.97786L11.8666 6.5112L11.4 6.04453L11.8666 5.57786L12.6 6.24453Z" fill="#D297FF"/>
161
+ </svg>
162
+ {{ card.type }}
163
+ </span>
164
+ </div>
165
+ </div>
166
+
167
+ <div class="kc-card__body">
168
+ <div v-if="card.platforms?.length" class="kc-card__platforms">
169
+ <span v-for="p in card.platforms" :key="typeof p === 'string' ? p : p.id" class="kc-card__platform-tag">
170
+ {{ platformAbbr(p) }}
171
+ </span>
172
+ </div>
173
+ <p class="kc-card__name">{{ card.name }}</p>
174
+
175
+ <div class="kc-card__buttons">
176
+ <!-- Not accessible -->
177
+ <template v-if="!userCanAccess">
178
+ <NuxtLink :to="detailRoute">
179
+ <button class="btn secondary">{{ $t("keys.card.see_campaign") }}</button>
180
+ </NuxtLink>
181
+ <NuxtLink v-if="!publicMode" :to="`/${locale}/dashboard/pricing`">
182
+ <button class="btn secondary upgrade-btn">
183
+ <i class="fa-solid fa-arrow-up" /> {{ $t("keys.card.upgrade_plan") }}
184
+ </button>
185
+ </NuxtLink>
186
+ </template>
187
+
188
+ <template v-else-if="isExpired">
189
+ <NuxtLink :to="detailRoute">
190
+ <button class="btn secondary">{{ $t("keys.card.see_campaign") }}</button>
191
+ </NuxtLink>
192
+ <div class="unavailable-label">◉ {{ $t("keys.card.unavailable") }}</div>
193
+ </template>
194
+
195
+ <template v-else-if="isUpcoming">
196
+ <button class="btn secondary" disabled>{{ $t("keys.card.upcoming") }}</button>
197
+ </template>
198
+
199
+ <template v-else-if="availCount < 1">
200
+ <NuxtLink :to="detailRoute">
201
+ <button class="btn secondary">{{ $t("keys.card.see_campaign") }}</button>
202
+ </NuxtLink>
203
+ <div class="unavailable-label">◉ {{ $t("keys.card.unavailable") }}</div>
204
+ </template>
205
+
206
+ <template v-else>
207
+ <NuxtLink :to="detailRoute">
208
+ <button class="btn primary">{{ $t("keys.card.redeem") }}</button>
209
+ </NuxtLink>
210
+ </template>
211
+ </div>
212
+ </div>
213
+ </div>
214
+ </template>
215
+
216
+ <style scoped lang="scss">
217
+ .kc-card {
218
+ display: flex;
219
+ flex-direction: column;
220
+ background: var(--card-bg, #111);
221
+
222
+ &.unavailable { opacity: 0.6; }
223
+ &.locked { }
224
+
225
+ &__header { position: relative; }
226
+
227
+ &__img {
228
+ position: relative;
229
+ aspect-ratio: 3 / 4;
230
+ overflow: hidden;
231
+
232
+ img {
233
+ width: 100%;
234
+ height: 100%;
235
+ object-fit: cover;
236
+ display: block;
237
+ transition: transform 0.25s ease;
238
+
239
+ &.gray-out { filter: grayscale(1) brightness(0.6); }
240
+ }
241
+
242
+ &:hover img { transform: scale(1.03); }
243
+
244
+ &.unavailable img { filter: grayscale(1) brightness(0.5); }
245
+ }
246
+
247
+ &__keys-badge {
248
+ position: absolute;
249
+ top: 6px;
250
+ right: 6px;
251
+ background: var(--primary);
252
+ color: #fff;
253
+ font-size: 11px;
254
+ font-weight: 700;
255
+ padding: 2px 7px;
256
+ min-width: 22px;
257
+ text-align: center;
258
+ }
259
+
260
+ &__exclusive-badge {
261
+ position: absolute;
262
+ bottom: 6px;
263
+ left: 6px;
264
+ background: rgba(0, 0, 0, 0.75);
265
+ color: #D297FF;
266
+ font-size: 10px;
267
+ font-weight: 700;
268
+ padding: 3px 7px;
269
+ display: flex;
270
+ align-items: center;
271
+ gap: 4px;
272
+ text-transform: uppercase;
273
+
274
+ &.kc-card__exclusive-lock { color: #fff; }
275
+ }
276
+
277
+ &__early-access-badge {
278
+ position: absolute;
279
+ bottom: 6px;
280
+ left: 6px;
281
+ background: var(--primary);
282
+ color: #fff;
283
+ font-size: 10px;
284
+ font-weight: 700;
285
+ padding: 3px 7px;
286
+ display: flex;
287
+ align-items: center;
288
+ gap: 4px;
289
+ text-transform: uppercase;
290
+ }
291
+
292
+ &__level-badge {
293
+ top: 6px;
294
+ left: 6px;
295
+ bottom: auto;
296
+ color: #fff;
297
+ }
298
+
299
+ &__lock-overlay {
300
+ position: absolute;
301
+ inset: 0;
302
+ background: rgba(0, 0, 0, 0.65);
303
+ display: flex;
304
+ flex-direction: column;
305
+ align-items: center;
306
+ justify-content: center;
307
+ gap: 4px;
308
+ padding: 12px;
309
+ text-align: center;
310
+
311
+ i { font-size: 1.5rem; color: var(--primary); }
312
+ .lock-label { font-size: 11px; font-weight: 700; color: #fff; text-transform: uppercase; }
313
+ .lock-countdown { font-size: 10px; color: rgba(255,255,255,0.7); }
314
+
315
+ &--exclusive i { color: #D297FF; }
316
+ }
317
+
318
+ &__meta-bar {
319
+ display: flex;
320
+ align-items: center;
321
+ justify-content: space-between;
322
+ padding: 6px 8px;
323
+ background: var(--card-bg, #181818);
324
+ min-height: 30px;
325
+ }
326
+
327
+ &__days {
328
+ font-size: 11px;
329
+ color: var(--text-secondary, #aaa);
330
+ }
331
+
332
+ &__type {
333
+ font-size: 11px;
334
+ color: var(--secondary-info-fg, #aaa);
335
+ display: flex;
336
+ align-items: center;
337
+ gap: 4px;
338
+
339
+ svg { flex-shrink: 0; }
340
+ }
341
+
342
+ &__body {
343
+ padding: 8px 0;
344
+ display: flex;
345
+ flex-direction: column;
346
+ gap: 6px;
347
+ }
348
+
349
+ &__platforms {
350
+ display: flex;
351
+ flex-wrap: wrap;
352
+ gap: 4px;
353
+ }
354
+
355
+ &__platform-tag {
356
+ font-size: 10px;
357
+ font-weight: 600;
358
+ padding: 2px 6px;
359
+ border: 1px solid var(--border-color, rgba(255,255,255,0.2));
360
+ color: var(--text-secondary, #aaa);
361
+ text-transform: uppercase;
362
+ letter-spacing: 0.4px;
363
+ }
364
+
365
+ &__name {
366
+ font-size: 13px;
367
+ font-weight: 600;
368
+ color: var(--text-primary, #fff);
369
+ margin: 0;
370
+ overflow: hidden;
371
+ display: -webkit-box;
372
+ -webkit-line-clamp: 2;
373
+ -webkit-box-orient: vertical;
374
+ line-height: 1.3;
375
+ }
376
+
377
+ &__buttons {
378
+ display: flex;
379
+ flex-wrap: wrap;
380
+ gap: 6px;
381
+ margin-top: 2px;
382
+
383
+ .btn {
384
+ font-size: 11px;
385
+ padding: 5px 10px;
386
+ }
387
+
388
+ .upgrade-btn {
389
+ display: flex;
390
+ align-items: center;
391
+ gap: 4px;
392
+ }
393
+ }
394
+ }
395
+
396
+ .unavailable-label {
397
+ font-size: 11px;
398
+ color: var(--danger, #e53935);
399
+ display: flex;
400
+ align-items: center;
401
+ }
402
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mundogamernetwork/shared-ui",
3
- "version": "1.1.58",
3
+ "version": "1.1.60",
4
4
  "description": "Mundo Gamer Network - Shared UI Layer (Nuxt 3)",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -0,0 +1,129 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted } from 'vue'
3
+ import KeyCampaignDashboardCard from '../../components/keys/KeyCampaignDashboardCard.vue'
4
+
5
+ const props = defineProps<{
6
+ publicMode?: boolean
7
+ baseRoute?: string
8
+ }>()
9
+
10
+ const runtimeConfig = useRuntimeConfig()
11
+ const apiBase = computed(() =>
12
+ (runtimeConfig.public as any).apiBaseUrl ||
13
+ (runtimeConfig.public as any).tvApiBaseUrl ||
14
+ ''
15
+ )
16
+
17
+ const campaigns = ref<any[]>([])
18
+ const loading = ref(false)
19
+ const hasMore = ref(true)
20
+ const page = ref(1)
21
+ const search = ref('')
22
+ const typeFilter = ref('')
23
+
24
+ async function fetchCampaigns(reset = false) {
25
+ if (loading.value) return
26
+ loading.value = true
27
+ if (reset) { campaigns.value = []; page.value = 1; hasMore.value = true }
28
+ try {
29
+ const params = new URLSearchParams({
30
+ page: String(page.value),
31
+ per_page: '24',
32
+ ...(search.value ? { 'filter[search]': search.value } : {}),
33
+ ...(typeFilter.value ? { 'filter[type]': typeFilter.value } : {}),
34
+ })
35
+ const res = await $fetch(`${apiBase.value}/api/v1/public/keys?${params}`)
36
+ const items = (res as any).data ?? []
37
+ campaigns.value.push(...items)
38
+ hasMore.value = items.length === 24
39
+ page.value++
40
+ } catch { hasMore.value = false }
41
+ finally { loading.value = false }
42
+ }
43
+
44
+ onMounted(() => fetchCampaigns())
45
+ </script>
46
+
47
+ <template>
48
+ <div class="kc-page">
49
+ <div class="kc-page__header">
50
+ <h1 class="kc-page__title">{{ $t('keys.page.title') }}</h1>
51
+ <input v-model="search" class="kc-page__search" :placeholder="$t('keys.page.search_placeholder')" @input="fetchCampaigns(true)" />
52
+ </div>
53
+
54
+ <div v-if="loading && campaigns.length === 0" class="kc-page__loading">{{ $t('common.loading') }}</div>
55
+
56
+ <div class="kc-page__grid">
57
+ <KeyCampaignDashboardCard
58
+ v-for="c in campaigns"
59
+ :key="c.id"
60
+ :card="c"
61
+ :public-mode="publicMode"
62
+ :base-route="baseRoute"
63
+ />
64
+ </div>
65
+
66
+ <div v-if="hasMore && campaigns.length > 0" class="kc-page__more">
67
+ <button class="btn secondary" :disabled="loading" @click="fetchCampaigns()">
68
+ {{ loading ? $t('common.loading') : $t('keys.page.load_more') }}
69
+ </button>
70
+ </div>
71
+
72
+ <div v-if="!loading && campaigns.length === 0" class="kc-page__empty">
73
+ {{ $t('keys.page.empty') }}
74
+ </div>
75
+ </div>
76
+ </template>
77
+
78
+ <style scoped lang="scss">
79
+ .kc-page {
80
+ padding: 1.5rem;
81
+
82
+ &__header {
83
+ display: flex;
84
+ align-items: center;
85
+ justify-content: space-between;
86
+ gap: 1rem;
87
+ margin-bottom: 1.5rem;
88
+ flex-wrap: wrap;
89
+ }
90
+
91
+ &__title {
92
+ font-size: 1.25rem;
93
+ font-weight: 700;
94
+ color: var(--active, #fff);
95
+ margin: 0;
96
+ }
97
+
98
+ &__search {
99
+ background: var(--card-bg, #181818);
100
+ border: 1px solid var(--border-color, rgba(255,255,255,0.15));
101
+ color: var(--text-primary, #fff);
102
+ padding: 0.4rem 0.75rem;
103
+ font-size: 0.85rem;
104
+ width: 220px;
105
+ &:focus { outline: 1px solid var(--primary); }
106
+ }
107
+
108
+ &__grid {
109
+ display: grid;
110
+ grid-template-columns: repeat(4, 1fr);
111
+ gap: 1rem;
112
+
113
+ @media (max-width: 1024px) { grid-template-columns: repeat(3, 1fr); }
114
+ @media (max-width: 640px) { grid-template-columns: repeat(2, 1fr); }
115
+ }
116
+
117
+ &__more {
118
+ margin-top: 1.5rem;
119
+ text-align: center;
120
+ }
121
+
122
+ &__loading, &__empty {
123
+ text-align: center;
124
+ color: var(--text-secondary, #aaa);
125
+ padding: 3rem 0;
126
+ font-size: 0.9rem;
127
+ }
128
+ }
129
+ </style>
@@ -129,8 +129,10 @@ const facts = computed(() => {
129
129
  else if (k.release_date) out.push({ label: 'kit.press.release', value: k.release_date, accent: true });
130
130
  if (k.release_status) out.push({ label: 'kit.press.status', value: String(k.release_status).replace(/_/g, ' ') });
131
131
  if (k.price) out.push({ label: 'kit.press.price', value: `${k.price}${k.currency ? ' ' + k.currency : ''}` });
132
- if (k.website_url) out.push({ label: 'kit.press.website', value: k.website_url, accent: true, href: k.website_url });
133
- if (k.steam_url) out.push({ label: 'Steam', value: k.steam_url, href: k.steam_url });
132
+ if (k.website_url) out.push({ label: 'kit.press.website', value: k.website_url.replace(/^https?:\/\//, '').replace(/\/$/, ''), accent: true, href: k.website_url });
133
+ if (k.steam_url) out.push({ label: 'Steam', value: 'Steam ↗', href: k.steam_url });
134
+ if (k.epic_url) out.push({ label: 'Epic Games', value: 'Epic Games ↗', href: k.epic_url });
135
+ if (k.itch_url) out.push({ label: 'itch.io', value: 'itch.io ↗', href: k.itch_url });
134
136
  if (k.press_contact_email) out.push({ label: 'kit.press.contact', value: k.press_contact_email, href: `mailto:${k.press_contact_email}` });
135
137
  return out;
136
138
  });