feat(test): add short answer input on questions

fix(test): fix navigation upon test completion
This commit is contained in:
shafin-r
2026-01-28 21:23:31 +06:00
parent 355ca0c0c4
commit 5df438f474
2 changed files with 84 additions and 64 deletions

View File

@ -16,7 +16,7 @@ import {
} from "lucide-react";
import { api } from "../../../utils/api";
import { useAuthStore } from "../../../stores/authStore";
import type { Option, PracticeSheet } from "../../../types/sheet";
import type { Option, PracticeSheet, Question } from "../../../types/sheet";
import { Button } from "../../../components/ui/button";
import { useSatExam } from "../../../stores/useSatExam";
import { useSatTimer } from "../../../hooks/useSatTimer";
@ -33,7 +33,9 @@ export const Test = () => {
const [practiceSheet, setPracticeSheet] = useState<PracticeSheet | null>(
null,
);
const [answers, setAnswers] = useState<SubmitAnswer[]>([]);
const [answer, setAnswer] = useState<string>("");
const [answers, setAnswers] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const { sheetId } = useParams<{ sheetId: string }>();
@ -47,7 +49,6 @@ export const Test = () => {
const currentQuestion = currentModule?.questions[questionIndex];
const resetExam = useSatExam((s) => s.resetExam);
const startSatExam = useSatExam((s) => s.startExam);
const nextQuestion = useSatExam((s) => s.nextQuestion);
const prevQuestion = useSatExam((s) => s.prevQuestion);
const finishExam = useSatExam((s) => s.finishExam);
@ -110,47 +111,57 @@ export const Test = () => {
};
const handleNext = async () => {
if (!currentQuestion || !selectedOption || !sessionId) return;
if (!currentQuestion || !sessionId) return;
const userAnswer = answers[currentQuestion.id] ?? "";
let answerText = "";
// ✅ MCQ case
if (currentQuestion.options?.length) {
const selected = currentQuestion.options.find(
(opt) => opt.id === selectedOption,
(opt) => opt.id === userAnswer,
);
answerText = selected?.text ?? "";
}
// ✅ Text input case
else {
answerText = userAnswer;
}
if (!selected) return;
const answerPayload: SubmitAnswer = {
const payload: SubmitAnswer = {
question_id: currentQuestion.id,
answer_text: selected.text,
answer_text: answerText, // ✅ empty string if skipped
time_spent_seconds: 3,
};
try {
setIsSubmitting(true);
await api.submitAnswer(token!, sessionId, answerPayload);
await api.submitAnswer(token!, sessionId, payload);
} catch (err) {
console.error("Failed to submit answer:", err);
} finally {
setIsSubmitting(false);
}
const isLastQuestion =
questionIndex === currentModule!.questions.length - 1;
// ✅ normal question flow
// ✅ Move to next question
if (!isLastQuestion) {
nextQuestion();
setIsSubmitting(false);
return;
}
// ✅ ask backend for next module
// ✅ Module finished → ask backend for next module
const next = await api.fetchNextModule(token!, sessionId);
if (next?.finished) {
if (next?.status === "COMPLETED") {
finishExam();
} else {
await loadSessionQuestions(sessionId);
// ✅ IMPORTANT: start break AFTER module loads
useSatExam.getState().startBreak();
}
setIsSubmitting(false);
};
useEffect(() => {
@ -173,30 +184,24 @@ export const Test = () => {
if (!user) return;
}, [sheetId]);
const [selectedOption, setSelectedOption] = useState<string | null>(null);
useEffect(() => {
setAnswer("");
}, [questionIndex, currentModule?.module_id]);
const isLastQuestion =
questionIndex === (currentModule?.questions.length ?? 0) - 1;
// const isLastQuestion =
// questionIndex === (currentModule?.questions.length ?? 0) - 1;
const isFirstQuestion = questionIndex === 0;
useEffect(() => {
setSelectedOption(null);
}, [questionIndex, currentModule?.module_id]);
const renderOptions = (options?: Option[]) => {
if (!options || !Array.isArray(options)) {
return <p className="text-gray-500 text-20xl">No options available.</p>;
}
const handleOptionClick = (option: Option) => {
setSelectedOption(option.id);
};
const renderAnswerInput = (question?: Question) => {
if (!question) return null;
// ✅ MCQ
if (question.options && question.options.length > 0) {
return (
<div className="flex flex-col gap-4">
{options.map((option, index) => {
const isSelected = selectedOption === option.id;
{question.options.map((option, index) => {
const isSelected = answer === option.id;
return (
<button
@ -206,7 +211,7 @@ export const Test = () => {
? "bg-linear-to-br from-purple-400 to-purple-500 text-white"
: ""
}`}
onClick={() => handleOptionClick(option)}
onClick={() => setAnswer(option.id)}
>
<span
className={`px-2 py-1 rounded-full ${
@ -223,6 +228,19 @@ export const Test = () => {
})}
</div>
);
}
// ✅ SHORT ANSWER (text input)
return (
<div className="flex flex-col gap-3 pt-4">
<textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="Type your answer here..."
className="w-full min-h-30 border rounded-xl px-4 py-3 text-lg font-satoshi focus:outline-none focus:ring-2 focus:ring-purple-400"
/>
</div>
);
};
switch (phase) {
@ -341,14 +359,14 @@ export const Test = () => {
<div className="border border-gray-300"></div>
<section
className={`px-10 ${currentQuestion?.context ? "" : "pt-26"}`}
className={`px-10 ${currentQuestion?.context ? "pt-26" : "pt-26"}`}
>
<p className="font-satoshi-medium text-xl">
{currentQuestion?.text}
</p>
</section>
<section className="overflow-y-auto px-10 pb-20">
{renderOptions(currentQuestion?.options)}
{renderAnswerInput(currentQuestion)}
</section>
<section className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-300 py-4 flex justify-evenly">
<button
@ -364,7 +382,7 @@ export const Test = () => {
</button>
<button
disabled={isSubmitting || !selectedOption}
disabled={isSubmitting}
onClick={handleNext}
className="px-8 border rounded-full py-3 font-satoshi-medium text-white bg-linear-to-br from-purple-400 to-purple-500 disabled:opacity-50"
>

View File

@ -6,6 +6,8 @@ interface CreatedBy {
export type ExamPhase = "IDLE" | "MODULE" | "BREAK" | "FINISHED";
export type QuestionType = "MCQ" | "TEXT" | "SHORT_ANSWER";
export interface Subject {
name: string;
section: string;