Award experience points for user actions and improve level-up notifications
Introduces XP awarding for post creation, daily logins, and profile completion, alongside enhanced level-up notifications and refactored profile data handling. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 9203795e-937a-4306-b81d-b4d5c78c240e Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: ca9f859b-1a66-40d5-a421-8de34f2805fd Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7c94b7a0-29c7-4f2e-94ef-44b2153872b7/9203795e-937a-4306-b81d-b4d5c78c240e/MdI1YXa Replit-Helium-Checkpoint-Created: true
This commit is contained in:
parent
d7dc9d1066
commit
51f0cd2c7d
3 changed files with 538 additions and 4 deletions
|
|
@ -7,6 +7,7 @@ import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { communityService } from "@/lib/supabase-service";
|
import { communityService } from "@/lib/supabase-service";
|
||||||
import { storage } from "@/lib/supabase";
|
import { storage } from "@/lib/supabase";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useXP } from "@/hooks/use-xp";
|
||||||
|
|
||||||
function readFileAsDataURL(file: File): Promise<string> {
|
function readFileAsDataURL(file: File): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
@ -26,6 +27,7 @@ export default function PostComposer({
|
||||||
}) {
|
}) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { awardXP } = useXP();
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [mediaFile, setMediaFile] = useState<File | null>(null);
|
const [mediaFile, setMediaFile] = useState<File | null>(null);
|
||||||
const [mediaUrlInput, setMediaUrlInput] = useState("");
|
const [mediaUrlInput, setMediaUrlInput] = useState("");
|
||||||
|
|
@ -136,6 +138,10 @@ export default function PostComposer({
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
toast({ title: "Posted", description: "Your update is live" });
|
toast({ title: "Posted", description: "Your update is live" });
|
||||||
|
|
||||||
|
// Award XP for creating a post
|
||||||
|
awardXP("create_post").catch(() => {});
|
||||||
|
|
||||||
reset();
|
reset();
|
||||||
onPosted?.();
|
onPosted?.();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
|
||||||
112
client/hooks/use-xp.ts
Normal file
112
client/hooks/use-xp.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import {
|
||||||
|
aethexXPService,
|
||||||
|
XPEventType,
|
||||||
|
XPAwardResult,
|
||||||
|
calculateLevel,
|
||||||
|
levelProgress,
|
||||||
|
xpForNextLevel,
|
||||||
|
} from "@/lib/aethex-database-adapter";
|
||||||
|
import { aethexToast } from "@/lib/aethex-toast";
|
||||||
|
|
||||||
|
export interface UseXPReturn {
|
||||||
|
awardXP: (eventType: XPEventType, multiplier?: number) => Promise<XPAwardResult>;
|
||||||
|
awardCustomXP: (amount: number, reason?: string) => Promise<XPAwardResult>;
|
||||||
|
awardStreakBonus: (streakDays: number) => Promise<XPAwardResult>;
|
||||||
|
calculateLevel: typeof calculateLevel;
|
||||||
|
levelProgress: typeof levelProgress;
|
||||||
|
xpForNextLevel: typeof xpForNextLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useXP(): UseXPReturn {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const showLevelUpToast = useCallback((result: XPAwardResult) => {
|
||||||
|
if (result.leveledUp) {
|
||||||
|
aethexToast.aethex({
|
||||||
|
title: `Level Up! Level ${result.newLevel}`,
|
||||||
|
description: `You've earned ${result.xpAwarded} XP and reached level ${result.newLevel}!`,
|
||||||
|
duration: 6000,
|
||||||
|
});
|
||||||
|
} else if (result.success && result.xpAwarded > 0) {
|
||||||
|
aethexToast.success({
|
||||||
|
title: `+${result.xpAwarded} XP`,
|
||||||
|
description: `Total: ${result.newTotalXp} XP`,
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const awardXP = useCallback(
|
||||||
|
async (eventType: XPEventType, multiplier: number = 1): Promise<XPAwardResult> => {
|
||||||
|
if (!user?.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
xpAwarded: 0,
|
||||||
|
newTotalXp: 0,
|
||||||
|
previousLevel: 1,
|
||||||
|
newLevel: 1,
|
||||||
|
leveledUp: false,
|
||||||
|
error: "Not logged in",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await aethexXPService.awardXP(user.id, eventType, multiplier);
|
||||||
|
showLevelUpToast(result);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
[user?.id, showLevelUpToast]
|
||||||
|
);
|
||||||
|
|
||||||
|
const awardCustomXP = useCallback(
|
||||||
|
async (amount: number, reason?: string): Promise<XPAwardResult> => {
|
||||||
|
if (!user?.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
xpAwarded: 0,
|
||||||
|
newTotalXp: 0,
|
||||||
|
previousLevel: 1,
|
||||||
|
newLevel: 1,
|
||||||
|
leveledUp: false,
|
||||||
|
error: "Not logged in",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await aethexXPService.awardCustomXP(user.id, amount, reason);
|
||||||
|
showLevelUpToast(result);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
[user?.id, showLevelUpToast]
|
||||||
|
);
|
||||||
|
|
||||||
|
const awardStreakBonus = useCallback(
|
||||||
|
async (streakDays: number): Promise<XPAwardResult> => {
|
||||||
|
if (!user?.id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
xpAwarded: 0,
|
||||||
|
newTotalXp: 0,
|
||||||
|
previousLevel: 1,
|
||||||
|
newLevel: 1,
|
||||||
|
leveledUp: false,
|
||||||
|
error: "Not logged in",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await aethexXPService.awardStreakBonus(user.id, streakDays);
|
||||||
|
showLevelUpToast(result);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
[user?.id, showLevelUpToast]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
awardXP,
|
||||||
|
awardCustomXP,
|
||||||
|
awardStreakBonus,
|
||||||
|
calculateLevel,
|
||||||
|
levelProgress,
|
||||||
|
xpForNextLevel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -114,18 +114,47 @@ const ensureDailyStreakForProfile = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
|
// Calculate XP for daily login: 25 base + 10 per streak day (capped at 30 days)
|
||||||
|
const streakBonus = Math.min(current, 30) * 10;
|
||||||
|
const dailyLoginXp = 25 + streakBonus;
|
||||||
|
const currentTotalXp = Number((profile as any).total_xp) || 0;
|
||||||
|
const newTotalXp = currentTotalXp + dailyLoginXp;
|
||||||
|
const newLevel = Math.max(1, Math.floor(newTotalXp / 1000) + 1);
|
||||||
|
const previousLevel = Number((profile as any).level) || Math.max(1, Math.floor(currentTotalXp / 1000) + 1);
|
||||||
|
const leveledUp = newLevel > previousLevel;
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("user_profiles")
|
.from("user_profiles")
|
||||||
.update({
|
.update({
|
||||||
current_streak: current,
|
current_streak: current,
|
||||||
longest_streak: longest,
|
longest_streak: longest,
|
||||||
last_streak_at: isoToday,
|
last_streak_at: isoToday,
|
||||||
|
total_xp: newTotalXp,
|
||||||
|
level: newLevel,
|
||||||
})
|
})
|
||||||
.eq("id", profile.id)
|
.eq("id", profile.id)
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (!error && data) {
|
if (!error && data) {
|
||||||
|
// Create notification for daily login XP (async, don't await)
|
||||||
|
aethexNotificationService.createNotification(
|
||||||
|
profile.id,
|
||||||
|
"success",
|
||||||
|
`🌟 Daily Login: +${dailyLoginXp} XP`,
|
||||||
|
`Day ${current} streak! Keep it up for more bonus XP.`,
|
||||||
|
).catch(() => {});
|
||||||
|
|
||||||
|
// If leveled up, create level-up notification
|
||||||
|
if (leveledUp) {
|
||||||
|
aethexNotificationService.createNotification(
|
||||||
|
profile.id,
|
||||||
|
"success",
|
||||||
|
`🎉 Level Up! You're now Level ${newLevel}!`,
|
||||||
|
`Congratulations! You've reached level ${newLevel}. Keep up the great work!`,
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
return normalizeProfile(data, profile.email);
|
return normalizeProfile(data, profile.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -592,8 +621,34 @@ export const aethexUserService = {
|
||||||
userId,
|
userId,
|
||||||
"success",
|
"success",
|
||||||
"🎉 Welcome to AeThex!",
|
"🎉 Welcome to AeThex!",
|
||||||
"You've completed your profile setup. Let's get started!",
|
"You've completed your profile setup. Let's get started! +100 XP",
|
||||||
);
|
);
|
||||||
|
// Award XP for completing profile (100 XP) and check for level-up
|
||||||
|
const currentXp = Number((upserted as any).total_xp) || 0;
|
||||||
|
const previousLevel = Number((upserted as any).level) || Math.max(1, Math.floor(currentXp / 1000) + 1);
|
||||||
|
const newXp = currentXp + 100;
|
||||||
|
const newLevel = Math.max(1, Math.floor(newXp / 1000) + 1);
|
||||||
|
|
||||||
|
const { data: updatedProfile } = await supabase
|
||||||
|
.from("user_profiles")
|
||||||
|
.update({ total_xp: newXp, level: newLevel })
|
||||||
|
.eq("id", userId)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
// Level-up notification if leveled up
|
||||||
|
if (newLevel > previousLevel) {
|
||||||
|
await aethexNotificationService.createNotification(
|
||||||
|
userId,
|
||||||
|
"success",
|
||||||
|
`🎉 Level Up! You're now Level ${newLevel}!`,
|
||||||
|
`Congratulations! You've reached level ${newLevel}. Keep up the great work!`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedProfile) {
|
||||||
|
return normalizeProfile(updatedProfile);
|
||||||
|
}
|
||||||
} catch (notifError) {
|
} catch (notifError) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"Failed to create onboarding notification:",
|
"Failed to create onboarding notification:",
|
||||||
|
|
@ -602,7 +657,7 @@ export const aethexUserService = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return upserted as AethexUserProfile;
|
return normalizeProfile(upserted);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTableMissing(error)) {
|
if (isTableMissing(error)) {
|
||||||
|
|
@ -620,8 +675,34 @@ export const aethexUserService = {
|
||||||
userId,
|
userId,
|
||||||
"success",
|
"success",
|
||||||
"🎉 Welcome to AeThex!",
|
"🎉 Welcome to AeThex!",
|
||||||
"You've completed your profile setup. Let's get started!",
|
"You've completed your profile setup. Let's get started! +100 XP",
|
||||||
);
|
);
|
||||||
|
// Award XP for completing profile (100 XP) and check for level-up
|
||||||
|
const currentXp = Number((data as any).total_xp) || 0;
|
||||||
|
const previousLevel = Number((data as any).level) || Math.max(1, Math.floor(currentXp / 1000) + 1);
|
||||||
|
const newXp = currentXp + 100;
|
||||||
|
const newLevel = Math.max(1, Math.floor(newXp / 1000) + 1);
|
||||||
|
|
||||||
|
const { data: updatedProfile } = await supabase
|
||||||
|
.from("user_profiles")
|
||||||
|
.update({ total_xp: newXp, level: newLevel })
|
||||||
|
.eq("id", userId)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
// Level-up notification if leveled up
|
||||||
|
if (newLevel > previousLevel) {
|
||||||
|
await aethexNotificationService.createNotification(
|
||||||
|
userId,
|
||||||
|
"success",
|
||||||
|
`🎉 Level Up! You're now Level ${newLevel}!`,
|
||||||
|
`Congratulations! You've reached level ${newLevel}. Keep up the great work!`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedProfile) {
|
||||||
|
return normalizeProfile(updatedProfile);
|
||||||
|
}
|
||||||
} catch (notifError) {
|
} catch (notifError) {
|
||||||
console.warn("Failed to create onboarding notification:", notifError);
|
console.warn("Failed to create onboarding notification:", notifError);
|
||||||
}
|
}
|
||||||
|
|
@ -1518,12 +1599,45 @@ export const aethexBadgeService = {
|
||||||
userId,
|
userId,
|
||||||
"success",
|
"success",
|
||||||
`🏆 Badge Earned: ${badge.name}`,
|
`🏆 Badge Earned: ${badge.name}`,
|
||||||
`Congratulations! You've earned the "${badge.name}" badge.`,
|
`Congratulations! You've earned the "${badge.name}" badge. +200 XP`,
|
||||||
);
|
);
|
||||||
} catch (notifErr) {
|
} catch (notifErr) {
|
||||||
console.warn("Failed to create badge notification:", notifErr);
|
console.warn("Failed to create badge notification:", notifErr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Award XP for earning badge (200 XP)
|
||||||
|
try {
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from("user_profiles")
|
||||||
|
.select("total_xp, level")
|
||||||
|
.eq("id", userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (profile) {
|
||||||
|
const currentXp = Number(profile.total_xp) || 0;
|
||||||
|
const newXp = currentXp + 200;
|
||||||
|
const newLevel = Math.max(1, Math.floor(newXp / 1000) + 1);
|
||||||
|
const previousLevel = Number(profile.level) || 1;
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from("user_profiles")
|
||||||
|
.update({ total_xp: newXp, level: newLevel })
|
||||||
|
.eq("id", userId);
|
||||||
|
|
||||||
|
// Create level-up notification if leveled up
|
||||||
|
if (newLevel > previousLevel) {
|
||||||
|
await aethexNotificationService.createNotification(
|
||||||
|
userId,
|
||||||
|
"success",
|
||||||
|
`🎉 Level Up! You're now Level ${newLevel}!`,
|
||||||
|
`Congratulations! You've reached level ${newLevel}. Keep up the great work!`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (xpErr) {
|
||||||
|
console.warn("Failed to award badge XP:", xpErr);
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Failed to award badge:", err);
|
console.warn("Failed to award badge:", err);
|
||||||
|
|
@ -1661,3 +1775,305 @@ export const aethexTierService = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// XP Events and Rewards
|
||||||
|
export type XPEventType =
|
||||||
|
| "daily_login"
|
||||||
|
| "profile_complete"
|
||||||
|
| "first_post"
|
||||||
|
| "create_post"
|
||||||
|
| "receive_like"
|
||||||
|
| "create_comment"
|
||||||
|
| "create_project"
|
||||||
|
| "complete_project"
|
||||||
|
| "earn_achievement"
|
||||||
|
| "earn_badge"
|
||||||
|
| "streak_bonus"
|
||||||
|
| "referral";
|
||||||
|
|
||||||
|
export const XP_REWARDS: Record<XPEventType, number> = {
|
||||||
|
daily_login: 25,
|
||||||
|
profile_complete: 100,
|
||||||
|
first_post: 50,
|
||||||
|
create_post: 20,
|
||||||
|
receive_like: 5,
|
||||||
|
create_comment: 10,
|
||||||
|
create_project: 75,
|
||||||
|
complete_project: 150,
|
||||||
|
earn_achievement: 100,
|
||||||
|
earn_badge: 200,
|
||||||
|
streak_bonus: 10, // Per day of streak
|
||||||
|
referral: 250,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate level from XP (1000 XP per level, level 1 starts at 0)
|
||||||
|
export const calculateLevel = (totalXp: number): number => {
|
||||||
|
return Math.max(1, Math.floor(totalXp / 1000) + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate XP needed for next level
|
||||||
|
export const xpForNextLevel = (currentLevel: number): number => {
|
||||||
|
return currentLevel * 1000;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate progress to next level (0-100%)
|
||||||
|
export const levelProgress = (totalXp: number): number => {
|
||||||
|
const xpInCurrentLevel = totalXp % 1000;
|
||||||
|
return Math.min(100, Math.round((xpInCurrentLevel / 1000) * 100));
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface XPAwardResult {
|
||||||
|
success: boolean;
|
||||||
|
xpAwarded: number;
|
||||||
|
newTotalXp: number;
|
||||||
|
previousLevel: number;
|
||||||
|
newLevel: number;
|
||||||
|
leveledUp: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// XP Service
|
||||||
|
export const aethexXPService = {
|
||||||
|
async getUserXP(userId: string): Promise<{ totalXp: number; level: number } | null> {
|
||||||
|
if (!userId) return null;
|
||||||
|
try {
|
||||||
|
ensureSupabase();
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("user_profiles")
|
||||||
|
.select("total_xp, level")
|
||||||
|
.eq("id", userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error || !data) return null;
|
||||||
|
const totalXp = Number(data.total_xp) || 0;
|
||||||
|
const level = Number(data.level) || calculateLevel(totalXp);
|
||||||
|
return { totalXp, level };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async awardXP(
|
||||||
|
userId: string,
|
||||||
|
eventType: XPEventType,
|
||||||
|
multiplier: number = 1
|
||||||
|
): Promise<XPAwardResult> {
|
||||||
|
if (!userId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
xpAwarded: 0,
|
||||||
|
newTotalXp: 0,
|
||||||
|
previousLevel: 1,
|
||||||
|
newLevel: 1,
|
||||||
|
leveledUp: false,
|
||||||
|
error: "No user ID provided",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSupabase();
|
||||||
|
|
||||||
|
// Get current XP and level
|
||||||
|
const { data: profile, error: fetchError } = await supabase
|
||||||
|
.from("user_profiles")
|
||||||
|
.select("total_xp, level")
|
||||||
|
.eq("id", userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
xpAwarded: 0,
|
||||||
|
newTotalXp: 0,
|
||||||
|
previousLevel: 1,
|
||||||
|
newLevel: 1,
|
||||||
|
leveledUp: false,
|
||||||
|
error: fetchError.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentXp = Number(profile?.total_xp) || 0;
|
||||||
|
const previousLevel = Number(profile?.level) || calculateLevel(currentXp);
|
||||||
|
|
||||||
|
// Calculate XP to award
|
||||||
|
const baseXp = XP_REWARDS[eventType] || 0;
|
||||||
|
const xpAwarded = Math.round(baseXp * multiplier);
|
||||||
|
const newTotalXp = currentXp + xpAwarded;
|
||||||
|
const newLevel = calculateLevel(newTotalXp);
|
||||||
|
const leveledUp = newLevel > previousLevel;
|
||||||
|
|
||||||
|
// Update user profile
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("user_profiles")
|
||||||
|
.update({
|
||||||
|
total_xp: newTotalXp,
|
||||||
|
level: newLevel,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", userId);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
xpAwarded: 0,
|
||||||
|
newTotalXp: currentXp,
|
||||||
|
previousLevel,
|
||||||
|
newLevel: previousLevel,
|
||||||
|
leveledUp: false,
|
||||||
|
error: updateError.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create level-up notification if leveled up
|
||||||
|
if (leveledUp) {
|
||||||
|
try {
|
||||||
|
await aethexNotificationService.createNotification(
|
||||||
|
userId,
|
||||||
|
"success",
|
||||||
|
`🎉 Level Up! You're now Level ${newLevel}!`,
|
||||||
|
`Congratulations! You've earned ${xpAwarded} XP and reached level ${newLevel}. Keep up the great work!`,
|
||||||
|
);
|
||||||
|
} catch (notifErr) {
|
||||||
|
console.warn("Failed to create level-up notification:", notifErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
xpAwarded,
|
||||||
|
newTotalXp,
|
||||||
|
previousLevel,
|
||||||
|
newLevel,
|
||||||
|
leveledUp,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
xpAwarded: 0,
|
||||||
|
newTotalXp: 0,
|
||||||
|
previousLevel: 1,
|
||||||
|
newLevel: 1,
|
||||||
|
leveledUp: false,
|
||||||
|
error: err?.message || "Unknown error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async awardStreakBonus(userId: string, streakDays: number): Promise<XPAwardResult> {
|
||||||
|
// Award bonus XP based on streak length (10 XP per day of streak)
|
||||||
|
const multiplier = Math.min(streakDays, 30); // Cap at 30 days
|
||||||
|
return this.awardXP(userId, "streak_bonus", multiplier);
|
||||||
|
},
|
||||||
|
|
||||||
|
async awardCustomXP(
|
||||||
|
userId: string,
|
||||||
|
amount: number,
|
||||||
|
reason?: string
|
||||||
|
): Promise<XPAwardResult> {
|
||||||
|
if (!userId || amount <= 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
xpAwarded: 0,
|
||||||
|
newTotalXp: 0,
|
||||||
|
previousLevel: 1,
|
||||||
|
newLevel: 1,
|
||||||
|
leveledUp: false,
|
||||||
|
error: "Invalid user ID or amount",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSupabase();
|
||||||
|
|
||||||
|
// Get current XP and level
|
||||||
|
const { data: profile, error: fetchError } = await supabase
|
||||||
|
.from("user_profiles")
|
||||||
|
.select("total_xp, level")
|
||||||
|
.eq("id", userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
xpAwarded: 0,
|
||||||
|
newTotalXp: 0,
|
||||||
|
previousLevel: 1,
|
||||||
|
newLevel: 1,
|
||||||
|
leveledUp: false,
|
||||||
|
error: fetchError.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentXp = Number(profile?.total_xp) || 0;
|
||||||
|
const previousLevel = Number(profile?.level) || calculateLevel(currentXp);
|
||||||
|
const newTotalXp = currentXp + amount;
|
||||||
|
const newLevel = calculateLevel(newTotalXp);
|
||||||
|
const leveledUp = newLevel > previousLevel;
|
||||||
|
|
||||||
|
// Update user profile
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("user_profiles")
|
||||||
|
.update({
|
||||||
|
total_xp: newTotalXp,
|
||||||
|
level: newLevel,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq("id", userId);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
xpAwarded: 0,
|
||||||
|
newTotalXp: currentXp,
|
||||||
|
previousLevel,
|
||||||
|
newLevel: previousLevel,
|
||||||
|
leveledUp: false,
|
||||||
|
error: updateError.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create notification for custom XP
|
||||||
|
try {
|
||||||
|
await aethexNotificationService.createNotification(
|
||||||
|
userId,
|
||||||
|
"success",
|
||||||
|
`⭐ +${amount} XP Earned!`,
|
||||||
|
reason || `You've earned ${amount} XP!`,
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Create level-up notification if leveled up
|
||||||
|
if (leveledUp) {
|
||||||
|
try {
|
||||||
|
await aethexNotificationService.createNotification(
|
||||||
|
userId,
|
||||||
|
"success",
|
||||||
|
`🎉 Level Up! You're now Level ${newLevel}!`,
|
||||||
|
`Congratulations! You've reached level ${newLevel}. Keep up the great work!`,
|
||||||
|
);
|
||||||
|
} catch (notifErr) {
|
||||||
|
console.warn("Failed to create level-up notification:", notifErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
xpAwarded: amount,
|
||||||
|
newTotalXp,
|
||||||
|
previousLevel,
|
||||||
|
newLevel,
|
||||||
|
leveledUp,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
xpAwarded: 0,
|
||||||
|
newTotalXp: 0,
|
||||||
|
previousLevel: 1,
|
||||||
|
newLevel: 1,
|
||||||
|
leveledUp: false,
|
||||||
|
error: err?.message || "Unknown error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue