fix(ui): refactor results page for exam results logic

This commit is contained in:
shafin-r
2025-08-31 23:27:32 +06:00
parent 7df2708db7
commit 5507602031
9 changed files with 127 additions and 444 deletions

View File

@ -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() {
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 flex">
<button
onClick={() => handleSubmitExam(type)}
className="flex-1 bg-blue-900 text-white p-6 font-bold text-lg hover:bg-blue-800 transition-colors"
disabled={isSubmitting}
className="flex-1 bg-blue-900 text-white p-6 font-bold text-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-blue-800 transition-colors flex justify-center items-center gap-2"
>
Submit
{isSubmitting ? (
<>
<span className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></span>
Submitting...
</>
) : (
"Submit"
)}
</button>
</div>
</div>

View File

@ -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 (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="mt-60 flex flex-col items-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
<p className="text-xl font-medium text-center">Loading...</p>
</div>
</div>
<div className="min-h-screen flex items-center justify-center">
<p className="text-lg font-medium">No results to display.</p>
</div>
);
}
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 (
<div className="min-h-screen bg-white">
<button className="p-10" onClick={handleBackToHome}>
<button className="px-10 pt-10" onClick={handleBackToHome}>
<ArrowLeft size={30} color="black" />
</button>
<div className="bg-white rounded-lg shadow-lg px-10 pb-20">
<h1 className="text-2xl font-bold text-[#113768] mb-4 text-center">
{getScoreMessage()}
<h1 className="text-2xl font-bold text-[#113768] text-center">
You did great!
</h1>
{/* Score Display */}
<SlidingGallery className="my-8" views={views} height="170px" />
<SlidingGallery views={views} height={"26vh"} />
{apiResponse?.questions && (
<div className="mb-8">
<h3 className="text-2xl font-bold text-[#113768] mb-4">
Solutions
</h3>
<div className="flex flex-col gap-7">
{apiResponse.questions.map((question: Question) => (
<QuestionItem
key={question.id}
question={question}
selectedAnswer={
currentAttempt.answers[parseInt(question.id) - 1]
}
mode="result"
/>
))}
{/* Render questions with correctness */}
{result.user_questions.map((q, idx) => {
const userAnswer = result.user_answers[idx];
const correctAnswer = result.correct_answers[idx];
return (
<div key={q.question_id} className={`rounded-3xl mb-6`}>
<QuestionItem
question={q}
index={idx}
selectedAnswer={userAnswer}
onSelect={() => {}} // disabled in results
/>
{/* Answer feedback */}
<div className="mt-2 text-sm">
{userAnswer === null ? (
<span className="text-yellow-600 font-medium">
Skipped Correct: {String.fromCharCode(65 + correctAnswer)}
</span>
) : userAnswer === correctAnswer ? (
<span className="text-green-600 font-medium">Correct</span>
) : (
<span className="text-red-600 font-medium">
Your Answer: {String.fromCharCode(65 + userAnswer)} |
Correct Answer: {String.fromCharCode(65 + correctAnswer)}
</span>
)}
</div>
</div>
</div>
)}
);
})}
</div>
<button
onClick={handleBackToHome}
className="fixed bottom-0 w-full bg-blue-900 text-white h-[74px] font-bold text-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="fixed bottom-0 w-full bg-blue-900 text-white h-[74px] font-bold text-lg hover:bg-blue-800 transition-colors"
>
Finish
</button>

View File

@ -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 && (
<div className={styles.profile}>
<Avatar className="bg-gray-200 w-10 h-10">
<AvatarFallback className=" text-lg">
{user?.username ? user.username.charAt(0).toUpperCase() : "U"}
<AvatarFallback className="flex items-center justify-center h-10 text-lg">
{user?.username ? (
user.username.charAt(0).toUpperCase()
) : (
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
)}
</AvatarFallback>
</Avatar>
<span className={styles.text}>

View File

@ -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<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(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<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState<UserData | null>(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 (
<AuthContext.Provider
value={{ token, setToken, logout, isLoading, user, fetchUser }}
>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};

View File

@ -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<void>;
setAnswer: (questionIndex: number, answer: Answer) => void;
submitExam: () => Promise<void>;
cancelExam: () => void;
}
const ExamContext = createContext<ExamContextType | undefined>(undefined);
export const ExamProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [test, setTest] = useState<Test | null>(null);
const [answers, setAnswers] = useState<Answer[]>([]);
// 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 (
<ExamContext.Provider
value={{ test, answers, startExam, setAnswer, submitExam, cancelExam }}
>
{children}
</ExamContext.Provider>
);
};
export const useExam = (): ExamContextType => {
const ctx = useContext(ExamContext);
if (!ctx) throw new Error("useExam must be used inside ExamProvider");
return ctx;
};

View File

@ -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<TimerContextType | undefined>(undefined);
export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [timeRemaining, setTimeRemaining] = useState<number>(0);
const timerRef = useRef<NodeJS.Timeout | null>(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 (
<TimerContext.Provider
value={{ timeRemaining, resetTimer, stopTimer, setInitialTime }}
>
{children}
</TimerContext.Provider>
);
};
export const useTimer = (): TimerContextType => {
const context = useContext(TimerContext);
if (!context) {
throw new Error("useTimer must be used within a TimerProvider");
}
return context;
};

View File

@ -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) => [
<h2 className="text-6xl font-bold text-[#113678]">
{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) => [
<h2 className="text-6xl font-bold text-[#113678]">
{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) => [
<h2 className="text-6xl font-bold text-[#113678]">
{examResults
? (
(examResults.answers.length / examResults.totalQuestions) *
(examResults.user_answers.length /
examResults.user_questions.length) *
100
).toFixed(1)
: "0"}

View File

@ -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<void>;
startExam: (testType: string, testId: string) => Promise<Test | null>;
setAnswer: (questionIndex: number, answer: Answer) => void;
submitExam: (testType: string) => Promise<void>;
submitExam: (testType: string) => Promise<ExamResult | null>;
cancelExam: () => void;
clearResult: () => void;
}
export const useExamStore = create<ExamState>((set, get) => ({
test: null,
answers: [],
result: null,
// start exam
startExam: async (testType: string, testId: string) => {
@ -35,9 +41,13 @@ export const useExamStore = create<ExamState>((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<ExamState>((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<ExamState>((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 });
},
}));

17
types/exam.d.ts vendored
View File

@ -24,3 +24,20 @@ export interface Test {
export type Answer = number | null;
export type AnswersMap = Record<string, Answer>;
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;
}