generated from muhtadeetaron/nextjs-template
fix(ui): fix results screen
This commit is contained in:
@ -11,8 +11,8 @@ import DestructibleAlert from "@/components/DestructibleAlert";
|
|||||||
import { ChevronRight } from "lucide-react"; // Using Lucide React for icons
|
import { ChevronRight } from "lucide-react"; // Using Lucide React for icons
|
||||||
import styles from "@/css/Home.module.css";
|
import styles from "@/css/Home.module.css";
|
||||||
import facebookStyles from "@/css/SlidingGallery.module.css";
|
import facebookStyles from "@/css/SlidingGallery.module.css";
|
||||||
|
import { API_URL } from "@/lib/auth";
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api";
|
import { Avatar, AvatarFallback } from "@radix-ui/react-avatar";
|
||||||
|
|
||||||
const page = () => {
|
const page = () => {
|
||||||
const profileImg = "/images/static/avatar.jpg";
|
const profileImg = "/images/static/avatar.jpg";
|
||||||
@ -198,13 +198,7 @@ const page = () => {
|
|||||||
<div key={idx} className={styles.topThreeItem}>
|
<div key={idx} className={styles.topThreeItem}>
|
||||||
<div className={styles.studentInfo}>
|
<div className={styles.studentInfo}>
|
||||||
<span className={styles.rank}>{student.rank}</span>
|
<span className={styles.rank}>{student.rank}</span>
|
||||||
<Image
|
<Avatar className="bg-slate-300 w-4 h-4 rounded-full"></Avatar>
|
||||||
src="/images/static/avatar.jpg"
|
|
||||||
alt="Avatar"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className={styles.avatar}
|
|
||||||
/>
|
|
||||||
<span className={styles.studentName}>
|
<span className={styles.studentName}>
|
||||||
{student.name}
|
{student.name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import BackgroundWrapper from "@/components/BackgroundWrapper";
|
import BackgroundWrapper from "@/components/BackgroundWrapper";
|
||||||
import Header from "@/components/Header";
|
import Header from "@/components/Header";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { API_URL, getToken } from "@/lib/auth";
|
import { API_URL, getToken } from "@/lib/auth";
|
||||||
|
import Image from "next/image";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
const page = () => {
|
const page = () => {
|
||||||
@ -94,6 +96,82 @@ const page = () => {
|
|||||||
<BackgroundWrapper>
|
<BackgroundWrapper>
|
||||||
<section>
|
<section>
|
||||||
<Header displaySubject={"Leaderboard"} displayTabTitle={null} />
|
<Header displaySubject={"Leaderboard"} displayTabTitle={null} />
|
||||||
|
<section className="flex flex-col mx-10 pt-10 space-y-4">
|
||||||
|
<section className="flex justify-evenly items-end">
|
||||||
|
{getTopThree(boardData).map((student, idx) =>
|
||||||
|
student ? (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="w-[100px] flex flex-col bg-[#113768] rounded-t-xl items-center justify-start pt-4 space-y-3"
|
||||||
|
style={{ height: student.height }}
|
||||||
|
>
|
||||||
|
<h3 className="font-bold text-xl text-white">
|
||||||
|
{student.rank}
|
||||||
|
</h3>
|
||||||
|
<Avatar className="bg-slate-300 w-12 h-12">
|
||||||
|
<AvatarFallback className="text-xl font-semibold">
|
||||||
|
{student.name.charAt(0).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<p className="font-bold text-md text-center text-white">
|
||||||
|
{student.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-white">({student.points}pt)</p>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<div className="w-full border-[0.5px] border-[#c5dbf8] bg-[#c5dbf8]"></div>
|
||||||
|
<section className="border-[1px] border-[#c0dafc] w-full rounded-3xl p-6 space-y-4 mb-20">
|
||||||
|
<section>
|
||||||
|
{getUserData(boardData, userData?.name).map((user, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex bg-[#113768] rounded-[8] py-2 px-4 justify-between items-center"
|
||||||
|
>
|
||||||
|
<div className=" flex gap-3 items-center">
|
||||||
|
<h2 className="font-medium text-lg text-white">
|
||||||
|
{user.rank}
|
||||||
|
</h2>
|
||||||
|
<Avatar className="bg-slate-300 w-6 h-6">
|
||||||
|
<AvatarFallback className="text-md font-semibold">
|
||||||
|
{user.name.charAt(0).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<h3 className="font-medium text-sm text-white">You</h3>
|
||||||
|
</div>
|
||||||
|
<p className="font-medium text-white/70">{user.points}pt</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
<div className="w-full border-[0.5px] border-[#c5dbf8] bg-[#c5dbf8]"></div>
|
||||||
|
<section className="space-y-4">
|
||||||
|
{getLeaderboard(boardData)
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((user, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex border-2 border-[#c5dbf8] rounded-[8] py-2 px-4 justify-between items-center"
|
||||||
|
>
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<h2 className="font-medium text-lg">{idx + 1}</h2>
|
||||||
|
<Avatar className="bg-slate-300 w-6 h-6">
|
||||||
|
<AvatarFallback className="text-md font-semibold">
|
||||||
|
{user.name.charAt(0).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<h3 className="font-medium text-sm">
|
||||||
|
{user.name.split(" ").slice(0, 2).join(" ")}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="font-medium text-[#000]/40">
|
||||||
|
{user.points}pt
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</BackgroundWrapper>
|
</BackgroundWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,65 +9,67 @@ import Header from "@/components/Header";
|
|||||||
import { Bookmark, BookmarkCheck } from "lucide-react";
|
import { Bookmark, BookmarkCheck } from "lucide-react";
|
||||||
import { useModal } from "@/context/ModalContext";
|
import { useModal } from "@/context/ModalContext";
|
||||||
import Modal from "@/components/ExamModal";
|
import Modal from "@/components/ExamModal";
|
||||||
|
import { Question } from "@/types/exam";
|
||||||
|
import QuestionItem from "@/components/QuestionItem";
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
interface Question {
|
// interface Question {
|
||||||
id: number;
|
// id: number;
|
||||||
question: string;
|
// question: string;
|
||||||
options: Record<string, string>;
|
// options: Record<string, string>;
|
||||||
}
|
// }
|
||||||
|
|
||||||
interface QuestionItemProps {
|
// interface QuestionItemProps {
|
||||||
question: Question;
|
// question: Question;
|
||||||
selectedAnswer?: string;
|
// selectedAnswer?: string;
|
||||||
handleSelect: (questionId: number, option: string) => void;
|
// handleSelect: (questionId: number, option: string) => void;
|
||||||
}
|
// }
|
||||||
|
|
||||||
const QuestionItem = React.memo<QuestionItemProps>(
|
// const QuestionItem = React.memo<QuestionItemProps>(
|
||||||
({ question, selectedAnswer, handleSelect }) => {
|
// ({ question, selectedAnswer, handleSelect }) => {
|
||||||
const [bookmark, setBookmark] = useState(false);
|
// const [bookmark, setBookmark] = useState(false);
|
||||||
|
|
||||||
return (
|
// return (
|
||||||
<div className="border-[0.5px] border-[#8abdff]/60 rounded-2xl p-4 flex flex-col">
|
// <div className="border-[0.5px] border-[#8abdff]/60 rounded-2xl p-4 flex flex-col">
|
||||||
<h3 className="text-xl font-medium mb-[20px]">
|
// <h3 className="text-xl font-medium mb-[20px]">
|
||||||
{question.id}. {question.question}
|
// {question.id}. {question.question}
|
||||||
</h3>
|
// </h3>
|
||||||
<div className="flex justify-between items-center">
|
// <div className="flex justify-between items-center">
|
||||||
<div></div>
|
// <div></div>
|
||||||
<button onClick={() => setBookmark(!bookmark)}>
|
// <button onClick={() => setBookmark(!bookmark)}>
|
||||||
{bookmark ? (
|
// {bookmark ? (
|
||||||
<BookmarkCheck size={25} color="#113768" />
|
// <BookmarkCheck size={25} color="#113768" />
|
||||||
) : (
|
// ) : (
|
||||||
<Bookmark size={25} color="#113768" />
|
// <Bookmark size={25} color="#113768" />
|
||||||
)}
|
// )}
|
||||||
</button>
|
// </button>
|
||||||
</div>
|
// </div>
|
||||||
<div className="flex flex-col gap-4 items-start">
|
// <div className="flex flex-col gap-4 items-start">
|
||||||
{Object.entries(question.options).map(([key, value]) => (
|
// {Object.entries(question.options).map(([key, value]) => (
|
||||||
<button
|
// <button
|
||||||
key={key}
|
// key={key}
|
||||||
className="flex items-center gap-3"
|
// className="flex items-center gap-3"
|
||||||
onClick={() => handleSelect(question.id, key)}
|
// onClick={() => handleSelect(question.id, key)}
|
||||||
>
|
// >
|
||||||
<span
|
// <span
|
||||||
className={`flex items-center rounded-full border px-1.5 ${
|
// className={`flex items-center rounded-full border px-1.5 ${
|
||||||
selectedAnswer === key
|
// selectedAnswer === key
|
||||||
? "text-white bg-[#113768] border-[#113768]"
|
// ? "text-white bg-[#113768] border-[#113768]"
|
||||||
: ""
|
// : ""
|
||||||
}`}
|
// }`}
|
||||||
>
|
// >
|
||||||
{key.toUpperCase()}
|
// {key.toUpperCase()}
|
||||||
</span>
|
// </span>
|
||||||
<span className="option-description">{value}</span>
|
// <span className="option-description">{value}</span>
|
||||||
</button>
|
// </button>
|
||||||
))}
|
// ))}
|
||||||
</div>
|
// </div>
|
||||||
</div>
|
// </div>
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
);
|
// );
|
||||||
|
|
||||||
QuestionItem.displayName = "QuestionItem";
|
// QuestionItem.displayName = "QuestionItem";
|
||||||
|
|
||||||
export default function ExamPage() {
|
export default function ExamPage() {
|
||||||
// All hooks at the top - no conditional calls
|
// All hooks at the top - no conditional calls
|
||||||
@ -267,7 +269,7 @@ export default function ExamPage() {
|
|||||||
if (submissionLoading) {
|
if (submissionLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="flex flex-col items-center justify-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-900 mb-4"></div>
|
<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>
|
<p className="text-lg font-medium text-gray-900">Submitting...</p>
|
||||||
</div>
|
</div>
|
||||||
@ -343,6 +345,7 @@ export default function ExamPage() {
|
|||||||
question={q}
|
question={q}
|
||||||
selectedAnswer={getAnswer(q.id.toString())}
|
selectedAnswer={getAnswer(q.id.toString())}
|
||||||
handleSelect={handleSelect}
|
handleSelect={handleSelect}
|
||||||
|
mode="exam"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,312 +2,118 @@
|
|||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useExam, useExamResults } from "@/context/ExamContext";
|
import { useExam, useExamResults } from "@/context/ExamContext";
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import Image from "next/image";
|
|
||||||
import SlidingGallery from "@/components/SlidingGallery";
|
import SlidingGallery from "@/components/SlidingGallery";
|
||||||
|
import QuestionItem from "@/components/QuestionItem";
|
||||||
interface Question {
|
import { getResultViews } from "@/lib/resultViews";
|
||||||
correctAnswer: string;
|
|
||||||
id: number;
|
|
||||||
question: string;
|
|
||||||
options: Record<string, string>;
|
|
||||||
solution?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface QuestionItemProps {
|
|
||||||
question: Question;
|
|
||||||
selectedAnswer: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QuestionItem = ({ question, selectedAnswer }: QuestionItemProps) => (
|
|
||||||
<div className="border border-[#8abdff]/50 rounded-2xl p-4 flex flex-col gap-7">
|
|
||||||
<h3 className="text-xl font-medium">
|
|
||||||
{question.id}. {question.question}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div></div>
|
|
||||||
|
|
||||||
{!selectedAnswer ? (
|
|
||||||
<Badge className="bg-yellow-500" variant="destructive">
|
|
||||||
Skipped
|
|
||||||
</Badge>
|
|
||||||
) : selectedAnswer.answer === question.correctAnswer ? (
|
|
||||||
<Badge className="bg-green-500 text-white" variant="default">
|
|
||||||
Correct
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge className="bg-red-500 text-white" variant="default">
|
|
||||||
Incorrect
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 items-start">
|
|
||||||
{Object.entries(question.options).map(([key, value]) => {
|
|
||||||
const isCorrect = key === question.correctAnswer;
|
|
||||||
const isSelected = key === selectedAnswer?.answer;
|
|
||||||
|
|
||||||
let optionStyle =
|
|
||||||
"px-2 py-1 flex items-center rounded-full border font-medium text-sm";
|
|
||||||
|
|
||||||
if (isCorrect) {
|
|
||||||
optionStyle += " bg-green-600 text-white border-green-600";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSelected && !isCorrect) {
|
|
||||||
optionStyle += " bg-red-600 text-white border-red-600";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isCorrect && !isSelected) {
|
|
||||||
optionStyle += " border-gray-300 text-gray-700";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={key} className="flex items-center gap-3">
|
|
||||||
<span className={optionStyle}>{key.toUpperCase()}</span>
|
|
||||||
<span className="option-description">{value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="h-[0.5px] border-[0.5px] border-dashed border-black/20"></div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h3 className="text-lg font-bold text-black/40">Solution:</h3>
|
|
||||||
<p className="text-lg">{question.solution}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function ResultsPage() {
|
export default function ResultsPage() {
|
||||||
// All hooks at the top - no conditional calls
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { clearExam, isExamCompleted, getApiResponse } = useExam();
|
const {
|
||||||
|
clearExam,
|
||||||
|
isExamCompleted,
|
||||||
|
getApiResponse,
|
||||||
|
currentAttempt,
|
||||||
|
isHydrated,
|
||||||
|
} = useExam();
|
||||||
|
|
||||||
// Add a ref to track if we're in cleanup mode
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const isCleaningUp = useRef(false);
|
|
||||||
|
|
||||||
// Conditionally call useExamResults based on cleanup state
|
|
||||||
const examResults = !isCleaningUp.current ? useExamResults() : null;
|
|
||||||
|
|
||||||
// State to control component behavior
|
|
||||||
const [componentState, setComponentState] = useState<
|
|
||||||
"loading" | "redirecting" | "ready"
|
|
||||||
>("loading");
|
|
||||||
|
|
||||||
// Single useEffect to handle all initialization logic
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
// Wait for hydration first
|
||||||
|
if (!isHydrated) return;
|
||||||
|
|
||||||
const initializeComponent = async () => {
|
// Check if exam is completed, redirect if not
|
||||||
// Allow time for all hooks to initialize
|
if (!isExamCompleted() || !currentAttempt) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
router.push("/unit");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
// If we have exam results, we're ready to render
|
||||||
|
if (currentAttempt?.answers) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [isExamCompleted, currentAttempt, isHydrated, router]);
|
||||||
|
|
||||||
// Check if exam is completed
|
const handleBackToHome = () => {
|
||||||
if (!isExamCompleted()) {
|
clearExam();
|
||||||
setComponentState("redirecting");
|
router.push("/unit");
|
||||||
// Small delay before redirect to prevent hook order issues
|
};
|
||||||
setTimeout(() => {
|
|
||||||
if (mounted) {
|
|
||||||
router.push("/unit");
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we have exam results
|
|
||||||
if (!examResults || !examResults.answers) {
|
|
||||||
// Keep loading state
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Everything is ready
|
|
||||||
setComponentState("ready");
|
|
||||||
};
|
|
||||||
|
|
||||||
initializeComponent();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [isExamCompleted, router, examResults]);
|
|
||||||
|
|
||||||
// Always render loading screen for non-ready states
|
|
||||||
if (componentState !== "ready") {
|
|
||||||
const loadingText =
|
|
||||||
componentState === "redirecting" ? "Redirecting..." : "Loading...";
|
|
||||||
|
|
||||||
|
// 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 bg-gray-50 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mt-60 flex flex-col items-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>
|
<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">{loadingText}</p>
|
<p className="text-xl font-medium text-center">Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// At this point, we know examResults exists and component is ready
|
|
||||||
const apiResponse = getApiResponse();
|
const apiResponse = getApiResponse();
|
||||||
|
|
||||||
const handleBackToHome = () => {
|
|
||||||
// Set cleanup flag to prevent useExamResults from running
|
|
||||||
isCleaningUp.current = true;
|
|
||||||
|
|
||||||
clearExam();
|
|
||||||
setTimeout(() => {
|
|
||||||
router.push("/unit");
|
|
||||||
}, 400);
|
|
||||||
};
|
|
||||||
|
|
||||||
const timeTaken =
|
const timeTaken =
|
||||||
examResults?.endTime && examResults?.startTime
|
currentAttempt.endTime && currentAttempt.startTime
|
||||||
? Math.round(
|
? Math.round(
|
||||||
(examResults.endTime.getTime() - examResults.startTime.getTime()) /
|
(currentAttempt.endTime.getTime() -
|
||||||
|
currentAttempt.startTime.getTime()) /
|
||||||
1000 /
|
1000 /
|
||||||
60
|
60
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const resultViews = [
|
const views = getResultViews(currentAttempt);
|
||||||
{
|
|
||||||
id: 1,
|
// Get score-based message
|
||||||
content: (
|
const getScoreMessage = () => {
|
||||||
<div className="w-full">
|
if (!currentAttempt.score || currentAttempt.score < 30)
|
||||||
<div className="bg-blue-50/60 border border-[#113678]/50 rounded-4xl h-[170px] flex flex-col items-center justify-center gap-4">
|
return "Try harder!";
|
||||||
<div className="text-xl text-black ">
|
if (currentAttempt.score < 70) return "Getting Better";
|
||||||
<span className="font-bold">Accuracy</span> Rate:
|
return "You did great!";
|
||||||
</div>
|
};
|
||||||
<div className="flex gap-4">
|
|
||||||
<Image
|
|
||||||
src="/images/icons/accuracy.png"
|
|
||||||
alt="accuracy"
|
|
||||||
width={60}
|
|
||||||
height={60}
|
|
||||||
/>
|
|
||||||
<h2 className="text-6xl font-bold text-[#113678]">
|
|
||||||
{examResults
|
|
||||||
? (
|
|
||||||
(examResults.score / examResults.totalQuestions) *
|
|
||||||
100
|
|
||||||
).toFixed(1)
|
|
||||||
: "0"}
|
|
||||||
%
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
content: (
|
|
||||||
<div className=" w-full">
|
|
||||||
<div className="bg-blue-50/60 border border-[#113678]/50 rounded-4xl h-[170px] flex flex-col items-center justify-center gap-3">
|
|
||||||
<div className="text-xl text-black ">
|
|
||||||
<span className="font-bold">Error</span> Rate:
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<Image
|
|
||||||
src="/images/icons/error.png"
|
|
||||||
alt="accuracy"
|
|
||||||
width={60}
|
|
||||||
height={60}
|
|
||||||
/>
|
|
||||||
<h2 className="text-6xl font-bold text-[#113678]">
|
|
||||||
{examResults
|
|
||||||
? (
|
|
||||||
((examResults.totalQuestions - examResults.score) /
|
|
||||||
examResults.totalQuestions) *
|
|
||||||
100
|
|
||||||
).toFixed(1)
|
|
||||||
: "0"}
|
|
||||||
%
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
content: (
|
|
||||||
<div className="my-8 w-full">
|
|
||||||
<div className="bg-blue-50/60 border border-[#113678]/50 rounded-4xl h-[170px] flex flex-col items-center justify-center gap-4">
|
|
||||||
<div className="text-xl text-black">
|
|
||||||
<span className="font-bold">Attempt</span> Rate:
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<Image
|
|
||||||
src="/images/icons/attempt.png"
|
|
||||||
alt="accuracy"
|
|
||||||
width={60}
|
|
||||||
height={60}
|
|
||||||
/>
|
|
||||||
<h2 className="text-6xl font-bold text-[#113678]">
|
|
||||||
{examResults
|
|
||||||
? (
|
|
||||||
(examResults.answers.length /
|
|
||||||
examResults.totalQuestions) *
|
|
||||||
100
|
|
||||||
).toFixed(1)
|
|
||||||
: "0"}
|
|
||||||
%
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
<button className="p-10" onClick={() => handleBackToHome()}>
|
<button className="p-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] mb-4 text-center">
|
||||||
{!examResults?.score || examResults?.score < 30
|
{getScoreMessage()}
|
||||||
? "Try harder!"
|
|
||||||
: examResults?.score < 70
|
|
||||||
? "Getting Better"
|
|
||||||
: "You did great!"}
|
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Score Display */}
|
{/* Score Display */}
|
||||||
<SlidingGallery className="my-8" views={resultViews} height="170px" />
|
<SlidingGallery className="my-8" views={views} height="170px" />
|
||||||
|
|
||||||
{apiResponse && (
|
{apiResponse?.questions && (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h3 className="text-2xl font-bold text-[#113768] mb-4">
|
<h3 className="text-2xl font-bold text-[#113768] mb-4">
|
||||||
Solutions
|
Solutions
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-col gap-7">
|
<div className="flex flex-col gap-7">
|
||||||
{apiResponse.questions?.map((question) => (
|
{apiResponse.questions.map((question) => (
|
||||||
<QuestionItem
|
<QuestionItem
|
||||||
key={question.id}
|
key={question.id}
|
||||||
question={question}
|
question={question}
|
||||||
selectedAnswer={examResults?.answers?.[question.id]}
|
selectedAnswer={currentAttempt.answers[question.id]}
|
||||||
|
mode="result"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</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 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
Finish
|
Finish
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
130
components/QuestionItem.tsx
Normal file
130
components/QuestionItem.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import { Question } from "@/types/exam";
|
||||||
|
import { BookmarkCheck, Bookmark } from "lucide-react";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Badge } from "./ui/badge";
|
||||||
|
|
||||||
|
interface ResultItemProps {
|
||||||
|
mode: "result";
|
||||||
|
question: Question;
|
||||||
|
selectedAnswer: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExamItemProps {
|
||||||
|
mode: "exam";
|
||||||
|
question: Question;
|
||||||
|
selectedAnswer?: string;
|
||||||
|
handleSelect: (questionId: number, option: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuestionItemProps = ResultItemProps | ExamItemProps;
|
||||||
|
|
||||||
|
const QuestionItem = (props: QuestionItemProps) => {
|
||||||
|
const [bookmark, setBookmark] = useState(false);
|
||||||
|
const { question, selectedAnswer } = props;
|
||||||
|
|
||||||
|
const isExam = props.mode === "exam";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-[0.5px] border-[#8abdff]/60 rounded-2xl p-4 flex flex-col">
|
||||||
|
<h3 className="text-xl font-medium mb-[20px]">
|
||||||
|
{question.id}. {question.question}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<div></div>
|
||||||
|
<button onClick={() => setBookmark(!bookmark)}>
|
||||||
|
{bookmark ? (
|
||||||
|
<BookmarkCheck size={25} color="#113768" />
|
||||||
|
) : (
|
||||||
|
<Bookmark size={25} color="#113768" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExam ? (
|
||||||
|
<div className="flex flex-col gap-4 items-start">
|
||||||
|
{Object.entries(question.options).map(([key, value]) => {
|
||||||
|
const isSelected = selectedAnswer === key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
className="flex items-center gap-3"
|
||||||
|
onClick={
|
||||||
|
isExam
|
||||||
|
? () => props.handleSelect(question.id, key)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
disabled={!isExam}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex items-center rounded-full border px-1.5 ${
|
||||||
|
isSelected ? "text-white bg-[#113768] border-[#113768]" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{key.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="option-description">{value}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div></div>
|
||||||
|
|
||||||
|
{!selectedAnswer ? (
|
||||||
|
<Badge className="bg-yellow-500" variant="destructive">
|
||||||
|
Skipped
|
||||||
|
</Badge>
|
||||||
|
) : selectedAnswer.answer === question.correctAnswer ? (
|
||||||
|
<Badge className="bg-green-500 text-white" variant="default">
|
||||||
|
Correct
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-red-500 text-white" variant="default">
|
||||||
|
Incorrect
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 items-start">
|
||||||
|
{Object.entries(question.options).map(([key, value]) => {
|
||||||
|
const isCorrect = key === question.correctAnswer;
|
||||||
|
const isSelected = key === selectedAnswer?.answer;
|
||||||
|
|
||||||
|
let optionStyle =
|
||||||
|
"px-2 py-1 flex items-center rounded-full border font-medium text-sm";
|
||||||
|
|
||||||
|
if (isCorrect) {
|
||||||
|
optionStyle += " bg-green-600 text-white border-green-600";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSelected && !isCorrect) {
|
||||||
|
optionStyle += " bg-red-600 text-white border-red-600";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCorrect && !isSelected) {
|
||||||
|
optionStyle += " border-gray-300 text-gray-700";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-center gap-3">
|
||||||
|
<span className={optionStyle}>{key.toUpperCase()}</span>
|
||||||
|
<span className="option-description">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="h-[0.5px] border-[0.5px] border-dashed border-black/20"></div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-lg font-bold text-black/40">Solution:</h3>
|
||||||
|
<p className="text-lg">{question.solution}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuestionItem;
|
||||||
@ -7,6 +7,7 @@ import React, {
|
|||||||
useEffect,
|
useEffect,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { Exam, ExamAnswer, ExamAttempt, ExamContextType } from "@/types/exam";
|
import { Exam, ExamAnswer, ExamAttempt, ExamContextType } from "@/types/exam";
|
||||||
import { getFromStorage, removeFromStorage, setToStorage } from "@/lib/utils";
|
import { getFromStorage, removeFromStorage, setToStorage } from "@/lib/utils";
|
||||||
@ -21,6 +22,7 @@ const STORAGE_KEYS = {
|
|||||||
export const ExamProvider: React.FC<{ children: ReactNode }> = ({
|
export const ExamProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
const [currentExam, setCurrentExamState] = useState<Exam | null>(null);
|
const [currentExam, setCurrentExamState] = useState<Exam | null>(null);
|
||||||
const [currentAttempt, setCurrentAttemptState] = useState<ExamAttempt | null>(
|
const [currentAttempt, setCurrentAttemptState] = useState<ExamAttempt | null>(
|
||||||
null
|
null
|
||||||
@ -81,13 +83,14 @@ export const ExamProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
|
|
||||||
const setCurrentExam = (exam: Exam) => {
|
const setCurrentExam = (exam: Exam) => {
|
||||||
setCurrentExamState(exam);
|
setCurrentExamState(exam);
|
||||||
|
|
||||||
setCurrentAttemptState(null);
|
setCurrentAttemptState(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const startExam = () => {
|
const startExam = () => {
|
||||||
if (!currentExam) {
|
if (!currentExam) {
|
||||||
throw new Error("No exam selected");
|
console.warn("No exam selected, redirecting to /unit");
|
||||||
|
router.push("/unit");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const attempt: ExamAttempt = {
|
const attempt: ExamAttempt = {
|
||||||
@ -103,7 +106,9 @@ export const ExamProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
|
|
||||||
const setAnswer = (questionId: string, answer: any) => {
|
const setAnswer = (questionId: string, answer: any) => {
|
||||||
if (!currentAttempt) {
|
if (!currentAttempt) {
|
||||||
throw new Error("No exam attempt started");
|
console.warn("No exam attempt started, redirecting to /unit");
|
||||||
|
router.push("/unit");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentAttemptState((prev) => {
|
setCurrentAttemptState((prev) => {
|
||||||
@ -138,7 +143,9 @@ export const ExamProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
|
|
||||||
const setApiResponse = (response: any) => {
|
const setApiResponse = (response: any) => {
|
||||||
if (!currentAttempt) {
|
if (!currentAttempt) {
|
||||||
throw new Error("No exam attempt started");
|
console.warn("No exam attempt started, redirecting to /unit");
|
||||||
|
router.push("/unit");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentAttemptState((prev) => {
|
setCurrentAttemptState((prev) => {
|
||||||
@ -150,9 +157,11 @@ export const ExamProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitExam = (): ExamAttempt => {
|
const submitExam = (): ExamAttempt | null => {
|
||||||
if (!currentAttempt) {
|
if (!currentAttempt) {
|
||||||
throw new Error("No exam attempt to submit");
|
console.warn("No exam attempt to submit, redirecting to /unit");
|
||||||
|
router.push("/unit");
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate score (simple example - you can customize this)
|
// Calculate score (simple example - you can customize this)
|
||||||
@ -209,7 +218,7 @@ export const ExamProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const isExamStarted = () => !!currentExam && !!currentAttempt;
|
const isExamStarted = () => !!currentExam && !!currentAttempt;
|
||||||
|
|
||||||
const isExamCompleted = (): boolean => {
|
const isExamCompleted = (): boolean => {
|
||||||
if (!isHydrated) return false; // ⛔ wait for hydration
|
if (!isHydrated) return false; // wait for hydration
|
||||||
return currentAttempt !== null && currentAttempt.endTime !== undefined;
|
return currentAttempt !== null && currentAttempt.endTime !== undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -246,12 +255,18 @@ export const useExam = (): ExamContextType => {
|
|||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hook for exam results (only when exam is completed)
|
// Hook for exam results (only when exam is completed) - now returns null instead of throwing
|
||||||
export const useExamResults = (): ExamAttempt => {
|
export const useExamResults = (): ExamAttempt | null => {
|
||||||
const { currentAttempt, isExamCompleted } = useExam();
|
const { currentAttempt, isExamCompleted, isHydrated } = useExam();
|
||||||
|
|
||||||
|
// Wait for hydration before making decisions
|
||||||
|
if (!isHydrated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no completed exam is found, return null (let component handle redirect)
|
||||||
if (!isExamCompleted() || !currentAttempt) {
|
if (!isExamCompleted() || !currentAttempt) {
|
||||||
throw new Error("No completed exam attempt found");
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return currentAttempt;
|
return currentAttempt;
|
||||||
|
|||||||
99
lib/resultViews.tsx
Normal file
99
lib/resultViews.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
// lib/resultViews.tsx
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
interface ExamResults {
|
||||||
|
score: number;
|
||||||
|
totalQuestions: number;
|
||||||
|
answers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getResultViews = (examResults: ExamResults | null) => [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
content: (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="bg-blue-50/60 border border-[#113678]/50 rounded-4xl h-[170px] flex flex-col items-center justify-center gap-4">
|
||||||
|
<div className="text-xl text-black">
|
||||||
|
<span className="font-bold">Accuracy</span> Rate:
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Image
|
||||||
|
src="/images/icons/accuracy.png"
|
||||||
|
alt="accuracy"
|
||||||
|
width={60}
|
||||||
|
height={60}
|
||||||
|
/>
|
||||||
|
<h2 className="text-6xl font-bold text-[#113678]">
|
||||||
|
{examResults
|
||||||
|
? (
|
||||||
|
(examResults.score / examResults.totalQuestions) *
|
||||||
|
100
|
||||||
|
).toFixed(1)
|
||||||
|
: "0"}
|
||||||
|
%
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
content: (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="bg-blue-50/60 border border-[#113678]/50 rounded-4xl h-[170px] flex flex-col items-center justify-center gap-3">
|
||||||
|
<div className="text-xl text-black">
|
||||||
|
<span className="font-bold">Error</span> Rate:
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Image
|
||||||
|
src="/images/icons/error.png"
|
||||||
|
alt="error"
|
||||||
|
width={60}
|
||||||
|
height={60}
|
||||||
|
/>
|
||||||
|
<h2 className="text-6xl font-bold text-[#113678]">
|
||||||
|
{examResults
|
||||||
|
? (
|
||||||
|
((examResults.totalQuestions - examResults.score) /
|
||||||
|
examResults.totalQuestions) *
|
||||||
|
100
|
||||||
|
).toFixed(1)
|
||||||
|
: "0"}
|
||||||
|
%
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
content: (
|
||||||
|
<div className="my-8 w-full">
|
||||||
|
<div className="bg-blue-50/60 border border-[#113678]/50 rounded-4xl h-[170px] flex flex-col items-center justify-center gap-4">
|
||||||
|
<div className="text-xl text-black">
|
||||||
|
<span className="font-bold">Attempt</span> Rate:
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Image
|
||||||
|
src="/images/icons/attempt.png"
|
||||||
|
alt="attempt"
|
||||||
|
width={60}
|
||||||
|
height={60}
|
||||||
|
/>
|
||||||
|
<h2 className="text-6xl font-bold text-[#113678]">
|
||||||
|
{examResults
|
||||||
|
? (
|
||||||
|
(examResults.answers.length / examResults.totalQuestions) *
|
||||||
|
100
|
||||||
|
).toFixed(1)
|
||||||
|
: "0"}
|
||||||
|
%
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
6
types/exam.d.ts
vendored
6
types/exam.d.ts
vendored
@ -1,8 +1,10 @@
|
|||||||
export interface Question {
|
export interface Question {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
options?: string[];
|
options?: Record<string, string>;
|
||||||
type: "multiple-choice" | "text" | "boolean";
|
type: "multiple-choice" | "text" | "boolean" | undefined;
|
||||||
|
correctAnswer: string | undefined;
|
||||||
|
solution?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Exam {
|
export interface Exam {
|
||||||
|
|||||||
Reference in New Issue
Block a user