feat(ui): refactor gamification UI for role awareness and remove floaters
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user