Navigation rework, added prompt edit to components that were missing

This commit is contained in:
Carlos-Mesquita
2024-11-25 16:50:46 +00:00
parent e9b7bd14cc
commit 114da173be
105 changed files with 3761 additions and 3728 deletions

View File

@@ -3,12 +3,11 @@ import ModuleTitle from "@/components/Medium/ModuleTitle";
import { moduleResultText } from "@/constants/ielts";
import { Module } from "@/interfaces";
import { User } from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
import useExamStore from "@/stores/exam";
import { calculateBandScore, getGradingLabel } from "@/utils/score";
import clsx from "clsx";
import Link from "next/link";
import { useRouter } from "next/router";
import { Fragment, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import {
BsArrowCounterclockwise,
BsBan,
@@ -56,9 +55,9 @@ export default function Finish({ user, scores, modules, information, solutions,
const [selectedModule, setSelectedModule] = useState(modules[0]);
const [selectedScore, setSelectedScore] = useState<Score>(scores.find((x) => x.module === modules[0])!);
const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false);
const {selectedModules, exams, dispatch} = useExamStore((s) => s);
const aiUsage = Math.round(ai_usage(solutions) * 100);
const exams = useExamStore((state) => state.exams);
const { gradingSystem } = useGradingSystem();
const router = useRouter()
@@ -112,6 +111,11 @@ export default function Finish({ user, scores, modules, information, solutions,
return <span className="text-3xl font-bold">{level}</span>;
};
const handlePlayAgain = () => {
dispatch({type: "INIT_EXAM", payload: {exams, modules: selectedModules}})
router.push(destination || "/exam")
}
return (
<>
<Modal title="Extra Information" isOpen={isExtraInformationOpen} onClose={() => setIsExtraInformationOpen(false)}>
@@ -135,6 +139,7 @@ export default function Finish({ user, scores, modules, information, solutions,
totalExercises={getTotalExercises()}
exerciseIndex={getTotalExercises()}
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
preview={false}
disableTimer
/>
<div className="flex gap-4 self-start w-full">
@@ -289,7 +294,7 @@ export default function Finish({ user, scores, modules, information, solutions,
<div className="flex gap-8">
<div className="flex w-fit cursor-pointer flex-col items-center gap-1">
<button
onClick={() => router.push(destination || "/exam")}
onClick={handlePlayAgain}
disabled={!!assignment}
className="bg-mti-purple-light hover:bg-mti-purple flex h-11 w-11 items-center justify-center rounded-full transition duration-300 ease-in-out">
<BsArrowCounterclockwise className="h-7 w-7 text-white" />

View File

@@ -5,11 +5,10 @@ import ModuleTitle from "@/components/Medium/ModuleTitle";
import { renderSolution } from "@/components/Solutions";
import { Module } from "@/interfaces";
import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, UserSolution } from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import { usePersistentExamStore } from "@/stores/examStore";
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import { countExercises } from "@/utils/moduleUtils";
import clsx from "clsx";
import { use, useEffect, useMemo, useState } from "react";
import { use, useCallback, useEffect, useMemo, useRef, useState } from "react";
import TextComponent from "./TextComponent";
import PartDivider from "../Navigation/SectionDivider";
import Timer from "@/components/Medium/Timer";
@@ -19,39 +18,44 @@ import Modal from "@/components/Modal";
import { typeCheckWordsMC } from "@/utils/type.check";
import SectionNavbar from "../Navigation/SectionNavbar";
import AudioPlayer from "@/components/Low/AudioPlayer";
import { ExamProps } from "../types";
import {answeredEveryQuestionInPart} from "../utils/answeredEveryQuestion";
import useExamTimer from "@/hooks/useExamTimer";
import ProgressButtons from "../components/ProgressButtons";
interface Props {
exam: LevelExam;
showSolutions?: boolean;
onFinish: (userSolutions: UserSolution[]) => void;
preview?: boolean;
partDividers?: boolean;
}
export default function Level({ exam, showSolutions = false, onFinish, preview = false }: Props) {
const Level: React.FC<ExamProps<LevelExam>> = ({ exam, showSolutions = false, preview = false }) => {
const levelBgColor = "bg-ielts-level-light";
const updateTimers = useExamTimer(exam.module, preview || showSolutions);
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
const {
userSolutions,
hasExamEnded,
partIndex,
exerciseIndex,
questionIndex,
shuffles,
currentSolution,
setTimeIsUp,
setBgColor,
setUserSolutions,
setHasExamEnded,
setPartIndex,
setExerciseIndex,
setQuestionIndex,
setShuffles,
setCurrentSolution
flags,
timeSpentCurrentModule,
dispatch,
} = !preview ? examState : persistentExamState;
const { finalizeModule, timeIsUp } = flags;
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
// In case client want to switch back
const textRenderDisabled = true;
@@ -61,8 +65,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
const [continueAnyways, setContinueAnyways] = useState(false);
const [textRender, setTextRender] = useState(false);
const [changedPrompt, setChangedPrompt] = useState(false);
const [nextExerciseCalled, setNextExerciseCalled] = useState(false);
const [currentSolutionSet, setCurrentSolutionSet] = useState(false);
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : [0]));
@@ -72,8 +74,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
type: "blankQuestions",
onClose: function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
});
const [currentExercise, setCurrentExercise] = useState<Exercise | undefined>(undefined);
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions);
const [startNow, setStartNow] = useState<boolean>(!showSolutions);
@@ -86,12 +86,10 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exerciseIndex]);
useEffect(() => {
if (currentExercise === undefined && partIndex === 0 && exerciseIndex === 0) {
setCurrentExercise(exam.parts[0].exercises[0]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentExercise, partIndex, exerciseIndex]);
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
userSolutionRef.current = updateSolution;
setSolutionWasUpdated(true);
}, []);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
@@ -99,23 +97,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
const [contextWordLines, setContextWordLines] = useState<number[] | undefined>(undefined);
const [totalLines, setTotalLines] = useState<number>(0);
const [showSolutionsSave, setShowSolutionsSave] = useState(showSolutions ? userSolutions.filter((x) => x.module === "level") : undefined)
useEffect(() => {
if (typeof currentSolution !== "undefined") {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== currentSolution.exercise), { ...currentSolution, module: "level" as Module, exam: exam.id, shuffleMaps: exam.shuffle ? [...shuffles.find((x) => x.exerciseID == currentExercise?.id)?.shuffles!] : [] }]);
setCurrentSolutionSet(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSolution, exam.id, exam.shuffle, shuffles, currentExercise])
useEffect(() => {
if (typeof currentSolution !== "undefined") {
setCurrentSolution(undefined);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSolution]);
useEffect(() => {
if (showSolutions) {
const solutionShuffles = userSolutions.map(solution => ({
@@ -127,42 +108,53 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const getExercise = () => {
let exercise = exam.parts[partIndex]?.exercises[exerciseIndex];
const currentExercise = useMemo<Exercise>(() => {
let exercise = exam.parts[partIndex].exercises[exerciseIndex];
exercise = {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise == exercise.id)?.solutions || [],
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
};
exercise = shuffleExamExercise(exam.shuffle, exercise, showSolutions, userSolutions, shuffles, setShuffles);
return exercise;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [partIndex, exerciseIndex]);
useEffect(() => {
setCurrentExercise(getExercise());
if (solutionWasUpdated && userSolutionRef.current) {
const solution = userSolutionRef.current();
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "level" as Module, exam: exam.id, shuffleMaps: exam.shuffle ? [...shuffles.find((x) => x.exerciseID == currentExercise?.id)?.shuffles!] : [] }]);
setSolutionWasUpdated(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [partIndex, exerciseIndex, questionIndex]);
}, [solutionWasUpdated]);
const next = () => {
setNextExerciseCalled(true);
}
useEffect(() => {
if (finalizeModule || timeIsUp) {
updateTimers();
if (timeIsUp) setTimeIsUp(false);
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [finalizeModule, timeIsUp])
const nextExercise = () => {
scrollToTop();
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length) {
setExerciseIndex(exerciseIndex + 1);
setCurrentSolutionSet(false);
return;
}
if (partIndex + 1 === exam.parts.length && !hasExamEnded && !showQuestionsModal && !showSolutions && !continueAnyways) {
if (partIndex + 1 === exam.parts.length && !showQuestionsModal && !showSolutions && !continueAnyways) {
modalKwargs();
setShowQuestionsModal(true);
return;
}
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
if (!answeredEveryQuestion(partIndex) && !continueAnyways && !showSolutions && !seenParts.has(partIndex + 1)) {
if (partIndex + 1 < exam.parts.length) {
if (!answeredEveryQuestionInPart(exam, partIndex, userSolutions) && !continueAnyways && !showSolutions && !seenParts.has(partIndex + 1)) {
modalKwargs();
setShowQuestionsModal(true);
return;
@@ -181,7 +173,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
setPartIndex(partIndex + 1);
setExerciseIndex(0);
setQuestionIndex(0);
setCurrentSolutionSet(false);
return;
}
@@ -190,24 +181,14 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
setShowQuestionsModal(true);
}
setHasExamEnded(false);
setCurrentSolutionSet(false);
if (typeof showSolutionsSave !== "undefined") {
onFinish(showSolutionsSave);
} else {
onFinish(userSolutions);
}
if (!showSolutions) {
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: true } })
} else {
dispatch({ type: "FINALIZE_MODULE_SOLUTIONS"})
}
}
useEffect(() => {
if (nextExerciseCalled && currentSolutionSet) {
nextExercise();
setNextExerciseCalled(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nextExerciseCalled, currentSolutionSet])
const previousExercise = (solution?: UserSolution) => {
const previousExercise = () => {
scrollToTop();
if (exam.parts[partIndex].context && questionIndex === 0 && !textRender && !textRenderDisabled) {
@@ -353,25 +334,6 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
}
}
const answeredEveryQuestion = (partIndex: number) => {
return exam.parts[partIndex].exercises.every((exercise) => {
const userSolution = userSolutions.find(x => x.exercise === exercise.id);
switch (exercise.type) {
case 'multipleChoice':
return userSolution?.solutions.length === exercise.questions.length;
case 'fillBlanks':
return userSolution?.solutions.length === exercise.words.length;
case 'writeBlanks':
return userSolution?.solutions.length === exercise.solutions.length;
case 'matchSentences':
return userSolution?.solutions.length === exercise.sentences.length;
case 'trueFalse':
return userSolution?.solutions.length === exercise.questions.length;
}
return false;
});
}
useEffect(() => {
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
@@ -408,8 +370,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
useEffect(() => {
if (
exerciseIndex !== -1 && currentExercise &&
currentExercise.type === "multipleChoice" &&
currentExercise && currentExercise.type === "multipleChoice" &&
exam.parts[partIndex].context && contextWordLines
) {
if (contextWordLines.length > 0) {
@@ -446,7 +407,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
if (partIndex === exam.parts.length - 1) {
kwargs.type = "submit"
kwargs.unanswered = !exam.parts.every((_, partIndex) => answeredEveryQuestion(partIndex));
kwargs.unanswered = !exam.parts.every((_, partIndex) => answeredEveryQuestionInPart(exam, partIndex, userSolutions));
kwargs.onClose = function (x: boolean | undefined) { if (x) { setShowSubmissionModal(true); setShowQuestionsModal(false); } else { setShowQuestionsModal(false) } };
}
setQuestionModalKwargs(kwargs);
@@ -462,6 +423,7 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
runOnClick: setQuestionIndex
}
const progressButtons = <ProgressButtons handlePrevious={previousExercise} handleNext={nextExercise} />;
const memoizedRender = useMemo(() => {
setChangedPrompt(false);
@@ -473,8 +435,8 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
{exam.parts[partIndex]?.context && renderText()}
{exam.parts[partIndex]?.audio && renderAudioPlayer()}
{(showSolutions) ?
currentExercise && renderSolution(currentExercise, nextExercise, previousExercise) :
currentExercise && renderExercise(currentExercise, exam.id, next, previousExercise)
currentExercise && renderSolution(currentExercise, progressButtons, progressButtons) :
currentExercise && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)
}
</>
}
@@ -520,34 +482,24 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
onNext={() => { setShowPartDivider(false); setStartNow(false); setBgColor("bg-white"); setSeenParts(prev => new Set(prev).add(partIndex)); }}
/> : (
<>
{exam.parts[0].intro && (
<SectionNavbar
module="level"
sections={exam.parts}
sectionLabel="Part"
sectionIndex={partIndex}
setSectionIndex={setPartIndex}
onClick={
(index: number) => {
setExerciseIndex(0);
setQuestionIndex(0);
if (!seenParts.has(index)) {
setShowPartDivider(true);
setBgColor(levelBgColor);
setSeenParts(prev => new Set(prev).add(index));
}
}
} />
)}
<SectionNavbar
module="level"
sectionLabel="Part"
seenParts={seenParts}
setShowPartDivider={setShowPartDivider}
setSeenParts={setSeenParts}
preview={preview}
/>
<ModuleTitle
examLabel={exam.label}
partLabel={partLabel()}
minTimer={exam.minTimer}
minTimer={timer.current}
exerciseIndex={calculateExerciseIndex()}
module="level"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions}
showTimer={false}
preview={preview}
{...mcNavKwargs}
/>
<div
@@ -563,3 +515,5 @@ export default function Level({ exam, showSolutions = false, onFinish, preview =
</>
);
}
export default Level;

View File

@@ -1,287 +1,127 @@
import { ListeningExam, MultipleChoiceExercise, Script, UserSolution } from "@/interfaces/exam";
import { Fragment, useEffect, useState } from "react";
import { Exercise, ListeningExam, MultipleChoiceExercise, Script, UserSolution } from "@/interfaces/exam";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { renderExercise } from "@/components/Exercises";
import { renderSolution } from "@/components/Solutions";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import AudioPlayer from "@/components/Low/AudioPlayer";
import Button from "@/components/Low/Button";
import BlankQuestionsModal from "@/components/QuestionsModal";
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import PartDivider from "./Navigation/SectionDivider";
import { Dialog, Transition } from "@headlessui/react";
import { capitalize } from "lodash";
import { mapBy } from "@/utils";
import ScriptModal from "./components/ScriptModal";
import { ExamProps } from "./types";
import useExamTimer from "@/hooks/useExamTimer";
import useExamNavigation from "./Navigation/useExamNavigation";
import RenderAudioInstructionsPlayer from "./components/RenderAudioInstructionsPlayer";
import RenderAudioPlayer from "./components/RenderAudioPlayer";
import SectionNavbar from "./Navigation/SectionNavbar";
import ProgressButtons from "./components/ProgressButtons";
import { countExercises } from "@/utils/moduleUtils";
import { calculateExerciseIndex } from "./utils/calculateExerciseIndex";
interface Props {
exam: ListeningExam;
showSolutions?: boolean;
preview?: boolean;
onFinish: (userSolutions: UserSolution[]) => void;
}
function ScriptModal({ isOpen, script, onClose }: { isOpen: boolean; script: Script; onClose: () => void }) {
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<Dialog.Panel className="w-full relative max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
<div className="mt-2 overflow-auto mb-28">
<p className="text-sm">
{typeof script === "string" && script.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
{typeof script === "object" && script.map((line, index) => (
<span key={index}>
<b>{line.name} ({capitalize(line.gender)})</b>: {line.text}
<br />
<br />
</span>
))}
</p>
</div>
<div className="absolute bottom-8 right-8 max-w-[200px] self-end w-full">
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full" onClick={onClose}>
Close
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}
const INSTRUCTIONS_AUDIO_SRC =
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82";
export default function Listening({ exam, showSolutions = false, preview = false, onFinish }: Props) {
const listeningBgColor = "bg-ielts-listening-light";
const Listening: React.FC<ExamProps<ListeningExam>> = ({ exam, showSolutions = false, preview = false }) => {
const updateTimers = useExamTimer(exam.module, preview || showSolutions);
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
const [showTextModal, setShowTextModal] = useState(false);
const [timesListened, setTimesListened] = useState(0);
const [showBlankModal, setShowBlankModal] = useState(false);
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : []));
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && exam.parts[0].intro !== "");
const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
const {
hasExamEnded,
userSolutions,
exerciseIndex,
partIndex,
questionIndex: storeQuestionIndex,
setBgColor,
setUserSolutions,
setHasExamEnded,
setExerciseIndex,
setPartIndex,
setQuestionIndex: setStoreQuestionIndex
exerciseIndex, partIndex, assignment,
userSolutions, flags, timeSpentCurrentModule,
questionIndex,
setBgColor, setUserSolutions, setTimeIsUp,
dispatch
} = !preview ? examState : persistentExamState;
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
const { finalizeModule, timeIsUp } = flags;
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
const [isFirstTimeRender, setIsFirstTimeRender] = useState(partIndex === 0 && exerciseIndex == 0 && !showSolutions);
const {
nextExercise, previousExercise,
showPartDivider, setShowPartDivider,
seenParts, setSeenParts
} = useExamNavigation({ exam, module: "listening", showBlankModal, setShowBlankModal, showSolutions, preview, disableBetweenParts: true });
useEffect(() => {
if (!showSolutions && exam.parts[partIndex]?.intro !== undefined && exam.parts[partIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) {
setShowPartDivider(true);
setBgColor(listeningBgColor);
if (finalizeModule || timeIsUp) {
updateTimers();
if (timeIsUp) setTimeIsUp(false);
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [partIndex]);
}, [finalizeModule, timeIsUp])
useEffect(() => {
if (showSolutions) return setExerciseIndex(-1);
}, [setExerciseIndex, showSolutions]);
useEffect(() => {
if (partIndex === -1 && exam.variant === "partial") {
setPartIndex(0);
}
}, [partIndex, exam, setPartIndex]);
useEffect(() => {
const previousParts = exam.parts.filter((_, index) => index < partIndex);
let previousMultipleChoice = previousParts.flatMap((x) => x.exercises).filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
if (partIndex > -1 && exerciseIndex > -1) {
const previousPartExercises = exam.parts[partIndex].exercises.filter((_, index) => index < exerciseIndex);
const partMultipleChoice = previousPartExercises.filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
previousMultipleChoice = [...previousMultipleChoice, ...partMultipleChoice];
}
setMultipleChoicesDone(previousMultipleChoice.map((x) => ({ id: x.id, amount: x.questions.length - 1 })));
// eslint-disable-next-line react-hooks/exhaustive-deps
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
userSolutionRef.current = updateSolution;
setSolutionWasUpdated(true);
}, []);
useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex(exerciseIndex + 1);
if (solutionWasUpdated && userSolutionRef.current) {
const solution = userSolutionRef.current();
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "listening", exam: exam.id }]);
setSolutionWasUpdated(false);
}
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [solutionWasUpdated])
const currentExercise = useMemo<Exercise>(() => {
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
return {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [partIndex, exerciseIndex]);
const confirmFinishModule = (keepGoing?: boolean) => {
if (!keepGoing) {
setShowBlankModal(false);
return;
} else {
nextExercise(true);
setShowBlankModal(false);
}
onFinish(userSolutions);
};
const nextExercise = (solution?: UserSolution) => {
if (solution)
setUserSolutions([
...userSolutions.filter((x) => x.exercise !== solution.exercise),
{ ...solution, module: "listening", exam: exam.id }
]);
};
const memoizedExerciseIndex = useMemo(() =>
calculateExerciseIndex(exam, partIndex, exerciseIndex, questionIndex)
const previousExercise = (solution?: UserSolution) => { };
const nextPart = () => {
scrollToTop()
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
setPartIndex(partIndex + 1);
setExerciseIndex(0);
return;
}
if (!showSolutions && !hasExamEnded) {
const exercises = partIndex < exam.parts.length ? exam.parts[partIndex].exercises : []
const exerciseIDs = mapBy(exercises, 'id')
const hasMissing = userSolutions.filter(x => exerciseIDs.includes(x.exercise)).map(x => x.score.missing).some(x => x > 0)
if (hasMissing) return setShowBlankModal(true);
}
setHasExamEnded(false);
onFinish(userSolutions);
}
const renderPartExercises = () => {
const exercises = partIndex > -1 ? exam.parts[partIndex].exercises : []
const formattedExercises = exercises.map(exercise => ({
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
}))
return (
<div className="flex flex-col gap-4">
{formattedExercises.map(e => showSolutions
? renderSolution(e, nextExercise, previousExercise, undefined, true)
: renderExercise(e, exam.id, nextExercise, previousExercise, undefined, true))}
</div>
)
}
const renderAudioInstructionsPlayer = () => (
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
<div className="flex flex-col w-full gap-2">
<h4 className="text-xl font-semibold">Please listen to the instructions audio attentively.</h4>
</div>
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
<AudioPlayer key={partIndex} src={INSTRUCTIONS_AUDIO_SRC} color="listening" />
</div>
</div>
// eslint-disable-next-line react-hooks/exhaustive-deps
, [partIndex, exerciseIndex, questionIndex]
);
const renderAudioPlayer = () => (
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
{exam?.parts[partIndex]?.audio?.source ? (
<>
<div className="w-full items-start flex justify-between">
<div className="flex flex-col w-full gap-2">
<h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4>
<span className="text-base">
{(() => {
const audioRepeatTimes = exam?.parts[partIndex]?.audio?.repeatableTimes;
return audioRepeatTimes && audioRepeatTimes > 0
? `You will only be allowed to listen to the audio ${audioRepeatTimes - timesListened} time(s).`
: "You may listen to the audio as many times as you would like.";
})()}
</span>
</div>
{partIndex > -1 && !examState.assignment && !!exam.parts[partIndex].script && (
<Button
onClick={() => setShowTextModal(true)}
variant="outline"
color="gray"
className="w-full max-w-[200px]"
>
View Transcript
</Button>
)}
</div>
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
<AudioPlayer
key={partIndex}
src={exam?.parts[partIndex]?.audio?.source ?? ''}
color="listening"
onEnd={() => setTimesListened((prev) => prev + 1)}
disabled={exam?.parts[partIndex]?.audio?.repeatableTimes != null &&
timesListened === exam.parts[partIndex]?.audio?.repeatableTimes}
disablePause
/>
</div>
</>
) : (
<span>This section will be displayed the audio once it has been generated.</span>
)}
const handlePartDividerClick = () => {
setShowPartDivider(false);
setBgColor("bg-white");
setSeenParts((prev) => new Set(prev).add(partIndex));
if (isFirstTimeRender) setIsFirstTimeRender(false);
}
</div>
);
useEffect(() => {
setTimesListened(0);
}, [partIndex])
const progressButtons = () => (
<div className="flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={previousExercise}
className="max-w-[200px] w-full">
Back
</Button>
<Button
color="purple"
onClick={nextPart}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)
const progressButtons = useMemo(() =>
// Do not remove the ()=> in handle next
<ProgressButtons handlePrevious={previousExercise} handleNext={() => nextExercise()} />
, [nextExercise, previousExercise]);
return (
<>
@@ -292,73 +132,64 @@ export default function Listening({ exam, showSolutions = false, preview = false
defaultTitle="Listening exam"
section={exam.parts[partIndex]}
sectionIndex={partIndex}
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }}
onNext={handlePartDividerClick}
/> : (
<>
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
{partIndex > -1 && exam.parts[partIndex].script &&
{!isFirstTimeRender && exam.parts[partIndex].script &&
<ScriptModal script={exam.parts[partIndex].script!} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
}
<div className="flex flex-col h-full w-full gap-8 justify-between">
<ModuleTitle
exerciseIndex={partIndex + 1}
minTimer={exam.minTimer}
{exam.parts.length > 1 && <SectionNavbar
module="listening"
totalExercises={exam.parts.length}
sectionLabel="Section"
seenParts={seenParts}
setShowPartDivider={setShowPartDivider}
setSeenParts={setSeenParts}
preview={preview}
/>
}
<ModuleTitle
minTimer={timer.current}
module="listening"
exerciseIndex={memoizedExerciseIndex}
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions || preview}
indexLabel="Part"
indexLabel="Exercise"
preview={preview}
/>
{/* Audio Player for the Instructions */}
{partIndex === -1 && renderAudioInstructionsPlayer()}
{isFirstTimeRender && <RenderAudioInstructionsPlayer />}
{/* Part's audio player */}
{partIndex > -1 && renderAudioPlayer()}
{!isFirstTimeRender &&
<RenderAudioPlayer
audioSource={exam?.parts[partIndex]?.audio?.source}
repeatableTimes={exam?.parts[partIndex]?.audio?.repeatableTimes}
script={exam?.parts[partIndex]?.script}
assignment={assignment}
timesListened={timesListened}
setShowTextModal={setShowTextModal}
setTimesListened={setTimesListened}
/>}
{/* Exercise renderer */}
{exerciseIndex > -1 && partIndex > -1 && (
<>
{progressButtons()}
{renderPartExercises()}
{progressButtons()}
</>
)}
{!isFirstTimeRender && !showPartDivider && !showSolutions && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)}
{showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)}
</div>
{exerciseIndex === -1 && partIndex > -1 && exam.variant !== "partial" && (
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => {
if (partIndex === 0) return setPartIndex(-1);
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
setPartIndex(partIndex - 1);
}}
className="max-w-[200px] w-full">
Back
</Button>
<Button color="purple" onClick={() => setExerciseIndex(0)} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)}
{partIndex === -1 && exam.variant !== "partial" && (
<Button color="purple" onClick={() => setPartIndex(0)} className="max-w-[200px] self-end w-full justify-self-end">
Start now
</Button>
)}
{exerciseIndex === -1 && partIndex === 0 && exam.variant === "partial" && (
<Button color="purple" onClick={() => setExerciseIndex(0)} className="max-w-[200px] self-end w-full justify-self-end">
Start now
</Button>
)}
{((isFirstTimeRender) && !showPartDivider && !showSolutions) &&
<ProgressButtons
hidePrevious={partIndex == 0 && isFirstTimeRender}
nextLabel={isFirstTimeRender ? "Start now" : "Next Page"}
handlePrevious={previousExercise}
handleNext={() => isFirstTimeRender ? setIsFirstTimeRender(false) : nextExercise()} />
}
</>)
}
</>
);
}
export default Listening;

View File

@@ -1,26 +1,64 @@
import { Module } from "@/interfaces";
import { LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
import { ExerciseOnlyExam, LevelPart, ListeningPart, PartExam, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import { Tab, TabGroup, TabList } from "@headlessui/react";
import clsx from "clsx";
import React from "react";
import hasDivider from "../utils/hasDivider";
interface Props {
module: Module;
sections: LevelPart[] | ReadingPart[] | ListeningPart[] | WritingExercise[] | SpeakingExercise[];
sectionIndex: number;
sectionLabel: string;
setSectionIndex: (index: number) => void;
onClick: (index: number) => void;
seenParts: Set<number>;
setShowPartDivider: React.Dispatch<React.SetStateAction<boolean>>;
setSeenParts: React.Dispatch<React.SetStateAction<Set<number>>>;
preview: boolean;
setIsBetweenParts?: React.Dispatch<React.SetStateAction<boolean>>;
}
const SectionNavbar: React.FC<Props> = ({module, sections, sectionIndex, sectionLabel, setSectionIndex, onClick}) => {
const SectionNavbar: React.FC<Props> = ({ module, sectionLabel, seenParts, setSeenParts, setShowPartDivider, setIsBetweenParts, preview }) => {
const isPartExam = ["reading", "listening", "level"].includes(module);
const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
const {
exam,
partIndex, setPartIndex,
exerciseIndex, setExerciseIndex,
setQuestionIndex,
setBgColor
} = !preview ? examState : persistentExamState;
const sections = isPartExam ? (exam as PartExam).parts : (exam as ExerciseOnlyExam).exercises;
const sectionIndex = isPartExam ? partIndex : exerciseIndex;
const handleSectionChange = (index: number) => {
const changeSection = isPartExam ? setPartIndex : setExerciseIndex;
changeSection(index);
}
const handleClick = (index: number) => {
setExerciseIndex(0);
setQuestionIndex(0);
if (!seenParts.has(index)) {
if (hasDivider(exam!, index)) {
setShowPartDivider(true);
setBgColor(`bg-ielts-${module}-light`);
} else if(setIsBetweenParts) {
setIsBetweenParts(true);
}
setSeenParts(prev => new Set(prev).add(index));
}
}
return (
<div className="w-full">
<TabGroup className="w-[90%]" selectedIndex={sectionIndex} onChange={setSectionIndex}>
<TabGroup className="w-[90%]" selectedIndex={sectionIndex} onChange={handleSectionChange}>
<TabList className={`flex space-x-1 rounded-xl bg-ielts-${module}/20 p-1`}>
{sections.map((_, index) =>
<Tab key={index} onClick={() => onClick(index)}
<Tab key={index} onClick={() => handleClick(index)}
className={({ selected }) =>
clsx(
`w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-ielts-${module}/80`,

View File

@@ -0,0 +1,217 @@
import { ExerciseOnlyExam, ModuleExam, PartExam } from "@/interfaces/exam";
import { useState, useEffect } from "react";
import scrollToTop from "../utils/scrollToTop";
import { Module } from "@/interfaces";
import { answeredEveryQuestion } from "../utils/answeredEveryQuestion";
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import hasDivider from "../utils/hasDivider";
const MC_PER_PAGE = 2;
type UseExamNavigation = (props: {
exam: ModuleExam;
module: Module;
showBlankModal?: boolean;
setShowBlankModal?: React.Dispatch<React.SetStateAction<boolean>>;
showSolutions: boolean;
preview: boolean;
disableBetweenParts?: boolean;
}) => {
showPartDivider: boolean;
seenParts: Set<number>;
isBetweenParts: boolean;
nextExercise: (isBetweenParts?: boolean) => void;
previousExercise: (isBetweenParts?: boolean) => void;
setShowPartDivider: React.Dispatch<React.SetStateAction<boolean>>;
setSeenParts: React.Dispatch<React.SetStateAction<Set<number>>>;
setIsBetweenParts: React.Dispatch<React.SetStateAction<boolean>>;
};
const useExamNavigation: UseExamNavigation = ({
exam,
module,
setShowBlankModal,
showSolutions,
preview,
disableBetweenParts = false,
}) => {
const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
const {
exerciseIndex, setExerciseIndex,
partIndex, setPartIndex,
questionIndex, setQuestionIndex,
userSolutions, setModuleIndex,
setBgColor,
dispatch,
} = !preview ? examState : persistentExamState;
const [isBetweenParts, setIsBetweenParts] = useState(partIndex !== 0 && exerciseIndex == 0 && !disableBetweenParts);
const isPartExam = ["reading", "listening", "level"].includes(exam.module);
const [seenParts, setSeenParts] = useState<Set<number>>(
new Set(showSolutions ?
(isPartExam ?
(exam as PartExam).parts.map((_, index) => index) :
(exam as ExerciseOnlyExam).exercises.map((_, index) => index)
) :
[]
)
);
const [showPartDivider, setShowPartDivider] = useState<boolean>(hasDivider(exam, 0));
useEffect(() => {
if (!showSolutions && hasDivider(exam, isPartExam ? partIndex : exerciseIndex) && !seenParts.has(partIndex)) {
setShowPartDivider(true);
setBgColor(`bg-ielts-${module}-light`);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [partIndex]);
const nextExercise = (keepGoing: boolean = false) => {
scrollToTop();
if (isPartExam) {
nextPartExam(keepGoing);
} else {
nextExerciseOnlyExam();
}
};
const previousExercise = () => {
scrollToTop();
if (isPartExam) {
previousPartExam();
} else {
previousExerciseOnlyExam();
}
};
const nextPartExam = (keepGoing: boolean) => {
const partExam = (exam as PartExam);
const reachedFinalExercise = exerciseIndex + 1 === partExam.parts[partIndex].exercises.length;
const currentExercise = partExam.parts[partIndex].exercises[exerciseIndex];
if (isBetweenParts) {
setIsBetweenParts(false);
return;
}
if (currentExercise.type === "multipleChoice" && questionIndex < currentExercise.questions.length - 1) {
setQuestionIndex(questionIndex + MC_PER_PAGE);
return;
}
if (!reachedFinalExercise) {
setExerciseIndex(exerciseIndex + 1);
setQuestionIndex(0);
return;
}
if (partIndex < partExam.parts.length - 1) {
if (!disableBetweenParts) setIsBetweenParts(true);
setPartIndex(partIndex + 1);
setExerciseIndex(0);
setQuestionIndex(0);
return;
}
if (!answeredEveryQuestion(exam as PartExam, userSolutions) && !keepGoing && setShowBlankModal && !showSolutions && !preview) {
setShowBlankModal(true);
return;
}
if (preview) {
setPartIndex(0);
setExerciseIndex(0);
setQuestionIndex(0);
}
if (!showSolutions) {
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: true } });
} else {
dispatch({ type: "FINALIZE_MODULE_SOLUTIONS"});
}
}
const previousPartExam = () => {
if (partIndex !== 0) {
setPartIndex(partIndex - 1);
setExerciseIndex((exam as PartExam).parts[partIndex].exercises.length - 1);
if (isBetweenParts) setIsBetweenParts(false);
return;
}
setQuestionIndex(0);
if (exerciseIndex === 0 && !disableBetweenParts) {
setIsBetweenParts(true);
return;
}
if (exerciseIndex !== 0) {
setExerciseIndex(exerciseIndex - 1);
}
};
const nextExerciseOnlyExam = () => {
const exerciseOnlyExam = (exam as ExerciseOnlyExam);
const reachedFinalExercise = exerciseIndex + 1 === exerciseOnlyExam.exercises.length;
const currentExercise = exerciseOnlyExam.exercises[exerciseIndex];
if (currentExercise.type === "interactiveSpeaking" && questionIndex < currentExercise.prompts.length - 1) {
setQuestionIndex(questionIndex + 1)
return;
}
if (!reachedFinalExercise) {
setQuestionIndex(0);
setExerciseIndex(exerciseIndex + 1);
return;
}
if (preview) {
setPartIndex(0);
setExerciseIndex(0);
setQuestionIndex(0);
}
if (!showSolutions) {
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: true } });
} else {
dispatch({ type: "FINALIZE_MODULE_SOLUTIONS"});
}
}
const previousExerciseOnlyExam = () => {
const currentExercise = (exam as ExerciseOnlyExam).exercises[exerciseIndex];
if (currentExercise.type === "interactiveSpeaking" && questionIndex !== 0) {
setQuestionIndex(questionIndex - 1);
return;
}
if (exerciseIndex !== 0) {
setExerciseIndex(exerciseIndex - 1);
return;
}
};
return {
seenParts,
showPartDivider,
nextExercise,
previousExercise,
setShowPartDivider,
setSeenParts,
isBetweenParts,
setIsBetweenParts,
};
}
export default useExamNavigation;

View File

@@ -1,162 +1,66 @@
import { MultipleChoiceExercise, ReadingExam, ReadingPart, UserSolution } from "@/interfaces/exam";
import { Fragment, useEffect, useState } from "react";
import { Exercise, ReadingExam, UserSolution } from "@/interfaces/exam";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import clsx from "clsx";
import { convertCamelCaseToReadable } from "@/utils/string";
import { Dialog, Transition } from "@headlessui/react";
import { renderExercise } from "@/components/Exercises";
import { renderSolution } from "@/components/Solutions";
import { BsChevronDown, BsChevronUp } from "react-icons/bs";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import Button from "@/components/Low/Button";
import BlankQuestionsModal from "@/components/QuestionsModal";
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
import { countExercises } from "@/utils/moduleUtils";
import PartDivider from "./Navigation/SectionDivider";
import ReadingPassage from "./components/ReadingPassage";
//import ReadingPassageModal from "./components/ReadingPassageModal";
import {calculateExerciseIndex} from "./utils/calculateExerciseIndex";
import useExamNavigation from "./Navigation/useExamNavigation";
import { ExamProps } from "./types";
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import useExamTimer from "@/hooks/useExamTimer";
import SectionNavbar from "./Navigation/SectionNavbar";
import ProgressButtons from "./components/ProgressButtons";
interface Props {
exam: ReadingExam;
showSolutions?: boolean;
preview?: boolean;
onFinish: (userSolutions: UserSolution[]) => void;
}
const Reading: React.FC<ExamProps<ReadingExam>> = ({ exam, showSolutions = false, preview = false }) => {
const updateTimers = useExamTimer(exam.module, preview || showSolutions);
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
const numberToLetter = (number: number) => (number + 9).toString(36).toUpperCase();
function TextModal({ isOpen, title, content, onClose }: { isOpen: boolean; title: string; content: string; onClose: () => void }) {
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<Dialog.Panel className="w-full relative max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
{title}
</Dialog.Title>
<div className="mt-2 overflow-auto mb-28">
<p className="text-sm">
{content.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</p>
</div>
<div className="absolute bottom-8 right-8 max-w-[200px] self-end w-full">
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full" onClick={onClose}>
Close
</Button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}
function TextComponent({ part, exerciseType }: { part: ReadingPart; exerciseType: string }) {
return (
<div className="flex flex-col gap-2 w-full">
<h3 className="text-xl font-semibold">{part.text.title}</h3>
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
{part.text.content
.split(/\n|(\\n)/g)
.filter((x) => x && x.length > 0 && x !== "\\n")
.map((line, index) => (
<Fragment key={index}>
{exerciseType === "matchSentences" && (
<div className="flex gap-3 border border-transparent hover:border-mti-purple-light rounded-lg transition ease-in-out duration-300 p-2 px-3 cursor-pointer">
<span className="font-bold text-mti-purple-dark">{numberToLetter(index + 1)}</span>
<p>{line}</p>
</div>
)}
{exerciseType !== "matchSentences" && <p key={index}>{line}</p>}
</Fragment>
))}
</div>
);
}
export default function Reading({ exam, showSolutions = false, preview = false, onFinish }: Props) {
const readingBgColor = "bg-ielts-reading-light";
const [showTextModal, setShowTextModal] = useState(false);
const [showBlankModal, setShowBlankModal] = useState(false);
const [multipleChoicesDone, setMultipleChoicesDone] = useState<{ id: string; amount: number }[]>([]);
//const [showTextModal, setShowTextModal] = useState(false);
const [isTextMinimized, setIsTextMinimzed] = useState(false);
const [exerciseType, setExerciseType] = useState("");
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.parts.map((_, index) => index) : []));
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && exam.parts[0].intro !== "");
const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
const {
hasExamEnded,
userSolutions,
exerciseIndex,
partIndex,
questionIndex: storeQuestionIndex,
setBgColor,
setUserSolutions,
setHasExamEnded,
setExerciseIndex,
setPartIndex,
setQuestionIndex: setStoreQuestionIndex
exerciseIndex, partIndex, questionIndex,
userSolutions, flags, timeSpentCurrentModule,
setBgColor, setUserSolutions, setTimeIsUp,
dispatch
} = !preview ? examState : persistentExamState;
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
const { finalizeModule, timeIsUp } = flags;
const [isFirstTimeRender, setIsFirstTimeRender] = useState(partIndex === 0 && exerciseIndex == 0 && !showSolutions);
const {
nextExercise, previousExercise,
showPartDivider, setShowPartDivider,
seenParts, setSeenParts,
isBetweenParts, setIsBetweenParts
} = useExamNavigation({ exam, module: "reading", showBlankModal, setShowBlankModal, showSolutions, preview, disableBetweenParts: showSolutions });
useEffect(() => {
if (!showSolutions && exam.parts[partIndex]?.intro !== undefined && exam.parts[partIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) {
setShowPartDivider(true);
setBgColor(readingBgColor);
if (finalizeModule || timeIsUp) {
updateTimers();
if (timeIsUp) {
setTimeIsUp(false);
}
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [partIndex]);
useEffect(() => {
if (showSolutions) setExerciseIndex(-1);
}, [setExerciseIndex, showSolutions]);
useEffect(() => {
const previousParts = exam.parts.filter((_, index) => index < partIndex);
let previousMultipleChoice = previousParts.flatMap((x) => x.exercises).filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
if (partIndex > -1 && exerciseIndex > -1) {
const previousPartExercises = exam.parts[partIndex].exercises.filter((_, index) => index < exerciseIndex);
const partMultipleChoice = previousPartExercises.filter((x) => x.type === "multipleChoice") as MultipleChoiceExercise[];
previousMultipleChoice = [...previousMultipleChoice, ...partMultipleChoice];
}
setMultipleChoicesDone(previousMultipleChoice.map((x) => ({ id: x.id, amount: x.questions.length - 1 })));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [finalizeModule, timeIsUp])
useEffect(() => {
const listener = (e: KeyboardEvent) => {
@@ -164,144 +68,70 @@ export default function Reading({ exam, showSolutions = false, preview = false,
e.preventDefault();
}
};
document.addEventListener("keydown", listener);
return () => {
document.removeEventListener("keydown", listener);
};
}, []);
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
userSolutionRef.current = updateSolution;
setSolutionWasUpdated(true);
}, []);
useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex(exerciseIndex + 1);
}
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
const confirmFinishModule = (keepGoing?: boolean) => {
if (!keepGoing) {
setShowBlankModal(false);
return;
}
onFinish(userSolutions);
};
const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
if (solutionWasUpdated && userSolutionRef.current) {
const solution = userSolutionRef.current();
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "reading", exam: exam.id }]);
setSolutionWasUpdated(false);
}
if (storeQuestionIndex > 0) {
const exercise = getExercise();
setExerciseType(exercise.type);
setMultipleChoicesDone((prev) => [...prev.filter((x) => x.id !== exercise.id), { id: exercise.id, amount: storeQuestionIndex }]);
}
setStoreQuestionIndex(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [solutionWasUpdated])
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) {
setExerciseIndex(exerciseIndex + 1);
return;
}
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
setPartIndex(partIndex + 1);
setExerciseIndex(showSolutions ? 0 : -1);
return;
}
if (
solution &&
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
(x) => x === 0,
) &&
!showSolutions &&
!preview &&
!hasExamEnded
) {
setShowBlankModal(true);
return;
}
setHasExamEnded(false);
if (solution) {
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "reading", exam: exam.id }]);
} else {
onFinish(userSolutions);
}
};
const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "reading", exam: exam.id }]);
}
setStoreQuestionIndex(0);
setExerciseIndex(exerciseIndex - 1);
};
const getExercise = () => {
const currentExercise = useMemo<Exercise>(() => {
const exercise = exam.parts[partIndex].exercises[exerciseIndex];
return {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [partIndex, exerciseIndex]);
const confirmFinishModule = (keepGoing?: boolean) => {
if (!keepGoing) {
setShowBlankModal(false);
return;
} else {
nextExercise(true);
setShowBlankModal(false);
}
};
const memoizedExerciseIndex = useMemo(() =>
calculateExerciseIndex(exam, partIndex, exerciseIndex, questionIndex)
// eslint-disable-next-line react-hooks/exhaustive-deps
, [partIndex, exerciseIndex, questionIndex]
);
const handlePartDividerClick = () => {
setShowPartDivider(false);
setBgColor("bg-white");
setSeenParts((prev) => new Set(prev).add(partIndex));
if (isFirstTimeRender) setIsFirstTimeRender(false);
}
useEffect(() => {
if (partIndex > -1 && exerciseIndex > -1) {
const exercise = getExercise();
setExerciseType(exercise.type);
setMultipleChoicesDone((prev) => prev.filter((x) => x.id !== exercise.id));
if (partIndex !== 0 && !showSolutions) {
setIsBetweenParts(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exerciseIndex, partIndex]);
}, [partIndex, setIsBetweenParts, showSolutions])
const calculateExerciseIndex = () => {
if (partIndex === -1) return 0;
if (partIndex === 0)
return (
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex + multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
);
const exercisesPerPart = exam.parts.map((x) => x.exercises.length);
const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0);
return (
exercisesDone +
(exerciseIndex === -1 ? 0 : exerciseIndex + 1) +
storeQuestionIndex +
multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0)
);
};
const renderText = () => (
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative", isTextMinimized ? "p-2 px-8" : "py-8 px-16")}>
<button
data-tip={isTextMinimized ? "Maximise" : "Minimize"}
className={clsx("absolute right-8 tooltip", isTextMinimized ? "top-1/2 -translate-y-1/2" : "top-8")}
onClick={() => setIsTextMinimzed((prev) => !prev)}>
{isTextMinimized ? (
<BsChevronDown className="text-mti-purple-dark text-lg" />
) : (
<BsChevronUp className="text-mti-purple-dark text-lg" />
)}
</button>
{!isTextMinimized && (
<>
<div className="flex flex-col w-full gap-2">
<h4 className="text-xl font-semibold">
Please read the following excerpt attentively, you will then be asked questions about the text you&apos;ve read.
</h4>
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
</div>
<TextComponent part={exam.parts[partIndex]} exerciseType={exerciseType} />
</>
)}
{isTextMinimized && <span className="font-semibold">Reading Passage</span>}
</div>
);
const progressButtons = useMemo(() =>
// Do not remove the ()=> in handle next
<ProgressButtons handlePrevious={previousExercise} handleNext={() => nextExercise()} />
, [nextExercise, previousExercise]);
return (
<>
@@ -313,42 +143,47 @@ export default function Reading({ exam, showSolutions = false, preview = false,
defaultTitle="Reading exam"
section={exam.parts[partIndex]}
sectionIndex={partIndex}
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }}
onNext={() => handlePartDividerClick()}
/>
</div> : (
<>
<div className="flex flex-col h-full w-full gap-8">
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
{partIndex > -1 && <TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />}
{/*<ReadingPassageModal text={exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />*/}
{exam.parts.length > 1 && <SectionNavbar
module="reading"
sectionLabel="Part"
seenParts={seenParts}
setShowPartDivider={setShowPartDivider}
setSeenParts={setSeenParts}
setIsBetweenParts={setIsBetweenParts}
preview={preview}
/>}
<ModuleTitle
minTimer={exam.minTimer}
exerciseIndex={calculateExerciseIndex()}
minTimer={timer.current}
exerciseIndex={memoizedExerciseIndex}
module="reading"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
disableTimer={showSolutions || preview}
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
label={convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)}
preview={preview}
/>
<div
className={clsx(
"mb-20 w-full",
exerciseIndex > -1 && !isTextMinimized && "grid grid-cols-2 gap-4",
exerciseIndex > -1 && isTextMinimized && "flex flex-col gap-2",
((isFirstTimeRender || isBetweenParts) && !showSolutions) ? "flex flex-col gap-2" : "grid grid-cols-2 gap-4",
)}>
{partIndex > -1 && renderText()}
{exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, undefined, preview)}
{exerciseIndex > -1 &&
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
<ReadingPassage
exam={exam}
partIndex={partIndex}
exerciseType={currentExercise.type}
isTextMinimized={isTextMinimized}
setIsTextMinimized={setIsTextMinimzed}
/>
{!isFirstTimeRender && !showPartDivider && !showSolutions && !isBetweenParts && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)}
{showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)}
</div>
{exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
{/*exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
<Button
color="purple"
variant="outline"
@@ -356,33 +191,19 @@ export default function Reading({ exam, showSolutions = false, preview = false,
className="max-w-[200px] self-end w-full absolute bottom-[31px] right-64">
Read text
</Button>
)}
)*/}
</div>
{exerciseIndex === -1 && partIndex > 0 && (
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() => {
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
setPartIndex(partIndex - 1);
}}
className="max-w-[200px] w-full">
Back
</Button>
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
)}
{exerciseIndex === -1 && partIndex === 0 && (
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
Start now
</Button>
)}
{((isFirstTimeRender || isBetweenParts) && !showPartDivider && !showSolutions) &&
<ProgressButtons
hidePrevious={partIndex == 0 && isBetweenParts || isFirstTimeRender}
nextLabel={isFirstTimeRender ? "Start now" : "Next Page"}
handlePrevious={previousExercise}
handleNext={() => isFirstTimeRender ? setIsFirstTimeRender(false) : nextExercise()} />
}
</>
)}
</>
);
}
export default Reading;

View File

@@ -15,7 +15,7 @@ import ProfileSummary from "@/components/ProfileSummary";
import {ShuffleMap, Shuffles, Variant} from "@/interfaces/exam";
import useSessions, {Session} from "@/hooks/useSessions";
import SessionCard from "@/components/Medium/SessionCard";
import useExamStore from "@/stores/examStore";
import useExamStore from "@/stores/exam";
import moment from "moment";
interface Props {
@@ -32,7 +32,7 @@ export default function Selection({user, page, onStart}: Props) {
const {data: stats} = useFilterRecordsByUser<Stat[]>(user?.id);
const {sessions, isLoading, reload} = useSessions(user.id);
const state = useExamStore((state) => state);
const dispatch = useExamStore((state) => state.dispatch);
const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module);
@@ -44,19 +44,7 @@ export default function Selection({user, page, onStart}: Props) {
)
const loadSession = async (session: Session) => {
state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []})));
state.setSelectedModules(session.selectedModules);
state.setExam(session.exam);
state.setExams(session.exams);
state.setSessionId(session.sessionId);
state.setAssignment(session.assignment);
state.setExerciseIndex(session.exerciseIndex);
state.setPartIndex(session.partIndex);
state.setModuleIndex(session.moduleIndex);
state.setTimeSpent(session.timeSpent);
state.setUserSolutions(session.userSolutions);
state.setShowSolutions(false);
state.setQuestionIndex(session.questionIndex);
dispatch({type: "SET_SESSION", payload: { session }})
};
return (

View File

@@ -1,112 +1,104 @@
import { renderExercise } from "@/components/Exercises";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import { renderSolution } from "@/components/Solutions";
import { infoButtonStyle } from "@/constants/buttonStyles";
import { UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise } from "@/interfaces/exam";
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
import { defaultUserSolutions } from "@/utils/exams";
import { UserSolution, SpeakingExam, SpeakingExercise, InteractiveSpeakingExercise, Exercise } from "@/interfaces/exam";
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import { countExercises } from "@/utils/moduleUtils";
import { convertCamelCaseToReadable } from "@/utils/string";
import { mdiArrowRight } from "@mdi/js";
import Icon from "@mdi/react";
import clsx from "clsx";
import { Fragment, useEffect, useState } from "react";
import { toast } from "react-toastify";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import PartDivider from "./Navigation/SectionDivider";
import { ExamProps } from "./types";
import useExamTimer from "@/hooks/useExamTimer";
import useExamNavigation from "./Navigation/useExamNavigation";
import ProgressButtons from "./components/ProgressButtons";
import { calculateExerciseIndexSpeaking } from "./utils/calculateExerciseIndex";
interface Props {
exam: SpeakingExam;
showSolutions?: boolean;
onFinish: (userSolutions: UserSolution[]) => void;
preview?: boolean;
}
export default function Speaking({ exam, showSolutions = false, onFinish, preview = false }: Props) {
const [speakingPromptsDone, setSpeakingPromptsDone] = useState<{ id: string; amount: number }[]>([]);
const speakingBgColor = "bg-ielts-speaking-light";
const Speaking: React.FC<ExamProps<SpeakingExam>> = ({ exam, showSolutions = false, preview = false }) => {
const updateTimers = useExamTimer(exam.module, preview);
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
const {
userSolutions,
questionIndex,
exerciseIndex,
hasExamEnded,
setBgColor,
setUserSolutions,
setHasExamEnded,
setQuestionIndex,
setExerciseIndex,
exerciseIndex, userSolutions, flags,
timeSpentCurrentModule, questionIndex,
setBgColor, setUserSolutions, setTimeIsUp,
dispatch,
} = !preview ? examState : persistentExamState;
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.exercises.map((_, index) => index) : []));
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.exercises[0].intro === "string" && exam.exercises[0].intro !== "");
const { finalizeModule, timeIsUp } = flags;
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
const {
nextExercise, previousExercise,
showPartDivider, setShowPartDivider,
setSeenParts,
} = useExamNavigation({ exam, module: "speaking", showSolutions, preview, disableBetweenParts: true });
useEffect(() => {
if (!showSolutions && exam.exercises[exerciseIndex]?.intro !== undefined && exam.exercises[exerciseIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) {
setShowPartDivider(true);
setBgColor(speakingBgColor);
if (finalizeModule || timeIsUp) {
updateTimers();
if (timeIsUp) {
setTimeIsUp(false);
}
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exerciseIndex]);
}, [finalizeModule, timeIsUp])
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
userSolutionRef.current = updateSolution;
setSolutionWasUpdated(true);
}, []);
useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex(exerciseIndex + 1);
}
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
if (solutionWasUpdated && userSolutionRef.current) {
const solution = userSolutionRef.current();
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]);
setSolutionWasUpdated(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [solutionWasUpdated])
if (questionIndex > 0) {
const exercise = getExercise();
setSpeakingPromptsDone((prev) => [...prev.filter((x) => x.id !== exercise.id), { id: exercise.id, amount: questionIndex }]);
}
setQuestionIndex(0);
if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex(exerciseIndex + 1);
return;
}
if (exerciseIndex >= exam.exercises.length) return;
setHasExamEnded(false);
if (solution) {
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]);
} else {
onFinish(userSolutions);
}
};
const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "speaking", exam: exam.id }]);
}
if (exerciseIndex > 0) {
setExerciseIndex(exerciseIndex - 1);
}
};
const getExercise = () => {
const currentExercise = useMemo<Exercise>(() => {
const exercise = exam.exercises[exerciseIndex];
return {
...exercise,
variant: exerciseIndex < 2 && exercise.type === "interactiveSpeaking" ? "initial" : undefined,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
} as SpeakingExercise | InteractiveSpeakingExercise;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exerciseIndex]);
const progressButtons = useMemo(() =>
// Do not remove the ()=> in handle next
<ProgressButtons handlePrevious={previousExercise} handleNext={() => nextExercise()} />
, [nextExercise, previousExercise]);
const handlePartDividerClick = () => {
setShowPartDivider(false);
setBgColor("bg-white");
setSeenParts((prev) => new Set(prev).add(exerciseIndex));
}
const memoizedExerciseIndex = useMemo(() => {
const bruh = calculateExerciseIndexSpeaking(exam, exerciseIndex, questionIndex)
console.log(bruh);
return bruh;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
, [exerciseIndex, questionIndex]
);
return (
<>
@@ -117,27 +109,24 @@ export default function Speaking({ exam, showSolutions = false, onFinish, previe
defaultTitle="Speaking exam"
section={exam.exercises[exerciseIndex]}
sectionIndex={exerciseIndex}
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex)) }}
onNext={handlePartDividerClick}
/> : (
<div className="flex flex-col h-full w-full gap-8 items-center">
<ModuleTitle
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1 + questionIndex + speakingPromptsDone.reduce((acc, curr) => acc + curr.amount, 0)}
minTimer={timer.current}
exerciseIndex={memoizedExerciseIndex}
module="speaking"
totalExercises={countExercises(exam.exercises)}
disableTimer={showSolutions || preview}
preview={preview}
/>
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, undefined, preview)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
{!showPartDivider && !showSolutions && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)}
{showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)}
</div>
)}
</>
);
}
export default Speaking;

View File

@@ -1,95 +1,88 @@
import { renderExercise } from "@/components/Exercises";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import { renderSolution } from "@/components/Solutions";
import { UserSolution, WritingExam } from "@/interfaces/exam";
import useExamStore, { usePersistentExamStore } from "@/stores/examStore";
import { Exercise, UserSolution, WritingExam } from "@/interfaces/exam";
import useExamStore, { usePersistentExamStore } from "@/stores/exam";
import { countExercises } from "@/utils/moduleUtils";
import PartDivider from "./Navigation/SectionDivider";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ExamProps } from "./types";
import useExamTimer from "@/hooks/useExamTimer";
import useExamNavigation from "./Navigation/useExamNavigation";
import SectionNavbar from "./Navigation/SectionNavbar";
import ProgressButtons from "./components/ProgressButtons";
interface Props {
exam: WritingExam;
showSolutions?: boolean;
preview?: boolean;
onFinish: (userSolutions: UserSolution[]) => void;
}
export default function Writing({ exam, showSolutions = false, preview = false, onFinish }: Props) {
const writingBgColor = "bg-ielts-writing-light";
const Writing: React.FC<ExamProps<WritingExam>> = ({ exam, showSolutions = false, preview = false }) => {
const updateTimers = useExamTimer(exam.module, preview);
const userSolutionRef = useRef<(() => UserSolution) | null>(null);
const [solutionWasUpdated, setSolutionWasUpdated] = useState(false);
const examState = useExamStore((state) => state);
const persistentExamState = usePersistentExamStore((state) => state);
const {
userSolutions,
exerciseIndex,
hasExamEnded,
setBgColor,
setUserSolutions,
setHasExamEnded,
setExerciseIndex,
exerciseIndex, flags, setBgColor,
setUserSolutions, setTimeIsUp, userSolutions,
dispatch, navigation, timeSpentCurrentModule
} = !preview ? examState : persistentExamState;
const [seenParts, setSeenParts] = useState<Set<number>>(new Set(showSolutions ? exam.exercises.map((_, index) => index) : []));
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.exercises[0].intro === "string" && exam.exercises[0].intro !== "");
const timer = useRef(exam.minTimer - timeSpentCurrentModule / 60);
const { finalizeModule, timeIsUp } = flags;
const { nextDisabled } = navigation;
useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex(exerciseIndex + 1);
}
}, [hasExamEnded, exerciseIndex, setExerciseIndex]);
if (finalizeModule || timeIsUp) {
updateTimers();
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
useEffect(() => {
if (!showSolutions && exam.exercises[exerciseIndex]?.intro !== undefined && exam.exercises[exerciseIndex]?.intro !== "" && !seenParts.has(exerciseIndex)) {
setShowPartDivider(true);
setBgColor(writingBgColor);
if (timeIsUp) {
setTimeIsUp(false);
}
dispatch({ type: "FINALIZE_MODULE", payload: { updateTimers: false } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exerciseIndex]);
}, [finalizeModule, timeIsUp])
const {
nextExercise, previousExercise,
showPartDivider, setShowPartDivider,
seenParts, setSeenParts,
} = useExamNavigation({ exam, module: "writing", showSolutions, preview });
const registerSolution = useCallback((updateSolution: () => UserSolution) => {
userSolutionRef.current = updateSolution;
setSolutionWasUpdated(true);
}, []);
const nextExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
useEffect(() => {
if (solutionWasUpdated && userSolutionRef.current) {
const solution = userSolutionRef.current();
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "writing", exam: exam.id }]);
setSolutionWasUpdated(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [solutionWasUpdated])
if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex(exerciseIndex + 1);
return;
}
if (exerciseIndex >= exam.exercises.length) return;
setHasExamEnded(false);
if (solution) {
onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "writing", exam: exam.id }]);
} else {
onFinish(userSolutions);
}
};
const previousExercise = (solution?: UserSolution) => {
scrollToTop();
if (solution) {
setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), { ...solution, module: "writing", exam: exam.id }]);
}
if (exerciseIndex > 0) {
setExerciseIndex(exerciseIndex - 1);
}
};
const getExercise = () => {
const currentExercise = useMemo<Exercise>(() => {
const exercise = exam.exercises[exerciseIndex];
return {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
};
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exerciseIndex]);
const handlePartDividerClick = () => {
setShowPartDivider(false);
setBgColor("bg-white");
setSeenParts((prev) => new Set(prev).add(exerciseIndex));
}
const progressButtons = useMemo(() =>
// Do not remove the ()=> in handle next
<ProgressButtons handlePrevious={previousExercise} handleNext={() => nextExercise()} nextDisabled={nextDisabled}/>
, [nextExercise, previousExercise, nextDisabled]);
return (
<>
@@ -100,27 +93,32 @@ export default function Writing({ exam, showSolutions = false, preview = false,
defaultTitle="Writing exam"
section={exam.exercises[exerciseIndex]}
sectionIndex={exerciseIndex}
onNext={() => { setShowPartDivider(false); setBgColor("bg-white"); setSeenParts((prev) => new Set(prev).add(exerciseIndex))}}
onNext={handlePartDividerClick}
/> : (
<div className="flex flex-col h-full w-full gap-8 items-center">
{exam.exercises.length > 1 && <SectionNavbar
module="writing"
sectionLabel="Part"
seenParts={seenParts}
setShowPartDivider={setShowPartDivider}
setSeenParts={setSeenParts}
preview={preview}
/>}
<ModuleTitle
minTimer={exam.minTimer}
minTimer={timer.current}
exerciseIndex={exerciseIndex + 1}
module="writing"
totalExercises={countExercises(exam.exercises)}
disableTimer={showSolutions || preview}
preview={preview}
/>
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, preview)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
{!showPartDivider && !showSolutions && renderExercise(currentExercise, exam.id, registerSolution, preview, progressButtons, progressButtons)}
{showSolutions && renderSolution(currentExercise, progressButtons, progressButtons)}
</div>
)
}
</>
);
}
export default Writing;

View File

@@ -0,0 +1,56 @@
import Button from "@/components/Low/Button";
import clsx from "clsx";
interface Props {
handlePrevious: () => void;
handleNext: () => void;
hidePrevious?: boolean;
nextLabel?: string;
isLoading?: boolean;
isDisabled?: boolean;
previousDisabled?: boolean;
nextDisabled?: boolean;
previousLoading?: boolean;
nextLoading?: boolean;
}
const ProgressButtons: React.FC<Props> = ({
handlePrevious,
handleNext,
hidePrevious = false,
isLoading = false,
isDisabled = false,
previousDisabled = false,
nextDisabled = false,
previousLoading = false,
nextLoading = false,
nextLabel = "Next Page"
}) => {
return (
<div className={clsx("flex w-full gap-8", !hidePrevious ? "justify-between" : "flex-row-reverse")}>
{!hidePrevious && (
<Button
disabled={isDisabled || previousDisabled}
isLoading={isLoading || previousLoading}
color="purple"
variant="outline"
onClick={handlePrevious}
className="max-w-[200px] w-full"
>
Previous Page
</Button>
)}
<Button
disabled={isDisabled || nextDisabled}
isLoading={isLoading || nextLoading}
color="purple"
onClick={handleNext}
className="max-w-[200px] self-end w-full"
>
{nextLabel}
</Button>
</div>
);
};
export default ProgressButtons;

View File

@@ -0,0 +1,68 @@
import { LevelExam, LevelPart, ReadingExam, ReadingPart } from "@/interfaces/exam";
import clsx from "clsx";
import { Fragment, useState } from "react";
import { BsChevronDown, BsChevronUp } from "react-icons/bs";
const TextComponent: React.FC<{ part: ReadingPart | LevelPart; exerciseType: string }> = ({ part, exerciseType }) => {
const numberToLetter = (number: number) => (number + 9).toString(36).toUpperCase();
return (
<div className="flex flex-col gap-2 w-full">
<h3 className="text-xl font-semibold">{part.text?.title}</h3>
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
{part.text?.content
.split(/\n|(\\n)/g)
.filter((x) => x && x.length > 0 && x !== "\\n")
.map((line, index) => (
<Fragment key={index}>
{exerciseType === "matchSentences" && (
<div className="flex gap-3 border border-transparent hover:border-mti-purple-light rounded-lg transition ease-in-out duration-300 p-2 px-3 cursor-pointer">
<span className="font-bold text-mti-purple-dark">{numberToLetter(index + 1)}</span>
<p>{line}</p>
</div>
)}
{exerciseType !== "matchSentences" && <p key={index}>{line}</p>}
</Fragment>
))}
</div>
);
}
interface Props {
exam: ReadingExam | LevelExam;
partIndex: number;
exerciseType: string;
isTextMinimized: boolean;
setIsTextMinimized: React.Dispatch<React.SetStateAction<boolean>>;
}
const ReadingPassage: React.FC<Props> = ({exam, partIndex, exerciseType, isTextMinimized, setIsTextMinimized}) => {
return (
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative", isTextMinimized ? "p-2 px-8" : "py-8 px-16")}>
<button
data-tip={isTextMinimized ? "Maximise" : "Minimize"}
className={clsx("absolute right-8 tooltip", isTextMinimized ? "top-1/2 -translate-y-1/2" : "top-8")}
onClick={() => setIsTextMinimized((prev) => !prev)}>
{isTextMinimized ? (
<BsChevronDown className="text-mti-purple-dark text-lg" />
) : (
<BsChevronUp className="text-mti-purple-dark text-lg" />
)}
</button>
{!isTextMinimized && (
<>
<div className="flex flex-col w-full gap-2">
<h4 className="text-xl font-semibold">
Please read the following excerpt attentively, you will then be asked questions about the text you&apos;ve read.
</h4>
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
</div>
<TextComponent part={exam.parts[partIndex]} exerciseType={exerciseType} />
</>
)}
{isTextMinimized && <span className="font-semibold">Reading Passage</span>}
</div>
);
};
export default ReadingPassage;

View File

@@ -0,0 +1,70 @@
import Button from "@/components/Low/Button";
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react";
import { Fragment } from "react";
interface Props {
isOpen: boolean;
text: {
title?: string;
content: string;
}
onClose: () => void
}
const ReadingPassageModal: React.FC<Props> = ({ isOpen, text, onClose }) => {
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={onClose}>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black bg-opacity-25" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<DialogPanel className="w-full relative max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
{text.title &&
<DialogTitle as="h3" className="text-lg font-medium leading-6 text-gray-900">
{text.title}
</DialogTitle>
}
<div className="mt-2 overflow-auto mb-28">
<p className="text-sm">
{text.content.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</p>
</div>
<div className="absolute bottom-8 right-8 max-w-[200px] self-end w-full">
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full" onClick={onClose}>
Close
</Button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
);
}
export default ReadingPassageModal;

View File

@@ -0,0 +1,19 @@
import AudioPlayer from "@/components/Low/AudioPlayer";
import { v4 } from "uuid";
const INSTRUCTIONS_AUDIO_SRC =
"https://firebasestorage.googleapis.com/v0/b/storied-phalanx-349916.appspot.com/o/generic_listening_intro_v2.mp3?alt=media&token=16769f5f-1e9b-4a72-86a9-45a6f0fa9f82";
const RenderAudioInstructionsPlayer: React.FC = () => (
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
<div className="flex flex-col w-full gap-2">
<h4 className="text-xl font-semibold">Please listen to the instructions audio attentively.</h4>
</div>
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
<AudioPlayer key={v4()} src={INSTRUCTIONS_AUDIO_SRC} color="listening" />
</div>
</div>
);
export default RenderAudioInstructionsPlayer;

View File

@@ -0,0 +1,65 @@
import AudioPlayer from "@/components/Low/AudioPlayer";
import Button from "@/components/Low/Button";
import { Script } from "@/interfaces/exam";
import { Assignment } from "@/interfaces/results";
import { v4 } from "uuid";
interface Props {
audioSource?: string;
repeatableTimes?: number;
timesListened: number;
script?: Script;
assignment?: Assignment;
setShowTextModal: React.Dispatch<React.SetStateAction<boolean>>;
setTimesListened: React.Dispatch<React.SetStateAction<number>>;
}
const RenderAudioPlayer: React.FC<Props> = ({
audioSource, repeatableTimes, timesListened,
script, assignment, setShowTextModal, setTimesListened
}) => (
<div className="flex flex-col gap-8 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
{audioSource ? (
<>
<div className="w-full items-start flex justify-between">
<div className="flex flex-col w-full gap-2">
<h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4>
<span className="text-base">
{(() => {
return repeatableTimes && repeatableTimes > 0
? `You will only be allowed to listen to the audio ${repeatableTimes - timesListened} time(s).`
: "You may listen to the audio as many times as you would like.";
})()}
</span>
</div>
{!assignment && !!script && (
<Button
onClick={() => setShowTextModal(true)}
variant="outline"
color="gray"
className="w-full max-w-[200px]"
>
View Transcript
</Button>
)}
</div>
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
<AudioPlayer
key={v4()}
src={audioSource ?? ''}
color="listening"
onEnd={() => setTimesListened((prev) => prev + 1)}
disabled={repeatableTimes != null &&
timesListened === repeatableTimes}
disablePause
/>
</div>
</>
) : (
<span>This section will display the audio once it has been generated.</span>
)}
</div>
);
export default RenderAudioPlayer;

View File

@@ -0,0 +1,72 @@
import Button from "@/components/Low/Button";
import { Script } from "@/interfaces/exam";
import { Dialog, DialogPanel, Transition, TransitionChild } from "@headlessui/react";
import { capitalize } from "lodash";
import { Fragment } from "react";
interface Props {
isOpen: boolean;
script: Script;
onClose: () => void
}
const ScriptModal: React.FC<Props> = ({ isOpen, script, onClose }) => {
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={onClose}>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black bg-opacity-25" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<DialogPanel className="w-full relative max-w-4xl transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
<div className="mt-2 overflow-auto mb-28">
<p className="text-sm">
{typeof script === "string" && script.split("\\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
{typeof script === "object" && script.map((line, index) => (
<span key={index}>
<b>{line.name} ({capitalize(line.gender)})</b>: {line.text}
<br />
<br />
</span>
))}
</p>
</div>
<div className="absolute bottom-8 right-8 max-w-[200px] self-end w-full">
<Button color="purple" variant="outline" className="max-w-[200px] self-end w-full" onClick={onClose}>
Close
</Button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
);
}
export default ScriptModal;

7
src/exams/types.ts Normal file
View File

@@ -0,0 +1,7 @@
import { Exam } from "@/interfaces/exam";
export type ExamProps<T extends Exam> = {
exam: T;
showSolutions?: boolean;
preview?: boolean;
};

View File

@@ -0,0 +1,47 @@
import { LevelExam, ListeningExam, ReadingExam, UserSolution } from "@/interfaces/exam";
// so that the compiler doesnt complain
interface Part {
exercises: Array<{
id: string;
type: string;
questions?: Array<any>;
score?: { total: number };
}>;
}
type PartExam = {
parts: Part[];
} & (ReadingExam | ListeningExam | LevelExam)
const answeredEveryQuestionInPart = (exam: PartExam, partIndex: number, userSolutions: UserSolution[]) => {
return exam.parts[partIndex].exercises.every((exercise) => {
const userSolution = userSolutions.find(x => x.exercise === exercise.id);
switch (exercise.type) {
case 'multipleChoice':
return userSolution?.solutions.length === exercise.questions!.length;
case 'fillBlanks':
return userSolution?.solutions.length === userSolution?.score.total;
case 'writeBlanks':
return userSolution?.solutions.length === userSolution?.score.total;
case 'matchSentences':
return userSolution?.solutions.length === userSolution?.score.total;
case 'trueFalse':
return userSolution?.solutions.length === userSolution?.score.total;
}
return false;
});
}
const answeredEveryQuestion = (exam: PartExam, userSolutions: UserSolution[]) => {
return exam.parts.every((_, index) => {
return answeredEveryQuestionInPart(exam, index, userSolutions);
});
}
export {
answeredEveryQuestion,
answeredEveryQuestionInPart
};

View File

@@ -0,0 +1,61 @@
import { PartExam, SpeakingExam } from "@/interfaces/exam";
import { countCurrentExercises } from "@/utils/moduleUtils";
const calculateExerciseIndex = (
exam: PartExam,
partIndex: number,
exerciseIndex: number,
questionIndex: number
) => {
let total = 0;
// Count all exercises in previous parts
for (let i = 0; i < partIndex; i++) {
total += countCurrentExercises(
exam.parts[i].exercises,
exam.parts[i].exercises.length - 1
);
}
// Count previous exercises in current part
if (partIndex < exam.parts.length && exerciseIndex > 0) {
total += countCurrentExercises(
exam.parts[partIndex].exercises.slice(0, exerciseIndex),
exerciseIndex - 1
);
}
// Only pass questionIndex if current exercise is multiple choice
const currentExercise = exam.parts[partIndex].exercises[exerciseIndex];
if (currentExercise.type === "multipleChoice") {
total += countCurrentExercises(
[currentExercise],
0,
questionIndex
);
return total;
}
// Add 1 for non-MC exercises
total += 1;
return total;
};
const calculateExerciseIndexSpeaking = (
exam: SpeakingExam,
exerciseIndex: number,
questionIndex: number
) => {
let total = 0;
for (let i = 0; i < exerciseIndex; i++) {
total += exam.exercises[i].type === "speaking" ? 1 : exam.exercises[i].prompts.length;
}
total += exam.exercises[exerciseIndex].type === "speaking" ? 1 : questionIndex + 1;
return total;
};
export {
calculateExerciseIndex,
calculateExerciseIndexSpeaking
};

View File

@@ -0,0 +1,11 @@
import { Exam, ExerciseOnlyExam, PartExam } from "@/interfaces/exam";
const hasDivider = (exam: Exam, index: number) => {
const isPartExam = ["reading", "listening", "level"].includes(exam.module);
if (isPartExam) {
return typeof (exam as PartExam).parts[index].intro === "string" && (exam as PartExam).parts[index].intro !== "";
}
return typeof (exam as ExerciseOnlyExam).exercises[index].intro === "string" && (exam as ExerciseOnlyExam).exercises[index].intro !== ""
}
export default hasDivider;

View File

@@ -0,0 +1,7 @@
const scrollToTop = () => {
Array.from(document.getElementsByTagName("body")).forEach((body) =>
body.scrollTo(0, 0)
);
};
export default scrollToTop;