generated from muhtadeetaron/nextjs-template
fix(nav): fix exam flow navigation
chore(zustand): refactor auth code for zustand store
This commit is contained in:
@ -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<LoginForm>({
|
||||
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);
|
||||
|
||||
@ -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<RegisterForm>({
|
||||
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));
|
||||
|
||||
@ -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 (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<p className="text-lg font-medium text-gray-900">Submitting exam...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function cancelExam() {
|
||||
throw new Error("Function not implemented.");
|
||||
}
|
||||
|
||||
@ -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<Test>();
|
||||
|
||||
// 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<Metadata | null>(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() {
|
||||
<div className="flex-1 overflow-y-auto mb-20">
|
||||
{metadata ? (
|
||||
<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" />
|
||||
</button>
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
25
components/ExamGuard.tsx
Normal file
25
components/ExamGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
@ -32,7 +32,7 @@ const Header = ({
|
||||
if (confirmed) {
|
||||
stopTimer();
|
||||
cancelExam();
|
||||
router.push("/categories");
|
||||
router.replace("/categories");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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
|
||||
33
lib/auth.ts
33
lib/auth.ts
@ -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;
|
||||
|
||||
// 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<string | null> => {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = document.cookie.match(/(?:^|;\s*)authToken=([^;]*)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
};
|
||||
|
||||
@ -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<void>;
|
||||
register: (form: RegisterForm) => Promise<void>;
|
||||
setToken: (token: string | null) => void;
|
||||
fetchUser: () => Promise<void>;
|
||||
logout: () => void;
|
||||
@ -48,6 +55,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
token: null,
|
||||
isLoading: true,
|
||||
hydrated: false,
|
||||
error: null,
|
||||
user: null,
|
||||
|
||||
setToken: (newToken) => {
|
||||
@ -55,6 +63,61 @@ export const useAuthStore = create<AuthState>((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<AuthState>((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);
|
||||
|
||||
@ -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<ExamState>((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
|
||||
|
||||
Reference in New Issue
Block a user