generated from muhtadeetaron/nextjs-template
feat(nav): add flow-guarding for exam result screens
This commit is contained in:
@ -1,22 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useCallback, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import Header from "@/components/Header";
|
||||
import QuestionItem from "@/components/QuestionItem";
|
||||
import BackgroundWrapper from "@/components/BackgroundWrapper";
|
||||
import { useExamStore } from "@/stores/examStore";
|
||||
import { useTimerStore } from "@/stores/timerStore";
|
||||
import { useExamExitGuard } from "@/hooks/useExamExitGuard";
|
||||
|
||||
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") || "";
|
||||
|
||||
const { test, answers, startExam, setAnswer, submitExam, cancelExam } =
|
||||
useExamStore();
|
||||
const {
|
||||
setStatus,
|
||||
test,
|
||||
answers,
|
||||
startExam,
|
||||
setAnswer,
|
||||
submitExam,
|
||||
cancelExam,
|
||||
status,
|
||||
} = useExamStore();
|
||||
const { resetTimer, stopTimer } = useTimerStore();
|
||||
const { showExitDialog } = useExamExitGuard(type);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
@ -25,24 +36,52 @@ export default function ExamPage() {
|
||||
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 exam
|
||||
// Timer ended → auto-submit
|
||||
setStatus("finished");
|
||||
stopTimer();
|
||||
submitExam(type);
|
||||
router.push(`/exam/results`);
|
||||
router.replace(`/exam/results`);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [type, test_id, startExam, resetTimer, submitExam, router]);
|
||||
}, [
|
||||
type,
|
||||
test_id,
|
||||
startExam,
|
||||
resetTimer,
|
||||
submitExam,
|
||||
router,
|
||||
setStatus,
|
||||
stopTimer,
|
||||
]);
|
||||
|
||||
const showExitDialog = useCallback(() => {
|
||||
if (window.confirm("Are you sure you want to quit the exam?")) {
|
||||
stopTimer();
|
||||
cancelExam();
|
||||
router.push(`/categories/${type}s`);
|
||||
}
|
||||
}, [stopTimer, cancelExam, router, type]);
|
||||
// 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 (!test) {
|
||||
return (
|
||||
@ -57,10 +96,11 @@ export default function ExamPage() {
|
||||
|
||||
const handleSubmitExam = async (type: string) => {
|
||||
try {
|
||||
setStatus("finished"); // ✅ mark exam finished
|
||||
stopTimer();
|
||||
setIsSubmitting(true);
|
||||
await submitExam(type);
|
||||
router.push(`/exam/results`);
|
||||
router.replace(`/exam/results`); // ✅ replace to prevent back nav
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
@ -13,7 +13,6 @@ import {
|
||||
import DestructibleAlert from "@/components/DestructibleAlert";
|
||||
import BackgroundWrapper from "@/components/BackgroundWrapper";
|
||||
import { API_URL, getToken } from "@/lib/auth";
|
||||
import { useExam } from "@/context/ExamContext";
|
||||
import { Test } from "@/types/exam";
|
||||
import { Metadata } from "@/types/exam";
|
||||
import { useExamStore } from "@/stores/examStore";
|
||||
@ -21,7 +20,7 @@ import { useExamStore } from "@/stores/examStore";
|
||||
function PretestPageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { startExam } = useExamStore();
|
||||
|
||||
const [examData, setExamData] = useState<Test>();
|
||||
|
||||
// Get params from URL search params
|
||||
@ -35,6 +34,7 @@ function PretestPageContent() {
|
||||
const [metadata, setMetadata] = useState<Metadata | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
const { setStatus } = useExamStore();
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchQuestions() {
|
||||
@ -123,6 +123,7 @@ function PretestPageContent() {
|
||||
|
||||
function handleStartExam() {
|
||||
if (!examData) return;
|
||||
setStatus("in-progress");
|
||||
|
||||
router.push(
|
||||
`/exam/${id}?type=${type}&test_id=${metadata?.test_id}&attempt_id=${metadata?.attempt_id}`
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useExamStore } from "@/stores/examStore";
|
||||
import QuestionItem from "@/components/QuestionItem";
|
||||
@ -10,7 +10,20 @@ import { getResultViews } from "@/lib/gallery-views";
|
||||
|
||||
export default function ResultsPage() {
|
||||
const router = useRouter();
|
||||
const { result, clearResult } = useExamStore();
|
||||
const { result, clearResult, setStatus, status } = useExamStore();
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
if (status !== "finished") {
|
||||
router.replace(`/categories`);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => {
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
};
|
||||
}, [status, router, setStatus]);
|
||||
|
||||
if (!result) {
|
||||
return (
|
||||
@ -21,8 +34,9 @@ export default function ResultsPage() {
|
||||
}
|
||||
|
||||
const handleBackToHome = () => {
|
||||
clearResult();
|
||||
router.push("/categories");
|
||||
setStatus("not-started"); // ✅ reset exam flow
|
||||
clearResult(); // ✅ clear stored results
|
||||
router.replace("/categories"); // ✅ prevent re-entry
|
||||
};
|
||||
|
||||
const views = getResultViews(result);
|
||||
|
||||
42
hooks/useExamExitGuard.ts
Normal file
42
hooks/useExamExitGuard.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useExamStore } from "@/stores/examStore";
|
||||
import { useTimerStore } from "@/stores/timerStore";
|
||||
|
||||
export function useExamExitGuard(type: string) {
|
||||
const { status, setStatus, cancelExam } = useExamStore();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { stopTimer } = useTimerStore();
|
||||
|
||||
// Guard page render: always redirect if status invalid
|
||||
useEffect(() => {
|
||||
if (status !== "in-progress") {
|
||||
router.replace(`/categories/${type}s`);
|
||||
}
|
||||
}, [status, router, type]);
|
||||
|
||||
// Confirm before leaving page / tab close
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (status === "in-progress") {
|
||||
e.preventDefault();
|
||||
e.returnValue = ""; // shows native browser dialog
|
||||
}
|
||||
};
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [status]);
|
||||
|
||||
// Call this to quit exam manually
|
||||
const showExitDialog = () => {
|
||||
if (window.confirm("Are you sure you want to quit the exam?")) {
|
||||
setStatus("finished");
|
||||
stopTimer();
|
||||
cancelExam();
|
||||
router.replace(`/categories/${type}s`);
|
||||
}
|
||||
};
|
||||
|
||||
return { showExitDialog };
|
||||
}
|
||||
@ -7,10 +7,14 @@ import { ExamResult } from "@/types/exam";
|
||||
|
||||
// Result type (based on your API response)
|
||||
|
||||
type ExamStatus = "not-started" | "in-progress" | "finished";
|
||||
|
||||
interface ExamState {
|
||||
test: Test | null;
|
||||
answers: Answer[];
|
||||
result: ExamResult | null;
|
||||
status: ExamStatus;
|
||||
setStatus: (status: ExamStatus) => void;
|
||||
|
||||
startExam: (testType: string, testId: string) => Promise<Test | null>;
|
||||
setAnswer: (questionIndex: number, answer: Answer) => void;
|
||||
@ -23,6 +27,8 @@ export const useExamStore = create<ExamState>((set, get) => ({
|
||||
test: null,
|
||||
answers: [],
|
||||
result: null,
|
||||
status: "not-started",
|
||||
setStatus: (status) => set({ status }),
|
||||
|
||||
// start exam
|
||||
startExam: async (testType: string, testId: string) => {
|
||||
|
||||
Reference in New Issue
Block a user