Made it so when the timer ends, the module ends

This commit is contained in:
Tiago Ribeiro
2023-07-27 13:59:00 +01:00
parent f5c3abb310
commit 77692d270e
16 changed files with 155 additions and 22 deletions

View File

@@ -1,4 +1,5 @@
import {FillBlanksExercise} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import {Fragment, useEffect, useState} from "react";
import reactStringReplace from "react-string-replace";
@@ -79,10 +80,17 @@ export default function FillBlanks({
const [currentBlankId, setCurrentBlankId] = useState<string>();
const [isDrawerShowing, setIsDrawerShowing] = useState(false);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
setTimeout(() => setIsDrawerShowing(!!currentBlankId), 100);
}, [currentBlankId]);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = answers.filter((x) => solutions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;

View File

@@ -3,16 +3,19 @@ import {MatchSentencesExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {Fragment, useState} from "react";
import {Fragment, useEffect, useState} from "react";
import LineTo from "react-lineto";
import {CommonProps} from ".";
import Button from "../Low/Button";
import Xarrow from "react-xarrows";
import useExamStore from "@/stores/examStore";
export default function MatchSentences({id, options, type, prompt, sentences, userSolutions, onNext, onBack}: MatchSentencesExercise & CommonProps) {
const [selectedQuestion, setSelectedQuestion] = useState<string>();
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const calculateScore = () => {
const total = sentences.length;
const correct = answers.filter((x) => sentences.find((y) => y.id === x.question)?.solution === x.option || false).length;
@@ -27,9 +30,11 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
setSelectedQuestion(undefined);
};
const getSentenceColor = (id: string) => {
return sentences.find((x) => x.id === id)?.color || "";
};
useEffect(() => {
console.log({hasExamEnded});
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
return (
<>
@@ -44,7 +49,7 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
</span>
<div className="flex gap-8 w-full items-center justify-between bg-mti-gray-smoke rounded-xl px-24 py-6">
<div className="flex flex-col gap-4">
{sentences.map(({sentence, id, color}) => (
{sentences.map(({sentence, id}) => (
<div key={`question_${id}`} className="flex items-center justify-end gap-2 cursor-pointer">
<span>{sentence} </span>
<button

View File

@@ -1,7 +1,8 @@
/* eslint-disable @next/next/no-img-element */
import {MultipleChoiceExercise, MultipleChoiceQuestion} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import clsx from "clsx";
import {useState} from "react";
import {useEffect, useState} from "react";
import {CommonProps} from ".";
import Button from "../Low/Button";
@@ -51,6 +52,13 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
const [answers, setAnswers] = useState<{question: string; option: string}[]>(userSolutions);
const [questionIndex, setQuestionIndex] = useState(0);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const onSelectOption = (option: string) => {
const question = questions[questionIndex];
setAnswers((prev) => [...prev.filter((x) => x.question !== question.id), {option, question: question.id}]);

View File

@@ -4,6 +4,7 @@ import {Fragment, useEffect, useState} from "react";
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs";
import dynamic from "next/dynamic";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
@@ -15,6 +16,20 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack
const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>();
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (hasExamEnded) {
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
useEffect(() => {
let recordingInterval: NodeJS.Timer | undefined = undefined;
if (isRecording) {

View File

@@ -8,6 +8,7 @@ import reactStringReplace from "react-string-replace";
import {CommonProps} from ".";
import {toast} from "react-toastify";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
function Blank({
id,
@@ -48,6 +49,13 @@ function Blank({
export default function WriteBlanks({id, prompt, type, maxWords, solutions, userSolutions, text, onNext, onBack}: WriteBlanksExercise & CommonProps) {
const [answers, setAnswers] = useState<{id: string; solution: string}[]>(userSolutions);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: answers, score: calculateScore(), type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = answers.filter(

View File

@@ -1,20 +1,24 @@
/* eslint-disable @next/next/no-img-element */
import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles";
import {WritingExercise} from "@/interfaces/exam";
import {mdiArrowLeft, mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import {CommonProps} from ".";
import {Fragment, useEffect, useState} from "react";
import {toast} from "react-toastify";
import Button from "../Low/Button";
import {Dialog, Transition} from "@headlessui/react";
import useExamStore from "@/stores/examStore";
export default function Writing({id, prompt, info, type, wordCounter, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [inputText, setInputText] = useState(userSolutions.length === 1 ? userSolutions[0].solution : "");
const [isSubmitEnabled, setIsSubmitEnabled] = useState(false);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
useEffect(() => {
const words = inputText.split(" ").filter((x) => x !== "");

View File

@@ -1,4 +1,5 @@
import {Module} from "@/interfaces";
import useExamStore from "@/stores/examStore";
import {moduleLabels} from "@/utils/moduleUtils";
import {ReactNode, useEffect, useState} from "react";
import {BsBook, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
@@ -15,6 +16,7 @@ interface Props {
export default function ModuleTitle({minTimer, module, label, exerciseIndex, totalExercises, disableTimer = false}: Props) {
const [timer, setTimer] = useState(minTimer * 60);
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
useEffect(() => {
if (!disableTimer) {
@@ -26,6 +28,10 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
}
}, [disableTimer, minTimer]);
useEffect(() => {
if (timer <= 0) setHasExamEnded(true);
}, [setHasExamEnded, timer]);
const moduleIcon: {[key in Module]: ReactNode} = {
reading: <BsBook className="text-ielts-reading w-6 h-6" />,
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,

View File

@@ -1,5 +1,5 @@
import {ListeningExam, UserSolution} from "@/interfaces/exam";
import {useState} from "react";
import {useEffect, useState} from "react";
import Icon from "@mdi/react";
import {mdiArrowRight} from "@mdi/js";
import clsx from "clsx";
@@ -10,6 +10,8 @@ import ModuleTitle from "@/components/Medium/ModuleTitle";
import AudioPlayer from "@/components/Low/AudioPlayer";
import Button from "@/components/Low/Button";
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
interface Props {
exam: ListeningExam;
@@ -20,9 +22,11 @@ interface Props {
export default function Listening({exam, showSolutions = false, onFinish}: Props) {
const [exerciseIndex, setExerciseIndex] = useState(-1);
const [timesListened, setTimesListened] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const [showBlankModal, setShowBlankModal] = useState(false);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const confirmFinishModule = (keepGoing?: boolean) => {
if (!keepGoing) {
setShowBlankModal(false);
@@ -37,12 +41,12 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
}
if (exerciseIndex + 1 < exam.exercises.length) {
if (exerciseIndex + 1 < exam.exercises.length && !hasExamEnded) {
setExerciseIndex((prev) => prev + 1);
return;
}
if (!userSolutions.map((x) => x.score.missing).every((x) => x === 0)) {
if (!userSolutions.map((x) => x.score.missing).every((x) => x === 0) && !hasExamEnded) {
setShowBlankModal(true);
return;
}

View File

@@ -16,6 +16,8 @@ import ModuleTitle from "@/components/Medium/ModuleTitle";
import {Divider} from "primereact/divider";
import Button from "@/components/Low/Button";
import BlankQuestionsModal from "@/components/BlankQuestionsModal";
import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
interface Props {
exam: ReadingExam;
@@ -80,9 +82,11 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
export default function Reading({exam, showSolutions = false, onFinish}: Props) {
const [exerciseIndex, setExerciseIndex] = useState(-1);
const [showTextModal, setShowTextModal] = useState(false);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const [showBlankModal, setShowBlankModal] = useState(false);
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
const confirmFinishModule = (keepGoing?: boolean) => {
if (!keepGoing) {
setShowBlankModal(false);
@@ -97,16 +101,18 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
}
if (exerciseIndex + 1 < exam.exercises.length) {
if (exerciseIndex + 1 < exam.exercises.length && !hasExamEnded) {
setExerciseIndex((prev) => prev + 1);
return;
}
if (!userSolutions.map((x) => x.score.missing).every((x) => x === 0)) {
if (!userSolutions.map((x) => x.score.missing).every((x) => x === 0) && !hasExamEnded) {
setShowBlankModal(true);
return;
}
setHasExamEnded(false);
if (solution) {
onFinish(
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "reading", exam: exam.id})),

View File

@@ -3,6 +3,8 @@ import ModuleTitle from "@/components/Medium/ModuleTitle";
import {renderSolution} from "@/components/Solutions";
import {infoButtonStyle} from "@/constants/buttonStyles";
import {UserSolution, SpeakingExam} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
import {mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
@@ -17,7 +19,7 @@ interface Props {
export default function Speaking({exam, showSolutions = false, onFinish}: Props) {
const [exerciseIndex, setExerciseIndex] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const nextExercise = (solution?: UserSolution) => {
if (solution) {
@@ -29,6 +31,8 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
return;
}
if (exerciseIndex >= exam.exercises.length) return;
if (solution) {
onFinish(
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "speaking", exam: exam.id})),

View File

@@ -3,6 +3,8 @@ import ModuleTitle from "@/components/Medium/ModuleTitle";
import {renderSolution} from "@/components/Solutions";
import {infoButtonStyle} from "@/constants/buttonStyles";
import {UserSolution, WritingExam} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
import {mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
@@ -17,7 +19,7 @@ interface Props {
export default function Writing({exam, showSolutions = false, onFinish}: Props) {
const [exerciseIndex, setExerciseIndex] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>([]);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const nextExercise = (solution?: UserSolution) => {
if (solution) {
@@ -29,6 +31,8 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
return;
}
if (exerciseIndex >= exam.exercises.length) return;
if (solution) {
onFinish(
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "writing", exam: exam.id})),

View File

@@ -32,7 +32,15 @@ async function login(req: NextApiRequest, res: NextApiResponse) {
const userId = userCredentials.user.uid;
delete req.body.password;
const user = {...req.body, desiredLevels: DEFAULT_DESIRED_LEVELS, levels: DEFAULT_LEVELS, bio: "", isFirstLogin: true};
const user = {
...req.body,
desiredLevels: DEFAULT_DESIRED_LEVELS,
levels: DEFAULT_LEVELS,
bio: "",
isFirstLogin: true,
focus: "academic",
type: "student",
};
await setDoc(doc(db, "users", userId), user);

View File

@@ -61,6 +61,7 @@ export default function Page() {
const [userSolutions, setUserSolutions] = useExamStore((state) => [state.userSolutions, state.setUserSolutions]);
const [showSolutions, setShowSolutions] = useExamStore((state) => [state.showSolutions, state.setShowSolutions]);
const [selectedModules, setSelectedModules] = useExamStore((state) => [state.selectedModules, state.setSelectedModules]);
const setHasExamEnded = useExamStore((state) => state.setHasExamEnded);
const {user} = useUser({redirectTo: "/login"});

View File

@@ -201,6 +201,11 @@ export default function Page() {
const onFinish = (solutions: UserSolution[]) => {
const solutionIds = solutions.map((x) => x.exercise);
const solutionExams = solutions.map((x) => x.exam);
console.log(solutionExams, exam?.id, moduleIndex, selectedModules);
if (exam && !solutionExams.includes(exam.id)) return;
if (exam && (exam.module === "writing" || exam.module === "speaking") && solutions.length > 0 && !showSolutions) {
setHasBeenUploaded(true);

View File

@@ -1,13 +1,14 @@
import {Module} from "@/interfaces";
import {Exam, UserSolution} from "@/interfaces/exam";
import {Stat} from "@/interfaces/user";
import {create} from "zustand";
export interface ExamState {
exams: Exam[];
userSolutions: UserSolution[];
showSolutions: boolean;
hasExamEnded: boolean;
selectedModules: Module[];
setHasExamEnded: (hasExamEnded: boolean) => void;
setUserSolutions: (userSolutions: UserSolution[]) => void;
setExams: (exams: Exam[]) => void;
setShowSolutions: (showSolutions: boolean) => void;
@@ -20,6 +21,7 @@ export const initialState = {
userSolutions: [],
showSolutions: false,
selectedModules: [],
hasExamEnded: false,
};
const useExamStore = create<ExamState>((set) => ({
@@ -28,6 +30,7 @@ const useExamStore = create<ExamState>((set) => ({
setExams: (exams: Exam[]) => set(() => ({exams})),
setShowSolutions: (showSolutions: boolean) => set(() => ({showSolutions})),
setSelectedModules: (modules: Module[]) => set(() => ({selectedModules: modules})),
setHasExamEnded: (hasExamEnded: boolean) => set(() => ({hasExamEnded})),
reset: () => set(() => initialState),
}));

View File

@@ -1,5 +1,15 @@
import {Module} from "@/interfaces";
import {Exam, ReadingExam, ListeningExam, WritingExam, SpeakingExam} from "@/interfaces/exam";
import {
Exam,
ReadingExam,
ListeningExam,
WritingExam,
SpeakingExam,
Exercise,
UserSolution,
FillBlanksExercise,
MatchSentencesExercise,
} from "@/interfaces/exam";
import axios from "axios";
export const getExamById = async (module: Module, id: string): Promise<Exam | undefined> => {
@@ -21,3 +31,37 @@ export const getExamById = async (module: Module, id: string): Promise<Exam | un
return newExam as SpeakingExam;
}
};
export const defaultUserSolutions = (exercise: Exercise, exam: Exam): UserSolution => {
const defaultSettings = {
exam: exam.id,
exercise: exercise.id,
solutions: [],
module: exam.module,
type: exercise.type,
};
let total = 0;
switch (exercise.type) {
case "fillBlanks":
total = exercise.text.match(/({{\d+}})/g)?.length || 0;
return {...defaultSettings, score: {correct: 0, total, missing: total}};
case "matchSentences":
total = exercise.sentences.length;
return {...defaultSettings, score: {correct: 0, total, missing: total}};
case "multipleChoice":
total = exercise.questions.length;
return {...defaultSettings, score: {correct: 0, total, missing: total}};
case "writeBlanks":
total = exercise.text.match(/({{\d+}})/g)?.length || 0;
return {...defaultSettings, score: {correct: 0, total, missing: total}};
case "writing":
total = 1;
return {...defaultSettings, score: {correct: 0, total, missing: total}};
case "speaking":
total = 1;
return {...defaultSettings, score: {correct: 0, total, missing: total}};
default:
return {...defaultSettings, score: {correct: 0, total: 0, missing: 0}};
}
};