@mundogamernetwork/shared-ui 1.1.61 → 1.1.63

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,1246 @@
1
+ <template>
2
+ <div id="shared-campaigns">
3
+ <div class="container">
4
+ <div class="stepper">
5
+ <div class="stepper-header">
6
+ <div
7
+ :class="['stepper-step', { complete: currentStep > 1, active: currentStep === 1 }]"
8
+ @click="currentStep = 1"
9
+ >
10
+ {{ $t("keys.campaigns.title") }}
11
+ </div>
12
+ <div
13
+ :class="['stepper-step', { complete: currentStep > 2, active: currentStep === 2 }]"
14
+ @click="currentStep = 2"
15
+ >
16
+ {{ $t("keys.campaigns.my_requests") }}
17
+ </div>
18
+ </div>
19
+
20
+ <div class="stepper-content">
21
+ <!-- Step 1: Campaign listing -->
22
+ <div class="step-inside" v-if="currentStep === 1">
23
+ <div class="image-slider">
24
+ <div v-if="loadingFeatured" class="slider-placeholder"></div>
25
+ <div v-else class="slider-container" ref="slider">
26
+ <div class="slide" v-for="(slide, index) in sliderImages" :key="index">
27
+ <div class="texts">{{ slide.name }}</div>
28
+ <nuxt-link :to="`/${locale}/key-campaigns/${slide.slug}`">
29
+ <button class="btn primary">{{ $t("keys.campaigns.details") }}</button>
30
+ <img :src="slide.cover" :alt="slide.name" />
31
+ </nuxt-link>
32
+ </div>
33
+ </div>
34
+ <div class="slider-dots" v-if="!loadingFeatured && sliderImages.length > 1">
35
+ <span
36
+ v-for="(_, index) in sliderImages"
37
+ :key="index"
38
+ :class="{ active: currentSlide === index }"
39
+ @click="goToSlide(index)"
40
+ ></span>
41
+ </div>
42
+ </div>
43
+
44
+ <div class="header">
45
+ <div class="header-left">
46
+ <button
47
+ class="btn tertiary"
48
+ :class="{ active: activeFilter === null }"
49
+ @click="setFilter(null)"
50
+ >
51
+ {{ $t("keys.campaigns.all") }}
52
+ </button>
53
+ <button
54
+ class="btn tertiary"
55
+ v-for="type in campaignTypes"
56
+ :key="type.id"
57
+ :class="{ active: activeFilter === type.id }"
58
+ @click="setFilter(type.id)"
59
+ >
60
+ {{ type.localized_name || type.name }}
61
+ </button>
62
+
63
+ <template v-if="platformList.length">
64
+ <span class="filter-divider">|</span>
65
+ <select
66
+ class="platform-select"
67
+ :value="activePlatformFilter ?? ''"
68
+ @change="setPlatformFilter(($event.target as HTMLSelectElement).value ? Number(($event.target as HTMLSelectElement).value) : null)"
69
+ >
70
+ <option value="">{{ $t("keys.campaigns.all") }}</option>
71
+ <option v-for="platform in platformList" :key="platform.id" :value="platform.id">
72
+ {{ platformAbbr(platform.name) }}
73
+ </option>
74
+ </select>
75
+ </template>
76
+ </div>
77
+ <div class="bar">
78
+ <input
79
+ type="text"
80
+ v-model="searchTerm"
81
+ :placeholder="$t('keys.campaigns.search_placeholder')"
82
+ />
83
+ <button type="button">
84
+ <MGIcon icon="search" />
85
+ </button>
86
+ </div>
87
+ </div>
88
+
89
+ <div class="cards">
90
+ <template v-if="loading">
91
+ <div class="card skeleton" v-for="n in 3" :key="n"></div>
92
+ </template>
93
+ <template v-else>
94
+ <div
95
+ class="card"
96
+ v-for="card in filteredCards"
97
+ :key="card.id"
98
+ :class="{
99
+ unavailable: card.isExpired,
100
+ 'no-keys': !card.isExpired && !card.isUpcoming && card.available_count < 1,
101
+ upcoming: card.isUpcoming,
102
+ locked: !card.user_can_access,
103
+ }"
104
+ >
105
+ <div class="card-header">
106
+ <nuxt-link :to="`/${locale}/key-campaigns/${card.slug}`">
107
+ <div
108
+ class="img-content"
109
+ :class="{ unavailable: card.isExpired || (!card.isUpcoming && card.available_count < 1) }"
110
+ >
111
+ <img
112
+ :src="card.cover"
113
+ :alt="card.text"
114
+ :class="{ 'gray-out': card.isExpired || card.isUpcoming || !card.user_can_access }"
115
+ @error="(e) => { (e.target as HTMLImageElement).src = '/imgs/no-cover-img.jpg' }"
116
+ />
117
+
118
+ <div
119
+ class="keys-badge"
120
+ v-if="card.available_count > 0 && card.user_can_access && !card.isExpired && !card.isUpcoming"
121
+ >
122
+ {{ card.available_count }}
123
+ </div>
124
+
125
+ <div
126
+ v-if="card.is_exclusive && !card.user_can_access"
127
+ class="exclusive-badge exclusive-lock"
128
+ >
129
+ <i class="fa-solid fa-crown" /> {{ $t("keys.campaigns.exclusive_upgrade") }}
130
+ </div>
131
+ <div v-else-if="card.is_exclusive" class="exclusive-badge">
132
+ <i class="fa-solid fa-crown" /> {{ $t("keys.campaigns.exclusive") }}
133
+ </div>
134
+
135
+ <div
136
+ v-else-if="card.is_tier_locked && card.access_tier !== 'free'"
137
+ class="exclusive-badge"
138
+ >
139
+ <i class="fa-solid fa-crown" /> {{ cardTierLabel(card.access_tier) }}
140
+ </div>
141
+
142
+ <div
143
+ v-else-if="card.early_access_hours > 0 && card.user_can_access"
144
+ class="early-access-badge"
145
+ >
146
+ <i class="fa-solid fa-bolt" /> {{ $t("keys.campaigns.early_access") }}
147
+ </div>
148
+
149
+ <div
150
+ v-if="card.streamer_type === 2 || card.streamer_type === 3"
151
+ class="exclusive-badge level-badge"
152
+ >
153
+ <i class="fa-solid fa-user-check" />
154
+ {{
155
+ card.streamer_type === 2
156
+ ? $t("keys.campaigns.restriction.level_official")
157
+ : $t("keys.campaigns.restriction.level_affiliate")
158
+ }}
159
+ </div>
160
+
161
+ <div
162
+ v-if="!card.user_can_access && card.available_at_for_user"
163
+ class="early-access-lock-overlay"
164
+ >
165
+ <i class="fa-solid fa-lock" />
166
+ <span class="lock-label">{{ $t("keys.campaigns.early_access_lock_title") }}</span>
167
+ <span class="lock-countdown">
168
+ {{ $t("keys.campaigns.early_access_opens_in") }}
169
+ {{ formatCountdown(card.available_at_for_user) }}
170
+ </span>
171
+ </div>
172
+
173
+ <div
174
+ v-else-if="!card.user_can_access && card.is_exclusive"
175
+ class="exclusive-lock-overlay"
176
+ >
177
+ <i class="fa-solid fa-crown" />
178
+ <span class="lock-label">{{ $t("keys.campaigns.exclusive_lock_title") }}</span>
179
+ <span class="lock-cta">{{ $t("keys.campaigns.exclusive_upgrade_cta") }}</span>
180
+ </div>
181
+ </div>
182
+ </nuxt-link>
183
+
184
+ <div class="title">
185
+ <div class="days" v-if="card.time > 0 && card.user_can_access">
186
+ <span>{{ card.time }}</span> {{ $t("keys.campaigns.days") }}
187
+ </div>
188
+ <div class="days" v-else></div>
189
+ <span>
190
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="17" viewBox="0 0 16 17" fill="none">
191
+ <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" />
192
+ </svg>
193
+ {{ card.type }}
194
+ </span>
195
+ </div>
196
+ </div>
197
+
198
+ <div class="text">
199
+ <div v-if="card.platforms && card.platforms.length" class="card-platforms">
200
+ <span v-for="p in card.platforms" :key="p" class="card-platform-tag">
201
+ {{ platformAbbr(p) }}
202
+ </span>
203
+ </div>
204
+ {{ card.text }}
205
+
206
+ <div class="buttons">
207
+ <template v-if="!card.user_can_access">
208
+ <nuxt-link :to="`/${locale}/key-campaigns/${card.slug}`">
209
+ <button class="btn secondary">{{ $t("keys.campaigns.see_campaign") }}</button>
210
+ </nuxt-link>
211
+ <nuxt-link :to="upgradeRoute">
212
+ <button class="btn secondary upgrade-btn">
213
+ <i class="fa-solid fa-arrow-up" />
214
+ {{ $t("keys.campaigns.upgrade_plan") }}
215
+ </button>
216
+ </nuxt-link>
217
+ </template>
218
+
219
+ <template v-else-if="card.isExpired">
220
+ <nuxt-link :to="`/${locale}/key-campaigns/${card.slug}`">
221
+ <button class="btn secondary">{{ $t("keys.campaigns.see_campaign") }}</button>
222
+ </nuxt-link>
223
+ <div class="unavailable-label">◉ {{ $t("keys.campaigns.unavailable") }}</div>
224
+ </template>
225
+
226
+ <template v-else-if="card.isUpcoming">
227
+ <button class="btn secondary" disabled>{{ $t("keys.campaigns.upcoming") }}</button>
228
+ </template>
229
+
230
+ <template v-else-if="card.available_count < 1">
231
+ <nuxt-link :to="`/${locale}/key-campaigns/${card.slug}`">
232
+ <button class="btn secondary">{{ $t("keys.campaigns.see_content") }}</button>
233
+ </nuxt-link>
234
+ </template>
235
+
236
+ <template v-else-if="card.userRequestSlug">
237
+ <nuxt-link :to="`/${locale}/key-campaigns/${card.slug}`">
238
+ <button class="btn secondary">{{ $t("keys.campaigns.see_campaign") }}</button>
239
+ </nuxt-link>
240
+ </template>
241
+
242
+ <template v-else>
243
+ <button class="btn primary" @click="handleRedeemClick(card.slug)">
244
+ {{ $t("keys.campaigns.redeem") }}
245
+ </button>
246
+ </template>
247
+
248
+ <button
249
+ v-if="card.user_can_access && !card.isExpired && !card.isUpcoming && card.available_count > 0"
250
+ class="btn-share"
251
+ @click.prevent="shareCard(card.slug)"
252
+ :title="$t('keys.campaigns.share')"
253
+ >
254
+ <MGIcon icon="share" />
255
+ </button>
256
+ </div>
257
+ </div>
258
+ </div>
259
+ </template>
260
+
261
+ <div class="load-more" v-if="hasMoreItems && !loading && filteredCards.length > 0">
262
+ <button class="btn secondary" @click="loadMoreItems" :disabled="loadingMore">
263
+ <template v-if="!loadingMore">{{ $t("keys.campaigns.load_more") }}</template>
264
+ <template v-else><span class="spinner"></span></template>
265
+ </button>
266
+ </div>
267
+ </div>
268
+
269
+ <div v-if="!loading && filteredCards.length === 0" class="d-flex flex-column align-items-center">
270
+ <div class="no-content"><MGIcon icon="eye-slash" /></div>
271
+ <h4>{{ $t("keys.campaigns.no_results") }}</h4>
272
+ </div>
273
+ </div>
274
+
275
+ <!-- Step 2: My requests -->
276
+ <div class="step-inside" v-if="currentStep === 2">
277
+ <div class="header">
278
+ <div class="header-left">
279
+ <button
280
+ class="btn tertiary"
281
+ :class="{ active: redeemedStatusFilter === null }"
282
+ @click="redeemedStatusFilter = null"
283
+ >
284
+ {{ $t("keys.campaigns.all") }}
285
+ </button>
286
+ <button
287
+ class="btn tertiary"
288
+ v-for="status in redeemedStatuses"
289
+ :key="status.id"
290
+ :class="{ active: redeemedStatusFilter === status.id }"
291
+ @click="redeemedStatusFilter = status.id"
292
+ >
293
+ {{ $t(status.name) }}
294
+ </button>
295
+ </div>
296
+ <div class="bar">
297
+ <input
298
+ type="text"
299
+ v-model="searchTerm"
300
+ :placeholder="$t('keys.campaigns.search_placeholder')"
301
+ />
302
+ <button type="button"><MGIcon icon="search" /></button>
303
+ </div>
304
+ </div>
305
+
306
+ <div class="cards mt-5">
307
+ <template v-if="loadingRequests">
308
+ <div class="card skeleton" v-for="n in 3" :key="n"></div>
309
+ </template>
310
+ <template v-else>
311
+ <div class="card" v-for="card in filteredCards" :key="card.id">
312
+ <div class="card-header">
313
+ <nuxt-link :to="`/${locale}/key-campaigns/${card.key.slug}`">
314
+ <div class="img-content">
315
+ <img :src="card.key.cover_src" :alt="card.key.name" />
316
+ </div>
317
+ </nuxt-link>
318
+ </div>
319
+ <div class="text">
320
+ {{ card.key.name }}
321
+ <div class="status">
322
+ <span :style="{ color: card.color }">◉</span>
323
+ {{ $t(card.status) }}
324
+ </div>
325
+ </div>
326
+ </div>
327
+ </template>
328
+ </div>
329
+
330
+ <div v-if="!loadingRequests && filteredCards.length === 0" class="d-flex flex-column align-items-center">
331
+ <div class="no-content"><MGIcon icon="eye-slash" /></div>
332
+ <h4>{{ $t("keys.campaigns.no_results") }}</h4>
333
+ </div>
334
+ </div>
335
+ </div>
336
+ </div>
337
+ </div>
338
+ </div>
339
+ </template>
340
+
341
+ <script setup lang="ts">
342
+ import { ref, computed, onMounted, onUnmounted, watch, inject } from "vue"
343
+ import { useNuxtApp, navigateTo } from "#app"
344
+ import { useCampaignsStore } from "../../stores/campaigns"
345
+ import { fetchMyRequests, fetchRedeemedUsers } from "../../services/campaignService"
346
+
347
+ // Injected by each consuming front — provide these in app.vue / a plugin
348
+ const mgPlatform = inject<string>("mgPlatform", "MGTV")
349
+ const loginUrl = inject<string>("loginUrl", "/")
350
+ const upgradeRoute = inject<string>("upgradeRoute", "/")
351
+
352
+ const { $i18n } = useNuxtApp()
353
+ const locale = $i18n.locale
354
+
355
+ const campaignsStore = useCampaignsStore()
356
+
357
+ const currentStep = ref(1)
358
+ const searchTerm = ref("")
359
+ const currentSlide = ref(0)
360
+ const slider = ref<HTMLElement | null>(null)
361
+ let slideInterval: ReturnType<typeof setInterval> | null = null
362
+
363
+ const loading = ref(true)
364
+ const loadingFeatured = ref(true)
365
+ const loadingRequests = ref(false)
366
+
367
+ // ------------------------------------------------------------------
368
+ // Filters
369
+ // ------------------------------------------------------------------
370
+ const campaignTypes = ref<Array<{ id: number; name: string; localized_name?: string }>>([])
371
+ const platformList = ref<Array<{ id: number; name: string; slug: string }>>([])
372
+ const activeFilter = ref<number | null>(null)
373
+ const activePlatformFilter = ref<number | null>(null)
374
+ const redeemedStatusFilter = ref<number | null>(null)
375
+
376
+ // ------------------------------------------------------------------
377
+ // Data
378
+ // ------------------------------------------------------------------
379
+ const sliderImages = ref<Array<{ slug: string; cover: string; name: string; id: number }>>([])
380
+ const cards = ref<Array<any>>([])
381
+ const redeemedCards = ref<Array<any>>([])
382
+
383
+ const currentPage = ref(1)
384
+ const perPage = ref(15)
385
+ const hasMoreItems = ref(true)
386
+ const loadingMore = ref(false)
387
+
388
+ // ------------------------------------------------------------------
389
+ // Platform abbr map
390
+ // ------------------------------------------------------------------
391
+ const PLATFORM_ABBR: Record<string, string> = {
392
+ "playstation 5": "PS5",
393
+ "playstation 4": "PS4",
394
+ "playstation 3": "PS3",
395
+ "xbox series x": "Series X",
396
+ "xbox series s": "Series S",
397
+ "xbox series x/s": "Series X/S",
398
+ "xbox one": "Xbox One",
399
+ "xbox 360": "Xbox 360",
400
+ "nintendo switch": "Switch",
401
+ "nintendo switch 2": "Switch 2",
402
+ "epic games store": "Epic",
403
+ "epic games": "Epic",
404
+ "google stadia": "Stadia",
405
+ "amazon luna": "Luna",
406
+ "oculus quest": "Quest",
407
+ "meta quest": "Quest",
408
+ steam: "Steam",
409
+ pc: "PC",
410
+ mac: "Mac",
411
+ macos: "Mac",
412
+ linux: "Linux",
413
+ ios: "iOS",
414
+ android: "Android",
415
+ gog: "GOG",
416
+ }
417
+
418
+ function platformAbbr(name: string): string {
419
+ return PLATFORM_ABBR[name.toLowerCase()] ?? name
420
+ }
421
+
422
+ function cardTierLabel(tier: string): string {
423
+ const labels: Record<string, string> = { free: "Free", pro: "PRO", business: "Business", official: "Official" }
424
+ return $i18n.t("keys.campaigns.restriction.tier_locked", { tier: labels[tier] || tier })
425
+ }
426
+
427
+ function formatCountdown(availableAt: string | null): string {
428
+ if (!availableAt) return ""
429
+ const diff = new Date(availableAt).getTime() - Date.now()
430
+ if (diff <= 0) return ""
431
+ const h = Math.floor(diff / 3600000)
432
+ const m = Math.floor((diff % 3600000) / 60000)
433
+ if (h >= 24) {
434
+ const d = Math.floor(h / 24)
435
+ return `${d}d ${h % 24}h`
436
+ }
437
+ return h > 0 ? `${h}h ${m}m` : `${m}m`
438
+ }
439
+
440
+ // ------------------------------------------------------------------
441
+ // Auth-guard helper
442
+ // ------------------------------------------------------------------
443
+ function handleRedeemClick(slug: string) {
444
+ // Check for injected user — consuming front should provide 'currentUser' or
445
+ // the component can react on its own. We just navigate to the detail page;
446
+ // the detail page handles auth gating itself.
447
+ navigateTo(`/${locale.value}/key-campaigns/${slug}`)
448
+ }
449
+
450
+ async function shareCard(slug: string) {
451
+ if (!import.meta.client) return
452
+ const url = `${window.location.origin}/${locale.value}/key-campaigns/${slug}`
453
+ if (navigator.share) {
454
+ try { await navigator.share({ url }) } catch { /* user dismissed */ }
455
+ } else {
456
+ await navigator.clipboard.writeText(url)
457
+ }
458
+ }
459
+
460
+ // ------------------------------------------------------------------
461
+ // Status i18n map for "My Requests" tab
462
+ // ------------------------------------------------------------------
463
+ const statusI18nMap: Record<string, string> = {
464
+ pending: "keys.campaigns.status.pending",
465
+ "under-review": "keys.campaigns.status.under_review",
466
+ approved: "keys.campaigns.status.approved",
467
+ "waiting-material": "keys.campaigns.status.waiting_material",
468
+ completed: "keys.campaigns.status.completed",
469
+ refused: "keys.campaigns.status.refused",
470
+ cancelled: "keys.campaigns.status.cancelled",
471
+ expired: "keys.campaigns.status.expired",
472
+ revoked: "keys.campaigns.status.revoked",
473
+ }
474
+
475
+ // ------------------------------------------------------------------
476
+ // Fetch helpers
477
+ // ------------------------------------------------------------------
478
+ async function loadFeaturedCampaigns() {
479
+ loadingFeatured.value = true
480
+ try {
481
+ const response = await campaignsStore.fetchFeaturedCampaigns(mgPlatform)
482
+ sliderImages.value = (response.data || []).map((item: any) => ({
483
+ id: item.id,
484
+ cover: item.game?.url_banner_src || item.cover_src,
485
+ name: item.name,
486
+ slug: item.slug,
487
+ }))
488
+ } catch {
489
+ // silent
490
+ } finally {
491
+ loadingFeatured.value = false
492
+ }
493
+ }
494
+
495
+ async function loadCampaigns(page = 1) {
496
+ if (page === 1) {
497
+ loading.value = true
498
+ cards.value = []
499
+ } else {
500
+ loadingMore.value = true
501
+ }
502
+
503
+ const params: Record<string, any> = {
504
+ page,
505
+ per_page: perPage.value,
506
+ sort: "id",
507
+ order: "desc",
508
+ }
509
+
510
+ if (activeFilter.value !== null) params["filter[type_id]"] = activeFilter.value
511
+ if (activePlatformFilter.value !== null) params["filter[platform_id]"] = activePlatformFilter.value
512
+
513
+ try {
514
+ const response = await campaignsStore.fetchCampaigns(params, mgPlatform)
515
+ const keysData = response.data || []
516
+ const pagination = response.meta?.pagination || {}
517
+
518
+ hasMoreItems.value = !!pagination.links?.next
519
+
520
+ const newItems = keysData.map((item: any) => {
521
+ const endDate = item.end_date ? new Date(item.end_date) : null
522
+ const startDate = item.start_date ? new Date(item.start_date) : null
523
+ const isExpired = endDate ? endDate.getTime() < Date.now() : false
524
+ const isUpcoming = startDate ? startDate.getTime() > Date.now() : false
525
+
526
+ return {
527
+ id: item.id,
528
+ type: item.type?.name,
529
+ typeId: item.type?.id,
530
+ cover: item.cover_src || item.game?.url_cover_src || item.game?.url_banner_src || "/imgs/no-cover-img.jpg",
531
+ text: item.name,
532
+ time: item.time_to_expire ? item.time_to_expire.split(":")[0] : "0",
533
+ slug: item.slug,
534
+ available_count: item.available_count ?? 0,
535
+ isExpired,
536
+ isUpcoming,
537
+ is_exclusive: item.is_exclusive || false,
538
+ is_tier_locked: item.is_tier_locked || false,
539
+ streamer_type: item.streamer_type_id || 1,
540
+ early_access_hours: item.early_access_hours || 0,
541
+ access_tier: item.access_tier || "free",
542
+ user_can_access: item.user_can_access !== false,
543
+ available_at_for_user: item.available_at_for_user || null,
544
+ max_keys_for_user: item.max_keys_for_user || 1,
545
+ platforms: (item.platforms || []).map((p: any) => p.name),
546
+ gameName: item.game?.name || null,
547
+ userRequestSlug: null,
548
+ }
549
+ })
550
+
551
+ if (page === 1) {
552
+ cards.value = newItems
553
+ } else {
554
+ cards.value = [...cards.value, ...newItems]
555
+ }
556
+
557
+ currentPage.value = page
558
+ applyUserRequestStatuses()
559
+ } catch {
560
+ hasMoreItems.value = false
561
+ } finally {
562
+ loading.value = false
563
+ loadingMore.value = false
564
+ }
565
+ }
566
+
567
+ function applyUserRequestStatuses() {
568
+ if (!redeemedCards.value.length || !cards.value.length) return
569
+ const bySlug = new Map(redeemedCards.value.map((r: any) => [r.key.slug, r.statusSlug]))
570
+ cards.value = cards.value.map((card: any) => ({
571
+ ...card,
572
+ userRequestSlug: bySlug.get(card.slug) ?? null,
573
+ }))
574
+ }
575
+
576
+ async function loadMyRequests() {
577
+ loadingRequests.value = true
578
+ try {
579
+ const response = await fetchMyRequests({ page: 1, per_page: 30, sort: "id", order: "asc" })
580
+ const keysData = response?.data?.data || []
581
+ redeemedCards.value = keysData.map((item: any) => ({
582
+ id: item.id,
583
+ statusId: item.status_request.id,
584
+ statusSlug: item.status_request.slug,
585
+ status: statusI18nMap[item.status_request.slug] ?? item.status_request.name,
586
+ color: item.status_request.color,
587
+ key: {
588
+ ...item.key,
589
+ cover_src: item.key.cover_src,
590
+ slug: item.key.slug,
591
+ name: item.key.name,
592
+ },
593
+ }))
594
+ applyUserRequestStatuses()
595
+ } catch {
596
+ // silent
597
+ } finally {
598
+ loadingRequests.value = false
599
+ }
600
+ }
601
+
602
+ async function loadCampaignTypes() {
603
+ try {
604
+ const response = await campaignsStore.fetchCampaignTypes()
605
+ campaignTypes.value = response.data || []
606
+ } catch { /* silent */ }
607
+ }
608
+
609
+ async function loadPlatforms() {
610
+ try {
611
+ await campaignsStore.fetchCampaignPlatforms()
612
+ platformList.value = campaignsStore.allPlatforms
613
+ } catch { /* silent */ }
614
+ }
615
+
616
+ // ------------------------------------------------------------------
617
+ // Filter setters
618
+ // ------------------------------------------------------------------
619
+ function setFilter(typeId: number | null) {
620
+ activeFilter.value = typeId
621
+ currentPage.value = 1
622
+ hasMoreItems.value = true
623
+ loadCampaigns(1)
624
+ }
625
+
626
+ function setPlatformFilter(platformId: number | null) {
627
+ activePlatformFilter.value = platformId
628
+ currentPage.value = 1
629
+ hasMoreItems.value = true
630
+ loadCampaigns(1)
631
+ }
632
+
633
+ const loadMoreItems = async () => {
634
+ if (!hasMoreItems.value || loading.value || loadingMore.value) return
635
+ await loadCampaigns(currentPage.value + 1)
636
+ }
637
+
638
+ // ------------------------------------------------------------------
639
+ // Computed
640
+ // ------------------------------------------------------------------
641
+ const redeemedStatuses = computed(() => {
642
+ const seen = new Map<number, { id: number; name: string; color: string }>()
643
+ for (const card of redeemedCards.value) {
644
+ if (card.statusId && !seen.has(card.statusId)) {
645
+ seen.set(card.statusId, { id: card.statusId, name: card.status, color: card.color })
646
+ }
647
+ }
648
+ return Array.from(seen.values())
649
+ })
650
+
651
+ const filteredCards = computed(() => {
652
+ let source = currentStep.value === 1 ? cards.value : redeemedCards.value
653
+
654
+ if (currentStep.value === 2 && redeemedStatusFilter.value !== null) {
655
+ source = source.filter((c: any) => c.statusId === redeemedStatusFilter.value)
656
+ }
657
+
658
+ if (searchTerm.value) {
659
+ const q = searchTerm.value.toLowerCase()
660
+ return source.filter((card: any) => {
661
+ if (currentStep.value === 1) {
662
+ return (card.text || "").toLowerCase().includes(q) || (card.gameName || "").toLowerCase().includes(q)
663
+ }
664
+ return (card.key?.name || "").toLowerCase().includes(q)
665
+ })
666
+ }
667
+ return source
668
+ })
669
+
670
+ // ------------------------------------------------------------------
671
+ // Slider
672
+ // ------------------------------------------------------------------
673
+ const goToSlide = (index: number) => {
674
+ currentSlide.value = index
675
+ updateSlider()
676
+ resetAutoplay()
677
+ }
678
+
679
+ const nextSlide = () => {
680
+ if (!sliderImages.value.length) return
681
+ currentSlide.value = (currentSlide.value + 1) % sliderImages.value.length
682
+ updateSlider()
683
+ }
684
+
685
+ const updateSlider = () => {
686
+ if (slider.value) {
687
+ slider.value.style.transform = `translateX(-${currentSlide.value * 100}%)`
688
+ }
689
+ }
690
+
691
+ const startAutoplay = () => { slideInterval = setInterval(nextSlide, 8000) }
692
+ const resetAutoplay = () => {
693
+ if (slideInterval) { clearInterval(slideInterval); startAutoplay() }
694
+ }
695
+
696
+ // ------------------------------------------------------------------
697
+ // Step watcher
698
+ // ------------------------------------------------------------------
699
+ watch(currentStep, (newVal) => {
700
+ activeFilter.value = null
701
+ activePlatformFilter.value = null
702
+ redeemedStatusFilter.value = null
703
+ searchTerm.value = ""
704
+
705
+ if (newVal === 1 && cards.value.length === 0) {
706
+ currentPage.value = 1
707
+ hasMoreItems.value = true
708
+ loadCampaigns(1)
709
+ }
710
+ if (newVal === 2 && redeemedCards.value.length === 0) {
711
+ loadMyRequests()
712
+ }
713
+ })
714
+
715
+ // ------------------------------------------------------------------
716
+ // Lifecycle
717
+ // ------------------------------------------------------------------
718
+ onMounted(async () => {
719
+ await loadFeaturedCampaigns()
720
+ await loadCampaigns(1)
721
+ await loadCampaignTypes()
722
+ await loadPlatforms()
723
+ await loadMyRequests()
724
+
725
+ updateSlider()
726
+ startAutoplay()
727
+ })
728
+
729
+ onUnmounted(() => {
730
+ if (slideInterval) clearInterval(slideInterval)
731
+ })
732
+ </script>
733
+
734
+ <style lang="scss" scoped>
735
+ .spinner {
736
+ display: inline-block;
737
+ width: 20px;
738
+ height: 20px;
739
+ border: 2px solid #d297ff;
740
+ border-radius: 50%;
741
+ border-top-color: #fff;
742
+ animation: spin 1s ease-in-out infinite;
743
+ }
744
+
745
+ @keyframes spin {
746
+ to { transform: rotate(360deg); }
747
+ }
748
+
749
+ .card.unavailable {
750
+ filter: grayscale(60%);
751
+ opacity: 0.8;
752
+ }
753
+
754
+ .card.upcoming { opacity: 0.85; }
755
+
756
+ .load-more {
757
+ display: flex;
758
+ width: 100%;
759
+ justify-content: center;
760
+ margin-top: 20px;
761
+
762
+ button[disabled] {
763
+ opacity: 0.7;
764
+ cursor: not-allowed;
765
+ }
766
+ }
767
+
768
+ .img-content.unavailable .gray-out {
769
+ filter: grayscale(100%);
770
+ opacity: 0.5;
771
+ }
772
+
773
+ .keys-badge {
774
+ position: absolute;
775
+ bottom: 8px;
776
+ right: 8px;
777
+ background: rgba(107, 21, 172, 0.9);
778
+ color: #fff;
779
+ font-size: 12px;
780
+ font-weight: 600;
781
+ padding: 4px 8px;
782
+ display: flex;
783
+ align-items: center;
784
+ gap: 4px;
785
+ line-height: 16px;
786
+ }
787
+
788
+ .exclusive-badge {
789
+ position: absolute;
790
+ top: 8px;
791
+ left: 8px;
792
+ background: rgba(234, 179, 8, 0.9);
793
+ color: #000;
794
+ font-size: 10px;
795
+ font-weight: 700;
796
+ padding: 3px 8px;
797
+ display: flex;
798
+ align-items: center;
799
+ gap: 4px;
800
+ text-transform: uppercase;
801
+ letter-spacing: 0.5px;
802
+ i { font-size: 10px; }
803
+ }
804
+
805
+ .level-badge {
806
+ top: auto;
807
+ bottom: 8px;
808
+ background: rgba(210, 151, 255, 0.92);
809
+ color: #1c1c1c;
810
+ }
811
+
812
+ .early-access-badge {
813
+ position: absolute;
814
+ top: 8px;
815
+ left: 8px;
816
+ background: rgba(107, 21, 172, 0.9);
817
+ color: #fff;
818
+ font-size: 10px;
819
+ font-weight: 700;
820
+ padding: 3px 8px;
821
+ display: flex;
822
+ align-items: center;
823
+ gap: 4px;
824
+ text-transform: uppercase;
825
+ letter-spacing: 0.5px;
826
+ i { font-size: 10px; }
827
+ }
828
+
829
+ .exclusive-lock { background: rgba(234, 179, 8, 0.6) !important; }
830
+
831
+ .early-access-lock-overlay,
832
+ .exclusive-lock-overlay {
833
+ position: absolute;
834
+ inset: 0;
835
+ background: rgba(0, 0, 0, 0.72);
836
+ display: flex;
837
+ flex-direction: column;
838
+ align-items: center;
839
+ justify-content: center;
840
+ gap: 6px;
841
+ padding: 12px;
842
+ text-align: center;
843
+
844
+ i { font-size: 24px; color: #D297FF; }
845
+ .lock-label { color: #fff; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; }
846
+ .lock-countdown { color: #D297FF; font-size: 12px; font-weight: 600; }
847
+ .lock-cta { color: #EAB308; font-size: 11px; font-weight: 600; }
848
+ }
849
+
850
+ .exclusive-lock-overlay i { color: #EAB308; }
851
+
852
+ .upgrade-btn {
853
+ display: flex;
854
+ align-items: center;
855
+ gap: 6px;
856
+ i { font-size: 12px; }
857
+ }
858
+
859
+ .card.locked {
860
+ filter: none !important;
861
+ opacity: 1 !important;
862
+ pointer-events: auto !important;
863
+ }
864
+
865
+ .card.skeleton {
866
+ min-height: 300px;
867
+ background: var(--button-secondary-default-bg, #1e1e1e);
868
+ animation: pulse 1.5s ease-in-out infinite;
869
+ }
870
+
871
+ @keyframes pulse {
872
+ 0%, 100% { opacity: 0.6; }
873
+ 50% { opacity: 1; }
874
+ }
875
+
876
+ #shared-campaigns {
877
+ align-items: center;
878
+ width: 100%;
879
+ justify-content: center;
880
+ overflow: auto;
881
+ height: 100%;
882
+ padding-top: 49px;
883
+
884
+ .image-slider {
885
+ width: 100%;
886
+ overflow: hidden;
887
+ margin-bottom: 80px;
888
+ position: relative;
889
+
890
+ .slider-placeholder {
891
+ width: 100%;
892
+ height: 276px;
893
+ background: var(--button-secondary-default-bg, #1e1e1e);
894
+ animation: pulse 1.5s ease-in-out infinite;
895
+ }
896
+
897
+ .slider-container {
898
+ display: flex;
899
+ transition: transform 0.5s ease;
900
+
901
+ .slide {
902
+ min-width: 100%;
903
+ height: 276px;
904
+ box-sizing: border-box;
905
+ display: flex;
906
+ align-items: center;
907
+ justify-content: center;
908
+ overflow: hidden;
909
+ position: relative;
910
+
911
+ a { width: 100%; }
912
+
913
+ img {
914
+ width: 100%;
915
+ height: auto;
916
+ max-height: 100%;
917
+ object-fit: cover;
918
+ }
919
+
920
+ .texts {
921
+ position: absolute;
922
+ left: 24px;
923
+ top: 24px;
924
+ color: #fff;
925
+ font-size: 16px;
926
+ font-weight: 600;
927
+ z-index: 2;
928
+ }
929
+
930
+ button {
931
+ position: absolute;
932
+ left: 24px;
933
+ bottom: 24px;
934
+ }
935
+ }
936
+ }
937
+
938
+ .slider-dots {
939
+ position: absolute;
940
+ bottom: 10px;
941
+ left: 0;
942
+ right: 0;
943
+ display: flex;
944
+ justify-content: center;
945
+ gap: 5px;
946
+
947
+ span {
948
+ width: 10px;
949
+ height: 10px;
950
+ background: #ccc;
951
+ cursor: pointer;
952
+ }
953
+
954
+ span.active { background: #6b15ac; }
955
+ }
956
+ }
957
+
958
+ .container { max-width: 1140px !important; }
959
+
960
+ .stepper-content {
961
+ width: 100%;
962
+ display: flex;
963
+ flex-direction: column;
964
+ align-items: flex-start;
965
+ gap: 24px;
966
+
967
+ .step-inside {
968
+ width: 100%;
969
+
970
+ h4 { color: var(--inactive); }
971
+
972
+ .no-content {
973
+ display: flex;
974
+ align-items: center;
975
+ justify-content: center;
976
+ height: 80px;
977
+ width: 80px;
978
+ margin-bottom: 1rem;
979
+ border-radius: 100%;
980
+ background: var(--body-bg-card);
981
+ color: var(--inactive);
982
+ margin-top: 42px;
983
+ i { font-size: 2rem; }
984
+ }
985
+
986
+ .header {
987
+ width: 100%;
988
+ display: flex;
989
+ padding: 24px 0;
990
+ justify-content: space-between;
991
+
992
+ .header-left {
993
+ display: flex;
994
+ gap: 12px;
995
+ button { height: 44px; }
996
+ }
997
+
998
+ .header-left .btn.tertiary.active {
999
+ background-color: #D297FF;
1000
+ color: #1C1C1C;
1001
+ }
1002
+
1003
+ .platform-select {
1004
+ background: transparent;
1005
+ border: 1px solid var(--secondary-info-fg);
1006
+ color: var(--title-fg);
1007
+ font-size: 0.875rem;
1008
+ padding: 4px 6px;
1009
+ height: 44px;
1010
+ max-width: 140px;
1011
+ cursor: pointer;
1012
+
1013
+ option {
1014
+ background: var(--bg-primary, #1c1c1c);
1015
+ color: var(--title-fg);
1016
+ }
1017
+
1018
+ &:focus { outline: none; border-color: #D297FF; }
1019
+ }
1020
+
1021
+ .filter-divider {
1022
+ display: flex;
1023
+ align-items: center;
1024
+ color: var(--bt-text-inactive, #555);
1025
+ font-size: 1rem;
1026
+ user-select: none;
1027
+ }
1028
+
1029
+ .bar {
1030
+ position: relative;
1031
+ display: flex;
1032
+ width: 339px;
1033
+ border: 1px solid var(--secondary-info-fg);
1034
+ height: 44px;
1035
+
1036
+ input {
1037
+ background-color: transparent;
1038
+ border: none;
1039
+ width: 100%;
1040
+ padding: 0 8px;
1041
+ color: var(--secondary-info-fg);
1042
+ font-size: 12px;
1043
+ &:focus { outline: 0; }
1044
+ }
1045
+
1046
+ button {
1047
+ width: 32px;
1048
+ height: 32px;
1049
+ border: 0;
1050
+ background: transparent;
1051
+ color: var(--search-bar-fg);
1052
+ &:hover { color: var(--search-bar-active-fg); }
1053
+ }
1054
+
1055
+ &:hover, &:focus, &:active { border: 2px solid var(--search-bar-active-fg); }
1056
+ }
1057
+ }
1058
+
1059
+ .cards {
1060
+ display: flex;
1061
+ align-items: flex-start;
1062
+ gap: 24px;
1063
+ height: 100%;
1064
+ flex-flow: wrap;
1065
+ margin-bottom: 80px;
1066
+
1067
+ .card {
1068
+ display: flex;
1069
+ flex-direction: column;
1070
+ flex: 1 0 0;
1071
+ background-color: var(--button-secondary-default-bg);
1072
+ border: 0;
1073
+ border-radius: 0;
1074
+ max-width: 31.9%;
1075
+ min-width: 31.9%;
1076
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
1077
+
1078
+ &:not(.unavailable):hover {
1079
+ transform: translateY(-4px);
1080
+ box-shadow: 0 8px 24px rgba(107, 21, 172, 0.15);
1081
+ }
1082
+
1083
+ .card-header {
1084
+ display: flex;
1085
+ align-items: center;
1086
+ gap: 16px;
1087
+ border: 0;
1088
+ padding: 0;
1089
+ background-color: transparent;
1090
+
1091
+ .title {
1092
+ display: flex;
1093
+ padding: 8px 16px;
1094
+ align-items: center;
1095
+ position: absolute;
1096
+ background: rgba(13, 13, 13, 0.9);
1097
+ top: 0;
1098
+ width: 100%;
1099
+ justify-content: space-between;
1100
+
1101
+ .days {
1102
+ font-size: 12px;
1103
+ color: var(--title-fg);
1104
+ display: flex;
1105
+ align-items: center;
1106
+ gap: 4px;
1107
+ span { color: #d297ff; font-size: 16px; font-weight: 600; }
1108
+ }
1109
+
1110
+ span {
1111
+ font-size: 12px;
1112
+ color: var(--secondary-info-fg);
1113
+ display: flex;
1114
+ align-items: center;
1115
+ gap: 4px;
1116
+ }
1117
+ }
1118
+
1119
+ a {
1120
+ width: 100%;
1121
+ .img-content {
1122
+ max-width: 100%;
1123
+ position: relative;
1124
+ max-height: 174px;
1125
+ min-height: 174px;
1126
+ overflow: hidden;
1127
+ img { width: 100%; height: auto; display: flex; }
1128
+ }
1129
+ }
1130
+ }
1131
+
1132
+ .text {
1133
+ color: var(--title-fg);
1134
+ font-size: 16px;
1135
+ font-weight: 600;
1136
+ display: flex;
1137
+ padding: 16px;
1138
+ flex-direction: column;
1139
+ gap: 12px;
1140
+
1141
+ .card-platforms {
1142
+ display: flex;
1143
+ flex-wrap: wrap;
1144
+ gap: 4px;
1145
+ .card-platform-tag {
1146
+ font-size: 11px;
1147
+ font-weight: 600;
1148
+ padding: 3px 7px;
1149
+ border: 1px solid var(--secondary-info-fg);
1150
+ color: var(--secondary-info-fg);
1151
+ text-transform: uppercase;
1152
+ }
1153
+ }
1154
+
1155
+ .buttons {
1156
+ display: flex;
1157
+ justify-content: space-between;
1158
+ align-items: center;
1159
+ width: 100%;
1160
+
1161
+ .btn-share {
1162
+ background: transparent;
1163
+ border: 1px solid var(--secondary-info-fg);
1164
+ color: var(--secondary-info-fg);
1165
+ width: 36px;
1166
+ height: 36px;
1167
+ display: flex;
1168
+ align-items: center;
1169
+ justify-content: center;
1170
+ cursor: pointer;
1171
+ flex-shrink: 0;
1172
+ &:hover { border-color: #D297FF; color: #D297FF; }
1173
+ i { font-size: 14px; }
1174
+ }
1175
+ }
1176
+
1177
+ .unavailable-label {
1178
+ color: #aaa;
1179
+ font-size: 12px;
1180
+ font-weight: 400;
1181
+ }
1182
+ }
1183
+
1184
+ .status {
1185
+ display: flex;
1186
+ align-items: center;
1187
+ gap: 4px;
1188
+ font-size: 12px;
1189
+ color: var(--title-fg);
1190
+ font-weight: 400;
1191
+ span { color: #d297ff; }
1192
+ }
1193
+ }
1194
+ }
1195
+ }
1196
+ }
1197
+
1198
+ @media (max-width: 767px) {
1199
+ .stepper-content .step-inside {
1200
+ .header {
1201
+ flex-direction: column;
1202
+ gap: 12px;
1203
+ .bar { width: 100%; }
1204
+ }
1205
+ .cards {
1206
+ flex-direction: column;
1207
+ .card { max-width: 100%; width: 100%; }
1208
+ }
1209
+ }
1210
+ .image-slider {
1211
+ margin-bottom: 20px;
1212
+ .slider-container .slide { height: auto; }
1213
+ }
1214
+ }
1215
+ }
1216
+ </style>
1217
+
1218
+ <style lang="scss">
1219
+ #shared-campaigns {
1220
+ .stepper {
1221
+ display: flex;
1222
+ flex-direction: column;
1223
+ align-items: center;
1224
+ width: 100%;
1225
+ }
1226
+ .stepper-header { display: flex; width: 100%; }
1227
+ .stepper-step {
1228
+ display: flex;
1229
+ padding: 12px;
1230
+ justify-content: center;
1231
+ align-items: center;
1232
+ gap: 10px;
1233
+ flex: 1 0 0;
1234
+ color: var(--secondary-info-fg);
1235
+ font-size: 16px;
1236
+ cursor: pointer;
1237
+ width: 50%;
1238
+ border-bottom: 1px solid var(--secondary-info-fg);
1239
+ }
1240
+ .stepper-step.active {
1241
+ border-bottom: 2px solid var(--chip-text);
1242
+ color: var(--title-fg);
1243
+ }
1244
+ .stepper-step.complete { color: var(--secondary-info-fg); }
1245
+ }
1246
+ </style>