feat(test): add short answer input on questions
fix(test): fix navigation upon test completion
This commit is contained in:
@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user