feat(nav): add flow-guarding for exam result screens

This commit is contained in:
shafin-r
2025-09-01 17:53:44 +06:00
parent 5fd76bc0ec
commit 3b2488054c
5 changed files with 123 additions and 20 deletions

View File

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

View File

@ -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}`

View File

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

View File

@ -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) => {