@codingfactory/socialkit-vue 0.6.0 → 0.7.0

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,1565 @@
1
+ /**
2
+ * Generic gamification store factory for SocialKit-powered frontends.
3
+ */
4
+
5
+ import { defineStore } from 'pinia'
6
+ import { computed, onScopeDispose, ref } from 'vue'
7
+ import type { AxiosInstance } from 'axios'
8
+ import type {
9
+ Achievement,
10
+ AchievementShareCard,
11
+ AchievementUnlockNotification,
12
+ ClaimedReward,
13
+ LeaderboardEntry,
14
+ LeaderboardPeriod,
15
+ Quest,
16
+ QuestFilter,
17
+ QuestObjective,
18
+ Reward,
19
+ RewardTransaction,
20
+ UserStats,
21
+ } from '../types/gamification.js'
22
+ import type { ConnectionStatus } from '../types/realtime.js'
23
+
24
+ export type {
25
+ Achievement,
26
+ AchievementShareCard,
27
+ AchievementUnlockNotification,
28
+ ClaimedReward,
29
+ LeaderboardEntry,
30
+ LeaderboardPeriod,
31
+ Quest,
32
+ QuestFilter,
33
+ QuestObjective,
34
+ Reward,
35
+ RewardTransaction,
36
+ UserStats,
37
+ }
38
+
39
+ export interface GamificationStoreCurrentUser {
40
+ id: string
41
+ tenant_id?: string | null
42
+ tenantId?: string | null
43
+ }
44
+
45
+ export interface GamificationRealtimeChannelLike {
46
+ listen(event: string, callback: (data: unknown) => void): GamificationRealtimeChannelLike
47
+ stopListening(event: string): GamificationRealtimeChannelLike
48
+ }
49
+
50
+ export interface GamificationRealtimeClientLike {
51
+ private(channel: string): GamificationRealtimeChannelLike
52
+ leave(channel: string): void
53
+ }
54
+
55
+ export interface GamificationStoreLogger {
56
+ debug: (...args: unknown[]) => void
57
+ error: (...args: unknown[]) => void
58
+ }
59
+
60
+ export interface GamificationStoreConfig {
61
+ client: AxiosInstance
62
+ getCurrentUser: () => GamificationStoreCurrentUser | null
63
+ getEcho: () => GamificationRealtimeClientLike | null
64
+ onConnectionStatusChange: (callback: (status: ConnectionStatus) => void) => (() => void) | void
65
+ logger?: GamificationStoreLogger
66
+ debug?: boolean
67
+ storeId?: string
68
+ }
69
+
70
+ interface LeaderboardQueryContext {
71
+ period: LeaderboardPeriod
72
+ limit: number
73
+ }
74
+
75
+ interface BackendRewardRecord {
76
+ id: string
77
+ name: string
78
+ description: string
79
+ reward_type: BackendRewardType
80
+ cost_points: number
81
+ reward_data?: unknown
82
+ stock_quantity?: number | null
83
+ max_per_user?: number
84
+ can_redeem?: boolean
85
+ user_points?: number
86
+ points_needed?: number
87
+ user_redemptions?: number
88
+ stock_remaining?: number | null
89
+ availability_status?: Reward['availability_status']
90
+ }
91
+
92
+ interface BackendAchievementRecord {
93
+ id: string
94
+ name: string
95
+ description: string
96
+ icon: string
97
+ rarity?: unknown
98
+ is_unlocked?: boolean
99
+ is_claimed?: boolean
100
+ can_claim?: boolean
101
+ unlocked_at?: string | null
102
+ claimed_at?: string | null
103
+ progress_percentage?: number
104
+ progress_current?: number
105
+ progress_target?: number
106
+ reward_data?: unknown
107
+ share_card?: AchievementShareCard
108
+ }
109
+
110
+ interface BackendQuestRecord {
111
+ id: string
112
+ name?: string
113
+ title?: string
114
+ description: string
115
+ quest_type?: string
116
+ type?: string
117
+ objectives: QuestObjective[]
118
+ rewards?: Quest['rewards']
119
+ reward_points: number
120
+ reward_data?: unknown
121
+ duration_hours?: number
122
+ max_completions?: number
123
+ status?: Quest['status']
124
+ is_active?: boolean
125
+ is_accepted?: boolean
126
+ starts_at?: string
127
+ ends_at?: string | null
128
+ expires_at?: string | null
129
+ completed_at?: string | null
130
+ accepted_at?: string | null
131
+ progress_percentage?: number
132
+ created_at?: string
133
+ updated_at?: string
134
+ progress?: Quest['progress']
135
+ category?: string
136
+ }
137
+
138
+ interface CompleteQuestResponsePayload {
139
+ quest_id: string
140
+ completed_at: string
141
+ points_awarded: number
142
+ }
143
+
144
+ interface AchievementUnlockedEvent {
145
+ achievement_id: string
146
+ unlocked_at: string
147
+ achievement?: {
148
+ id?: string
149
+ name?: string
150
+ description?: string
151
+ icon?: string
152
+ reward_points?: number
153
+ }
154
+ user_stats?: UserStats
155
+ }
156
+
157
+ interface QuestCompletedEvent {
158
+ quest_id: string
159
+ completed_at: string
160
+ user_stats?: UserStats
161
+ }
162
+
163
+ interface QuestAcceptedEvent {
164
+ userId: string
165
+ quest: {
166
+ id: string
167
+ name: string
168
+ description: string
169
+ quest_type: string
170
+ reward_points: number
171
+ }
172
+ message: string
173
+ timestamp: string
174
+ }
175
+
176
+ interface QuestProgressUpdatedEvent {
177
+ userId: string
178
+ questId: string
179
+ progress: Record<string, unknown>
180
+ timestamp: string
181
+ }
182
+
183
+ interface LevelUpEvent {
184
+ new_level: number
185
+ level_details: {
186
+ level_number: number
187
+ name: string
188
+ badge_color: string
189
+ badge_icon: string
190
+ benefits: string[]
191
+ min_points?: number
192
+ }
193
+ next_level_details: {
194
+ level_number: number
195
+ name: string
196
+ badge_color: string
197
+ badge_icon: string
198
+ benefits: string[]
199
+ min_points?: number
200
+ } | null
201
+ }
202
+
203
+ interface PointsAwardedEvent {
204
+ points: number
205
+ total_points?: number
206
+ lifetime_points?: number
207
+ level_progress?: number
208
+ points_this_week?: number
209
+ points_this_month?: number
210
+ }
211
+
212
+ interface RewardClaimedEvent {
213
+ reward_id?: string
214
+ reward?: Reward
215
+ claimed_reward?: ClaimedReward
216
+ user_stats?: UserStats
217
+ }
218
+
219
+ interface LeaderboardPositionChangedEvent {
220
+ userId: string
221
+ newRank: number
222
+ totalPoints: number
223
+ previousRank: number
224
+ rankImprovement: number
225
+ timestamp: string
226
+ }
227
+
228
+ type BackendRewardType = 'badge' | 'title' | 'cosmetic' | 'boost' | 'feature'
229
+
230
+ const QUEST_FILTER_VALUES: readonly QuestFilter[] = ['active', 'daily', 'weekly', 'completed'] as const
231
+
232
+ function isAxiosError(error: unknown): error is { response?: { status?: number; data?: { message?: string } } } {
233
+ return error !== null
234
+ && typeof error === 'object'
235
+ && 'response' in error
236
+ }
237
+
238
+ function isQuestFilter(value: string): value is QuestFilter {
239
+ return QUEST_FILTER_VALUES.includes(value as QuestFilter)
240
+ }
241
+
242
+ function createQuestCompletionIdempotencyKey(questId: string): string {
243
+ const randomPart = typeof globalThis.crypto?.randomUUID === 'function'
244
+ ? globalThis.crypto.randomUUID()
245
+ : Math.random().toString(36).slice(2)
246
+
247
+ return `quest-complete:${questId}:${randomPart}`
248
+ }
249
+
250
+ function isAchievementRarity(value: unknown): value is NonNullable<Achievement['rarity']> {
251
+ return value === 'common'
252
+ || value === 'uncommon'
253
+ || value === 'rare'
254
+ || value === 'epic'
255
+ || value === 'legendary'
256
+ }
257
+
258
+ function toRewardDataRecord(value: unknown): Record<string, unknown> {
259
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
260
+ return {}
261
+ }
262
+
263
+ return value as Record<string, unknown>
264
+ }
265
+
266
+ function normalizeAchievementRewardData(value: unknown): Achievement['reward_data'] | undefined {
267
+ const rewardData = toRewardDataRecord(value)
268
+ const normalized: NonNullable<Achievement['reward_data']> = {}
269
+
270
+ if (typeof rewardData.badge_color === 'string') {
271
+ normalized.badge_color = rewardData.badge_color
272
+ }
273
+ if (typeof rewardData.badge_text === 'string') {
274
+ normalized.badge_text = rewardData.badge_text
275
+ }
276
+ if (Array.isArray(rewardData.special_privileges)) {
277
+ normalized.special_privileges = rewardData.special_privileges.filter(
278
+ (entry): entry is string => typeof entry === 'string'
279
+ )
280
+ }
281
+ if (typeof rewardData.unlocks_feature === 'string') {
282
+ normalized.unlocks_feature = rewardData.unlocks_feature
283
+ }
284
+
285
+ return Object.keys(normalized).length > 0 ? normalized : undefined
286
+ }
287
+
288
+ function resolveAchievementRarity(achievement: BackendAchievementRecord): NonNullable<Achievement['rarity']> {
289
+ if (isAchievementRarity(achievement.rarity)) {
290
+ return achievement.rarity
291
+ }
292
+
293
+ const rewardData = toRewardDataRecord(achievement.reward_data)
294
+ const badgeRarity = rewardData.badge_rarity
295
+ if (isAchievementRarity(badgeRarity)) {
296
+ return badgeRarity
297
+ }
298
+
299
+ return 'common'
300
+ }
301
+
302
+ function isBackendRewardType(value: unknown): value is BackendRewardType {
303
+ return value === 'badge'
304
+ || value === 'title'
305
+ || value === 'cosmetic'
306
+ || value === 'boost'
307
+ || value === 'feature'
308
+ }
309
+
310
+ function isBackendRewardRecord(value: unknown): value is BackendRewardRecord {
311
+ if (value === null || typeof value !== 'object') {
312
+ return false
313
+ }
314
+
315
+ const candidate = value as Record<string, unknown>
316
+
317
+ return typeof candidate.id === 'string'
318
+ && typeof candidate.name === 'string'
319
+ && typeof candidate.description === 'string'
320
+ && typeof candidate.cost_points === 'number'
321
+ && isBackendRewardType(candidate.reward_type)
322
+ }
323
+
324
+ function extractBackendRewardRecords(value: unknown): BackendRewardRecord[] {
325
+ if (value === null || typeof value !== 'object') {
326
+ return []
327
+ }
328
+
329
+ const payload = value as { data?: unknown }
330
+ if (!Array.isArray(payload.data)) {
331
+ return []
332
+ }
333
+
334
+ return payload.data.filter(isBackendRewardRecord)
335
+ }
336
+
337
+ function mapRewardType(rewardType: BackendRewardType): NonNullable<Reward['type']> {
338
+ switch (rewardType) {
339
+ case 'badge':
340
+ return 'item'
341
+ case 'cosmetic':
342
+ return 'cosmetic'
343
+ case 'boost':
344
+ return 'boost'
345
+ case 'title':
346
+ case 'feature':
347
+ return 'privilege'
348
+ }
349
+ }
350
+
351
+ function mapRewardCategory(rewardType: BackendRewardType): NonNullable<Reward['category']> {
352
+ switch (rewardType) {
353
+ case 'badge':
354
+ return 'badge'
355
+ case 'cosmetic':
356
+ return 'cosmetic'
357
+ case 'title':
358
+ case 'feature':
359
+ return 'privilege'
360
+ case 'boost':
361
+ return 'bonus'
362
+ }
363
+ }
364
+
365
+ function mapRewardIcon(rewardType: BackendRewardType, rewardData: Record<string, unknown>): string {
366
+ const preferredIcon = rewardData.badge_icon ?? rewardData.icon
367
+ if (typeof preferredIcon === 'string' && preferredIcon.length > 0) {
368
+ return preferredIcon
369
+ }
370
+
371
+ switch (rewardType) {
372
+ case 'badge':
373
+ return 'badge'
374
+ case 'cosmetic':
375
+ return 'palette'
376
+ case 'boost':
377
+ return 'flash'
378
+ case 'title':
379
+ case 'feature':
380
+ return 'settings'
381
+ }
382
+ }
383
+
384
+ function mapRewardPreviewImage(rewardData: Record<string, unknown>): string | undefined {
385
+ const previewUrl = rewardData.preview_url
386
+ if (typeof previewUrl === 'string' && previewUrl.length > 0) {
387
+ return previewUrl
388
+ }
389
+
390
+ return undefined
391
+ }
392
+
393
+ function isBackendAchievementRecord(value: unknown): value is BackendAchievementRecord {
394
+ if (value === null || typeof value !== 'object') {
395
+ return false
396
+ }
397
+
398
+ const candidate = value as Record<string, unknown>
399
+
400
+ return typeof candidate.id === 'string'
401
+ && typeof candidate.name === 'string'
402
+ && typeof candidate.description === 'string'
403
+ && typeof candidate.icon === 'string'
404
+ }
405
+
406
+ function isAchievementShareCard(value: unknown): value is AchievementShareCard {
407
+ if (value === null || typeof value !== 'object') {
408
+ return false
409
+ }
410
+
411
+ const candidate = value as Record<string, unknown>
412
+
413
+ return typeof candidate.title === 'string'
414
+ && typeof candidate.badge_label === 'string'
415
+ && typeof candidate.description === 'string'
416
+ && typeof candidate.image_url === 'string'
417
+ && typeof candidate.share_url === 'string'
418
+ && typeof candidate.share_text === 'string'
419
+ }
420
+
421
+ function toSameOriginRequestPath(sourceUrl: string): string {
422
+ if (sourceUrl.startsWith('/')) {
423
+ return sourceUrl
424
+ }
425
+
426
+ if (typeof window === 'undefined') {
427
+ return sourceUrl
428
+ }
429
+
430
+ try {
431
+ const parsedUrl = new URL(sourceUrl, window.location.origin)
432
+ const requestPath = `${parsedUrl.pathname}${parsedUrl.search}`
433
+ if (requestPath.startsWith('/api/')) {
434
+ return requestPath.slice(4)
435
+ }
436
+
437
+ return requestPath
438
+ } catch {
439
+ return sourceUrl
440
+ }
441
+ }
442
+
443
+ function normalizeAchievement(achievement: BackendAchievementRecord): Achievement {
444
+ const unlockedAt = achievement.unlocked_at ?? null
445
+ const claimedAt = achievement.claimed_at ?? unlockedAt
446
+ const isUnlocked = achievement.is_unlocked === true || unlockedAt !== null
447
+ const progressPercentage = typeof achievement.progress_percentage === 'number'
448
+ ? achievement.progress_percentage
449
+ : 0
450
+ const canClaim = achievement.can_claim === true || (!isUnlocked && progressPercentage >= 100)
451
+ const rarity = resolveAchievementRarity(achievement)
452
+ const rewardData = normalizeAchievementRewardData(achievement.reward_data)
453
+
454
+ const normalized: Achievement = {
455
+ id: achievement.id,
456
+ name: achievement.name,
457
+ description: achievement.description,
458
+ icon: achievement.icon,
459
+ rarity,
460
+ is_unlocked: isUnlocked,
461
+ is_claimed: achievement.is_claimed ?? isUnlocked,
462
+ can_claim: canClaim,
463
+ unlocked_at: unlockedAt,
464
+ claimed_at: claimedAt,
465
+ }
466
+
467
+ if (typeof achievement.progress_percentage === 'number') {
468
+ normalized.progress_percentage = achievement.progress_percentage
469
+ }
470
+ if (typeof achievement.progress_current === 'number') {
471
+ normalized.progress_current = achievement.progress_current
472
+ }
473
+ if (typeof achievement.progress_target === 'number') {
474
+ normalized.progress_target = achievement.progress_target
475
+ }
476
+ if (rewardData) {
477
+ normalized.reward_data = rewardData
478
+ }
479
+
480
+ if (isAchievementShareCard(achievement.share_card)) {
481
+ normalized.share_card = achievement.share_card
482
+ }
483
+
484
+ return normalized
485
+ }
486
+
487
+ function normalizeReward(reward: BackendRewardRecord): Reward {
488
+ const rewardData = toRewardDataRecord(reward.reward_data)
489
+ const maxPerUser = reward.max_per_user ?? 0
490
+ const userRedemptions = reward.user_redemptions ?? 0
491
+ const stockQuantity = reward.stock_quantity ?? null
492
+ const stockRemaining = reward.stock_remaining ?? null
493
+ const previewImage = mapRewardPreviewImage(rewardData)
494
+
495
+ const normalizedReward: Reward = {
496
+ id: reward.id,
497
+ name: reward.name,
498
+ description: reward.description,
499
+ cost_points: reward.cost_points,
500
+ reward_type: reward.reward_type,
501
+ type: mapRewardType(reward.reward_type),
502
+ category: mapRewardCategory(reward.reward_type),
503
+ icon: mapRewardIcon(reward.reward_type, rewardData),
504
+ reward_data: rewardData,
505
+ stock_quantity: stockQuantity,
506
+ stock_remaining: stockRemaining,
507
+ max_per_user: maxPerUser,
508
+ can_redeem: reward.can_redeem ?? false,
509
+ user_points: reward.user_points ?? 0,
510
+ points_needed: reward.points_needed ?? 0,
511
+ user_redemptions: userRedemptions,
512
+ availability_status: reward.availability_status ?? 'available',
513
+ limited_quantity: stockQuantity,
514
+ remaining_quantity: stockRemaining,
515
+ is_available: reward.can_redeem ?? false,
516
+ is_claimed: maxPerUser > 0 && userRedemptions >= maxPerUser,
517
+ }
518
+
519
+ if (previewImage !== undefined) {
520
+ normalizedReward.preview_image = previewImage
521
+ }
522
+
523
+ return normalizedReward
524
+ }
525
+
526
+ function normalizeQuest(raw: BackendQuestRecord): Quest {
527
+ const questType = (raw.quest_type ?? raw.type ?? 'daily') as Quest['type']
528
+ const title = raw.title ?? raw.name ?? 'Untitled Quest'
529
+
530
+ let acceptedAt = raw.accepted_at ?? null
531
+ if (acceptedAt === null && raw.is_accepted === true) {
532
+ acceptedAt = new Date().toISOString()
533
+ }
534
+
535
+ const quest: Quest = {
536
+ id: raw.id,
537
+ title,
538
+ description: raw.description,
539
+ type: questType,
540
+ objectives: raw.objectives,
541
+ reward_points: raw.reward_points,
542
+ expires_at: raw.expires_at ?? null,
543
+ completed_at: raw.completed_at ?? null,
544
+ accepted_at: acceptedAt,
545
+ }
546
+
547
+ if (raw.name !== undefined) {
548
+ quest.name = raw.name
549
+ }
550
+ if (raw.rewards !== undefined) {
551
+ quest.rewards = raw.rewards
552
+ }
553
+ if (raw.status !== undefined) {
554
+ quest.status = raw.status
555
+ }
556
+ if (raw.is_active !== undefined) {
557
+ quest.is_active = raw.is_active
558
+ }
559
+ if (raw.starts_at !== undefined) {
560
+ quest.starts_at = raw.starts_at
561
+ }
562
+ if (raw.progress !== undefined) {
563
+ quest.progress = raw.progress
564
+ }
565
+ if (raw.progress_percentage !== undefined) {
566
+ quest.progress_percentage = raw.progress_percentage
567
+ }
568
+ if (raw.category !== undefined) {
569
+ quest.category = raw.category
570
+ }
571
+
572
+ return quest
573
+ }
574
+
575
+ export type GamificationStoreReturn = ReturnType<ReturnType<typeof createGamificationStoreDefinition>>
576
+
577
+ export function createGamificationStoreDefinition(config: GamificationStoreConfig) {
578
+ const {
579
+ client,
580
+ getCurrentUser,
581
+ getEcho,
582
+ onConnectionStatusChange,
583
+ logger = console,
584
+ debug = false,
585
+ storeId = 'gamification',
586
+ } = config
587
+
588
+ const debugLog = (message: string, ...args: unknown[]): void => {
589
+ if (!debug) {
590
+ return
591
+ }
592
+
593
+ logger.debug(message, ...args)
594
+ }
595
+
596
+ return defineStore(storeId, () => {
597
+ const achievements = ref<Achievement[]>([])
598
+ const quests = ref<Quest[]>([])
599
+ const dailyQuests = ref<Quest[]>([])
600
+ const completedQuestsData = ref<Quest[]>([])
601
+ const questFilter = ref<QuestFilter>('active')
602
+ const rewards = ref<Reward[]>([])
603
+ const claimedRewards = ref<ClaimedReward[]>([])
604
+ const rewardTransactions = ref<RewardTransaction[]>([])
605
+ const leaderboard = ref<LeaderboardEntry[]>([])
606
+ const activeLeaderboardContext = ref<LeaderboardQueryContext>({
607
+ period: 'all-time',
608
+ limit: 50,
609
+ })
610
+ const userStats = ref<UserStats | null>(null)
611
+ const lastAchievementUnlockedEvent = ref<AchievementUnlockNotification | null>(null)
612
+
613
+ const loadingAchievements = ref(false)
614
+ const loadingQuests = ref(false)
615
+ const loadingRewards = ref(false)
616
+ const loadingLeaderboard = ref(false)
617
+ const loadingStats = ref(false)
618
+ const claiming = ref(false)
619
+
620
+ const lastAchievementsUpdate = ref<Date | null>(null)
621
+ const lastQuestsUpdate = ref<Date | null>(null)
622
+ const lastRewardsUpdate = ref<Date | null>(null)
623
+ const lastLeaderboardUpdate = ref<Date | null>(null)
624
+ const lastStatsUpdate = ref<Date | null>(null)
625
+
626
+ const ACHIEVEMENTS_CACHE_TTL = 5 * 60 * 1000
627
+ const QUESTS_CACHE_TTL = 2 * 60 * 1000
628
+ const REWARDS_CACHE_TTL = 10 * 60 * 1000
629
+ const LEADERBOARD_CACHE_TTL = 5 * 60 * 1000
630
+ const STATS_CACHE_TTL = 2 * 60 * 1000
631
+
632
+ const isAchievementsStale = computed(() => {
633
+ if (!lastAchievementsUpdate.value) {
634
+ return true
635
+ }
636
+ return Date.now() - lastAchievementsUpdate.value.getTime() > ACHIEVEMENTS_CACHE_TTL
637
+ })
638
+
639
+ const isQuestsStale = computed(() => {
640
+ if (!lastQuestsUpdate.value) {
641
+ return true
642
+ }
643
+ return Date.now() - lastQuestsUpdate.value.getTime() > QUESTS_CACHE_TTL
644
+ })
645
+
646
+ const isRewardsStale = computed(() => {
647
+ if (!lastRewardsUpdate.value) {
648
+ return true
649
+ }
650
+ return Date.now() - lastRewardsUpdate.value.getTime() > REWARDS_CACHE_TTL
651
+ })
652
+
653
+ const isLeaderboardStale = computed(() => {
654
+ if (!lastLeaderboardUpdate.value) {
655
+ return true
656
+ }
657
+ return Date.now() - lastLeaderboardUpdate.value.getTime() > LEADERBOARD_CACHE_TTL
658
+ })
659
+
660
+ const isStatsStale = computed(() => {
661
+ if (!lastStatsUpdate.value) {
662
+ return true
663
+ }
664
+ return Date.now() - lastStatsUpdate.value.getTime() > STATS_CACHE_TTL
665
+ })
666
+
667
+ const unlockedAchievements = computed(() => (
668
+ achievements.value.filter((achievement) => achievement.unlocked_at !== null && achievement.unlocked_at !== undefined)
669
+ ))
670
+
671
+ const availableAchievements = computed(() => (
672
+ achievements.value.filter((achievement) => (
673
+ (achievement.unlocked_at === null || achievement.unlocked_at === undefined) && achievement.is_visible
674
+ ))
675
+ ))
676
+
677
+ const activeQuests = computed(() => quests.value.filter((quest) => !quest.completed_at && quest.accepted_at))
678
+ const completedQuests = computed(() => completedQuestsData.value)
679
+ const rewardBalance = computed(() => userStats.value?.total_points || 0)
680
+ const currentXP = computed(() => userStats.value?.lifetime_points || 0)
681
+ const currentLevel = computed(() => userStats.value?.current_level || 1)
682
+ const nextLevel = computed(() => userStats.value?.next_level)
683
+ const levelProgress = computed(() => userStats.value?.level_progress || 0)
684
+ const recentXPGain = computed(() => userStats.value?.points_this_week || 0)
685
+
686
+ function setQuestFilter(nextFilter: string): void {
687
+ if (!isQuestFilter(nextFilter)) {
688
+ return
689
+ }
690
+
691
+ questFilter.value = nextFilter
692
+ }
693
+
694
+ async function loadAchievements(useCache = true): Promise<Achievement[]> {
695
+ if (loadingAchievements.value) {
696
+ return achievements.value
697
+ }
698
+
699
+ if (useCache && !isAchievementsStale.value && achievements.value.length > 0) {
700
+ return achievements.value
701
+ }
702
+
703
+ loadingAchievements.value = true
704
+
705
+ try {
706
+ const response = await client.get('/v1/gamification/achievements')
707
+ const responseData = response.data as { data?: unknown }
708
+ const records = Array.isArray(responseData.data)
709
+ ? responseData.data.filter(isBackendAchievementRecord)
710
+ : []
711
+ achievements.value = records.map(normalizeAchievement)
712
+ lastAchievementsUpdate.value = new Date()
713
+ return achievements.value
714
+ } catch (error) {
715
+ logger.error('Failed to load achievements:', error)
716
+
717
+ if (isAxiosError(error) && error.response?.status === 404) {
718
+ achievements.value = []
719
+ return achievements.value
720
+ }
721
+
722
+ throw error
723
+ } finally {
724
+ loadingAchievements.value = false
725
+ }
726
+ }
727
+
728
+ async function loadQuests(useCache = true): Promise<Quest[]> {
729
+ if (loadingQuests.value) {
730
+ return quests.value
731
+ }
732
+
733
+ if (useCache && !isQuestsStale.value && quests.value.length > 0) {
734
+ return quests.value
735
+ }
736
+
737
+ loadingQuests.value = true
738
+
739
+ try {
740
+ const [availableResponse, myQuestsResponse] = await Promise.all([
741
+ client.get('/v1/gamification/quests/available'),
742
+ client.get('/v1/gamification/quests/my-quests'),
743
+ ])
744
+
745
+ const availableRaw = (availableResponse.data as { data?: unknown }).data ?? []
746
+ const myQuestsRaw = (myQuestsResponse.data as { data?: unknown }).data ?? []
747
+
748
+ const normalizedAvailable = Array.isArray(availableRaw)
749
+ ? availableRaw.map((quest) => normalizeQuest(quest as BackendQuestRecord))
750
+ : []
751
+ const normalizedMyQuests = Array.isArray(myQuestsRaw)
752
+ ? myQuestsRaw.map((quest) => normalizeQuest(quest as BackendQuestRecord))
753
+ : []
754
+
755
+ const myQuestIds = new Set(normalizedMyQuests.map((quest) => quest.id))
756
+ quests.value = [
757
+ ...normalizedMyQuests,
758
+ ...normalizedAvailable.filter((quest) => !myQuestIds.has(quest.id)),
759
+ ]
760
+ dailyQuests.value = quests.value.filter((quest) => quest.type === 'daily')
761
+
762
+ lastQuestsUpdate.value = new Date()
763
+ return quests.value
764
+ } catch (error) {
765
+ logger.error('Failed to load quests:', error)
766
+
767
+ if (isAxiosError(error) && error.response?.status === 404) {
768
+ quests.value = []
769
+ dailyQuests.value = []
770
+ return quests.value
771
+ }
772
+
773
+ throw error
774
+ } finally {
775
+ loadingQuests.value = false
776
+ }
777
+ }
778
+
779
+ async function loadCompletedQuests(useCache = true): Promise<Quest[]> {
780
+ if (loadingQuests.value) {
781
+ return completedQuestsData.value
782
+ }
783
+
784
+ if (useCache && completedQuestsData.value.length > 0) {
785
+ return completedQuestsData.value
786
+ }
787
+
788
+ loadingQuests.value = true
789
+
790
+ try {
791
+ const response = await client.get('/v1/gamification/quests/completed')
792
+ const completedRaw = ((response.data as { data?: unknown }).data ?? []) as BackendQuestRecord[]
793
+ completedQuestsData.value = completedRaw.map(normalizeQuest)
794
+ return completedQuestsData.value
795
+ } catch (error) {
796
+ logger.error('Failed to load completed quests:', error)
797
+
798
+ if (isAxiosError(error) && error.response?.status === 404) {
799
+ completedQuestsData.value = []
800
+ return completedQuestsData.value
801
+ }
802
+
803
+ throw error
804
+ } finally {
805
+ loadingQuests.value = false
806
+ }
807
+ }
808
+
809
+ async function loadRewards(useCache = true): Promise<Reward[]> {
810
+ if (loadingRewards.value) {
811
+ return rewards.value
812
+ }
813
+
814
+ if (useCache && !isRewardsStale.value && rewards.value.length > 0) {
815
+ return rewards.value
816
+ }
817
+
818
+ loadingRewards.value = true
819
+
820
+ try {
821
+ const response = await client.get('/v1/gamification/rewards')
822
+ const rewardRecords = extractBackendRewardRecords(response.data)
823
+ rewards.value = rewardRecords.map(normalizeReward)
824
+ lastRewardsUpdate.value = new Date()
825
+ return rewards.value
826
+ } catch (error) {
827
+ logger.error('Failed to load rewards:', error)
828
+ throw error
829
+ } finally {
830
+ loadingRewards.value = false
831
+ }
832
+ }
833
+
834
+ async function loadLeaderboard(period: LeaderboardPeriod = 'all-time', limit = 50): Promise<LeaderboardEntry[]> {
835
+ loadingLeaderboard.value = true
836
+
837
+ try {
838
+ activeLeaderboardContext.value = { period, limit }
839
+ const response = await client.get('/v1/gamification/leaderboard', {
840
+ params: { period, limit },
841
+ })
842
+ const responseData = response.data as { data?: LeaderboardEntry[] }
843
+ leaderboard.value = responseData.data ?? []
844
+ lastLeaderboardUpdate.value = new Date()
845
+ return leaderboard.value
846
+ } catch (error) {
847
+ logger.error('Failed to load leaderboard:', error)
848
+ throw error
849
+ } finally {
850
+ loadingLeaderboard.value = false
851
+ }
852
+ }
853
+
854
+ async function loadUserStats(useCache = true): Promise<UserStats> {
855
+ if (loadingStats.value) {
856
+ if (userStats.value) {
857
+ return userStats.value
858
+ }
859
+
860
+ return new Promise((resolve, reject) => {
861
+ const checkInterval = setInterval(() => {
862
+ if (!loadingStats.value) {
863
+ clearInterval(checkInterval)
864
+ if (userStats.value) {
865
+ resolve(userStats.value)
866
+ } else {
867
+ reject(new Error('Failed to load user stats'))
868
+ }
869
+ }
870
+ }, 100)
871
+
872
+ setTimeout(() => {
873
+ clearInterval(checkInterval)
874
+ reject(new Error('Timeout loading user stats'))
875
+ }, 10000)
876
+ })
877
+ }
878
+
879
+ if (useCache && !isStatsStale.value && userStats.value) {
880
+ return userStats.value
881
+ }
882
+
883
+ loadingStats.value = true
884
+
885
+ try {
886
+ const response = await client.get('/v1/gamification/users/me/reputation')
887
+ const responseData = response.data as { data?: UserStats }
888
+ if (!responseData.data) {
889
+ throw new Error('Failed to load user stats')
890
+ }
891
+ userStats.value = responseData.data
892
+ lastStatsUpdate.value = new Date()
893
+ return userStats.value
894
+ } catch (error) {
895
+ logger.error('Failed to load user stats:', error)
896
+
897
+ if (isAxiosError(error) && error.response?.status === 404) {
898
+ const defaultStats = {
899
+ id: 'guest',
900
+ user_id: 'guest',
901
+ total_points: 0,
902
+ coin_balance: 0,
903
+ lifetime_points: 0,
904
+ current_level: 1,
905
+ points_this_week: 0,
906
+ points_this_month: 0,
907
+ points_this_year: 0,
908
+ level_progress: 0,
909
+ points_to_next_level: 100,
910
+ level: {
911
+ level_number: 1,
912
+ name: 'Beginner',
913
+ badge_color: 'gray',
914
+ badge_icon: 'star',
915
+ benefits: [],
916
+ },
917
+ next_level: null,
918
+ privileges: [],
919
+ stats: {},
920
+ } as unknown as UserStats
921
+
922
+ userStats.value = defaultStats
923
+ return defaultStats
924
+ }
925
+
926
+ throw error
927
+ } finally {
928
+ loadingStats.value = false
929
+ }
930
+ }
931
+
932
+ async function loadAchievementById(achievementId: string): Promise<Achievement> {
933
+ const response = await client.get(`/v1/gamification/achievements/${achievementId}`)
934
+ const payload = response.data as { data?: unknown }
935
+
936
+ if (!isBackendAchievementRecord(payload.data)) {
937
+ throw new Error('Failed to load achievement details')
938
+ }
939
+
940
+ const normalized = normalizeAchievement(payload.data)
941
+ const existingIndex = achievements.value.findIndex((achievement) => achievement.id === achievementId)
942
+ if (existingIndex >= 0) {
943
+ achievements.value[existingIndex] = normalized
944
+ } else {
945
+ achievements.value.unshift(normalized)
946
+ }
947
+
948
+ return normalized
949
+ }
950
+
951
+ async function getAchievementShareCard(achievementId: string): Promise<AchievementShareCard> {
952
+ const existing = achievements.value.find((achievement) => achievement.id === achievementId)
953
+ if (existing?.share_card) {
954
+ return existing.share_card
955
+ }
956
+
957
+ const refreshedAchievement = await loadAchievementById(achievementId)
958
+ if (!refreshedAchievement.share_card) {
959
+ throw new Error('Share card metadata is unavailable')
960
+ }
961
+
962
+ return refreshedAchievement.share_card
963
+ }
964
+
965
+ async function fetchAchievementShareCardAsset(achievementId: string): Promise<string> {
966
+ const shareCard = await getAchievementShareCard(achievementId)
967
+ const shareCardAssetPath = toSameOriginRequestPath(shareCard.image_url)
968
+ const response = await client.get<string>(shareCardAssetPath, { responseType: 'text' })
969
+
970
+ if (typeof response.data !== 'string' || !response.data.includes('<svg')) {
971
+ throw new Error('Share card image could not be generated')
972
+ }
973
+
974
+ return response.data
975
+ }
976
+
977
+ async function claimAchievement(achievementId: string): Promise<void> {
978
+ if (claiming.value) {
979
+ return
980
+ }
981
+
982
+ claiming.value = true
983
+
984
+ try {
985
+ const response = await client.post(`/v1/gamification/achievements/${achievementId}/claim`)
986
+ const payload = response.data as {
987
+ data?: {
988
+ unlocked_at?: string
989
+ achievement?: BackendAchievementRecord
990
+ user_stats?: UserStats
991
+ }
992
+ meta?: {
993
+ new_total_points?: number
994
+ }
995
+ }
996
+
997
+ const achievement = achievements.value.find((entry) => entry.id === achievementId)
998
+ if (achievement) {
999
+ const unlockedAt = payload.data?.unlocked_at
1000
+
1001
+ if (payload.data?.achievement && isBackendAchievementRecord(payload.data.achievement)) {
1002
+ const normalized = normalizeAchievement({
1003
+ ...payload.data.achievement,
1004
+ unlocked_at: payload.data.achievement.unlocked_at ?? unlockedAt ?? null,
1005
+ claimed_at: payload.data.achievement.claimed_at ?? unlockedAt ?? null,
1006
+ is_unlocked: true,
1007
+ is_claimed: true,
1008
+ can_claim: false,
1009
+ })
1010
+ Object.assign(achievement, normalized)
1011
+ } else {
1012
+ const claimedTimestamp = unlockedAt ?? new Date().toISOString()
1013
+ achievement.is_unlocked = true
1014
+ achievement.is_claimed = true
1015
+ achievement.can_claim = false
1016
+ achievement.unlocked_at = claimedTimestamp
1017
+ achievement.claimed_at = claimedTimestamp
1018
+ achievement.progress_percentage = 100
1019
+ }
1020
+ }
1021
+
1022
+ if (payload.data?.user_stats) {
1023
+ userStats.value = payload.data.user_stats
1024
+ } else if (typeof payload.meta?.new_total_points === 'number' && userStats.value) {
1025
+ userStats.value.total_points = payload.meta.new_total_points
1026
+ }
1027
+ } catch (error) {
1028
+ logger.error('Failed to claim achievement:', error)
1029
+ throw error
1030
+ } finally {
1031
+ claiming.value = false
1032
+ }
1033
+ }
1034
+
1035
+ async function acceptQuest(questId: string): Promise<void> {
1036
+ try {
1037
+ const response = await client.post(`/v1/gamification/quests/${questId}/accept`)
1038
+ const normalized = normalizeQuest((response.data as { data: BackendQuestRecord }).data)
1039
+ const index = quests.value.findIndex((quest) => quest.id === questId)
1040
+ if (index !== -1) {
1041
+ quests.value.splice(index, 1, normalized)
1042
+ }
1043
+ } catch (error) {
1044
+ logger.error('Failed to accept quest:', error)
1045
+ throw error
1046
+ }
1047
+ }
1048
+
1049
+ async function completeQuest(questId: string): Promise<void> {
1050
+ try {
1051
+ const quest = quests.value.find((item) => item.id === questId)
1052
+ const idempotencyKey = createQuestCompletionIdempotencyKey(questId)
1053
+
1054
+ const progressData = quest?.objectives.map((objective) => ({
1055
+ current: objective.current_value ?? objective.current,
1056
+ completed: objective.completed ?? objective.is_completed ?? false,
1057
+ }))
1058
+
1059
+ const requestPayload = progressData !== undefined && progressData.length > 0
1060
+ ? { progress_data: progressData }
1061
+ : {}
1062
+
1063
+ const response = await client.post(
1064
+ `/v1/gamification/quests/${questId}/complete`,
1065
+ requestPayload,
1066
+ {
1067
+ headers: {
1068
+ 'Idempotency-Key': idempotencyKey,
1069
+ },
1070
+ },
1071
+ )
1072
+ const payload = (response.data as { data: CompleteQuestResponsePayload }).data
1073
+
1074
+ const index = quests.value.findIndex((item) => item.id === questId)
1075
+ if (index !== -1 && quest) {
1076
+ const updated: Quest = {
1077
+ ...quest,
1078
+ completed_at: payload.completed_at,
1079
+ progress_percentage: 100,
1080
+ objectives: quest.objectives.map((objective) => ({
1081
+ ...objective,
1082
+ completed: true,
1083
+ })),
1084
+ }
1085
+ quests.value.splice(index, 1, updated)
1086
+ }
1087
+ } catch (error) {
1088
+ logger.error('Failed to complete quest:', error)
1089
+ throw error
1090
+ }
1091
+ }
1092
+
1093
+ async function abandonQuest(questId: string): Promise<void> {
1094
+ try {
1095
+ await client.delete(`/v1/gamification/quests/${questId}/abandon`)
1096
+ await loadQuests(false)
1097
+ } catch (error) {
1098
+ logger.error('Failed to abandon quest:', error)
1099
+ throw error
1100
+ }
1101
+ }
1102
+
1103
+ async function redeemReward(rewardId: string): Promise<void> {
1104
+ if (claiming.value) {
1105
+ return
1106
+ }
1107
+
1108
+ claiming.value = true
1109
+
1110
+ try {
1111
+ const response = await client.post(`/v1/gamification/rewards/${rewardId}/redeem`)
1112
+ const responseData = response.data as {
1113
+ data?: {
1114
+ reward?: Reward
1115
+ claimed_reward?: ClaimedReward
1116
+ user_stats?: UserStats
1117
+ }
1118
+ }
1119
+
1120
+ const reward = rewards.value.find((entry) => entry.id === rewardId)
1121
+ if (reward && responseData.data?.reward) {
1122
+ Object.assign(reward, responseData.data.reward)
1123
+ }
1124
+
1125
+ if (responseData.data?.claimed_reward) {
1126
+ claimedRewards.value.unshift(responseData.data.claimed_reward)
1127
+ }
1128
+
1129
+ if (responseData.data?.user_stats) {
1130
+ userStats.value = responseData.data.user_stats
1131
+ }
1132
+ } catch (error) {
1133
+ logger.error('Failed to redeem reward:', error)
1134
+ throw error
1135
+ } finally {
1136
+ claiming.value = false
1137
+ }
1138
+ }
1139
+
1140
+ let subscribedGamificationChannel: string | null = null
1141
+ let subscribedLeaderboardChannel: string | null = null
1142
+ let shouldMaintainRealtimeSubscription = false
1143
+
1144
+ function subscribeToGamificationUpdates(): void {
1145
+ const currentUser = getCurrentUser()
1146
+ if (!currentUser?.id) {
1147
+ return
1148
+ }
1149
+
1150
+ shouldMaintainRealtimeSubscription = true
1151
+ const channelName = `gamification.${currentUser.id}`
1152
+
1153
+ const echo = getEcho()
1154
+ if (!echo) {
1155
+ debugLog(`Gamification: Echo unavailable; waiting to subscribe to ${channelName}`)
1156
+ return
1157
+ }
1158
+
1159
+ const tenantScopeValue = currentUser.tenant_id ?? currentUser.tenantId
1160
+ const tenantScope = typeof tenantScopeValue === 'string' && tenantScopeValue.length > 0
1161
+ ? tenantScopeValue
1162
+ : 'global'
1163
+ const leaderboardChannelName = `leaderboard.${tenantScope}`
1164
+
1165
+ if (
1166
+ subscribedGamificationChannel === channelName
1167
+ && subscribedLeaderboardChannel === leaderboardChannelName
1168
+ ) {
1169
+ return
1170
+ }
1171
+
1172
+ if (subscribedGamificationChannel && subscribedGamificationChannel !== channelName) {
1173
+ const staleChannel = echo.private(subscribedGamificationChannel)
1174
+ staleChannel.stopListening('.achievement.unlocked')
1175
+ staleChannel.stopListening('.quest.completed')
1176
+ staleChannel.stopListening('.quest.accepted')
1177
+ staleChannel.stopListening('.quest.progress-updated')
1178
+ staleChannel.stopListening('.level.up')
1179
+ staleChannel.stopListening('.points.awarded')
1180
+ staleChannel.stopListening('.reward.redeemed')
1181
+ }
1182
+
1183
+ if (subscribedLeaderboardChannel) {
1184
+ echo.leave(subscribedLeaderboardChannel)
1185
+ }
1186
+
1187
+ debugLog(`Gamification: Subscribing to ${channelName}`)
1188
+
1189
+ const privateChannel = echo.private(channelName)
1190
+ privateChannel.listen('.achievement.unlocked', (event) => {
1191
+ handleAchievementUnlocked(event as AchievementUnlockedEvent)
1192
+ })
1193
+ privateChannel.listen('.quest.completed', (event) => {
1194
+ handleQuestCompleted(event as QuestCompletedEvent)
1195
+ })
1196
+ privateChannel.listen('.quest.accepted', (event) => {
1197
+ handleQuestAccepted(event as QuestAcceptedEvent)
1198
+ })
1199
+ privateChannel.listen('.quest.progress-updated', (event) => {
1200
+ handleQuestProgressUpdated(event as QuestProgressUpdatedEvent)
1201
+ })
1202
+ privateChannel.listen('.level.up', (event) => {
1203
+ handleLevelUp(event as LevelUpEvent)
1204
+ })
1205
+ privateChannel.listen('.points.awarded', (event) => {
1206
+ handlePointsAwarded(event as PointsAwardedEvent)
1207
+ })
1208
+ privateChannel.listen('.reward.redeemed', (event) => {
1209
+ handleRewardClaimed(event as RewardClaimedEvent)
1210
+ })
1211
+ subscribedGamificationChannel = channelName
1212
+
1213
+ const leaderboardChannel = echo.private(leaderboardChannelName)
1214
+ leaderboardChannel.listen('.leaderboard.position-changed', (event) => {
1215
+ handleLeaderboardUpdated(event as LeaderboardPositionChangedEvent)
1216
+ })
1217
+ subscribedLeaderboardChannel = leaderboardChannelName
1218
+ }
1219
+
1220
+ function unsubscribeFromGamificationUpdates(): void {
1221
+ shouldMaintainRealtimeSubscription = false
1222
+
1223
+ if (!subscribedGamificationChannel && !subscribedLeaderboardChannel) {
1224
+ return
1225
+ }
1226
+
1227
+ const echo = getEcho()
1228
+ if (echo) {
1229
+ debugLog('Gamification: Unsubscribing from all channels')
1230
+
1231
+ if (subscribedGamificationChannel) {
1232
+ const channel = echo.private(subscribedGamificationChannel)
1233
+ channel.stopListening('.achievement.unlocked')
1234
+ channel.stopListening('.quest.completed')
1235
+ channel.stopListening('.quest.accepted')
1236
+ channel.stopListening('.quest.progress-updated')
1237
+ channel.stopListening('.level.up')
1238
+ channel.stopListening('.points.awarded')
1239
+ channel.stopListening('.reward.redeemed')
1240
+ }
1241
+
1242
+ if (subscribedLeaderboardChannel) {
1243
+ echo.leave(subscribedLeaderboardChannel)
1244
+ }
1245
+ }
1246
+
1247
+ subscribedGamificationChannel = null
1248
+ subscribedLeaderboardChannel = null
1249
+ }
1250
+
1251
+ const stopListeningToConnectionStatus = onConnectionStatusChange((status) => {
1252
+ if (status !== 'connected') {
1253
+ return
1254
+ }
1255
+
1256
+ if (!shouldMaintainRealtimeSubscription) {
1257
+ return
1258
+ }
1259
+
1260
+ subscribedGamificationChannel = null
1261
+ subscribedLeaderboardChannel = null
1262
+ subscribeToGamificationUpdates()
1263
+ })
1264
+
1265
+ if (typeof stopListeningToConnectionStatus === 'function') {
1266
+ onScopeDispose(() => {
1267
+ stopListeningToConnectionStatus()
1268
+ })
1269
+ }
1270
+
1271
+ function handleAchievementUnlocked(event: AchievementUnlockedEvent): void {
1272
+ debugLog('Gamification: Achievement unlocked event', event)
1273
+
1274
+ const achievement = achievements.value.find((entry) => entry.id === event.achievement_id)
1275
+ if (achievement) {
1276
+ achievement.unlocked_at = event.unlocked_at
1277
+ delete achievement.progress
1278
+ }
1279
+
1280
+ if (event.user_stats) {
1281
+ userStats.value = event.user_stats
1282
+ }
1283
+
1284
+ const payload = event.achievement
1285
+ const title = typeof payload?.name === 'string' && payload.name.length > 0
1286
+ ? payload.name
1287
+ : achievement?.name ?? 'Achievement Unlocked'
1288
+ const description = typeof payload?.description === 'string' && payload.description.length > 0
1289
+ ? payload.description
1290
+ : achievement?.description ?? 'You unlocked a new achievement.'
1291
+ const icon = typeof payload?.icon === 'string' && payload.icon.length > 0
1292
+ ? payload.icon
1293
+ : achievement?.icon ?? 'trophy'
1294
+
1295
+ const notification: AchievementUnlockNotification = {
1296
+ achievementId: event.achievement_id,
1297
+ title,
1298
+ description,
1299
+ icon,
1300
+ unlockedAt: event.unlocked_at,
1301
+ }
1302
+
1303
+ if (typeof payload?.reward_points === 'number') {
1304
+ notification.points = payload.reward_points
1305
+ } else if (typeof achievement?.reward_points === 'number') {
1306
+ notification.points = achievement.reward_points
1307
+ } else if (typeof achievement?.points_awarded === 'number') {
1308
+ notification.points = achievement.points_awarded
1309
+ }
1310
+
1311
+ lastAchievementUnlockedEvent.value = notification
1312
+ }
1313
+
1314
+ function clearLastAchievementUnlockedEvent(): void {
1315
+ lastAchievementUnlockedEvent.value = null
1316
+ }
1317
+
1318
+ function handleQuestCompleted(event: QuestCompletedEvent): void {
1319
+ debugLog('Gamification: Quest completed event', event)
1320
+
1321
+ const quest = quests.value.find((entry) => entry.id === event.quest_id)
1322
+ if (quest) {
1323
+ quest.completed_at = event.completed_at
1324
+ quest.progress_percentage = 100
1325
+ }
1326
+
1327
+ if (event.user_stats) {
1328
+ userStats.value = event.user_stats
1329
+ }
1330
+ }
1331
+
1332
+ function handleQuestAccepted(event: QuestAcceptedEvent): void {
1333
+ debugLog('Gamification: Quest accepted event', event)
1334
+
1335
+ const quest = quests.value.find((entry) => entry.id === event.quest.id)
1336
+ if (quest) {
1337
+ quest.accepted_at = event.timestamp
1338
+ }
1339
+ }
1340
+
1341
+ function handleQuestProgressUpdated(event: QuestProgressUpdatedEvent): void {
1342
+ debugLog('Gamification: Quest progress updated event', event)
1343
+
1344
+ const quest = quests.value.find((entry) => entry.id === event.questId)
1345
+ if (quest && event.progress) {
1346
+ const progressPct = event.progress.percentage
1347
+ if (typeof progressPct === 'number') {
1348
+ quest.progress_percentage = progressPct
1349
+ }
1350
+ }
1351
+ }
1352
+
1353
+ function handleLevelUp(event: LevelUpEvent): void {
1354
+ debugLog('Gamification: Level up event', event)
1355
+
1356
+ if (userStats.value) {
1357
+ userStats.value.current_level = event.new_level
1358
+ userStats.value.level_progress = 0
1359
+ }
1360
+ }
1361
+
1362
+ function handlePointsAwarded(event: PointsAwardedEvent): void {
1363
+ debugLog('Gamification: Points awarded event', event)
1364
+
1365
+ if (userStats.value) {
1366
+ if (event.lifetime_points !== undefined) {
1367
+ userStats.value.lifetime_points = event.lifetime_points
1368
+ } else {
1369
+ userStats.value.lifetime_points += event.points
1370
+ }
1371
+
1372
+ userStats.value.level_progress = event.level_progress ?? userStats.value.level_progress
1373
+
1374
+ if (event.points_this_week !== undefined) {
1375
+ userStats.value.points_this_week = event.points_this_week
1376
+ }
1377
+ if (event.points_this_month !== undefined) {
1378
+ userStats.value.points_this_month = event.points_this_month
1379
+ }
1380
+ }
1381
+
1382
+ const currentUserId = getCurrentUser()?.id
1383
+ if (currentUserId && leaderboard.value.length > 0) {
1384
+ const entryIndex = leaderboard.value.findIndex((entry) => entry.user_id === currentUserId)
1385
+ if (entryIndex !== -1) {
1386
+ const entry = leaderboard.value[entryIndex]
1387
+ if (entry) {
1388
+ if (event.total_points !== undefined) {
1389
+ entry.total_points = event.total_points
1390
+ } else {
1391
+ entry.total_points += event.points
1392
+ }
1393
+ lastLeaderboardUpdate.value = new Date()
1394
+ }
1395
+ }
1396
+ }
1397
+ }
1398
+
1399
+ function handleRewardClaimed(event: RewardClaimedEvent): void {
1400
+ debugLog('Gamification: Reward claimed event', event)
1401
+
1402
+ if (event.reward_id && event.reward) {
1403
+ const reward = rewards.value.find((entry) => entry.id === event.reward_id)
1404
+ if (reward) {
1405
+ Object.assign(reward, event.reward)
1406
+ }
1407
+ }
1408
+
1409
+ if (event.claimed_reward) {
1410
+ claimedRewards.value.unshift(event.claimed_reward)
1411
+ }
1412
+
1413
+ if (event.user_stats) {
1414
+ userStats.value = event.user_stats
1415
+ }
1416
+ }
1417
+
1418
+ function handleLeaderboardUpdated(event: LeaderboardPositionChangedEvent): void {
1419
+ debugLog('Gamification: Leaderboard position changed event', event)
1420
+
1421
+ if (activeLeaderboardContext.value.period !== 'all-time') {
1422
+ const { period, limit } = activeLeaderboardContext.value
1423
+ void loadLeaderboard(period, limit).catch((error: unknown) => {
1424
+ logger.error('Failed to refresh period leaderboard after realtime update:', error)
1425
+ })
1426
+ return
1427
+ }
1428
+
1429
+ const existingIndex = leaderboard.value.findIndex((entry) => entry.user_id === event.userId)
1430
+
1431
+ if (existingIndex !== -1) {
1432
+ const entry = leaderboard.value[existingIndex]
1433
+ if (entry) {
1434
+ entry.rank = event.newRank
1435
+ entry.total_points = event.totalPoints
1436
+ }
1437
+ }
1438
+
1439
+ leaderboard.value.sort((left, right) => {
1440
+ if (right.total_points !== left.total_points) {
1441
+ return right.total_points - left.total_points
1442
+ }
1443
+
1444
+ if (left.rank !== right.rank) {
1445
+ return left.rank - right.rank
1446
+ }
1447
+
1448
+ return left.user_id.localeCompare(right.user_id)
1449
+ })
1450
+ leaderboard.value.forEach((entry, index) => {
1451
+ entry.rank = index + 1
1452
+ })
1453
+ lastLeaderboardUpdate.value = new Date()
1454
+ }
1455
+
1456
+ async function loadAllData(): Promise<void> {
1457
+ try {
1458
+ await Promise.all([
1459
+ loadUserStats(),
1460
+ loadAchievements(),
1461
+ loadQuests(),
1462
+ loadRewards(),
1463
+ loadLeaderboard(),
1464
+ ])
1465
+ } catch (error) {
1466
+ logger.error('Failed to load gamification data:', error)
1467
+ throw error
1468
+ }
1469
+ }
1470
+
1471
+ function $reset(): void {
1472
+ achievements.value = []
1473
+ quests.value = []
1474
+ dailyQuests.value = []
1475
+ questFilter.value = 'active'
1476
+ rewards.value = []
1477
+ claimedRewards.value = []
1478
+ rewardTransactions.value = []
1479
+ leaderboard.value = []
1480
+ activeLeaderboardContext.value = {
1481
+ period: 'all-time',
1482
+ limit: 50,
1483
+ }
1484
+ userStats.value = null
1485
+ lastAchievementUnlockedEvent.value = null
1486
+
1487
+ loadingAchievements.value = false
1488
+ loadingQuests.value = false
1489
+ loadingRewards.value = false
1490
+ loadingLeaderboard.value = false
1491
+ loadingStats.value = false
1492
+ claiming.value = false
1493
+
1494
+ lastAchievementsUpdate.value = null
1495
+ lastQuestsUpdate.value = null
1496
+ lastRewardsUpdate.value = null
1497
+ lastLeaderboardUpdate.value = null
1498
+ lastStatsUpdate.value = null
1499
+ }
1500
+
1501
+ return {
1502
+ achievements,
1503
+ quests,
1504
+ dailyQuests,
1505
+ questFilter,
1506
+ rewards,
1507
+ claimedRewards,
1508
+ rewardTransactions,
1509
+ leaderboard,
1510
+ activeLeaderboardContext,
1511
+ userStats,
1512
+ lastAchievementUnlockedEvent,
1513
+
1514
+ loadingAchievements,
1515
+ loadingQuests,
1516
+ loadingRewards,
1517
+ loadingLeaderboard,
1518
+ loadingStats,
1519
+ claiming,
1520
+
1521
+ lastAchievementsUpdate,
1522
+ lastQuestsUpdate,
1523
+ lastRewardsUpdate,
1524
+ lastLeaderboardUpdate,
1525
+ lastStatsUpdate,
1526
+
1527
+ isAchievementsStale,
1528
+ isQuestsStale,
1529
+ isRewardsStale,
1530
+ isLeaderboardStale,
1531
+ isStatsStale,
1532
+ unlockedAchievements,
1533
+ availableAchievements,
1534
+ activeQuests,
1535
+ completedQuests,
1536
+ rewardBalance,
1537
+ currentXP,
1538
+ currentLevel,
1539
+ nextLevel,
1540
+ levelProgress,
1541
+ recentXPGain,
1542
+
1543
+ loadAchievements,
1544
+ loadQuests,
1545
+ loadCompletedQuests,
1546
+ loadRewards,
1547
+ loadLeaderboard,
1548
+ loadUserStats,
1549
+ loadAchievementById,
1550
+ setQuestFilter,
1551
+ getAchievementShareCard,
1552
+ fetchAchievementShareCardAsset,
1553
+ claimAchievement,
1554
+ acceptQuest,
1555
+ completeQuest,
1556
+ abandonQuest,
1557
+ redeemReward,
1558
+ subscribeToGamificationUpdates,
1559
+ unsubscribeFromGamificationUpdates,
1560
+ clearLastAchievementUnlockedEvent,
1561
+ loadAllData,
1562
+ $reset,
1563
+ }
1564
+ })
1565
+ }