feat(ui): add new ui
This commit is contained in:
@ -1,165 +1,390 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useResults } from "../../../stores/useResults";
|
||||
import { LucideArrowLeft } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { CircularLevelProgress } from "../../../components/CircularLevelProgress";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
||||
|
||||
const XPGainedCard = ({
|
||||
results,
|
||||
}: {
|
||||
results?: {
|
||||
xp_gained: number;
|
||||
total_xp: number;
|
||||
current_level_start: number;
|
||||
next_level_threshold: number;
|
||||
current_level: number;
|
||||
};
|
||||
}) => {
|
||||
const [displayXP, setDisplayXP] = useState(0);
|
||||
// ─── 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 (!results?.xp_gained) return;
|
||||
|
||||
let startTime: number | null = null;
|
||||
const duration = 800;
|
||||
|
||||
const animate = (time: number) => {
|
||||
if (!startTime) startTime = time;
|
||||
const t = Math.min((time - startTime) / duration, 1);
|
||||
setDisplayXP(Math.floor(t * results.xp_gained));
|
||||
if (t < 1) requestAnimationFrame(animate);
|
||||
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;
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}, [results?.xp_gained]);
|
||||
// ─── 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" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>XP</CardTitle>
|
||||
<CardDescription>How much did you improve?</CardDescription>
|
||||
<CardAction>
|
||||
<p className="font-satoshi-medium">+{displayXP} XP</p>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Targeted static results ──────────────────────────────────────────────────
|
||||
// ─── Targeted results ─────────────────────────────────────────────────────────
|
||||
const TARGETED_XP = 15;
|
||||
const TARGETED_SCORE = 15;
|
||||
|
||||
const TargetedResults = ({ onFinish }: { onFinish: () => void }) => {
|
||||
const { userXp, setUserXp } = useExamConfigStore();
|
||||
|
||||
// previousXP is whatever the user had before; we add 15 on top
|
||||
const previousXP = userXp ?? 0;
|
||||
const gainedXP = TARGETED_XP;
|
||||
const totalXP = previousXP;
|
||||
|
||||
// Sync updated XP back into the store
|
||||
useEffect(() => {
|
||||
setUserXp(totalXP);
|
||||
}, []);
|
||||
|
||||
// Simple level bounds — 0–100 per level so progress is visible
|
||||
// Adjust these to match your real level thresholds if needed
|
||||
const levelMinXP = Math.floor(previousXP / 100) * 100;
|
||||
const levelMaxXP = levelMinXP + 100;
|
||||
const currentLevel = Math.floor(previousXP / 100) + 1;
|
||||
|
||||
const [displayXP, setDisplayXP] = useState(0);
|
||||
const displayXP = useCountUp(gainedXP);
|
||||
|
||||
useEffect(() => {
|
||||
let startTime: number | null = null;
|
||||
const duration = 800;
|
||||
const animate = (time: number) => {
|
||||
if (!startTime) startTime = time;
|
||||
const t = Math.min((time - startTime) / duration, 1);
|
||||
setDisplayXP(Math.floor(t * gainedXP));
|
||||
if (t < 1) requestAnimationFrame(animate);
|
||||
};
|
||||
requestAnimationFrame(animate);
|
||||
setUserXp(previousXP);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-gray-50 space-y-6 mx-auto px-8 sm:px-6 lg:px-90 py-10">
|
||||
<header className="flex gap-4">
|
||||
<button
|
||||
onClick={onFinish}
|
||||
className="p-2 rounded-full border border-purple-400 bg-linear-to-br from-purple-400 to-purple-500"
|
||||
>
|
||||
<LucideArrowLeft size={20} color="white" />
|
||||
</button>
|
||||
<h1 className="text-3xl font-satoshi-bold">Results</h1>
|
||||
</header>
|
||||
<div className="results-screen">
|
||||
<style>{STYLES}</style>
|
||||
|
||||
{/* Targeted mode badge */}
|
||||
<div className="flex items-center gap-2 bg-purple-50 border border-purple-200 rounded-2xl px-4 py-3">
|
||||
<span className="text-xl">🎯</span>
|
||||
<p className="font-satoshi text-purple-700 text-sm">
|
||||
<strong>Targeted Mode Complete!</strong> You answered all questions
|
||||
correctly.
|
||||
</p>
|
||||
</div>
|
||||
{/* 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" />
|
||||
|
||||
<section className="w-full flex items-center justify-center">
|
||||
<CircularLevelProgress
|
||||
previousXP={previousXP}
|
||||
gainedXP={gainedXP}
|
||||
levelMinXP={levelMinXP}
|
||||
levelMaxXP={levelMaxXP}
|
||||
level={currentLevel}
|
||||
{/* 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
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>XP</CardTitle>
|
||||
<CardDescription>How much did you improve?</CardDescription>
|
||||
<CardAction>
|
||||
<p className="font-satoshi-medium">+{displayXP} XP</p>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<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>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Score</CardTitle>
|
||||
<CardDescription>Total score you achieved.</CardDescription>
|
||||
<CardAction>
|
||||
<p className="font-satoshi-medium">{TARGETED_SCORE}</p>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
{/* 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>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Keep it up! 🚀</CardTitle>
|
||||
<CardDescription>
|
||||
Great work getting every question right. Keep practicing to level up
|
||||
faster!
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
{/* 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>
|
||||
|
||||
<button
|
||||
onClick={onFinish}
|
||||
className="w-full font-satoshi rounded-3xl text-lg py-4 bg-linear-to-br from-purple-500 to-purple-600 text-white"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</main>
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -168,84 +393,149 @@ export const Results = () => {
|
||||
const navigate = useNavigate();
|
||||
const results = useResults((s) => s.results);
|
||||
const clearResults = useResults((s) => s.clearResults);
|
||||
|
||||
const { setUserXp, payload } = useExamConfigStore();
|
||||
const isTargeted = payload?.mode === "TARGETED";
|
||||
|
||||
useEffect(() => {
|
||||
if (results) setUserXp(results?.total_xp);
|
||||
if (results) setUserXp(results.total_xp);
|
||||
}, [results]);
|
||||
|
||||
function handleFinishExam() {
|
||||
useExamConfigStore.getState().clearPayload();
|
||||
clearResults();
|
||||
navigate(`/student/home`);
|
||||
navigate("/student/home");
|
||||
}
|
||||
|
||||
// ── Targeted mode: show static screen ──────────────────────────────────────
|
||||
if (isTargeted) {
|
||||
return <TargetedResults onFinish={handleFinishExam} />;
|
||||
}
|
||||
if (isTargeted) return <TargetedResults onFinish={handleFinishExam} />;
|
||||
|
||||
// ── Standard mode ──────────────────────────────────────────────────────────
|
||||
// ── 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 (
|
||||
<main className="min-h-screen bg-gray-50 space-y-6 mx-auto px-8 sm:px-6 lg:px-90 py-10">
|
||||
<header className="flex gap-4">
|
||||
<button
|
||||
onClick={() => handleFinishExam()}
|
||||
className="p-2 rounded-full border border-purple-400 bg-linear-to-br from-purple-400 to-purple-500"
|
||||
>
|
||||
<LucideArrowLeft size={20} color="white" />
|
||||
</button>
|
||||
<h1 className="text-3xl font-satoshi-bold">Results</h1>
|
||||
</header>
|
||||
<section className="w-full flex items-center justify-center">
|
||||
{results && (
|
||||
<CircularLevelProgress
|
||||
previousXP={previousXP}
|
||||
gainedXP={results.xp_gained}
|
||||
levelMinXP={results.current_level_start}
|
||||
levelMaxXP={results.next_level_threshold}
|
||||
level={results.current_level}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
<div className="results-screen">
|
||||
<style>{STYLES}</style>
|
||||
|
||||
<XPGainedCard results={results} />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Score</CardTitle>
|
||||
<CardDescription>Total score you achieved.</CardDescription>
|
||||
<CardAction>
|
||||
<p className="font-satoshi-medium">{results?.score}</p>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Accuracy</CardTitle>
|
||||
<CardDescription>How many did you answer correct?</CardDescription>
|
||||
<CardAction>
|
||||
<p className="font-satoshi-medium">
|
||||
{results && results.total_questions > 0
|
||||
? `${Math.round(
|
||||
(results.correct_count / results.total_questions) * 100,
|
||||
)}%`
|
||||
: "—"}
|
||||
</p>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>How do you improve?</CardTitle>
|
||||
<CardDescription>
|
||||
Your score is good, but you can do better!
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</main>
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user