diff --git a/app/(tabs)/home/page.tsx b/app/(tabs)/home/page.tsx index a842136..6435c36 100644 --- a/app/(tabs)/home/page.tsx +++ b/app/(tabs)/home/page.tsx @@ -11,8 +11,8 @@ import DestructibleAlert from "@/components/DestructibleAlert"; import { ChevronRight } from "lucide-react"; // Using Lucide React for icons import styles from "@/css/Home.module.css"; import facebookStyles from "@/css/SlidingGallery.module.css"; - -const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api"; +import { API_URL } from "@/lib/auth"; +import { Avatar, AvatarFallback } from "@radix-ui/react-avatar"; const page = () => { const profileImg = "/images/static/avatar.jpg"; @@ -198,13 +198,7 @@ const page = () => {
{student.rank} - Avatar + {student.name} diff --git a/app/(tabs)/leaderboard/page.tsx b/app/(tabs)/leaderboard/page.tsx index 4660abc..18fa5d0 100644 --- a/app/(tabs)/leaderboard/page.tsx +++ b/app/(tabs)/leaderboard/page.tsx @@ -2,7 +2,9 @@ import BackgroundWrapper from "@/components/BackgroundWrapper"; import Header from "@/components/Header"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { API_URL, getToken } from "@/lib/auth"; +import Image from "next/image"; import React, { useEffect, useState } from "react"; const page = () => { @@ -94,6 +96,82 @@ const page = () => {
+
+
+ {getTopThree(boardData).map((student, idx) => + student ? ( +
+

+ {student.rank} +

+ + + {student.name.charAt(0).toUpperCase()} + + +

+ {student.name} +

+

({student.points}pt)

+
+ ) : null + )} +
+
+
+
+ {getUserData(boardData, userData?.name).map((user, idx) => ( +
+
+

+ {user.rank} +

+ + + {user.name.charAt(0).toUpperCase()} + + +

You

+
+

{user.points}pt

+
+ ))} +
+
+
+ {getLeaderboard(boardData) + .slice(0, 10) + .map((user, idx) => ( +
+
+

{idx + 1}

+ + + {user.name.charAt(0).toUpperCase()} + + +

+ {user.name.split(" ").slice(0, 2).join(" ")} +

+
+

+ {user.points}pt +

+
+ ))} +
+
+
); diff --git a/app/exam/[id]/page.tsx b/app/exam/[id]/page.tsx index 47d60e1..921dfb2 100644 --- a/app/exam/[id]/page.tsx +++ b/app/exam/[id]/page.tsx @@ -9,65 +9,67 @@ import Header from "@/components/Header"; import { Bookmark, BookmarkCheck } from "lucide-react"; import { useModal } from "@/context/ModalContext"; import Modal from "@/components/ExamModal"; +import { Question } from "@/types/exam"; +import QuestionItem from "@/components/QuestionItem"; // Types -interface Question { - id: number; - question: string; - options: Record; -} +// interface Question { +// id: number; +// question: string; +// options: Record; +// } -interface QuestionItemProps { - question: Question; - selectedAnswer?: string; - handleSelect: (questionId: number, option: string) => void; -} +// interface QuestionItemProps { +// question: Question; +// selectedAnswer?: string; +// handleSelect: (questionId: number, option: string) => void; +// } -const QuestionItem = React.memo( - ({ question, selectedAnswer, handleSelect }) => { - const [bookmark, setBookmark] = useState(false); +// const QuestionItem = React.memo( +// ({ question, selectedAnswer, handleSelect }) => { +// const [bookmark, setBookmark] = useState(false); - return ( -
-

- {question.id}. {question.question} -

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

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

+//
+//
+// +//
+//
+// {Object.entries(question.options).map(([key, value]) => ( +// +// ))} +//
+//
+// ); +// } +// ); -QuestionItem.displayName = "QuestionItem"; +// QuestionItem.displayName = "QuestionItem"; export default function ExamPage() { // All hooks at the top - no conditional calls @@ -267,7 +269,7 @@ export default function ExamPage() { if (submissionLoading) { return (
-
+

Submitting...

@@ -343,6 +345,7 @@ export default function ExamPage() { question={q} selectedAnswer={getAnswer(q.id.toString())} handleSelect={handleSelect} + mode="exam" /> ))}
diff --git a/app/exam/results/page.tsx b/app/exam/results/page.tsx index c2ef166..1b1b8b4 100644 --- a/app/exam/results/page.tsx +++ b/app/exam/results/page.tsx @@ -2,312 +2,118 @@ import { useRouter } from "next/navigation"; import { useExam, useExamResults } from "@/context/ExamContext"; -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState } from "react"; import React from "react"; import { ArrowLeft } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import Image from "next/image"; import SlidingGallery from "@/components/SlidingGallery"; - -interface Question { - correctAnswer: string; - id: number; - question: string; - options: Record; - solution?: string; -} - -interface QuestionItemProps { - question: Question; - selectedAnswer: string | undefined; -} - -const QuestionItem = ({ question, selectedAnswer }: QuestionItemProps) => ( -
-

- {question.id}. {question.question} -

- -
-
- - {!selectedAnswer ? ( - - Skipped - - ) : selectedAnswer.answer === question.correctAnswer ? ( - - Correct - - ) : ( - - Incorrect - - )} -
- -
- {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 ( -
- {key.toUpperCase()} - {value} -
- ); - })} -
-
-
-

Solution:

-

{question.solution}

-
-
-); +import QuestionItem from "@/components/QuestionItem"; +import { getResultViews } from "@/lib/resultViews"; export default function ResultsPage() { - // All hooks at the top - no conditional calls 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 isCleaningUp = useRef(false); + const [isLoading, setIsLoading] = useState(true); - // 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(() => { - let mounted = true; + // Wait for hydration first + if (!isHydrated) return; - const initializeComponent = async () => { - // Allow time for all hooks to initialize - await new Promise((resolve) => setTimeout(resolve, 50)); + // Check if exam is completed, redirect if not + if (!isExamCompleted() || !currentAttempt) { + 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 - if (!isExamCompleted()) { - setComponentState("redirecting"); - // 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..."; + const handleBackToHome = () => { + clearExam(); + router.push("/unit"); + }; + // Show loading screen while initializing or if no exam results + if (isLoading || !currentAttempt) { return (
-

{loadingText}

+

Loading...

); } - // At this point, we know examResults exists and component is ready const apiResponse = getApiResponse(); - const handleBackToHome = () => { - // Set cleanup flag to prevent useExamResults from running - isCleaningUp.current = true; - - clearExam(); - setTimeout(() => { - router.push("/unit"); - }, 400); - }; - const timeTaken = - examResults?.endTime && examResults?.startTime + currentAttempt.endTime && currentAttempt.startTime ? Math.round( - (examResults.endTime.getTime() - examResults.startTime.getTime()) / + (currentAttempt.endTime.getTime() - + currentAttempt.startTime.getTime()) / 1000 / 60 ) : 0; - const resultViews = [ - { - id: 1, - content: ( -
-
-
- Accuracy Rate: -
-
- accuracy -

- {examResults - ? ( - (examResults.score / examResults.totalQuestions) * - 100 - ).toFixed(1) - : "0"} - % -

-
-
-
-
- ), - }, - { - id: 2, - content: ( -
-
-
- Error Rate: -
-
- accuracy -

- {examResults - ? ( - ((examResults.totalQuestions - examResults.score) / - examResults.totalQuestions) * - 100 - ).toFixed(1) - : "0"} - % -

-
-
-
-
- ), - }, - { - id: 3, - content: ( -
-
-
- Attempt Rate: -
-
- accuracy -

- {examResults - ? ( - (examResults.answers.length / - examResults.totalQuestions) * - 100 - ).toFixed(1) - : "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!"; + }; return (
- +

- {!examResults?.score || examResults?.score < 30 - ? "Try harder!" - : examResults?.score < 70 - ? "Getting Better" - : "You did great!"} + {getScoreMessage()}

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

+

Solutions

- {apiResponse.questions?.map((question) => ( + {apiResponse.questions.map((question) => ( ))}
)}
+ diff --git a/components/QuestionItem.tsx b/components/QuestionItem.tsx new file mode 100644 index 0000000..ba18648 --- /dev/null +++ b/components/QuestionItem.tsx @@ -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 ( +
+

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

+ +
+
+ +
+ + {isExam ? ( +
+ {Object.entries(question.options).map(([key, value]) => { + const isSelected = selectedAnswer === key; + + return ( + + ); + })} +
+ ) : ( + <> +
+
+ + {!selectedAnswer ? ( + + Skipped + + ) : selectedAnswer.answer === question.correctAnswer ? ( + + Correct + + ) : ( + + Incorrect + + )} +
+
+ {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 ( +
+ {key.toUpperCase()} + {value} +
+ ); + })} +
+
+
+

Solution:

+

{question.solution}

+
+ + )} +
+ ); +}; + +export default QuestionItem; diff --git a/context/ExamContext.tsx b/context/ExamContext.tsx index f9c858d..c9af9e6 100644 --- a/context/ExamContext.tsx +++ b/context/ExamContext.tsx @@ -7,6 +7,7 @@ import React, { useEffect, ReactNode, } from "react"; +import { useRouter } from "next/navigation"; import { Exam, ExamAnswer, ExamAttempt, ExamContextType } from "@/types/exam"; import { getFromStorage, removeFromStorage, setToStorage } from "@/lib/utils"; @@ -21,6 +22,7 @@ const STORAGE_KEYS = { export const ExamProvider: React.FC<{ children: ReactNode }> = ({ children, }) => { + const router = useRouter(); const [currentExam, setCurrentExamState] = useState(null); const [currentAttempt, setCurrentAttemptState] = useState( null @@ -81,13 +83,14 @@ export const ExamProvider: React.FC<{ children: ReactNode }> = ({ const setCurrentExam = (exam: Exam) => { setCurrentExamState(exam); - setCurrentAttemptState(null); }; const startExam = () => { if (!currentExam) { - throw new Error("No exam selected"); + console.warn("No exam selected, redirecting to /unit"); + router.push("/unit"); + return; } const attempt: ExamAttempt = { @@ -103,7 +106,9 @@ export const ExamProvider: React.FC<{ children: ReactNode }> = ({ const setAnswer = (questionId: string, answer: any) => { if (!currentAttempt) { - throw new Error("No exam attempt started"); + console.warn("No exam attempt started, redirecting to /unit"); + router.push("/unit"); + return; } setCurrentAttemptState((prev) => { @@ -138,7 +143,9 @@ export const ExamProvider: React.FC<{ children: ReactNode }> = ({ const setApiResponse = (response: any) => { if (!currentAttempt) { - throw new Error("No exam attempt started"); + console.warn("No exam attempt started, redirecting to /unit"); + router.push("/unit"); + return; } setCurrentAttemptState((prev) => { @@ -150,9 +157,11 @@ export const ExamProvider: React.FC<{ children: ReactNode }> = ({ }); }; - const submitExam = (): ExamAttempt => { + const submitExam = (): ExamAttempt | null => { 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) @@ -209,7 +218,7 @@ export const ExamProvider: React.FC<{ children: ReactNode }> = ({ const isExamStarted = () => !!currentExam && !!currentAttempt; const isExamCompleted = (): boolean => { - if (!isHydrated) return false; // ⛔ wait for hydration + if (!isHydrated) return false; // wait for hydration return currentAttempt !== null && currentAttempt.endTime !== undefined; }; @@ -246,12 +255,18 @@ export const useExam = (): ExamContextType => { return context; }; -// Hook for exam results (only when exam is completed) -export const useExamResults = (): ExamAttempt => { - const { currentAttempt, isExamCompleted } = useExam(); +// Hook for exam results (only when exam is completed) - now returns null instead of throwing +export const useExamResults = (): ExamAttempt | null => { + 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) { - throw new Error("No completed exam attempt found"); + return null; } return currentAttempt; diff --git a/lib/resultViews.tsx b/lib/resultViews.tsx new file mode 100644 index 0000000..893e222 --- /dev/null +++ b/lib/resultViews.tsx @@ -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: ( +
+
+
+ Accuracy Rate: +
+
+ accuracy +

+ {examResults + ? ( + (examResults.score / examResults.totalQuestions) * + 100 + ).toFixed(1) + : "0"} + % +

+
+
+
+ ), + }, + { + id: 2, + content: ( +
+
+
+ Error Rate: +
+
+ error +

+ {examResults + ? ( + ((examResults.totalQuestions - examResults.score) / + examResults.totalQuestions) * + 100 + ).toFixed(1) + : "0"} + % +

+
+
+
+ ), + }, + { + id: 3, + content: ( +
+
+
+ Attempt Rate: +
+
+ attempt +

+ {examResults + ? ( + (examResults.answers.length / examResults.totalQuestions) * + 100 + ).toFixed(1) + : "0"} + % +

+
+
+
+ ), + }, +]; diff --git a/types/exam.d.ts b/types/exam.d.ts index 6060c42..d387567 100644 --- a/types/exam.d.ts +++ b/types/exam.d.ts @@ -1,8 +1,10 @@ export interface Question { id: string; text: string; - options?: string[]; - type: "multiple-choice" | "text" | "boolean"; + options?: Record; + type: "multiple-choice" | "text" | "boolean" | undefined; + correctAnswer: string | undefined; + solution?: string | undefined; } export interface Exam {