toggleModule("listening")}
+ onClick={!disableSelection ? () => toggleModule("listening") : undefined}
className={clsx(
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
- selectedModules.includes("listening") ? "border-mti-green-light" : "border-mti-gray-platinum",
+ selectedModules.includes("listening") || disableSelection ? "border-mti-green-light" : "border-mti-gray-platinum",
)}>
@@ -128,14 +131,18 @@ export default function Selection({user, onStart}: Props) {
Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
- {!selectedModules.includes("listening") &&
}
- {selectedModules.includes("listening") &&
}
+ {!selectedModules.includes("listening") && !disableSelection && (
+
+ )}
+ {(selectedModules.includes("listening") || disableSelection) && (
+
+ )}
toggleModule("writing")}
+ onClick={!disableSelection ? () => toggleModule("writing") : undefined}
className={clsx(
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
- selectedModules.includes("writing") ? "border-mti-green-light" : "border-mti-gray-platinum",
+ selectedModules.includes("writing") || disableSelection ? "border-mti-green-light" : "border-mti-gray-platinum",
)}>
@@ -144,14 +151,16 @@ export default function Selection({user, onStart}: Props) {
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
- {!selectedModules.includes("writing") &&
}
- {selectedModules.includes("writing") &&
}
+ {!selectedModules.includes("writing") && !disableSelection && (
+
+ )}
+ {(selectedModules.includes("writing") || disableSelection) &&
}
toggleModule("speaking")}
+ onClick={!disableSelection ? () => toggleModule("speaking") : undefined}
className={clsx(
"relative w-fit max-w-xs flex flex-col items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-2 pt-12 cursor-pointer",
- selectedModules.includes("speaking") ? "border-mti-green-light" : "border-mti-gray-platinum",
+ selectedModules.includes("speaking") || disableSelection ? "border-mti-green-light" : "border-mti-gray-platinum",
)}>
@@ -160,15 +169,21 @@ export default function Selection({user, onStart}: Props) {
You'll have access to interactive dialogs, pronunciation exercises and speech recordings.
- {!selectedModules.includes("speaking") &&
}
- {selectedModules.includes("speaking") &&
}
+ {!selectedModules.includes("speaking") && !disableSelection && (
+
+ )}
+ {(selectedModules.includes("speaking") || disableSelection) && (
+
+ )}
diff --git a/src/pages/exam.tsx b/src/pages/exam.tsx
index 4a21142c..803a020a 100644
--- a/src/pages/exam.tsx
+++ b/src/pages/exam.tsx
@@ -1,6 +1,5 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
-import Navbar from "@/components/Navbar";
import {useEffect, useState} from "react";
import {Module} from "@/interfaces";
@@ -19,9 +18,7 @@ import Speaking from "@/exams/Speaking";
import {v4 as uuidv4} from "uuid";
import useUser from "@/hooks/useUser";
import useExamStore from "@/stores/examStore";
-import Sidebar from "@/components/Sidebar";
import Layout from "@/components/High/Layout";
-import {sortByModule} from "@/utils/moduleUtils";
import {writingReverseMarking} from "@/utils/score";
export const getServerSideProps = withIronSessionSsr(({req, res}) => {
@@ -212,7 +209,7 @@ export default function Page() {
const renderScreen = () => {
if (selectedModules.length === 0) {
- return
;
+ return
;
}
if (moduleIndex >= selectedModules.length) {
diff --git a/src/pages/exercises.tsx b/src/pages/exercises.tsx
new file mode 100644
index 00000000..4a21142c
--- /dev/null
+++ b/src/pages/exercises.tsx
@@ -0,0 +1,277 @@
+/* eslint-disable @next/next/no-img-element */
+import Head from "next/head";
+import Navbar from "@/components/Navbar";
+import {useEffect, useState} from "react";
+import {Module} from "@/interfaces";
+
+import Selection from "@/exams/Selection";
+import Reading from "@/exams/Reading";
+import {Exam, ListeningExam, ReadingExam, SpeakingExam, UserSolution, WritingEvaluation, WritingExam, WritingExercise} from "@/interfaces/exam";
+import Listening from "@/exams/Listening";
+import Writing from "@/exams/Writing";
+import {ToastContainer, toast} from "react-toastify";
+import Finish from "@/exams/Finish";
+import axios from "axios";
+import {withIronSessionSsr} from "iron-session/next";
+import {sessionOptions} from "@/lib/session";
+import {Stat} from "@/interfaces/user";
+import Speaking from "@/exams/Speaking";
+import {v4 as uuidv4} from "uuid";
+import useUser from "@/hooks/useUser";
+import useExamStore from "@/stores/examStore";
+import Sidebar from "@/components/Sidebar";
+import Layout from "@/components/High/Layout";
+import {sortByModule} from "@/utils/moduleUtils";
+import {writingReverseMarking} from "@/utils/score";
+
+export const getServerSideProps = withIronSessionSsr(({req, res}) => {
+ const user = req.session.user;
+
+ if (!user) {
+ res.setHeader("location", "/login");
+ res.statusCode = 302;
+ res.end();
+ return {
+ props: {
+ user: null,
+ },
+ };
+ }
+
+ return {
+ props: {user: req.session.user},
+ };
+}, sessionOptions);
+
+export default function Page() {
+ const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
+ const [moduleIndex, setModuleIndex] = useState(0);
+ const [sessionId, setSessionId] = useState("");
+ const [exam, setExam] = useState
();
+ const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
+
+ const [exams, setExams] = useExamStore((state) => [state.exams, state.setExams]);
+ 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 {user} = useUser({redirectTo: "/login"});
+
+ useEffect(() => setSessionId(uuidv4()), []);
+
+ useEffect(() => {
+ (async () => {
+ if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) {
+ const nextExam = exams[moduleIndex];
+ setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined);
+ }
+ })();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedModules, moduleIndex, exams]);
+
+ useEffect(() => {
+ (async () => {
+ if (selectedModules.length > 0 && exams.length === 0) {
+ const examPromises = selectedModules.map(getExam);
+ Promise.all(examPromises).then((values) => {
+ if (values.every((x) => !!x)) {
+ setExams(values.map((x) => x!));
+ }
+ });
+ }
+ })();
+ }, [selectedModules, setExams, exams]);
+
+ useEffect(() => {
+ if (selectedModules.length > 0 && exams.length !== 0 && moduleIndex >= selectedModules.length && !hasBeenUploaded) {
+ const newStats: Stat[] = userSolutions.map((solution) => ({
+ ...solution,
+ session: sessionId,
+ exam: solution.exam!,
+ module: solution.module!,
+ user: user?.id || "",
+ date: new Date().getTime(),
+ }));
+
+ axios
+ .post<{ok: boolean}>("/api/stats", newStats)
+ .then((response) => setHasBeenUploaded(response.data.ok))
+ .catch(() => setHasBeenUploaded(false));
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedModules, moduleIndex, hasBeenUploaded]);
+
+ const getExam = async (module: Module): Promise => {
+ const examRequest = await axios(`/api/exam/${module}`);
+ if (examRequest.status !== 200) {
+ toast.error("Something went wrong!");
+ return undefined;
+ }
+
+ const newExam = examRequest.data;
+
+ switch (module) {
+ case "reading":
+ return newExam.shift() as ReadingExam;
+ case "listening":
+ return newExam.shift() as ListeningExam;
+ case "writing":
+ return newExam.shift() as WritingExam;
+ case "speaking":
+ return newExam.shift() as SpeakingExam;
+ }
+ };
+
+ const evaluateWritingAnswer = async (examId: string, exerciseId: string, solution: UserSolution) => {
+ const writingExam = exams.find((x) => x.id === examId)!;
+ const exercise = writingExam.exercises.find((x) => x.id === exerciseId)! as WritingExercise;
+
+ const response = await axios.post("/api/exam/writing/evaluate", {
+ question: `${exercise.prompt} ${exercise.attachment ? exercise.attachment.description : ""}`.replaceAll("\n", ""),
+ answer: solution.solutions[0].solution.trim().replaceAll("\n", " "),
+ });
+
+ if (response.status === 200) {
+ setUserSolutions([
+ ...userSolutions.filter((x) => x.exercise !== exerciseId),
+ {
+ ...solution,
+ score: {
+ correct: writingReverseMarking[response.data.overall],
+ missing: 0,
+ total: 100,
+ },
+ solutions: [{id: exerciseId, solution: solution.solutions[0].solution, evaluation: response.data}],
+ },
+ ]);
+ }
+ };
+
+ const updateExamWithUserSolutions = (exam: Exam): Exam => {
+ const exercises = exam.exercises.map((x) =>
+ Object.assign(x, !x.userSolutions ? {userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions} : x.userSolutions),
+ );
+
+ return Object.assign(exam, exercises);
+ };
+
+ const onFinish = (solutions: UserSolution[]) => {
+ const solutionIds = solutions.map((x) => x.exercise);
+
+ if (exam && exam.module === "writing" && solutions.length > 0 && !showSolutions) {
+ setHasBeenUploaded(true);
+ setIsEvaluationLoading(true);
+ Promise.all(
+ exam.exercises.map((exercise) => evaluateWritingAnswer(exam.id, exercise.id, solutions.find((x) => x.exercise === exercise.id)!)),
+ ).finally(() => {
+ setIsEvaluationLoading(false);
+ setHasBeenUploaded(false);
+ });
+ }
+
+ setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]);
+ setModuleIndex((prev) => prev + 1);
+ };
+
+ const aggregateScoresByModule = (answers: UserSolution[]): {module: Module; total: number; missing: number; correct: number}[] => {
+ const scores: {[key in Module]: {total: number; missing: number; correct: number}} = {
+ reading: {
+ total: 0,
+ correct: 0,
+ missing: 0,
+ },
+ listening: {
+ total: 0,
+ correct: 0,
+ missing: 0,
+ },
+ writing: {
+ total: 0,
+ correct: 0,
+ missing: 0,
+ },
+ speaking: {
+ total: 0,
+ correct: 0,
+ missing: 0,
+ },
+ };
+
+ answers.forEach((x) => {
+ scores[x.module!] = {
+ total: scores[x.module!].total + x.score.total,
+ correct: scores[x.module!].correct + x.score.correct,
+ missing: scores[x.module!].missing + x.score.missing,
+ };
+ });
+
+ return Object.keys(scores)
+ .filter((x) => scores[x as Module].total > 0)
+ .map((x) => ({module: x as Module, ...scores[x as Module]}));
+ };
+
+ const renderScreen = () => {
+ if (selectedModules.length === 0) {
+ return ;
+ }
+
+ if (moduleIndex >= selectedModules.length) {
+ return (
+ {
+ setShowSolutions(true);
+ setModuleIndex(0);
+ setExam(exams[0]);
+ }}
+ scores={aggregateScoresByModule(userSolutions)}
+ />
+ );
+ }
+
+ if (exam && exam.module === "reading") {
+ return ;
+ }
+
+ if (exam && exam.module === "listening") {
+ return ;
+ }
+
+ if (exam && exam.module === "writing") {
+ return ;
+ }
+
+ if (exam && exam.module === "speaking" && showSolutions) {
+ setModuleIndex((prev) => prev + 1);
+ return <>>;
+ }
+
+ if (exam && exam.module === "speaking") {
+ return ;
+ }
+
+ return <>Loading...>;
+ };
+
+ return (
+ <>
+
+ Exam | IELTS GPT
+
+
+
+
+
+ {user && (
+
+ {renderScreen()}
+
+ )}
+ >
+ );
+}