feat(ui): complete ProCanvas role-based overhaul and fix Recharts crash

- Implement unified Neomorphic tactical layout for ProCanvas across Agent, Admin, and Owner roles.

- Add Quick Operations Log widget for immediate action logging.

- Integrate discrete Checkpoint/Stages progression system to replace static prize tracks.

- Build custom AnimatedCounter for GSAP-style numerical state changes.

- Fix Recharts ResponsiveContainer loading crash in OwnerSnapshot.jsx.
This commit is contained in:
Satyam
2026-02-21 21:23:37 +05:30
parent 7e166726da
commit 058b5bb217
8 changed files with 741 additions and 28 deletions
+16
View File
@@ -89,6 +89,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/emp/fa/pro-canvas"
element={
<ProtectedRoute allowedRoles={['FIELD_AGENT', 'ADMIN', 'OWNER']}>
<ProCanvas />
</ProtectedRoute>
}
/>
{/* Owner Routes */}
<Route path="/owner/snapshot" element={
<ProtectedRoute allowedRoles={['OWNER']}>
@@ -165,6 +173,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/admin/pro-canvas"
element={
<ProtectedRoute allowedRoles={['ADMIN', 'OWNER']}>
<ProCanvas />
</ProtectedRoute>
}
/>
{/* Contractor Routes */}
<Route path="/contractor/dashboard" element={
+39
View File
@@ -0,0 +1,39 @@
import React, { useEffect, useState, useRef } from 'react';
import { useInView } from 'framer-motion';
export const AnimatedCounter = ({ from = 0, to, duration = 1.5, className = "" }) => {
const [value, setValue] = useState(from);
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: "-20px" });
useEffect(() => {
if (!isInView) return;
let startTime;
let animationFrame;
const animate = (timestamp) => {
if (!startTime) startTime = timestamp;
const progress = (timestamp - startTime) / (duration * 1000);
if (progress < 1) {
// Easing function: easeOutQuart
const easeOut = 1 - Math.pow(1 - progress, 4);
setValue(Math.floor(from + (to - from) * easeOut));
animationFrame = requestAnimationFrame(animate);
} else {
setValue(to);
}
};
animationFrame = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationFrame);
}, [from, to, duration, isInView]);
return (
<span ref={ref} className={className}>
{value.toLocaleString()}
</span>
);
};
+10
View File
@@ -154,6 +154,11 @@ const Layout = () => {
{ to: "/admin/schedule", icon: Calendar, label: "Schedule" },
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
{ to: "/admin/maps", icon: Map, label: "Territory Map" },
{
to: "/admin/pro-canvas",
icon: LayoutDashboard,
label: <span><span className="text-[#fda913]">Pro</span>Canvas</span>
},
...commonItems,
];
case 'CONTRACTOR':
@@ -173,6 +178,11 @@ const Layout = () => {
return [
{ to: "/emp/fa/dashboard", icon: LayoutDashboard, label: "Dashboard" },
{ to: "/emp/fa/maps", icon: Map, label: "My Map" },
{
to: "/emp/fa/pro-canvas",
icon: LayoutDashboard,
label: <span><span className="text-[#fda913]">Pro</span>Canvas</span>
},
...commonItems,
];
default: // Customer or Fallback
+5 -5
View File
@@ -653,11 +653,11 @@ const MOCK_USERS = [
{ id: 'c3', type: 'customer', username: 'charlie', email: 'charlie@example.com', password: 'password', name: 'Charlie Client' },
// Field Agents (5 Agents)
{ id: 'e1', type: 'employee', empId: 'FA001', email: 'agent1@plano.com', password: 'password', role: 'FIELD_AGENT', name: 'Frank Agent' },
{ id: 'e2', type: 'employee', empId: 'FA002', email: 'agent2@plano.com', password: 'password', role: 'FIELD_AGENT', name: 'Fiona Field' },
{ id: 'e3', type: 'employee', empId: 'FA003', email: 'agent3@plano.com', password: 'password', role: 'FIELD_AGENT', name: 'Fred Flyer' },
{ id: 'e4', type: 'employee', empId: 'FA004', email: 'agent4@plano.com', password: 'password', role: 'FIELD_AGENT', name: 'Felicity Fast' },
{ id: 'e5', type: 'employee', empId: 'FA005', email: 'agent5@plano.com', password: 'password', role: 'FIELD_AGENT', name: 'Felix Fixer' },
{ id: 'e1', type: 'employee', empId: 'FA001', email: 'agent1@plano.com', password: 'password', role: 'FIELD_AGENT', name: 'Frank Agent', xp: 12450, doorsKnocked: 342, leadsGained: 45, appointmentsSet: 18, streakDays: 5, achievements: ['Hot Spot Hunter', 'Storm Chaser'] },
{ id: 'e2', type: 'employee', empId: 'FA002', email: 'agent2@plano.com', password: 'password', role: 'FIELD_AGENT', name: 'Fiona Field', xp: 8200, doorsKnocked: 215, leadsGained: 30, appointmentsSet: 12, streakDays: 2, achievements: ['Star Closer'] },
{ id: 'e3', type: 'employee', empId: 'FA003', email: 'agent3@plano.com', password: 'password', role: 'FIELD_AGENT', name: 'Fred Flyer', xp: 2100, doorsKnocked: 85, leadsGained: 5, appointmentsSet: 2, streakDays: 0, achievements: [] },
{ id: 'e4', type: 'employee', empId: 'FA004', email: 'agent4@plano.com', password: 'password', role: 'FIELD_AGENT', name: 'Felicity Fast', xp: 15600, doorsKnocked: 410, leadsGained: 65, appointmentsSet: 24, streakDays: 8, achievements: ['Map Master', 'Hot Spot Hunter', 'Storm Chaser'] },
{ id: 'e5', type: 'employee', empId: 'FA005', email: 'agent5@plano.com', password: 'password', role: 'FIELD_AGENT', name: 'Felix Fixer', xp: 5400, doorsKnocked: 156, leadsGained: 20, appointmentsSet: 8, streakDays: 1, achievements: ['First Knock'] },
// Admins (3 Admins)
{ id: 'a1', type: 'employee', empId: 'ADM01', email: 'admin@plano.com', password: 'password', role: 'ADMIN', name: 'Adam Admin' },
+128
View File
@@ -0,0 +1,128 @@
import { useMemo } from 'react';
const LEVEL_THRESHOLDS = [
{ level: 1, xp: 0, title: 'Rookie' },
{ level: 2, xp: 1000, title: 'Scout' },
{ level: 3, xp: 2500, title: 'Trekker' },
{ level: 4, xp: 5000, title: 'Pathfinder' },
{ level: 5, xp: 10000, title: 'Roof Warrior' },
{ level: 6, xp: 15000, title: 'Storm Chaser' },
{ level: 7, xp: 25000, title: 'Elite Operator' },
{ level: 8, xp: 40000, title: 'Neighborhood Boss' },
{ level: 9, xp: 60000, title: 'Commanding Officer' },
{ level: 10, xp: 100000, title: 'Legend' }
];
export const useGamification = (user) => {
return useMemo(() => {
if (!user) return null;
const xp = user.xp || 0;
// Calculate Level
let currentLevelObj = LEVEL_THRESHOLDS[0];
let nextLevelObj = LEVEL_THRESHOLDS[1];
for (let i = 0; i < LEVEL_THRESHOLDS.length; i++) {
if (xp >= LEVEL_THRESHOLDS[i].xp) {
currentLevelObj = LEVEL_THRESHOLDS[i];
nextLevelObj = LEVEL_THRESHOLDS[i + 1] || LEVEL_THRESHOLDS[i]; // Max level fallback
} else {
break;
}
}
const { level, title } = currentLevelObj;
// XP Progress
let xpProgress = 100;
let xpToNext = 0;
let nextLevelXp = nextLevelObj.xp;
let currentLevelBaseXp = currentLevelObj.xp;
if (level < LEVEL_THRESHOLDS[LEVEL_THRESHOLDS.length - 1].level) {
const range = nextLevelXp - currentLevelBaseXp;
const progress = xp - currentLevelBaseXp;
xpProgress = Math.min(100, Math.max(0, (progress / range) * 100));
xpToNext = nextLevelXp - xp;
}
// --- PHASE 3: CHECKPOINTS & STAGES ---
const STAGES = [
{
id: 1,
title: "Stage 1: Groundwork",
requirement: { type: "doorsKnocked", amount: 20 },
reward: { type: "coupon", name: "$25 Uber Eats Card", icon: "uber" },
progress: Math.min((user.doorsKnocked || 0), 20),
goal: 20
},
{
id: 2,
title: "Stage 2: Connections",
requirement: { type: "appointmentsSet", amount: 10 },
reward: { type: "giftcard", name: "$50 Amazon Card", icon: "amazon" },
progress: Math.min((user.appointmentsSet || 0), 10),
goal: 10
},
{
id: 3,
title: "Stage 3: Rainmaker",
requirement: { type: "leadsGained", amount: 25 },
reward: { type: "cash", name: "$150 Performance Bonus", icon: "cash" },
progress: Math.min((user.leadsGained || 0), 25),
goal: 25
}
];
// Determine lock states
const stages = STAGES.map((s, idx) => {
let status = "locked";
if (s.progress >= s.goal) {
status = "completed";
} else if (idx === 0 || STAGES[idx - 1].progress >= STAGES[idx - 1].goal) {
status = "in-progress";
}
return { ...s, status };
});
const activeStage = stages.find(s => s.status === "in-progress") || stages[stages.length - 1];
// --- PHASE 3: DETAILED BADGE ARCHITECTURE ---
// Mapping known string arrays from mockStore into detailed objects
const BADGE_DEFINITIONS = [
{ id: 'Hot Spot Hunter', title: "Hot Spot Hunter", desc: "Identify 10 high-damage zones.", criteria: "10 Hot Leads identified." },
{ id: 'Storm Chaser', title: "Storm Chaser", desc: "Canvass a territory within 48h of a storm.", criteria: "1 Storm deployed operation." },
{ id: 'Star Closer', title: "Star Closer", desc: "Maintain a 50% conversion rate.", criteria: "Convert 5 deals continuously." },
{ id: 'Map Master', title: "Map Master", desc: "Knock 500 total doors.", criteria: "500 Lifetime Doors." },
{ id: 'First Knock', title: "First Knock", desc: "Complete your first day in the field.", criteria: "Log your first shift." }
];
const badges = BADGE_DEFINITIONS.map(def => {
const isUnlocked = (user.achievements || []).includes(def.id);
return { ...def, status: isUnlocked ? 'unlocked' : 'locked' };
});
// Derived Stats
const stats = {
doorsKnocked: user.doorsKnocked || 0,
leadsGained: user.leadsGained || 0,
appointmentsSet: user.appointmentsSet || 0,
streakDays: user.streakDays || 0,
};
return {
level,
title,
currentXp: xp,
nextLevelXp,
xpProgress,
xpToNext,
stats,
badges,
stages,
activeStage,
achievements: user.achievements || [] // keeping for legacy
};
}, [user]);
};
+512 -18
View File
@@ -1,26 +1,520 @@
import React from 'react';
import { Construction } from 'lucide-react';
import { motion } 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
} from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { useMockStore } from '../data/mockStore';
import { useGamification } from '../hooks/useGamification';
import { SpotlightCard } from '../components/SpotlightCard';
import { AnimatedCounter } from '../components/AnimatedCounter';
const ProCanvas = () => {
// --- NEOMORPHIC STYLING CONSTANTS ---
const NEO_PANEL_CLASS = "bg-zinc-900/40 backdrop-blur-3xl border border-white/5 shadow-[8px_8px_16px_rgba(0,0,0,0.4),-8px_-8px_16px_rgba(255,255,255,0.02)] rounded-2xl relative overflow-hidden transition-all duration-300";
const NEON_GREEN = "text-[#39ff14] drop-shadow-[0_0_8px_rgba(57,255,20,0.4)]";
const NEON_GOLD = "text-[#fda913] drop-shadow-[0_0_8px_rgba(253,169,19,0.4)]";
const NEON_ORANGE = "text-[#ff4500] drop-shadow-[0_0_8px_rgba(255,69,0,0.4)]";
const NEON_BLUE = "text-[#00f0ff] drop-shadow-[0_0_8px_rgba(0,240,255,0.4)]";
// -----------------------------------------------------------------
// SHARED UI COMPONENTS
// -----------------------------------------------------------------
const NeoCard = ({ children, className = "", innerClassName = "", spotlightColor = "rgba(255, 255, 255, 0.05)" }) => (
<SpotlightCard className={`${NEO_PANEL_CLASS} h-full w-full ${className}`} spotlightColor={spotlightColor}>
<div className={`relative z-10 w-full h-full p-4 md:p-6 flex flex-col ${innerClassName}`}>
{children}
</div>
</SpotlightCard>
);
const ProgressBar = ({ progress, goal, colorClass = "bg-[#39ff14]", shadowClass = "shadow-[0_0_10px_#39ff14]" }) => {
const pct = Math.min((progress / goal) * 100, 100);
return (
<div className="min-h-full bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white p-8 flex items-center justify-center">
<SpotlightCard className="max-w-2xl w-full p-12 text-center">
<div className="flex justify-center mb-6">
<Construction size={64} className="text-[#fda913] animate-pulse" />
</div>
<h1 className="text-4xl font-extrabold mb-4">
Pro<span className="text-[#fda913]">Canvas</span>
</h1>
<p className="text-xl text-zinc-500 dark:text-zinc-400 mb-8 font-light">
This module is currently under construction.
</p>
<div className="p-4 bg-zinc-100 dark:bg-white/5 rounded-xl border border-dashed border-zinc-300 dark:border-white/10">
<p className="text-sm font-mono text-[#fda913]">STATUS: DEVELOPMENT_IN_PROGRESS</p>
</div>
</SpotlightCard>
<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: `${pct}%` }}
transition={{ duration: 1.5, ease: "easeOut" }}
className={`absolute top-0 left-0 h-full rounded-full ${colorClass} ${shadowClass}`}
/>
</div>
);
};
export default ProCanvas;
// -----------------------------------------------------------------
// ROLE-AGNOSTIC WIDGETS
// -----------------------------------------------------------------
const OperatorProfile = ({ gamestate, user, className = "" }) => {
return (
<NeoCard spotlightColor="rgba(253, 169, 19, 0.1)" className={className} innerClassName="justify-center">
<div className="flex flex-col md:flex-row items-center gap-6 md:gap-8">
<div className="relative w-28 h-28 md:w-32 md:h-32 flex-shrink-0">
<motion.svg
animate={{ rotate: 360 }}
transition={{ duration: 30, repeat: Infinity, ease: "linear" }}
className="absolute inset-0 w-full h-full text-[#fda913]/30"
viewBox="0 0 100 100"
>
<circle cx="50" cy="50" r="48" fill="none" stroke="currentColor" strokeWidth="1" strokeDasharray="4 4" />
<circle cx="50" cy="50" r="42" fill="none" stroke="currentColor" strokeWidth="2" strokeDasharray="20 10" />
</motion.svg>
<div className="absolute inset-3 rounded-full bg-zinc-900 border border-white/10 shadow-[inset_4px_4px_10px_rgba(0,0,0,0.8),inset_-4px_-4px_10px_rgba(255,255,255,0.05)] flex items-center justify-center overflow-hidden">
<span className="text-3xl md:text-4xl font-black text-transparent bg-clip-text bg-gradient-to-br from-white to-zinc-500 md:translate-y-0.5">
{user.name.split(' ').map(n => n[0]).join('')}
</span>
</div>
</div>
<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>
<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>
</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>
</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>
</NeoCard>
);
};
const LiveMissionTracker = ({ stage, className = "" }) => {
const navigate = useNavigate();
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>
</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>
<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>
</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"
>
<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 ]
</span>
<span className="absolute inset-0 border border-[#39ff14] rounded-xl animate-ping opacity-10" />
</button>
</div>
</NeoCard>
);
};
const CheckpointsSystem = ({ stages, className = "" }) => {
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>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 relative w-full flex-1">
{/* Connecting Line for Desktop */}
<div className="hidden md:block absolute top-[28px] left-[15%] right-[15%] h-0.5 bg-zinc-800 rounded z-0" />
{stages.map((stage, idx) => {
const isLocked = stage.status === 'locked';
const isCompleted = stage.status === 'completed';
const isInProgress = stage.status === 'in-progress';
return (
<div key={stage.id} className="relative z-10 flex flex-col items-center md:items-start text-center md:text-left bg-black/40 md:bg-transparent p-4 md:p-0 rounded-xl md:rounded-none border border-white/5 md:border-none shadow-[inset_0_2px_10px_rgba(0,0,0,0.5)] md:shadow-none h-full">
{/* Icon Node */}
<div className={`w-14 h-14 flex-shrink-0 rounded-xl flex items-center justify-center mb-4 transition-all duration-500 mx-auto md:mx-0
${isCompleted ? 'bg-[#39ff14]/20 text-[#39ff14] border-2 border-[#39ff14] shadow-[0_0_15px_#39ff14]' :
isInProgress ? 'bg-amber-500/20 text-amber-500 border-2 border-amber-500 shadow-[0_0_15px_#fda913] animate-pulse' :
'bg-zinc-900 text-zinc-600 border border-zinc-700'}
`}>
{isCompleted ? <CheckCircle size={24} /> : isLocked ? <Lock size={24} /> : <Target size={24} />}
</div>
<h4 className="font-bold text-white mb-1">{stage.title}</h4>
<p className="text-xs font-mono text-zinc-400 mb-3">{stage.progress} / {stage.goal} {stage.requirement.type.replace(/([A-Z])/g, ' $1').trim()}</p>
{/* Reward Box */}
<div className={`px-3 py-2 mt-auto rounded flex items-center gap-2 text-[10px] font-mono w-full justify-center md:justify-start
${isCompleted ? 'bg-[#39ff14]/10 border border-[#39ff14]/30 text-[#39ff14]' : 'bg-black/50 border border-white/5 text-zinc-500'}
`}>
<Award size={14} className="flex-shrink-0" />
<span className="truncate">{stage.reward.name} {isLocked ? '(LOCKED)' : ''}</span>
</div>
</div>
);
})}
</div>
</NeoCard>
);
};
const DetailedBadges = ({ badges, className = "" }) => {
return (
<NeoCard className={className}>
<div className="flex items-center gap-3 mb-6">
<Medal className={NEON_GOLD} size={24} />
<h3 className="text-lg font-mono font-bold text-white tracking-widest uppercase">Commendations & Badges</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{badges.map((badge, idx) => {
const isUnlocked = badge.status === 'unlocked';
let Icon = Shield;
if (badge.id.includes("Hunter")) Icon = Crosshair;
if (badge.id.includes("Storm")) Icon = Zap;
if (badge.id.includes("Star")) Icon = Star;
if (badge.id.includes("Master")) Icon = Map;
return (
<div key={idx} className={`p-4 rounded-xl flex gap-4 transition-all duration-300 h-full
${isUnlocked ? 'bg-zinc-800/50 border border-amber-500/30 shadow-[inset_0_2px_10px_rgba(0,0,0,0.5)]' : 'bg-black/40 border border-white/5 opacity-60 grayscale'}`
}>
<div className={`w-12 h-12 rounded-lg flex-shrink-0 flex items-center justify-center
${isUnlocked ? 'bg-black border border-amber-500/50 shadow-[0_0_10px_#fda913]' : 'bg-zinc-900 border border-zinc-700'}`
}>
<Icon size={20} className={isUnlocked ? NEON_GOLD : 'text-zinc-600'} />
</div>
<div className="flex flex-col justify-center min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-bold text-sm text-white truncate">{badge.title}</h4>
{!isUnlocked && <Lock size={12} className="text-zinc-600 flex-shrink-0" />}
</div>
<p className="text-[10px] md:text-xs text-zinc-400 font-mono mb-2 leading-tight line-clamp-2">{badge.desc}</p>
<p className="text-[9px] text-[#39ff14] font-mono tracking-widest uppercase opacity-80 truncate">CRITERIA: {badge.criteria}</p>
</div>
</div>
);
})}
</div>
</NeoCard>
);
};
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" },
];
return (
<NeoCard className={className} innerClassName="flex flex-col h-full">
<div className="flex flex-shrink-0 items-center justify-between mb-6">
<h3 className="text-sm md:text-base font-mono text-zinc-400 font-bold tracking-widest uppercase flex items-center gap-2">
<Icon size={18} className={NEON_BLUE} /> {title}
</h3>
</div>
<div className="space-y-4 flex-1 flex flex-col justify-center">
{leaders.map((agent, i) => (
<div
key={i}
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'}`}
>
<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}
</span>
<div className="min-w-0">
<span className="font-bold text-white text-sm md:text-base block truncate">{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>
</div>
))}
</div>
</NeoCard>
);
};
// -----------------------------------------------------------------
// ADMIN / OWNER SPECIFIC WIDGETS
// -----------------------------------------------------------------
const AdminCommandCenter = ({ className = "" }) => {
return (
<NeoCard spotlightColor="rgba(0, 240, 255, 0.1)" className={className} innerClassName="flex flex-col justify-between h-full">
<div className="flex items-center gap-3 mb-6">
<Activity className={NEON_BLUE} size={28} />
<div>
<h2 className="text-xl md:text-2xl font-black text-white tracking-widest uppercase">Global Control</h2>
<p className="font-mono text-[10px] md:text-xs text-zinc-500 tracking-[0.2em]">Administrative Overseer Panel</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 flex-1">
<div className="p-4 md:p-5 rounded-2xl bg-black/40 border border-white/5 shadow-[inset_0_2px_15px_rgba(0,0,0,0.5)] flex flex-col justify-center">
<p className="text-[10px] md:text-xs font-mono text-zinc-400 tracking-widest mb-2">ACTIVE SQUADS</p>
<div className="flex items-end gap-3">
<span className={`text-3xl md:text-4xl font-black ${NEON_BLUE}`}><AnimatedCounter to={12} /></span>
<span className="text-[10px] md:text-sm font-mono text-[#39ff14] pb-1">All Online</span>
</div>
</div>
<div className="p-4 md:p-5 rounded-2xl bg-black/40 border border-white/5 shadow-[inset_0_2px_15px_rgba(0,0,0,0.5)] flex flex-col justify-center">
<p className="text-[10px] md:text-xs font-mono text-zinc-400 tracking-widest mb-2">DOORS KNOCKED TODAY</p>
<div className="flex items-end gap-3">
<span className={`text-3xl md:text-4xl font-black ${NEON_GOLD}`}><AnimatedCounter to={1432} /></span>
<span className="text-[10px] md:text-sm font-mono text-[#39ff14] pb-1">+14% vs Yday</span>
</div>
</div>
<div className="p-4 md:p-5 rounded-2xl bg-black/40 border border-white/5 shadow-[inset_0_2px_15px_rgba(0,0,0,0.5)] flex flex-col justify-between h-full">
<p className="text-[10px] md:text-xs font-mono text-zinc-400 tracking-widest mb-2">APPROVAL QUEUE</p>
<div className="flex items-center justify-between mt-auto">
<span className={`text-3xl md:text-4xl font-black ${NEON_ORANGE}`}><AnimatedCounter to={8} /></span>
<button className="px-3 md:px-4 py-2 bg-zinc-800 border border-white/10 rounded-lg text-[10px] md:text-xs font-mono text-white hover:bg-zinc-700 transition-colors">
REVIEW
</button>
</div>
</div>
</div>
</NeoCard>
);
};
const OwnerROIDashboard = ({ className = "" }) => {
return (
<NeoCard spotlightColor="rgba(253, 169, 19, 0.1)" className={className} innerClassName="flex flex-col justify-between h-full">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between mb-8 gap-4 flex-shrink-0">
<div className="flex items-center gap-3">
<TrendingUp className={NEON_GOLD} size={28} />
<div>
<h2 className="text-xl md:text-2xl font-black text-white tracking-widest uppercase">Executive ROI</h2>
<p className="font-mono text-[10px] md:text-xs text-zinc-500 tracking-[0.2em]">Gamification Investment Returns</p>
</div>
</div>
<div className="px-4 py-3 bg-black/50 border border-amber-500/20 rounded-lg flex items-center gap-4 shadow-[inset_0_2px_10px_rgba(0,0,0,0.5)] w-full md:w-auto overflow-hidden">
<span className="text-[10px] md:text-xs font-mono text-zinc-400 whitespace-nowrap">Program Cost YTD:</span>
<span className={`text-base md:text-lg font-bold ${NEON_ORANGE}`}>$<AnimatedCounter to={12450} /></span>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 flex-1">
<div className="space-y-6 flex flex-col justify-center h-full">
<div className="w-full">
<div className="flex justify-between text-[10px] md:text-xs font-mono mb-2">
<span className="text-zinc-400">TEAM PARTICIPATION</span>
<span className="text-[#39ff14]"><AnimatedCounter to={92} />%</span>
</div>
<ProgressBar progress={92} goal={100} />
</div>
<div className="w-full">
<div className="flex justify-between text-[10px] md:text-xs font-mono mb-2">
<span className="text-zinc-400">PRIZE FUND DISPERSED</span>
<span className="text-[#fda913]"><AnimatedCounter to={68} />%</span>
</div>
<ProgressBar progress={68} goal={100} colorClass="bg-[#fda913]" shadowClass="shadow-[0_0_10px_#fda913]" />
</div>
</div>
<div className="grid grid-cols-2 gap-3 md:gap-4 h-full">
<div className="p-4 rounded-xl bg-zinc-900/50 border border-white/5 flex flex-col justify-center items-center text-center shadow-[inset_0_2px_10px_rgba(0,0,0,0.3)] h-full">
<p className="text-[9px] md:text-[10px] font-mono text-zinc-500 mb-2">GAMIFIED CONVERSION</p>
<p className={`text-2xl md:text-3xl font-black ${NEON_GREEN}`}>+<AnimatedCounter to={24} />%</p>
<p className="text-[9px] md:text-[10px] text-zinc-400 mt-1">vs Non-participants</p>
</div>
<div className="p-4 rounded-xl bg-zinc-900/50 border border-white/5 flex flex-col justify-center items-center text-center shadow-[inset_0_2px_10px_rgba(0,0,0,0.3)] h-full">
<p className="text-[9px] md:text-[10px] font-mono text-zinc-500 mb-2">GENERATED REVENUE</p>
<p className={`text-2xl md:text-3xl font-black ${NEON_GOLD}`}>$1.2M</p>
<p className="text-[9px] md:text-[10px] text-zinc-400 mt-1">Directly attributed</p>
</div>
</div>
</div>
</NeoCard>
);
};
// -----------------------------------------------------------------
// SHARED ACTION WIDGETS
// -----------------------------------------------------------------
const QuickLogAction = ({ className = "", gridClassName = "grid grid-cols-2 gap-3 flex-1" }) => {
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">
<Zap className={NEON_GREEN} size={20} />
<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">
<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">
<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">
<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>
<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-pink-500/50 hover:shadow-[0_0_15px_rgba(236,72,153,0.2)] transition-all flex flex-col items-center justify-center gap-1 md:gap-2 group cursor-crosshair min-h-0">
<CheckCircle size={18} className="text-zinc-500 group-hover:text-pink-500 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">Deal Closed</span>
</button>
</div>
</NeoCard>
);
};
const ScanUpload = ({ className = "" }) => {
return (
<NeoCard className={className} innerClassName="flex flex-col items-center justify-center text-center h-full group cursor-pointer w-full">
<div className="w-16 h-16 rounded-full bg-zinc-900 shadow-[inset_4px_4px_8px_rgba(0,0,0,0.5),inset_-4px_-4px_8px_rgba(255,255,255,0.05)] flex items-center justify-center mb-4 group-hover:shadow-[0_0_15px_#39ff14] transition-all duration-300 transform group-hover:-translate-y-2">
<UploadCloud size={28} className="text-zinc-500 group-hover:text-[#39ff14] transition-colors" />
</div>
<h4 className="font-mono text-sm tracking-widest text-white mb-2">SCAN UPLOAD</h4>
<p className="text-[10px] text-zinc-500 font-mono mb-0">DRAG RECON INTEL HERE (+500XP)</p>
</NeoCard>
);
};
// -----------------------------------------------------------------
// MAIN COMPONENT EXPORT
// -----------------------------------------------------------------
export default function ProCanvas() {
const { user } = useAuth();
const { users } = useMockStore();
const role = user?.role || 'FIELD_AGENT';
const mockDbUser = users.find(u => u.email === user?.email) || users.find(u => u.roles?.includes('FIELD_AGENT')) || users.find(u => u.type === 'employee') ||
{
name: user?.name || 'Agent Unknown',
empId: 'OP-00',
xp: 12450,
doorsKnocked: 342,
leadsGained: 45,
appointmentsSet: 18,
streakDays: 5,
achievements: ['Hot Spot Hunter', 'Storm Chaser']
};
const gamestate = useGamification(mockDbUser);
if (!gamestate) 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">
{/* Hex Grid Background & Scanlines */}
<div className="fixed inset-0 pointer-events-none z-0">
<div className="absolute inset-0 opacity-20">
<svg className="w-full h-full" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="hexagons" width="50" height="43.4" patternUnits="userSpaceOnUse" patternTransform="scale(2)">
<path d="M25 0 L50 14.4 L50 43.3 L25 57.7 L0 43.3 L0 14.4 Z" fill="none" stroke="rgba(255,255,255,0.05)" strokeWidth="1" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#hexagons)" />
</svg>
</div>
{/* CSS Scanline Overlay */}
<div className="absolute inset-0 opacity-[0.03] bg-[linear-gradient(to_bottom,transparent_50%,rgba(0,0,0,0.5)_50%)] bg-[length:100%_4px]" />
</div>
<div className="max-w-7xl mx-auto space-y-6 md:space-y-8 relative z-10 w-full">
<header className="mb-6 md:mb-10 flex flex-col md:flex-row justify-between items-start md:items-end gap-4 border-b border-white/10 pb-6 w-full">
<div>
<h1 className="text-3xl md:text-4xl font-black text-transparent bg-clip-text bg-gradient-to-r from-white via-zinc-300 to-zinc-600 tracking-tighter drop-shadow-xl">
PRO<span className="text-[#fda913]">CANVAS</span>
</h1>
<p className="font-mono text-zinc-500 text-xs md:text-sm tracking-widest uppercase mt-2">
{role === 'FIELD_AGENT' ? 'Tactical HUD Controller' : role === 'ADMIN' ? 'Command Center Dashboard' : 'Executive ROI Overview'}
</p>
</div>
<div className="font-mono text-[9px] md:text-[10px] text-[#39ff14] flex flex-col md:items-end opacity-70 bg-black/40 p-2 md:p-3 rounded border border-[#39ff14]/20 shadow-[0_0_10px_rgba(57,255,20,0.1)] w-full md:w-auto">
<span className="flex justify-between md:justify-end gap-8 w-full">SYS_STATUS: <span>ONLINE</span></span>
<span className="flex justify-between md:justify-end gap-8 w-full">NET_SECURE: <span>TRUE</span></span>
<span className="flex justify-between md:justify-end gap-8 w-full text-zinc-500">AUTH_LVL: <span>{role}</span></span>
</div>
</header>
<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" />
{/* Checkpoints & Stages */}
<CheckpointsSystem stages={gamestate.stages} className="lg:col-span-3" />
{/* Field Agent Only Features */}
{role === 'FIELD_AGENT' && (
<DetailedBadges badges={gamestate.badges} className="lg:col-span-3" />
)}
{/* Owner / Admin Tops */}
{role === 'OWNER' && (
<OwnerROIDashboard className="lg:col-span-3" />
)}
{(role === 'ADMIN' || role === 'OWNER') && (
<AdminCommandCenter className="lg:col-span-3" />
)}
{/* Shared Bottom Row */}
<Leaderboard
title={role === 'FIELD_AGENT' ? "Squad Rankings" : "Global Leaderboard"}
icon={role === 'OWNER' ? Star : Trophy}
className="lg:col-span-2"
/>
{/* Side Column spanning same height as Leaderboard */}
<div className="lg:col-span-1 flex flex-col gap-6 md:gap-8 h-full w-full min-h-0">
<QuickLogAction
className={role === 'FIELD_AGENT' ? "flex-1 min-h-0" : "h-full min-h-0"}
gridClassName="grid grid-cols-2 gap-2 lg:gap-3 flex-1 min-h-0"
/>
{role === 'FIELD_AGENT' && (
<ScanUpload className="flex-1 min-h-0" />
)}
</div>
</div>
</div>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import React from 'react';
import { Construction } from 'lucide-react';
import { SpotlightCard } from '../components/SpotlightCard';
const UnderConstruction = ({ title = "Under Construction", moduleName = "Module" }) => {
return (
<div className="min-h-full bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white p-8 flex items-center justify-center">
<SpotlightCard className="max-w-2xl w-full p-12 text-center">
<div className="flex justify-center mb-6">
<Construction size={64} className="text-[#fda913] animate-pulse" />
</div>
<h1 className="text-4xl font-extrabold mb-4">
{title}
</h1>
<p className="text-xl text-zinc-500 dark:text-zinc-400 mb-8 font-light">
This {moduleName} is currently under construction.
</p>
<div className="p-4 bg-zinc-100 dark:bg-white/5 rounded-xl border border-dashed border-zinc-300 dark:border-white/10">
<p className="text-sm font-mono text-[#fda913]">STATUS: DEVELOPMENT_IN_PROGRESS</p>
</div>
</SpotlightCard>
</div>
);
};
export default UnderConstruction;
+5 -5
View File
@@ -198,7 +198,7 @@ const OwnerSnapshot = () => {
<h3 className="text-lg font-bold text-zinc-900 dark:text-white mb-1">Budget vs Actual Spend</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-4">Per project (in $k)</p>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height={256}>
<BarChart data={budgetChartData} barGap={4}>
<XAxis dataKey="name" tick={{ fontSize: 11, fill: '#a1a1aa' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: '#a1a1aa' }} axisLine={false} tickLine={false} />
@@ -215,7 +215,7 @@ const OwnerSnapshot = () => {
<h3 className="text-lg font-bold text-zinc-900 dark:text-white mb-1">Project Status</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-4">Distribution</p>
<div className="h-64 flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height={256}>
<PieChart>
<Pie
data={statusChartData}
@@ -244,7 +244,7 @@ const OwnerSnapshot = () => {
<h3 className="text-lg font-bold text-zinc-900 dark:text-white mb-1">Monthly Spend Trend</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-4">Last 12 months (in $k)</p>
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<ResponsiveContainer width="100%" height={224}>
<BarChart data={monthlySpendData}>
<XAxis dataKey="month" tick={{ fontSize: 11, fill: '#a1a1aa' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: '#a1a1aa' }} axisLine={false} tickLine={false} />
@@ -275,8 +275,8 @@ const OwnerSnapshot = () => {
{activityFeed.length > 0 ? activityFeed.map((item, i) => (
<div key={i} className="flex items-start space-x-3 p-3 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors">
<div className={`w-2 h-2 mt-2 rounded-full shrink-0 ${item.action?.toLowerCase().includes('payment') ? 'bg-emerald-400' :
item.action?.toLowerCase().includes('issue') || item.action?.toLowerCase().includes('dispute') ? 'bg-red-400' :
'bg-blue-400'
item.action?.toLowerCase().includes('issue') || item.action?.toLowerCase().includes('dispute') ? 'bg-red-400' :
'bg-blue-400'
}`}></div>
<div>
<p className="text-sm text-zinc-600 dark:text-zinc-300">