generated from muhtadeetaron/nextjs-template
364 lines
11 KiB
TypeScript
364 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useState, useCallback, useMemo } from "react";
|
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
|
import { useTimer } from "@/context/TimerContext";
|
|
import { useExam } from "@/context/ExamContext";
|
|
import { API_URL, getToken } from "@/lib/auth";
|
|
import Header from "@/components/Header";
|
|
import { Bookmark, BookmarkCheck } from "lucide-react";
|
|
import { useModal } from "@/context/ModalContext";
|
|
import Modal from "@/components/ExamModal";
|
|
|
|
// Types
|
|
interface Question {
|
|
id: number;
|
|
question: string;
|
|
options: Record<string, string>;
|
|
}
|
|
|
|
interface QuestionItemProps {
|
|
question: Question;
|
|
selectedAnswer?: string;
|
|
handleSelect: (questionId: number, option: string) => void;
|
|
}
|
|
|
|
const QuestionItem = React.memo<QuestionItemProps>(
|
|
({ question, selectedAnswer, handleSelect }) => {
|
|
const [bookmark, setBookmark] = useState(false);
|
|
|
|
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">
|
|
<div></div>
|
|
<button onClick={() => setBookmark(!bookmark)}>
|
|
{bookmark ? (
|
|
<BookmarkCheck size={25} color="#113768" />
|
|
) : (
|
|
<Bookmark size={25} color="#113768" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
<div className="flex flex-col gap-4 items-start">
|
|
{Object.entries(question.options).map(([key, value]) => (
|
|
<button
|
|
key={key}
|
|
className="flex items-center gap-3"
|
|
onClick={() => handleSelect(question.id, key)}
|
|
>
|
|
<span
|
|
className={`flex items-center rounded-full border px-1.5 ${
|
|
selectedAnswer === key
|
|
? "text-white bg-[#113768] border-[#113768]"
|
|
: ""
|
|
}`}
|
|
>
|
|
{key.toUpperCase()}
|
|
</span>
|
|
<span className="option-description">{value}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
QuestionItem.displayName = "QuestionItem";
|
|
|
|
export default function ExamPage() {
|
|
// All hooks at the top - no conditional calls
|
|
const router = useRouter();
|
|
const { id } = useParams();
|
|
const time = useSearchParams().get("time");
|
|
const { isOpen, close } = useModal();
|
|
const { setInitialTime, stopTimer } = useTimer();
|
|
const {
|
|
currentAttempt,
|
|
setAnswer,
|
|
getAnswer,
|
|
submitExam: submitExamContext,
|
|
setApiResponse,
|
|
isExamStarted,
|
|
isExamCompleted,
|
|
isHydrated,
|
|
isInitialized,
|
|
currentExam,
|
|
} = useExam();
|
|
|
|
// State management
|
|
const [questions, setQuestions] = useState<Question[] | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [submissionLoading, setSubmissionLoading] = useState(false);
|
|
const [componentState, setComponentState] = useState<
|
|
"loading" | "redirecting" | "ready"
|
|
>("loading");
|
|
|
|
// Combined initialization effect
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
|
|
const initializeComponent = async () => {
|
|
// Wait for hydration and initialization
|
|
if (!isHydrated || !isInitialized || isSubmitting) {
|
|
return;
|
|
}
|
|
|
|
// Check exam state and handle redirects
|
|
if (!isExamStarted()) {
|
|
if (mounted) {
|
|
setComponentState("redirecting");
|
|
setTimeout(() => {
|
|
if (mounted) router.push("/unit");
|
|
}, 100);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (isExamCompleted()) {
|
|
if (mounted) {
|
|
setComponentState("redirecting");
|
|
setTimeout(() => {
|
|
if (mounted) router.push("/exam/results");
|
|
}, 100);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Component is ready to render
|
|
if (mounted) {
|
|
setComponentState("ready");
|
|
}
|
|
};
|
|
|
|
initializeComponent();
|
|
|
|
return () => {
|
|
mounted = false;
|
|
};
|
|
}, [
|
|
isHydrated,
|
|
isInitialized,
|
|
isExamStarted,
|
|
isExamCompleted,
|
|
isSubmitting,
|
|
router,
|
|
]);
|
|
|
|
// Fetch questions effect
|
|
useEffect(() => {
|
|
if (componentState !== "ready") return;
|
|
|
|
const fetchQuestions = async () => {
|
|
try {
|
|
const response = await fetch(`${API_URL}/mock/${id}`);
|
|
const data = await response.json();
|
|
setQuestions(data.questions);
|
|
} catch (error) {
|
|
console.error("Error fetching questions:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchQuestions();
|
|
if (time) setInitialTime(Number(time));
|
|
}, [id, time, setInitialTime, componentState]);
|
|
|
|
const handleSelect = useCallback(
|
|
(questionId: number, option: string) => {
|
|
setAnswer(questionId.toString(), option);
|
|
},
|
|
[setAnswer]
|
|
);
|
|
|
|
const handleSubmit = async () => {
|
|
if (!currentAttempt) return console.error("No exam attempt found");
|
|
|
|
stopTimer();
|
|
setSubmissionLoading(true);
|
|
setIsSubmitting(true);
|
|
|
|
const answersForAPI = currentAttempt.answers.reduce(
|
|
(acc, { questionId, answer }) => {
|
|
acc[+questionId] = answer;
|
|
return acc;
|
|
},
|
|
{} as Record<number, string>
|
|
);
|
|
|
|
try {
|
|
const response = await fetch(`${API_URL}/submit`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${await getToken()}`,
|
|
},
|
|
body: JSON.stringify({ mock_id: id, data: answersForAPI }),
|
|
});
|
|
|
|
if (!response.ok)
|
|
throw new Error((await response.json()).message || "Submission failed");
|
|
|
|
const responseData = await response.json();
|
|
submitExamContext();
|
|
setApiResponse(responseData);
|
|
router.push("/exam/results");
|
|
} catch (error) {
|
|
console.error("Error submitting answers:", error);
|
|
setIsSubmitting(false);
|
|
} finally {
|
|
setSubmissionLoading(false);
|
|
}
|
|
};
|
|
|
|
const showExitDialog = useCallback(() => {
|
|
if (window.confirm("Are you sure you want to quit the exam?")) {
|
|
stopTimer();
|
|
router.push("/unit");
|
|
}
|
|
}, [stopTimer, router]);
|
|
|
|
useEffect(() => {
|
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
e.preventDefault();
|
|
e.returnValue = "";
|
|
};
|
|
|
|
const handlePopState = (e: PopStateEvent) => {
|
|
e.preventDefault();
|
|
showExitDialog();
|
|
};
|
|
|
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
window.addEventListener("popstate", handlePopState);
|
|
|
|
return () => {
|
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
window.removeEventListener("popstate", handlePopState);
|
|
};
|
|
}, [showExitDialog]);
|
|
|
|
const answeredSet = useMemo(() => {
|
|
if (!currentAttempt) return new Set<string>();
|
|
return new Set(currentAttempt.answers.map((a) => String(a.questionId)));
|
|
}, [currentAttempt]);
|
|
|
|
// Show loading/redirecting state
|
|
if (componentState === "loading" || componentState === "redirecting") {
|
|
const loadingText =
|
|
componentState === "redirecting" ? "Redirecting..." : "Loading...";
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<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">{loadingText}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Show submission loading
|
|
if (submissionLoading) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Render the main exam interface
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
<Header
|
|
examDuration={time}
|
|
displayTabTitle={null}
|
|
image={undefined}
|
|
displayUser={undefined}
|
|
displaySubject={undefined}
|
|
/>
|
|
<Modal open={isOpen} onClose={close} title={currentExam?.title}>
|
|
{currentAttempt ? (
|
|
<div>
|
|
<div className="flex gap-4">
|
|
<p className="">Questions: {currentExam?.questions.length}</p>
|
|
<p className="">
|
|
Answers:{" "}
|
|
<span className="text-[#113768] font-bold">
|
|
{currentAttempt?.answers.length}
|
|
</span>
|
|
</p>
|
|
<p className="">
|
|
Skipped:{" "}
|
|
<span className="text-yellow-600 font-bold">
|
|
{currentExam?.questions.length -
|
|
currentAttempt?.answers.length}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
<div className="h-[0.5px] border-[0.5px] border-black/10 w-full my-3"></div>
|
|
<section className="flex flex-wrap gap-4">
|
|
{currentExam?.questions.map((q, idx) => {
|
|
const answered = answeredSet.has(String(q.id));
|
|
|
|
return (
|
|
<div
|
|
key={q.id ?? idx}
|
|
className={`h-16 w-16 rounded-full flex items-center justify-center
|
|
text-2xl
|
|
${
|
|
answered
|
|
? "bg-[#0E2C53] text-white font-semibold"
|
|
: "bg-[#E9EDF1] text-black font-normal"
|
|
}`}
|
|
>
|
|
{idx + 1}
|
|
</div>
|
|
);
|
|
})}
|
|
</section>
|
|
</div>
|
|
) : (
|
|
<p>No attempt data.</p>
|
|
)}
|
|
</Modal>
|
|
<div className="container mx-auto px-6 py-8">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center min-h-64">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-900"></div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6 mb-20">
|
|
{questions?.map((q) => (
|
|
<QuestionItem
|
|
key={q.id}
|
|
question={q}
|
|
selectedAnswer={getAnswer(q.id.toString())}
|
|
handleSelect={handleSelect}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 p-4">
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={submissionLoading}
|
|
className="w-full bg-blue-900 text-white py-4 px-6 rounded-lg font-bold text-lg hover:bg-blue-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
Submit
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|