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:
sirpiglr 2025-12-12 23:44:00 +00:00
parent d7dc9d1066
commit 51f0cd2c7d
3 changed files with 538 additions and 4 deletions

View file

@ -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
View 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,
};
}

View file

@ -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",
};
}
},
};