From 108d34988df4f807a323f85d3bb0a5780d31a9db Mon Sep 17 00:00:00 2001 From: shafin-r Date: Tue, 9 Sep 2025 20:45:30 +0600 Subject: [PATCH] fix(nav): fix exam flow navigation chore(zustand): refactor auth code for zustand store --- app/(auth)/login/page.tsx | 5 +- app/(auth)/register/page.tsx | 6 +- app/exam/exam-screen/page.tsx | 89 +++++++++---------- app/exam/pretest/page.tsx | 15 +--- app/exam/results/page.tsx | 6 +- components/ExamGuard.tsx | 25 ++++++ components/Header.tsx | 2 +- hooks/{useExamExitGuard.ts => useNavGuard.ts} | 5 +- lib/auth.ts | 33 ++----- stores/authStore.ts | 66 +++++++++++++- stores/examStore.ts | 46 +++++----- 11 files changed, 172 insertions(+), 126 deletions(-) create mode 100644 components/ExamGuard.tsx rename hooks/{useExamExitGuard.ts => useNavGuard.ts} (89%) diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index b16795a..ec35fc3 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -6,7 +6,6 @@ import { useRouter } from "next/navigation"; import Image from "next/image"; import BackgroundWrapper from "@/components/BackgroundWrapper"; import FormField from "@/components/FormField"; -import { login } from "@/lib/auth"; import DestructibleAlert from "@/components/DestructibleAlert"; import { LoginForm } from "@/types/auth"; import { CircleAlert } from "lucide-react"; @@ -14,7 +13,7 @@ import { useAuthStore } from "@/stores/authStore"; const LoginPage = () => { const router = useRouter(); - const { setToken } = useAuthStore(); + const { login } = useAuthStore(); const [form, setForm] = useState({ identifier: "", @@ -28,7 +27,7 @@ const LoginPage = () => { try { setIsLoading(true); setError(null); - await login(form, setToken); + await login(form); router.replace("/home"); } catch (err: unknown) { console.error(err); diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 2bf8ca7..627edc2 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -4,8 +4,6 @@ import { useState } from "react"; import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; - -import { register } from "@/lib/auth"; import BackgroundWrapper from "@/components/BackgroundWrapper"; import FormField from "@/components/FormField"; import DestructibleAlert from "@/components/DestructibleAlert"; @@ -27,7 +25,7 @@ interface CustomError extends Error { } export default function RegisterPage() { - const { setToken } = useAuthStore(); + const { register } = useAuthStore(); const router = useRouter(); const [form, setForm] = useState({ full_name: "", @@ -87,7 +85,7 @@ export default function RegisterPage() { } try { - await register(form, setToken); + await register(form); router.replace("/login"); } catch (err: unknown) { setError(formatError(err)); diff --git a/app/exam/exam-screen/page.tsx b/app/exam/exam-screen/page.tsx index 6efa89b..af80ce0 100644 --- a/app/exam/exam-screen/page.tsx +++ b/app/exam/exam-screen/page.tsx @@ -10,7 +10,6 @@ import { useTimerStore } from "@/stores/timerStore"; export default function ExamPage() { const router = useRouter(); - const pathname = usePathname(); const searchParams = useSearchParams(); const test_id = searchParams.get("test_id") || ""; const type = searchParams.get("type") || ""; @@ -23,55 +22,46 @@ export default function ExamPage() { // Start exam + timer automatically useEffect(() => { - if (type && test_id) { - startExam(type, test_id).then((fetchedTest) => { - if (fetchedTest?.metadata.time_limit_minutes) { - setStatus("in-progress"); // ✅ make sure exam status is set here - resetTimer(fetchedTest.metadata.time_limit_minutes * 60, () => { - // Timer ended → auto-submit - setStatus("finished"); - stopTimer(); - submitExam(type); - router.replace(`/exam/results`); - }); - } - }); - } + if (!type || !test_id) return; + + const initExam = async () => { + const fetchedTest = await startExam(type, test_id); + + if (!fetchedTest) return; + + setStatus("in-progress"); + + const timeLimit = fetchedTest.metadata.time_limit_minutes; + if (timeLimit) { + resetTimer(timeLimit * 60, async () => { + // Auto-submit when timer ends + stopTimer(); + setStatus("finished"); + await submitExam(type); + router.replace("/exam/results"); + }); + } + }; + + initExam(); }, [ type, test_id, startExam, resetTimer, + stopTimer, submitExam, router, setStatus, - stopTimer, ]); - // useEffect(() => { - // const handlePopState = (event: PopStateEvent) => { - // if (status === "in-progress") { - // const confirmExit = window.confirm( - // "Are you sure you want to quit the exam?" - // ); - - // if (confirmExit) { - // setStatus("finished"); - // stopTimer(); - // cancelExam(); - // router.replace(`/categories/${type}s`); - // } else { - // // User canceled → push them back to current page - // router.replace(pathname, { scroll: false }); - // } - // } else { - // router.replace(`/categories/${type}s`); - // } - // }; - - // window.addEventListener("popstate", handlePopState); - // return () => window.removeEventListener("popstate", handlePopState); - // }, [status, router, pathname, type, setStatus, stopTimer, cancelExam]); + if (isSubmitting) { + return ( +
+

Submitting exam...

+
+ ); + } if (!test) { return ( @@ -85,12 +75,18 @@ export default function ExamPage() { } const handleSubmitExam = async (type: string) => { + setIsSubmitting(true); + stopTimer(); + try { - setStatus("finished"); // ✅ mark exam finished - stopTimer(); - setIsSubmitting(true); - await submitExam(type); - router.replace(`/exam/results`); // ✅ replace to prevent back nav + const result = await submitExam(type); // throws if fails + + if (!result) throw new Error("Submission failed"); + + router.replace("/exam/results"); // navigate + } catch (err) { + console.error("Submit exam failed:", err); + alert("Failed to submit exam. Please try again."); } finally { setIsSubmitting(false); } @@ -137,6 +133,3 @@ export default function ExamPage() { ); } -function cancelExam() { - throw new Error("Function not implemented."); -} diff --git a/app/exam/pretest/page.tsx b/app/exam/pretest/page.tsx index 9b5b43d..51df88f 100644 --- a/app/exam/pretest/page.tsx +++ b/app/exam/pretest/page.tsx @@ -13,7 +13,6 @@ import { import DestructibleAlert from "@/components/DestructibleAlert"; import BackgroundWrapper from "@/components/BackgroundWrapper"; import { API_URL, getToken } from "@/lib/auth"; -import { Test } from "@/types/exam"; import { Metadata } from "@/types/exam"; import { useExamStore } from "@/stores/examStore"; @@ -21,15 +20,9 @@ function PretestPageContent() { const router = useRouter(); const searchParams = useSearchParams(); - const [examData, setExamData] = useState(); - // Get params from URL search params const id = searchParams.get("test_id") || ""; - const typeParam = searchParams.get("type"); - const type = - typeParam === "mock" || typeParam === "subject" || typeParam === "topic" - ? typeParam - : null; + const type = searchParams.get("type"); const [metadata, setMetadata] = useState(null); const [loading, setLoading] = useState(true); @@ -56,10 +49,8 @@ function PretestPageContent() { const data = await questionResponse.json(); const fetchedMetadata: Metadata = data.metadata; - const fetchedQuestions: Test = data.questions; setMetadata(fetchedMetadata); - setExamData(fetchedQuestions); } catch (error) { console.error(error); setError(error instanceof Error ? error.message : "An error occurred"); @@ -122,7 +113,7 @@ function PretestPageContent() { } function handleStartExam() { - if (!examData) return; + if (!metadata) return; setStatus("in-progress"); router.push( @@ -135,7 +126,7 @@ function PretestPageContent() {
{metadata ? (
- diff --git a/app/exam/results/page.tsx b/app/exam/results/page.tsx index c574f11..a2d7bc3 100644 --- a/app/exam/results/page.tsx +++ b/app/exam/results/page.tsx @@ -11,7 +11,6 @@ import { getResultViews } from "@/lib/gallery-views"; export default function ResultsPage() { const router = useRouter(); const { result, clearResult, setStatus, status } = useExamStore(); - useEffect(() => { const handlePopState = () => { if (status !== "finished") { @@ -34,9 +33,8 @@ export default function ResultsPage() { } const handleBackToHome = () => { - setStatus("not-started"); // ✅ reset exam flow - clearResult(); // ✅ clear stored results - router.replace("/categories"); // ✅ prevent re-entry + clearResult(); + router.replace("/categories"); }; const views = getResultViews(result); diff --git a/components/ExamGuard.tsx b/components/ExamGuard.tsx new file mode 100644 index 0000000..2479a8e --- /dev/null +++ b/components/ExamGuard.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useNavStore } from "@/stores/navStore"; +import { useRouter, usePathname } from "next/navigation"; +import { useEffect } from "react"; + +export function ExamGuard({ children }: { children: React.ReactNode }) { + const { examSubmitted, resultsDone } = useNavStore(); + const router = useRouter(); + const pathname = usePathname(); + + useEffect(() => { + // Prevent access to /exam after submission + if (pathname === "/exam/exam-screen" && examSubmitted) { + router.replace("/results"); + } + + // Prevent access to /results after done + if (pathname === "/exam/results" && resultsDone) { + router.replace("/categories"); // or wherever you want them to go + } + }, [pathname, examSubmitted, resultsDone, router]); + + return <>{children}; +} diff --git a/components/Header.tsx b/components/Header.tsx index 52821be..f4d3d0f 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -32,7 +32,7 @@ const Header = ({ if (confirmed) { stopTimer(); cancelExam(); - router.push("/categories"); + router.replace("/categories"); } }; diff --git a/hooks/useExamExitGuard.ts b/hooks/useNavGuard.ts similarity index 89% rename from hooks/useExamExitGuard.ts rename to hooks/useNavGuard.ts index bc40087..875fd21 100644 --- a/hooks/useExamExitGuard.ts +++ b/hooks/useNavGuard.ts @@ -1,12 +1,11 @@ -import { useRouter, usePathname } from "next/navigation"; +import { useRouter } from "next/navigation"; import { useEffect } from "react"; import { useExamStore } from "@/stores/examStore"; import { useTimerStore } from "@/stores/timerStore"; -export function useExamExitGuard(type: string) { +export function useNavGuard(type: string) { const { status, setStatus, cancelExam } = useExamStore(); const router = useRouter(); - const pathname = usePathname(); const { stopTimer } = useTimerStore(); // Guard page render: always redirect if status invalid diff --git a/lib/auth.ts b/lib/auth.ts index 9e99dd3..9faaa68 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -17,6 +17,15 @@ const setCookie = (name: string, value: string | null, days: number = 7) => { } }; +export const getToken = async (): Promise => { + if (typeof window === "undefined") { + return null; + } + + const match = document.cookie.match(/(?:^|;\s*)authToken=([^;]*)/); + return match ? decodeURIComponent(match[1]) : null; +}; + type SetTokenFn = (token: string) => void; // Optional: Create a custom error type to carry extra data @@ -69,27 +78,3 @@ export const register = async ( setCookie("authToken", data.token); setToken(data.token); }; - -export const getTokenFromCookie = (): string | null => { - if (typeof document === "undefined") return null; - - const value = `; ${document.cookie}`; - const parts = value.split(`; authToken=`); - if (parts.length === 2) { - return parts.pop()?.split(";").shift() || null; - } - return null; -}; - -export const clearAuthToken = (): void => { - setCookie("authToken", null); -}; - -export const getToken = async (): Promise => { - if (typeof window === "undefined") { - return null; - } - - const match = document.cookie.match(/(?:^|;\s*)authToken=([^;]*)/); - return match ? decodeURIComponent(match[1]) : null; -}; diff --git a/stores/authStore.ts b/stores/authStore.ts index 62a0901..2881c64 100644 --- a/stores/authStore.ts +++ b/stores/authStore.ts @@ -1,7 +1,7 @@ "use client"; import { create } from "zustand"; -import { UserData } from "@/types/auth"; +import { LoginForm, RegisterForm, UserData } from "@/types/auth"; import { API_URL } from "@/lib/auth"; // Cookie utilities @@ -32,12 +32,19 @@ const setCookie = ( } }; +interface APIError extends Error { + response?: any; +} + interface AuthState { token: string | null; isLoading: boolean; hydrated: boolean; user: UserData | null; + error: string | null; + login: (form: LoginForm) => Promise; + register: (form: RegisterForm) => Promise; setToken: (token: string | null) => void; fetchUser: () => Promise; logout: () => void; @@ -48,6 +55,7 @@ export const useAuthStore = create((set, get) => ({ token: null, isLoading: true, hydrated: false, + error: null, user: null, setToken: (newToken) => { @@ -55,6 +63,61 @@ export const useAuthStore = create((set, get) => ({ setCookie("authToken", newToken); }, + login: async (form: LoginForm) => { + set({ isLoading: true, error: null }); + try { + const response = await fetch(`${API_URL}/auth/login/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(form), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Login failed"); + } + + setCookie("authToken", data.token); + set({ token: data.token, isLoading: false }); + } catch (err: any) { + set({ + error: err?.message || "Login failed", + isLoading: false, + }); + throw err; + } + }, + register: async (form: RegisterForm) => { + set({ isLoading: true, error: null }); + try { + const response = await fetch(`${API_URL}/auth/register/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(form), + }); + + const data = await response.json(); + + if (!response.ok) { + const error: APIError = new Error( + data?.detail || "Registration failed" + ); + error.response = data; + throw error; + } + + setCookie("authToken", data.token); + set({ token: data.token, isLoading: false }); + } catch (err: any) { + set({ + error: err?.message || "Registration failed", + isLoading: false, + }); + throw err; + } + }, + fetchUser: async () => { const token = get().token; if (!token) return; @@ -67,6 +130,7 @@ export const useAuthStore = create((set, get) => ({ if (!res.ok) throw new Error("Failed to fetch user info"); const data: UserData = await res.json(); + console.log(data); set({ user: data }); } catch (err) { console.error("Error fetching user:", err); diff --git a/stores/examStore.ts b/stores/examStore.ts index bd60321..56308f9 100644 --- a/stores/examStore.ts +++ b/stores/examStore.ts @@ -1,12 +1,10 @@ "use client"; import { create } from "zustand"; -import { Test, Answer, Question } from "@/types/exam"; +import { Test, Answer } from "@/types/exam"; import { API_URL, getToken } from "@/lib/auth"; import { ExamResult } from "@/types/exam"; -// Result type (based on your API response) - type ExamStatus = "not-started" | "in-progress" | "finished"; interface ExamState { @@ -69,35 +67,31 @@ export const useExamStore = create((set, get) => ({ // submit exam submitExam: async (testType: string) => { const { test, answers } = get(); - if (!test) return null; + if (!test) throw new Error("No test to submit"); const token = await getToken(); - try { - const { test_id, attempt_id } = test.metadata; - const res = await fetch( - `${API_URL}/tests/${testType}/${test_id}/${attempt_id}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ answers }), - } - ); + const { test_id, attempt_id } = test.metadata; + const res = await fetch( + `${API_URL}/tests/${testType}/${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"); - const result: ExamResult = await res.json(); + if (!res.ok) throw new Error("Failed to submit exam"); - // save result, clear test+answers - set({ test: null, answers: [], result }); + const result: ExamResult = await res.json(); - return result; - } catch (err) { - console.error("Failed to submit exam. Reason:", err); - return null; - } + // save result only + set({ result }); + + return result; }, // cancel exam