From 55076020311a6aa68860e0e974d156eb84573bca Mon Sep 17 00:00:00 2001 From: shafin-r Date: Sun, 31 Aug 2025 23:27:32 +0600 Subject: [PATCH] fix(ui): refactor results page for exam results logic --- app/exam/[id]/page.tsx | 28 ++++++-- app/exam/results/page.tsx | 134 +++++++++++++------------------------ components/Header.tsx | 10 ++- context/AuthContext.tsx | 136 -------------------------------------- context/ExamContext.tsx | 100 ---------------------------- context/TimerContext.tsx | 88 ------------------------ lib/gallery-views.tsx | 22 +++--- stores/examStore.ts | 36 +++++++--- types/exam.d.ts | 17 +++++ 9 files changed, 127 insertions(+), 444 deletions(-) delete mode 100644 context/AuthContext.tsx delete mode 100644 context/ExamContext.tsx delete mode 100644 context/TimerContext.tsx diff --git a/app/exam/[id]/page.tsx b/app/exam/[id]/page.tsx index 0845d04..aaed23c 100644 --- a/app/exam/[id]/page.tsx +++ b/app/exam/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useCallback } from "react"; +import React, { useEffect, useCallback, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import Header from "@/components/Header"; import QuestionItem from "@/components/QuestionItem"; @@ -18,6 +18,8 @@ export default function ExamPage() { useExamStore(); const { resetTimer, stopTimer } = useTimerStore(); + const [isSubmitting, setIsSubmitting] = useState(false); + // Start exam + timer automatically useEffect(() => { if (type && test_id) { @@ -27,7 +29,6 @@ export default function ExamPage() { // Timer ended → auto-submit exam submitExam(type); router.push(`/categories/${type}s`); - alert("Time's up! Your exam has been submitted."); }); } }); @@ -53,9 +54,14 @@ export default function ExamPage() { ); } - const handleSubmitExam = (type: string) => { - submitExam(type); - router.push(`/categories/${type}s`); + const handleSubmitExam = async (type: string) => { + try { + setIsSubmitting(true); + await submitExam(type); + router.push(`/exam/results`); + } finally { + setIsSubmitting(false); + } }; return ( @@ -81,9 +87,17 @@ export default function ExamPage() {
diff --git a/app/exam/results/page.tsx b/app/exam/results/page.tsx index 4052af5..9a0c524 100644 --- a/app/exam/results/page.tsx +++ b/app/exam/results/page.tsx @@ -1,122 +1,82 @@ "use client"; import { useRouter } from "next/navigation"; -import { useExam } from "@/context/ExamContext"; -import { useEffect, useState } from "react"; import React from "react"; import { ArrowLeft } from "lucide-react"; -import SlidingGallery from "@/components/SlidingGallery"; +import { useExamStore } from "@/stores/examStore"; import QuestionItem from "@/components/QuestionItem"; +import SlidingGallery from "@/components/SlidingGallery"; import { getResultViews } from "@/lib/gallery-views"; -import { Question } from "@/types/exam"; export default function ResultsPage() { const router = useRouter(); - const { - clearExam, - isExamCompleted, - getApiResponse, - currentAttempt, - isHydrated, - } = useExam(); + const { result, clearResult } = useExamStore(); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - // Wait for hydration first - if (!isHydrated) return; - - // Check if exam is completed, redirect if not - if (!isExamCompleted() || !currentAttempt) { - router.push("/unit"); - return; - } - - // If we have exam results, we're ready to render - if (currentAttempt?.answers) { - setIsLoading(false); - } - }, [isExamCompleted, currentAttempt, isHydrated, router]); - - const handleBackToHome = () => { - clearExam(); - router.push("/unit"); - }; - - // Show loading screen while initializing or if no exam results - if (isLoading || !currentAttempt) { + if (!result) { return ( -
-
-
-
-

Loading...

-
-
+
+

No results to display.

); } - const apiResponse = getApiResponse(); - - // const timeTaken = - // currentAttempt.endTime && currentAttempt.startTime - // ? Math.round( - // (currentAttempt.endTime.getTime() - - // currentAttempt.startTime.getTime()) / - // 1000 / - // 60 - // ) - // : 0; - - const views = getResultViews(currentAttempt); - - // Get score-based message - const getScoreMessage = () => { - if (!currentAttempt.score || currentAttempt.score < 30) - return "Try harder!"; - if (currentAttempt.score < 70) return "Getting Better"; - return "You did great!"; + const handleBackToHome = () => { + clearResult(); + router.push("/categories"); }; + const views = getResultViews(result); + return (
-
-

- {getScoreMessage()} +

+ You did great!

- {/* Score Display */} - + - {apiResponse?.questions && ( -
-

- Solutions -

-
- {apiResponse.questions.map((question: Question) => ( - - ))} + {/* Render questions with correctness */} + {result.user_questions.map((q, idx) => { + const userAnswer = result.user_answers[idx]; + const correctAnswer = result.correct_answers[idx]; + + return ( +
+ {}} // disabled in results + /> + + {/* Answer feedback */} +
+ {userAnswer === null ? ( + + Skipped — Correct: {String.fromCharCode(65 + correctAnswer)} + + ) : userAnswer === correctAnswer ? ( + Correct + ) : ( + + Your Answer: {String.fromCharCode(65 + userAnswer)} | + Correct Answer: {String.fromCharCode(65 + correctAnswer)} + + )} +
-
- )} + ); + })}
diff --git a/components/Header.tsx b/components/Header.tsx index 8719607..7d1ef2a 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -2,7 +2,7 @@ import React from "react"; import { useRouter } from "next/navigation"; -import { ChevronLeft, Layers } from "lucide-react"; +import { ChevronLeft, Layers, Loader } from "lucide-react"; import { useTimer } from "@/context/TimerContext"; import styles from "@/css/Header.module.css"; import { useExam } from "@/context/ExamContext"; @@ -53,8 +53,12 @@ const Header = ({ {displayUser && (
- - {user?.username ? user.username.charAt(0).toUpperCase() : "U"} + + {user?.username ? ( + user.username.charAt(0).toUpperCase() + ) : ( +
+ )}
diff --git a/context/AuthContext.tsx b/context/AuthContext.tsx deleted file mode 100644 index f411d03..0000000 --- a/context/AuthContext.tsx +++ /dev/null @@ -1,136 +0,0 @@ -"use client"; - -import React, { createContext, useContext, useState, useEffect } from "react"; -import { useRouter, usePathname } from "next/navigation"; -import { UserData } from "@/types/auth"; -import { API_URL } from "@/lib/auth"; - -interface AuthContextType { - token: string | null; - setToken: (token: string | null) => void; - logout: () => void; - isLoading: boolean; - user: UserData | null; - fetchUser: () => Promise; -} - -const AuthContext = createContext(undefined); - -// Cookie utility functions -const getCookie = (name: string): string | null => { - if (typeof document === "undefined") return null; - - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) { - return parts.pop()?.split(";").shift() || null; - } - return null; -}; - -const setCookie = ( - name: string, - value: string | null, - days: number = 7 -): void => { - if (typeof document === "undefined") return; - - if (value === null) { - document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Strict; Secure`; - } else { - const expires = new Date(); - expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000); - document.cookie = `${name}=${value}; expires=${expires.toUTCString()}; path=/; SameSite=Strict; Secure`; - } -}; - -export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => { - const [token, setTokenState] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [user, setUser] = useState(null); - const router = useRouter(); - const pathname = usePathname(); - - const setToken = (newToken: string | null) => { - setTokenState(newToken); - setCookie("authToken", newToken); - }; - - // Fetch user info from API - const fetchUser = async () => { - if (!token) return; - - try { - const res = await fetch(`${API_URL}/me/profile/`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (!res.ok) { - throw new Error("Failed to fetch user info"); - } - const data: UserData = await res.json(); - setUser(data); - } catch (error) { - console.error("Error fetching user:", error); - setUser(null); - logout(); - } - }; - - useEffect(() => { - const initializeAuth = async () => { - const storedToken = getCookie("authToken"); - - if (storedToken) { - setTokenState(storedToken); - - if ( - pathname === "/" || - pathname === "/login" || - pathname === "/register" - ) { - router.replace("/home"); - } - - // Fetch user info when token is found - await fetchUser(); - } else { - const publicPages = ["/", "/login", "/register"]; - if (!publicPages.includes(pathname)) { - router.replace("/"); - } - } - - setIsLoading(false); - }; - - initializeAuth(); - }, [pathname, router]); - - const logout = () => { - setTokenState(null); - setUser(null); - setCookie("authToken", null); - router.replace("/login"); - }; - - return ( - - {children} - - ); -}; - -export const useAuth = () => { - const context = useContext(AuthContext); - if (!context) { - throw new Error("useAuth must be used within an AuthProvider"); - } - return context; -}; diff --git a/context/ExamContext.tsx b/context/ExamContext.tsx deleted file mode 100644 index 448fbf6..0000000 --- a/context/ExamContext.tsx +++ /dev/null @@ -1,100 +0,0 @@ -"use client"; - -import React, { createContext, useContext, useState } from "react"; -import { Test, Answer } from "@/types/exam"; -import { API_URL } from "@/lib/auth"; -import { getToken } from "@/lib/auth"; - -interface ExamContextType { - test: Test | null; - answers: Answer[]; - startExam: (testType: string, testId: string) => Promise; - setAnswer: (questionIndex: number, answer: Answer) => void; - submitExam: () => Promise; - cancelExam: () => void; -} - -const ExamContext = createContext(undefined); - -export const ExamProvider: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => { - const [test, setTest] = useState(null); - const [answers, setAnswers] = useState([]); - - // start exam - const startExam = async (testType: string, testId: string) => { - try { - const token = await getToken(); // if needed - const res = await fetch(`${API_URL}/tests/${testType}/${testId}`, { - method: "GET", - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - if (!res.ok) throw new Error(`Failed to fetch test: ${res.status}`); - const data: Test = await res.json(); - setTest(data); - setAnswers(Array(data.questions.length).fill(null)); - } catch (err) { - console.error("startExam error:", err); - } - }; - - // update answer - const setAnswer = (questionIndex: number, answer: Answer) => { - setAnswers((prev) => { - const updated = [...prev]; - updated[questionIndex] = answer; - return updated; - }); - }; - - // submit exam - const submitExam = async () => { - if (!test) return; - const token = await getToken(); - try { - const { type, test_id, attempt_id } = test.metadata; - const res = await fetch( - `${API_URL}/tests/${type}/${test_id}/${attempt_id}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ answers }), - } - ); - if (!res.ok) throw new Error("Failed to submit exam"); - - // clear - setTest(null); - setAnswers([]); - } catch (err) { - console.error("Failed to submit exam. Reason:", err); - } - }; - - // cancel exam - const cancelExam = () => { - setTest(null); - setAnswers([]); - }; - - return ( - - {children} - - ); -}; - -export const useExam = (): ExamContextType => { - const ctx = useContext(ExamContext); - if (!ctx) throw new Error("useExam must be used inside ExamProvider"); - return ctx; -}; diff --git a/context/TimerContext.tsx b/context/TimerContext.tsx deleted file mode 100644 index 43d8563..0000000 --- a/context/TimerContext.tsx +++ /dev/null @@ -1,88 +0,0 @@ -"use client"; - -import React, { - createContext, - useContext, - useState, - useEffect, - useRef, -} from "react"; - -interface TimerContextType { - timeRemaining: number; - resetTimer: (duration: number) => void; - stopTimer: () => void; - setInitialTime: (duration: number) => void; -} - -const TimerContext = createContext(undefined); - -export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => { - const [timeRemaining, setTimeRemaining] = useState(0); - const timerRef = useRef(null); - - // Effect: run interval whenever timeRemaining is set > 0 - useEffect(() => { - if (timeRemaining > 0 && !timerRef.current) { - timerRef.current = setInterval(() => { - setTimeRemaining((prev) => { - if (prev <= 1) { - clearInterval(timerRef.current!); - timerRef.current = null; - return 0; - } - return prev - 1; - }); - }, 1000); - } - - return () => { - if (timerRef.current && timeRemaining <= 0) { - clearInterval(timerRef.current); - timerRef.current = null; - } - }; - }, [timeRemaining]); // 👈 depend on timeRemaining - - const resetTimer = (duration: number) => { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - setTimeRemaining(duration); - }; - - const stopTimer = () => { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - setTimeRemaining(0); - }; - - const setInitialTime = (duration: number) => { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - setTimeRemaining(duration); - }; - - return ( - - {children} - - ); -}; - -export const useTimer = (): TimerContextType => { - const context = useContext(TimerContext); - if (!context) { - throw new Error("useTimer must be used within a TimerProvider"); - } - return context; -}; diff --git a/lib/gallery-views.tsx b/lib/gallery-views.tsx index dc69bae..6292ab3 100644 --- a/lib/gallery-views.tsx +++ b/lib/gallery-views.tsx @@ -1,17 +1,10 @@ // lib/gallery-views.tsx import Link from "next/link"; import Image from "next/image"; -import { ExamAnswer } from "@/types/exam"; import { GalleryViews } from "@/types/gallery"; +import { ExamResult } from "@/types/exam"; -// Define the ExamResults type if not already defined -interface ExamResults { - score: number; - totalQuestions: number; - answers: ExamAnswer[]; // or more specific type based on your answer structure -} - -export const getResultViews = (examResults: ExamResults | null) => [ +export const getResultViews = (examResults: ExamResult | null) => [ { id: 1, content: ( @@ -30,7 +23,8 @@ export const getResultViews = (examResults: ExamResults | null) => [

{examResults ? ( - (examResults.score / examResults.totalQuestions) * + (examResults.correct_answers_count / + examResults.user_questions.length) * 100 ).toFixed(1) : "0"} @@ -59,8 +53,9 @@ export const getResultViews = (examResults: ExamResults | null) => [

{examResults ? ( - ((examResults.totalQuestions - examResults.score) / - examResults.totalQuestions) * + ((examResults.user_questions.length - + examResults.correct_answers_count) / + examResults.user_questions.length) * 100 ).toFixed(1) : "0"} @@ -89,7 +84,8 @@ export const getResultViews = (examResults: ExamResults | null) => [

{examResults ? ( - (examResults.answers.length / examResults.totalQuestions) * + (examResults.user_answers.length / + examResults.user_questions.length) * 100 ).toFixed(1) : "0"} diff --git a/stores/examStore.ts b/stores/examStore.ts index e7bd362..3930956 100644 --- a/stores/examStore.ts +++ b/stores/examStore.ts @@ -1,22 +1,28 @@ "use client"; import { create } from "zustand"; -import { Test, Answer } from "@/types/exam"; +import { Test, Answer, Question } from "@/types/exam"; import { API_URL, getToken } from "@/lib/auth"; +import { ExamResult } from "@/types/exam"; + +// Result type (based on your API response) interface ExamState { test: Test | null; answers: Answer[]; + result: ExamResult | null; - startExam: (testType: string, testId: string) => Promise; + startExam: (testType: string, testId: string) => Promise; setAnswer: (questionIndex: number, answer: Answer) => void; - submitExam: (testType: string) => Promise; + submitExam: (testType: string) => Promise; cancelExam: () => void; + clearResult: () => void; } export const useExamStore = create((set, get) => ({ test: null, answers: [], + result: null, // start exam startExam: async (testType: string, testId: string) => { @@ -35,9 +41,13 @@ export const useExamStore = create((set, get) => ({ set({ test: data, answers: Array(data.questions.length).fill(null), + result: null, // clear old result }); + + return data; } catch (err) { console.error("startExam error:", err); + return null; } }, @@ -53,13 +63,12 @@ export const useExamStore = create((set, get) => ({ // submit exam submitExam: async (testType: string) => { const { test, answers } = get(); - if (!test) return; + if (!test) return null; const token = await getToken(); try { const { test_id, attempt_id } = test.metadata; - console.log(answers); const res = await fetch( `${API_URL}/tests/${testType}/${test_id}/${attempt_id}`, { @@ -72,19 +81,26 @@ export const useExamStore = create((set, get) => ({ } ); - console.log(res); - if (!res.ok) throw new Error("Failed to submit exam"); + const result: ExamResult = await res.json(); - // reset store - set({ test: null, answers: [] }); + // save result, clear test+answers + set({ test: null, answers: [], result }); + + return result; } catch (err) { console.error("Failed to submit exam. Reason:", err); + return null; } }, // cancel exam cancelExam: () => { - set({ test: null, answers: [] }); + set({ test: null, answers: [], result: null }); + }, + + // clear result manually (e.g., when leaving results page) + clearResult: () => { + set({ result: null }); }, })); diff --git a/types/exam.d.ts b/types/exam.d.ts index 1038b50..593fcc1 100644 --- a/types/exam.d.ts +++ b/types/exam.d.ts @@ -24,3 +24,20 @@ export interface Test { export type Answer = number | null; export type AnswersMap = Record; + +export interface ExamResult { + user_id: string; + test_id: string; + subject_id: string; + topic_id: string; + test_type: string; + attempt_id: string; + start_time: string; + end_time: string; + user_questions: Question[]; + user_answers: (number | null)[]; + correct_answers: number[]; + correct_answers_count: number; + wrong_answers_count: number; + skipped_questions_count: number; +}