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";
|
"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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}`
|
||||||
|
|||||||
@ -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
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)
|
// 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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user