generated from muhtadeetaron/nextjs-template
added exam routes
This commit is contained in:
@ -1,7 +1,310 @@
|
||||
import React from "react";
|
||||
"use client";
|
||||
|
||||
const page = () => {
|
||||
return <div>page</div>;
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useEffect, useState, useCallback, useReducer } from "react";
|
||||
import { useTimer } from "@/context/TimerContext";
|
||||
import { API_URL, getToken } from "@/lib/auth";
|
||||
|
||||
// Types
|
||||
interface Question {
|
||||
id: number;
|
||||
question: string;
|
||||
options: Record<string, string>;
|
||||
}
|
||||
|
||||
interface QuestionItemProps {
|
||||
question: Question;
|
||||
selectedAnswer: string | undefined;
|
||||
handleSelect: (questionId: number, option: string) => void;
|
||||
}
|
||||
|
||||
interface AnswerState {
|
||||
[questionId: number]: string;
|
||||
}
|
||||
|
||||
interface AnswerAction {
|
||||
type: "SELECT_ANSWER";
|
||||
questionId: number;
|
||||
option: string;
|
||||
}
|
||||
|
||||
// Components
|
||||
const QuestionItem = React.memo<QuestionItemProps>(
|
||||
({ question, selectedAnswer, handleSelect }) => (
|
||||
<div className="question-container">
|
||||
<h3 className="question-text">
|
||||
{question.id}. {question.question}
|
||||
</h3>
|
||||
<div className="options-container">
|
||||
{Object.entries(question.options).map(([key, value]) => (
|
||||
<button
|
||||
key={key}
|
||||
className={`option-button ${
|
||||
selectedAnswer === key ? "selected-option" : ""
|
||||
}`}
|
||||
onClick={() => handleSelect(question.id, key)}
|
||||
>
|
||||
<span
|
||||
className={`option-text ${
|
||||
selectedAnswer === key ? "selected-option-text" : ""
|
||||
}`}
|
||||
>
|
||||
{key.toUpperCase()}
|
||||
</span>
|
||||
<span className="option-description">{value}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
QuestionItem.displayName = "QuestionItem";
|
||||
|
||||
const reducer = (state: AnswerState, action: AnswerAction): AnswerState => {
|
||||
switch (action.type) {
|
||||
case "SELECT_ANSWER":
|
||||
return { ...state, [action.questionId]: action.option };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default page;
|
||||
export default function ExamPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const id = params.id as string;
|
||||
const time = searchParams.get("time");
|
||||
|
||||
const { setInitialTime, stopTimer } = useTimer();
|
||||
|
||||
const [questions, setQuestions] = useState<Question[] | null>(null);
|
||||
const [answers, dispatch] = useReducer(reducer, {});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submissionLoading, setSubmissionLoading] = useState(false);
|
||||
|
||||
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) => {
|
||||
dispatch({ type: "SELECT_ANSWER", questionId, option });
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
stopTimer();
|
||||
setSubmissionLoading(true);
|
||||
|
||||
const payload = {
|
||||
mock_id: id,
|
||||
data: answers,
|
||||
};
|
||||
|
||||
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"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
router.push(
|
||||
`/exam/results?id=${id}&answers=${encodeURIComponent(
|
||||
JSON.stringify(responseData)
|
||||
)}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error submitting answers:", 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">
|
||||
<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">
|
||||
{questions?.map((question) => (
|
||||
<QuestionItem
|
||||
key={question.id}
|
||||
question={question}
|
||||
selectedAnswer={answers[question.id]}
|
||||
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>
|
||||
|
||||
<style jsx>{`
|
||||
.question-container {
|
||||
border: 1px solid #8abdff;
|
||||
border-radius: 25px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.question-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.options-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.option-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
border-radius: 5px;
|
||||
border: 1px solid transparent;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.option-button:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.selected-option {
|
||||
background-color: #f0f8ff;
|
||||
}
|
||||
|
||||
.option-text {
|
||||
font-size: 14px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 25px;
|
||||
text-align: center;
|
||||
border: 1px solid #ddd;
|
||||
min-width: 32px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.selected-option-text {
|
||||
color: white;
|
||||
background-color: #113768;
|
||||
border-color: #113768;
|
||||
}
|
||||
|
||||
.option-description {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.question-container {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.question-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.option-description {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user