generated from muhtadeetaron/nextjs-template
fix(api): fix api logic for exam screen
needs more work for the timercontext
This commit is contained in:
@ -1,300 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useEffect, useCallback, useMemo } from "react";
|
||||
import { 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 { useModal } from "@/context/ModalContext";
|
||||
import Modal from "@/components/ExamModal";
|
||||
import { Question } from "@/types/exam";
|
||||
import QuestionItem from "@/components/QuestionItem";
|
||||
import BackgroundWrapper from "@/components/BackgroundWrapper";
|
||||
|
||||
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();
|
||||
const searchParams = useSearchParams();
|
||||
const test_id = searchParams.get("test_id") || "";
|
||||
const type = searchParams.get("type") || "";
|
||||
|
||||
// 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");
|
||||
const { isOpen, close, open } = useModal();
|
||||
const { timeRemaining, setInitialTime, stopTimer } = useTimer();
|
||||
const { test, answers, startExam, setAnswer, submitExam, cancelExam } =
|
||||
useExam();
|
||||
|
||||
// Combined initialization effect
|
||||
// Start exam + timer
|
||||
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);
|
||||
if (type && test_id) {
|
||||
startExam(type, test_id).then(() => {
|
||||
if (test?.metadata.time_limit_minutes) {
|
||||
setInitialTime(test.metadata.time_limit_minutes * 60); // convert to seconds
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
}, [type, test_id, startExam, setInitialTime]);
|
||||
|
||||
const showExitDialog = useCallback(() => {
|
||||
if (window.confirm("Are you sure you want to quit the exam?")) {
|
||||
stopTimer();
|
||||
cancelExam();
|
||||
router.push("/unit");
|
||||
}
|
||||
}, [stopTimer, router]);
|
||||
}, [stopTimer, cancelExam, 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) {
|
||||
if (!test) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-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>
|
||||
<p className="text-lg font-medium text-gray-900">Submitting...</p>
|
||||
<p className="text-lg font-medium text-gray-900">Loading exam...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render the main exam interface
|
||||
// answered set for modal overview
|
||||
// const answeredSet = useMemo(
|
||||
// () =>
|
||||
// new Set(
|
||||
// answers
|
||||
// .map((a, idx) =>
|
||||
// a !== null && a !== undefined ? idx.toString() : null
|
||||
// )
|
||||
// .filter(Boolean) as string[]
|
||||
// ),
|
||||
// [answers]
|
||||
// );
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header examDuration={time} />
|
||||
<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 ?? 0) -
|
||||
(currentAttempt?.answers?.length ?? 0)}
|
||||
</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));
|
||||
{/* Header with live timer */}
|
||||
<Header />
|
||||
|
||||
return (
|
||||
<div
|
||||
key={q.id ?? idx}
|
||||
className={`h-16 w-16 rounded-full flex items-center justify-center
|
||||
text-2xl
|
||||
{/* Modal: Question overview */}
|
||||
{/* <Modal open={isOpen} onClose={close} title="Exam Overview">
|
||||
<div>
|
||||
<div className="flex gap-6 mb-4">
|
||||
<p className="font-medium">Questions: {test.questions.length}</p>
|
||||
<p className="font-medium">
|
||||
Answered:{" "}
|
||||
<span className="text-blue-900 font-bold">
|
||||
{answeredSet.size}
|
||||
</span>
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
Skipped:{" "}
|
||||
<span className="text-yellow-600 font-bold">
|
||||
{test.questions.length - answeredSet.size}
|
||||
</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">
|
||||
{test.questions.map((q, idx) => {
|
||||
const answered = answeredSet.has(String(idx));
|
||||
return (
|
||||
<div
|
||||
key={q.question_id}
|
||||
className={`h-12 w-12 rounded-full flex items-center justify-center cursor-pointer
|
||||
${
|
||||
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}
|
||||
mode="exam"
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
? "bg-blue-900 text-white"
|
||||
: "bg-gray-200 text-gray-900"
|
||||
}
|
||||
hover:opacity-80 transition`}
|
||||
onClick={() => {
|
||||
// optional: scroll to question
|
||||
const el = document.getElementById(`question-${idx}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth" });
|
||||
close();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{idx + 1}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</Modal> */}
|
||||
|
||||
{/* Questions */}
|
||||
<BackgroundWrapper>
|
||||
<div className="container mx-auto px-6 py-8 mb-20">
|
||||
{test.questions.map((q, idx) => (
|
||||
<div id={`question-${idx}`} key={q.question_id}>
|
||||
<QuestionItem
|
||||
question={q}
|
||||
index={idx}
|
||||
selectedAnswer={answers[idx]}
|
||||
onSelect={(answer) => setAnswer(idx, answer)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Bottom submit bar */}
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 flex">
|
||||
<button
|
||||
onClick={submitExam}
|
||||
className="flex-1 bg-blue-900 text-white p-6 font-bold text-lg hover:bg-blue-800 transition-colors"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
<button
|
||||
onClick={showExitDialog}
|
||||
className="flex-1 bg-gray-200 text-gray-900 p-6 font-bold text-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BackgroundWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user