From 23b3703b674bae6572adc026664342e77375cde1 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Sat, 27 May 2023 15:45:03 +0100 Subject: [PATCH 1/7] - Created a Diagnostics component; - Corrected the history code; --- src/components/Diagnostic.tsx | 86 +++++++++++++++++++++++++++++++++++ src/interfaces/exam.ts | 1 + src/interfaces/user.ts | 2 + src/pages/api/users/update.ts | 26 +++++++++++ src/pages/history.tsx | 27 ++--------- src/pages/index.tsx | 22 +++++++++ src/utils/exams.ts | 23 ++++++++++ 7 files changed, 163 insertions(+), 24 deletions(-) create mode 100644 src/components/Diagnostic.tsx create mode 100644 src/pages/api/users/update.ts create mode 100644 src/utils/exams.ts diff --git a/src/components/Diagnostic.tsx b/src/components/Diagnostic.tsx new file mode 100644 index 00000000..dde882ec --- /dev/null +++ b/src/components/Diagnostic.tsx @@ -0,0 +1,86 @@ +import {infoButtonStyle} from "@/constants/buttonStyles"; +import {Module} from "@/interfaces"; +import {User} from "@/interfaces/user"; +import useExamStore from "@/stores/examStore"; +import {getExamById} from "@/utils/exams"; +import axios from "axios"; +import clsx from "clsx"; +import {useRouter} from "next/router"; +import {useEffect, useState} from "react"; +import {ToastContainer, toast} from "react-toastify"; + +interface Props { + user: User; +} + +const DIAGNOSTIC_EXAMS = [ + ["reading", ""], + ["listening", ""], + ["writing", ""], + ["speaking", ""], +]; + +export default function Diagnostic({user}: Props) { + const [focus, setFocus] = useState<"academic" | "general">(); + const [isInsert, setIsInsert] = useState(false); + + const router = useRouter(); + + const setExams = useExamStore((state) => state.setExams); + const setSelectedModules = useExamStore((state) => state.setSelectedModules); + + const selectExam = () => { + const examPromises = DIAGNOSTIC_EXAMS.map((exam) => getExamById(exam[0] as Module, exam[1])); + + Promise.all(examPromises).then((exams) => { + if (exams.every((x) => !!x)) { + setExams(exams.map((x) => x!)); + setSelectedModules(exams.map((x) => x!.module)); + router.push("/exam"); + } + }); + }; + + const onPerformDiagnosis = async () => { + axios + .post("/api/users/update", {focus, isFirstLogin: false}) + .then(selectExam) + .catch(() => { + toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"}); + }); + }; + + useEffect(() => { + toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"}); + }, []); + + if (!focus) { + return ( +
+

What is your focus?

+
+ + +
+
+ ); + } + + return ( +
+

What is your current IELTS level?

+
+ + +
+
+ ); +} diff --git a/src/interfaces/exam.ts b/src/interfaces/exam.ts index ea4102f4..572228ea 100644 --- a/src/interfaces/exam.ts +++ b/src/interfaces/exam.ts @@ -11,6 +11,7 @@ export interface ReadingExam { exercises: Exercise[]; module: "reading"; minTimer: number; + type: "academic" | "general"; } export interface ListeningExam { diff --git a/src/interfaces/user.ts b/src/interfaces/user.ts index 5bec2c57..fc39e641 100644 --- a/src/interfaces/user.ts +++ b/src/interfaces/user.ts @@ -6,6 +6,8 @@ export interface User { profilePicture: string; id: string; experience: number; + isFirstLogin: boolean; + focus: "academic" | "general"; type: Type; } diff --git a/src/pages/api/users/update.ts b/src/pages/api/users/update.ts new file mode 100644 index 00000000..4c353f70 --- /dev/null +++ b/src/pages/api/users/update.ts @@ -0,0 +1,26 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type {NextApiRequest, NextApiResponse} from "next"; +import {app} from "@/firebase"; +import {getFirestore, collection, getDocs, getDoc, doc, setDoc} from "firebase/firestore"; +import {withIronSessionApiRoute} from "iron-session/next"; +import {sessionOptions} from "@/lib/session"; +import {User} from "@/interfaces/user"; + +const db = getFirestore(app); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ok: false}); + return; + } + + const docUser = await getDoc(doc(db, "users", req.session.user.id)); + const user = docUser.data() as User; + + const userRef = doc(db, "users", user.id); + setDoc(userRef, req.body, {merge: true}); + + res.status(200).json({ok: true}); +} diff --git a/src/pages/history.tsx b/src/pages/history.tsx index d8ae5a92..2083e7a7 100644 --- a/src/pages/history.tsx +++ b/src/pages/history.tsx @@ -24,6 +24,8 @@ import {toast} from "react-toastify"; import {useRouter} from "next/router"; import Icon from "@mdi/react"; import {mdiArrowRight, mdiChevronRight} from "@mdi/js"; +import {uniqBy} from "lodash"; +import {getExamById} from "@/utils/exams"; export const getServerSideProps = withIronSessionSsr(({req, res}) => { const user = req.session.user; @@ -64,27 +66,6 @@ export default function History({user}: {user: User}) { } }, [stats, isStatsLoading]); - 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 formatTimestamp = (timestamp: string) => { const date = moment(parseInt(timestamp)); const formatter = "YYYY/MM/DD - HH:mm"; @@ -100,9 +81,7 @@ export default function History({user}: {user: User}) { const total = dateStats.reduce((accumulator, current) => accumulator + current.score.total, 0); const selectExam = () => { - const examPromises = formatModuleTotalStats(dateStats) - .filter((x) => x.value > 0) - .map((module) => getExam(module.label.toLowerCase() as Module)); + const examPromises = uniqBy(dateStats, "exam").map((stat) => getExamById(stat.module, stat.exam)); Promise.all(examPromises).then((exams) => { if (exams.every((x) => !!x)) { diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 639cb0a3..a388fff6 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -11,6 +11,8 @@ import useStats from "@/hooks/useStats"; import {averageScore, formatModuleTotalStats, totalExams} from "@/utils/stats"; import {Divider} from "primereact/divider"; import useUser from "@/hooks/useUser"; +import Diagnostic from "@/components/Diagnostic"; +import {ToastContainer} from "react-toastify"; export const getServerSideProps = withIronSessionSsr(({req, res}) => { const user = req.session.user; @@ -41,6 +43,25 @@ export default function Home() { useEffect(() => setShowEndExam(window.innerWidth <= 960), []); useEffect(() => setWindowWidth(window.innerWidth), []); + if (user && user.isFirstLogin) { + return ( + <> + + IELTS GPT | Muscat Training Institute + + + + +
+ +
+ + ); + } + return ( <> @@ -52,6 +73,7 @@ export default function Home() { + {user && (
diff --git a/src/utils/exams.ts b/src/utils/exams.ts new file mode 100644 index 00000000..83a2498e --- /dev/null +++ b/src/utils/exams.ts @@ -0,0 +1,23 @@ +import {Module} from "@/interfaces"; +import {Exam, ReadingExam, ListeningExam, WritingExam, SpeakingExam} from "@/interfaces/exam"; +import axios from "axios"; + +export const getExamById = async (module: Module, id: string): Promise => { + const examRequest = await axios(`/api/exam/${module}/${id}`); + if (examRequest.status !== 200) { + return undefined; + } + + const newExam = examRequest.data; + + switch (module) { + case "reading": + return newExam as ReadingExam; + case "listening": + return newExam as ListeningExam; + case "writing": + return newExam as WritingExam; + case "speaking": + return newExam as SpeakingExam; + } +}; From b5dabe336abb5814dac15109a8b11f0a903b227a Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Sat, 27 May 2023 16:15:23 +0100 Subject: [PATCH 2/7] Implemented the whole flow for when a user intends to input their own levels --- src/components/Diagnostic.tsx | 50 +++++++++++++++++++++++++++++++---- src/interfaces/user.ts | 1 + src/pages/api/users/update.ts | 7 ++--- src/pages/index.tsx | 8 ++++-- 4 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/components/Diagnostic.tsx b/src/components/Diagnostic.tsx index dde882ec..d51a8411 100644 --- a/src/components/Diagnostic.tsx +++ b/src/components/Diagnostic.tsx @@ -5,12 +5,14 @@ import useExamStore from "@/stores/examStore"; import {getExamById} from "@/utils/exams"; import axios from "axios"; import clsx from "clsx"; +import {capitalize} from "lodash"; import {useRouter} from "next/router"; import {useEffect, useState} from "react"; import {ToastContainer, toast} from "react-toastify"; interface Props { user: User; + onFinish: () => void; } const DIAGNOSTIC_EXAMS = [ @@ -20,9 +22,10 @@ const DIAGNOSTIC_EXAMS = [ ["speaking", ""], ]; -export default function Diagnostic({user}: Props) { +export default function Diagnostic({onFinish}: Props) { const [focus, setFocus] = useState<"academic" | "general">(); const [isInsert, setIsInsert] = useState(false); + const [levels, setLevels] = useState({reading: 0, listening: 0, writing: 0, speaking: 0}); const router = useRouter(); @@ -41,6 +44,15 @@ export default function Diagnostic({user}: Props) { }); }; + const updateUser = () => { + axios + .post("/api/users/update", {focus, levels, isFirstLogin: false}) + .then(onFinish) + .catch(() => { + toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"}); + }); + }; + const onPerformDiagnosis = async () => { axios .post("/api/users/update", {focus, isFirstLogin: false}) @@ -50,10 +62,6 @@ export default function Diagnostic({user}: Props) { }); }; - useEffect(() => { - toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"}); - }, []); - if (!focus) { return (
@@ -70,6 +78,38 @@ export default function Diagnostic({user}: Props) { ); } + if (isInsert) { + return ( +
+

What is your level?

+
+ {Object.keys(levels).map((module) => ( +
+ {capitalize(module)} + + setLevels((prev) => + parseInt(e.target.value) <= 9 && parseInt(e.target.value) >= 0 + ? {...prev, [module]: parseInt(e.target.value)} + : prev, + ) + } + /> +
+ ))} +
+ +
+ ); + } + return (

What is your current IELTS level?

diff --git a/src/interfaces/user.ts b/src/interfaces/user.ts index fc39e641..099166e2 100644 --- a/src/interfaces/user.ts +++ b/src/interfaces/user.ts @@ -8,6 +8,7 @@ export interface User { experience: number; isFirstLogin: boolean; focus: "academic" | "general"; + levels: {[key in Module]: number}; type: Type; } diff --git a/src/pages/api/users/update.ts b/src/pages/api/users/update.ts index 4c353f70..038a9eb7 100644 --- a/src/pages/api/users/update.ts +++ b/src/pages/api/users/update.ts @@ -16,11 +16,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { return; } - const docUser = await getDoc(doc(db, "users", req.session.user.id)); - const user = docUser.data() as User; - - const userRef = doc(db, "users", user.id); - setDoc(userRef, req.body, {merge: true}); + const userRef = doc(db, "users", req.session.user.id); + await setDoc(userRef, req.body, {merge: true}); res.status(200).json({ok: true}); } diff --git a/src/pages/index.tsx b/src/pages/index.tsx index a388fff6..ff8d1d19 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -36,14 +36,18 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => { export default function Home() { const [showEndExam, setShowEndExam] = useState(false); const [windowWidth, setWindowWidth] = useState(0); + const [showDiagnostics, setShowDiagnostics] = useState(false); const {stats, isLoading} = useStats(); const {user} = useUser({redirectTo: "/login"}); useEffect(() => setShowEndExam(window.innerWidth <= 960), []); useEffect(() => setWindowWidth(window.innerWidth), []); + useEffect(() => { + if (user) setShowDiagnostics(user.isFirstLogin); + }, [user]); - if (user && user.isFirstLogin) { + if (user && showDiagnostics) { return ( <> @@ -56,7 +60,7 @@ export default function Home() {
- + setShowDiagnostics(false)} />
); From 2a86efc97ef26cabe29c91910ef7bce576aba376 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Sat, 27 May 2023 16:29:12 +0100 Subject: [PATCH 3/7] Improved a bit of error handling --- src/components/Diagnostic.tsx | 24 +++++++++++++----------- src/constants/ielts.ts | 8 ++++++++ 2 files changed, 21 insertions(+), 11 deletions(-) create mode 100644 src/constants/ielts.ts diff --git a/src/components/Diagnostic.tsx b/src/components/Diagnostic.tsx index d51a8411..77783cc9 100644 --- a/src/components/Diagnostic.tsx +++ b/src/components/Diagnostic.tsx @@ -1,4 +1,5 @@ import {infoButtonStyle} from "@/constants/buttonStyles"; +import {BAND_SCORES} from "@/constants/ielts"; import {Module} from "@/interfaces"; import {User} from "@/interfaces/user"; import useExamStore from "@/stores/examStore"; @@ -7,8 +8,8 @@ import axios from "axios"; import clsx from "clsx"; import {capitalize} from "lodash"; import {useRouter} from "next/router"; -import {useEffect, useState} from "react"; -import {ToastContainer, toast} from "react-toastify"; +import {useState} from "react"; +import {toast} from "react-toastify"; interface Props { user: User; @@ -88,22 +89,23 @@ export default function Diagnostic({onFinish}: Props) { {capitalize(module)} - setLevels((prev) => - parseInt(e.target.value) <= 9 && parseInt(e.target.value) >= 0 - ? {...prev, [module]: parseInt(e.target.value)} - : prev, - ) - } + step={0.5} + onChange={(e) => setLevels((prev) => ({...prev, [module]: parseFloat(e.target.value)}))} />
))}
- diff --git a/src/constants/ielts.ts b/src/constants/ielts.ts new file mode 100644 index 00000000..2facb6ea --- /dev/null +++ b/src/constants/ielts.ts @@ -0,0 +1,8 @@ +import {Module} from "@/interfaces"; + +export const BAND_SCORES: {[key in Module]: number[]} = { + reading: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9], + listening: [0, 2.5, 3, 3.5, 4, 4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9], + writing: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + speaking: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], +}; From 61758af8035ebfbae3d448978dcaf62e9cc7472c Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Sat, 27 May 2023 17:06:40 +0100 Subject: [PATCH 4/7] Added the IDs of the diagnostic exams --- src/components/Diagnostic.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Diagnostic.tsx b/src/components/Diagnostic.tsx index 77783cc9..6ea57cde 100644 --- a/src/components/Diagnostic.tsx +++ b/src/components/Diagnostic.tsx @@ -17,10 +17,10 @@ interface Props { } const DIAGNOSTIC_EXAMS = [ - ["reading", ""], - ["listening", ""], - ["writing", ""], - ["speaking", ""], + ["reading", "CurQtQoxWmHaJHeN0JW2"], + ["listening", "Y6cMao8kUcVnPQOo6teV"], + ["writing", "hbueuDaEZXV37EW7I12A"], + ["speaking", "QVFm4pdcziJQZN2iUTDo"], ]; export default function Diagnostic({onFinish}: Props) { From fe72b770e9cec1eab32200d372a220681ed65f51 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Sat, 27 May 2023 17:13:55 +0100 Subject: [PATCH 5/7] Made it so the levels are update also when the user starts a diagnostics exam --- src/components/Diagnostic.tsx | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/components/Diagnostic.tsx b/src/components/Diagnostic.tsx index 6ea57cde..fe225045 100644 --- a/src/components/Diagnostic.tsx +++ b/src/components/Diagnostic.tsx @@ -45,19 +45,10 @@ export default function Diagnostic({onFinish}: Props) { }); }; - const updateUser = () => { + const updateUser = (callback: () => void) => { axios .post("/api/users/update", {focus, levels, isFirstLogin: false}) - .then(onFinish) - .catch(() => { - toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"}); - }); - }; - - const onPerformDiagnosis = async () => { - axios - .post("/api/users/update", {focus, isFirstLogin: false}) - .then(selectExam) + .then(callback) .catch(() => { toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"}); }); @@ -103,7 +94,7 @@ export default function Diagnostic({onFinish}: Props) { ))} - From 7f72349f767b7d03fd3cbfdc71097114d27a79e6 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Sat, 27 May 2023 20:20:16 +0100 Subject: [PATCH 6/7] Prevented the exam to reload --- src/pages/exam/index.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pages/exam/index.tsx b/src/pages/exam/index.tsx index b66bb1c4..c9330a78 100644 --- a/src/pages/exam/index.tsx +++ b/src/pages/exam/index.tsx @@ -14,11 +14,11 @@ import Finish from "@/exams/Finish"; import axios from "axios"; import {withIronSessionSsr} from "iron-session/next"; import {sessionOptions} from "@/lib/session"; -import {Stat, User} from "@/interfaces/user"; +import {Stat} from "@/interfaces/user"; import Speaking from "@/exams/Speaking"; import {v4 as uuidv4} from "uuid"; import useUser from "@/hooks/useUser"; -import useExamStore, {ExamState} from "@/stores/examStore"; +import useExamStore from "@/stores/examStore"; export const getServerSideProps = withIronSessionSsr(({req, res}) => { const user = req.session.user; @@ -41,7 +41,7 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => { export default function Page() { const [hasBeenUploaded, setHasBeenUploaded] = useState(false); - const [moduleIndex, setModuleIndex] = useState(0); + const [moduleIndex, setModuleIndex] = useState(1); const [sessionId, setSessionId] = useState(""); const [exam, setExam] = useState(); const [timer, setTimer] = useState(-1); @@ -67,7 +67,7 @@ export default function Page() { useEffect(() => { (async () => { - if (selectedModules.length > 0) { + if (selectedModules.length > 0 && exams.length === 0) { const examPromises = selectedModules.map(getExam); Promise.all(examPromises).then((values) => { if (values.every((x) => !!x)) { @@ -76,11 +76,11 @@ export default function Page() { }); } })(); - }, [selectedModules, setExams]); + }, [selectedModules, setExams, exams]); useEffect(() => { (async () => { - if (selectedModules.length > 0 && moduleIndex >= selectedModules.length && !hasBeenUploaded) { + if (selectedModules.length > 0 && exams.length === 0 && moduleIndex >= selectedModules.length && !hasBeenUploaded) { const newStats: Stat[] = userSolutions.map((solution) => ({ ...solution, session: sessionId, From 79a532b5fffe1e126ad726898175d50e4b8d61ae Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Sun, 28 May 2023 10:00:06 +0100 Subject: [PATCH 7/7] - Implemented an image attachment to the Writing exercise; - Made it so the exams are now sorted when going through the history; - Corrected some little mistakes; --- src/components/Diagnostic.tsx | 2 +- src/components/Exercises/Writing.tsx | 4 +++- src/exams/Selection.tsx | 4 +++- src/interfaces/exam.ts | 5 +++++ src/pages/api/exam/[module]/index.ts | 6 ++++-- src/pages/exam/index.tsx | 2 +- src/pages/history.tsx | 10 ++++++++-- src/utils/moduleUtils.ts | 6 ++++++ 8 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/components/Diagnostic.tsx b/src/components/Diagnostic.tsx index fe225045..a67159f5 100644 --- a/src/components/Diagnostic.tsx +++ b/src/components/Diagnostic.tsx @@ -47,7 +47,7 @@ export default function Diagnostic({onFinish}: Props) { const updateUser = (callback: () => void) => { axios - .post("/api/users/update", {focus, levels, isFirstLogin: false}) + .patch("/api/users/update", {focus, levels, isFirstLogin: false}) .then(callback) .catch(() => { toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"}); diff --git a/src/components/Exercises/Writing.tsx b/src/components/Exercises/Writing.tsx index 5db9d90b..cf2d5063 100644 --- a/src/components/Exercises/Writing.tsx +++ b/src/components/Exercises/Writing.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @next/next/no-img-element */ import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles"; import {WritingExercise} from "@/interfaces/exam"; import {mdiArrowLeft, mdiArrowRight} from "@mdi/js"; @@ -7,7 +8,7 @@ import {CommonProps} from "."; import {Fragment, useEffect, useState} from "react"; import {toast} from "react-toastify"; -export default function Writing({id, prompt, info, type, wordCounter, onNext, onBack}: WritingExercise & CommonProps) { +export default function Writing({id, prompt, info, type, wordCounter, attachment, onNext, onBack}: WritingExercise & CommonProps) { const [inputText, setInputText] = useState(""); const [isSubmitEnabled, setIsSubmitEnabled] = useState(false); @@ -40,6 +41,7 @@ export default function Writing({id, prompt, info, type, wordCounter, onNext, on You should write {wordCounter.type === "min" ? "at least" : "at most"} {wordCounter.limit} words. + {attachment && Exercise attachment}