Files
edbridge-scholars/src/pages/student/practice/Results.tsx

535 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useNavigate } from "react-router-dom";
import { useResults } from "../../../stores/useResults";
import { LucideArrowLeft } from "lucide-react";
import { CircularLevelProgress } from "../../../components/CircularLevelProgress";
import { useEffect, useState } from "react";
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
// ─── Shared styles injected once ─────────────────────────────────────────────
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600&display=swap');
.results-screen {
min-height: 100vh;
background: #fffbf4;
font-family: 'Nunito', sans-serif;
position: relative;
overflow-x: hidden;
padding: 0 0 3rem 0;
}
/* ── Blobs ── */
.r-blob { position: fixed; pointer-events: none; z-index: 0; }
.r-blob-1 {
width: 260px; height: 260px; background: #fde68a;
top: -80px; left: -80px;
border-radius: 60% 40% 70% 30% / 50% 60% 40% 50%;
animation: rWobble1 6s ease-in-out infinite;
}
.r-blob-2 {
width: 200px; height: 200px; background: #a5f3c0;
bottom: -60px; left: 8%;
border-radius: 40% 60% 30% 70% / 60% 40% 60% 40%;
animation: rWobble2 7s ease-in-out infinite;
}
.r-blob-3 {
width: 220px; height: 220px; background: #fbcfe8;
top: 12%; right: -60px;
border-radius: 70% 30% 50% 50% / 40% 60% 40% 60%;
animation: rWobble1 8s ease-in-out infinite reverse;
}
.r-blob-4 {
width: 160px; height: 160px; background: #bfdbfe;
bottom: 15%; right: 3%;
border-radius: 50% 50% 30% 70% / 60% 40% 60% 40%;
animation: rWobble2 5s ease-in-out infinite;
}
@keyframes rWobble1 {
0%,100% { border-radius:60% 40% 70% 30%/50% 60% 40% 50%; transform:translate(0,0) rotate(0deg); }
50% { border-radius:40% 60% 30% 70%/60% 40% 60% 40%; transform:translate(12px,16px) rotate(8deg); }
}
@keyframes rWobble2 {
0%,100% { border-radius:40% 60% 30% 70%/60% 40% 60% 40%; transform:translate(0,0) rotate(0deg); }
50% { border-radius:60% 40% 70% 30%/40% 60% 40% 60%; transform:translate(-10px,12px) rotate(-6deg); }
}
/* ── Floating dots ── */
.r-dot {
position: fixed; border-radius: 50%; pointer-events: none; z-index: 0;
animation: rFloat 4s ease-in-out infinite;
}
@keyframes rFloat {
0%,100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-14px) rotate(180deg); }
}
/* ── Content wrapper ── */
.results-inner {
position: relative; z-index: 1;
max-width: 520px;
margin: 0 auto;
padding: 2.5rem 1.5rem 2rem;
display: flex; flex-direction: column; gap: 1rem;
}
/* ── Header ── */
.results-header {
display: flex; align-items: center; gap: 1rem;
margin-bottom: 0.25rem;
animation: rPopIn 0.45s cubic-bezier(0.34,1.56,0.64,1) both;
}
.results-back-btn {
display: flex; align-items: center; justify-content: center;
width: 40px; height: 40px; border-radius: 50%;
border: 2px solid #c084fc;
background: linear-gradient(135deg, #c084fc, #a855f7);
cursor: pointer; flex-shrink: 0;
box-shadow: 0 4px 0 #7e22ce55;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.results-back-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 0 #7e22ce55; }
.results-back-btn:active { transform: translateY(2px); box-shadow: 0 2px 0 #7e22ce55; }
.results-title {
font-size: 2rem; font-weight: 900; color: #1e1b4b;
letter-spacing: -0.02em;
}
/* ── Mode badge ── */
.mode-badge {
display: flex; align-items: center; gap: 0.6rem;
background: white; border: 2.5px solid #e9d5ff;
border-radius: 16px; padding: 0.75rem 1.1rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
animation: rPopIn 0.45s cubic-bezier(0.34,1.56,0.64,1) 0.05s both;
}
.mode-badge-text { font-size: 0.85rem; font-weight: 700; color: #7e22ce; }
.mode-badge-sub { font-size: 0.78rem; font-weight: 600; color: #a78bfa; }
/* ── Hero congratulations banner ── */
.congrats-banner {
background: white;
border: 2.5px solid #f3f4f6;
border-radius: 24px;
padding: 1.5rem;
text-align: center;
box-shadow: 0 6px 20px rgba(0,0,0,0.05);
animation: rPopIn 0.45s cubic-bezier(0.34,1.56,0.64,1) 0.1s both;
display: flex; flex-direction: column; align-items: center; gap: 0.25rem;
}
.congrats-emoji {
font-size: 3rem;
animation: rBounce 2s ease-in-out infinite;
display: block; margin-bottom: 0.25rem;
}
@keyframes rBounce {
0%,100% { transform: translateY(0) rotate(-4deg); }
50% { transform: translateY(-10px) rotate(4deg); }
}
.congrats-title {
font-size: 1.4rem; font-weight: 900; color: #1e1b4b;
}
.congrats-title span { color: #f97316; }
.congrats-sub {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.85rem; font-weight: 600; color: #9ca3af;
}
/* ── XP ring section ── */
.xp-section {
display: flex; justify-content: center;
animation: rPopIn 0.45s cubic-bezier(0.34,1.56,0.64,1) 0.15s both;
}
/* ── Stat cards grid ── */
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
animation: rPopIn 0.45s cubic-bezier(0.34,1.56,0.64,1) 0.2s both;
}
.stat-card {
background: white;
border: 2.5px solid #f3f4f6;
border-radius: 20px;
padding: 1.1rem 1rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.04);
display: flex; flex-direction: column; gap: 0.2rem;
}
.stat-card-label {
font-size: 0.65rem; font-weight: 700;
letter-spacing: 0.12em; text-transform: uppercase;
color: #9ca3af;
display: flex; align-items: center; gap: 0.35rem;
}
.stat-card-value {
font-size: 1.9rem; font-weight: 900; color: #1e1b4b;
line-height: 1;
}
.stat-card-sub {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.75rem; font-weight: 600; color: #d1d5db;
}
/* Accent colours per card */
.stat-card.orange { border-color: #fed7aa; }
.stat-card.orange .stat-card-value { color: #f97316; }
.stat-card.green { border-color: #bbf7d0; }
.stat-card.green .stat-card-value { color: #16a34a; }
.stat-card.purple { border-color: #e9d5ff; }
.stat-card.purple .stat-card-value { color: #9333ea; }
.stat-card.blue { border-color: #bfdbfe; }
.stat-card.blue .stat-card-value { color: #2563eb; }
/* ── Improvement tip card ── */
.tip-card {
background: white;
border: 2.5px solid #f3f4f6;
border-radius: 20px;
padding: 1.1rem 1.25rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.04);
animation: rPopIn 0.45s cubic-bezier(0.34,1.56,0.64,1) 0.25s both;
}
.tip-card-header {
display: flex; align-items: center; gap: 0.5rem;
margin-bottom: 0.6rem;
}
.tip-card-title {
font-size: 0.95rem; font-weight: 900; color: #1e1b4b;
}
.tip-chips {
display: flex; flex-wrap: wrap; gap: 0.5rem;
}
.tip-chip {
background: #fafafa; border: 2px solid #f3f4f6;
border-radius: 100px; padding: 0.35rem 0.85rem;
font-size: 0.75rem; font-weight: 700; color: #374151;
display: flex; align-items: center; gap: 0.3rem;
}
/* ── Done button ── */
.done-btn {
width: 100%;
background: #f97316;
color: white; border: none;
border-radius: 100px;
padding: 1rem 2.5rem;
font-family: 'Nunito', sans-serif;
font-size: 1rem; font-weight: 800;
cursor: pointer;
box-shadow: 0 6px 0 #c2560e, 0 8px 20px rgba(249,115,22,0.25);
transition: transform 0.1s ease, box-shadow 0.1s ease;
animation: rPopIn 0.45s cubic-bezier(0.34,1.56,0.64,1) 0.35s both;
letter-spacing: 0.01em;
}
.done-btn:hover { transform: translateY(-2px); box-shadow: 0 8px 0 #c2560e, 0 12px 24px rgba(249,115,22,0.3); }
.done-btn:active { transform: translateY(3px); box-shadow: 0 3px 0 #c2560e, 0 4px 12px rgba(249,115,22,0.2); }
@keyframes rPopIn {
from { opacity:0; transform: scale(0.88) translateY(12px); }
to { opacity:1; transform: scale(1) translateY(0); }
}
`;
// ─── Animated counter ─────────────────────────────────────────────────────────
function useCountUp(target: number, duration = 900) {
const [val, setVal] = useState(0);
useEffect(() => {
if (!target) return;
let start: number | null = null;
const tick = (t: number) => {
if (!start) start = t;
const p = Math.min((t - start) / duration, 1);
setVal(Math.floor(p * target));
if (p < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}, [target]);
return val;
}
// ─── Floating dots config (shared) ───────────────────────────────────────────
const DOTS = [
{ size: 12, color: "#f97316", top: "18%", left: "12%", delay: "0s" },
{ size: 8, color: "#a855f7", top: "33%", left: "5%", delay: "1s" },
{ size: 10, color: "#22c55e", top: "62%", left: "15%", delay: "0.5s" },
{ size: 14, color: "#3b82f6", top: "22%", right: "10%", delay: "1.5s" },
{ size: 8, color: "#f43f5e", top: "52%", right: "6%", delay: "0.8s" },
{ size: 10, color: "#eab308", top: "72%", right: "18%", delay: "0.3s" },
];
// ─── Targeted results ─────────────────────────────────────────────────────────
const TARGETED_XP = 15;
const TARGETED_SCORE = 15;
const TargetedResults = ({ onFinish }: { onFinish: () => void }) => {
const { userMetrics } = useExamConfigStore();
const previousXP = userMetrics.xp ?? 0;
const gainedXP = TARGETED_XP;
const levelMinXP = Math.floor(previousXP / 100) * 100;
const levelMaxXP = levelMinXP + 100;
const currentLevel = Math.floor(previousXP / 100) + 1;
const displayXP = useCountUp(gainedXP);
return (
<div className="results-screen">
<style>{STYLES}</style>
{/* Blobs */}
<div className="r-blob r-blob-1" />
<div className="r-blob r-blob-2" />
<div className="r-blob r-blob-3" />
<div className="r-blob r-blob-4" />
{/* Dots */}
{DOTS.map((d, i) => (
<div
key={i}
className="r-dot"
style={
{
width: d.size,
height: d.size,
background: d.color,
top: d.top,
left: d.left,
right: d.right,
animationDelay: d.delay,
animationDuration: `${3.5 + i * 0.4}s`,
} as React.CSSProperties
}
/>
))}
<div className="results-inner">
{/* Header */}
<header className="results-header">
<button className="results-back-btn" onClick={onFinish}>
<LucideArrowLeft size={18} color="white" />
</button>
<h1 className="results-title">Results</h1>
</header>
{/* Mode badge */}
<div className="mode-badge">
<span style={{ fontSize: "1.4rem" }}>🎯</span>
<div>
<p className="mode-badge-text">Targeted Mode Complete!</p>
<p className="mode-badge-sub">
You answered all questions correctly.
</p>
</div>
</div>
{/* Congrats banner */}
<div className="congrats-banner">
<span className="congrats-emoji">🏆</span>
<p className="congrats-title">
Nailed it, <span>champ!</span>
</p>
<p className="congrats-sub">Perfect run every question down.</p>
</div>
{/* XP ring */}
<div className="xp-section">
<CircularLevelProgress
previousXP={previousXP}
gainedXP={gainedXP}
levelMinXP={levelMinXP}
levelMaxXP={levelMaxXP}
level={currentLevel}
/>
</div>
{/* Stats grid */}
<div className="stats-grid">
<div className="stat-card orange">
<span className="stat-card-label"> XP Gained</span>
<span className="stat-card-value">+{displayXP}</span>
<span className="stat-card-sub">experience points</span>
</div>
<div className="stat-card green">
<span className="stat-card-label">🎯 Score</span>
<span className="stat-card-value">{TARGETED_SCORE}</span>
<span className="stat-card-sub">total points</span>
</div>
<div className="stat-card purple">
<span className="stat-card-label"> Accuracy</span>
<span className="stat-card-value">100%</span>
<span className="stat-card-sub">all correct</span>
</div>
<div className="stat-card blue">
<span className="stat-card-label">🔥 Streak</span>
<span className="stat-card-value">Perfect</span>
<span className="stat-card-sub">no mistakes</span>
</div>
</div>
{/* Tip card */}
<div className="tip-card">
<div className="tip-card-header">
<span style={{ fontSize: "1.2rem" }}>🚀</span>
<span className="tip-card-title">Keep the momentum going!</span>
</div>
<div className="tip-chips">
<div className="tip-chip">📖 Review mistakes</div>
<div className="tip-chip"> Try timed mode</div>
<div className="tip-chip">🎯 Next topic</div>
</div>
</div>
<button className="done-btn" onClick={onFinish}>
Done
</button>
</div>
</div>
);
};
// ─── Main Results ─────────────────────────────────────────────────────────────
export const Results = () => {
const navigate = useNavigate();
const results = useResults((s) => s.results);
const clearResults = useResults((s) => s.clearResults);
const { payload } = useExamConfigStore();
const isTargeted = payload?.mode === "TARGETED";
function handleFinishExam() {
useExamConfigStore.getState().clearPayload();
clearResults();
navigate("/student/home");
}
if (isTargeted) return <TargetedResults onFinish={handleFinishExam} />;
// ── Standard mode values ───────────────────────────────────────────────────
const previousXP = results ? results.total_xp - results.xp_gained : 0;
const accuracy =
results && results.total_questions > 0
? Math.round((results.correct_count / results.total_questions) * 100)
: 0;
const displayXP = useCountUp(results?.xp_gained ?? 0);
const displayScore = useCountUp(results?.score ?? 0);
// Motivational headline based on accuracy
const headline =
accuracy >= 90
? { emoji: "🏆", text: "Absolutely crushing it!" }
: accuracy >= 70
? { emoji: "🎉", text: "Solid work, keep going!" }
: accuracy >= 50
? { emoji: "💪", text: "Good effort, room to grow!" }
: { emoji: "📚", text: "Every attempt makes you better!" };
return (
<div className="results-screen">
<style>{STYLES}</style>
{/* Blobs */}
<div className="r-blob r-blob-1" />
<div className="r-blob r-blob-2" />
<div className="r-blob r-blob-3" />
<div className="r-blob r-blob-4" />
{/* Dots */}
{DOTS.map((d, i) => (
<div
key={i}
className="r-dot"
style={
{
width: d.size,
height: d.size,
background: d.color,
top: d.top,
left: d.left,
right: d.right,
animationDelay: d.delay,
animationDuration: `${3.5 + i * 0.4}s`,
} as React.CSSProperties
}
/>
))}
<div className="results-inner">
{/* Header */}
<header className="results-header">
<button className="results-back-btn" onClick={handleFinishExam}>
<LucideArrowLeft size={18} color="white" />
</button>
<h1 className="results-title">Results</h1>
</header>
{/* Congrats banner — dynamic */}
<div className="congrats-banner">
<span className="congrats-emoji">{headline.emoji}</span>
<p className="congrats-title">
{headline.text.split(" ").slice(0, -1).join(" ")}{" "}
<span>{headline.text.split(" ").slice(-1)}</span>
</p>
<p className="congrats-sub">Here's how you performed today</p>
</div>
{/* XP ring */}
<div className="xp-section">
{results && (
<CircularLevelProgress
previousXP={previousXP}
gainedXP={results.xp_gained}
levelMinXP={results.current_level_start}
levelMaxXP={results.next_level_threshold}
level={results.current_level}
/>
)}
</div>
{/* Stats grid */}
<div className="stats-grid">
<div className="stat-card orange">
<span className="stat-card-label"> XP Gained</span>
<span className="stat-card-value">+{displayXP}</span>
<span className="stat-card-sub">experience points</span>
</div>
<div className="stat-card green">
<span className="stat-card-label">🎯 Score</span>
<span className="stat-card-value">{displayScore}</span>
<span className="stat-card-sub">total points</span>
</div>
<div className={`stat-card ${accuracy >= 70 ? "purple" : "blue"}`}>
<span className="stat-card-label"> Accuracy</span>
<span className="stat-card-value">{accuracy}%</span>
<span className="stat-card-sub">
{results?.correct_count ?? 0} of {results?.total_questions ?? 0}{" "}
correct
</span>
</div>
<div className="stat-card blue">
<span className="stat-card-label"> Missed</span>
<span className="stat-card-value">
{results ? results.total_questions - results.correct_count : 0}
</span>
<span className="stat-card-sub">questions to review</span>
</div>
</div>
{/* Tip card */}
<div className="tip-card">
<div className="tip-card-header">
<span style={{ fontSize: "1.2rem" }}>💡</span>
<span className="tip-card-title">How to improve faster</span>
</div>
<div className="tip-chips">
<div className="tip-chip">📖 Review wrong answers</div>
<div className="tip-chip">🔁 Retry missed questions</div>
<div className="tip-chip"> Work on pacing</div>
<div className="tip-chip">📈 Track your trends</div>
</div>
</div>
<button className="done-btn" onClick={handleFinishExam}>
Done
</button>
</div>
</div>
);
};