feat(results): add resutls page
fix(leaderboard): fix leaderboard fetch logic fix(test): fix navigation bug upon test quit
This commit is contained in:
@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<script src="https://www.geogebra.org/apps/deployggb.js"></script>
|
||||||
<title>Edbridge Scholars</title>
|
<title>Edbridge Scholars</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
145
src/components/CircularLevelProgress.tsx
Normal file
145
src/components/CircularLevelProgress.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ConfettiBurst } from "./ConfettiBurst";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
size?: number;
|
||||||
|
strokeWidth?: number;
|
||||||
|
previousXP: number;
|
||||||
|
gainedXP: number;
|
||||||
|
levelMinXP: number;
|
||||||
|
levelMaxXP: number;
|
||||||
|
level: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CircularLevelProgress = ({
|
||||||
|
size = 300,
|
||||||
|
strokeWidth = 16,
|
||||||
|
previousXP,
|
||||||
|
gainedXP,
|
||||||
|
levelMinXP,
|
||||||
|
levelMaxXP,
|
||||||
|
level,
|
||||||
|
}: Props) => {
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const levelRange = levelMaxXP - levelMinXP;
|
||||||
|
|
||||||
|
const normalize = (xp: number) =>
|
||||||
|
Math.min(Math.max(xp - levelMinXP, 0), levelRange) / levelRange;
|
||||||
|
|
||||||
|
const [progress, setProgress] = useState(normalize(previousXP));
|
||||||
|
const [currentLevel, setCurrentLevel] = useState(level);
|
||||||
|
const [showLevelUp, setShowLevelUp] = useState(false);
|
||||||
|
const [showThresholdText, setShowThresholdText] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let animationFrame: number;
|
||||||
|
let start: number | null = null;
|
||||||
|
|
||||||
|
const availableXP = previousXP + gainedXP;
|
||||||
|
const crossesLevel = availableXP >= levelMaxXP;
|
||||||
|
|
||||||
|
const phase1Target = crossesLevel ? 1 : normalize(previousXP + gainedXP);
|
||||||
|
|
||||||
|
const leftoverXP = crossesLevel ? availableXP - levelMaxXP : 0;
|
||||||
|
|
||||||
|
const duration = 1200;
|
||||||
|
|
||||||
|
const animatePhase1 = (timestamp: number) => {
|
||||||
|
if (!start) start = timestamp;
|
||||||
|
const t = Math.min((timestamp - start) / duration, 1);
|
||||||
|
|
||||||
|
setProgress(
|
||||||
|
normalize(previousXP) + t * (phase1Target - normalize(previousXP)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (t < 1) {
|
||||||
|
animationFrame = requestAnimationFrame(animatePhase1);
|
||||||
|
} else if (crossesLevel) {
|
||||||
|
setShowLevelUp(true);
|
||||||
|
setTimeout(startPhase2, 1200);
|
||||||
|
} else {
|
||||||
|
setShowThresholdText(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPhase2 = () => {
|
||||||
|
start = null;
|
||||||
|
setShowLevelUp(false);
|
||||||
|
setCurrentLevel((l) => l + 1);
|
||||||
|
setProgress(0);
|
||||||
|
|
||||||
|
const target = Math.min(leftoverXP / levelRange, 1);
|
||||||
|
|
||||||
|
const animatePhase2 = (timestamp: number) => {
|
||||||
|
if (!start) start = timestamp;
|
||||||
|
const t = Math.min((timestamp - start) / duration, 1);
|
||||||
|
|
||||||
|
setProgress(t * target);
|
||||||
|
|
||||||
|
if (t < 1) {
|
||||||
|
animationFrame = requestAnimationFrame(animatePhase2);
|
||||||
|
} else {
|
||||||
|
setShowThresholdText(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
animationFrame = requestAnimationFrame(animatePhase2);
|
||||||
|
};
|
||||||
|
|
||||||
|
animationFrame = requestAnimationFrame(animatePhase1);
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(animationFrame);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const offset = circumference * (1 - progress);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-col items-center gap-2">
|
||||||
|
{showLevelUp && <ConfettiBurst />}
|
||||||
|
<div
|
||||||
|
className="relative flex items-center justify-center"
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
>
|
||||||
|
<svg width={size} height={size}>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
stroke="oklch(94.6% 0.033 307.174)"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
stroke="oklch(62.7% 0.265 303.9)"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<span className="absolute text-[100px] font-satoshi-bold flex flex-col items-center">
|
||||||
|
{currentLevel}
|
||||||
|
|
||||||
|
{showThresholdText && (
|
||||||
|
<span className="text-xl font-satoshi-medium text-gray-500 animate-fade-in">
|
||||||
|
Total XP: {previousXP + gainedXP}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showLevelUp && (
|
||||||
|
<span className="text-xl font-satoshi-medium text-purple-600 animate-fade-in">
|
||||||
|
🎉 You leveled up!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
44
src/components/ConfettiBurst.tsx
Normal file
44
src/components/ConfettiBurst.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
type ConfettiBurstProps = {
|
||||||
|
count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConfettiBurst = ({ count = 30 }: ConfettiBurstProps) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
const container = document.getElementById("confetti-container");
|
||||||
|
if (container) container.innerHTML = "";
|
||||||
|
}, 1200);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="confetti-container"
|
||||||
|
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||||
|
>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="confetti"
|
||||||
|
style={{
|
||||||
|
left: `${Math.random() * 100}%`,
|
||||||
|
backgroundColor: CONFETTI_COLORS[i % CONFETTI_COLORS.length],
|
||||||
|
animationDelay: `${Math.random() * 0.2}s`,
|
||||||
|
transform: `rotate(${Math.random() * 360}deg)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONFETTI_COLORS = [
|
||||||
|
"#a855f7", // purple
|
||||||
|
"#6366f1", // indigo
|
||||||
|
"#ec4899", // pink
|
||||||
|
"#22c55e", // green
|
||||||
|
"#facc15", // yellow
|
||||||
|
];
|
||||||
93
src/components/GeoGebraGraph.tsx
Normal file
93
src/components/GeoGebraGraph.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
GGBApplet: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GraphProps {
|
||||||
|
width?: string;
|
||||||
|
height?: string;
|
||||||
|
commands?: string[];
|
||||||
|
defaultZoom?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Graph({
|
||||||
|
width = "w-full",
|
||||||
|
height = "h-30",
|
||||||
|
commands = [],
|
||||||
|
defaultZoom = 1,
|
||||||
|
}: GraphProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const appRef = useRef<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!(window as any).GGBApplet) {
|
||||||
|
console.error("GeoGebra library not loaded");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const applet = new window.GGBApplet(
|
||||||
|
{
|
||||||
|
appName: "graphing",
|
||||||
|
width: 480,
|
||||||
|
height: 320,
|
||||||
|
scale: 1.4,
|
||||||
|
|
||||||
|
showToolBar: false,
|
||||||
|
showAlgebraInput: false,
|
||||||
|
showMenuBar: false,
|
||||||
|
showResetIcon: false,
|
||||||
|
|
||||||
|
enableRightClick: false,
|
||||||
|
enableLabelDrags: false,
|
||||||
|
enableShiftDragZoom: true,
|
||||||
|
showZoomButtons: true,
|
||||||
|
|
||||||
|
appletOnLoad(api: any) {
|
||||||
|
appRef.current = api;
|
||||||
|
|
||||||
|
api.setPerspective("G");
|
||||||
|
api.setMode(0);
|
||||||
|
api.setAxesVisible(true, true);
|
||||||
|
api.setGridVisible(true);
|
||||||
|
|
||||||
|
api.setCoordSystem(-5, 5, -5, 5);
|
||||||
|
|
||||||
|
commands.forEach((command, i) => {
|
||||||
|
const name = `f${i}`;
|
||||||
|
api.evalCommand(`${name}: ${command}`);
|
||||||
|
api.setFixed(name, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inside appletOnLoad:
|
||||||
|
},
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
applet.inject("ggb-container");
|
||||||
|
}, [commands, defaultZoom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const resize = () => {
|
||||||
|
if (!containerRef.current || !appRef.current) return;
|
||||||
|
appRef.current.setSize(
|
||||||
|
containerRef.current.offsetWidth,
|
||||||
|
containerRef.current.offsetHeight,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("resize", resize);
|
||||||
|
resize(); // initial resize
|
||||||
|
|
||||||
|
return () => window.removeEventListener("resize", resize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="h-[480] w-[320]">
|
||||||
|
<div id="ggb-container" className="w-full h-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -281,3 +281,38 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 300ms ease-out forwards;
|
||||||
|
}
|
||||||
|
.confetti {
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
width: 8px;
|
||||||
|
height: 14px;
|
||||||
|
opacity: 0.9;
|
||||||
|
animation: confetti-fall 1.2s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes confetti-fall {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0) rotate(0deg);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(220px) rotate(720deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,8 +35,6 @@ export const Home = () => {
|
|||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// const logout = useAuthStore((state) => state.logout);
|
|
||||||
// const navigate = useNavigate();
|
|
||||||
const [practiceSheets, setPracticeSheets] = useState<PracticeSheet[]>([]);
|
const [practiceSheets, setPracticeSheets] = useState<PracticeSheet[]>([]);
|
||||||
const [notStartedSheets, setNotStartedSheets] = useState<PracticeSheet[]>([]);
|
const [notStartedSheets, setNotStartedSheets] = useState<PracticeSheet[]>([]);
|
||||||
const [inProgressSheets, setInProgressSheets] = useState<PracticeSheet[]>([]);
|
const [inProgressSheets, setInProgressSheets] = useState<PracticeSheet[]>([]);
|
||||||
@ -92,7 +90,7 @@ export const Home = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-gray-50 space-y-6 max-w-full mx-auto px-8 sm:px-6 lg:px-8 py-12">
|
<main className="min-h-screen bg-gray-50 space-y-6 max-w-full mx-auto px-8 sm:px-6 lg:px-90 py-12">
|
||||||
<h1 className="text-4xl font-satoshi-bold tracking-tight text-gray-800 text-center">
|
<h1 className="text-4xl font-satoshi-bold tracking-tight text-gray-800 text-center">
|
||||||
Welcome, {user?.name || "Student"}
|
Welcome, {user?.name || "Student"}
|
||||||
</h1>
|
</h1>
|
||||||
@ -103,7 +101,7 @@ export const Home = () => {
|
|||||||
your scores now!
|
your scores now!
|
||||||
</p>
|
</p>
|
||||||
</section> */}
|
</section> */}
|
||||||
<Card className="relative bg-linear-to-br from-red-600 to-red-700 rounded-4xl">
|
{/* <Card className="relative bg-linear-to-br from-red-600 to-red-700 rounded-4xl">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<CardHeader className="">
|
<CardHeader className="">
|
||||||
<CardTitle className="font-satoshi-bold tracking-tight text-3xl text-white">
|
<CardTitle className="font-satoshi-bold tracking-tight text-3xl text-white">
|
||||||
@ -142,7 +140,7 @@ export const Home = () => {
|
|||||||
<div className="overflow-hidden opacity-30 -rotate-45 absolute -top-2 -right-30 ">
|
<div className="overflow-hidden opacity-30 -rotate-45 absolute -top-2 -right-30 ">
|
||||||
<DecimalsArrowRight size={380} color="white" />
|
<DecimalsArrowRight size={380} color="white" />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card> */}
|
||||||
<h1 className="font-satoshi-bold text-2xl tracking-tight">
|
<h1 className="font-satoshi-bold text-2xl tracking-tight">
|
||||||
What are you looking for?
|
What are you looking for?
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@ -24,6 +24,7 @@ export const Practice = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const userXp = useExamConfigStore.getState().userXp;
|
const userXp = useExamConfigStore.getState().userXp;
|
||||||
|
console.log(userXp);
|
||||||
return (
|
return (
|
||||||
<main className="h-fit max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4">
|
<main className="h-fit max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4">
|
||||||
<header className="flex justify-between items-center">
|
<header className="flex justify-between items-center">
|
||||||
|
|||||||
@ -67,6 +67,7 @@ export const Rewards = () => {
|
|||||||
const response = await api.fetchLeaderboard(token);
|
const response = await api.fetchLeaderboard(token);
|
||||||
|
|
||||||
setLeaderboard(response);
|
setLeaderboard(response);
|
||||||
|
|
||||||
setUserXp(response.user_rank.total_xp);
|
setUserXp(response.user_rank.total_xp);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -320,13 +321,15 @@ export const Rewards = () => {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{isTopThree ? (
|
{isTopThree ? (
|
||||||
<img
|
<img
|
||||||
src={trophies[leaderboard?.user_rank?.rank ?? Infinity]}
|
src={
|
||||||
|
trophies[(leaderboard?.user_rank?.rank ?? Infinity) - 1]
|
||||||
|
}
|
||||||
alt={`trophy_${leaderboard?.user_rank?.rank ?? Infinity}`}
|
alt={`trophy_${leaderboard?.user_rank?.rank ?? Infinity}`}
|
||||||
className="w-12 h-12"
|
className="w-12 h-12"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="w-12 text-center font-satoshi-bold text-white">
|
<span className="w-12 text-center font-satoshi-bold text-white">
|
||||||
{leaderboard?.user_rank?.rank ?? Infinity}
|
{(leaderboard?.user_rank?.rank ?? Infinity) - 1}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Avatar className={`p-6 ${getRandomColor()}`}>
|
<Avatar className={`p-6 ${getRandomColor()}`}>
|
||||||
|
|||||||
@ -13,11 +13,11 @@ import {
|
|||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
||||||
|
import { useSatExam } from "../../../stores/useSatExam";
|
||||||
|
|
||||||
export const Pretest = () => {
|
export const Pretest = () => {
|
||||||
const { setSheetId, setMode, storeDuration, setQuestionCount } =
|
const { setSheetId, setMode, storeDuration, setQuestionCount } =
|
||||||
useExamConfigStore();
|
useExamConfigStore();
|
||||||
|
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const { sheetId } = useParams<{ sheetId: string }>();
|
const { sheetId } = useParams<{ sheetId: string }>();
|
||||||
const [carouselApi, setCarouselApi] = useState<CarouselApi>();
|
const [carouselApi, setCarouselApi] = useState<CarouselApi>();
|
||||||
|
|||||||
@ -1,12 +1,159 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import { useResults } from "../../../stores/useResults";
|
||||||
|
import { useSatExam } from "../../../stores/useSatExam";
|
||||||
|
import { LucideArrowLeft } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../../components/ui/card";
|
||||||
|
import { Progress } from "../../../components/ui/progress";
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!results?.xp_gained) return;
|
||||||
|
|
||||||
|
let startTime: number | null = null;
|
||||||
|
const duration = 800; // ms
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}, [results?.xp_gained]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const Results = () => {
|
export const Results = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const results = useResults((s) => s.results);
|
||||||
|
const clearResults = useResults((s) => s.clearResults);
|
||||||
|
|
||||||
|
const { setUserXp } = useExamConfigStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (results) setUserXp(results?.total_xp);
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
|
function handleFinishExam() {
|
||||||
|
clearResults();
|
||||||
|
navigate(`/student/home`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// const [displayXP, setDisplayXP] = useState(0);
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (!results?.score) return;
|
||||||
|
// let start = 0;
|
||||||
|
// const duration = 600;
|
||||||
|
// const startTime = performance.now();
|
||||||
|
|
||||||
|
// const animate = (time: number) => {
|
||||||
|
// const t = Math.min((time - startTime) / duration, 1);
|
||||||
|
// setDisplayXP(Math.floor(t * results.score));
|
||||||
|
// if (t < 1) requestAnimationFrame(animate);
|
||||||
|
// };
|
||||||
|
|
||||||
|
// requestAnimationFrame(animate);
|
||||||
|
// }, [results?.score]);
|
||||||
|
|
||||||
|
const previousXP = results ? results.total_xp - results.xp_gained : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center text-2xl font-satoshi-bold">
|
<main className="min-h-screen bg-gray-50 space-y-6 mx-auto px-8 sm:px-6 lg:px-90 py-10">
|
||||||
Your results go here
|
<header className="flex gap-4">
|
||||||
<Button onClick={() => navigate("/student/home")}>Go to home</Button>
|
<button
|
||||||
</div>
|
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={505}
|
||||||
|
// gainedXP={605}
|
||||||
|
// levelMinXP={500}
|
||||||
|
// levelMaxXP={1000}
|
||||||
|
// level={3}
|
||||||
|
previousXP={previousXP}
|
||||||
|
gainedXP={results.xp_gained}
|
||||||
|
levelMinXP={results.current_level_start}
|
||||||
|
levelMaxXP={results.next_level_threshold}
|
||||||
|
level={results.current_level}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { Navigate, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@ -39,6 +39,7 @@ import {
|
|||||||
} from "../../../components/ui/dialog";
|
} from "../../../components/ui/dialog";
|
||||||
import { useExamNavigationGuard } from "../../../hooks/useExamNavGuard";
|
import { useExamNavigationGuard } from "../../../hooks/useExamNavGuard";
|
||||||
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
|
||||||
|
import { useResults } from "../../../stores/useResults";
|
||||||
|
|
||||||
export const Test = () => {
|
export const Test = () => {
|
||||||
const sheetId = localStorage.getItem("activePracticeSheetId");
|
const sheetId = localStorage.getItem("activePracticeSheetId");
|
||||||
@ -79,6 +80,8 @@ export const Test = () => {
|
|||||||
const goToQuestion = useSatExam((s) => s.goToQuestion);
|
const goToQuestion = useSatExam((s) => s.goToQuestion);
|
||||||
|
|
||||||
const finishExam = useSatExam((s) => s.finishExam);
|
const finishExam = useSatExam((s) => s.finishExam);
|
||||||
|
const quitExam = useSatExam((s) => s.quitExam);
|
||||||
|
const setResults = useResults((s) => s.setResults);
|
||||||
|
|
||||||
const startExam = async () => {
|
const startExam = async () => {
|
||||||
if (!user || !sheetId) return;
|
if (!user || !sheetId) return;
|
||||||
@ -181,6 +184,11 @@ export const Test = () => {
|
|||||||
|
|
||||||
if (next?.status === "COMPLETED") {
|
if (next?.status === "COMPLETED") {
|
||||||
useExamConfigStore.getState().clearPayload();
|
useExamConfigStore.getState().clearPayload();
|
||||||
|
console.log(next.results);
|
||||||
|
setResults(next.results);
|
||||||
|
|
||||||
|
// ✅ Store results first
|
||||||
|
|
||||||
finishExam();
|
finishExam();
|
||||||
} else {
|
} else {
|
||||||
await loadSessionQuestions(sessionId);
|
await loadSessionQuestions(sessionId);
|
||||||
@ -190,14 +198,20 @@ export const Test = () => {
|
|||||||
|
|
||||||
const handleQuitExam = () => {
|
const handleQuitExam = () => {
|
||||||
useExamConfigStore.getState().clearPayload();
|
useExamConfigStore.getState().clearPayload();
|
||||||
finishExam();
|
|
||||||
navigate("/student/home");
|
quitExam();
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetExam(); // ✅ important
|
resetExam(); // ✅ important
|
||||||
}, [sheetId]);
|
}, [sheetId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
resetExam();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase === "FINISHED") {
|
if (phase === "FINISHED") {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@ -402,7 +416,7 @@ export const Test = () => {
|
|||||||
{currentQuestion?.context && (
|
{currentQuestion?.context && (
|
||||||
<section className="h-100 overflow-y-auto px-10 pt-30">
|
<section className="h-100 overflow-y-auto px-10 pt-30">
|
||||||
<p className="font-satoshi tracking-wide text-lg">
|
<p className="font-satoshi tracking-wide text-lg">
|
||||||
{currentQuestion?.context}
|
{renderQuestionText(currentQuestion?.context)}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@ -611,6 +625,8 @@ export const Test = () => {
|
|||||||
<p className="text-lg text-gray-500">Redirecting to results...</p>
|
<p className="text-lg text-gray-500">Redirecting to results...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
case "QUIT":
|
||||||
|
return <Navigate to="/student/home" replace />;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -17,8 +17,8 @@ export const TargetedPractice = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const {
|
const {
|
||||||
storeTopics,
|
storeTopics,
|
||||||
setDifficulty: storeDifficulty,
|
|
||||||
storeDuration,
|
storeDuration,
|
||||||
|
setDifficulty: storeDifficulty,
|
||||||
setMode,
|
setMode,
|
||||||
setQuestionCount,
|
setQuestionCount,
|
||||||
} = useExamConfigStore();
|
} = useExamConfigStore();
|
||||||
@ -34,7 +34,6 @@ export const TargetedPractice = () => {
|
|||||||
const [difficulty, setDifficulty] = useState<
|
const [difficulty, setDifficulty] = useState<
|
||||||
"EASY" | "MEDIUM" | "HARD" | null
|
"EASY" | "MEDIUM" | "HARD" | null
|
||||||
>(null);
|
>(null);
|
||||||
const [duration, setDuration] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
@ -43,8 +42,6 @@ export const TargetedPractice = () => {
|
|||||||
|
|
||||||
const difficulties = ["EASY", "MEDIUM", "HARD"] as const;
|
const difficulties = ["EASY", "MEDIUM", "HARD"] as const;
|
||||||
|
|
||||||
const durations = [10, 20, 30, 45];
|
|
||||||
|
|
||||||
const toggleTopic = (topic: Topic) => {
|
const toggleTopic = (topic: Topic) => {
|
||||||
setSelectedTopics((prev) => {
|
setSelectedTopics((prev) => {
|
||||||
const exists = prev.some((t) => t.id === topic.id);
|
const exists = prev.some((t) => t.id === topic.id);
|
||||||
@ -58,7 +55,9 @@ export const TargetedPractice = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function handleStartTargetedPractice() {
|
async function handleStartTargetedPractice() {
|
||||||
if (!user || !token || !topics || !difficulty || !duration) return;
|
if (!user || !token || !topics || !difficulty) return;
|
||||||
|
|
||||||
|
storeDuration(10);
|
||||||
|
|
||||||
navigate(`/student/practice/${topics[0].id}/test`, { replace: true });
|
navigate(`/student/practice/${topics[0].id}/test`, { replace: true });
|
||||||
}
|
}
|
||||||
@ -193,36 +192,6 @@ export const TargetedPractice = () => {
|
|||||||
setDifficulty(d); // local UI
|
setDifficulty(d); // local UI
|
||||||
storeDifficulty(d); // ✅ STORE
|
storeDifficulty(d); // ✅ STORE
|
||||||
setDirection(1);
|
setDirection(1);
|
||||||
setStep("duration");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === "duration" && (
|
|
||||||
<motion.div
|
|
||||||
key="duration"
|
|
||||||
custom={direction}
|
|
||||||
variants={slideVariants}
|
|
||||||
initial="initial"
|
|
||||||
animate="animate"
|
|
||||||
exit="exit"
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
<h2 className="text-xl font-satoshi-bold">Select duration</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
|
||||||
{durations.map((d) => (
|
|
||||||
<ChoiceCard
|
|
||||||
key={d}
|
|
||||||
label={`${d} minutes`}
|
|
||||||
selected={duration === d}
|
|
||||||
onClick={() => {
|
|
||||||
setDuration(d);
|
|
||||||
storeDuration(d); // ✅ STORE
|
|
||||||
setDirection(1);
|
|
||||||
setStep("review");
|
setStep("review");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -252,9 +221,6 @@ export const TargetedPractice = () => {
|
|||||||
<p>
|
<p>
|
||||||
<strong>Difficulty:</strong> {difficulty}
|
<strong>Difficulty:</strong> {difficulty}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
|
||||||
<strong>Duration:</strong> {duration} minutes
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
@ -263,7 +229,7 @@ export const TargetedPractice = () => {
|
|||||||
<button
|
<button
|
||||||
disabled={step === "topic"}
|
disabled={step === "topic"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const order: Step[] = ["topic", "difficulty", "duration", "review"];
|
const order: Step[] = ["topic", "difficulty", "review"];
|
||||||
setDirection(-1);
|
setDirection(-1);
|
||||||
setStep(order[order.indexOf(step) - 1]);
|
setStep(order[order.indexOf(step) - 1]);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -50,10 +50,7 @@ export const useExamConfigStore = create<ExamConfigState>()(
|
|||||||
}),
|
}),
|
||||||
setUserXp: (userXp) =>
|
setUserXp: (userXp) =>
|
||||||
set({
|
set({
|
||||||
payload: {
|
userXp: userXp,
|
||||||
...(get().payload ?? {}),
|
|
||||||
userXp,
|
|
||||||
} as StartExamPayload,
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setDifficulty: (difficulty) =>
|
setDifficulty: (difficulty) =>
|
||||||
|
|||||||
16
src/stores/useResults.ts
Normal file
16
src/stores/useResults.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { Results } from "../types/test";
|
||||||
|
|
||||||
|
interface ResultsState {
|
||||||
|
results: Results | null;
|
||||||
|
setResults: (results: Results) => void;
|
||||||
|
clearResults: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useResults = create<ResultsState>((set) => ({
|
||||||
|
results: null,
|
||||||
|
|
||||||
|
setResults: (results: Results) => set({ results }),
|
||||||
|
|
||||||
|
clearResults: () => set({ results: null }),
|
||||||
|
}));
|
||||||
@ -2,33 +2,14 @@ import { create } from "zustand";
|
|||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
import { type ExamPhase } from "../types/sheet";
|
import { type ExamPhase } from "../types/sheet";
|
||||||
import type { SessionModuleQuestions } from "../types/session";
|
import type { SessionModuleQuestions } from "../types/session";
|
||||||
|
import type { Results } from "../types/test";
|
||||||
// interface SatExamState {
|
|
||||||
// modules: Module[];
|
|
||||||
// phase: ExamPhase;
|
|
||||||
// moduleIndex: number;
|
|
||||||
// questionIndex: number;
|
|
||||||
// endTime: number | null;
|
|
||||||
|
|
||||||
// startExam: () => void;
|
|
||||||
// setModuleQuestionss: (modules: Module[]) => void;
|
|
||||||
|
|
||||||
// nextQuestion: () => void;
|
|
||||||
// prevQuestion: () => void;
|
|
||||||
// nextModule: () => void;
|
|
||||||
// nextPhase: () => void;
|
|
||||||
// skipBreak: () => void;
|
|
||||||
// getRemainingTime: () => number;
|
|
||||||
// finishExam: () => void;
|
|
||||||
// resetExam: () => void;
|
|
||||||
// replaceModules: (modules: Module[]) => void;
|
|
||||||
// }
|
|
||||||
|
|
||||||
interface SatExamState {
|
interface SatExamState {
|
||||||
currentModuleQuestions: SessionModuleQuestions | null;
|
currentModuleQuestions: SessionModuleQuestions | null;
|
||||||
phase: ExamPhase;
|
phase: ExamPhase;
|
||||||
questionIndex: number;
|
questionIndex: number;
|
||||||
endTime: number | null;
|
endTime: number | null;
|
||||||
|
results: Results | null;
|
||||||
|
|
||||||
setModuleQuestions: (module: SessionModuleQuestions) => void;
|
setModuleQuestions: (module: SessionModuleQuestions) => void;
|
||||||
startExam: () => void;
|
startExam: () => void;
|
||||||
@ -36,10 +17,14 @@ interface SatExamState {
|
|||||||
prevQuestion: () => void;
|
prevQuestion: () => void;
|
||||||
goToQuestion: (index: number) => void;
|
goToQuestion: (index: number) => void;
|
||||||
|
|
||||||
|
setPhase: (phase: ExamPhase) => void;
|
||||||
|
|
||||||
startBreak: () => void;
|
startBreak: () => void;
|
||||||
skipBreak: () => void;
|
skipBreak: () => void;
|
||||||
finishExam: () => void;
|
finishExam: () => void;
|
||||||
|
quitExam: () => void;
|
||||||
resetExam: () => void;
|
resetExam: () => void;
|
||||||
|
setResults: (results: Results) => void;
|
||||||
getRemainingTime: () => number;
|
getRemainingTime: () => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,6 +37,7 @@ export const useSatExam = create<SatExamState>()(
|
|||||||
phase: "IDLE",
|
phase: "IDLE",
|
||||||
questionIndex: 0,
|
questionIndex: 0,
|
||||||
endTime: null,
|
endTime: null,
|
||||||
|
results: null,
|
||||||
|
|
||||||
setModuleQuestions: (module: SessionModuleQuestions) => {
|
setModuleQuestions: (module: SessionModuleQuestions) => {
|
||||||
const endTime = Date.now() + module.time_limit_minutes * 1000;
|
const endTime = Date.now() + module.time_limit_minutes * 1000;
|
||||||
@ -117,6 +103,13 @@ export const useSatExam = create<SatExamState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
finishExam: () => set({ phase: "FINISHED", endTime: null }),
|
finishExam: () => set({ phase: "FINISHED", endTime: null }),
|
||||||
|
quitExam: () => set({ phase: "QUIT", endTime: null }),
|
||||||
|
|
||||||
|
setPhase: (phase) => {
|
||||||
|
set({ phase: phase });
|
||||||
|
},
|
||||||
|
|
||||||
|
setResults: (results: Results) => set({ results }),
|
||||||
|
|
||||||
getRemainingTime: () => {
|
getRemainingTime: () => {
|
||||||
const { endTime } = get();
|
const { endTime } = get();
|
||||||
@ -130,8 +123,19 @@ export const useSatExam = create<SatExamState>()(
|
|||||||
phase: "IDLE",
|
phase: "IDLE",
|
||||||
questionIndex: 0,
|
questionIndex: 0,
|
||||||
endTime: null,
|
endTime: null,
|
||||||
|
results: null, // reset results too
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
{ name: "sat-exam-storage" },
|
{
|
||||||
|
name: "sat-exam-storage",
|
||||||
|
partialize: (state) => ({
|
||||||
|
// Only persist things you want to survive reloads
|
||||||
|
currentModuleQuestions: state.currentModuleQuestions,
|
||||||
|
phase: state.phase,
|
||||||
|
questionIndex: state.questionIndex,
|
||||||
|
endTime: state.endTime,
|
||||||
|
// Notice: results is NOT included
|
||||||
|
}),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,7 +4,7 @@ interface CreatedBy {
|
|||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExamPhase = "IDLE" | "MODULE" | "BREAK" | "FINISHED";
|
export type ExamPhase = "IDLE" | "MODULE" | "BREAK" | "FINISHED" | "QUIT";
|
||||||
|
|
||||||
export type QuestionType = "MCQ" | "TEXT" | "SHORT_ANSWER";
|
export type QuestionType = "MCQ" | "TEXT" | "SHORT_ANSWER";
|
||||||
|
|
||||||
|
|||||||
@ -9,3 +9,15 @@ export interface StartExamPayload {
|
|||||||
time_limit_minutes: number;
|
time_limit_minutes: number;
|
||||||
mode: ExamMode;
|
mode: ExamMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Results {
|
||||||
|
score: number;
|
||||||
|
correct_count: number;
|
||||||
|
total_questions: number;
|
||||||
|
xp_gained: number;
|
||||||
|
leveled_up: boolean;
|
||||||
|
current_level: number;
|
||||||
|
total_xp: number;
|
||||||
|
next_level_threshold: number;
|
||||||
|
current_level_start: number;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user