diff --git a/app/exam/[id]/page.tsx b/app/exam/[id]/page.tsx index 47bfbeb..38102ac 100644 --- a/app/exam/[id]/page.tsx +++ b/app/exam/[id]/page.tsx @@ -1,8 +1,9 @@ "use client"; import { useParams, useRouter, useSearchParams } from "next/navigation"; -import React, { useEffect, useState, useCallback, useReducer } from "react"; +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"; @@ -19,16 +20,6 @@ interface QuestionItemProps { 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( ({ question, selectedAnswer, handleSelect }) => ( @@ -62,30 +53,58 @@ const QuestionItem = React.memo( 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 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(null); - const [answers, dispatch] = useReducer(reducer, {}); 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}`, { @@ -107,17 +126,33 @@ export default function ExamPage() { } }, [id, time, setInitialTime]); - const handleSelect = useCallback((questionId: number, option: string) => { - dispatch({ type: "SELECT_ANSWER", questionId, option }); - }, []); + 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); const payload = { mock_id: id, - data: answers, + data: answersForAPI, }; try { @@ -136,18 +171,24 @@ export default function ExamPage() { "Submission failed:", errorData.message || "Unknown error" ); + setIsSubmitting(false); // Reset on error return; } const responseData = await response.json(); - router.push( - `/exam/results?id=${id}&answers=${encodeURIComponent( - JSON.stringify(responseData) - )}` - ); + // 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); } @@ -215,7 +256,7 @@ export default function ExamPage() { ))} @@ -234,85 +275,6 @@ export default function ExamPage() { - - ); } diff --git a/app/exam/pretest/page.tsx b/app/exam/pretest/page.tsx index 5365d80..ad73818 100644 --- a/app/exam/pretest/page.tsx +++ b/app/exam/pretest/page.tsx @@ -6,6 +6,7 @@ import { ArrowLeft, HelpCircle, Clock, XCircle } from "lucide-react"; import DestructibleAlert from "@/components/DestructibleAlert"; import BackgroundWrapper from "@/components/BackgroundWrapper"; import { API_URL } from "@/lib/auth"; +import { useExam } from "@/context/ExamContext"; interface Metadata { metadata: { @@ -19,6 +20,8 @@ interface Metadata { export default function PretestPage() { const router = useRouter(); const searchParams = useSearchParams(); + const [examData, setExamData] = useState(); + const { startExam, setCurrentExam } = useExam(); // Get params from URL search params const name = searchParams.get("name") || ""; @@ -38,12 +41,14 @@ export default function PretestPage() { const questionResponse = await fetch(`${API_URL}/mock/${id}`, { method: "GET", }); + const data = await questionResponse.json(); + console.log(data); if (!questionResponse.ok) { throw new Error("Failed to fetch questions"); } - - const fetchedMetadata: Metadata = await questionResponse.json(); + setExamData(data); + const fetchedMetadata: Metadata = data; setMetadata(fetchedMetadata); } catch (error) { console.error(error); @@ -171,16 +176,14 @@ export default function PretestPage() { {/* */} diff --git a/app/exam/results/page.tsx b/app/exam/results/page.tsx index 32a50f4..dbd7ba8 100644 --- a/app/exam/results/page.tsx +++ b/app/exam/results/page.tsx @@ -1,277 +1,141 @@ "use client"; -import { useRouter, useSearchParams } from "next/navigation"; -import { ArrowLeft, LocateIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useExam, useExamResults } from "@/context/ExamContext"; import { useEffect } from "react"; +import React from "react"; -// Types interface Question { + solution: string; + id: number; question: string; options: Record; - correctAnswer: string; - userAnswer: string | null; - isCorrect: boolean; - solution: string; } -interface ResultSheet { - score: number; - questions: Question[]; +interface QuestionItemProps { + question: Question; + selectedAnswer: string | undefined; } -const ResultsPage = () => { +const QuestionItem = React.memo( + ({ question, selectedAnswer }) => ( +
+

+ {question.id}. {question.question} +

+
+ {Object.entries(question.options).map(([key, value]) => ( + + ))} +
+
+

Solution:

+

{question.solution}

+
+
+ ) +); + +export default function ResultsPage() { const router = useRouter(); - const searchParams = useSearchParams(); - const answersParam = searchParams.get("answers"); + const { clearExam, isExamCompleted, getApiResponse } = useExam(); - if (!answersParam) { + useEffect(() => { + // Redirect if no completed exam + if (!isExamCompleted()) { + router.push("/exam/select"); + return; + } + }, [isExamCompleted, router]); + + let examResults; + try { + examResults = useExamResults(); + } catch (error) { + // Handle case where there's no completed exam return (
-

- No results found -

+

+ No exam results found +

); } - const resultSheet: ResultSheet = JSON.parse(decodeURIComponent(answersParam)); + // Get API response data + const apiResponse = getApiResponse(); - const getScoreMessage = (score: number) => { - if (score < 30) return "Try harder!"; - if (score < 70) return "Getting Better"; - return "You did great!"; + const handleBackToHome = () => { + router.push("/unit"); + clearExam(); }; - const getStatusColor = (question: Question) => { - if (question.userAnswer === null) return "bg-yellow-500"; - return question.isCorrect ? "bg-green-500" : "bg-red-500"; - }; - - const getStatusText = (question: Question) => { - if (question.userAnswer === null) return "Skipped"; - return question.isCorrect ? "Correct" : "Incorrect"; - }; - - const getOptionClassName = (key: string, question: Question): string => { - const isCorrectAnswer = key === question.correctAnswer; - const isUserAnswer = key === question.userAnswer; - const isCorrectAndUserAnswer = isCorrectAnswer && isUserAnswer; - const isUserAnswerWrong = isUserAnswer && !isCorrectAnswer; - - if (isCorrectAndUserAnswer) { - return "bg-blue-900 text-white border-blue-900"; - } else if (isCorrectAnswer) { - return "bg-green-500 text-white border-green-500"; - } else if (isUserAnswerWrong) { - return "bg-red-500 text-white border-red-500"; - } - return "border-gray-300"; - }; - - // Handle browser back button - useEffect(() => { - const handlePopState = () => { - router.push("/unit"); - }; - - window.addEventListener("popstate", handlePopState); - return () => window.removeEventListener("popstate", handlePopState); - }, [router]); + const timeTaken = + examResults.endTime && examResults.startTime + ? Math.round( + (examResults.endTime.getTime() - examResults.startTime.getTime()) / + 1000 / + 60 + ) + : 0; return ( -
-
- {/* Header */} -
- +
+
+

+ Keep up the good work! +

+ + {/* Score Display */} +
+
+
+ {examResults.score}% +
+
Final Score
+
- {/* Main Content */} -
- {/* Score Message */} -

- {getScoreMessage(resultSheet.score)} -

- - {/* Score Card */} -
-

Score:

-
-
-
-
- - {resultSheet.score} - -
-
- - {/* Solutions Section */} -
-

Solutions

- -
- {resultSheet.questions.map((question, idx) => ( -
- {/* Question Header */} -
-

- {idx + 1}. {question.question} -

- - {getStatusText(question)} - -
- - {/* Options */} -
- {Object.entries(question.options).map(([key, option]) => ( -
- - {key.toUpperCase()} - - {option} -
- ))} -
- - {/* Solution Divider */} -
- - {/* Solution */} -
-

- Solution: -

-

- {question.solution} -

-
-
+ {apiResponse && ( +
+

+ Solutions +

+
+ {apiResponse.questions?.map((question) => ( + ))}
-
+ )} - {/* Bottom Button */} -
-
- -
-
- - {/* Spacer for fixed button */} -
+ {/* Action Buttons */}
- - +
); -}; - -export default ResultsPage; +} diff --git a/app/layout.tsx b/app/layout.tsx index a3b5bc1..aada37a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,8 @@ import { Montserrat } from "next/font/google"; import "./globals.css"; import { AuthProvider } from "@/context/AuthContext"; import { TimerProvider } from "@/context/TimerContext"; +import { ExamProvider } from "@/context/ExamContext"; +import { Providers } from "./providers"; const montserrat = Montserrat({ subsets: ["latin"], @@ -24,9 +26,7 @@ export default function RootLayout({ return ( - - {children} - + {children} ); diff --git a/app/providers.tsx b/app/providers.tsx new file mode 100644 index 0000000..6df70af --- /dev/null +++ b/app/providers.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { ExamProvider } from "@/context/ExamContext"; +import { TimerProvider } from "@/context/TimerContext"; +import { AuthProvider } from "@/context/AuthContext"; + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/context/ExamContext.tsx b/context/ExamContext.tsx new file mode 100644 index 0000000..7428917 --- /dev/null +++ b/context/ExamContext.tsx @@ -0,0 +1,260 @@ +"use client"; + +import React, { + createContext, + useContext, + useState, + useEffect, + ReactNode, +} from "react"; + +import { Exam, ExamAnswer, ExamAttempt, ExamContextType } from "@/types/exam"; +import { getFromStorage, removeFromStorage, setToStorage } from "@/lib/utils"; + +const ExamContext = createContext(undefined); + +const STORAGE_KEYS = { + CURRENT_EXAM: "current-exam", + CURRENT_ATTEMPT: "current-attempt", +} as const; + +export const ExamProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [currentExam, setCurrentExamState] = useState(null); + const [currentAttempt, setCurrentAttemptState] = useState( + null + ); + const [isHydrated, setIsHydrated] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); + + // Hydrate from session storage on mount + useEffect(() => { + const savedExam = getFromStorage(STORAGE_KEYS.CURRENT_EXAM); + const savedAttempt = getFromStorage( + STORAGE_KEYS.CURRENT_ATTEMPT + ); + + if (savedExam) { + setCurrentExamState(savedExam); + } + + if (savedAttempt) { + // Convert date strings back to Date objects + const hydratedAttempt = { + ...savedAttempt, + startTime: new Date(savedAttempt.startTime), + endTime: savedAttempt.endTime + ? new Date(savedAttempt.endTime) + : undefined, + answers: savedAttempt.answers.map((answer) => ({ + ...answer, + timestamp: new Date(answer.timestamp), + })), + }; + setCurrentAttemptState(hydratedAttempt); + } + + setIsHydrated(true); + }, []); + + // Persist to session storage whenever state changes + useEffect(() => { + if (!isHydrated) return; + + if (currentExam) { + setToStorage(STORAGE_KEYS.CURRENT_EXAM, currentExam); + } else { + removeFromStorage(STORAGE_KEYS.CURRENT_EXAM); + } + }, [currentExam, isHydrated]); + + useEffect(() => { + if (!isHydrated) return; + + if (currentAttempt) { + setToStorage(STORAGE_KEYS.CURRENT_ATTEMPT, currentAttempt); + } else { + removeFromStorage(STORAGE_KEYS.CURRENT_ATTEMPT); + } + }, [currentAttempt, isHydrated]); + + const setCurrentExam = (exam: Exam) => { + setCurrentExamState(exam); + // Clear any existing attempt when setting a new exam + setCurrentAttemptState(null); + }; + + const startExam = () => { + if (!currentExam) { + throw new Error("No exam selected"); + } + + const attempt: ExamAttempt = { + examId: currentExam.id, + exam: currentExam, + answers: [], + startTime: new Date(), + }; + + setCurrentAttemptState(attempt); + setIsInitialized(true); + }; + + const setAnswer = (questionId: string, answer: any) => { + if (!currentAttempt) { + throw new Error("No exam attempt started"); + } + + setCurrentAttemptState((prev) => { + if (!prev) return null; + + const existingAnswerIndex = prev.answers.findIndex( + (a) => a.questionId === questionId + ); + + const newAnswer: ExamAnswer = { + questionId, + answer, + timestamp: new Date(), + }; + + let newAnswers: ExamAnswer[]; + if (existingAnswerIndex >= 0) { + // Update existing answer + newAnswers = [...prev.answers]; + newAnswers[existingAnswerIndex] = newAnswer; + } else { + // Add new answer + newAnswers = [...prev.answers, newAnswer]; + } + + return { + ...prev, + answers: newAnswers, + }; + }); + }; + + const setApiResponse = (response: any) => { + if (!currentAttempt) { + throw new Error("No exam attempt started"); + } + + setCurrentAttemptState((prev) => { + if (!prev) return null; + return { + ...prev, + apiResponse: response, + }; + }); + }; + + const submitExam = (): ExamAttempt => { + if (!currentAttempt) { + throw new Error("No exam attempt to submit"); + } + + // Calculate score (simple example - you can customize this) + const attemptQuestions = currentAttempt.exam.questions; + const totalQuestions = attemptQuestions.length; + const answeredQuestions = currentAttempt.answers.filter( + (a) => a.questionId !== "__api_response__" + ).length; + const score = Math.round((answeredQuestions / totalQuestions) * 100); + + const completedAttempt: ExamAttempt = { + ...currentAttempt, + endTime: new Date(), + score, + passed: currentAttempt.exam.passingScore + ? score >= currentAttempt.exam.passingScore + : undefined, + }; + + setCurrentAttemptState(completedAttempt); + return completedAttempt; + }; + + const clearExam = () => { + setCurrentExamState(null); + setCurrentAttemptState(null); + }; + + const getAnswer = (questionId: string): any => { + if (!currentAttempt) return undefined; + + const answer = currentAttempt.answers.find( + (a) => a.questionId === questionId + ); + return answer?.answer; + }; + + const getApiResponse = (): any => { + return currentAttempt?.apiResponse; + }; + + const getProgress = (): number => { + if (!currentAttempt || !currentAttempt.exam) return 0; + + const totalQuestions = currentAttempt.exam.questions.length; + const answeredQuestions = currentAttempt.answers.filter( + (a) => a.questionId !== "__api_response__" + ).length; + + return totalQuestions > 0 ? (answeredQuestions / totalQuestions) * 100 : 0; + }; + + const isExamStarted = (): boolean => { + if (!isHydrated) return false; // ⛔ wait for hydration + return currentAttempt !== null && !currentAttempt.endTime; + }; + + const isExamCompleted = (): boolean => { + if (!isHydrated) return false; // ⛔ wait for hydration + return currentAttempt !== null && currentAttempt.endTime !== undefined; + }; + + const contextValue: ExamContextType = { + currentExam, + currentAttempt, + setCurrentExam, + startExam, + setAnswer, + submitExam, + clearExam, + setApiResponse, + getAnswer, + getProgress, + isExamStarted, + isExamCompleted, + getApiResponse, + isHydrated, + isInitialized, + }; + + return ( + {children} + ); +}; + +export const useExam = (): ExamContextType => { + const context = useContext(ExamContext); + + if (context === undefined) { + throw new Error("useExam must be used within an ExamProvider"); + } + + return context; +}; + +// Hook for exam results (only when exam is completed) +export const useExamResults = (): ExamAttempt => { + const { currentAttempt, isExamCompleted } = useExam(); + + if (!isExamCompleted() || !currentAttempt) { + throw new Error("No completed exam attempt found"); + } + + return currentAttempt; +}; diff --git a/lib/utils.ts b/lib/utils.ts index bd0c391..8d822f3 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,6 +1,38 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } + +export const getFromStorage = (key: string): T | null => { + if (typeof window === "undefined") return null; + + try { + const item = sessionStorage.getItem(key); + return item ? JSON.parse(item) : null; + } catch (error) { + console.error(`Error reading from sessionStorage (${key}):`, error); + return null; + } +}; + +export const setToStorage = (key: string, value: T): void => { + if (typeof window === "undefined") return; + + try { + sessionStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.error(`Error writing to sessionStorage (${key}):`, error); + } +}; + +export const removeFromStorage = (key: string): void => { + if (typeof window === "undefined") return; + + try { + sessionStorage.removeItem(key); + } catch (error) { + console.error(`Error removing from sessionStorage (${key}):`, error); + } +}; diff --git a/types/exam.d.ts b/types/exam.d.ts new file mode 100644 index 0000000..f6381a8 --- /dev/null +++ b/types/exam.d.ts @@ -0,0 +1,54 @@ +export interface Question { + id: string; + text: string; + options?: string[]; + type: "multiple-choice" | "text" | "boolean"; +} + +export interface Exam { + id: string; + title: string; + description: string; + questions: Question[]; + timeLimit?: number; + passingScore?: number; +} + +export interface ExamAnswer { + questionId: string; + answer: any; + timestamp: Date; +} + +export interface ExamAttempt { + examId: string; + exam: Exam; + answers: ExamAnswer[]; + startTime: Date; + endTime?: Date; + score?: number; + passed?: boolean; + apiResponse?: any; +} + +export interface ExamContextType { + currentExam: Exam | null; + currentAttempt: ExamAttempt | null; + isHydrated: boolean; + isInitialized: boolean; + + // Actions + setCurrentExam: (exam: Exam) => void; + startExam: () => void; + setAnswer: (questionId: string, answer: any) => void; + submitExam: () => ExamAttempt; + clearExam: () => void; + setApiResponse: (response: any) => void; + + // Getters + getAnswer: (questionId: string) => any; + getProgress: () => number; + isExamStarted: () => boolean; + isExamCompleted: () => boolean; + getApiResponse: () => any; +}