@codingfactory/socialkit-vue 0.5.3 → 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.
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/services/gamification.d.ts +87 -0
- package/dist/services/gamification.d.ts.map +1 -0
- package/dist/services/gamification.js +263 -0
- package/dist/services/gamification.js.map +1 -0
- package/dist/stores/content.d.ts +1418 -0
- package/dist/stores/content.d.ts.map +1 -0
- package/dist/stores/content.js +1195 -0
- package/dist/stores/content.js.map +1 -0
- package/dist/stores/gamification.d.ts +2875 -0
- package/dist/stores/gamification.d.ts.map +1 -0
- package/dist/stores/gamification.js +1136 -0
- package/dist/stores/gamification.js.map +1 -0
- package/dist/types/api.d.ts +1 -0
- package/dist/types/api.d.ts.map +1 -1
- package/dist/types/content-api.d.ts +23 -0
- package/dist/types/content-api.d.ts.map +1 -0
- package/dist/types/content-api.js +5 -0
- package/dist/types/content-api.js.map +1 -0
- package/dist/types/content.d.ts +309 -0
- package/dist/types/content.d.ts.map +1 -0
- package/dist/types/content.js +36 -0
- package/dist/types/content.js.map +1 -0
- package/dist/types/gamification.d.ts +267 -0
- package/dist/types/gamification.d.ts.map +1 -0
- package/dist/types/gamification.js +5 -0
- package/dist/types/gamification.js.map +1 -0
- package/dist/types/media.d.ts +63 -0
- package/dist/types/media.d.ts.map +1 -0
- package/dist/types/media.js +13 -0
- package/dist/types/media.js.map +1 -0
- package/dist/types/reputation.d.ts +55 -0
- package/dist/types/reputation.d.ts.map +1 -0
- package/dist/types/reputation.js +5 -0
- package/dist/types/reputation.js.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +143 -0
- package/src/services/gamification.ts +432 -0
- package/src/stores/content.ts +1477 -0
- package/src/stores/gamification.ts +1565 -0
- package/src/types/api.ts +1 -0
- package/src/types/content-api.ts +24 -0
- package/src/types/content.ts +381 -0
- package/src/types/gamification.ts +286 -0
- package/src/types/media.ts +81 -0
- package/src/types/reputation.ts +78 -0
|
@@ -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
|
+
}
|