File "ExamKeyModal.tsx"
Full Path: /home/trinadezambia/public_html/student_panel/src/components/ui/pages/dashboard/ExamKeyModal.tsx
File size: 11.19 KB
MIME-type: text/x-java
Charset: utf-8
'use client';
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogClose,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { BiX } from 'react-icons/bi';
import { useAppDispatch, useAppSelector } from '@/components/store';
import {
clearSelectedExam,
selectSelectedExam,
OnlineExamData,
setExamQuestions,
} from '@/components/store/slices/examSlice';
import {
useOnlineExamQuestions,
useGetSchoolSettings,
} from '@/lib/api/student/queryHooks';
import { useTranslate } from '@/components/hooks/useTranslate';
import { isApiError } from '@/lib/api/student/responseHandler';
import { toastUtils } from '@/components/lib/toast';
/**
* Exam Key Modal Props Interface
* Defines the props for the exam key entry modal
*/
interface ExamKeyModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onStartExam: (examKey: string) => void;
}
/**
* Exam Key Modal Component
*
* Modal for entering exam key before starting an online exam.
* Uses Redux store for exam data and validation.
* Matches the design shown in the image with proper styling and layout.
*/
export default function ExamKeyModal({
open,
onOpenChange,
onStartExam,
}: ExamKeyModalProps) {
const translate = useTranslate();
// Redux dispatch and selectors
const dispatch = useAppDispatch();
const selectedExam = useAppSelector(
selectSelectedExam,
) as OnlineExamData | null;
// State for exam key input
const [examKey, setExamKey] = useState('');
// State for rules agreement checkbox
const [agreedToRules, setAgreedToRules] = useState(false);
// State for form validation
const [isSubmitting, setIsSubmitting] = useState(false);
// State for questions API request
const [questionsRequest, setQuestionsRequest] = useState<{
exam_id: number;
exam_key: string;
} | null>(null);
// Fetch exam questions using the hook
const {
data: questionsData,
isLoading: questionsLoading,
error: questionsError,
} = useOnlineExamQuestions(questionsRequest);
// Fetch school settings for exam rules
const { data: settingsData } = useGetSchoolSettings();
// Handle questions API response
useEffect(() => {
// Check if we have data and it's not an error response
if (questionsData && !questionsData.error) {
// Store questions in Redux
dispatch(
setExamQuestions({
questions: questionsData.data,
totalQuestions: questionsData.total_questions,
totalMarks: questionsData.total_marks,
examKey: examKey.trim(),
}),
);
// Call the onStartExam callback to navigate to exam page
onStartExam(examKey.trim());
// Save key to session storage for persistence during reload
if (typeof window !== 'undefined') {
sessionStorage.setItem(`exam_key_${selectedExam?.id}`, examKey.trim());
}
// Reset form and close modal
setExamKey('');
setAgreedToRules(false);
setQuestionsRequest(null);
onOpenChange(false);
} else if (questionsData && questionsData.error) {
// Handle API error response (e.g., "Exam not started yet")
// Use the API's error message if available, otherwise use generic message
const errorMessage =
questionsData.message || translate('failedToLoadExamQuestions');
// Show error in toast notification
toastUtils.error(errorMessage);
setIsSubmitting(false);
setQuestionsRequest(null);
} else if (questionsError) {
// Handle network/axios errors (connection issues, etc.)
// Try to extract error message from the error object
let errorMessage = translate('failedToLoadExamQuestions');
// Check if it's an ApiError (from axios interceptor) - it will have the API's message
if (isApiError(questionsError)) {
// TypeScript now knows this is ApiError, so message is available
errorMessage = questionsError.message;
} else if (questionsError instanceof Error) {
errorMessage = questionsError.message;
} else if (
questionsError &&
typeof questionsError === 'object' &&
'message' in questionsError
) {
errorMessage = String((questionsError as { message: string }).message);
}
// Show error in toast notification
toastUtils.error(errorMessage);
setIsSubmitting(false);
setQuestionsRequest(null);
}
}, [
questionsData,
questionsError,
dispatch,
examKey,
onStartExam,
onOpenChange,
translate,
selectedExam?.id,
]);
/**
* Handle form submission
* Validates the exam key using Redux store and starts the exam
*/
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!examKey.trim()) {
return;
}
if (!agreedToRules) {
return;
}
if (!selectedExam) {
console.error('No exam selected');
return;
}
setIsSubmitting(true);
try {
// For now, let's implement a simple validation
// In a real app, this would validate against the stored exam key
const providedKey = parseInt(examKey.trim());
// Check if the exam has a predefined exam key
if (selectedExam.exam_key && providedKey !== selectedExam.exam_key) {
const errorMessage = translate('invalidExamKey');
// Show error in toast notification
toastUtils.error(errorMessage);
setIsSubmitting(false);
return;
}
// Trigger questions API call
setQuestionsRequest({
exam_id: selectedExam.id,
exam_key: examKey.trim(),
});
} catch (error) {
console.error('Error starting exam:', error);
const errorMessage = translate('failedToStartExam');
// Show error in toast notification
toastUtils.error(errorMessage);
setIsSubmitting(false);
}
};
/**
* Handle modal close
* Reset form state and clear Redux state when closing
*/
const handleClose = () => {
setExamKey('');
setAgreedToRules(false);
dispatch(clearSelectedExam());
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent
className="sm:max-w-[600px] w-full max-w-[calc(100vw-2rem)] max-h-[calc(100vh-2rem)] p-0 overflow-hidden gap-0 flex flex-col"
showCloseButton={false}
>
{/* Header Section */}
<DialogHeader className="px-6 pt-6 pb-4 space-y-3 shrink-0">
<div className="flex items-start justify-between">
<div className="flex-1 pr-8">
<DialogTitle className="text-xl font-bold text-gray-900 text-left mb-3">
{translate('enterExamKey')}
</DialogTitle>
<DialogDescription className="text-sm font-normal text-gray-600 leading-relaxed text-left">
{translate('pleaseReadAndAgreeToExamRules')}
</DialogDescription>
</div>
<DialogClose
onClick={handleClose}
className="shrink-0 p-1.5 rounded-[4px] bg-(--light-primary-color) border border-gray-200 hover:bg-gray-100 transition-colors -mt-1"
>
<BiX className="w-5 h-5 text-gray-500" />
</DialogClose>
</div>
</DialogHeader>
{/* Divider */}
<div className="border-t border-gray-200 shrink-0"></div>
{/* Form Section - Scrollable */}
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
<div className="px-6 pt-5 overflow-y-auto flex-1">
{/* Exam Information Section */}
<div className="mb-6">
<div className="text-lg font-medium text-gray-900 mb-1">
{selectedExam?.subject_with_name || 'Science'}
</div>
<div className="text-sm font-normal text-gray-800">
{selectedExam?.title || 'Conceptual Understanding Test'}
</div>
</div>
{/* Exam Key Input */}
<div className="mb-6">
<Input
type="number"
placeholder={translate('enterExamKeyPlaceholder')}
value={examKey}
onChange={(e) => setExamKey(e.target.value)}
className="w-full h-12 px-4 shadow-none rounded-[4px] bg-(--light-primary-color) border-gray-200 text-[15px] placeholder:text-gray-400 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]"
required
/>
</div>
{/* Exam Rules Section */}
<div className="bg-(--light-primary-color) rounded-[4px] p-5 mb-5">
<h3 className="text-sm font-medium text-gray-900 mb-3">
{translate('examRules')}
</h3>
<div className="text-sm font-normal text-gray-700 leading-relaxed max-h-[150px] overflow-y-auto pr-2">
{settingsData?.data?.settings?.online_exam_terms_condition ? (
<div
dangerouslySetInnerHTML={{
__html:
settingsData.data.settings.online_exam_terms_condition.replace(
/(?:<p[^>]*>(?: |\s|<br\s*\/?>)*<\/p>|<br\s*\/?>|\s)+$/gi,
'',
),
}}
className="prose prose-sm max-w-none text-gray-700 space-y-2 [&>ul]:list-disc [&>ul]:pl-5 [&>ol]:list-decimal [&>ol]:pl-5 [&_p:empty]:hidden [&_p]:mb-0"
/>
) : (
<p></p>
)}
</div>
</div>
{/* Agreement Checkbox */}
<div className="flex items-start gap-3 mb-6">
<input
type="checkbox"
id="agree-rules"
checked={agreedToRules}
onChange={(e) => setAgreedToRules(e.target.checked)}
className="mt-0.5 h-[18px] w-[18px] text-blue-600 focus:ring-blue-500 border-gray-300 rounded cursor-pointer"
required
/>
<label
htmlFor="agree-rules"
className="text-sm font-normal text-gray-700 leading-relaxed cursor-pointer select-none"
>
{translate('iConfirmThatIUnderstandAndAcceptAllExamRules')}
</label>
</div>
</div>
{/* Submit Button - Fixed at bottom */}
<div className="px-6 pb-6 pt-4 shrink-0 border-t border-gray-100">
<Button
type="submit"
disabled={
!examKey.trim() ||
!agreedToRules ||
isSubmitting ||
questionsLoading
}
className="w-full h-12 bg-(--primary-color) hover:bg-(--primary-color)/90 text-white text-xl font-normal rounded-[4px] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting || questionsLoading
? translate('loadingQuestions')
: translate('submit')}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}