generated from muhtadeetaron/nextjs-template
281 lines
7.7 KiB
TypeScript
281 lines
7.7 KiB
TypeScript
"use client";
|
|
|
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
|
import React, { useEffect, useState, useCallback } from "react";
|
|
import { useTimer } from "@/context/TimerContext";
|
|
import { useExam } from "@/context/ExamContext";
|
|
import { API_URL, getToken } from "@/lib/auth";
|
|
import Header from "@/components/Header";
|
|
|
|
// Types
|
|
interface Question {
|
|
id: number;
|
|
question: string;
|
|
options: Record<string, string>;
|
|
}
|
|
|
|
interface QuestionItemProps {
|
|
question: Question;
|
|
selectedAnswer: string | undefined;
|
|
handleSelect: (questionId: number, option: string) => void;
|
|
}
|
|
|
|
// Components
|
|
const QuestionItem = React.memo<QuestionItemProps>(
|
|
({ question, selectedAnswer, handleSelect }) => (
|
|
<div className="border border-[#8abdff]/50 rounded-2xl p-4">
|
|
<h3 className="text-xl font-medium mb-[20px]">
|
|
{question.id}. {question.question}
|
|
</h3>
|
|
<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() {
|
|
const router = useRouter();
|
|
const params = useParams();
|
|
const searchParams = useSearchParams();
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
const id = params.id as string;
|
|
const time = searchParams.get("time");
|
|
|
|
const { setInitialTime, stopTimer } = useTimer();
|
|
|
|
// Use exam context instead of local state
|
|
const {
|
|
currentAttempt,
|
|
setAnswer,
|
|
getAnswer,
|
|
submitExam: submitExamContext,
|
|
setApiResponse,
|
|
isExamStarted,
|
|
isExamCompleted,
|
|
isHydrated,
|
|
isInitialized,
|
|
} = useExam();
|
|
|
|
const [questions, setQuestions] = useState<Question[] | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [submissionLoading, setSubmissionLoading] = useState(false);
|
|
|
|
// Check if exam is properly started
|
|
useEffect(() => {
|
|
if (!isHydrated) return;
|
|
if (!isInitialized) return;
|
|
if (isSubmitting) return; // Don't redirect while submitting
|
|
|
|
if (!isExamStarted()) {
|
|
router.push("/unit");
|
|
return;
|
|
}
|
|
|
|
if (isExamCompleted()) {
|
|
router.push("/exam/results");
|
|
return;
|
|
}
|
|
}, [
|
|
isHydrated,
|
|
isExamStarted,
|
|
isExamCompleted,
|
|
router,
|
|
isInitialized,
|
|
isSubmitting,
|
|
]);
|
|
|
|
const fetchQuestions = async () => {
|
|
try {
|
|
const response = await fetch(`${API_URL}/mock/${id}`, {
|
|
method: "GET",
|
|
});
|
|
const data = await response.json();
|
|
setQuestions(data.questions);
|
|
} catch (error) {
|
|
console.error("Error fetching questions:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchQuestions();
|
|
if (time) {
|
|
setInitialTime(Number(time));
|
|
}
|
|
}, [id, time, setInitialTime]);
|
|
|
|
const handleSelect = useCallback(
|
|
(questionId: number, option: string) => {
|
|
// Store answer in context instead of local reducer
|
|
setAnswer(questionId.toString(), option);
|
|
},
|
|
[setAnswer]
|
|
);
|
|
|
|
const handleSubmit = async () => {
|
|
if (!currentAttempt) {
|
|
console.error("No exam attempt found");
|
|
return;
|
|
}
|
|
|
|
stopTimer();
|
|
setSubmissionLoading(true);
|
|
setIsSubmitting(true); // Add this line
|
|
|
|
// Convert context answers to the format your API expects
|
|
const answersForAPI = currentAttempt.answers.reduce((acc, answer) => {
|
|
acc[parseInt(answer.questionId)] = answer.answer;
|
|
return acc;
|
|
}, {} as Record<number, string>);
|
|
|
|
const payload = {
|
|
mock_id: id,
|
|
data: answersForAPI,
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`${API_URL}/submit`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${await getToken()}`,
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
console.error(
|
|
"Submission failed:",
|
|
errorData.message || "Unknown error"
|
|
);
|
|
setIsSubmitting(false); // Reset on error
|
|
return;
|
|
}
|
|
|
|
const responseData = await response.json();
|
|
|
|
// Submit exam in context (this will store the completed attempt)
|
|
const completedAttempt = submitExamContext();
|
|
|
|
// Store API response in context for results page
|
|
setApiResponse(responseData);
|
|
|
|
// Navigate to results without URL parameters
|
|
router.push("/exam/results");
|
|
console.log("I'm here");
|
|
} catch (error) {
|
|
console.error("Error submitting answers:", error);
|
|
setIsSubmitting(false); // Reset on error
|
|
} finally {
|
|
setSubmissionLoading(false);
|
|
}
|
|
};
|
|
|
|
const showExitDialog = () => {
|
|
if (window.confirm("Are you sure you want to quit the exam?")) {
|
|
stopTimer();
|
|
router.push("/unit");
|
|
}
|
|
};
|
|
|
|
// Handle browser back button
|
|
useEffect(() => {
|
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
e.preventDefault();
|
|
e.returnValue = "";
|
|
return "";
|
|
};
|
|
|
|
const handlePopState = (e: PopStateEvent) => {
|
|
e.preventDefault();
|
|
showExitDialog();
|
|
};
|
|
|
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
window.addEventListener("popstate", handlePopState);
|
|
|
|
return () => {
|
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
window.removeEventListener("popstate", handlePopState);
|
|
};
|
|
}, []);
|
|
|
|
if (submissionLoading) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="flex flex-col items-center justify-center min-h-64">
|
|
<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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
<Header
|
|
examDuration={time}
|
|
displayTabTitle={null}
|
|
image={undefined}
|
|
displayUser={undefined}
|
|
displaySubject={undefined}
|
|
/>
|
|
<div className="container mx-auto px-4 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((question) => (
|
|
<QuestionItem
|
|
key={question.id}
|
|
question={question}
|
|
selectedAnswer={getAnswer(question.id.toString())}
|
|
handleSelect={handleSelect}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 p-4">
|
|
<div className="container mx-auto">
|
|
<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>
|
|
</div>
|
|
);
|
|
}
|