fix(ui): fix results screen

This commit is contained in:
shafin-r
2025-07-22 17:59:15 +06:00
parent 5245ab878d
commit 341a46e788
8 changed files with 450 additions and 323 deletions

View File

@ -11,8 +11,8 @@ import DestructibleAlert from "@/components/DestructibleAlert";
import { ChevronRight } from "lucide-react"; // Using Lucide React for icons import { ChevronRight } from "lucide-react"; // Using Lucide React for icons
import styles from "@/css/Home.module.css"; import styles from "@/css/Home.module.css";
import facebookStyles from "@/css/SlidingGallery.module.css"; import facebookStyles from "@/css/SlidingGallery.module.css";
import { API_URL } from "@/lib/auth";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api"; import { Avatar, AvatarFallback } from "@radix-ui/react-avatar";
const page = () => { const page = () => {
const profileImg = "/images/static/avatar.jpg"; const profileImg = "/images/static/avatar.jpg";
@ -198,13 +198,7 @@ const page = () => {
<div key={idx} className={styles.topThreeItem}> <div key={idx} className={styles.topThreeItem}>
<div className={styles.studentInfo}> <div className={styles.studentInfo}>
<span className={styles.rank}>{student.rank}</span> <span className={styles.rank}>{student.rank}</span>
<Image <Avatar className="bg-slate-300 w-4 h-4 rounded-full"></Avatar>
src="/images/static/avatar.jpg"
alt="Avatar"
width={20}
height={20}
className={styles.avatar}
/>
<span className={styles.studentName}> <span className={styles.studentName}>
{student.name} {student.name}
</span> </span>

View File

@ -2,7 +2,9 @@
import BackgroundWrapper from "@/components/BackgroundWrapper"; import BackgroundWrapper from "@/components/BackgroundWrapper";
import Header from "@/components/Header"; import Header from "@/components/Header";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { API_URL, getToken } from "@/lib/auth"; import { API_URL, getToken } from "@/lib/auth";
import Image from "next/image";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
const page = () => { const page = () => {
@ -94,6 +96,82 @@ const page = () => {
<BackgroundWrapper> <BackgroundWrapper>
<section> <section>
<Header displaySubject={"Leaderboard"} displayTabTitle={null} /> <Header displaySubject={"Leaderboard"} displayTabTitle={null} />
<section className="flex flex-col mx-10 pt-10 space-y-4">
<section className="flex justify-evenly items-end">
{getTopThree(boardData).map((student, idx) =>
student ? (
<div
key={idx}
className="w-[100px] flex flex-col bg-[#113768] rounded-t-xl items-center justify-start pt-4 space-y-3"
style={{ height: student.height }}
>
<h3 className="font-bold text-xl text-white">
{student.rank}
</h3>
<Avatar className="bg-slate-300 w-12 h-12">
<AvatarFallback className="text-xl font-semibold">
{student.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<p className="font-bold text-md text-center text-white">
{student.name}
</p>
<p className="text-sm text-white">({student.points}pt)</p>
</div>
) : null
)}
</section>
<div className="w-full border-[0.5px] border-[#c5dbf8] bg-[#c5dbf8]"></div>
<section className="border-[1px] border-[#c0dafc] w-full rounded-3xl p-6 space-y-4 mb-20">
<section>
{getUserData(boardData, userData?.name).map((user, idx) => (
<div
key={idx}
className="flex bg-[#113768] rounded-[8] py-2 px-4 justify-between items-center"
>
<div className=" flex gap-3 items-center">
<h2 className="font-medium text-lg text-white">
{user.rank}
</h2>
<Avatar className="bg-slate-300 w-6 h-6">
<AvatarFallback className="text-md font-semibold">
{user.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<h3 className="font-medium text-sm text-white">You</h3>
</div>
<p className="font-medium text-white/70">{user.points}pt</p>
</div>
))}
</section>
<div className="w-full border-[0.5px] border-[#c5dbf8] bg-[#c5dbf8]"></div>
<section className="space-y-4">
{getLeaderboard(boardData)
.slice(0, 10)
.map((user, idx) => (
<div
key={idx}
className="flex border-2 border-[#c5dbf8] rounded-[8] py-2 px-4 justify-between items-center"
>
<div className="flex gap-3 items-center">
<h2 className="font-medium text-lg">{idx + 1}</h2>
<Avatar className="bg-slate-300 w-6 h-6">
<AvatarFallback className="text-md font-semibold">
{user.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<h3 className="font-medium text-sm">
{user.name.split(" ").slice(0, 2).join(" ")}
</h3>
</div>
<p className="font-medium text-[#000]/40">
{user.points}pt
</p>
</div>
))}
</section>
</section>
</section>
</section> </section>
</BackgroundWrapper> </BackgroundWrapper>
); );

View File

@ -9,65 +9,67 @@ import Header from "@/components/Header";
import { Bookmark, BookmarkCheck } from "lucide-react"; import { Bookmark, BookmarkCheck } from "lucide-react";
import { useModal } from "@/context/ModalContext"; import { useModal } from "@/context/ModalContext";
import Modal from "@/components/ExamModal"; import Modal from "@/components/ExamModal";
import { Question } from "@/types/exam";
import QuestionItem from "@/components/QuestionItem";
// Types // Types
interface Question { // interface Question {
id: number; // id: number;
question: string; // question: string;
options: Record<string, string>; // options: Record<string, string>;
} // }
interface QuestionItemProps { // interface QuestionItemProps {
question: Question; // question: Question;
selectedAnswer?: string; // selectedAnswer?: string;
handleSelect: (questionId: number, option: string) => void; // handleSelect: (questionId: number, option: string) => void;
} // }
const QuestionItem = React.memo<QuestionItemProps>( // const QuestionItem = React.memo<QuestionItemProps>(
({ question, selectedAnswer, handleSelect }) => { // ({ question, selectedAnswer, handleSelect }) => {
const [bookmark, setBookmark] = useState(false); // const [bookmark, setBookmark] = useState(false);
return ( // return (
<div className="border-[0.5px] border-[#8abdff]/60 rounded-2xl p-4 flex flex-col"> // <div className="border-[0.5px] border-[#8abdff]/60 rounded-2xl p-4 flex flex-col">
<h3 className="text-xl font-medium mb-[20px]"> // <h3 className="text-xl font-medium mb-[20px]">
{question.id}. {question.question} // {question.id}. {question.question}
</h3> // </h3>
<div className="flex justify-between items-center"> // <div className="flex justify-between items-center">
<div></div> // <div></div>
<button onClick={() => setBookmark(!bookmark)}> // <button onClick={() => setBookmark(!bookmark)}>
{bookmark ? ( // {bookmark ? (
<BookmarkCheck size={25} color="#113768" /> // <BookmarkCheck size={25} color="#113768" />
) : ( // ) : (
<Bookmark size={25} color="#113768" /> // <Bookmark size={25} color="#113768" />
)} // )}
</button> // </button>
</div> // </div>
<div className="flex flex-col gap-4 items-start"> // <div className="flex flex-col gap-4 items-start">
{Object.entries(question.options).map(([key, value]) => ( // {Object.entries(question.options).map(([key, value]) => (
<button // <button
key={key} // key={key}
className="flex items-center gap-3" // className="flex items-center gap-3"
onClick={() => handleSelect(question.id, key)} // onClick={() => handleSelect(question.id, key)}
> // >
<span // <span
className={`flex items-center rounded-full border px-1.5 ${ // className={`flex items-center rounded-full border px-1.5 ${
selectedAnswer === key // selectedAnswer === key
? "text-white bg-[#113768] border-[#113768]" // ? "text-white bg-[#113768] border-[#113768]"
: "" // : ""
}`} // }`}
> // >
{key.toUpperCase()} // {key.toUpperCase()}
</span> // </span>
<span className="option-description">{value}</span> // <span className="option-description">{value}</span>
</button> // </button>
))} // ))}
</div> // </div>
</div> // </div>
); // );
} // }
); // );
QuestionItem.displayName = "QuestionItem"; // QuestionItem.displayName = "QuestionItem";
export default function ExamPage() { export default function ExamPage() {
// All hooks at the top - no conditional calls // All hooks at the top - no conditional calls
@ -267,7 +269,7 @@ export default function ExamPage() {
if (submissionLoading) { if (submissionLoading) {
return ( return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center"> <div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center"> <div className="flex flex-col items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-900 mb-4"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-900 mb-4"></div>
<p className="text-lg font-medium text-gray-900">Submitting...</p> <p className="text-lg font-medium text-gray-900">Submitting...</p>
</div> </div>
@ -343,6 +345,7 @@ export default function ExamPage() {
question={q} question={q}
selectedAnswer={getAnswer(q.id.toString())} selectedAnswer={getAnswer(q.id.toString())}
handleSelect={handleSelect} handleSelect={handleSelect}
mode="exam"
/> />
))} ))}
</div> </div>

View File

@ -2,309 +2,115 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useExam, useExamResults } from "@/context/ExamContext"; import { useExam, useExamResults } from "@/context/ExamContext";
import { useEffect, useState, useRef } from "react"; import { useEffect, useState } from "react";
import React from "react"; import React from "react";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import Image from "next/image";
import SlidingGallery from "@/components/SlidingGallery"; import SlidingGallery from "@/components/SlidingGallery";
import QuestionItem from "@/components/QuestionItem";
interface Question { import { getResultViews } from "@/lib/resultViews";
correctAnswer: string;
id: number;
question: string;
options: Record<string, string>;
solution?: string;
}
interface QuestionItemProps {
question: Question;
selectedAnswer: string | undefined;
}
const QuestionItem = ({ question, selectedAnswer }: QuestionItemProps) => (
<div className="border border-[#8abdff]/50 rounded-2xl p-4 flex flex-col gap-7">
<h3 className="text-xl font-medium">
{question.id}. {question.question}
</h3>
<div className="flex justify-between items-center">
<div></div>
{!selectedAnswer ? (
<Badge className="bg-yellow-500" variant="destructive">
Skipped
</Badge>
) : selectedAnswer.answer === question.correctAnswer ? (
<Badge className="bg-green-500 text-white" variant="default">
Correct
</Badge>
) : (
<Badge className="bg-red-500 text-white" variant="default">
Incorrect
</Badge>
)}
</div>
<div className="flex flex-col gap-4 items-start">
{Object.entries(question.options).map(([key, value]) => {
const isCorrect = key === question.correctAnswer;
const isSelected = key === selectedAnswer?.answer;
let optionStyle =
"px-2 py-1 flex items-center rounded-full border font-medium text-sm";
if (isCorrect) {
optionStyle += " bg-green-600 text-white border-green-600";
}
if (isSelected && !isCorrect) {
optionStyle += " bg-red-600 text-white border-red-600";
}
if (!isCorrect && !isSelected) {
optionStyle += " border-gray-300 text-gray-700";
}
return (
<div key={key} className="flex items-center gap-3">
<span className={optionStyle}>{key.toUpperCase()}</span>
<span className="option-description">{value}</span>
</div>
);
})}
</div>
<div className="h-[0.5px] border-[0.5px] border-dashed border-black/20"></div>
<div className="flex flex-col gap-2">
<h3 className="text-lg font-bold text-black/40">Solution:</h3>
<p className="text-lg">{question.solution}</p>
</div>
</div>
);
export default function ResultsPage() { export default function ResultsPage() {
// All hooks at the top - no conditional calls
const router = useRouter(); const router = useRouter();
const { clearExam, isExamCompleted, getApiResponse } = useExam(); const {
clearExam,
isExamCompleted,
getApiResponse,
currentAttempt,
isHydrated,
} = useExam();
// Add a ref to track if we're in cleanup mode const [isLoading, setIsLoading] = useState(true);
const isCleaningUp = useRef(false);
// Conditionally call useExamResults based on cleanup state
const examResults = !isCleaningUp.current ? useExamResults() : null;
// State to control component behavior
const [componentState, setComponentState] = useState<
"loading" | "redirecting" | "ready"
>("loading");
// Single useEffect to handle all initialization logic
useEffect(() => { useEffect(() => {
let mounted = true; // Wait for hydration first
if (!isHydrated) return;
const initializeComponent = async () => { // Check if exam is completed, redirect if not
// Allow time for all hooks to initialize if (!isExamCompleted() || !currentAttempt) {
await new Promise((resolve) => setTimeout(resolve, 50));
if (!mounted) return;
// Check if exam is completed
if (!isExamCompleted()) {
setComponentState("redirecting");
// Small delay before redirect to prevent hook order issues
setTimeout(() => {
if (mounted) {
router.push("/unit"); router.push("/unit");
}
}, 100);
return; return;
} }
// Check if we have exam results // If we have exam results, we're ready to render
if (!examResults || !examResults.answers) { if (currentAttempt?.answers) {
// Keep loading state setIsLoading(false);
return;
} }
}, [isExamCompleted, currentAttempt, isHydrated, router]);
// Everything is ready const handleBackToHome = () => {
setComponentState("ready"); clearExam();
router.push("/unit");
}; };
initializeComponent(); // Show loading screen while initializing or if no exam results
if (isLoading || !currentAttempt) {
return () => {
mounted = false;
};
}, [isExamCompleted, router, examResults]);
// Always render loading screen for non-ready states
if (componentState !== "ready") {
const loadingText =
componentState === "redirecting" ? "Redirecting..." : "Loading...";
return ( return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center"> <div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<div className="mt-60 flex flex-col items-center"> <div className="mt-60 flex flex-col items-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
<p className="text-xl font-medium text-center">{loadingText}</p> <p className="text-xl font-medium text-center">Loading...</p>
</div> </div>
</div> </div>
</div> </div>
); );
} }
// At this point, we know examResults exists and component is ready
const apiResponse = getApiResponse(); const apiResponse = getApiResponse();
const handleBackToHome = () => {
// Set cleanup flag to prevent useExamResults from running
isCleaningUp.current = true;
clearExam();
setTimeout(() => {
router.push("/unit");
}, 400);
};
const timeTaken = const timeTaken =
examResults?.endTime && examResults?.startTime currentAttempt.endTime && currentAttempt.startTime
? Math.round( ? Math.round(
(examResults.endTime.getTime() - examResults.startTime.getTime()) / (currentAttempt.endTime.getTime() -
currentAttempt.startTime.getTime()) /
1000 / 1000 /
60 60
) )
: 0; : 0;
const resultViews = [ const views = getResultViews(currentAttempt);
{
id: 1, // Get score-based message
content: ( const getScoreMessage = () => {
<div className="w-full"> if (!currentAttempt.score || currentAttempt.score < 30)
<div className="bg-blue-50/60 border border-[#113678]/50 rounded-4xl h-[170px] flex flex-col items-center justify-center gap-4"> return "Try harder!";
<div className="text-xl text-black "> if (currentAttempt.score < 70) return "Getting Better";
<span className="font-bold">Accuracy</span> Rate: return "You did great!";
</div> };
<div className="flex gap-4">
<Image
src="/images/icons/accuracy.png"
alt="accuracy"
width={60}
height={60}
/>
<h2 className="text-6xl font-bold text-[#113678]">
{examResults
? (
(examResults.score / examResults.totalQuestions) *
100
).toFixed(1)
: "0"}
%
</h2>
</div>
<div></div>
</div>
</div>
),
},
{
id: 2,
content: (
<div className=" w-full">
<div className="bg-blue-50/60 border border-[#113678]/50 rounded-4xl h-[170px] flex flex-col items-center justify-center gap-3">
<div className="text-xl text-black ">
<span className="font-bold">Error</span> Rate:
</div>
<div className="flex gap-4">
<Image
src="/images/icons/error.png"
alt="accuracy"
width={60}
height={60}
/>
<h2 className="text-6xl font-bold text-[#113678]">
{examResults
? (
((examResults.totalQuestions - examResults.score) /
examResults.totalQuestions) *
100
).toFixed(1)
: "0"}
%
</h2>
</div>
<div></div>
</div>
</div>
),
},
{
id: 3,
content: (
<div className="my-8 w-full">
<div className="bg-blue-50/60 border border-[#113678]/50 rounded-4xl h-[170px] flex flex-col items-center justify-center gap-4">
<div className="text-xl text-black">
<span className="font-bold">Attempt</span> Rate:
</div>
<div className="flex gap-4">
<Image
src="/images/icons/attempt.png"
alt="accuracy"
width={60}
height={60}
/>
<h2 className="text-6xl font-bold text-[#113678]">
{examResults
? (
(examResults.answers.length /
examResults.totalQuestions) *
100
).toFixed(1)
: "0"}
%
</h2>
</div>
<div></div>
</div>
</div>
),
},
];
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
<button className="p-10" onClick={() => handleBackToHome()}> <button className="p-10" onClick={handleBackToHome}>
<ArrowLeft size={30} color="black" /> <ArrowLeft size={30} color="black" />
</button> </button>
<div className="bg-white rounded-lg shadow-lg px-10 pb-20"> <div className="bg-white rounded-lg shadow-lg px-10 pb-20">
<h1 className="text-2xl font-bold text-[#113768] mb-4 text-center"> <h1 className="text-2xl font-bold text-[#113768] mb-4 text-center">
{!examResults?.score || examResults?.score < 30 {getScoreMessage()}
? "Try harder!"
: examResults?.score < 70
? "Getting Better"
: "You did great!"}
</h1> </h1>
{/* Score Display */} {/* Score Display */}
<SlidingGallery className="my-8" views={resultViews} height="170px" /> <SlidingGallery className="my-8" views={views} height="170px" />
{apiResponse && ( {apiResponse?.questions && (
<div className="mb-8"> <div className="mb-8">
<h3 className="text-2xl font-bold text-[#113768] mb-4"> <h3 className="text-2xl font-bold text-[#113768] mb-4">
Solutions Solutions
</h3> </h3>
<div className="flex flex-col gap-7"> <div className="flex flex-col gap-7">
{apiResponse.questions?.map((question) => ( {apiResponse.questions.map((question) => (
<QuestionItem <QuestionItem
key={question.id} key={question.id}
question={question} question={question}
selectedAnswer={examResults?.answers?.[question.id]} selectedAnswer={currentAttempt.answers[question.id]}
mode="result"
/> />
))} ))}
</div> </div>
</div> </div>
)} )}
</div> </div>
<button <button
onClick={handleBackToHome} onClick={handleBackToHome}
className="fixed bottom-0 w-full bg-blue-900 text-white h-[74px] font-bold text-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="fixed bottom-0 w-full bg-blue-900 text-white h-[74px] font-bold text-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors"

130
components/QuestionItem.tsx Normal file
View File

@ -0,0 +1,130 @@
import { Question } from "@/types/exam";
import { BookmarkCheck, Bookmark } from "lucide-react";
import React, { useState } from "react";
import { Badge } from "./ui/badge";
interface ResultItemProps {
mode: "result";
question: Question;
selectedAnswer: string | undefined;
}
interface ExamItemProps {
mode: "exam";
question: Question;
selectedAnswer?: string;
handleSelect: (questionId: number, option: string) => void;
}
type QuestionItemProps = ResultItemProps | ExamItemProps;
const QuestionItem = (props: QuestionItemProps) => {
const [bookmark, setBookmark] = useState(false);
const { question, selectedAnswer } = props;
const isExam = props.mode === "exam";
return (
<div className="border-[0.5px] border-[#8abdff]/60 rounded-2xl p-4 flex flex-col">
<h3 className="text-xl font-medium mb-[20px]">
{question.id}. {question.question}
</h3>
<div className="flex justify-between items-center mb-4">
<div></div>
<button onClick={() => setBookmark(!bookmark)}>
{bookmark ? (
<BookmarkCheck size={25} color="#113768" />
) : (
<Bookmark size={25} color="#113768" />
)}
</button>
</div>
{isExam ? (
<div className="flex flex-col gap-4 items-start">
{Object.entries(question.options).map(([key, value]) => {
const isSelected = selectedAnswer === key;
return (
<button
key={key}
className="flex items-center gap-3"
onClick={
isExam
? () => props.handleSelect(question.id, key)
: undefined
}
disabled={!isExam}
>
<span
className={`flex items-center rounded-full border px-1.5 ${
isSelected ? "text-white bg-[#113768] border-[#113768]" : ""
}`}
>
{key.toUpperCase()}
</span>
<span className="option-description">{value}</span>
</button>
);
})}
</div>
) : (
<>
<div className="flex justify-between items-center">
<div></div>
{!selectedAnswer ? (
<Badge className="bg-yellow-500" variant="destructive">
Skipped
</Badge>
) : selectedAnswer.answer === question.correctAnswer ? (
<Badge className="bg-green-500 text-white" variant="default">
Correct
</Badge>
) : (
<Badge className="bg-red-500 text-white" variant="default">
Incorrect
</Badge>
)}
</div>
<div className="flex flex-col gap-4 items-start">
{Object.entries(question.options).map(([key, value]) => {
const isCorrect = key === question.correctAnswer;
const isSelected = key === selectedAnswer?.answer;
let optionStyle =
"px-2 py-1 flex items-center rounded-full border font-medium text-sm";
if (isCorrect) {
optionStyle += " bg-green-600 text-white border-green-600";
}
if (isSelected && !isCorrect) {
optionStyle += " bg-red-600 text-white border-red-600";
}
if (!isCorrect && !isSelected) {
optionStyle += " border-gray-300 text-gray-700";
}
return (
<div key={key} className="flex items-center gap-3">
<span className={optionStyle}>{key.toUpperCase()}</span>
<span className="option-description">{value}</span>
</div>
);
})}
</div>
<div className="h-[0.5px] border-[0.5px] border-dashed border-black/20"></div>
<div className="flex flex-col gap-2">
<h3 className="text-lg font-bold text-black/40">Solution:</h3>
<p className="text-lg">{question.solution}</p>
</div>
</>
)}
</div>
);
};
export default QuestionItem;

View File

@ -7,6 +7,7 @@ import React, {
useEffect, useEffect,
ReactNode, ReactNode,
} from "react"; } from "react";
import { useRouter } from "next/navigation";
import { Exam, ExamAnswer, ExamAttempt, ExamContextType } from "@/types/exam"; import { Exam, ExamAnswer, ExamAttempt, ExamContextType } from "@/types/exam";
import { getFromStorage, removeFromStorage, setToStorage } from "@/lib/utils"; import { getFromStorage, removeFromStorage, setToStorage } from "@/lib/utils";
@ -21,6 +22,7 @@ const STORAGE_KEYS = {
export const ExamProvider: React.FC<{ children: ReactNode }> = ({ export const ExamProvider: React.FC<{ children: ReactNode }> = ({
children, children,
}) => { }) => {
const router = useRouter();
const [currentExam, setCurrentExamState] = useState<Exam | null>(null); const [currentExam, setCurrentExamState] = useState<Exam | null>(null);
const [currentAttempt, setCurrentAttemptState] = useState<ExamAttempt | null>( const [currentAttempt, setCurrentAttemptState] = useState<ExamAttempt | null>(
null null
@ -81,13 +83,14 @@ export const ExamProvider: React.FC<{ children: ReactNode }> = ({
const setCurrentExam = (exam: Exam) => { const setCurrentExam = (exam: Exam) => {
setCurrentExamState(exam); setCurrentExamState(exam);
setCurrentAttemptState(null); setCurrentAttemptState(null);
}; };
const startExam = () => { const startExam = () => {
if (!currentExam) { if (!currentExam) {
throw new Error("No exam selected"); console.warn("No exam selected, redirecting to /unit");
router.push("/unit");
return;
} }
const attempt: ExamAttempt = { const attempt: ExamAttempt = {
@ -103,7 +106,9 @@ export const ExamProvider: React.FC<{ children: ReactNode }> = ({
const setAnswer = (questionId: string, answer: any) => { const setAnswer = (questionId: string, answer: any) => {
if (!currentAttempt) { if (!currentAttempt) {
throw new Error("No exam attempt started"); console.warn("No exam attempt started, redirecting to /unit");
router.push("/unit");
return;
} }
setCurrentAttemptState((prev) => { setCurrentAttemptState((prev) => {
@ -138,7 +143,9 @@ export const ExamProvider: React.FC<{ children: ReactNode }> = ({
const setApiResponse = (response: any) => { const setApiResponse = (response: any) => {
if (!currentAttempt) { if (!currentAttempt) {
throw new Error("No exam attempt started"); console.warn("No exam attempt started, redirecting to /unit");
router.push("/unit");
return;
} }
setCurrentAttemptState((prev) => { setCurrentAttemptState((prev) => {
@ -150,9 +157,11 @@ export const ExamProvider: React.FC<{ children: ReactNode }> = ({
}); });
}; };
const submitExam = (): ExamAttempt => { const submitExam = (): ExamAttempt | null => {
if (!currentAttempt) { if (!currentAttempt) {
throw new Error("No exam attempt to submit"); console.warn("No exam attempt to submit, redirecting to /unit");
router.push("/unit");
return null;
} }
// Calculate score (simple example - you can customize this) // Calculate score (simple example - you can customize this)
@ -209,7 +218,7 @@ export const ExamProvider: React.FC<{ children: ReactNode }> = ({
const isExamStarted = () => !!currentExam && !!currentAttempt; const isExamStarted = () => !!currentExam && !!currentAttempt;
const isExamCompleted = (): boolean => { const isExamCompleted = (): boolean => {
if (!isHydrated) return false; // wait for hydration if (!isHydrated) return false; // wait for hydration
return currentAttempt !== null && currentAttempt.endTime !== undefined; return currentAttempt !== null && currentAttempt.endTime !== undefined;
}; };
@ -246,12 +255,18 @@ export const useExam = (): ExamContextType => {
return context; return context;
}; };
// Hook for exam results (only when exam is completed) // Hook for exam results (only when exam is completed) - now returns null instead of throwing
export const useExamResults = (): ExamAttempt => { export const useExamResults = (): ExamAttempt | null => {
const { currentAttempt, isExamCompleted } = useExam(); const { currentAttempt, isExamCompleted, isHydrated } = useExam();
// Wait for hydration before making decisions
if (!isHydrated) {
return null;
}
// If no completed exam is found, return null (let component handle redirect)
if (!isExamCompleted() || !currentAttempt) { if (!isExamCompleted() || !currentAttempt) {
throw new Error("No completed exam attempt found"); return null;
} }
return currentAttempt; return currentAttempt;

99
lib/resultViews.tsx Normal file
View File

@ -0,0 +1,99 @@
// lib/resultViews.tsx
import Image from "next/image";
interface ExamResults {
score: number;
totalQuestions: number;
answers: string[];
}
export const getResultViews = (examResults: ExamResults | null) => [
{
id: 1,
content: (
<div className="w-full">
<div className="bg-blue-50/60 border border-[#113678]/50 rounded-4xl h-[170px] flex flex-col items-center justify-center gap-4">
<div className="text-xl text-black">
<span className="font-bold">Accuracy</span> Rate:
</div>
<div className="flex gap-4">
<Image
src="/images/icons/accuracy.png"
alt="accuracy"
width={60}
height={60}
/>
<h2 className="text-6xl font-bold text-[#113678]">
{examResults
? (
(examResults.score / examResults.totalQuestions) *
100
).toFixed(1)
: "0"}
%
</h2>
</div>
</div>
</div>
),
},
{
id: 2,
content: (
<div className="w-full">
<div className="bg-blue-50/60 border border-[#113678]/50 rounded-4xl h-[170px] flex flex-col items-center justify-center gap-3">
<div className="text-xl text-black">
<span className="font-bold">Error</span> Rate:
</div>
<div className="flex gap-4">
<Image
src="/images/icons/error.png"
alt="error"
width={60}
height={60}
/>
<h2 className="text-6xl font-bold text-[#113678]">
{examResults
? (
((examResults.totalQuestions - examResults.score) /
examResults.totalQuestions) *
100
).toFixed(1)
: "0"}
%
</h2>
</div>
</div>
</div>
),
},
{
id: 3,
content: (
<div className="my-8 w-full">
<div className="bg-blue-50/60 border border-[#113678]/50 rounded-4xl h-[170px] flex flex-col items-center justify-center gap-4">
<div className="text-xl text-black">
<span className="font-bold">Attempt</span> Rate:
</div>
<div className="flex gap-4">
<Image
src="/images/icons/attempt.png"
alt="attempt"
width={60}
height={60}
/>
<h2 className="text-6xl font-bold text-[#113678]">
{examResults
? (
(examResults.answers.length / examResults.totalQuestions) *
100
).toFixed(1)
: "0"}
%
</h2>
</div>
</div>
</div>
),
},
];

6
types/exam.d.ts vendored
View File

@ -1,8 +1,10 @@
export interface Question { export interface Question {
id: string; id: string;
text: string; text: string;
options?: string[]; options?: Record<string, string>;
type: "multiple-choice" | "text" | "boolean"; type: "multiple-choice" | "text" | "boolean" | undefined;
correctAnswer: string | undefined;
solution?: string | undefined;
} }
export interface Exam { export interface Exam {