feat(ui): refactor gamification UI for role awareness and remove floaters

This commit is contained in:
Satyam
2026-02-22 01:24:26 +05:30
parent 058b5bb217
commit b8a20144d3
5 changed files with 668 additions and 96 deletions
+247
View File
@@ -0,0 +1,247 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { useAuth } from './AuthContext';
const GamificationContext = createContext();
const INITIAL_STATE = {
xp: 3550,
level: 12,
streak: 4,
todayPoints: 150,
weeklyPoints: 850,
dailyQuests: {
knocks: { current: 18, target: 20 },
leads: { current: 4, target: 5 },
inspections: { current: 0, target: 1 }
},
weeklyChallenge: {
knocks: { current: 140, target: 150 }
}
};
const LEADERBOARD_MOCK = [
{ id: '1', name: 'Alex M.', points: 1200, avatar: 'AM', isUser: false },
{ id: '2', name: 'Jessica R.', points: 1050, avatar: 'JR', isUser: false },
{ id: '3', name: 'David L.', points: 980, avatar: 'DL', isUser: false },
{ id: '4', name: 'Sarah M.', points: 900, avatar: 'SM', isUser: false },
{ id: 'user', name: 'You', points: 850, avatar: 'You', isUser: true },
{ id: '6', name: 'Mike T.', points: 700, avatar: 'MT', isUser: false },
{ id: '7', name: 'Emily C.', points: 650, avatar: 'EC', isUser: false },
];
export const GamificationProvider = ({ children }) => {
const { user } = useAuth();
const role = user?.role || 'FIELD_AGENT';
const [gameState, setGameState] = useState(INITIAL_STATE);
const [leaderboard, setLeaderboard] = useState(() => {
return LEADERBOARD_MOCK.map((u, i) => {
if (u.isUser) {
return { ...u, name: user?.name || 'You', role: role === 'FIELD_AGENT' ? 'Field Agent' : role === 'ADMIN' ? 'Admin' : 'Owner' };
}
// Assign realistic mock roles to others
const mockRole = i < 2 ? 'Owner' : (i < 4 ? 'Admin' : 'Field Agent');
return { ...u, role: mockRole };
});
});
// Ephemeral states for animations
const [recentAction, setRecentAction] = useState(null); // { id, x, y, amount, text, type }
const [levelUpData, setLevelUpData] = useState(null); // { newLevel, newTitle }
const [missionComplete, setMissionComplete] = useState(null); // { type, message }
const [rankToast, setRankToast] = useState(null); // { newRank, oldRank, message }
const getRankTitle = (level) => {
if (level <= 5) return 'Rookie';
if (level <= 15) return 'Field Runner';
if (level <= 30) return 'Roof Warrior';
if (level <= 45) return 'Territory Titan';
return 'Legendary Closer';
};
const getNextLevelXP = (level) => {
// Demo formula: Level 13 is 3600. (Since demo starts at L12, 3550 XP)
// Formula: next level needs `1000 + (level * 200)`
return 1000 + (level * 200);
};
const addXP = useCallback((amount, coordinates, actionName, isCrit = false) => {
setGameState(prev => {
let newXp = prev.xp + amount;
const threshold = getNextLevelXP(prev.level);
let newLevel = prev.level;
let titleChanged = false;
// Handle Level Up
if (newXp >= threshold) {
newLevel = prev.level + 1;
// Check if title changed
if (getRankTitle(newLevel) !== getRankTitle(prev.level)) {
titleChanged = true;
}
// Trigger celebration
setLevelUpData({
newLevel,
newTitle: getRankTitle(newLevel),
titleChanged
});
// Auto clear level up modal after 5s
setTimeout(() => setLevelUpData(null), 5000);
}
// Add points
const newWeeklyPoints = prev.weeklyPoints + amount;
// Setup float text
setRecentAction({
id: Date.now(),
x: coordinates?.x || window.innerWidth / 2,
y: coordinates?.y || window.innerHeight / 2,
amount,
text: isCrit ? `CRIT! +${amount} XP` : `+${amount} XP`,
type: isCrit ? 'crit' : 'normal',
actionLabel: actionName
});
return {
...prev,
xp: newXp,
level: newLevel,
todayPoints: prev.todayPoints + amount,
weeklyPoints: newWeeklyPoints
};
});
}, []);
// Sync leaderboard when weeklyPoints change
useEffect(() => {
setLeaderboard(prev => {
// Find old rank
const sortedPrev = [...prev].sort((a, b) => b.points - a.points);
const oldRankIndex = sortedPrev.findIndex(u => u.isUser);
const newBoard = prev.map(user => {
if (user.isUser) {
return { ...user, points: gameState.weeklyPoints };
}
return user;
});
// Sort descending
const sortedNew = newBoard.sort((a, b) => b.points - a.points);
const newRankIndex = sortedNew.findIndex(u => u.isUser);
// Trigger Toast if rank went up
if (oldRankIndex > newRankIndex && newRankIndex !== -1) {
const surpassedUser = sortedPrev[newRankIndex];
setRankToast({
oldRank: oldRankIndex + 1,
newRank: newRankIndex + 1,
message: `You passed ${surpassedUser.name}! (Rank #${newRankIndex + 1})`
});
setTimeout(() => setRankToast(null), 4000);
}
return sortedNew;
});
}, [gameState.weeklyPoints]);
const updateQuest = useCallback((type, amount = 1) => {
setGameState(prev => {
const quest = prev.dailyQuests[type];
if (!quest) return prev;
const newCurrent = Math.min(quest.current + amount, quest.target);
// Check if just completed
if (quest.current < quest.target && newCurrent >= quest.target) {
setMissionComplete({
type: 'daily',
message: `Daily ${type} mission complete!`
});
setTimeout(() => setMissionComplete(null), 3500);
}
const newState = {
...prev,
dailyQuests: {
...prev.dailyQuests,
[type]: { ...quest, current: newCurrent }
}
};
// Also update weekly if it's a knock
if (type === 'knocks') {
const wQuest = prev.weeklyChallenge.knocks;
const newWCurrent = Math.min(wQuest.current + amount, wQuest.target);
if (wQuest.current < wQuest.target && newWCurrent >= wQuest.target) {
setTimeout(() => {
setMissionComplete({
type: 'weekly',
message: `Weekly Challenge Complete!`
});
setTimeout(() => setMissionComplete(null), 3500);
}, 1000); // offset from daily complete
}
newState.weeklyChallenge.knocks.current = newWCurrent;
}
return newState;
});
}, []);
// Actions
const logKnock = useCallback((isHighValue, coords) => {
const baseXP = 10;
const multi = isHighValue ? 1.5 : 1;
const finalXP = Math.round(baseXP * multi);
addXP(finalXP, coords, 'Knock', isHighValue);
updateQuest('knocks', 1);
}, [addXP, updateQuest]);
const logLead = useCallback((coords) => {
addXP(50, coords, 'New Lead');
updateQuest('leads', 1);
}, [addXP, updateQuest]);
const logInspection = useCallback((coords) => {
addXP(150, coords, 'Inspection Set');
updateQuest('inspections', 1);
}, [addXP, updateQuest]);
// Expose rank title logic
const currentTitle = getRankTitle(gameState.level);
const nextLevelXP = getNextLevelXP(gameState.level);
// Calculate prior level XP to know the base for the progress bar
const prevLevelXP = gameState.level === 1 ? 0 : getNextLevelXP(gameState.level - 1);
const progressPercent = Math.max(0, Math.min(100, ((gameState.xp - prevLevelXP) / (nextLevelXP - prevLevelXP)) * 100));
const value = {
...gameState,
leaderboard,
recentAction,
levelUpData,
missionComplete,
rankToast,
currentTitle,
nextLevelXP,
prevLevelXP,
progressPercent,
logKnock,
logLead,
logInspection,
setRecentAction // For clearing after animation
};
return (
<GamificationContext.Provider value={value}>
{children}
</GamificationContext.Provider>
);
};
export const useGamification = () => useContext(GamificationContext);