fix(nav): fix exam flow navigation

chore(zustand): refactor auth code for zustand store
This commit is contained in:
shafin-r
2025-09-09 20:45:30 +06:00
parent c3ead879ad
commit 108d34988d
11 changed files with 172 additions and 126 deletions

View File

@ -6,7 +6,6 @@ import { useRouter } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
import BackgroundWrapper from "@/components/BackgroundWrapper"; import BackgroundWrapper from "@/components/BackgroundWrapper";
import FormField from "@/components/FormField"; import FormField from "@/components/FormField";
import { login } from "@/lib/auth";
import DestructibleAlert from "@/components/DestructibleAlert"; import DestructibleAlert from "@/components/DestructibleAlert";
import { LoginForm } from "@/types/auth"; import { LoginForm } from "@/types/auth";
import { CircleAlert } from "lucide-react"; import { CircleAlert } from "lucide-react";
@ -14,7 +13,7 @@ import { useAuthStore } from "@/stores/authStore";
const LoginPage = () => { const LoginPage = () => {
const router = useRouter(); const router = useRouter();
const { setToken } = useAuthStore(); const { login } = useAuthStore();
const [form, setForm] = useState<LoginForm>({ const [form, setForm] = useState<LoginForm>({
identifier: "", identifier: "",
@ -28,7 +27,7 @@ const LoginPage = () => {
try { try {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
await login(form, setToken); await login(form);
router.replace("/home"); router.replace("/home");
} catch (err: unknown) { } catch (err: unknown) {
console.error(err); console.error(err);

View File

@ -4,8 +4,6 @@ import { useState } from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { register } from "@/lib/auth";
import BackgroundWrapper from "@/components/BackgroundWrapper"; import BackgroundWrapper from "@/components/BackgroundWrapper";
import FormField from "@/components/FormField"; import FormField from "@/components/FormField";
import DestructibleAlert from "@/components/DestructibleAlert"; import DestructibleAlert from "@/components/DestructibleAlert";
@ -27,7 +25,7 @@ interface CustomError extends Error {
} }
export default function RegisterPage() { export default function RegisterPage() {
const { setToken } = useAuthStore(); const { register } = useAuthStore();
const router = useRouter(); const router = useRouter();
const [form, setForm] = useState<RegisterForm>({ const [form, setForm] = useState<RegisterForm>({
full_name: "", full_name: "",
@ -87,7 +85,7 @@ export default function RegisterPage() {
} }
try { try {
await register(form, setToken); await register(form);
router.replace("/login"); router.replace("/login");
} catch (err: unknown) { } catch (err: unknown) {
setError(formatError(err)); setError(formatError(err));

View File

@ -10,7 +10,6 @@ import { useTimerStore } from "@/stores/timerStore";
export default function ExamPage() { export default function ExamPage() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const test_id = searchParams.get("test_id") || ""; const test_id = searchParams.get("test_id") || "";
const type = searchParams.get("type") || ""; const type = searchParams.get("type") || "";
@ -23,55 +22,46 @@ export default function ExamPage() {
// Start exam + timer automatically // Start exam + timer automatically
useEffect(() => { useEffect(() => {
if (type && test_id) { if (!type || !test_id) return;
startExam(type, test_id).then((fetchedTest) => {
if (fetchedTest?.metadata.time_limit_minutes) { const initExam = async () => {
setStatus("in-progress"); // ✅ make sure exam status is set here const fetchedTest = await startExam(type, test_id);
resetTimer(fetchedTest.metadata.time_limit_minutes * 60, () => {
// Timer ended → auto-submit if (!fetchedTest) return;
setStatus("finished");
stopTimer(); setStatus("in-progress");
submitExam(type);
router.replace(`/exam/results`); 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, type,
test_id, test_id,
startExam, startExam,
resetTimer, resetTimer,
stopTimer,
submitExam, submitExam,
router, router,
setStatus, setStatus,
stopTimer,
]); ]);
// useEffect(() => { if (isSubmitting) {
// const handlePopState = (event: PopStateEvent) => { return (
// if (status === "in-progress") { <div className="min-h-screen bg-gray-50 flex items-center justify-center">
// const confirmExit = window.confirm( <p className="text-lg font-medium text-gray-900">Submitting exam...</p>
// "Are you sure you want to quit the exam?" </div>
// ); );
}
// 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 (!test) { if (!test) {
return ( return (
@ -85,12 +75,18 @@ export default function ExamPage() {
} }
const handleSubmitExam = async (type: string) => { const handleSubmitExam = async (type: string) => {
setIsSubmitting(true);
stopTimer();
try { try {
setStatus("finished"); // ✅ mark exam finished const result = await submitExam(type); // throws if fails
stopTimer();
setIsSubmitting(true); if (!result) throw new Error("Submission failed");
await submitExam(type);
router.replace(`/exam/results`); // ✅ replace to prevent back nav router.replace("/exam/results"); // navigate
} catch (err) {
console.error("Submit exam failed:", err);
alert("Failed to submit exam. Please try again.");
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -137,6 +133,3 @@ export default function ExamPage() {
</div> </div>
); );
} }
function cancelExam() {
throw new Error("Function not implemented.");
}

View File

@ -13,7 +13,6 @@ import {
import DestructibleAlert from "@/components/DestructibleAlert"; import DestructibleAlert from "@/components/DestructibleAlert";
import BackgroundWrapper from "@/components/BackgroundWrapper"; import BackgroundWrapper from "@/components/BackgroundWrapper";
import { API_URL, getToken } from "@/lib/auth"; import { API_URL, getToken } from "@/lib/auth";
import { Test } from "@/types/exam";
import { Metadata } from "@/types/exam"; import { Metadata } from "@/types/exam";
import { useExamStore } from "@/stores/examStore"; import { useExamStore } from "@/stores/examStore";
@ -21,15 +20,9 @@ function PretestPageContent() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [examData, setExamData] = useState<Test>();
// Get params from URL search params // Get params from URL search params
const id = searchParams.get("test_id") || ""; const id = searchParams.get("test_id") || "";
const typeParam = searchParams.get("type"); const type = searchParams.get("type");
const type =
typeParam === "mock" || typeParam === "subject" || typeParam === "topic"
? typeParam
: null;
const [metadata, setMetadata] = useState<Metadata | null>(null); const [metadata, setMetadata] = useState<Metadata | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -56,10 +49,8 @@ function PretestPageContent() {
const data = await questionResponse.json(); const data = await questionResponse.json();
const fetchedMetadata: Metadata = data.metadata; const fetchedMetadata: Metadata = data.metadata;
const fetchedQuestions: Test = data.questions;
setMetadata(fetchedMetadata); setMetadata(fetchedMetadata);
setExamData(fetchedQuestions);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
setError(error instanceof Error ? error.message : "An error occurred"); setError(error instanceof Error ? error.message : "An error occurred");
@ -122,7 +113,7 @@ function PretestPageContent() {
} }
function handleStartExam() { function handleStartExam() {
if (!examData) return; if (!metadata) return;
setStatus("in-progress"); setStatus("in-progress");
router.push( router.push(
@ -135,7 +126,7 @@ function PretestPageContent() {
<div className="flex-1 overflow-y-auto mb-20"> <div className="flex-1 overflow-y-auto mb-20">
{metadata ? ( {metadata ? (
<div className="mx-10 mt-10 gap-6 pb-6 space-y-6"> <div className="mx-10 mt-10 gap-6 pb-6 space-y-6">
<button onClick={() => router.back()}> <button onClick={() => router.replace(`/categories/${type}s`)}>
<ArrowLeft size={30} color="black" /> <ArrowLeft size={30} color="black" />
</button> </button>

View File

@ -11,7 +11,6 @@ import { getResultViews } from "@/lib/gallery-views";
export default function ResultsPage() { export default function ResultsPage() {
const router = useRouter(); const router = useRouter();
const { result, clearResult, setStatus, status } = useExamStore(); const { result, clearResult, setStatus, status } = useExamStore();
useEffect(() => { useEffect(() => {
const handlePopState = () => { const handlePopState = () => {
if (status !== "finished") { if (status !== "finished") {
@ -34,9 +33,8 @@ export default function ResultsPage() {
} }
const handleBackToHome = () => { const handleBackToHome = () => {
setStatus("not-started"); // ✅ reset exam flow clearResult();
clearResult(); // ✅ clear stored results router.replace("/categories");
router.replace("/categories"); // ✅ prevent re-entry
}; };
const views = getResultViews(result); const views = getResultViews(result);

25
components/ExamGuard.tsx Normal file
View File

@ -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}</>;
}

View File

@ -32,7 +32,7 @@ const Header = ({
if (confirmed) { if (confirmed) {
stopTimer(); stopTimer();
cancelExam(); cancelExam();
router.push("/categories"); router.replace("/categories");
} }
}; };

View File

@ -1,12 +1,11 @@
import { useRouter, usePathname } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
import { useExamStore } from "@/stores/examStore"; import { useExamStore } from "@/stores/examStore";
import { useTimerStore } from "@/stores/timerStore"; import { useTimerStore } from "@/stores/timerStore";
export function useExamExitGuard(type: string) { export function useNavGuard(type: string) {
const { status, setStatus, cancelExam } = useExamStore(); const { status, setStatus, cancelExam } = useExamStore();
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const { stopTimer } = useTimerStore(); const { stopTimer } = useTimerStore();
// Guard page render: always redirect if status invalid // Guard page render: always redirect if status invalid

View File

@ -17,6 +17,15 @@ const setCookie = (name: string, value: string | null, days: number = 7) => {
} }
}; };
export const getToken = async (): Promise<string | null> => {
if (typeof window === "undefined") {
return null;
}
const match = document.cookie.match(/(?:^|;\s*)authToken=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
};
type SetTokenFn = (token: string) => void; type SetTokenFn = (token: string) => void;
// Optional: Create a custom error type to carry extra data // Optional: Create a custom error type to carry extra data
@ -69,27 +78,3 @@ export const register = async (
setCookie("authToken", data.token); setCookie("authToken", data.token);
setToken(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<string | null> => {
if (typeof window === "undefined") {
return null;
}
const match = document.cookie.match(/(?:^|;\s*)authToken=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
};

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { create } from "zustand"; import { create } from "zustand";
import { UserData } from "@/types/auth"; import { LoginForm, RegisterForm, UserData } from "@/types/auth";
import { API_URL } from "@/lib/auth"; import { API_URL } from "@/lib/auth";
// Cookie utilities // Cookie utilities
@ -32,12 +32,19 @@ const setCookie = (
} }
}; };
interface APIError extends Error {
response?: any;
}
interface AuthState { interface AuthState {
token: string | null; token: string | null;
isLoading: boolean; isLoading: boolean;
hydrated: boolean; hydrated: boolean;
user: UserData | null; user: UserData | null;
error: string | null;
login: (form: LoginForm) => Promise<void>;
register: (form: RegisterForm) => Promise<void>;
setToken: (token: string | null) => void; setToken: (token: string | null) => void;
fetchUser: () => Promise<void>; fetchUser: () => Promise<void>;
logout: () => void; logout: () => void;
@ -48,6 +55,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
token: null, token: null,
isLoading: true, isLoading: true,
hydrated: false, hydrated: false,
error: null,
user: null, user: null,
setToken: (newToken) => { setToken: (newToken) => {
@ -55,6 +63,61 @@ export const useAuthStore = create<AuthState>((set, get) => ({
setCookie("authToken", newToken); 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 () => { fetchUser: async () => {
const token = get().token; const token = get().token;
if (!token) return; if (!token) return;
@ -67,6 +130,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
if (!res.ok) throw new Error("Failed to fetch user info"); if (!res.ok) throw new Error("Failed to fetch user info");
const data: UserData = await res.json(); const data: UserData = await res.json();
console.log(data);
set({ user: data }); set({ user: data });
} catch (err) { } catch (err) {
console.error("Error fetching user:", err); console.error("Error fetching user:", err);

View File

@ -1,12 +1,10 @@
"use client"; "use client";
import { create } from "zustand"; 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 { API_URL, getToken } from "@/lib/auth";
import { ExamResult } from "@/types/exam"; import { ExamResult } from "@/types/exam";
// Result type (based on your API response)
type ExamStatus = "not-started" | "in-progress" | "finished"; type ExamStatus = "not-started" | "in-progress" | "finished";
interface ExamState { interface ExamState {
@ -69,35 +67,31 @@ 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 null; if (!test) throw new Error("No test to submit");
const token = await getToken(); const token = await getToken();
try { const { test_id, attempt_id } = test.metadata;
const { test_id, attempt_id } = test.metadata; const res = await fetch(
const res = await fetch( `${API_URL}/tests/${testType}/${test_id}/${attempt_id}`,
`${API_URL}/tests/${testType}/${test_id}/${attempt_id}`, {
{ method: "POST",
method: "POST", headers: {
headers: { "Content-Type": "application/json",
"Content-Type": "application/json", Authorization: `Bearer ${token}`,
Authorization: `Bearer ${token}`, },
}, body: JSON.stringify({ answers }),
body: JSON.stringify({ answers }), }
} );
);
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();
// save result, clear test+answers const result: ExamResult = await res.json();
set({ test: null, answers: [], result });
return result; // save result only
} catch (err) { set({ result });
console.error("Failed to submit exam. Reason:", err);
return null; return result;
}
}, },
// cancel exam // cancel exam