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 { storage } from "@/lib/supabase";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useXP } from "@/hooks/use-xp";
|
||||
|
||||
function readFileAsDataURL(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -26,6 +27,7 @@ export default function PostComposer({
|
|||
}) {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const { awardXP } = useXP();
|
||||
const [text, setText] = useState("");
|
||||
const [mediaFile, setMediaFile] = useState<File | null>(null);
|
||||
const [mediaUrlInput, setMediaUrlInput] = useState("");
|
||||
|
|
@ -136,6 +138,10 @@ export default function PostComposer({
|
|||
} as any);
|
||||
|
||||
toast({ title: "Posted", description: "Your update is live" });
|
||||
|
||||
// Award XP for creating a post
|
||||
awardXP("create_post").catch(() => {});
|
||||
|
||||
reset();
|
||||
onPosted?.();
|
||||
} 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) {
|
||||
// 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
|
||||
.from("user_profiles")
|
||||
.update({
|
||||
current_streak: current,
|
||||
longest_streak: longest,
|
||||
last_streak_at: isoToday,
|
||||
total_xp: newTotalXp,
|
||||
level: newLevel,
|
||||
})
|
||||
.eq("id", profile.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -592,8 +621,34 @@ export const aethexUserService = {
|
|||
userId,
|
||||
"success",
|
||||
"🎉 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) {
|
||||
console.warn(
|
||||
"Failed to create onboarding notification:",
|
||||
|
|
@ -602,7 +657,7 @@ export const aethexUserService = {
|
|||
}
|
||||
}
|
||||
|
||||
return upserted as AethexUserProfile;
|
||||
return normalizeProfile(upserted);
|
||||
}
|
||||
|
||||
if (isTableMissing(error)) {
|
||||
|
|
@ -620,8 +675,34 @@ export const aethexUserService = {
|
|||
userId,
|
||||
"success",
|
||||
"🎉 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) {
|
||||
console.warn("Failed to create onboarding notification:", notifError);
|
||||
}
|
||||
|
|
@ -1518,12 +1599,45 @@ export const aethexBadgeService = {
|
|||
userId,
|
||||
"success",
|
||||
`🏆 Badge Earned: ${badge.name}`,
|
||||
`Congratulations! You've earned the "${badge.name}" badge.`,
|
||||
`Congratulations! You've earned the "${badge.name}" badge. +200 XP`,
|
||||
);
|
||||
} catch (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;
|
||||
} catch (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