@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.
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -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/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/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/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 +65 -0
- package/src/services/gamification.ts +432 -0
- package/src/stores/gamification.ts +1565 -0
- package/src/types/api.ts +1 -0
- package/src/types/gamification.ts +286 -0
- package/src/types/reputation.ts +78 -0
|
@@ -0,0 +1,1136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic gamification store factory for SocialKit-powered frontends.
|
|
3
|
+
*/
|
|
4
|
+
import { defineStore } from 'pinia';
|
|
5
|
+
import { computed, onScopeDispose, ref } from 'vue';
|
|
6
|
+
const QUEST_FILTER_VALUES = ['active', 'daily', 'weekly', 'completed'];
|
|
7
|
+
function isAxiosError(error) {
|
|
8
|
+
return error !== null
|
|
9
|
+
&& typeof error === 'object'
|
|
10
|
+
&& 'response' in error;
|
|
11
|
+
}
|
|
12
|
+
function isQuestFilter(value) {
|
|
13
|
+
return QUEST_FILTER_VALUES.includes(value);
|
|
14
|
+
}
|
|
15
|
+
function createQuestCompletionIdempotencyKey(questId) {
|
|
16
|
+
const randomPart = typeof globalThis.crypto?.randomUUID === 'function'
|
|
17
|
+
? globalThis.crypto.randomUUID()
|
|
18
|
+
: Math.random().toString(36).slice(2);
|
|
19
|
+
return `quest-complete:${questId}:${randomPart}`;
|
|
20
|
+
}
|
|
21
|
+
function isAchievementRarity(value) {
|
|
22
|
+
return value === 'common'
|
|
23
|
+
|| value === 'uncommon'
|
|
24
|
+
|| value === 'rare'
|
|
25
|
+
|| value === 'epic'
|
|
26
|
+
|| value === 'legendary';
|
|
27
|
+
}
|
|
28
|
+
function toRewardDataRecord(value) {
|
|
29
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
function normalizeAchievementRewardData(value) {
|
|
35
|
+
const rewardData = toRewardDataRecord(value);
|
|
36
|
+
const normalized = {};
|
|
37
|
+
if (typeof rewardData.badge_color === 'string') {
|
|
38
|
+
normalized.badge_color = rewardData.badge_color;
|
|
39
|
+
}
|
|
40
|
+
if (typeof rewardData.badge_text === 'string') {
|
|
41
|
+
normalized.badge_text = rewardData.badge_text;
|
|
42
|
+
}
|
|
43
|
+
if (Array.isArray(rewardData.special_privileges)) {
|
|
44
|
+
normalized.special_privileges = rewardData.special_privileges.filter((entry) => typeof entry === 'string');
|
|
45
|
+
}
|
|
46
|
+
if (typeof rewardData.unlocks_feature === 'string') {
|
|
47
|
+
normalized.unlocks_feature = rewardData.unlocks_feature;
|
|
48
|
+
}
|
|
49
|
+
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
|
50
|
+
}
|
|
51
|
+
function resolveAchievementRarity(achievement) {
|
|
52
|
+
if (isAchievementRarity(achievement.rarity)) {
|
|
53
|
+
return achievement.rarity;
|
|
54
|
+
}
|
|
55
|
+
const rewardData = toRewardDataRecord(achievement.reward_data);
|
|
56
|
+
const badgeRarity = rewardData.badge_rarity;
|
|
57
|
+
if (isAchievementRarity(badgeRarity)) {
|
|
58
|
+
return badgeRarity;
|
|
59
|
+
}
|
|
60
|
+
return 'common';
|
|
61
|
+
}
|
|
62
|
+
function isBackendRewardType(value) {
|
|
63
|
+
return value === 'badge'
|
|
64
|
+
|| value === 'title'
|
|
65
|
+
|| value === 'cosmetic'
|
|
66
|
+
|| value === 'boost'
|
|
67
|
+
|| value === 'feature';
|
|
68
|
+
}
|
|
69
|
+
function isBackendRewardRecord(value) {
|
|
70
|
+
if (value === null || typeof value !== 'object') {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
const candidate = value;
|
|
74
|
+
return typeof candidate.id === 'string'
|
|
75
|
+
&& typeof candidate.name === 'string'
|
|
76
|
+
&& typeof candidate.description === 'string'
|
|
77
|
+
&& typeof candidate.cost_points === 'number'
|
|
78
|
+
&& isBackendRewardType(candidate.reward_type);
|
|
79
|
+
}
|
|
80
|
+
function extractBackendRewardRecords(value) {
|
|
81
|
+
if (value === null || typeof value !== 'object') {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
const payload = value;
|
|
85
|
+
if (!Array.isArray(payload.data)) {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
return payload.data.filter(isBackendRewardRecord);
|
|
89
|
+
}
|
|
90
|
+
function mapRewardType(rewardType) {
|
|
91
|
+
switch (rewardType) {
|
|
92
|
+
case 'badge':
|
|
93
|
+
return 'item';
|
|
94
|
+
case 'cosmetic':
|
|
95
|
+
return 'cosmetic';
|
|
96
|
+
case 'boost':
|
|
97
|
+
return 'boost';
|
|
98
|
+
case 'title':
|
|
99
|
+
case 'feature':
|
|
100
|
+
return 'privilege';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function mapRewardCategory(rewardType) {
|
|
104
|
+
switch (rewardType) {
|
|
105
|
+
case 'badge':
|
|
106
|
+
return 'badge';
|
|
107
|
+
case 'cosmetic':
|
|
108
|
+
return 'cosmetic';
|
|
109
|
+
case 'title':
|
|
110
|
+
case 'feature':
|
|
111
|
+
return 'privilege';
|
|
112
|
+
case 'boost':
|
|
113
|
+
return 'bonus';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function mapRewardIcon(rewardType, rewardData) {
|
|
117
|
+
const preferredIcon = rewardData.badge_icon ?? rewardData.icon;
|
|
118
|
+
if (typeof preferredIcon === 'string' && preferredIcon.length > 0) {
|
|
119
|
+
return preferredIcon;
|
|
120
|
+
}
|
|
121
|
+
switch (rewardType) {
|
|
122
|
+
case 'badge':
|
|
123
|
+
return 'badge';
|
|
124
|
+
case 'cosmetic':
|
|
125
|
+
return 'palette';
|
|
126
|
+
case 'boost':
|
|
127
|
+
return 'flash';
|
|
128
|
+
case 'title':
|
|
129
|
+
case 'feature':
|
|
130
|
+
return 'settings';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function mapRewardPreviewImage(rewardData) {
|
|
134
|
+
const previewUrl = rewardData.preview_url;
|
|
135
|
+
if (typeof previewUrl === 'string' && previewUrl.length > 0) {
|
|
136
|
+
return previewUrl;
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
function isBackendAchievementRecord(value) {
|
|
141
|
+
if (value === null || typeof value !== 'object') {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
const candidate = value;
|
|
145
|
+
return typeof candidate.id === 'string'
|
|
146
|
+
&& typeof candidate.name === 'string'
|
|
147
|
+
&& typeof candidate.description === 'string'
|
|
148
|
+
&& typeof candidate.icon === 'string';
|
|
149
|
+
}
|
|
150
|
+
function isAchievementShareCard(value) {
|
|
151
|
+
if (value === null || typeof value !== 'object') {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
const candidate = value;
|
|
155
|
+
return typeof candidate.title === 'string'
|
|
156
|
+
&& typeof candidate.badge_label === 'string'
|
|
157
|
+
&& typeof candidate.description === 'string'
|
|
158
|
+
&& typeof candidate.image_url === 'string'
|
|
159
|
+
&& typeof candidate.share_url === 'string'
|
|
160
|
+
&& typeof candidate.share_text === 'string';
|
|
161
|
+
}
|
|
162
|
+
function toSameOriginRequestPath(sourceUrl) {
|
|
163
|
+
if (sourceUrl.startsWith('/')) {
|
|
164
|
+
return sourceUrl;
|
|
165
|
+
}
|
|
166
|
+
if (typeof window === 'undefined') {
|
|
167
|
+
return sourceUrl;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const parsedUrl = new URL(sourceUrl, window.location.origin);
|
|
171
|
+
const requestPath = `${parsedUrl.pathname}${parsedUrl.search}`;
|
|
172
|
+
if (requestPath.startsWith('/api/')) {
|
|
173
|
+
return requestPath.slice(4);
|
|
174
|
+
}
|
|
175
|
+
return requestPath;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return sourceUrl;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function normalizeAchievement(achievement) {
|
|
182
|
+
const unlockedAt = achievement.unlocked_at ?? null;
|
|
183
|
+
const claimedAt = achievement.claimed_at ?? unlockedAt;
|
|
184
|
+
const isUnlocked = achievement.is_unlocked === true || unlockedAt !== null;
|
|
185
|
+
const progressPercentage = typeof achievement.progress_percentage === 'number'
|
|
186
|
+
? achievement.progress_percentage
|
|
187
|
+
: 0;
|
|
188
|
+
const canClaim = achievement.can_claim === true || (!isUnlocked && progressPercentage >= 100);
|
|
189
|
+
const rarity = resolveAchievementRarity(achievement);
|
|
190
|
+
const rewardData = normalizeAchievementRewardData(achievement.reward_data);
|
|
191
|
+
const normalized = {
|
|
192
|
+
id: achievement.id,
|
|
193
|
+
name: achievement.name,
|
|
194
|
+
description: achievement.description,
|
|
195
|
+
icon: achievement.icon,
|
|
196
|
+
rarity,
|
|
197
|
+
is_unlocked: isUnlocked,
|
|
198
|
+
is_claimed: achievement.is_claimed ?? isUnlocked,
|
|
199
|
+
can_claim: canClaim,
|
|
200
|
+
unlocked_at: unlockedAt,
|
|
201
|
+
claimed_at: claimedAt,
|
|
202
|
+
};
|
|
203
|
+
if (typeof achievement.progress_percentage === 'number') {
|
|
204
|
+
normalized.progress_percentage = achievement.progress_percentage;
|
|
205
|
+
}
|
|
206
|
+
if (typeof achievement.progress_current === 'number') {
|
|
207
|
+
normalized.progress_current = achievement.progress_current;
|
|
208
|
+
}
|
|
209
|
+
if (typeof achievement.progress_target === 'number') {
|
|
210
|
+
normalized.progress_target = achievement.progress_target;
|
|
211
|
+
}
|
|
212
|
+
if (rewardData) {
|
|
213
|
+
normalized.reward_data = rewardData;
|
|
214
|
+
}
|
|
215
|
+
if (isAchievementShareCard(achievement.share_card)) {
|
|
216
|
+
normalized.share_card = achievement.share_card;
|
|
217
|
+
}
|
|
218
|
+
return normalized;
|
|
219
|
+
}
|
|
220
|
+
function normalizeReward(reward) {
|
|
221
|
+
const rewardData = toRewardDataRecord(reward.reward_data);
|
|
222
|
+
const maxPerUser = reward.max_per_user ?? 0;
|
|
223
|
+
const userRedemptions = reward.user_redemptions ?? 0;
|
|
224
|
+
const stockQuantity = reward.stock_quantity ?? null;
|
|
225
|
+
const stockRemaining = reward.stock_remaining ?? null;
|
|
226
|
+
const previewImage = mapRewardPreviewImage(rewardData);
|
|
227
|
+
const normalizedReward = {
|
|
228
|
+
id: reward.id,
|
|
229
|
+
name: reward.name,
|
|
230
|
+
description: reward.description,
|
|
231
|
+
cost_points: reward.cost_points,
|
|
232
|
+
reward_type: reward.reward_type,
|
|
233
|
+
type: mapRewardType(reward.reward_type),
|
|
234
|
+
category: mapRewardCategory(reward.reward_type),
|
|
235
|
+
icon: mapRewardIcon(reward.reward_type, rewardData),
|
|
236
|
+
reward_data: rewardData,
|
|
237
|
+
stock_quantity: stockQuantity,
|
|
238
|
+
stock_remaining: stockRemaining,
|
|
239
|
+
max_per_user: maxPerUser,
|
|
240
|
+
can_redeem: reward.can_redeem ?? false,
|
|
241
|
+
user_points: reward.user_points ?? 0,
|
|
242
|
+
points_needed: reward.points_needed ?? 0,
|
|
243
|
+
user_redemptions: userRedemptions,
|
|
244
|
+
availability_status: reward.availability_status ?? 'available',
|
|
245
|
+
limited_quantity: stockQuantity,
|
|
246
|
+
remaining_quantity: stockRemaining,
|
|
247
|
+
is_available: reward.can_redeem ?? false,
|
|
248
|
+
is_claimed: maxPerUser > 0 && userRedemptions >= maxPerUser,
|
|
249
|
+
};
|
|
250
|
+
if (previewImage !== undefined) {
|
|
251
|
+
normalizedReward.preview_image = previewImage;
|
|
252
|
+
}
|
|
253
|
+
return normalizedReward;
|
|
254
|
+
}
|
|
255
|
+
function normalizeQuest(raw) {
|
|
256
|
+
const questType = (raw.quest_type ?? raw.type ?? 'daily');
|
|
257
|
+
const title = raw.title ?? raw.name ?? 'Untitled Quest';
|
|
258
|
+
let acceptedAt = raw.accepted_at ?? null;
|
|
259
|
+
if (acceptedAt === null && raw.is_accepted === true) {
|
|
260
|
+
acceptedAt = new Date().toISOString();
|
|
261
|
+
}
|
|
262
|
+
const quest = {
|
|
263
|
+
id: raw.id,
|
|
264
|
+
title,
|
|
265
|
+
description: raw.description,
|
|
266
|
+
type: questType,
|
|
267
|
+
objectives: raw.objectives,
|
|
268
|
+
reward_points: raw.reward_points,
|
|
269
|
+
expires_at: raw.expires_at ?? null,
|
|
270
|
+
completed_at: raw.completed_at ?? null,
|
|
271
|
+
accepted_at: acceptedAt,
|
|
272
|
+
};
|
|
273
|
+
if (raw.name !== undefined) {
|
|
274
|
+
quest.name = raw.name;
|
|
275
|
+
}
|
|
276
|
+
if (raw.rewards !== undefined) {
|
|
277
|
+
quest.rewards = raw.rewards;
|
|
278
|
+
}
|
|
279
|
+
if (raw.status !== undefined) {
|
|
280
|
+
quest.status = raw.status;
|
|
281
|
+
}
|
|
282
|
+
if (raw.is_active !== undefined) {
|
|
283
|
+
quest.is_active = raw.is_active;
|
|
284
|
+
}
|
|
285
|
+
if (raw.starts_at !== undefined) {
|
|
286
|
+
quest.starts_at = raw.starts_at;
|
|
287
|
+
}
|
|
288
|
+
if (raw.progress !== undefined) {
|
|
289
|
+
quest.progress = raw.progress;
|
|
290
|
+
}
|
|
291
|
+
if (raw.progress_percentage !== undefined) {
|
|
292
|
+
quest.progress_percentage = raw.progress_percentage;
|
|
293
|
+
}
|
|
294
|
+
if (raw.category !== undefined) {
|
|
295
|
+
quest.category = raw.category;
|
|
296
|
+
}
|
|
297
|
+
return quest;
|
|
298
|
+
}
|
|
299
|
+
export function createGamificationStoreDefinition(config) {
|
|
300
|
+
const { client, getCurrentUser, getEcho, onConnectionStatusChange, logger = console, debug = false, storeId = 'gamification', } = config;
|
|
301
|
+
const debugLog = (message, ...args) => {
|
|
302
|
+
if (!debug) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
logger.debug(message, ...args);
|
|
306
|
+
};
|
|
307
|
+
return defineStore(storeId, () => {
|
|
308
|
+
const achievements = ref([]);
|
|
309
|
+
const quests = ref([]);
|
|
310
|
+
const dailyQuests = ref([]);
|
|
311
|
+
const completedQuestsData = ref([]);
|
|
312
|
+
const questFilter = ref('active');
|
|
313
|
+
const rewards = ref([]);
|
|
314
|
+
const claimedRewards = ref([]);
|
|
315
|
+
const rewardTransactions = ref([]);
|
|
316
|
+
const leaderboard = ref([]);
|
|
317
|
+
const activeLeaderboardContext = ref({
|
|
318
|
+
period: 'all-time',
|
|
319
|
+
limit: 50,
|
|
320
|
+
});
|
|
321
|
+
const userStats = ref(null);
|
|
322
|
+
const lastAchievementUnlockedEvent = ref(null);
|
|
323
|
+
const loadingAchievements = ref(false);
|
|
324
|
+
const loadingQuests = ref(false);
|
|
325
|
+
const loadingRewards = ref(false);
|
|
326
|
+
const loadingLeaderboard = ref(false);
|
|
327
|
+
const loadingStats = ref(false);
|
|
328
|
+
const claiming = ref(false);
|
|
329
|
+
const lastAchievementsUpdate = ref(null);
|
|
330
|
+
const lastQuestsUpdate = ref(null);
|
|
331
|
+
const lastRewardsUpdate = ref(null);
|
|
332
|
+
const lastLeaderboardUpdate = ref(null);
|
|
333
|
+
const lastStatsUpdate = ref(null);
|
|
334
|
+
const ACHIEVEMENTS_CACHE_TTL = 5 * 60 * 1000;
|
|
335
|
+
const QUESTS_CACHE_TTL = 2 * 60 * 1000;
|
|
336
|
+
const REWARDS_CACHE_TTL = 10 * 60 * 1000;
|
|
337
|
+
const LEADERBOARD_CACHE_TTL = 5 * 60 * 1000;
|
|
338
|
+
const STATS_CACHE_TTL = 2 * 60 * 1000;
|
|
339
|
+
const isAchievementsStale = computed(() => {
|
|
340
|
+
if (!lastAchievementsUpdate.value) {
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
return Date.now() - lastAchievementsUpdate.value.getTime() > ACHIEVEMENTS_CACHE_TTL;
|
|
344
|
+
});
|
|
345
|
+
const isQuestsStale = computed(() => {
|
|
346
|
+
if (!lastQuestsUpdate.value) {
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
return Date.now() - lastQuestsUpdate.value.getTime() > QUESTS_CACHE_TTL;
|
|
350
|
+
});
|
|
351
|
+
const isRewardsStale = computed(() => {
|
|
352
|
+
if (!lastRewardsUpdate.value) {
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
return Date.now() - lastRewardsUpdate.value.getTime() > REWARDS_CACHE_TTL;
|
|
356
|
+
});
|
|
357
|
+
const isLeaderboardStale = computed(() => {
|
|
358
|
+
if (!lastLeaderboardUpdate.value) {
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
return Date.now() - lastLeaderboardUpdate.value.getTime() > LEADERBOARD_CACHE_TTL;
|
|
362
|
+
});
|
|
363
|
+
const isStatsStale = computed(() => {
|
|
364
|
+
if (!lastStatsUpdate.value) {
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
return Date.now() - lastStatsUpdate.value.getTime() > STATS_CACHE_TTL;
|
|
368
|
+
});
|
|
369
|
+
const unlockedAchievements = computed(() => (achievements.value.filter((achievement) => achievement.unlocked_at !== null && achievement.unlocked_at !== undefined)));
|
|
370
|
+
const availableAchievements = computed(() => (achievements.value.filter((achievement) => ((achievement.unlocked_at === null || achievement.unlocked_at === undefined) && achievement.is_visible))));
|
|
371
|
+
const activeQuests = computed(() => quests.value.filter((quest) => !quest.completed_at && quest.accepted_at));
|
|
372
|
+
const completedQuests = computed(() => completedQuestsData.value);
|
|
373
|
+
const rewardBalance = computed(() => userStats.value?.total_points || 0);
|
|
374
|
+
const currentXP = computed(() => userStats.value?.lifetime_points || 0);
|
|
375
|
+
const currentLevel = computed(() => userStats.value?.current_level || 1);
|
|
376
|
+
const nextLevel = computed(() => userStats.value?.next_level);
|
|
377
|
+
const levelProgress = computed(() => userStats.value?.level_progress || 0);
|
|
378
|
+
const recentXPGain = computed(() => userStats.value?.points_this_week || 0);
|
|
379
|
+
function setQuestFilter(nextFilter) {
|
|
380
|
+
if (!isQuestFilter(nextFilter)) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
questFilter.value = nextFilter;
|
|
384
|
+
}
|
|
385
|
+
async function loadAchievements(useCache = true) {
|
|
386
|
+
if (loadingAchievements.value) {
|
|
387
|
+
return achievements.value;
|
|
388
|
+
}
|
|
389
|
+
if (useCache && !isAchievementsStale.value && achievements.value.length > 0) {
|
|
390
|
+
return achievements.value;
|
|
391
|
+
}
|
|
392
|
+
loadingAchievements.value = true;
|
|
393
|
+
try {
|
|
394
|
+
const response = await client.get('/v1/gamification/achievements');
|
|
395
|
+
const responseData = response.data;
|
|
396
|
+
const records = Array.isArray(responseData.data)
|
|
397
|
+
? responseData.data.filter(isBackendAchievementRecord)
|
|
398
|
+
: [];
|
|
399
|
+
achievements.value = records.map(normalizeAchievement);
|
|
400
|
+
lastAchievementsUpdate.value = new Date();
|
|
401
|
+
return achievements.value;
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
logger.error('Failed to load achievements:', error);
|
|
405
|
+
if (isAxiosError(error) && error.response?.status === 404) {
|
|
406
|
+
achievements.value = [];
|
|
407
|
+
return achievements.value;
|
|
408
|
+
}
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
finally {
|
|
412
|
+
loadingAchievements.value = false;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
async function loadQuests(useCache = true) {
|
|
416
|
+
if (loadingQuests.value) {
|
|
417
|
+
return quests.value;
|
|
418
|
+
}
|
|
419
|
+
if (useCache && !isQuestsStale.value && quests.value.length > 0) {
|
|
420
|
+
return quests.value;
|
|
421
|
+
}
|
|
422
|
+
loadingQuests.value = true;
|
|
423
|
+
try {
|
|
424
|
+
const [availableResponse, myQuestsResponse] = await Promise.all([
|
|
425
|
+
client.get('/v1/gamification/quests/available'),
|
|
426
|
+
client.get('/v1/gamification/quests/my-quests'),
|
|
427
|
+
]);
|
|
428
|
+
const availableRaw = availableResponse.data.data ?? [];
|
|
429
|
+
const myQuestsRaw = myQuestsResponse.data.data ?? [];
|
|
430
|
+
const normalizedAvailable = Array.isArray(availableRaw)
|
|
431
|
+
? availableRaw.map((quest) => normalizeQuest(quest))
|
|
432
|
+
: [];
|
|
433
|
+
const normalizedMyQuests = Array.isArray(myQuestsRaw)
|
|
434
|
+
? myQuestsRaw.map((quest) => normalizeQuest(quest))
|
|
435
|
+
: [];
|
|
436
|
+
const myQuestIds = new Set(normalizedMyQuests.map((quest) => quest.id));
|
|
437
|
+
quests.value = [
|
|
438
|
+
...normalizedMyQuests,
|
|
439
|
+
...normalizedAvailable.filter((quest) => !myQuestIds.has(quest.id)),
|
|
440
|
+
];
|
|
441
|
+
dailyQuests.value = quests.value.filter((quest) => quest.type === 'daily');
|
|
442
|
+
lastQuestsUpdate.value = new Date();
|
|
443
|
+
return quests.value;
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
logger.error('Failed to load quests:', error);
|
|
447
|
+
if (isAxiosError(error) && error.response?.status === 404) {
|
|
448
|
+
quests.value = [];
|
|
449
|
+
dailyQuests.value = [];
|
|
450
|
+
return quests.value;
|
|
451
|
+
}
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
finally {
|
|
455
|
+
loadingQuests.value = false;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
async function loadCompletedQuests(useCache = true) {
|
|
459
|
+
if (loadingQuests.value) {
|
|
460
|
+
return completedQuestsData.value;
|
|
461
|
+
}
|
|
462
|
+
if (useCache && completedQuestsData.value.length > 0) {
|
|
463
|
+
return completedQuestsData.value;
|
|
464
|
+
}
|
|
465
|
+
loadingQuests.value = true;
|
|
466
|
+
try {
|
|
467
|
+
const response = await client.get('/v1/gamification/quests/completed');
|
|
468
|
+
const completedRaw = (response.data.data ?? []);
|
|
469
|
+
completedQuestsData.value = completedRaw.map(normalizeQuest);
|
|
470
|
+
return completedQuestsData.value;
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
logger.error('Failed to load completed quests:', error);
|
|
474
|
+
if (isAxiosError(error) && error.response?.status === 404) {
|
|
475
|
+
completedQuestsData.value = [];
|
|
476
|
+
return completedQuestsData.value;
|
|
477
|
+
}
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
finally {
|
|
481
|
+
loadingQuests.value = false;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
async function loadRewards(useCache = true) {
|
|
485
|
+
if (loadingRewards.value) {
|
|
486
|
+
return rewards.value;
|
|
487
|
+
}
|
|
488
|
+
if (useCache && !isRewardsStale.value && rewards.value.length > 0) {
|
|
489
|
+
return rewards.value;
|
|
490
|
+
}
|
|
491
|
+
loadingRewards.value = true;
|
|
492
|
+
try {
|
|
493
|
+
const response = await client.get('/v1/gamification/rewards');
|
|
494
|
+
const rewardRecords = extractBackendRewardRecords(response.data);
|
|
495
|
+
rewards.value = rewardRecords.map(normalizeReward);
|
|
496
|
+
lastRewardsUpdate.value = new Date();
|
|
497
|
+
return rewards.value;
|
|
498
|
+
}
|
|
499
|
+
catch (error) {
|
|
500
|
+
logger.error('Failed to load rewards:', error);
|
|
501
|
+
throw error;
|
|
502
|
+
}
|
|
503
|
+
finally {
|
|
504
|
+
loadingRewards.value = false;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
async function loadLeaderboard(period = 'all-time', limit = 50) {
|
|
508
|
+
loadingLeaderboard.value = true;
|
|
509
|
+
try {
|
|
510
|
+
activeLeaderboardContext.value = { period, limit };
|
|
511
|
+
const response = await client.get('/v1/gamification/leaderboard', {
|
|
512
|
+
params: { period, limit },
|
|
513
|
+
});
|
|
514
|
+
const responseData = response.data;
|
|
515
|
+
leaderboard.value = responseData.data ?? [];
|
|
516
|
+
lastLeaderboardUpdate.value = new Date();
|
|
517
|
+
return leaderboard.value;
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
logger.error('Failed to load leaderboard:', error);
|
|
521
|
+
throw error;
|
|
522
|
+
}
|
|
523
|
+
finally {
|
|
524
|
+
loadingLeaderboard.value = false;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
async function loadUserStats(useCache = true) {
|
|
528
|
+
if (loadingStats.value) {
|
|
529
|
+
if (userStats.value) {
|
|
530
|
+
return userStats.value;
|
|
531
|
+
}
|
|
532
|
+
return new Promise((resolve, reject) => {
|
|
533
|
+
const checkInterval = setInterval(() => {
|
|
534
|
+
if (!loadingStats.value) {
|
|
535
|
+
clearInterval(checkInterval);
|
|
536
|
+
if (userStats.value) {
|
|
537
|
+
resolve(userStats.value);
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
reject(new Error('Failed to load user stats'));
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}, 100);
|
|
544
|
+
setTimeout(() => {
|
|
545
|
+
clearInterval(checkInterval);
|
|
546
|
+
reject(new Error('Timeout loading user stats'));
|
|
547
|
+
}, 10000);
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
if (useCache && !isStatsStale.value && userStats.value) {
|
|
551
|
+
return userStats.value;
|
|
552
|
+
}
|
|
553
|
+
loadingStats.value = true;
|
|
554
|
+
try {
|
|
555
|
+
const response = await client.get('/v1/gamification/users/me/reputation');
|
|
556
|
+
const responseData = response.data;
|
|
557
|
+
if (!responseData.data) {
|
|
558
|
+
throw new Error('Failed to load user stats');
|
|
559
|
+
}
|
|
560
|
+
userStats.value = responseData.data;
|
|
561
|
+
lastStatsUpdate.value = new Date();
|
|
562
|
+
return userStats.value;
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
logger.error('Failed to load user stats:', error);
|
|
566
|
+
if (isAxiosError(error) && error.response?.status === 404) {
|
|
567
|
+
const defaultStats = {
|
|
568
|
+
id: 'guest',
|
|
569
|
+
user_id: 'guest',
|
|
570
|
+
total_points: 0,
|
|
571
|
+
coin_balance: 0,
|
|
572
|
+
lifetime_points: 0,
|
|
573
|
+
current_level: 1,
|
|
574
|
+
points_this_week: 0,
|
|
575
|
+
points_this_month: 0,
|
|
576
|
+
points_this_year: 0,
|
|
577
|
+
level_progress: 0,
|
|
578
|
+
points_to_next_level: 100,
|
|
579
|
+
level: {
|
|
580
|
+
level_number: 1,
|
|
581
|
+
name: 'Beginner',
|
|
582
|
+
badge_color: 'gray',
|
|
583
|
+
badge_icon: 'star',
|
|
584
|
+
benefits: [],
|
|
585
|
+
},
|
|
586
|
+
next_level: null,
|
|
587
|
+
privileges: [],
|
|
588
|
+
stats: {},
|
|
589
|
+
};
|
|
590
|
+
userStats.value = defaultStats;
|
|
591
|
+
return defaultStats;
|
|
592
|
+
}
|
|
593
|
+
throw error;
|
|
594
|
+
}
|
|
595
|
+
finally {
|
|
596
|
+
loadingStats.value = false;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
async function loadAchievementById(achievementId) {
|
|
600
|
+
const response = await client.get(`/v1/gamification/achievements/${achievementId}`);
|
|
601
|
+
const payload = response.data;
|
|
602
|
+
if (!isBackendAchievementRecord(payload.data)) {
|
|
603
|
+
throw new Error('Failed to load achievement details');
|
|
604
|
+
}
|
|
605
|
+
const normalized = normalizeAchievement(payload.data);
|
|
606
|
+
const existingIndex = achievements.value.findIndex((achievement) => achievement.id === achievementId);
|
|
607
|
+
if (existingIndex >= 0) {
|
|
608
|
+
achievements.value[existingIndex] = normalized;
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
achievements.value.unshift(normalized);
|
|
612
|
+
}
|
|
613
|
+
return normalized;
|
|
614
|
+
}
|
|
615
|
+
async function getAchievementShareCard(achievementId) {
|
|
616
|
+
const existing = achievements.value.find((achievement) => achievement.id === achievementId);
|
|
617
|
+
if (existing?.share_card) {
|
|
618
|
+
return existing.share_card;
|
|
619
|
+
}
|
|
620
|
+
const refreshedAchievement = await loadAchievementById(achievementId);
|
|
621
|
+
if (!refreshedAchievement.share_card) {
|
|
622
|
+
throw new Error('Share card metadata is unavailable');
|
|
623
|
+
}
|
|
624
|
+
return refreshedAchievement.share_card;
|
|
625
|
+
}
|
|
626
|
+
async function fetchAchievementShareCardAsset(achievementId) {
|
|
627
|
+
const shareCard = await getAchievementShareCard(achievementId);
|
|
628
|
+
const shareCardAssetPath = toSameOriginRequestPath(shareCard.image_url);
|
|
629
|
+
const response = await client.get(shareCardAssetPath, { responseType: 'text' });
|
|
630
|
+
if (typeof response.data !== 'string' || !response.data.includes('<svg')) {
|
|
631
|
+
throw new Error('Share card image could not be generated');
|
|
632
|
+
}
|
|
633
|
+
return response.data;
|
|
634
|
+
}
|
|
635
|
+
async function claimAchievement(achievementId) {
|
|
636
|
+
if (claiming.value) {
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
claiming.value = true;
|
|
640
|
+
try {
|
|
641
|
+
const response = await client.post(`/v1/gamification/achievements/${achievementId}/claim`);
|
|
642
|
+
const payload = response.data;
|
|
643
|
+
const achievement = achievements.value.find((entry) => entry.id === achievementId);
|
|
644
|
+
if (achievement) {
|
|
645
|
+
const unlockedAt = payload.data?.unlocked_at;
|
|
646
|
+
if (payload.data?.achievement && isBackendAchievementRecord(payload.data.achievement)) {
|
|
647
|
+
const normalized = normalizeAchievement({
|
|
648
|
+
...payload.data.achievement,
|
|
649
|
+
unlocked_at: payload.data.achievement.unlocked_at ?? unlockedAt ?? null,
|
|
650
|
+
claimed_at: payload.data.achievement.claimed_at ?? unlockedAt ?? null,
|
|
651
|
+
is_unlocked: true,
|
|
652
|
+
is_claimed: true,
|
|
653
|
+
can_claim: false,
|
|
654
|
+
});
|
|
655
|
+
Object.assign(achievement, normalized);
|
|
656
|
+
}
|
|
657
|
+
else {
|
|
658
|
+
const claimedTimestamp = unlockedAt ?? new Date().toISOString();
|
|
659
|
+
achievement.is_unlocked = true;
|
|
660
|
+
achievement.is_claimed = true;
|
|
661
|
+
achievement.can_claim = false;
|
|
662
|
+
achievement.unlocked_at = claimedTimestamp;
|
|
663
|
+
achievement.claimed_at = claimedTimestamp;
|
|
664
|
+
achievement.progress_percentage = 100;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
if (payload.data?.user_stats) {
|
|
668
|
+
userStats.value = payload.data.user_stats;
|
|
669
|
+
}
|
|
670
|
+
else if (typeof payload.meta?.new_total_points === 'number' && userStats.value) {
|
|
671
|
+
userStats.value.total_points = payload.meta.new_total_points;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
catch (error) {
|
|
675
|
+
logger.error('Failed to claim achievement:', error);
|
|
676
|
+
throw error;
|
|
677
|
+
}
|
|
678
|
+
finally {
|
|
679
|
+
claiming.value = false;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
async function acceptQuest(questId) {
|
|
683
|
+
try {
|
|
684
|
+
const response = await client.post(`/v1/gamification/quests/${questId}/accept`);
|
|
685
|
+
const normalized = normalizeQuest(response.data.data);
|
|
686
|
+
const index = quests.value.findIndex((quest) => quest.id === questId);
|
|
687
|
+
if (index !== -1) {
|
|
688
|
+
quests.value.splice(index, 1, normalized);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
catch (error) {
|
|
692
|
+
logger.error('Failed to accept quest:', error);
|
|
693
|
+
throw error;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
async function completeQuest(questId) {
|
|
697
|
+
try {
|
|
698
|
+
const quest = quests.value.find((item) => item.id === questId);
|
|
699
|
+
const idempotencyKey = createQuestCompletionIdempotencyKey(questId);
|
|
700
|
+
const progressData = quest?.objectives.map((objective) => ({
|
|
701
|
+
current: objective.current_value ?? objective.current,
|
|
702
|
+
completed: objective.completed ?? objective.is_completed ?? false,
|
|
703
|
+
}));
|
|
704
|
+
const requestPayload = progressData !== undefined && progressData.length > 0
|
|
705
|
+
? { progress_data: progressData }
|
|
706
|
+
: {};
|
|
707
|
+
const response = await client.post(`/v1/gamification/quests/${questId}/complete`, requestPayload, {
|
|
708
|
+
headers: {
|
|
709
|
+
'Idempotency-Key': idempotencyKey,
|
|
710
|
+
},
|
|
711
|
+
});
|
|
712
|
+
const payload = response.data.data;
|
|
713
|
+
const index = quests.value.findIndex((item) => item.id === questId);
|
|
714
|
+
if (index !== -1 && quest) {
|
|
715
|
+
const updated = {
|
|
716
|
+
...quest,
|
|
717
|
+
completed_at: payload.completed_at,
|
|
718
|
+
progress_percentage: 100,
|
|
719
|
+
objectives: quest.objectives.map((objective) => ({
|
|
720
|
+
...objective,
|
|
721
|
+
completed: true,
|
|
722
|
+
})),
|
|
723
|
+
};
|
|
724
|
+
quests.value.splice(index, 1, updated);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
catch (error) {
|
|
728
|
+
logger.error('Failed to complete quest:', error);
|
|
729
|
+
throw error;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
async function abandonQuest(questId) {
|
|
733
|
+
try {
|
|
734
|
+
await client.delete(`/v1/gamification/quests/${questId}/abandon`);
|
|
735
|
+
await loadQuests(false);
|
|
736
|
+
}
|
|
737
|
+
catch (error) {
|
|
738
|
+
logger.error('Failed to abandon quest:', error);
|
|
739
|
+
throw error;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
async function redeemReward(rewardId) {
|
|
743
|
+
if (claiming.value) {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
claiming.value = true;
|
|
747
|
+
try {
|
|
748
|
+
const response = await client.post(`/v1/gamification/rewards/${rewardId}/redeem`);
|
|
749
|
+
const responseData = response.data;
|
|
750
|
+
const reward = rewards.value.find((entry) => entry.id === rewardId);
|
|
751
|
+
if (reward && responseData.data?.reward) {
|
|
752
|
+
Object.assign(reward, responseData.data.reward);
|
|
753
|
+
}
|
|
754
|
+
if (responseData.data?.claimed_reward) {
|
|
755
|
+
claimedRewards.value.unshift(responseData.data.claimed_reward);
|
|
756
|
+
}
|
|
757
|
+
if (responseData.data?.user_stats) {
|
|
758
|
+
userStats.value = responseData.data.user_stats;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
catch (error) {
|
|
762
|
+
logger.error('Failed to redeem reward:', error);
|
|
763
|
+
throw error;
|
|
764
|
+
}
|
|
765
|
+
finally {
|
|
766
|
+
claiming.value = false;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
let subscribedGamificationChannel = null;
|
|
770
|
+
let subscribedLeaderboardChannel = null;
|
|
771
|
+
let shouldMaintainRealtimeSubscription = false;
|
|
772
|
+
function subscribeToGamificationUpdates() {
|
|
773
|
+
const currentUser = getCurrentUser();
|
|
774
|
+
if (!currentUser?.id) {
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
shouldMaintainRealtimeSubscription = true;
|
|
778
|
+
const channelName = `gamification.${currentUser.id}`;
|
|
779
|
+
const echo = getEcho();
|
|
780
|
+
if (!echo) {
|
|
781
|
+
debugLog(`Gamification: Echo unavailable; waiting to subscribe to ${channelName}`);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
const tenantScopeValue = currentUser.tenant_id ?? currentUser.tenantId;
|
|
785
|
+
const tenantScope = typeof tenantScopeValue === 'string' && tenantScopeValue.length > 0
|
|
786
|
+
? tenantScopeValue
|
|
787
|
+
: 'global';
|
|
788
|
+
const leaderboardChannelName = `leaderboard.${tenantScope}`;
|
|
789
|
+
if (subscribedGamificationChannel === channelName
|
|
790
|
+
&& subscribedLeaderboardChannel === leaderboardChannelName) {
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (subscribedGamificationChannel && subscribedGamificationChannel !== channelName) {
|
|
794
|
+
const staleChannel = echo.private(subscribedGamificationChannel);
|
|
795
|
+
staleChannel.stopListening('.achievement.unlocked');
|
|
796
|
+
staleChannel.stopListening('.quest.completed');
|
|
797
|
+
staleChannel.stopListening('.quest.accepted');
|
|
798
|
+
staleChannel.stopListening('.quest.progress-updated');
|
|
799
|
+
staleChannel.stopListening('.level.up');
|
|
800
|
+
staleChannel.stopListening('.points.awarded');
|
|
801
|
+
staleChannel.stopListening('.reward.redeemed');
|
|
802
|
+
}
|
|
803
|
+
if (subscribedLeaderboardChannel) {
|
|
804
|
+
echo.leave(subscribedLeaderboardChannel);
|
|
805
|
+
}
|
|
806
|
+
debugLog(`Gamification: Subscribing to ${channelName}`);
|
|
807
|
+
const privateChannel = echo.private(channelName);
|
|
808
|
+
privateChannel.listen('.achievement.unlocked', (event) => {
|
|
809
|
+
handleAchievementUnlocked(event);
|
|
810
|
+
});
|
|
811
|
+
privateChannel.listen('.quest.completed', (event) => {
|
|
812
|
+
handleQuestCompleted(event);
|
|
813
|
+
});
|
|
814
|
+
privateChannel.listen('.quest.accepted', (event) => {
|
|
815
|
+
handleQuestAccepted(event);
|
|
816
|
+
});
|
|
817
|
+
privateChannel.listen('.quest.progress-updated', (event) => {
|
|
818
|
+
handleQuestProgressUpdated(event);
|
|
819
|
+
});
|
|
820
|
+
privateChannel.listen('.level.up', (event) => {
|
|
821
|
+
handleLevelUp(event);
|
|
822
|
+
});
|
|
823
|
+
privateChannel.listen('.points.awarded', (event) => {
|
|
824
|
+
handlePointsAwarded(event);
|
|
825
|
+
});
|
|
826
|
+
privateChannel.listen('.reward.redeemed', (event) => {
|
|
827
|
+
handleRewardClaimed(event);
|
|
828
|
+
});
|
|
829
|
+
subscribedGamificationChannel = channelName;
|
|
830
|
+
const leaderboardChannel = echo.private(leaderboardChannelName);
|
|
831
|
+
leaderboardChannel.listen('.leaderboard.position-changed', (event) => {
|
|
832
|
+
handleLeaderboardUpdated(event);
|
|
833
|
+
});
|
|
834
|
+
subscribedLeaderboardChannel = leaderboardChannelName;
|
|
835
|
+
}
|
|
836
|
+
function unsubscribeFromGamificationUpdates() {
|
|
837
|
+
shouldMaintainRealtimeSubscription = false;
|
|
838
|
+
if (!subscribedGamificationChannel && !subscribedLeaderboardChannel) {
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
const echo = getEcho();
|
|
842
|
+
if (echo) {
|
|
843
|
+
debugLog('Gamification: Unsubscribing from all channels');
|
|
844
|
+
if (subscribedGamificationChannel) {
|
|
845
|
+
const channel = echo.private(subscribedGamificationChannel);
|
|
846
|
+
channel.stopListening('.achievement.unlocked');
|
|
847
|
+
channel.stopListening('.quest.completed');
|
|
848
|
+
channel.stopListening('.quest.accepted');
|
|
849
|
+
channel.stopListening('.quest.progress-updated');
|
|
850
|
+
channel.stopListening('.level.up');
|
|
851
|
+
channel.stopListening('.points.awarded');
|
|
852
|
+
channel.stopListening('.reward.redeemed');
|
|
853
|
+
}
|
|
854
|
+
if (subscribedLeaderboardChannel) {
|
|
855
|
+
echo.leave(subscribedLeaderboardChannel);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
subscribedGamificationChannel = null;
|
|
859
|
+
subscribedLeaderboardChannel = null;
|
|
860
|
+
}
|
|
861
|
+
const stopListeningToConnectionStatus = onConnectionStatusChange((status) => {
|
|
862
|
+
if (status !== 'connected') {
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
if (!shouldMaintainRealtimeSubscription) {
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
subscribedGamificationChannel = null;
|
|
869
|
+
subscribedLeaderboardChannel = null;
|
|
870
|
+
subscribeToGamificationUpdates();
|
|
871
|
+
});
|
|
872
|
+
if (typeof stopListeningToConnectionStatus === 'function') {
|
|
873
|
+
onScopeDispose(() => {
|
|
874
|
+
stopListeningToConnectionStatus();
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
function handleAchievementUnlocked(event) {
|
|
878
|
+
debugLog('Gamification: Achievement unlocked event', event);
|
|
879
|
+
const achievement = achievements.value.find((entry) => entry.id === event.achievement_id);
|
|
880
|
+
if (achievement) {
|
|
881
|
+
achievement.unlocked_at = event.unlocked_at;
|
|
882
|
+
delete achievement.progress;
|
|
883
|
+
}
|
|
884
|
+
if (event.user_stats) {
|
|
885
|
+
userStats.value = event.user_stats;
|
|
886
|
+
}
|
|
887
|
+
const payload = event.achievement;
|
|
888
|
+
const title = typeof payload?.name === 'string' && payload.name.length > 0
|
|
889
|
+
? payload.name
|
|
890
|
+
: achievement?.name ?? 'Achievement Unlocked';
|
|
891
|
+
const description = typeof payload?.description === 'string' && payload.description.length > 0
|
|
892
|
+
? payload.description
|
|
893
|
+
: achievement?.description ?? 'You unlocked a new achievement.';
|
|
894
|
+
const icon = typeof payload?.icon === 'string' && payload.icon.length > 0
|
|
895
|
+
? payload.icon
|
|
896
|
+
: achievement?.icon ?? 'trophy';
|
|
897
|
+
const notification = {
|
|
898
|
+
achievementId: event.achievement_id,
|
|
899
|
+
title,
|
|
900
|
+
description,
|
|
901
|
+
icon,
|
|
902
|
+
unlockedAt: event.unlocked_at,
|
|
903
|
+
};
|
|
904
|
+
if (typeof payload?.reward_points === 'number') {
|
|
905
|
+
notification.points = payload.reward_points;
|
|
906
|
+
}
|
|
907
|
+
else if (typeof achievement?.reward_points === 'number') {
|
|
908
|
+
notification.points = achievement.reward_points;
|
|
909
|
+
}
|
|
910
|
+
else if (typeof achievement?.points_awarded === 'number') {
|
|
911
|
+
notification.points = achievement.points_awarded;
|
|
912
|
+
}
|
|
913
|
+
lastAchievementUnlockedEvent.value = notification;
|
|
914
|
+
}
|
|
915
|
+
function clearLastAchievementUnlockedEvent() {
|
|
916
|
+
lastAchievementUnlockedEvent.value = null;
|
|
917
|
+
}
|
|
918
|
+
function handleQuestCompleted(event) {
|
|
919
|
+
debugLog('Gamification: Quest completed event', event);
|
|
920
|
+
const quest = quests.value.find((entry) => entry.id === event.quest_id);
|
|
921
|
+
if (quest) {
|
|
922
|
+
quest.completed_at = event.completed_at;
|
|
923
|
+
quest.progress_percentage = 100;
|
|
924
|
+
}
|
|
925
|
+
if (event.user_stats) {
|
|
926
|
+
userStats.value = event.user_stats;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
function handleQuestAccepted(event) {
|
|
930
|
+
debugLog('Gamification: Quest accepted event', event);
|
|
931
|
+
const quest = quests.value.find((entry) => entry.id === event.quest.id);
|
|
932
|
+
if (quest) {
|
|
933
|
+
quest.accepted_at = event.timestamp;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
function handleQuestProgressUpdated(event) {
|
|
937
|
+
debugLog('Gamification: Quest progress updated event', event);
|
|
938
|
+
const quest = quests.value.find((entry) => entry.id === event.questId);
|
|
939
|
+
if (quest && event.progress) {
|
|
940
|
+
const progressPct = event.progress.percentage;
|
|
941
|
+
if (typeof progressPct === 'number') {
|
|
942
|
+
quest.progress_percentage = progressPct;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
function handleLevelUp(event) {
|
|
947
|
+
debugLog('Gamification: Level up event', event);
|
|
948
|
+
if (userStats.value) {
|
|
949
|
+
userStats.value.current_level = event.new_level;
|
|
950
|
+
userStats.value.level_progress = 0;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
function handlePointsAwarded(event) {
|
|
954
|
+
debugLog('Gamification: Points awarded event', event);
|
|
955
|
+
if (userStats.value) {
|
|
956
|
+
if (event.lifetime_points !== undefined) {
|
|
957
|
+
userStats.value.lifetime_points = event.lifetime_points;
|
|
958
|
+
}
|
|
959
|
+
else {
|
|
960
|
+
userStats.value.lifetime_points += event.points;
|
|
961
|
+
}
|
|
962
|
+
userStats.value.level_progress = event.level_progress ?? userStats.value.level_progress;
|
|
963
|
+
if (event.points_this_week !== undefined) {
|
|
964
|
+
userStats.value.points_this_week = event.points_this_week;
|
|
965
|
+
}
|
|
966
|
+
if (event.points_this_month !== undefined) {
|
|
967
|
+
userStats.value.points_this_month = event.points_this_month;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
const currentUserId = getCurrentUser()?.id;
|
|
971
|
+
if (currentUserId && leaderboard.value.length > 0) {
|
|
972
|
+
const entryIndex = leaderboard.value.findIndex((entry) => entry.user_id === currentUserId);
|
|
973
|
+
if (entryIndex !== -1) {
|
|
974
|
+
const entry = leaderboard.value[entryIndex];
|
|
975
|
+
if (entry) {
|
|
976
|
+
if (event.total_points !== undefined) {
|
|
977
|
+
entry.total_points = event.total_points;
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
entry.total_points += event.points;
|
|
981
|
+
}
|
|
982
|
+
lastLeaderboardUpdate.value = new Date();
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
function handleRewardClaimed(event) {
|
|
988
|
+
debugLog('Gamification: Reward claimed event', event);
|
|
989
|
+
if (event.reward_id && event.reward) {
|
|
990
|
+
const reward = rewards.value.find((entry) => entry.id === event.reward_id);
|
|
991
|
+
if (reward) {
|
|
992
|
+
Object.assign(reward, event.reward);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
if (event.claimed_reward) {
|
|
996
|
+
claimedRewards.value.unshift(event.claimed_reward);
|
|
997
|
+
}
|
|
998
|
+
if (event.user_stats) {
|
|
999
|
+
userStats.value = event.user_stats;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
function handleLeaderboardUpdated(event) {
|
|
1003
|
+
debugLog('Gamification: Leaderboard position changed event', event);
|
|
1004
|
+
if (activeLeaderboardContext.value.period !== 'all-time') {
|
|
1005
|
+
const { period, limit } = activeLeaderboardContext.value;
|
|
1006
|
+
void loadLeaderboard(period, limit).catch((error) => {
|
|
1007
|
+
logger.error('Failed to refresh period leaderboard after realtime update:', error);
|
|
1008
|
+
});
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
const existingIndex = leaderboard.value.findIndex((entry) => entry.user_id === event.userId);
|
|
1012
|
+
if (existingIndex !== -1) {
|
|
1013
|
+
const entry = leaderboard.value[existingIndex];
|
|
1014
|
+
if (entry) {
|
|
1015
|
+
entry.rank = event.newRank;
|
|
1016
|
+
entry.total_points = event.totalPoints;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
leaderboard.value.sort((left, right) => {
|
|
1020
|
+
if (right.total_points !== left.total_points) {
|
|
1021
|
+
return right.total_points - left.total_points;
|
|
1022
|
+
}
|
|
1023
|
+
if (left.rank !== right.rank) {
|
|
1024
|
+
return left.rank - right.rank;
|
|
1025
|
+
}
|
|
1026
|
+
return left.user_id.localeCompare(right.user_id);
|
|
1027
|
+
});
|
|
1028
|
+
leaderboard.value.forEach((entry, index) => {
|
|
1029
|
+
entry.rank = index + 1;
|
|
1030
|
+
});
|
|
1031
|
+
lastLeaderboardUpdate.value = new Date();
|
|
1032
|
+
}
|
|
1033
|
+
async function loadAllData() {
|
|
1034
|
+
try {
|
|
1035
|
+
await Promise.all([
|
|
1036
|
+
loadUserStats(),
|
|
1037
|
+
loadAchievements(),
|
|
1038
|
+
loadQuests(),
|
|
1039
|
+
loadRewards(),
|
|
1040
|
+
loadLeaderboard(),
|
|
1041
|
+
]);
|
|
1042
|
+
}
|
|
1043
|
+
catch (error) {
|
|
1044
|
+
logger.error('Failed to load gamification data:', error);
|
|
1045
|
+
throw error;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
function $reset() {
|
|
1049
|
+
achievements.value = [];
|
|
1050
|
+
quests.value = [];
|
|
1051
|
+
dailyQuests.value = [];
|
|
1052
|
+
questFilter.value = 'active';
|
|
1053
|
+
rewards.value = [];
|
|
1054
|
+
claimedRewards.value = [];
|
|
1055
|
+
rewardTransactions.value = [];
|
|
1056
|
+
leaderboard.value = [];
|
|
1057
|
+
activeLeaderboardContext.value = {
|
|
1058
|
+
period: 'all-time',
|
|
1059
|
+
limit: 50,
|
|
1060
|
+
};
|
|
1061
|
+
userStats.value = null;
|
|
1062
|
+
lastAchievementUnlockedEvent.value = null;
|
|
1063
|
+
loadingAchievements.value = false;
|
|
1064
|
+
loadingQuests.value = false;
|
|
1065
|
+
loadingRewards.value = false;
|
|
1066
|
+
loadingLeaderboard.value = false;
|
|
1067
|
+
loadingStats.value = false;
|
|
1068
|
+
claiming.value = false;
|
|
1069
|
+
lastAchievementsUpdate.value = null;
|
|
1070
|
+
lastQuestsUpdate.value = null;
|
|
1071
|
+
lastRewardsUpdate.value = null;
|
|
1072
|
+
lastLeaderboardUpdate.value = null;
|
|
1073
|
+
lastStatsUpdate.value = null;
|
|
1074
|
+
}
|
|
1075
|
+
return {
|
|
1076
|
+
achievements,
|
|
1077
|
+
quests,
|
|
1078
|
+
dailyQuests,
|
|
1079
|
+
questFilter,
|
|
1080
|
+
rewards,
|
|
1081
|
+
claimedRewards,
|
|
1082
|
+
rewardTransactions,
|
|
1083
|
+
leaderboard,
|
|
1084
|
+
activeLeaderboardContext,
|
|
1085
|
+
userStats,
|
|
1086
|
+
lastAchievementUnlockedEvent,
|
|
1087
|
+
loadingAchievements,
|
|
1088
|
+
loadingQuests,
|
|
1089
|
+
loadingRewards,
|
|
1090
|
+
loadingLeaderboard,
|
|
1091
|
+
loadingStats,
|
|
1092
|
+
claiming,
|
|
1093
|
+
lastAchievementsUpdate,
|
|
1094
|
+
lastQuestsUpdate,
|
|
1095
|
+
lastRewardsUpdate,
|
|
1096
|
+
lastLeaderboardUpdate,
|
|
1097
|
+
lastStatsUpdate,
|
|
1098
|
+
isAchievementsStale,
|
|
1099
|
+
isQuestsStale,
|
|
1100
|
+
isRewardsStale,
|
|
1101
|
+
isLeaderboardStale,
|
|
1102
|
+
isStatsStale,
|
|
1103
|
+
unlockedAchievements,
|
|
1104
|
+
availableAchievements,
|
|
1105
|
+
activeQuests,
|
|
1106
|
+
completedQuests,
|
|
1107
|
+
rewardBalance,
|
|
1108
|
+
currentXP,
|
|
1109
|
+
currentLevel,
|
|
1110
|
+
nextLevel,
|
|
1111
|
+
levelProgress,
|
|
1112
|
+
recentXPGain,
|
|
1113
|
+
loadAchievements,
|
|
1114
|
+
loadQuests,
|
|
1115
|
+
loadCompletedQuests,
|
|
1116
|
+
loadRewards,
|
|
1117
|
+
loadLeaderboard,
|
|
1118
|
+
loadUserStats,
|
|
1119
|
+
loadAchievementById,
|
|
1120
|
+
setQuestFilter,
|
|
1121
|
+
getAchievementShareCard,
|
|
1122
|
+
fetchAchievementShareCardAsset,
|
|
1123
|
+
claimAchievement,
|
|
1124
|
+
acceptQuest,
|
|
1125
|
+
completeQuest,
|
|
1126
|
+
abandonQuest,
|
|
1127
|
+
redeemReward,
|
|
1128
|
+
subscribeToGamificationUpdates,
|
|
1129
|
+
unsubscribeFromGamificationUpdates,
|
|
1130
|
+
clearLastAchievementUnlockedEvent,
|
|
1131
|
+
loadAllData,
|
|
1132
|
+
$reset,
|
|
1133
|
+
};
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
//# sourceMappingURL=gamification.js.map
|