generated from muhtadeetaron/nextjs-template
fix(ui): refactor results page for exam results logic
This commit is contained in:
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useCallback } from "react";
|
import React, { useEffect, useCallback, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Header from "@/components/Header";
|
import Header from "@/components/Header";
|
||||||
import QuestionItem from "@/components/QuestionItem";
|
import QuestionItem from "@/components/QuestionItem";
|
||||||
@ -18,6 +18,8 @@ export default function ExamPage() {
|
|||||||
useExamStore();
|
useExamStore();
|
||||||
const { resetTimer, stopTimer } = useTimerStore();
|
const { resetTimer, stopTimer } = useTimerStore();
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
// Start exam + timer automatically
|
// Start exam + timer automatically
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (type && test_id) {
|
if (type && test_id) {
|
||||||
@ -27,7 +29,6 @@ export default function ExamPage() {
|
|||||||
// Timer ended → auto-submit exam
|
// Timer ended → auto-submit exam
|
||||||
submitExam(type);
|
submitExam(type);
|
||||||
router.push(`/categories/${type}s`);
|
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) => {
|
const handleSubmitExam = async (type: string) => {
|
||||||
submitExam(type);
|
try {
|
||||||
router.push(`/categories/${type}s`);
|
setIsSubmitting(true);
|
||||||
|
await submitExam(type);
|
||||||
|
router.push(`/exam/results`);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 flex">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSubmitExam(type)}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,122 +1,82 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useExam } from "@/context/ExamContext";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import SlidingGallery from "@/components/SlidingGallery";
|
import { useExamStore } from "@/stores/examStore";
|
||||||
import QuestionItem from "@/components/QuestionItem";
|
import QuestionItem from "@/components/QuestionItem";
|
||||||
|
import SlidingGallery from "@/components/SlidingGallery";
|
||||||
import { getResultViews } from "@/lib/gallery-views";
|
import { getResultViews } from "@/lib/gallery-views";
|
||||||
import { Question } from "@/types/exam";
|
|
||||||
|
|
||||||
export default function ResultsPage() {
|
export default function ResultsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const {
|
const { result, clearResult } = useExamStore();
|
||||||
clearExam,
|
|
||||||
isExamCompleted,
|
|
||||||
getApiResponse,
|
|
||||||
currentAttempt,
|
|
||||||
isHydrated,
|
|
||||||
} = useExam();
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
if (!result) {
|
||||||
|
|
||||||
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) {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="text-center">
|
<p className="text-lg font-medium">No results to display.</p>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiResponse = getApiResponse();
|
const handleBackToHome = () => {
|
||||||
|
clearResult();
|
||||||
// const timeTaken =
|
router.push("/categories");
|
||||||
// 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 views = getResultViews(result);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<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" />
|
<ArrowLeft size={30} color="black" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-lg px-10 pb-20">
|
<div className="bg-white rounded-lg shadow-lg px-10 pb-20">
|
||||||
<h1 className="text-2xl font-bold text-[#113768] mb-4 text-center">
|
<h1 className="text-2xl font-bold text-[#113768] text-center">
|
||||||
{getScoreMessage()}
|
You did great!
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Score Display */}
|
<SlidingGallery views={views} height={"26vh"} />
|
||||||
<SlidingGallery className="my-8" views={views} height="170px" />
|
|
||||||
|
|
||||||
{apiResponse?.questions && (
|
{/* Render questions with correctness */}
|
||||||
<div className="mb-8">
|
{result.user_questions.map((q, idx) => {
|
||||||
<h3 className="text-2xl font-bold text-[#113768] mb-4">
|
const userAnswer = result.user_answers[idx];
|
||||||
Solutions
|
const correctAnswer = result.correct_answers[idx];
|
||||||
</h3>
|
|
||||||
<div className="flex flex-col gap-7">
|
return (
|
||||||
{apiResponse.questions.map((question: Question) => (
|
<div key={q.question_id} className={`rounded-3xl mb-6`}>
|
||||||
<QuestionItem
|
<QuestionItem
|
||||||
key={question.id}
|
question={q}
|
||||||
question={question}
|
index={idx}
|
||||||
selectedAnswer={
|
selectedAnswer={userAnswer}
|
||||||
currentAttempt.answers[parseInt(question.id) - 1]
|
onSelect={() => {}} // disabled in results
|
||||||
}
|
/>
|
||||||
mode="result"
|
|
||||||
/>
|
{/* 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>
|
);
|
||||||
)}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleBackToHome}
|
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
|
Finish
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ChevronLeft, Layers } from "lucide-react";
|
import { ChevronLeft, Layers, Loader } from "lucide-react";
|
||||||
import { useTimer } from "@/context/TimerContext";
|
import { useTimer } from "@/context/TimerContext";
|
||||||
import styles from "@/css/Header.module.css";
|
import styles from "@/css/Header.module.css";
|
||||||
import { useExam } from "@/context/ExamContext";
|
import { useExam } from "@/context/ExamContext";
|
||||||
@ -53,8 +53,12 @@ const Header = ({
|
|||||||
{displayUser && (
|
{displayUser && (
|
||||||
<div className={styles.profile}>
|
<div className={styles.profile}>
|
||||||
<Avatar className="bg-gray-200 w-10 h-10">
|
<Avatar className="bg-gray-200 w-10 h-10">
|
||||||
<AvatarFallback className=" text-lg">
|
<AvatarFallback className="flex items-center justify-center h-10 text-lg">
|
||||||
{user?.username ? user.username.charAt(0).toUpperCase() : "U"}
|
{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>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className={styles.text}>
|
<span className={styles.text}>
|
||||||
|
|||||||
@ -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;
|
|
||||||
};
|
|
||||||
@ -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;
|
|
||||||
};
|
|
||||||
@ -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;
|
|
||||||
};
|
|
||||||
@ -1,17 +1,10 @@
|
|||||||
// lib/gallery-views.tsx
|
// lib/gallery-views.tsx
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { ExamAnswer } from "@/types/exam";
|
|
||||||
import { GalleryViews } from "@/types/gallery";
|
import { GalleryViews } from "@/types/gallery";
|
||||||
|
import { ExamResult } from "@/types/exam";
|
||||||
|
|
||||||
// Define the ExamResults type if not already defined
|
export const getResultViews = (examResults: ExamResult | null) => [
|
||||||
interface ExamResults {
|
|
||||||
score: number;
|
|
||||||
totalQuestions: number;
|
|
||||||
answers: ExamAnswer[]; // or more specific type based on your answer structure
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getResultViews = (examResults: ExamResults | null) => [
|
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
content: (
|
content: (
|
||||||
@ -30,7 +23,8 @@ export const getResultViews = (examResults: ExamResults | null) => [
|
|||||||
<h2 className="text-6xl font-bold text-[#113678]">
|
<h2 className="text-6xl font-bold text-[#113678]">
|
||||||
{examResults
|
{examResults
|
||||||
? (
|
? (
|
||||||
(examResults.score / examResults.totalQuestions) *
|
(examResults.correct_answers_count /
|
||||||
|
examResults.user_questions.length) *
|
||||||
100
|
100
|
||||||
).toFixed(1)
|
).toFixed(1)
|
||||||
: "0"}
|
: "0"}
|
||||||
@ -59,8 +53,9 @@ export const getResultViews = (examResults: ExamResults | null) => [
|
|||||||
<h2 className="text-6xl font-bold text-[#113678]">
|
<h2 className="text-6xl font-bold text-[#113678]">
|
||||||
{examResults
|
{examResults
|
||||||
? (
|
? (
|
||||||
((examResults.totalQuestions - examResults.score) /
|
((examResults.user_questions.length -
|
||||||
examResults.totalQuestions) *
|
examResults.correct_answers_count) /
|
||||||
|
examResults.user_questions.length) *
|
||||||
100
|
100
|
||||||
).toFixed(1)
|
).toFixed(1)
|
||||||
: "0"}
|
: "0"}
|
||||||
@ -89,7 +84,8 @@ export const getResultViews = (examResults: ExamResults | null) => [
|
|||||||
<h2 className="text-6xl font-bold text-[#113678]">
|
<h2 className="text-6xl font-bold text-[#113678]">
|
||||||
{examResults
|
{examResults
|
||||||
? (
|
? (
|
||||||
(examResults.answers.length / examResults.totalQuestions) *
|
(examResults.user_answers.length /
|
||||||
|
examResults.user_questions.length) *
|
||||||
100
|
100
|
||||||
).toFixed(1)
|
).toFixed(1)
|
||||||
: "0"}
|
: "0"}
|
||||||
|
|||||||
@ -1,22 +1,28 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { create } from "zustand";
|
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 { API_URL, getToken } from "@/lib/auth";
|
||||||
|
import { ExamResult } from "@/types/exam";
|
||||||
|
|
||||||
|
// Result type (based on your API response)
|
||||||
|
|
||||||
interface ExamState {
|
interface ExamState {
|
||||||
test: Test | null;
|
test: Test | null;
|
||||||
answers: Answer[];
|
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;
|
setAnswer: (questionIndex: number, answer: Answer) => void;
|
||||||
submitExam: (testType: string) => Promise<void>;
|
submitExam: (testType: string) => Promise<ExamResult | null>;
|
||||||
cancelExam: () => void;
|
cancelExam: () => void;
|
||||||
|
clearResult: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useExamStore = create<ExamState>((set, get) => ({
|
export const useExamStore = create<ExamState>((set, get) => ({
|
||||||
test: null,
|
test: null,
|
||||||
answers: [],
|
answers: [],
|
||||||
|
result: null,
|
||||||
|
|
||||||
// start exam
|
// start exam
|
||||||
startExam: async (testType: string, testId: string) => {
|
startExam: async (testType: string, testId: string) => {
|
||||||
@ -35,9 +41,13 @@ export const useExamStore = create<ExamState>((set, get) => ({
|
|||||||
set({
|
set({
|
||||||
test: data,
|
test: data,
|
||||||
answers: Array(data.questions.length).fill(null),
|
answers: Array(data.questions.length).fill(null),
|
||||||
|
result: null, // clear old result
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("startExam error:", err);
|
console.error("startExam error:", err);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -53,13 +63,12 @@ export const useExamStore = create<ExamState>((set, get) => ({
|
|||||||
// submit exam
|
// submit exam
|
||||||
submitExam: async (testType: string) => {
|
submitExam: async (testType: string) => {
|
||||||
const { test, answers } = get();
|
const { test, answers } = get();
|
||||||
if (!test) return;
|
if (!test) return null;
|
||||||
|
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { test_id, attempt_id } = test.metadata;
|
const { test_id, attempt_id } = test.metadata;
|
||||||
console.log(answers);
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_URL}/tests/${testType}/${test_id}/${attempt_id}`,
|
`${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");
|
if (!res.ok) throw new Error("Failed to submit exam");
|
||||||
|
const result: ExamResult = await res.json();
|
||||||
|
|
||||||
// reset store
|
// save result, clear test+answers
|
||||||
set({ test: null, answers: [] });
|
set({ test: null, answers: [], result });
|
||||||
|
|
||||||
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to submit exam. Reason:", err);
|
console.error("Failed to submit exam. Reason:", err);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// cancel exam
|
// cancel exam
|
||||||
cancelExam: () => {
|
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
17
types/exam.d.ts
vendored
@ -24,3 +24,20 @@ export interface Test {
|
|||||||
|
|
||||||
export type Answer = number | null;
|
export type Answer = number | null;
|
||||||
export type AnswersMap = Record<string, Answer>;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user