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
@@ -0,0 +1,77 @@
import React, { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useGamification } from '../../../context/GamificationContext';
const FloatingXPManager = () => {
const { recentAction, setRecentAction, missionComplete } = useGamification();
const [popups, setPopups] = useState([]);
useEffect(() => {
if (recentAction) {
setPopups(prev => [...prev, recentAction]);
// The context requires us to clear the recentAction immediately so same actions can fire again
setRecentAction(null);
}
}, [recentAction, setRecentAction]);
useEffect(() => {
if (popups.length > 0) {
const timer = setTimeout(() => {
setPopups(prev => prev.slice(1));
}, 1500); // Remove after animation finishes
return () => clearTimeout(timer);
}
}, [popups]);
return (
<>
<div className="absolute inset-0 pointer-events-none z-[60]">
<AnimatePresence>
{popups.map(popup => (
<motion.div
key={popup.id}
initial={{ opacity: 0, y: popup.y, x: popup.x, scale: 0.5 }}
animate={{ opacity: 1, y: popup.y - 120, x: popup.x, scale: 1.2 }}
exit={{ opacity: 0, y: popup.y - 150, scale: 0.8 }}
transition={{ duration: 1.2, ease: "easeOut" }}
className={`absolute transform -translate-x-1/2 pointer-events-none drop-shadow-2xl font-black text-2xl
${popup.type === 'crit'
? 'text-transparent bg-clip-text bg-gradient-to-r from-green-400 to-emerald-600 drop-shadow-[0_0_15px_rgba(52,211,153,0.8)]'
: 'text-white drop-shadow-[0_0_8px_rgba(255,255,255,0.8)]'}`}
style={{ padding: '4px' }}
>
{popup.text}
<div className="text-[10px] text-center font-bold text-slate-200 uppercase tracking-widest mt-1 drop-shadow-md">
{popup.actionLabel}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
{/* Mission Complete Banner Banner */}
<AnimatePresence>
{missionComplete && (
<div className="absolute top-24 left-1/2 transform -translate-x-1/2 pointer-events-none z-[70] flex justify-center w-full">
<motion.div
initial={{ y: -100, opacity: 0, scale: 0.8 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
exit={{ y: -50, opacity: 0, scale: 0.9 }}
transition={{ type: "spring", stiffness: 200, damping: 20 }}
className="bg-gradient-to-r from-emerald-500 to-teal-600 px-8 py-4 rounded-b-3xl shadow-[0_0_40px_rgba(16,185,129,0.5)] border-b-2 border-x-2 border-emerald-400"
>
<div className="text-emerald-100 text-sm font-bold uppercase tracking-widest text-center mb-1">
Mission Complete
</div>
<div className="text-white text-2xl font-black text-center whitespace-nowrap">
{missionComplete.message}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</>
);
};
export default FloatingXPManager;
@@ -0,0 +1,90 @@
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Star, Trophy, Zap } from 'lucide-react';
import { useGamification } from '../../../context/GamificationContext';
const LevelUpModal = () => {
const { levelUpData } = useGamification();
return (
<AnimatePresence>
{levelUpData && (
<div className="fixed inset-0 z-[100] flex items-center justify-center pointer-events-none">
{/* Backdrop glow */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm pointer-events-auto"
/>
<motion.div
initial={{ scale: 0.5, opacity: 0, y: 50 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.8, opacity: 0, y: -50 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
className="relative z-10 w-full max-w-sm"
>
{/* Celebration Ring */}
<motion.div
className="absolute inset-x-0 -top-12 h-40 bg-gradient-to-b from-yellow-400/20 to-transparent blur-2xl rounded-t-full"
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ repeat: Infinity, duration: 2 }}
/>
<div className="bg-slate-900 border-2 border-yellow-500/50 rounded-3xl p-8 shadow-[0_0_50px_rgba(234,179,8,0.3)] relative overflow-hidden pointer-events-auto text-center">
{/* Rotating background ray effect */}
<motion.div
className="absolute inset-0 z-0 opacity-20"
animate={{ rotate: 360 }}
transition={{ repeat: Infinity, duration: 20, ease: "linear" }}
style={{
background: 'repeating-conic-gradient(from 0deg, transparent 0deg 15deg, #eab308 15deg 30deg)'
}}
/>
<div className="relative z-10 flex flex-col items-center">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1, rotate: [0, -10, 10, -10, 0] }}
transition={{ delay: 0.2, type: "spring", stiffness: 200, damping: 10 }}
className="w-24 h-24 bg-gradient-to-br from-yellow-300 to-yellow-600 rounded-full flex items-center justify-center shadow-[0_0_30px_rgba(234,179,8,0.8)] mb-6 border-4 border-slate-900"
>
<Star className="w-12 h-12 text-slate-900 fill-slate-900" />
</motion.div>
<h2 className="text-4xl font-black text-transparent bg-clip-text bg-gradient-to-r from-white to-slate-300 mb-2 uppercase tracking-wide">
Level Up!
</h2>
<div className="text-xl font-bold text-yellow-400 mb-6 flex justify-center items-center gap-2">
<Zap className="w-5 h-5 fill-current" />
Level {levelUpData.newLevel} Reached
<Zap className="w-5 h-5 fill-current" />
</div>
{levelUpData.titleChanged && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="bg-slate-800/80 border border-slate-700 rounded-xl p-4 w-full shadow-inner"
>
<div className="text-xs text-slate-400 uppercase tracking-widest font-bold mb-1">New Rank Unlocked</div>
<div className="flex items-center justify-center gap-2 text-2xl font-black text-white">
<Trophy className="w-6 h-6 text-yellow-500" />
{levelUpData.newTitle}
</div>
</motion.div>
)}
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
);
};
export default LevelUpModal;
+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);
+17 -2
View File
@@ -13,7 +13,7 @@ const LEVEL_THRESHOLDS = [
{ level: 10, xp: 100000, title: 'Legend' }
];
export const useGamification = (user) => {
export const useGamification = (user, role = 'FIELD_AGENT') => {
return useMemo(() => {
if (!user) return null;
@@ -48,7 +48,21 @@ export const useGamification = (user) => {
}
// --- PHASE 3: CHECKPOINTS & STAGES ---
const STAGES = [
let STAGES = [];
if (role === 'OWNER') {
STAGES = [
{ id: 1, title: "Q1 Launch", requirement: { type: "onboarding", amount: 3 }, reward: { type: "coupon", name: "Territory Expansion", icon: "map" }, progress: 1, goal: 3 },
{ id: 2, title: "Market Domination", requirement: { type: "revenue", amount: 100 }, reward: { type: "giftcard", name: "Executive Suite Unlock", icon: "star" }, progress: 85, goal: 100 },
{ id: 3, title: "Empire Builder", requirement: { type: "deals", amount: 50 }, reward: { type: "cash", name: "Profit Share Tier 2", icon: "cash" }, progress: 20, goal: 50 }
];
} else if (role === 'ADMIN') {
STAGES = [
{ id: 1, title: "Squad Setup", requirement: { type: "recruits", amount: 5 }, reward: { type: "coupon", name: "Team Bonus Unlock", icon: "users" }, progress: 3, goal: 5 },
{ id: 2, title: "Territory Control", requirement: { type: "regions", amount: 4 }, reward: { type: "giftcard", name: "Admin Power-Up", icon: "zap" }, progress: 4, goal: 4 },
{ id: 3, title: "Regional Manager", requirement: { type: "sales", amount: 20 }, reward: { type: "cash", name: "Override Commission", icon: "cash" }, progress: 12, goal: 20 }
];
} else {
STAGES = [
{
id: 1,
title: "Stage 1: Groundwork",
@@ -74,6 +88,7 @@ export const useGamification = (user) => {
goal: 25
}
];
}
// Determine lock states
const stages = STAGES.map((s, idx) => {
+202 -59
View File
@@ -1,14 +1,17 @@
import React from 'react';
import { motion } from 'framer-motion';
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import {
Flame, Target, Crosshair, Trophy, UploadCloud,
Shield, Zap, Star, Medal, Map, Activity,
Users, TrendingUp, CheckCircle, Lock, FastForward, Award, Calendar
Shield, Zap, Star, Medal, Map, Activity, Home,
Users, TrendingUp, CheckCircle, Lock, FastForward, Award, Calendar, ClipboardCheck
} from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { useMockStore } from '../data/mockStore';
import { useGamification } from '../hooks/useGamification';
import { useGamification as useOldGamification } from '../hooks/useGamification';
import { GamificationProvider, useGamification } from '../context/GamificationContext';
import FloatingXPManager from '../components/ProCanvas/gamification/FloatingXPManager';
import LevelUpModal from '../components/ProCanvas/gamification/LevelUpModal';
import { SpotlightCard } from '../components/SpotlightCard';
import { AnimatedCounter } from '../components/AnimatedCounter';
@@ -73,21 +76,37 @@ const OperatorProfile = ({ gamestate, user, className = "" }) => {
<div className="flex-1 text-center md:text-left z-10 w-full flex flex-col justify-center">
<div className="flex justify-between items-start mb-4 gap-4 md:gap-8 w-full">
<div className="min-w-0 flex-1">
<p className="text-[10px] font-mono text-zinc-500 tracking-[0.2em] mb-1 uppercase truncate">Op ID: {user.empId || 'UNK-99'}</p>
<div className="flex items-center gap-2 mb-1">
<p className="text-[10px] font-mono text-zinc-500 tracking-[0.2em] uppercase truncate">Op ID: {user.empId || 'UNK-99'}</p>
<div className="flex items-center gap-1 bg-orange-500/10 text-orange-500 px-2 py-0.5 rounded-full border border-orange-500/20">
<motion.div animate={{ scale: [1, 1.2, 1] }} transition={{ repeat: Infinity, duration: 2 }}>
<Flame size={12} className="fill-current drop-shadow-[0_0_5px_rgba(249,115,22,0.8)]" />
</motion.div>
<span className="text-[10px] font-bold font-mono">{gamestate.streak} Day Streak</span>
</div>
</div>
<h2 className="text-2xl md:text-3xl font-black text-white tracking-wide truncate">{user.name}</h2>
</div>
<div className="flex flex-col items-end flex-shrink-0">
<span className={`text-xl font-black ${NEON_GOLD}`}>LVL <AnimatedCounter to={gamestate.level} duration={1} /></span>
<span className="text-xs font-mono text-zinc-400 tracking-widest uppercase whitespace-nowrap">{gamestate.title}</span>
<span className="text-xs font-mono text-zinc-400 tracking-widest uppercase whitespace-nowrap">{gamestate.currentTitle}</span>
</div>
</div>
<div className="w-full mt-2">
<div className="flex justify-between text-xs font-mono mb-2">
<span className="text-zinc-400">XP <span className="text-white font-bold"><AnimatedCounter to={gamestate.currentXp} /></span></span>
<span className="text-zinc-500">NEXT <span className="text-zinc-300">{gamestate.nextLevelXp.toLocaleString()}</span></span>
<span className="text-zinc-400">XP <span className="text-white font-bold"><AnimatedCounter to={gamestate.xp} /></span></span>
<span className="text-zinc-500">NEXT <span className="text-zinc-300">{gamestate.nextLevelXP.toLocaleString()}</span></span>
</div>
{/* Using context's exact progressPercent for precision inside the progress bar */}
<div className="h-2 w-full bg-black/80 rounded-full shadow-[inset_0_2px_4px_rgba(0,0,0,0.6)] border border-white/5 relative overflow-hidden flex-shrink-0">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${gamestate.progressPercent}%` }}
transition={{ type: "spring", stiffness: 50, damping: 15 }}
className={`absolute top-0 left-0 h-full rounded-full bg-gradient-to-r from-amber-600 to-[#fda913] shadow-[0_0_15px_#fda913]`}
/>
</div>
<ProgressBar progress={gamestate.currentXp} goal={gamestate.nextLevelXp} colorClass="bg-gradient-to-r from-amber-600 to-[#fda913]" shadowClass="shadow-[0_0_15px_#fda913]" />
</div>
</div>
</div>
@@ -95,40 +114,114 @@ const OperatorProfile = ({ gamestate, user, className = "" }) => {
);
};
const LiveMissionTracker = ({ stage, className = "" }) => {
const TacticalMissions = ({ role, gamestate, className = "" }) => {
const navigate = useNavigate();
// Role-based content adaptations
let headerTitle = "Daily Quests";
let weeklyTitle = "Weekly Challenge";
let weeklyTargetLabel = "Total Knocks";
if (role === 'ADMIN') {
headerTitle = "Squad Directives";
weeklyTitle = "Global Target";
weeklyTargetLabel = "Global Knocks";
} else if (role === 'OWNER') {
headerTitle = "Key Objectives";
weeklyTitle = "Revenue Drivers";
weeklyTargetLabel = "Required Activity";
}
const MissionBar = ({ current, target, colorClass, label, icon: Icon }) => {
const pct = Math.min((current / target) * 100, 100);
const isComplete = current >= target;
return (
<div className="mb-4 last:mb-0 w-full">
<div className="flex justify-between items-center mb-1.5">
<div className="flex items-center gap-2 text-[10px] md:text-xs font-mono font-medium text-zinc-400">
<Icon className={`w-3.5 h-3.5 ${isComplete ? colorClass : 'text-zinc-500'}`} />
<span>{label}</span>
</div>
<div className={`text-[10px] md:text-xs font-bold font-mono ${isComplete ? colorClass : 'text-white'}`}>
<AnimatedCounter to={current} /> / {target}
</div>
</div>
<div className="h-1.5 w-full bg-black/80 rounded-full shadow-[inset_0_2px_4px_rgba(0,0,0,0.6)] border border-white/5 relative overflow-hidden flex-shrink-0">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 1.5, type: "spring", stiffness: 50, damping: 15 }}
className={`absolute top-0 left-0 h-full rounded-full ${isComplete ? 'bg-[#39ff14] shadow-[0_0_10px_#39ff14]' : `bg-gradient-to-r ${colorClass}`}`}
/>
</div>
</div>
);
};
return (
<NeoCard spotlightColor="rgba(57, 255, 20, 0.1)" className={className} innerClassName="justify-between h-full">
<div className="flex flex-col h-full w-full">
<div className="flex items-center gap-3 mb-6 flex-shrink-0">
<Crosshair className={NEON_GREEN} size={24} />
<h3 className="text-sm md:text-base font-mono font-bold text-white tracking-widest uppercase">Live Mission Tracker</h3>
<h3 className="text-sm md:text-base font-mono font-bold text-white tracking-widest uppercase pb-1">Tactical Missions</h3>
</div>
<div className="mb-4 flex-shrink-0">
<p className="text-[10px] text-zinc-500 font-mono tracking-widest mb-1 uppercase">ACTIVE DIRECTIVE</p>
<p className="text-lg md:text-xl text-white font-bold mb-1 truncate">{stage.title}</p>
<p className="text-xs md:text-sm text-zinc-400 font-mono truncate">
Target: {stage.goal} {stage.requirement.type.replace(/([A-Z])/g, ' $1').trim()}
</p>
<div className="flex-1 space-y-6 flex flex-col justify-center min-h-0">
{/* Weekly Challenge Component */}
<div className="p-3 bg-black/30 rounded-xl border border-[#fda913]/20 shadow-[0_0_15px_rgba(253,169,19,0.05)] w-full">
<div className="flex items-center justify-between mb-3 border-b border-white/5 pb-2">
<span className="text-[10px] font-mono text-[#fda913] tracking-widest uppercase flex items-center gap-1.5">
<Trophy size={12} className="text-[#fda913]" /> {weeklyTitle}
</span>
</div>
<MissionBar
current={gamestate.weeklyChallenge?.knocks?.current || 0}
target={gamestate.weeklyChallenge?.knocks?.target || 1}
colorClass="from-amber-600 to-[#fda913]"
label={weeklyTargetLabel}
icon={Target}
/>
</div>
<div className="space-y-3 p-4 bg-black/30 rounded-xl shadow-[inset_0_2px_10px_rgba(0,0,0,0.4)] border border-white/5 mb-6 flex-shrink-0">
<div className="flex justify-between font-mono text-xs">
<span className={NEON_GREEN}>PROGRESS</span>
<span className="text-white font-bold"><AnimatedCounter to={stage.progress} /> / {stage.goal}</span>
{/* Daily Quests */}
<div className="p-3 bg-black/30 rounded-xl border border-[#00f0ff]/20 shadow-[0_0_15px_rgba(0,240,255,0.05)] w-full">
<div className="flex items-center justify-between mb-3 border-b border-white/5 pb-2">
<span className="text-[10px] font-mono text-[#00f0ff] tracking-widest uppercase flex items-center gap-1.5">
<Zap size={12} className="text-[#00f0ff]" /> {headerTitle}
</span>
</div>
<div className="space-y-4">
{role === 'ADMIN' ? (
<>
<MissionBar current={gamestate.dailyQuests?.knocks?.current || 0} target={gamestate.dailyQuests?.knocks?.target || 1} colorClass="from-blue-600 to-[#00f0ff]" label="Active Recruits" icon={Users} />
<MissionBar current={gamestate.dailyQuests?.leads?.current || 0} target={gamestate.dailyQuests?.leads?.target || 1} colorClass="from-emerald-600 to-[#39ff14]" label="Territories Mapped" icon={Map} />
<MissionBar current={gamestate.dailyQuests?.inspections?.current || 0} target={gamestate.dailyQuests?.inspections?.target || 1} colorClass="from-purple-600 to-pink-500" label="Squad Reviews" icon={ClipboardCheck} />
</>
) : role === 'OWNER' ? (
<>
<MissionBar current={gamestate.dailyQuests?.knocks?.current || 0} target={gamestate.dailyQuests?.knocks?.target || 1} colorClass="from-amber-600 to-[#fda913]" label="Capital Deployed" icon={TrendingUp} />
<MissionBar current={gamestate.dailyQuests?.leads?.current || 0} target={gamestate.dailyQuests?.leads?.target || 1} colorClass="from-emerald-600 to-[#39ff14]" label="New Franchises" icon={Map} />
<MissionBar current={gamestate.dailyQuests?.inspections?.current || 0} target={gamestate.dailyQuests?.inspections?.target || 1} colorClass="from-purple-600 to-pink-500" label="Exec Meetings" icon={Users} />
</>
) : (
<>
<MissionBar current={gamestate.dailyQuests?.knocks?.current || 0} target={gamestate.dailyQuests?.knocks?.target || 1} colorClass="from-blue-600 to-[#00f0ff]" label="Door Knocks" icon={Home} />
<MissionBar current={gamestate.dailyQuests?.leads?.current || 0} target={gamestate.dailyQuests?.leads?.target || 1} colorClass="from-emerald-600 to-[#39ff14]" label="New Leads" icon={Users} />
<MissionBar current={gamestate.dailyQuests?.inspections?.current || 0} target={gamestate.dailyQuests?.inspections?.target || 1} colorClass="from-purple-600 to-pink-500" label="Inspections" icon={ClipboardCheck} />
</>
)}
</div>
</div>
<ProgressBar progress={stage.progress} goal={stage.goal} />
</div>
<button
onClick={() => navigate('/emp/fa/maps')}
className="w-full group mt-auto relative overflow-hidden rounded-xl bg-zinc-900 border border-[#39ff14]/30 p-4 shadow-[4px_4px_15px_rgba(0,0,0,0.5),-4px_-4px_15px_rgba(255,255,255,0.02)] transition-all hover:-translate-y-1 hover:shadow-[#39ff14]/20 flex-shrink-0"
className="w-full group mt-6 relative overflow-hidden rounded-xl bg-zinc-900 border border-[#39ff14]/30 p-4 shadow-[4px_4px_15px_rgba(0,0,0,0.5),-4px_-4px_15px_rgba(255,255,255,0.02)] transition-all hover:-translate-y-1 hover:shadow-[#39ff14]/20 flex-shrink-0"
>
<div className="absolute inset-0 bg-gradient-to-r from-[#39ff14]/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<span className={`relative z-10 font-mono font-bold tracking-[0.2em] text-sm md:text-base uppercase flex justify-center w-full ${NEON_GREEN}`}>
[ GOCANVAS ]
[ ENGAGE MAP ]
</span>
<span className="absolute inset-0 border border-[#39ff14] rounded-xl animate-ping opacity-10" />
</button>
@@ -137,12 +230,16 @@ const LiveMissionTracker = ({ stage, className = "" }) => {
);
};
const CheckpointsSystem = ({ stages, className = "" }) => {
const CheckpointsSystem = ({ stages, role, className = "" }) => {
let headerTitle = "Rewards & Checkpoints";
if (role === 'ADMIN') headerTitle = "Squad Milestone Tracking";
if (role === 'OWNER') headerTitle = "Program Milestones";
return (
<NeoCard spotlightColor="rgba(0, 240, 255, 0.05)" className={className}>
<div className="flex items-center gap-3 mb-6">
<FastForward className={NEON_BLUE} size={24} />
<h3 className="text-lg font-mono font-bold text-white tracking-widest uppercase">Rewards & Checkpoints</h3>
<h3 className="text-lg font-mono font-bold text-white tracking-widest uppercase">{headerTitle}</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 relative w-full flex-1">
@@ -226,12 +323,7 @@ const DetailedBadges = ({ badges, className = "" }) => {
};
const Leaderboard = ({ title = "Live Intel Feed", icon: Icon = Target, className = "" }) => {
const leaders = [
{ rank: 1, name: "Felicity Fast", xp: 15600, delta: "+450", role: "Agent" },
{ rank: 2, name: "Frank Agent", xp: 12450, delta: "+310", role: "Agent" },
{ rank: 3, name: "Fiona Field", xp: 8200, delta: "+120", role: "Agent" },
{ rank: 4, name: "Felix Fixer", xp: 5400, delta: "+50", role: "Agent" },
];
const { leaderboard } = useGamification();
return (
<NeoCard className={className} innerClassName="flex flex-col h-full">
@@ -241,28 +333,53 @@ const Leaderboard = ({ title = "Live Intel Feed", icon: Icon = Target, className
</h3>
</div>
<div className="space-y-4 flex-1 flex flex-col justify-center">
{leaders.map((agent, i) => (
<div
key={i}
<div className="flex-1 overflow-hidden relative min-h-[300px]">
<motion.ul layout className="flex flex-col gap-3 relative h-full">
<AnimatePresence>
{leaderboard.map((agent, i) => {
const isUser = agent.isUser;
const isTop3 = i < 3;
const rank = i + 1;
return (
<motion.li
key={agent.id}
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className={`group relative flex items-center justify-between p-3 md:p-4 rounded-xl transition-all duration-300
${agent.rank <= 3 ? 'bg-black/40 shadow-[inset_0_2px_10px_rgba(0,0,0,0.5)] border border-white/5' : 'bg-transparent hover:bg-white/5'}`}
${isUser
? 'bg-blue-600/20 border border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]'
: isTop3
? 'bg-black/40 shadow-[inset_0_2px_10px_rgba(0,0,0,0.5)] border border-white/5'
: 'bg-transparent border border-transparent hover:bg-white/5 border-b-white/5'}`}
>
<div className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">
<span className={`font-mono font-bold w-6 text-lg md:text-xl flex-shrink-0 ${agent.rank === 1 ? NEON_GOLD : agent.rank === 2 ? 'text-zinc-300' : agent.rank === 3 ? 'text-amber-700' : 'text-zinc-600'}`}>
0{agent.rank}
{isUser && (
<motion.div
className="absolute inset-0 bg-gradient-to-r from-blue-500/0 via-blue-500/10 to-blue-500/0 pointer-events-none rounded-xl"
animate={{ x: ['-200%', '200%'] }}
transition={{ repeat: Infinity, duration: 3, ease: 'linear' }}
/>
)}
<div className="flex items-center gap-3 md:gap-4 flex-1 min-w-0 z-10 relative">
<span className={`font-mono font-bold w-6 text-lg md:text-xl flex-shrink-0 ${rank === 1 ? NEON_GOLD : rank === 2 ? 'text-zinc-300' : rank === 3 ? 'text-amber-700' : 'text-zinc-600'}`}>
0{rank}
</span>
<div className="min-w-0">
<span className="font-bold text-white text-sm md:text-base block truncate">{agent.name}</span>
<span className={`font-bold text-sm md:text-base block truncate ${isUser ? 'text-blue-400' : 'text-white'}`}>{agent.name}</span>
<span className="text-[9px] md:text-[10px] text-zinc-500 uppercase font-mono tracking-widest block truncate">{agent.role}</span>
</div>
</div>
<div className="flex flex-col items-end gap-1 font-mono text-xs md:text-sm flex-shrink-0">
<span className="text-white font-bold"><AnimatedCounter to={agent.xp} /> XP</span>
<span className="text-[#39ff14] text-[9px] md:text-[10px] tracking-wider">{agent.delta} Today</span>
<div className="flex flex-col items-end gap-1 font-mono text-xs md:text-sm flex-shrink-0 z-10 relative">
<span className={`font-bold ${isUser ? 'text-white' : 'text-zinc-300'}`}><AnimatedCounter to={agent.points} /> XP</span>
</div>
</div>
))}
</motion.li>
);
})}
</AnimatePresence>
</motion.ul>
</div>
</NeoCard>
);
@@ -371,6 +488,8 @@ const OwnerROIDashboard = ({ className = "" }) => {
// -----------------------------------------------------------------
const QuickLogAction = ({ className = "", gridClassName = "grid grid-cols-2 gap-3 flex-1" }) => {
const { logKnock, logLead, logInspection } = useGamification();
return (
<NeoCard spotlightColor="rgba(57, 255, 20, 0.1)" className={className} innerClassName="flex flex-col h-full">
<div className="flex items-center gap-3 mb-4 flex-shrink-0">
@@ -378,15 +497,25 @@ const QuickLogAction = ({ className = "", gridClassName = "grid grid-cols-2 gap-
<h3 className="text-sm md:text-base font-mono font-bold text-white tracking-widest uppercase">Quick Operations Log</h3>
</div>
<div className={`w-full ${gridClassName}`}>
<button className="h-full p-1.5 md:p-2 rounded-xl bg-black/40 border border-white/5 hover:bg-zinc-800 hover:border-[#39ff14]/50 hover:shadow-[0_0_15px_rgba(57,255,20,0.2)] transition-all flex flex-col items-center justify-center gap-1 md:gap-2 group cursor-crosshair min-h-0">
<button
onClick={(e) => {
// Simulate High Value Target 25% of the time, or hold shift key
const isHighValue = e.shiftKey || Math.random() > 0.75;
logKnock(isHighValue, { x: e.clientX, y: e.clientY });
}}
className="h-full p-1.5 md:p-2 rounded-xl bg-black/40 border border-white/5 hover:bg-zinc-800 hover:border-[#39ff14]/50 hover:shadow-[0_0_15px_rgba(57,255,20,0.2)] transition-all flex flex-col items-center justify-center gap-1 md:gap-2 group cursor-crosshair min-h-0">
<Map size={18} className="text-zinc-500 group-hover:text-[#39ff14] transition-colors flex-shrink-0" />
<span className="text-[9px] md:text-[10px] text-center font-mono text-zinc-400 group-hover:text-white transition-colors truncate w-full px-1">Door Knock</span>
</button>
<button className="h-full p-1.5 md:p-2 rounded-xl bg-black/40 border border-white/5 hover:bg-zinc-800 hover:border-[#fda913]/50 hover:shadow-[0_0_15px_rgba(253,169,19,0.2)] transition-all flex flex-col items-center justify-center gap-1 md:gap-2 group cursor-crosshair min-h-0">
<button
onClick={(e) => logLead({ x: e.clientX, y: e.clientY })}
className="h-full p-1.5 md:p-2 rounded-xl bg-black/40 border border-white/5 hover:bg-zinc-800 hover:border-[#fda913]/50 hover:shadow-[0_0_15px_rgba(253,169,19,0.2)] transition-all flex flex-col items-center justify-center gap-1 md:gap-2 group cursor-crosshair min-h-0">
<Users size={18} className="text-zinc-500 group-hover:text-[#fda913] transition-colors flex-shrink-0" />
<span className="text-[9px] md:text-[10px] text-center font-mono text-zinc-400 group-hover:text-white transition-colors truncate w-full px-1">Lead Gained</span>
</button>
<button className="h-full p-1.5 md:p-2 rounded-xl bg-black/40 border border-white/5 hover:bg-zinc-800 hover:border-[#00f0ff]/50 hover:shadow-[0_0_15px_rgba(0,240,255,0.2)] transition-all flex flex-col items-center justify-center gap-1 md:gap-2 group cursor-crosshair min-h-0">
<button
onClick={(e) => logInspection({ x: e.clientX, y: e.clientY })}
className="h-full p-1.5 md:p-2 rounded-xl bg-black/40 border border-white/5 hover:bg-zinc-800 hover:border-[#00f0ff]/50 hover:shadow-[0_0_15px_rgba(0,240,255,0.2)] transition-all flex flex-col items-center justify-center gap-1 md:gap-2 group cursor-crosshair min-h-0">
<Calendar size={18} className="text-zinc-500 group-hover:text-[#00f0ff] transition-colors flex-shrink-0" />
<span className="text-[9px] md:text-[10px] text-center font-mono text-zinc-400 group-hover:text-white transition-colors truncate w-full px-1">Appt Set</span>
</button>
@@ -415,7 +544,7 @@ const ScanUpload = ({ className = "" }) => {
// MAIN COMPONENT EXPORT
// -----------------------------------------------------------------
export default function ProCanvas() {
function ProCanvasContent() {
const { user } = useAuth();
const { users } = useMockStore();
@@ -433,12 +562,18 @@ export default function ProCanvas() {
achievements: ['Hot Spot Hunter', 'Storm Chaser']
};
const gamestate = useGamification(mockDbUser);
const oldGamestate = useOldGamification(mockDbUser);
const newGamestate = useGamification();
if (!gamestate) return <div className="p-8 text-white font-mono flex items-center justify-center h-screen bg-[#050505]">INITIALIZING HUD...</div>;
if (!oldGamestate || !newGamestate) return <div className="p-8 text-white font-mono flex items-center justify-center h-screen bg-[#050505]">INITIALIZING HUD...</div>;
return (
<div className="min-h-full bg-[#09090b] p-4 md:p-8 overflow-y-auto selection:bg-[#fda913] selection:text-black">
<div className="min-h-full bg-[#09090b] p-4 md:p-8 overflow-y-auto selection:bg-[#fda913] selection:text-black relative">
{/* New Gamification Overlay Components */}
<FloatingXPManager />
<LevelUpModal />
{/* Hex Grid Background & Scanlines */}
<div className="fixed inset-0 pointer-events-none z-0">
<div className="absolute inset-0 opacity-20">
@@ -476,15 +611,15 @@ export default function ProCanvas() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 md:gap-8 w-full items-stretch">
{/* Unified Top Row */}
<OperatorProfile gamestate={gamestate} user={mockDbUser} className="lg:col-span-2" />
<LiveMissionTracker stage={gamestate.activeStage} className="lg:col-span-1" />
<OperatorProfile gamestate={newGamestate} user={mockDbUser} className="lg:col-span-2" />
<TacticalMissions role={role} gamestate={newGamestate} className="lg:col-span-1" />
{/* Checkpoints & Stages */}
<CheckpointsSystem stages={gamestate.stages} className="lg:col-span-3" />
<CheckpointsSystem role={role} stages={oldGamestate.stages} className="lg:col-span-3" />
{/* Field Agent Only Features */}
{role === 'FIELD_AGENT' && (
<DetailedBadges badges={gamestate.badges} className="lg:col-span-3" />
<DetailedBadges badges={oldGamestate.badges} className="lg:col-span-3" />
)}
{/* Owner / Admin Tops */}
@@ -497,7 +632,7 @@ export default function ProCanvas() {
{/* Shared Bottom Row */}
<Leaderboard
title={role === 'FIELD_AGENT' ? "Squad Rankings" : "Global Leaderboard"}
title="Org Leaderboard"
icon={role === 'OWNER' ? Star : Trophy}
className="lg:col-span-2"
/>
@@ -518,3 +653,11 @@ export default function ProCanvas() {
</div>
);
}
export default function ProCanvas() {
return (
<GamificationProvider>
<ProCanvasContent />
</GamificationProvider>
);
}