From 51f0cd2c7db463b87a3cd1d5b752c92ba8a76dce Mon Sep 17 00:00:00 2001 From: sirpiglr <49359077-sirpiglr@users.noreply.replit.com> Date: Fri, 12 Dec 2025 23:44:00 +0000 Subject: [PATCH] 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 --- client/components/social/PostComposer.tsx | 6 + client/hooks/use-xp.ts | 112 ++++++ client/lib/aethex-database-adapter.ts | 424 +++++++++++++++++++++- 3 files changed, 538 insertions(+), 4 deletions(-) create mode 100644 client/hooks/use-xp.ts diff --git a/client/components/social/PostComposer.tsx b/client/components/social/PostComposer.tsx index 73ec38d0..852c935e 100644 --- a/client/components/social/PostComposer.tsx +++ b/client/components/social/PostComposer.tsx @@ -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 { 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(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) { diff --git a/client/hooks/use-xp.ts b/client/hooks/use-xp.ts new file mode 100644 index 00000000..6e8f43ca --- /dev/null +++ b/client/hooks/use-xp.ts @@ -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; + awardCustomXP: (amount: number, reason?: string) => Promise; + awardStreakBonus: (streakDays: number) => Promise; + 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 => { + 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 => { + 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 => { + 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, + }; +} diff --git a/client/lib/aethex-database-adapter.ts b/client/lib/aethex-database-adapter.ts index c243511f..0a56a9d5 100644 --- a/client/lib/aethex-database-adapter.ts +++ b/client/lib/aethex-database-adapter.ts @@ -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 = { + 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 { + 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 { + // 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 { + 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", + }; + } + }, +};