@mundogamernetwork/shared-ui 1.1.59 → 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> </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
|
@@ -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:
|
|
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
|
});
|