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"; "use client";
import React, { useEffect, useCallback, useState } from "react"; 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 Header from "@/components/Header";
import QuestionItem from "@/components/QuestionItem"; import QuestionItem from "@/components/QuestionItem";
import BackgroundWrapper from "@/components/BackgroundWrapper"; import BackgroundWrapper from "@/components/BackgroundWrapper";
import { useExamStore } from "@/stores/examStore"; import { useExamStore } from "@/stores/examStore";
import { useTimerStore } from "@/stores/timerStore"; import { useTimerStore } from "@/stores/timerStore";
import { useExamExitGuard } from "@/hooks/useExamExitGuard";
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") || "";
const { test, answers, startExam, setAnswer, submitExam, cancelExam } = const {
useExamStore(); setStatus,
test,
answers,
startExam,
setAnswer,
submitExam,
cancelExam,
status,
} = useExamStore();
const { resetTimer, stopTimer } = useTimerStore(); const { resetTimer, stopTimer } = useTimerStore();
const { showExitDialog } = useExamExitGuard(type);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@ -25,24 +36,52 @@ export default function ExamPage() {
if (type && test_id) { if (type && test_id) {
startExam(type, test_id).then((fetchedTest) => { startExam(type, test_id).then((fetchedTest) => {
if (fetchedTest?.metadata.time_limit_minutes) { if (fetchedTest?.metadata.time_limit_minutes) {
setStatus("in-progress"); // ✅ make sure exam status is set here
resetTimer(fetchedTest.metadata.time_limit_minutes * 60, () => { resetTimer(fetchedTest.metadata.time_limit_minutes * 60, () => {
// Timer ended → auto-submit exam // Timer ended → auto-submit
setStatus("finished");
stopTimer(); stopTimer();
submitExam(type); 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(() => { // useEffect(() => {
if (window.confirm("Are you sure you want to quit the exam?")) { // const handlePopState = (event: PopStateEvent) => {
stopTimer(); // if (status === "in-progress") {
cancelExam(); // const confirmExit = window.confirm(
router.push(`/categories/${type}s`); // "Are you sure you want to quit the exam?"
} // );
}, [stopTimer, cancelExam, router, type]);
// 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 (
@ -57,10 +96,11 @@ export default function ExamPage() {
const handleSubmitExam = async (type: string) => { const handleSubmitExam = async (type: string) => {
try { try {
setStatus("finished"); // ✅ mark exam finished
stopTimer(); stopTimer();
setIsSubmitting(true); setIsSubmitting(true);
await submitExam(type); await submitExam(type);
router.push(`/exam/results`); router.replace(`/exam/results`); // ✅ replace to prevent back nav
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }

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 { useExam } from "@/context/ExamContext";
import { Test } from "@/types/exam"; 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,7 +20,7 @@ import { useExamStore } from "@/stores/examStore";
function PretestPageContent() { function PretestPageContent() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { startExam } = useExamStore();
const [examData, setExamData] = useState<Test>(); const [examData, setExamData] = useState<Test>();
// Get params from URL search params // Get params from URL search params
@ -35,6 +34,7 @@ function PretestPageContent() {
const [metadata, setMetadata] = useState<Metadata | null>(null); const [metadata, setMetadata] = useState<Metadata | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const { setStatus } = useExamStore();
useEffect(() => { useEffect(() => {
async function fetchQuestions() { async function fetchQuestions() {
@ -123,6 +123,7 @@ function PretestPageContent() {
function handleStartExam() { function handleStartExam() {
if (!examData) return; if (!examData) return;
setStatus("in-progress");
router.push( router.push(
`/exam/${id}?type=${type}&test_id=${metadata?.test_id}&attempt_id=${metadata?.attempt_id}` `/exam/${id}?type=${type}&test_id=${metadata?.test_id}&attempt_id=${metadata?.attempt_id}`

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import React from "react"; import React, { useEffect } from "react";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { useExamStore } from "@/stores/examStore"; import { useExamStore } from "@/stores/examStore";
import QuestionItem from "@/components/QuestionItem"; import QuestionItem from "@/components/QuestionItem";
@ -10,7 +10,20 @@ import { getResultViews } from "@/lib/gallery-views";
export default function ResultsPage() { export default function ResultsPage() {
const router = useRouter(); 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) { if (!result) {
return ( return (
@ -21,8 +34,9 @@ export default function ResultsPage() {
} }
const handleBackToHome = () => { const handleBackToHome = () => {
clearResult(); setStatus("not-started"); // ✅ reset exam flow
router.push("/categories"); clearResult(); // ✅ clear stored results
router.replace("/categories"); // ✅ prevent re-entry
}; };
const views = getResultViews(result); 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) // Result type (based on your API response)
type ExamStatus = "not-started" | "in-progress" | "finished";
interface ExamState { interface ExamState {
test: Test | null; test: Test | null;
answers: Answer[]; answers: Answer[];
result: ExamResult | null; result: ExamResult | null;
status: ExamStatus;
setStatus: (status: ExamStatus) => void;
startExam: (testType: string, testId: string) => Promise<Test | null>; startExam: (testType: string, testId: string) => Promise<Test | null>;
setAnswer: (questionIndex: number, answer: Answer) => void; setAnswer: (questionIndex: number, answer: Answer) => void;
@ -23,6 +27,8 @@ export const useExamStore = create<ExamState>((set, get) => ({
test: null, test: null,
answers: [], answers: [],
result: null, result: null,
status: "not-started",
setStatus: (status) => set({ status }),
// start exam // start exam
startExam: async (testType: string, testId: string) => { startExam: async (testType: string, testId: string) => {