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