Files
edbridge-scholars/src/components/lessons/UserDashboard.tsx
2026-03-01 20:24:14 +06:00

379 lines
19 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import {
ArrowLeft, User, Shield, Clock, BookOpen, Calculator, Award,
TrendingUp, CheckCircle2, Circle, Lock, Eye, EyeOff, AlertCircle,
Check, Sparkles,
} from 'lucide-react';
import { useAuth, UserRecord } from './auth/AuthContext';
import { useProgress } from './progress/ProgressContext';
import { useGoldCoins } from './practice/GoldCoinContext';
import { LESSONS, EBRW_LESSONS } from '../constants';
import Mascot from './Mascot';
// Animated count-up
function useCountUp(target: number, duration = 900) {
const [count, setCount] = useState(0);
const started = useRef(false);
useEffect(() => {
if (started.current) return;
started.current = true;
const startTime = performance.now();
const animate = (now: number) => {
const progress = Math.min((now - startTime) / duration, 1);
const eased = 1 - Math.pow(1 - progress, 2.5);
setCount(Math.round(eased * target));
if (progress < 1) requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}, [target, duration]);
return count;
}
interface UserDashboardProps {
onExit: () => void;
}
export default function UserDashboard({ onExit }: UserDashboardProps) {
const { username, role, getUserRecord, changePassword, updateDisplayName } = useAuth();
const { getSubjectStats, getLessonStatus } = useProgress();
const { totalCoins, state: coinState } = useGoldCoins();
const user = getUserRecord(username || '');
const mathStats = getSubjectStats('math');
const ebrwStats = getSubjectStats('ebrw');
// Account settings
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showCurrentPw, setShowCurrentPw] = useState(false);
const [showNewPw, setShowNewPw] = useState(false);
const [pwMsg, setPwMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [pwLoading, setPwLoading] = useState(false);
const [editName, setEditName] = useState(false);
const [nameInput, setNameInput] = useState(user?.displayName || '');
const [nameSaved, setNameSaved] = useState(false);
const animCoins = useCountUp(totalCoins, 1200);
// Count completed topics across all practice
const topicsAttempted = Object.keys(coinState.topicProgress).length;
// Calculate total accuracy
let totalAttempted = 0;
let totalCorrect = 0;
Object.values(coinState.topicProgress).forEach((tp: any) => {
(['easy', 'medium', 'hard'] as const).forEach(d => {
totalAttempted += tp[d]?.attempted || 0;
totalCorrect += tp[d]?.correct || 0;
});
});
const accuracy = totalAttempted > 0 ? Math.round((totalCorrect / totalAttempted) * 100) : 0;
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setPwMsg(null);
if (newPassword !== confirmPassword) {
setPwMsg({ type: 'error', text: 'New passwords do not match.' });
return;
}
setPwLoading(true);
const result = await changePassword(username || '', currentPassword, newPassword);
setPwLoading(false);
if (result.success) {
setPwMsg({ type: 'success', text: 'Password changed successfully!' });
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} else {
setPwMsg({ type: 'error', text: result.error || 'Failed to change password.' });
}
};
const handleSaveName = () => {
if (username && nameInput.trim()) {
updateDisplayName(username, nameInput.trim());
setEditName(false);
setNameSaved(true);
setTimeout(() => setNameSaved(false), 2000);
}
};
// Progress ring
function ProgressRing({ percent, size = 72, stroke = 6, color }: { percent: number; size?: number; stroke?: number; color: string }) {
const r = (size - stroke) / 2;
const circ = 2 * Math.PI * r;
const offset = circ - (percent / 100) * circ;
return (
<svg width={size} height={size} className="transform -rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="currentColor" strokeWidth={stroke} className="text-slate-100" />
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={color} strokeWidth={stroke}
strokeDasharray={circ} strokeDashoffset={offset} strokeLinecap="round"
className="transition-all duration-1000 ease-out" />
<text x={size / 2} y={size / 2} textAnchor="middle" dominantBaseline="central"
className="text-sm font-bold fill-slate-800 transform rotate-90" style={{ transformOrigin: 'center' }}>
{percent}%
</text>
</svg>
);
}
function StatusIcon({ status }: { status: string }) {
if (status === 'completed') return <CheckCircle2 className="w-4 h-4 text-emerald-500" />;
if (status === 'in_progress') return <Circle className="w-4 h-4 text-blue-400" />;
return <Lock className="w-3.5 h-3.5 text-slate-300" />;
}
return (
<div className="min-h-screen bg-gradient-to-b from-white via-slate-50/50 to-white">
{/* Header */}
<header className="sticky top-0 z-40 glass-nav border-b border-slate-100">
<div className="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
<button onClick={onExit} className="flex items-center gap-2 text-sm font-semibold text-slate-500 hover:text-slate-900 transition-colors">
<ArrowLeft className="w-4 h-4" /> Back to Home
</button>
<h1 className="text-sm font-bold text-slate-800">My Dashboard</h1>
<div className="w-20" />
</div>
</header>
<div className="max-w-5xl mx-auto px-6 py-10 space-y-10">
{/* ── Welcome Hero ── */}
<div className="relative bg-gradient-to-br from-cyan-50 via-white to-blue-50 rounded-2xl p-8 border border-cyan-100 overflow-hidden anim-fade-in-up">
<div className="absolute -top-2 -right-2 pointer-events-none select-none opacity-80">
<Mascot pose="waving" height={120} />
</div>
<div className="relative">
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 rounded-xl bg-cyan-100 flex items-center justify-center">
<User className="w-6 h-6 text-cyan-600" />
</div>
<div>
<div className="flex items-center gap-2">
{editName ? (
<div className="flex items-center gap-2">
<input value={nameInput} onChange={e => setNameInput(e.target.value)}
className="text-xl font-bold text-slate-900 bg-white border border-slate-200 rounded-lg px-2 py-0.5 focus:outline-none focus:ring-2 focus:ring-cyan-400 w-48"
autoFocus onKeyDown={e => e.key === 'Enter' && handleSaveName()} />
<button onClick={handleSaveName} className="text-xs font-bold text-cyan-600 hover:text-cyan-800">Save</button>
<button onClick={() => setEditName(false)} className="text-xs text-slate-400 hover:text-slate-600">Cancel</button>
</div>
) : (
<>
<h2 className="text-xl font-bold text-slate-900">{user?.displayName || username}</h2>
<button onClick={() => { setNameInput(user?.displayName || ''); setEditName(true); }}
className="text-xs text-cyan-500 hover:text-cyan-700 font-medium">edit</button>
{nameSaved && <span className="text-xs text-emerald-500 font-medium flex items-center gap-1"><Check className="w-3 h-3" /> Saved</span>}
</>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-widest ${
role === 'admin' ? 'bg-amber-100 text-amber-700' : 'bg-cyan-100 text-cyan-700'
}`}>
{role === 'admin' && <Shield className="w-3 h-3" />}
{role}
</span>
<span className="text-xs text-slate-400">@{username}</span>
</div>
</div>
</div>
{user?.lastLoginAt && (
<p className="text-xs text-slate-400 mt-2 flex items-center gap-1">
<Clock className="w-3 h-3" />
Last login: {new Date(user.lastLoginAt).toLocaleString()}
{user.lastLoginIp && user.lastLoginIp !== 'unknown' && <span className="ml-1">from {user.lastLoginIp}</span>}
</p>
)}
</div>
</div>
{/* ── Stats Overview ── */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 anim-fade-in-up stagger-1">
<div className="bg-white rounded-2xl p-5 border border-slate-200 card-lift text-center">
<p className="text-3xl font-bold text-slate-900 tabular-nums">{mathStats.completed + ebrwStats.completed}</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mt-1">Lessons Done</p>
</div>
<div className="bg-white rounded-2xl p-5 border border-slate-200 card-lift text-center">
<p className="text-3xl font-bold text-amber-500 tabular-nums flex items-center justify-center gap-1">
<Award className="w-5 h-5" />{animCoins}
</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mt-1">Gold Coins</p>
</div>
<div className="bg-white rounded-2xl p-5 border border-slate-200 card-lift text-center">
<p className="text-3xl font-bold text-emerald-500 tabular-nums">{accuracy}%</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mt-1">Accuracy</p>
</div>
<div className="bg-white rounded-2xl p-5 border border-slate-200 card-lift text-center">
<p className="text-3xl font-bold text-blue-500 tabular-nums">{topicsAttempted}</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mt-1">Topics Practiced</p>
</div>
</div>
{/* ── Lesson Progress ── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 anim-fade-in-up stagger-2">
{/* Math */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 card-lift">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-50 flex items-center justify-center">
<Calculator className="w-5 h-5 text-blue-500" />
</div>
<div>
<h3 className="font-bold text-slate-900">Mathematics</h3>
<p className="text-xs text-slate-400">{mathStats.completed}/{mathStats.total} lessons completed</p>
</div>
</div>
<ProgressRing percent={mathStats.percentComplete} color="#3b82f6" />
</div>
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden mb-4">
<div className="h-full bg-blue-500 rounded-full transition-all duration-1000" style={{ width: `${mathStats.percentComplete}%` }} />
</div>
<div className="space-y-1 max-h-48 overflow-y-auto pr-1">
{LESSONS.map(l => (
<div key={l.id} className="flex items-center gap-2 py-1 text-xs">
<StatusIcon status={getLessonStatus(l.id, 'math')} />
<span className="text-slate-600 truncate">{l.title}</span>
</div>
))}
</div>
</div>
{/* EBRW */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 card-lift">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-purple-50 flex items-center justify-center">
<BookOpen className="w-5 h-5 text-purple-500" />
</div>
<div>
<h3 className="font-bold text-slate-900">Reading & Writing</h3>
<p className="text-xs text-slate-400">{ebrwStats.completed}/{ebrwStats.total} lessons completed</p>
</div>
</div>
<ProgressRing percent={ebrwStats.percentComplete} color="#a855f7" />
</div>
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden mb-4">
<div className="h-full bg-purple-500 rounded-full transition-all duration-1000" style={{ width: `${ebrwStats.percentComplete}%` }} />
</div>
<div className="space-y-1 max-h-48 overflow-y-auto pr-1">
{EBRW_LESSONS.map(l => (
<div key={l.id} className="flex items-center gap-2 py-1 text-xs">
<StatusIcon status={getLessonStatus(l.id, 'ebrw')} />
<span className="text-slate-600 truncate">{l.title}</span>
</div>
))}
</div>
</div>
</div>
{/* ── Practice Performance ── */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 anim-fade-in-up stagger-3">
<div className="flex items-center gap-3 mb-5">
<div className="w-10 h-10 rounded-xl bg-amber-50 flex items-center justify-center">
<TrendingUp className="w-5 h-5 text-amber-500" />
</div>
<div>
<h3 className="font-bold text-slate-900">Practice Performance</h3>
<p className="text-xs text-slate-400">{totalAttempted} questions attempted across {topicsAttempted} topics</p>
</div>
</div>
{topicsAttempted === 0 ? (
<div className="py-8 text-center text-slate-400 text-sm">
<Sparkles className="w-6 h-6 mx-auto mb-2 text-amber-300" />
No practice sessions yet. Start practicing to see your performance!
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{Object.entries(coinState.topicProgress).map(([topicId, tp]: [string, any]) => {
const easy = tp.easy || { attempted: 0, correct: 0 };
const medium = tp.medium || { attempted: 0, correct: 0 };
const hard = tp.hard || { attempted: 0, correct: 0 };
const total = easy.attempted + medium.attempted + hard.attempted;
const correct = easy.correct + medium.correct + hard.correct;
const acc = total > 0 ? Math.round((correct / total) * 100) : 0;
return (
<div key={topicId} className="border border-slate-100 rounded-xl p-3 hover:border-slate-200 transition-colors">
<p className="text-xs font-semibold text-slate-700 truncate mb-2">{topicId}</p>
<div className="flex items-center justify-between text-[10px] text-slate-400 mb-1">
<span>{correct}/{total} correct</span>
<span className={`font-bold ${acc >= 70 ? 'text-emerald-500' : acc >= 40 ? 'text-amber-500' : 'text-rose-500'}`}>{acc}%</span>
</div>
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${acc >= 70 ? 'bg-emerald-400' : acc >= 40 ? 'bg-amber-400' : 'bg-rose-400'}`} style={{ width: `${acc}%` }} />
</div>
<div className="flex gap-3 mt-2 text-[10px] text-slate-400">
<span>E: {easy.correct}/{easy.attempted}</span>
<span>M: {medium.correct}/{medium.attempted}</span>
<span>H: {hard.correct}/{hard.attempted}</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ── Account Settings ── */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 anim-fade-in-up stagger-4">
<div className="flex items-center gap-3 mb-5">
<div className="w-10 h-10 rounded-xl bg-slate-100 flex items-center justify-center">
<Lock className="w-5 h-5 text-slate-500" />
</div>
<div>
<h3 className="font-bold text-slate-900">Change Password</h3>
<p className="text-xs text-slate-400">Update your account password</p>
</div>
</div>
<form onSubmit={handleChangePassword} className="max-w-sm space-y-3">
{pwMsg && (
<div className={`flex items-center gap-2 p-3 rounded-xl text-sm ${
pwMsg.type === 'success' ? 'bg-emerald-50 border border-emerald-200 text-emerald-700' : 'bg-rose-50 border border-rose-200 text-rose-700'
}`}>
{pwMsg.type === 'success' ? <Check className="w-4 h-4 shrink-0" /> : <AlertCircle className="w-4 h-4 shrink-0" />}
{pwMsg.text}
</div>
)}
<div className="relative">
<label className="block text-xs font-semibold text-slate-600 mb-1">Current Password</label>
<input type={showCurrentPw ? 'text' : 'password'} value={currentPassword} onChange={e => setCurrentPassword(e.target.value)}
className="w-full px-3 py-2 pr-9 text-sm border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-cyan-400" required />
<button type="button" onClick={() => setShowCurrentPw(!showCurrentPw)} className="absolute right-3 top-7 text-slate-400 hover:text-slate-600">
{showCurrentPw ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<div className="relative">
<label className="block text-xs font-semibold text-slate-600 mb-1">New Password</label>
<input type={showNewPw ? 'text' : 'password'} value={newPassword} onChange={e => setNewPassword(e.target.value)}
className="w-full px-3 py-2 pr-9 text-sm border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-cyan-400" required minLength={4} />
<button type="button" onClick={() => setShowNewPw(!showNewPw)} className="absolute right-3 top-7 text-slate-400 hover:text-slate-600">
{showNewPw ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">Confirm New Password</label>
<input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-cyan-400" required minLength={4} />
</div>
<button type="submit" disabled={pwLoading}
className="px-5 py-2 bg-slate-900 text-white text-sm font-bold rounded-xl hover:bg-slate-700 transition-all btn-primary disabled:opacity-50">
{pwLoading ? 'Changing...' : 'Change Password'}
</button>
</form>
</div>
</div>
</div>
);
}