Compare commits

..

1 Commits

Author SHA1 Message Date
Cristiano Ferreira
7962857a95 Sidebar and button created. 2023-08-21 17:36:04 +01:00
142 changed files with 4946 additions and 13513 deletions

View File

@@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
yarn build

View File

@@ -6,30 +6,23 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint"
"prepare": "husky install"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^1.7.13", "@headlessui/react": "^1.7.13",
"@mdi/js": "^7.1.96", "@mdi/js": "^7.1.96",
"@mdi/react": "^1.6.1", "@mdi/react": "^1.6.1",
"@next/font": "13.1.6", "@next/font": "13.1.6",
"@tanstack/react-table": "^8.10.1",
"@types/node": "18.13.0", "@types/node": "18.13.0",
"@types/react": "18.0.27", "@types/react": "18.0.27",
"@types/react-dom": "18.0.10", "@types/react-dom": "18.0.10",
"axios": "^1.3.5", "axios": "^1.3.5",
"bcrypt": "^5.1.1",
"chart.js": "^4.2.1", "chart.js": "^4.2.1",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"countries-list": "^3.0.1",
"country-codes-list": "^1.6.11",
"daisyui": "^3.1.5", "daisyui": "^3.1.5",
"eslint": "8.33.0", "eslint": "8.33.0",
"eslint-config-next": "13.1.6", "eslint-config-next": "13.1.6",
"express-handlebars": "^7.1.2",
"firebase": "9.19.1", "firebase": "9.19.1",
"firebase-admin": "^11.10.1",
"formidable": "^3.5.0", "formidable": "^3.5.0",
"formidable-serverless": "^1.1.1", "formidable-serverless": "^1.1.1",
"framer-motion": "^9.0.2", "framer-motion": "^9.0.2",
@@ -37,31 +30,21 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.29.4", "moment": "^2.29.4",
"next": "13.1.6", "next": "13.1.6",
"nodemailer": "^6.9.5",
"nodemailer-express-handlebars": "^6.1.0",
"primeicons": "^6.0.1", "primeicons": "^6.0.1",
"primereact": "^9.2.3", "primereact": "^9.2.3",
"random-words": "^2.0.0",
"react": "18.2.0", "react": "18.2.0",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.2.0",
"react-datepicker": "^4.18.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-firebase-hooks": "^5.1.1", "react-firebase-hooks": "^5.1.1",
"react-icons": "^4.8.0", "react-icons": "^4.8.0",
"react-lineto": "^3.3.0", "react-lineto": "^3.3.0",
"react-media-recorder": "1.6.5", "react-media-recorder": "1.6.5",
"react-phone-number-input": "^3.3.6",
"react-player": "^2.12.0", "react-player": "^2.12.0",
"react-select": "^5.7.5",
"react-string-replace": "^1.1.0", "react-string-replace": "^1.1.0",
"react-toastify": "^9.1.2", "react-toastify": "^9.1.2",
"react-xarrows": "^2.0.2", "react-xarrows": "^2.0.2",
"short-unique-id": "^5.0.2",
"stripe": "^13.10.0",
"swr": "^2.1.3", "swr": "^2.1.3",
"tailwind-scrollbar-hide": "^1.1.7",
"typescript": "4.9.5", "typescript": "4.9.5",
"use-file-picker": "^2.1.0",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"wavesurfer.js": "^6.6.4", "wavesurfer.js": "^6.6.4",
"zustand": "^4.3.6" "zustand": "^4.3.6"
@@ -69,15 +52,11 @@
"devDependencies": { "devDependencies": {
"@types/formidable": "^3.4.0", "@types/formidable": "^3.4.0",
"@types/lodash": "^4.14.191", "@types/lodash": "^4.14.191",
"@types/nodemailer": "^6.4.11",
"@types/nodemailer-express-handlebars": "^4.0.3",
"@types/react-datepicker": "^4.15.1",
"@types/uuid": "^9.0.1", "@types/uuid": "^9.0.1",
"@types/wavesurfer.js": "^6.0.6", "@types/wavesurfer.js": "^6.0.6",
"@wixc3/react-board": "^2.2.0", "@wixc3/react-board": "^2.2.0",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"husky": "^8.0.3",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"tailwindcss": "^3.2.4" "tailwindcss": "^3.2.4"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -3,53 +3,60 @@ import {Fragment} from "react";
import Button from "./Low/Button"; import Button from "./Low/Button";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
abandonPopupTitle: string; abandonPopupTitle: string;
abandonPopupDescription: string; abandonPopupDescription: string;
abandonConfirmButtonText: string; abandonConfirmButtonText: string;
onAbandon: () => void; onAbandon: Function;
onCancel: () => void; onCancel: Function;
} }
export default function AbandonPopup({isOpen, abandonPopupTitle, abandonPopupDescription, abandonConfirmButtonText, onAbandon, onCancel}: Props) { export default function AbandonPopup({
return ( isOpen,
<Transition show={isOpen} as={Fragment}> abandonPopupTitle,
<Dialog onClose={onCancel} className="relative z-50"> abandonPopupDescription,
<Transition.Child abandonConfirmButtonText,
as={Fragment} onAbandon,
enter="ease-out duration-300" onCancel,
enterFrom="opacity-0" }: Props) {
enterTo="opacity-100" return (
leave="ease-in duration-200" <Transition show={isOpen} as={Fragment}>
leaveFrom="opacity-100" <Dialog onClose={onCancel} className="relative z-50">
leaveTo="opacity-0"> <Transition.Child
<div className="fixed inset-0 bg-black/30" /> as={Fragment}
</Transition.Child> enter="ease-out duration-300"
enterFrom="opacity-0"
<Transition.Child enterTo="opacity-100"
as={Fragment} leave="ease-in duration-200"
enter="ease-out duration-300" leaveFrom="opacity-100"
enterFrom="opacity-0 scale-95" leaveTo="opacity-0">
enterTo="opacity-100 scale-100" <div className="fixed inset-0 bg-black/30" />
leave="ease-in duration-200" </Transition.Child>
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"> <Transition.Child
<div className="fixed inset-0 flex items-center justify-center p-4"> as={Fragment}
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4"> enter="ease-out duration-300"
<Dialog.Title className="font-bold text-xl">{abandonPopupTitle}</Dialog.Title> enterFrom="opacity-0 scale-95"
<span>{abandonPopupDescription}</span> enterTo="opacity-100 scale-100"
<div className="w-full flex justify-between mt-8"> leave="ease-in duration-200"
<Button color="purple" onClick={onCancel} variant="outline" className="max-w-[200px] self-end w-full"> leaveFrom="opacity-100 scale-100"
Cancel leaveTo="opacity-0 scale-95">
</Button> <div className="fixed inset-0 flex items-center justify-center p-4">
<Button color="purple" onClick={onAbandon} className="max-w-[200px] self-end w-full"> <Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
{abandonConfirmButtonText} <Dialog.Title className="font-bold text-xl">{abandonPopupTitle}</Dialog.Title>
</Button> <span>{abandonPopupDescription}</span>
</div> <div className="w-full flex justify-between mt-8">
</Dialog.Panel> <Button color="purple" onClick={onCancel} variant="outline" className="max-w-[200px] self-end w-full">
</div> Cancel
</Transition.Child> </Button>
</Dialog> <Button color="purple" onClick={onAbandon} className="max-w-[200px] self-end w-full">
</Transition> {abandonConfirmButtonText}
); </Button>
} </div>
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
</Transition>
);
}

View File

@@ -1,63 +0,0 @@
import clsx from "clsx";
import {IconType} from "react-icons";
import {MdSpaceDashboard} from "react-icons/md";
import {BsFileEarmarkText, BsClockHistory, BsPencil, BsGraphUp} from "react-icons/bs";
import {RiLogoutBoxFill} from "react-icons/ri";
import {SlPencil} from "react-icons/sl";
import {FaAward} from "react-icons/fa";
import Link from "next/link";
import {useRouter} from "next/router";
import axios from "axios";
import FocusLayer from "@/components/FocusLayer";
import {preventNavigation} from "@/utils/navigation.disabled";
interface Props {
path: string;
navDisabled?: boolean;
focusMode?: boolean;
onFocusLayerMouseEnter?: () => void;
className?: string;
}
interface NavProps {
Icon: IconType;
label: string;
path: string;
keyPath: string;
disabled?: boolean;
}
const Nav = ({Icon, label, path, keyPath, disabled = false}: NavProps) => (
<Link
href={!disabled ? keyPath : ""}
className={clsx(
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-gray-500 hover:bg-mti-purple-light hover:text-white transition duration-300 ease-in-out",
path === keyPath && "bg-mti-purple-light text-white",
)}>
<Icon size={20} />
</Link>
);
export default function BottomBar({path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter, className}: Props) {
const router = useRouter();
const logout = async () => {
axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500);
});
};
const disableNavigation = preventNavigation(navDisabled, focusMode);
return (
<section className={clsx("w-full bg-white py-2 drop-shadow-2xl shadow-2xl rounded-t-2xl", className)}>
<div className="flex justify-around gap-3">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" />
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" />
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" />
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" />
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" />
</div>
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
</section>
);
}

View File

@@ -1,139 +0,0 @@
import {EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user";
import {FormEvent, useState} from "react";
import countryCodes from "country-codes-list";
import {RadioGroup} from "@headlessui/react";
import Input from "./Low/Input";
import clsx from "clsx";
import Button from "./Low/Button";
import {BsArrowRepeat} from "react-icons/bs";
import axios from "axios";
import {toast} from "react-toastify";
import {KeyedMutator} from "swr";
import CountrySelect from "./Low/CountrySelect";
interface Props {
mutateUser: KeyedMutator<User>;
}
export default function DemographicInformationInput({mutateUser}: Props) {
const [country, setCountry] = useState<string>();
const [phone, setPhone] = useState<string>();
const [gender, setGender] = useState<Gender>();
const [employment, setEmployment] = useState<EmploymentStatus>();
const [isLoading, setIsLoading] = useState(false);
const save = (e?: FormEvent) => {
if (e) e.preventDefault();
setIsLoading(true);
axios
.patch("/api/users/update", {
demographicInformation: {
country,
phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`,
gender,
employment,
},
})
.then((response) => mutateUser((response.data as {user: User}).user))
.catch(() => {
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
})
.finally(() => setIsLoading(false));
};
return (
<div className="flex flex-col items-center justify-center gap-12 w-full">
<h2 className="font-semibold text-center text-xl max-w-[800px]">
Welcome to EnCoach, the ultimate platform dedicated to helping you master the IELTS ! We are thrilled that you have chosen us as your
learning companion on this journey towards achieving your desired IELTS score.
<br />
<br />
To make the most of your learning experience, we kindly request you to complete your profile. By providing some essential information
about yourself.
</h2>
<form className="flex flex-col items-center justify-items-center gap-6 w-full h-full -md:px-4 lg:w-1/2 mb-32" onSubmit={save}>
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
<CountrySelect value={country} onChange={setCountry} />
</div>
<Input type="tel" name="phone" label="Phone number" onChange={(e) => setPhone(e)} placeholder="Enter phone number" required />
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Gender *</label>
<RadioGroup value={gender} onChange={setGender} className="flex flex-row justify-between">
<RadioGroup.Option value="male">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Male
</span>
)}
</RadioGroup.Option>
<RadioGroup.Option value="female">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Female
</span>
)}
</RadioGroup.Option>
<RadioGroup.Option value="other">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Other
</span>
)}
</RadioGroup.Option>
</RadioGroup>
</div>
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Employment Status *</label>
<RadioGroup value={employment} onChange={setEmployment} className="grid grid-cols-2 items-center gap-4 place-items-center">
{EMPLOYMENT_STATUS.map(({status, label}) => (
<RadioGroup.Option value={status} key={status}>
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-44 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
{label}
</span>
)}
</RadioGroup.Option>
))}
</RadioGroup>
</div>
</form>
<div className="self-end flex justify-end w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
className="lg:mt-8 max-w-[400px] w-full self-end"
color="purple"
onClick={save}
disabled={isLoading || !country || !phone || !gender || !employment}>
{!isLoading && "Save information"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="text-white animate-spin" size={25} />
</div>
)}
</Button>
</div>
</div>
);
}

View File

@@ -3,15 +3,14 @@ import {BAND_SCORES} from "@/constants/ielts";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {getExam, getExamById} from "@/utils/exams"; import {getExamById} from "@/utils/exams";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {writingMarking} from "@/utils/score"; import {writingMarking} from "@/utils/score";
import {Menu} from "@headlessui/react"; import {Menu} from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize} from "lodash"; import {capitalize} from "lodash";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useEffect, useState} from "react"; import {useState} from "react";
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs"; import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import Button from "./Low/Button"; import Button from "./Low/Button";
@@ -21,6 +20,13 @@ interface Props {
onFinish: () => void; onFinish: () => void;
} }
const DIAGNOSTIC_EXAMS = [
["reading", "CurQtQoxWmHaJHeN0JW2"],
["listening", "Y6cMao8kUcVnPQOo6teV"],
["writing", "hbueuDaEZXV37EW7I12A"],
["speaking", "QVFm4pdcziJQZN2iUTDo"],
];
export default function Diagnostic({onFinish}: Props) { export default function Diagnostic({onFinish}: Props) {
const [focus, setFocus] = useState<"academic" | "general">(); const [focus, setFocus] = useState<"academic" | "general">();
const [levels, setLevels] = useState({reading: -1, listening: -1, writing: -1, speaking: -1}); const [levels, setLevels] = useState({reading: -1, listening: -1, writing: -1, speaking: -1});
@@ -37,13 +43,13 @@ export default function Diagnostic({onFinish}: Props) {
}; };
const selectExam = () => { const selectExam = () => {
const examPromises = MODULE_ARRAY.map((module) => getExam(module, true)); const examPromises = DIAGNOSTIC_EXAMS.map((exam) => getExamById(exam[0] as Module, exam[1]));
Promise.all(examPromises).then((exams) => { Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) { if (exams.every((x) => !!x)) {
setExams(exams.map((x) => x!)); setExams(exams.map((x) => x!));
setSelectedModules(exams.map((x) => x!.module)); setSelectedModules(exams.map((x) => x!.module));
router.push("/exercises"); router.push("/exam");
} }
}); });
}; };
@@ -52,7 +58,7 @@ export default function Diagnostic({onFinish}: Props) {
axios axios
.patch("/api/users/update", { .patch("/api/users/update", {
focus, focus,
levels: Object.values(levels).includes(-1) ? {reading: 0, listening: 0, writing: 0, speaking: 0} : levels, levels: Object.values(levels).includes(-1) ? {reading: -1, listening: -1, writing: -1, speaking: -1} : levels,
desiredLevels, desiredLevels,
isFirstLogin: false, isFirstLogin: false,
}) })
@@ -67,7 +73,7 @@ export default function Diagnostic({onFinish}: Props) {
<div className="flex flex-col items-center justify-center gap-8 w-full"> <div className="flex flex-col items-center justify-center gap-8 w-full">
<h2 className="font-semibold text-xl">What is your current focus?</h2> <h2 className="font-semibold text-xl">What is your current focus?</h2>
<div className="flex flex-col gap-16 w-full"> <div className="flex flex-col gap-16 w-full">
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16"> <div className="grid grid-cols-2 gap-y-4 gap-x-16">
<button <button
onClick={() => setFocus("academic")} onClick={() => setFocus("academic")}
className={clsx( className={clsx(
@@ -93,8 +99,8 @@ export default function Diagnostic({onFinish}: Props) {
</div> </div>
<div className="flex flex-col items-center justify-center gap-8 w-full"> <div className="flex flex-col items-center justify-center gap-8 w-full">
<h2 className="font-semibold text-xl">What is your current IELTS level?</h2> <h2 className="font-semibold text-xl">What is your current IELTS level?</h2>
<div className="flex flex-col gap-32 w-full mb-20"> <div className="flex flex-col gap-16 w-full">
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16 mb-24"> <div className="grid grid-cols-2 gap-y-4 gap-x-16">
<div className="w-full flex flex-col gap-3.5 relative"> <div className="w-full flex flex-col gap-3.5 relative">
<span className="text-sm text-mti-gray-dim"> <span className="text-sm text-mti-gray-dim">
<span className="font-bold">Reading</span> level <span className="font-bold">Reading</span> level
@@ -107,7 +113,7 @@ export default function Diagnostic({onFinish}: Props) {
</span> </span>
<BsChevronDown className="text-mti-gray-cool" size={12} /> <BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button> </Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl"> <Menu.Items className="absolute origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl overflow-hidden">
{Object.values(writingMarking).map((x) => ( {Object.values(writingMarking).map((x) => (
<Menu.Item key={x}> <Menu.Item key={x}>
<span <span
@@ -132,7 +138,7 @@ export default function Diagnostic({onFinish}: Props) {
</span> </span>
<BsChevronDown className="text-mti-gray-cool" size={12} /> <BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button> </Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl"> <Menu.Items className="absolute origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl overflow-hidden">
{Object.values(writingMarking).map((x) => ( {Object.values(writingMarking).map((x) => (
<Menu.Item key={x}> <Menu.Item key={x}>
<span <span
@@ -157,7 +163,7 @@ export default function Diagnostic({onFinish}: Props) {
</span> </span>
<BsChevronDown className="text-mti-gray-cool" size={12} /> <BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button> </Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl"> <Menu.Items className="absolute origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl overflow-hidden">
{Object.values(writingMarking).map((x) => ( {Object.values(writingMarking).map((x) => (
<Menu.Item key={x}> <Menu.Item key={x}>
<span <span
@@ -182,7 +188,7 @@ export default function Diagnostic({onFinish}: Props) {
</span> </span>
<BsChevronDown className="text-mti-gray-cool" size={12} /> <BsChevronDown className="text-mti-gray-cool" size={12} />
</Menu.Button> </Menu.Button>
<Menu.Items className="absolute overflow-y-scroll scrollbar-hide max-h-[230px] origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl"> <Menu.Items className="absolute origin-top top-full bg-white flex flex-col items-center w-full z-20 drop-shadow-lg rounded-2xl">
{Object.values(writingMarking).map((x) => ( {Object.values(writingMarking).map((x) => (
<Menu.Item key={x}> <Menu.Item key={x}>
<span <span
@@ -196,22 +202,13 @@ export default function Diagnostic({onFinish}: Props) {
</Menu> </Menu>
</div> </div>
</div> </div>
<div className="md:self-end flex -md:flex-col justify-between w-full gap-8 absolute bottom-8 left-0 px-4 md:px-8">
<div className="w-full tooltip" data-tip="Your screen size is too small to perform a diagnostic test"> <div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
className="group flex items-center justify-center gap-6 relative md:max-w-[400px] w-full md:hidden"
disabled>
<BsQuestionSquare className="text-mti-purple-light transition duration-300 ease-in-out" size={20} />
<span>Perform diagnostic test instead</span>
</Button>
</div>
<Button <Button
onClick={() => updateUser(selectExam)} onClick={() => updateUser(selectExam)}
color="purple" color="purple"
variant="outline" variant="outline"
className="group flex items-center justify-center gap-6 relative md:max-w-[400px] w-full -md:hidden" className="group flex items-center justify-center gap-6 relative max-w-[400px] w-full"
disabled={!focus}> disabled={!focus}>
<BsQuestionSquare <BsQuestionSquare
className="text-mti-purple-light group-hover:text-white transition duration-300 ease-in-out" className="text-mti-purple-light group-hover:text-white transition duration-300 ease-in-out"
@@ -220,7 +217,7 @@ export default function Diagnostic({onFinish}: Props) {
/> />
<span onClick={() => updateUser(selectExam)}>Perform diagnostic test instead</span> <span onClick={() => updateUser(selectExam)}>Perform diagnostic test instead</span>
</Button> </Button>
<Button color="purple" className="md:max-w-[400px] w-full" onClick={() => updateUser(onFinish)} disabled={isNextDisabled()}> <Button color="purple" className="max-w-[400px] w-full" onClick={() => updateUser(onFinish)} disabled={isNextDisabled()}>
Next Step Next Step
</Button> </Button>
</div> </div>

View File

@@ -35,10 +35,10 @@ function WordsDrawer({words, isOpen, blankId, previouslySelectedWord, onCancel,
<div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div> <div className="rounded-full w-6 h-6 flex items-center justify-center text-white bg-mti-purple-light">{blankId}</div>
<span> Choose the correct word:</span> <span> Choose the correct word:</span>
</div> </div>
<div className="grid grid-cols-6 gap-6" key="word-array"> <div className="grid grid-cols-6 gap-6">
{words.map(({word, isDisabled}) => ( {words.map(({word, isDisabled}) => (
<button <button
key={`${word}_${blankId}`} key={word}
onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))} onClick={() => setSelectedWord((prev) => (prev === word ? undefined : word))}
className={clsx( className={clsx(
"rounded-full py-3 text-center transition duration-300 ease-in-out", "rounded-full py-3 text-center transition duration-300 ease-in-out",
@@ -81,7 +81,6 @@ export default function FillBlanks({
const [isDrawerShowing, setIsDrawerShowing] = useState(false); const [isDrawerShowing, setIsDrawerShowing] = useState(false);
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const allBlanks = Array.from(text.match(/({{\d+}})/g) || []).map((x) => x.replaceAll("{", "").replaceAll("}", ""));
useEffect(() => { useEffect(() => {
setTimeout(() => setIsDrawerShowing(!!currentBlankId), 100); setTimeout(() => setIsDrawerShowing(!!currentBlankId), 100);
@@ -94,10 +93,8 @@ export default function FillBlanks({
const calculateScore = () => { const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0; const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = answers.filter( const correct = answers.filter((x) => solutions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
(x) => solutions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false, const missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length;
).length;
const missing = total - answers.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing}; return {total, correct, missing};
}; };
@@ -131,7 +128,6 @@ export default function FillBlanks({
<div className="flex flex-col gap-4 mt-4 h-full mb-20"> <div className="flex flex-col gap-4 mt-4 h-full mb-20">
{(!!currentBlankId || isDrawerShowing) && ( {(!!currentBlankId || isDrawerShowing) && (
<WordsDrawer <WordsDrawer
key={currentBlankId}
blankId={currentBlankId} blankId={currentBlankId}
words={words.map((word) => ({word, isDisabled: allowRepetition ? false : answers.map((x) => x.solution).includes(word)}))} words={words.map((word) => ({word, isDisabled: allowRepetition ? false : answers.map((x) => x.solution).includes(word)}))}
previouslySelectedWord={currentBlankId ? answers.find((x) => x.id === currentBlankId)?.solution : undefined} previouslySelectedWord={currentBlankId ? answers.find((x) => x.id === currentBlankId)?.solution : undefined}
@@ -139,10 +135,6 @@ export default function FillBlanks({
onCancel={() => setCurrentBlankId(undefined)} onCancel={() => setCurrentBlankId(undefined)}
onAnswer={(solution: string) => { onAnswer={(solution: string) => {
setAnswers((prev) => [...prev.filter((x) => x.id !== currentBlankId), {id: currentBlankId!, solution}]); setAnswers((prev) => [...prev.filter((x) => x.id !== currentBlankId), {id: currentBlankId!, solution}]);
if (allBlanks.findIndex((x) => x === currentBlankId) + 1 < allBlanks.length) {
setCurrentBlankId(allBlanks[allBlanks.findIndex((x) => x === currentBlankId) + 1]);
return;
}
setCurrentBlankId(undefined); setCurrentBlankId(undefined);
}} }}
/> />

View File

@@ -1,240 +0,0 @@
import {InteractiveSpeakingExercise} from "@/interfaces/exam";
import {CommonProps} from ".";
import {useEffect, useState} from "react";
import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs";
import dynamic from "next/dynamic";
import Button from "../Low/Button";
import useExamStore from "@/stores/examStore";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
ssr: false,
});
export default function InteractiveSpeaking({id, title, text, type, prompts, onNext, onBack}: InteractiveSpeakingExercise & CommonProps) {
const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>();
const [promptIndex, setPromptIndex] = useState(0);
const [answers, setAnswers] = useState<{prompt: string; blob: string}[]>([]);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
if (hasExamEnded) {
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]);
useEffect(() => {
let recordingInterval: NodeJS.Timer | undefined = undefined;
if (isRecording) {
recordingInterval = setInterval(() => setRecordingDuration((prev) => prev + 1), 1000);
} else if (recordingInterval) {
clearInterval(recordingInterval);
}
return () => {
if (recordingInterval) clearInterval(recordingInterval);
};
}, [isRecording]);
useEffect(() => {
if (promptIndex === answers.length - 1) {
setMediaBlob(answers[promptIndex].blob);
}
}, [answers, promptIndex]);
const saveAnswer = () => {
const answer = {
prompt: prompts[promptIndex].text,
blob: mediaBlob!,
};
setAnswers((prev) => [...prev, answer]);
setMediaBlob(undefined);
};
return (
<div className="flex flex-col h-full w-full gap-9">
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<div className="flex flex-col gap-3">
<span className="font-semibold">{title}</span>
</div>
{prompts && prompts.length > 0 && (
<div className="flex flex-col gap-4 w-full items-center">
<video key={promptIndex} autoPlay controls className="max-w-3xl rounded-xl">
<source src={prompts[promptIndex].video_url} />
</video>
</div>
)}
</div>
<ReactMediaRecorder
audio
key={promptIndex}
onStop={(blob) => setMediaBlob(blob)}
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<p className="text-base font-normal">Record your answer:</p>
<div className="flex gap-8 items-center justify-center py-8">
{status === "idle" && (
<>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
{status === "idle" && (
<BsMicFill
onClick={() => {
setRecordingDuration(0);
startRecording();
setIsRecording(true);
}}
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
/>
)}
</>
)}
{status === "recording" && (
<>
<div className="flex gap-4 items-center">
<span className="text-xs w-9">
{Math.floor(recordingDuration / 60)
.toString(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.padStart(2, "0")}
</span>
</div>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
<div className="flex gap-4 items-center">
<BsPauseCircle
onClick={() => {
setIsRecording(false);
pauseRecording();
}}
className="text-red-500 w-8 h-8 cursor-pointer"
/>
<BsCheckCircleFill
onClick={() => {
setIsRecording(false);
stopRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
</div>
</>
)}
{status === "paused" && (
<>
<div className="flex gap-4 items-center">
<span className="text-xs w-9">
{Math.floor(recordingDuration / 60)
.toString(10)
.padStart(2, "0")}
:
{Math.floor(recordingDuration % 60)
.toString(10)
.padStart(2, "0")}
</span>
</div>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
<div className="flex gap-4 items-center">
<BsPlayCircle
onClick={() => {
setIsRecording(true);
resumeRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
<BsCheckCircleFill
onClick={() => {
setIsRecording(false);
stopRecording();
}}
className="text-mti-purple-light w-8 h-8 cursor-pointer"
/>
</div>
</>
)}
{status === "stopped" && mediaBlobUrl && (
<>
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" />
<div className="flex gap-4 items-center">
<BsTrashFill
className="text-mti-gray-cool cursor-pointer w-5 h-5"
onClick={() => {
setRecordingDuration(0);
clearBlobUrl();
setMediaBlob(undefined);
}}
/>
<BsMicFill
onClick={() => {
clearBlobUrl();
setRecordingDuration(0);
startRecording();
setIsRecording(true);
setMediaBlob(undefined);
}}
className="h-5 w-5 text-mti-gray-cool cursor-pointer"
/>
</div>
</>
)}
</div>
</div>
)}
/>
<div className="self-end flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: answers,
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
disabled={!mediaBlob}
onClick={() => {
saveAnswer();
if (promptIndex + 1 < prompts.length) {
setPromptIndex((prev) => prev + 1);
return;
}
onNext({
exercise: id,
solutions: [
...answers,
{
prompt: prompts[promptIndex].text,
blob: mediaBlob!,
},
],
score: {correct: 1, total: 1, missing: 0},
type,
});
}}
className="max-w-[200px] self-end w-full">
{promptIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
</Button>
</div>
</div>
);
}

View File

@@ -18,10 +18,8 @@ export default function MatchSentences({id, options, type, prompt, sentences, us
const calculateScore = () => { const calculateScore = () => {
const total = sentences.length; const total = sentences.length;
const correct = answers.filter( const correct = answers.filter((x) => sentences.find((y) => y.id === x.question)?.solution === x.option || false).length;
(x) => sentences.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false, const missing = total - answers.filter((x) => sentences.find((y) => y.id === x.question)).length;
).length;
const missing = total - answers.filter((x) => sentences.find((y) => y.id.toString() === x.question.toString())).length;
return {total, correct, missing}; return {total, correct, missing};
}; };

View File

@@ -16,30 +16,30 @@ function Question({
return ( return (
<div className="flex flex-col gap-10"> <div className="flex flex-col gap-10">
<span className="">{prompt}</span> <span className="">{prompt}</span>
<div className="flex flex-wrap gap-4 justify-between"> <div className="flex justify-between">
{variant === "image" && {variant === "image" &&
options.map((option) => ( options.map((option) => (
<div <div
key={option.id.toString()} key={option.id}
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)} onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
className={clsx( className={clsx(
"flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative", "flex flex-col items-center border border-mti-gray-platinum p-4 px-8 rounded-xl gap-4 cursor-pointer bg-white relative",
userSolution === option.id.toString() && "border-mti-purple-light", userSolution === option.id && "border-mti-purple-light",
)}> )}>
<span className={clsx("text-sm", userSolution !== option.id.toString() && "opacity-50")}>{option.id.toString()}</span> <span className={clsx("text-sm", userSolution !== option.id && "opacity-50")}>{option.id}</span>
<img src={option.src!} alt={`Option ${option.id.toString()}`} /> <img src={option.src!} alt={`Option ${option.id}`} />
</div> </div>
))} ))}
{variant === "text" && {variant === "text" &&
options.map((option) => ( options.map((option) => (
<div <div
key={option.id.toString()} key={option.id}
onClick={() => (onSelectOption ? onSelectOption(option.id.toString()) : null)} onClick={() => (onSelectOption ? onSelectOption(option.id) : null)}
className={clsx( className={clsx(
"flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm", "flex border p-4 rounded-xl gap-2 cursor-pointer bg-white text-sm",
userSolution === option.id.toString() && "border-mti-purple-light", userSolution === option.id && "border-mti-purple-light",
)}> )}>
<span className="font-semibold">{option.id.toString()}.</span> <span className="font-semibold">{option.id}.</span>
<span>{option.text}</span> <span>{option.text}</span>
</div> </div>
))} ))}
@@ -66,10 +66,8 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
const calculateScore = () => { const calculateScore = () => {
const total = questions.length; const total = questions.length;
const correct = answers.filter( const correct = answers.filter((x) => questions.find((y) => y.id === x.question)?.solution === x.option || false).length;
(x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false, const missing = total - answers.filter((x) => questions.find((y) => y.id === x.question)).length;
).length;
const missing = total - answers.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
return {total, correct, missing}; return {total, correct, missing};
}; };
@@ -92,7 +90,7 @@ export default function MultipleChoice({id, prompt, type, questions, userSolutio
return ( return (
<> <>
<div className="flex flex-col gap-2 mt-4 h-fit mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8"> <div className="flex flex-col gap-2 mt-4 h-full mb-20 bg-mti-gray-smoke rounded-xl px-16 py-8">
<span className="text-xl font-semibold">{prompt}</span> <span className="text-xl font-semibold">{prompt}</span>
{questionIndex < questions.length && ( {questionIndex < questions.length && (
<Question <Question

View File

@@ -11,7 +11,7 @@ const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mo
ssr: false, ssr: false,
}); });
export default function Speaking({id, title, text, video_url, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) { export default function Speaking({id, title, text, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) {
const [recordingDuration, setRecordingDuration] = useState(0); const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>(); const [mediaBlob, setMediaBlob] = useState<string>();
@@ -45,41 +45,30 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
return ( return (
<div className="flex flex-col h-full w-full gap-9"> <div className="flex flex-col h-full w-full gap-9">
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16"> <div className="flex flex-col w-full gap-14 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<span className="font-semibold">{title}</span> <span className="font-semibold">{title}</span>
{!video_url && ( <span className="font-regular">
<span className="font-regular"> {text.split("\\n").map((line, index) => (
{text.split("\\n").map((line, index) => ( <Fragment key={index}>
<Fragment key={index}> <span>{line}</span>
<span>{line}</span> <br />
<br /> </Fragment>
</Fragment> ))}
</span>
</div>
{prompts && prompts.length > 0 && (
<div className="flex flex-col gap-4">
<span className="font-bold">You should talk about the following things:</span>
<div className="flex flex-col gap-1 ml-4">
{prompts.map((x, index) => (
<li className="italic" key={index}>
{x}
</li>
))} ))}
</span>
)}
</div>
<div className="flex gap-6">
{video_url && (
<div className="flex flex-col gap-4 w-full items-center">
<video key={id} autoPlay controls className="max-w-3xl rounded-xl">
<source src={video_url} />
</video>
</div> </div>
)} </div>
{prompts && prompts.length > 0 && ( )}
<div className="flex flex-col gap-4">
<span className="font-bold">You should talk about the following things:</span>
<div className="flex flex-col gap-1 ml-4">
{prompts.map((x, index) => (
<li className="italic" key={index}>
{x}
</li>
))}
</div>
</div>
)}
</div>
</div> </div>
<ReactMediaRecorder <ReactMediaRecorder

View File

@@ -16,10 +16,8 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
const calculateScore = () => { const calculateScore = () => {
const total = questions.length || 0; const total = questions.length || 0;
const correct = answers.filter( const correct = answers.filter((x) => questions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
(x) => questions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false, const missing = total - answers.filter((x) => questions.find((y) => x.id === y.id)).length;
).length;
const missing = total - answers.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing}; return {total, correct, missing};
}; };
@@ -63,34 +61,26 @@ export default function TrueFalse({id, type, prompt, questions, userSolutions, o
<span className="text-sm w-full leading-6">You can click a selected option again to deselect it.</span> <span className="text-sm w-full leading-6">You can click a selected option again to deselect it.</span>
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-8"> <div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-8">
{questions.map((question, index) => ( {questions.map((question, index) => (
<div key={question.id.toString()} className="flex flex-col gap-4"> <div key={question.id} className="flex flex-col gap-4">
<span> <span>
{index + 1}. {question.prompt} {index + 1}. {question.prompt}
</span> </span>
<div className="flex gap-4"> <div className="flex gap-4">
<Button <Button
variant={ variant={answers.find((x) => x.id === question.id)?.solution === "true" ? "solid" : "outline"}
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "true" ? "solid" : "outline" onClick={() => toggleAnswer("true", question.id)}
}
onClick={() => toggleAnswer("true", question.id.toString())}
className="!py-2"> className="!py-2">
True True
</Button> </Button>
<Button <Button
variant={ variant={answers.find((x) => x.id === question.id)?.solution === "false" ? "solid" : "outline"}
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "false" ? "solid" : "outline" onClick={() => toggleAnswer("false", question.id)}
}
onClick={() => toggleAnswer("false", question.id.toString())}
className="!py-2"> className="!py-2">
False False
</Button> </Button>
<Button <Button
variant={ variant={answers.find((x) => x.id === question.id)?.solution === "not_given" ? "solid" : "outline"}
answers.find((x) => x.id.toString() === question.id.toString())?.solution === "not_given" onClick={() => toggleAnswer("not_given", question.id)}
? "solid"
: "outline"
}
onClick={() => toggleAnswer("not_given", question.id.toString())}
className="!py-2"> className="!py-2">
Not Given Not Given
</Button> </Button>

View File

@@ -36,7 +36,7 @@ function Blank({
return ( return (
<input <input
className="py-2 px-3 mx-2 rounded-2xl w-48 bg-white focus:outline-none my-2" className="py-2 px-3 rounded-2xl w-48 bg-white focus:outline-none my-2"
placeholder={id} placeholder={id}
onChange={(e) => setUserInput(e.target.value)} onChange={(e) => setUserInput(e.target.value)}
onBlur={() => setUserSolution(userInput)} onBlur={() => setUserSolution(userInput)}
@@ -61,9 +61,9 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
const correct = answers.filter( const correct = answers.filter(
(x) => (x) =>
solutions solutions
.find((y) => x.id.toString() === y.id.toString()) .find((y) => x.id === y.id)
?.solution.map((y) => y.toLowerCase().trim()) ?.solution.map((y) => y.toLowerCase())
.includes(x.solution.toLowerCase().trim()) || false, .includes(x.solution.toLowerCase()) || false,
).length; ).length;
const missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length; const missing = total - answers.filter((x) => solutions.find((y) => x.id === y.id)).length;
@@ -91,10 +91,10 @@ export default function WriteBlanks({id, prompt, type, maxWords, solutions, user
<div className="flex flex-col gap-4 mt-4 h-full mb-20"> <div className="flex flex-col gap-4 mt-4 h-full mb-20">
<span className="text-sm w-full leading-6"> <span className="text-sm w-full leading-6">
{prompt.split("\\n").map((line, index) => ( {prompt.split("\\n").map((line, index) => (
<span key={index}> <Fragment key={index}>
{line} {line}
<br /> <br />
</span> </Fragment>
))} ))}
</span> </span>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6"> <span className="bg-mti-gray-smoke rounded-xl px-5 py-6">

View File

@@ -1,7 +1,7 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {WritingExercise} from "@/interfaces/exam"; import {WritingExercise} from "@/interfaces/exam";
import {CommonProps} from "."; import {CommonProps} from ".";
import React, {Fragment, useEffect, useRef, useState} from "react"; import {Fragment, useEffect, useState} from "react";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import Button from "../Low/Button"; import Button from "../Low/Button";
import {Dialog, Transition} from "@headlessui/react"; import {Dialog, Transition} from "@headlessui/react";
@@ -25,20 +25,6 @@ export default function Writing({
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => {
const listener = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
e.preventDefault();
}
};
document.addEventListener("keydown", listener);
return () => {
document.removeEventListener("keydown", listener);
};
}, []);
useEffect(() => { useEffect(() => {
if (hasExamEnded) onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type}); if (hasExamEnded) onNext({exercise: id, solutions: [{id, solution: inputText}], score: {correct: 1, total: 1, missing: 0}, type});
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -94,11 +80,11 @@ export default function Writing({
<div className="flex flex-col h-full w-full gap-9 mb-20"> <div className="flex flex-col h-full w-full gap-9 mb-20">
<div className="flex flex-col w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16"> <div className="flex flex-col w-full gap-7 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<span> <span>
{prefix.split("\\n").map((line, index) => ( {prefix.split("\\n").map((line) => (
<React.Fragment key={index}> <>
{line} {line}
<br /> <br />
</React.Fragment> </>
))} ))}
</span> </span>
<span className="font-semibold"> <span className="font-semibold">
@@ -121,22 +107,19 @@ export default function Writing({
<div className="w-full h-full flex flex-col gap-4"> <div className="w-full h-full flex flex-col gap-4">
<span> <span>
{suffix.split("\\n").map((line, index) => ( {suffix.split("\\n").map((line) => (
<React.Fragment key={index}> <>
{line} {line}
<br /> <br />
</React.Fragment> </>
))} ))}
</span> </span>
<textarea <textarea
onContextMenu={(e) => e.preventDefault()} className="w-full h-full min-h-[148px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
className="w-full h-full min-h-[300px] cursor-text px-7 py-8 input border-2 border-mti-gray-platinum bg-white rounded-3xl"
onChange={(e) => setInputText(e.target.value)} onChange={(e) => setInputText(e.target.value)}
value={inputText} value={inputText}
placeholder="Write your text here..." placeholder="Write your text here..."
spellCheck={false}
/> />
<span className="text-base self-end text-mti-gray-cool">Word Count: {inputText.split(" ").filter((x) => x !== "").length}</span>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,6 @@
import { import {
Exercise, Exercise,
FillBlanksExercise, FillBlanksExercise,
InteractiveSpeakingExercise,
MatchSentencesExercise, MatchSentencesExercise,
MultipleChoiceExercise, MultipleChoiceExercise,
SpeakingExercise, SpeakingExercise,
@@ -17,7 +16,6 @@ import WriteBlanks from "./WriteBlanks";
import Writing from "./Writing"; import Writing from "./Writing";
import Speaking from "./Speaking"; import Speaking from "./Speaking";
import TrueFalse from "./TrueFalse"; import TrueFalse from "./TrueFalse";
import InteractiveSpeaking from "./InteractiveSpeaking";
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false}); const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
@@ -29,20 +27,18 @@ export interface CommonProps {
export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserSolution) => void, onBack: (userSolutions: UserSolution) => void) => { export const renderExercise = (exercise: Exercise, onNext: (userSolutions: UserSolution) => void, onBack: (userSolutions: UserSolution) => void) => {
switch (exercise.type) { switch (exercise.type) {
case "fillBlanks": case "fillBlanks":
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />; return <FillBlanks {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "trueFalse": case "trueFalse":
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />; return <TrueFalse {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
case "matchSentences": case "matchSentences":
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />; return <MatchSentences {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
case "multipleChoice": case "multipleChoice":
return <MultipleChoice key={exercise.id} {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />; return <MultipleChoice {...(exercise as MultipleChoiceExercise)} onNext={onNext} onBack={onBack} />;
case "writeBlanks": case "writeBlanks":
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />; return <WriteBlanks {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
case "writing": case "writing":
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />; return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
case "speaking": case "speaking":
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />; return <Speaking {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
case "interactiveSpeaking":
return <InteractiveSpeaking key={exercise.id} {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
} }
}; };

View File

@@ -1,9 +1,13 @@
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
interface Props { interface Props {
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter: Function,
} }
export default function FocusLayer({onFocusLayerMouseEnter}: Props) { export default function FocusLayer({
return <div className="absolute top-0 left-0 bottom-0 right-0" onMouseDown={onFocusLayerMouseEnter} />; onFocusLayerMouseEnter,
}: Props) {
return (
<div className="bg-gray-700 bg-opacity-30 absolute top-0 left-0 bottom-0 right-0" onMouseEnter={onFocusLayerMouseEnter}/>
);
} }

View File

@@ -1,7 +1,6 @@
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import clsx from "clsx"; import clsx from "clsx";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import BottomBar from "../BottomBar";
import Navbar from "../Navbar"; import Navbar from "../Navbar";
import Sidebar from "../Sidebar"; import Sidebar from "../Sidebar";
@@ -10,34 +9,21 @@ interface Props {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
navDisabled?: boolean; navDisabled?: boolean;
focusMode?: boolean; focusMode?: boolean
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: Function;
} }
export default function Layout({user, children, className, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) { export default function Layout({user, children, className, navDisabled = false, focusMode = false, onFocusLayerMouseEnter }: Props) {
const router = useRouter(); const router = useRouter();
return ( return (
<main className="w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke relative"> <main className="w-full min-h-full h-screen flex flex-col bg-mti-gray-smoke">
<Navbar <Navbar user={user} navDisabled={navDisabled} focusMode={focusMode} onFocusLayerMouseEnter={onFocusLayerMouseEnter} />
path={router.pathname}
user={user}
navDisabled={navDisabled}
focusMode={focusMode}
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
/>
<div className="h-full w-full flex gap-2"> <div className="h-full w-full flex gap-2">
<Sidebar <Sidebar path={router.pathname} navDisabled={navDisabled} focusMode={focusMode} onFocusLayerMouseEnter={onFocusLayerMouseEnter}/>
path={router.pathname}
navDisabled={navDisabled}
focusMode={focusMode}
onFocusLayerMouseEnter={onFocusLayerMouseEnter}
className="-md:hidden"
userType={user.type}
/>
<div <div
className={clsx( className={clsx(
"w-full min-h-full h-fit md:mr-8 bg-white shadow-md rounded-2xl p-4 xl:p-10 pb-8 flex flex-col gap-8 relative overflow-hidden mt-2", "w-5/6 min-h-full h-fit mr-8 bg-white shadow-md rounded-2xl p-12 pb-8 flex flex-col gap-12 relative overflow-hidden mt-2",
className, className,
)}> )}>
{children} {children}

View File

@@ -11,10 +11,9 @@ interface Props {
autoPlay?: boolean; autoPlay?: boolean;
disabled?: boolean; disabled?: boolean;
onEnd?: () => void; onEnd?: () => void;
disablePause?: boolean;
} }
export default function AudioPlayer({src, color, autoPlay = false, disabled = false, onEnd, disablePause = false}: Props) { export default function AudioPlayer({src, color, autoPlay = false, disabled = false, onEnd}: Props) {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
@@ -22,19 +21,11 @@ export default function AudioPlayer({src, color, autoPlay = false, disabled = fa
const audioPlayerRef = useRef<HTMLAudioElement | null>(null); const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => { useEffect(() => {
const durationInterval = setInterval(() => { if (audioPlayerRef && audioPlayerRef.current) {
if (duration > 0) clearInterval(durationInterval); const seconds = Math.floor(audioPlayerRef.current.duration);
setDuration(seconds);
const seconds = Math.floor(audioPlayerRef?.current?.duration || 0); }
if (seconds > 0) setDuration(seconds); }, [audioPlayerRef?.current?.readyState]);
}, 300);
if (duration > 0) clearInterval(durationInterval);
return () => {
clearInterval(durationInterval);
};
}, [duration]);
useEffect(() => { useEffect(() => {
let playingInterval: NodeJS.Timer | undefined = undefined; let playingInterval: NodeJS.Timer | undefined = undefined;
@@ -63,8 +54,8 @@ export default function AudioPlayer({src, color, autoPlay = false, disabled = fa
<div className="w-full h-fit flex gap-4 items-center mt-2"> <div className="w-full h-fit flex gap-4 items-center mt-2">
{isPlaying && ( {isPlaying && (
<BsPauseFill <BsPauseFill
className={clsx("text-mti-gray-cool cursor-pointer w-5 h-5", (disabled || disablePause) && "opacity-60 cursor-not-allowed")} className={clsx("text-mti-gray-cool cursor-pointer w-5 h-5", disabled && "opacity-60 cursor-not-allowed")}
onClick={disabled || disablePause ? undefined : togglePlayPause} onClick={disabled ? undefined : togglePlayPause}
/> />
)} )}
{!isPlaying && ( {!isPlaying && (

View File

@@ -1,34 +1,17 @@
import clsx from "clsx"; import clsx from "clsx";
import {ReactNode} from "react"; import {ReactNode} from "react";
import {BsArrowRepeat} from "react-icons/bs";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
color?: "rose" | "purple" | "red" | "green"; color?: "rose" | "purple" | "red";
variant?: "outline" | "solid"; variant?: "outline" | "solid";
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
isLoading?: boolean;
onClick?: () => void; onClick?: () => void;
type?: "button" | "reset" | "submit";
} }
export default function Button({ export default function Button({color = "purple", variant = "solid", disabled = false, className, children, onClick}: Props) {
color = "purple",
variant = "solid",
disabled = false,
isLoading = false,
className,
children,
type,
onClick,
}: Props) {
const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = { const colorClassNames: {[key in typeof color]: {[key in typeof variant]: string}} = {
green: {
solid: "bg-mti-green-light text-white border border-mti-green-light hover:bg-mti-green disabled:text-mti-green disabled:bg-mti-green-ultralight selection:bg-mti-green-dark",
outline:
"bg-transparent text-mti-green-light border border-mti-green-light hover:bg-mti-green-light disabled:text-mti-green disabled:bg-mti-green-ultralight disabled:border-none selection:bg-mti-green-dark hover:text-white selection:text-white",
},
purple: { purple: {
solid: "bg-mti-purple-light text-white border border-mti-purple-light hover:bg-mti-purple disabled:text-mti-purple disabled:bg-mti-purple-ultralight selection:bg-mti-purple-dark", solid: "bg-mti-purple-light text-white border border-mti-purple-light hover:bg-mti-purple disabled:text-mti-purple disabled:bg-mti-purple-ultralight selection:bg-mti-purple-dark",
outline: outline:
@@ -48,20 +31,14 @@ export default function Button({
return ( return (
<button <button
type={type}
onClick={onClick} onClick={onClick}
className={clsx( className={clsx(
"py-4 px-6 rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed cursor-pointer", "py-4 px-6 rounded-full transition ease-in-out duration-300 disabled:cursor-not-allowed",
className, className,
colorClassNames[color][variant], colorClassNames[color][variant],
)} )}
disabled={disabled || isLoading}> disabled={disabled}>
{!isLoading && children} {children}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="text-white animate-spin" size={25} />
</div>
)}
</button> </button>
); );
} }

View File

@@ -1,26 +0,0 @@
import clsx from "clsx";
import {ReactElement, ReactNode} from "react";
import {BsCheck} from "react-icons/bs";
interface Props {
isChecked: boolean;
onChange: (isChecked: boolean) => void;
children: ReactNode;
}
export default function Checkbox({isChecked, onChange, children}: Props) {
return (
<div className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer" onClick={() => onChange(!isChecked)}>
<input type="checkbox" className="hidden" />
<div
className={clsx(
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
"transition duration-300 ease-in-out",
isChecked && "!bg-mti-purple-light ",
)}>
<BsCheck color="white" className="w-full h-full" />
</div>
<span>{children}</span>
</div>
);
}

View File

@@ -1,81 +0,0 @@
import {countries, TCountries} from "countries-list";
import {Fragment, useState} from "react";
import {Combobox, Transition} from "@headlessui/react";
import {BsChevronExpand} from "react-icons/bs";
import countryCodes from "country-codes-list";
interface Props {
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
}
const mapCountries = (codes: string[]) => {
return codes.map((code) => ({
label: `${countryCodes.findOne("countryCode" as any, code).flag} ${countries[code as unknown as keyof TCountries].name} (+${
countries[code as unknown as keyof TCountries].phone
})`,
code,
}));
};
export default function CountrySelect({value, disabled = false, onChange}: Props) {
const [query, setQuery] = useState("");
const filteredCountries =
query === ""
? mapCountries(Object.keys(countries))
: mapCountries(
Object.keys(countries).filter((x) =>
countries[x as unknown as keyof TCountries].name.toLowerCase().includes(query.toLowerCase()),
),
);
return (
<>
<Combobox value={value} onChange={onChange} disabled={disabled}>
<div className="relative mt-1">
<div className="relative w-full cursor-default overflow-hidden ">
<Combobox.Input
className="py-6 w-full px-8 text-sm font-normal placeholder:text-mti-gray-cool bg-white disabled:bg-mti-gray-platinum/40 rounded-full border border-mti-gray-platinum focus:outline-none"
onChange={(e) => setQuery(e.target.value)}
displayValue={(code: string) => {
const country = countries[code as unknown as keyof TCountries];
return `${countryCodes.findOne("countryCode" as any, code).flag} ${country.name} (+${country.phone})`;
}}
/>
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-8">
<BsChevronExpand />
</Combobox.Button>
</div>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
afterLeave={() => setQuery("")}>
<Combobox.Options className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-xl bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{filteredCountries.length === 0 && query !== "" ? (
<div className="relative cursor-default select-none py-2 px-4 text-gray-700">Nothing found.</div>
) : (
filteredCountries.map((country) => (
<Combobox.Option
key={country.code}
value={country.code}
className={({active}) =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${
active ? "bg-mti-purple-light text-white" : "text-gray-900"
}`
}>
{country.label}
</Combobox.Option>
))
)}
</Combobox.Options>
</Transition>
</div>
</Combobox>
</>
);
}

View File

@@ -1,19 +1,16 @@
import clsx from "clsx";
import {useState} from "react"; import {useState} from "react";
interface Props { interface Props {
type: "email" | "text" | "password" | "tel" | "number"; type: "email" | "text" | "password";
required?: boolean; required?: boolean;
label?: string; label?: string;
placeholder?: string; placeholder?: string;
defaultValue?: string | number; defaultValue?: string;
className?: string;
disabled?: boolean;
name: string; name: string;
onChange: (value: string) => void; onChange: (value: string) => void;
} }
export default function Input({type, label, placeholder, name, required = false, defaultValue, className, disabled = false, onChange}: Props) { export default function Input({type, label, placeholder, name, required = false, defaultValue, onChange}: Props) {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
if (type === "password") { if (type === "password") {
@@ -31,7 +28,6 @@ export default function Input({type, label, placeholder, name, required = false,
name={name} name={name}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder} placeholder={placeholder}
defaultValue={defaultValue}
className="w-full px-8 py-6 text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none" className="w-full px-8 py-6 text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
/> />
<p <p
@@ -46,7 +42,7 @@ export default function Input({type, label, placeholder, name, required = false,
} }
return ( return (
<div className={clsx("flex flex-col gap-3 w-full", className)}> <div className="flex flex-col gap-3 w-full">
{label && ( {label && (
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">
{label} {label}
@@ -56,10 +52,9 @@ export default function Input({type, label, placeholder, name, required = false,
<input <input
type={type} type={type}
name={name} name={name}
disabled={disabled}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder} placeholder={placeholder}
className="px-8 py-6 text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none" className="px-8 py-6 text-sm font-normal placeholder:text-mti-gray-cool bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
required={required} required={required}
defaultValue={defaultValue} defaultValue={defaultValue}
/> />

View File

@@ -7,10 +7,9 @@ interface Props {
color: "red" | "rose" | "purple" | Module; color: "red" | "rose" | "purple" | Module;
useColor?: boolean; useColor?: boolean;
className?: string; className?: string;
textClassName?: string;
} }
export default function ProgressBar({label, percentage, color, useColor = false, className, textClassName}: Props) { export default function ProgressBar({label, percentage, color, useColor = false, className}: Props) {
const progressColorClass: {[key in typeof color]: string} = { const progressColorClass: {[key in typeof color]: string} = {
red: "bg-mti-red-light", red: "bg-mti-red-light",
rose: "bg-mti-rose-light", rose: "bg-mti-rose-light",
@@ -19,7 +18,6 @@ export default function ProgressBar({label, percentage, color, useColor = false,
listening: "bg-ielts-listening", listening: "bg-ielts-listening",
writing: "bg-ielts-writing", writing: "bg-ielts-writing",
speaking: "bg-ielts-speaking", speaking: "bg-ielts-speaking",
level: "bg-ielts-level",
}; };
return ( return (
@@ -34,7 +32,7 @@ export default function ProgressBar({label, percentage, color, useColor = false,
style={{width: `${percentage}%`}} style={{width: `${percentage}%`}}
className={clsx("absolute transition-all duration-300 ease-in-out top-0 left-0 h-full overflow-hidden", progressColorClass[color])} className={clsx("absolute transition-all duration-300 ease-in-out top-0 left-0 h-full overflow-hidden", progressColorClass[color])}
/> />
<span className={clsx("z-[1] justify-self-center text-white text-sm font-bold", textClassName)}>{label}</span> <span className="z-10 justify-self-center text-white text-sm font-bold">{label}</span>
</div> </div>
); );
} }

View File

@@ -4,7 +4,7 @@ import {moduleLabels} from "@/utils/moduleUtils";
import clsx from "clsx"; import clsx from "clsx";
import {motion} from "framer-motion"; import {motion} from "framer-motion";
import {ReactNode, useEffect, useState} from "react"; import {ReactNode, useEffect, useState} from "react";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs"; import {BsBook, BsHeadphones, BsMegaphone, BsPen, BsStopwatch} from "react-icons/bs";
import ProgressBar from "../Low/ProgressBar"; import ProgressBar from "../Low/ProgressBar";
import TimerEndedModal from "../TimerEndedModal"; import TimerEndedModal from "../TimerEndedModal";
@@ -46,7 +46,6 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />, listening: <BsHeadphones className="text-ielts-listening w-6 h-6" />,
writing: <BsPen className="text-ielts-writing w-6 h-6" />, writing: <BsPen className="text-ielts-writing w-6 h-6" />,
speaking: <BsMegaphone className="text-ielts-speaking w-6 h-6" />, speaking: <BsMegaphone className="text-ielts-speaking w-6 h-6" />,
level: <BsClipboard className="text-ielts-level w-6 h-6" />,
}; };
return ( return (
@@ -60,14 +59,14 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
/> />
<motion.div <motion.div
className={clsx( className={clsx(
"absolute top-4 right-6 bg-mti-gray-seasalt px-4 py-3 flex items-center gap-2 rounded-full text-mti-gray-davy", "absolute top-4 right-6 bg-mti-gray-seasalt px-3 py-2 flex items-center gap-2 rounded-full text-mti-gray-davy",
warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt", warningMode && !disableTimer && "bg-mti-red-light text-mti-gray-seasalt",
)} )}
initial={{scale: warningMode && !disableTimer ? 0.8 : 1}} initial={{scale: warningMode && !disableTimer ? 0.8 : 1}}
animate={{scale: warningMode && !disableTimer ? 1.1 : 1}} animate={{scale: warningMode && !disableTimer ? 1.1 : 1}}
transition={{repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut"}}> transition={{repeat: Infinity, repeatType: "reverse", duration: 0.5, ease: "easeInOut"}}>
<BsStopwatch className="w-6 h-6" /> <BsStopwatch className="w-4 h-4" />
<span className="text-base font-semibold w-12"> <span className="text-sm font-semibold w-11">
{timer > 0 && ( {timer > 0 && (
<> <>
{Math.floor(timer / 60) {Math.floor(timer / 60)
@@ -89,8 +88,8 @@ export default function ModuleTitle({minTimer, module, label, exerciseIndex, tot
<span className="text-base font-semibold"> <span className="text-base font-semibold">
{moduleLabels[module]} exam {label && `- ${label}`} {moduleLabels[module]} exam {label && `- ${label}`}
</span> </span>
<span className="text-sm font-semibold self-end"> <span className="text-xs font-normal self-end text-mti-gray-davy">
Exercise {exerciseIndex}/{totalExercises} Question {exerciseIndex}/{totalExercises}
</span> </span>
</div> </div>
<ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" /> <ProgressBar color={module} label="" percentage={(exerciseIndex * 100) / totalExercises} className="h-2 w-full" />

View File

@@ -1,138 +0,0 @@
import {User} from "@/interfaces/user";
import {Dialog, Transition} from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
import Image from "next/image";
import Link from "next/link";
import {useRouter} from "next/router";
import {Fragment} from "react";
import {BsShield, BsShieldFill, BsXLg} from "react-icons/bs";
interface Props {
isOpen: boolean;
onClose: () => void;
path: string;
user: User;
}
export default function MobileMenu({isOpen, onClose, path, user}: Props) {
const router = useRouter();
const logout = async () => {
axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500);
});
};
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 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 h-screen transform overflow-hidden bg-white text-left align-middle shadow-xl transition-all text-black flex flex-col gap-8">
<Dialog.Title as="header" className="w-full px-8 py-2 -md:flex justify-between items-center md:hidden shadow-sm">
<Link href="/">
<Image src="/logo_title.png" alt="EnCoach logo" width={69} height={69} />
</Link>
<div className="cursor-pointer" onClick={onClose} tabIndex={0}>
<BsXLg className="text-2xl text-mti-purple-light" onClick={onClose} />
</div>
</Dialog.Title>
<div className="flex flex-col h-full gap-6 px-8 text-lg">
<Link
href="/"
className={clsx(
"transition ease-in-out duration-300 w-fit",
path === "/" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
)}>
Dashboard
</Link>
{(user.type === "student" || user.type === "teacher" || user.type === "developer") && (
<>
<Link
href="/exam"
className={clsx(
"transition ease-in-out duration-300 w-fit",
path === "/exam" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
)}>
Exams
</Link>
<Link
href="/exercises"
className={clsx(
"transition ease-in-out duration-300 w-fit",
path === "/exercises" &&
"text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
)}>
Exercises
</Link>
</>
)}
<Link
href="/stats"
className={clsx(
"transition ease-in-out duration-300 w-fit",
path === "/stats" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
)}>
Stats
</Link>
<Link
href="/record"
className={clsx(
"transition ease-in-out duration-300 w-fit",
path === "/record" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
)}>
Record
</Link>
{user.type !== "student" && (
<Link
href="/admin"
className={clsx(
"transition ease-in-out duration-300 w-fit",
path === "/admin" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
)}>
Admin
</Link>
)}
<Link
href="/profile"
className={clsx(
"transition ease-in-out duration-300 w-fit",
path === "/profile" && "text-mti-purple-light font-semibold border-b-2 border-b-mti-purple-light ",
)}>
Profile
</Link>
<span
className={clsx("transition ease-in-out duration-300 w-fit justify-self-end cursor-pointer")}
onClick={logout}>
Logout
</span>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}

View File

@@ -1,50 +0,0 @@
import {Dialog, Transition} from "@headlessui/react";
import {Fragment, ReactElement} from "react";
interface Props {
isOpen: boolean;
onClose: () => void;
title?: string;
children?: ReactElement;
}
export default function Modal({isOpen, title, onClose, children}: Props) {
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 max-w-6xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
{title && (
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
{title}
</Dialog.Title>
)}
{children}
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
);
}

View File

@@ -1,82 +1,32 @@
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import Link from "next/link"; import Link from "next/link";
import FocusLayer from "@/components/FocusLayer"; import {Avatar} from "primereact/avatar";
import {preventNavigation} from "@/utils/navigation.disabled"; import FocusLayer from '@/components/FocusLayer';
import {useRouter} from "next/router"; import { preventNavigation } from "@/utils/navigation.disabled";
import {BsList} from "react-icons/bs";
import clsx from "clsx";
import moment from "moment";
import MobileMenu from "./MobileMenu";
import {useState} from "react";
interface Props { interface Props {
user: User; user: User;
navDisabled?: boolean; navDisabled?: boolean;
focusMode?: boolean; focusMode?: boolean;
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: Function;
path: string;
} }
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
export default function Navbar({user, path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) { export default function Navbar({user, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const disableNavigation = preventNavigation(navDisabled, focusMode); const disableNavigation = preventNavigation(navDisabled, focusMode);
const router = useRouter();
const expirationDateColor = (date: Date) => {
const momentDate = moment(date);
const today = moment(new Date());
if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light";
if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light";
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
};
const showExpirationDate = () => {
if (!user.subscriptionExpirationDate) return false;
const momentDate = moment(user.subscriptionExpirationDate);
const today = moment(new Date());
return today.add(7, "days").isAfter(momentDate);
};
return ( return (
<> <header className="w-full bg-transparent py-4 gap-2 flex items-center relative">
{user && <MobileMenu path={path} isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} user={user} />} <h1 className="font-bold text-2xl w-1/6 px-8">EnCoach</h1>
<header className="w-full bg-transparent py-2 md:py-4 -md:justify-between md:gap-12 flex items-center relative -md:px-4"> <div className="flex justify-between w-5/6 mr-8">
<Link href={disableNavigation ? "" : "/"} className=" md:px-8 flex gap-8 items-center"> <input type="text" placeholder="Search..." className="rounded-full py-4 px-6 border border-mti-gray-platinum outline-none" />
<img src="/logo.png" alt="EnCoach's Logo" className="w-8 md:w-12" /> <Link href={disableNavigation ? "" : "/profile"} className="flex gap-3 items-center justify-end">
<h1 className="font-bold text-2xl w-1/6 -md:hidden">EnCoach</h1> <img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" />
<span className="text-right">{user.name}</span>
</Link> </Link>
<div className="flex justify-end -md:items-center gap-4 md:w-5/6 md:mr-8"> </div>
{showExpirationDate() && ( {focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter}/>}
<Link </header>
href="https://encoach.com/join"
data-tip="Expiry date"
className={clsx(
"py-2 px-6 w-fit flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out tooltip tooltip-bottom",
!user.subscriptionExpirationDate
? "bg-mti-green-ultralight border-mti-green-light"
: expirationDateColor(user.subscriptionExpirationDate),
"bg-white border-mti-gray-platinum",
)}>
{!user.subscriptionExpirationDate && "Unlimited"}
{user.subscriptionExpirationDate && moment(user.subscriptionExpirationDate).format("DD/MM/YYYY")}
</Link>
)}
<Link href={disableNavigation ? "" : "/profile"} className="flex gap-6 items-center justify-end -md:hidden">
<img src={user.profilePicture} alt={user.name} className="w-10 h-10 rounded-full object-cover" />
<span className="text-right -md:hidden">{user.name}</span>
</Link>
<div className="cursor-pointer md:hidden" onClick={() => setIsMenuOpen(true)}>
<BsList className="text-mti-purple-light w-8 h-8" />
</div>
</div>
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
</header>
</>
); );
} }

View File

@@ -1,85 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import {User} from "@/interfaces/user";
import {calculateAverageLevel} from "@/utils/score";
import {capitalize} from "lodash";
import {ReactElement} from "react";
import ProgressBar from "./Low/ProgressBar";
interface Props {
user: User;
items: {
icon: ReactElement;
value: string | number;
label: string;
}[];
children?: ReactElement;
}
export default function ProfileSummary({user, items}: Props) {
return (
<section className="w-full flex -md:flex-col gap-4 md:gap-8">
<img
src={user.profilePicture}
alt={user.name}
className="aspect-square h-20 md:h-64 rounded-3xl drop-shadow-xl object-cover -md:hidden"
/>
<div className="flex md:flex-col gap-4 md:py-4 w-full -md:items-center">
<img src={user.profilePicture} alt={user.name} className="aspect-square h-24 md:hidden rounded-3xl drop-shadow-xl object-cover" />
<div className="flex -md:flex-col justify-between w-full gap-8">
<div className="flex flex-col gap-2 py-2">
<h1 className="font-bold text-2xl md:text-4xl">{user.name}</h1>
<h6 className="font-normal text-base text-mti-gray-taupe">{capitalize(user.type)}</h6>
</div>
<ProgressBar
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
percentage={100}
color="purple"
className="max-w-xs w-32 md:self-end h-10 -md:hidden"
/>
</div>
<ProgressBar
label=""
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
color="red"
className="w-full h-3 drop-shadow-lg -md:hidden"
/>
<div className="flex justify-between w-full mt-8 -md:hidden">
{items.map((item) => (
<div className="flex gap-4 items-center" key={item.label}>
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
{item.icon}
</div>
<div className="flex flex-col">
<span className="font-bold text-xl">{item.value}</span>
<span className="font-normal text-base text-mti-gray-dim">{item.label}</span>
</div>
</div>
))}
</div>
</div>
<ProgressBar
label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
color="purple"
className="w-full md:hidden h-8"
textClassName="!text-mti-black"
/>
<div className="grid grid-cols-2 gap-4 w-full mt-4 md:hidden">
{items.map((item) => (
<div className="flex gap-4 items-center" key={item.label}>
<div className="w-12 h-12 md:w-16 md:h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-lg md:rounded-xl">
{item.icon}
</div>
<div className="flex flex-col">
<span className="font-bold text-lg md:text-xl">{item.value}</span>
<span className="font-normal text-sm md:text-base text-mti-gray-dim">{item.label}</span>
</div>
</div>
))}
</div>
</section>
);
}

View File

@@ -1,25 +1,20 @@
import clsx from "clsx"; import clsx from "clsx";
import {IconType} from "react-icons"; import {IconType} from "react-icons";
import {MdSpaceDashboard} from "react-icons/md"; import {MdSpaceDashboard} from "react-icons/md";
import {BsFileEarmarkText, BsClockHistory, BsPencil, BsGraphUp, BsChevronBarRight, BsChevronBarLeft, BsShieldFill} from "react-icons/bs"; import {BsFileEarmarkText, BsClockHistory, BsPencil, BsGraphUp, BsShield} from "react-icons/bs";
import {RiLogoutBoxFill} from "react-icons/ri"; import {RiLogoutBoxFill} from "react-icons/ri";
import {SlPencil} from "react-icons/sl"; import {SlPencil} from "react-icons/sl";
import {FaAward} from "react-icons/fa"; import {FaAward} from "react-icons/fa";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import axios from "axios"; import axios from "axios";
import FocusLayer from "@/components/FocusLayer"; import FocusLayer from '@/components/FocusLayer';
import {preventNavigation} from "@/utils/navigation.disabled"; import { preventNavigation } from "@/utils/navigation.disabled";
import {useState} from "react";
import usePreferencesStore from "@/stores/preferencesStore";
import {Type} from "@/interfaces/user";
interface Props { interface Props {
path: string; path: string;
navDisabled?: boolean; navDisabled?: boolean;
focusMode?: boolean; focusMode?: boolean;
onFocusLayerMouseEnter?: () => void; onFocusLayerMouseEnter?: Function;
className?: string;
userType?: Type;
} }
interface NavProps { interface NavProps {
@@ -28,105 +23,52 @@ interface NavProps {
path: string; path: string;
keyPath: string; keyPath: string;
disabled?: boolean; disabled?: boolean;
isMinimized?: boolean;
} }
const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false}: NavProps) => ( const Nav = ({Icon, label, path, keyPath, disabled = false}: NavProps) => (
<Link <Link
href={!disabled ? keyPath : ""} href={!disabled ? keyPath : ""}
className={clsx( className={clsx(
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-gray-500 hover:bg-mti-purple-light hover:text-white", "p-4 px-8 rounded-full flex gap-4 items-center cursor-pointer text-gray-500 hover:bg-mti-purple-light hover:text-white transition duration-300 ease-in-out",
"transition-all duration-300 ease-in-out",
path === keyPath && "bg-mti-purple-light text-white", path === keyPath && "bg-mti-purple-light text-white",
isMinimized ? "w-fit" : "w-full min-w-[200px] 2xl:min-w-[220px] px-8",
)}> )}>
<Icon size={24} /> <Icon size={20} />
{!isMinimized && <span className="text-lg font-semibold">{label}</span>} <span className="text-lg font-semibold">{label}</span>
</Link> </Link>
); );
export default function Sidebar({path, navDisabled = false, focusMode = false, userType, onFocusLayerMouseEnter, className}: Props) { export default function Sidebar({path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) {
const router = useRouter(); const router = useRouter();
const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]);
const logout = async () => { const logout = async () => {
axios.post("/api/logout").finally(() => { axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500); setTimeout(() => router.reload(), 500);
}); });
}; };
const disableNavigation = preventNavigation(navDisabled, focusMode); const disableNavigation: Boolean = preventNavigation(navDisabled, focusMode);
return ( return (
<section <section className="h-full flex bg-transparent flex-col justify-between w-1/6 px-4 py-4 pb-8 relative">
className={clsx( <div className="flex flex-col gap-3">
"h-full flex bg-transparent flex-col justify-between px-4 py-4 pb-8 relative", <Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" />
isMinimized ? "w-fit" : "w-1/6 -xl:w-fit", <Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" />
className, <Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" />
)}> <Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" />
<div className="xl:flex -xl:hidden flex-col gap-3"> <Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" />
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={isMinimized} /> <Nav disabled={disableNavigation} Icon={BsShield} label="Admin" path={path} keyPath="/admin" />
{(userType === "student" || userType === "teacher" || userType === "developer") && (
<>
<Nav
disabled={disableNavigation}
Icon={BsFileEarmarkText}
label="Exams"
path={path}
keyPath="/exam"
isMinimized={isMinimized}
/>
<Nav
disabled={disableNavigation}
Icon={BsPencil}
label="Exercises"
path={path}
keyPath="/exercises"
isMinimized={isMinimized}
/>
</>
)}
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={isMinimized} />
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={isMinimized} />
{userType !== "student" && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Admin" path={path} keyPath="/admin" isMinimized={isMinimized} />
)}
</div>
<div className="xl:hidden -xl:flex flex-col gap-3">
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" isMinimized={true} />
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" isMinimized={true} />
{userType !== "student" && (
<Nav disabled={disableNavigation} Icon={BsShieldFill} label="Admin" path={path} keyPath="/admin" isMinimized={true} />
)}
</div> </div>
<div className="flex flex-col gap-0 absolute bottom-12"> <div
<div role="button"
role="button" tabIndex={1}
tabIndex={1} onClick={focusMode ? () => {} : logout}
onClick={toggleMinimize} className={clsx(
className={clsx( "p-4 px-8 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-rose transition duration-300 ease-in-out",
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-rose -xl:hidden transition duration-300 ease-in-out", "absolute bottom-8",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8", )}>
)}> <RiLogoutBoxFill size={20} />
{isMinimized ? <BsChevronBarRight size={24} /> : <BsChevronBarLeft size={24} />} <span className="text-lg font-medium">Log Out</span>
{!isMinimized && <span className="text-lg font-medium">Minimize</span>}
</div>
<div
role="button"
tabIndex={1}
onClick={focusMode ? () => {} : logout}
className={clsx(
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-black hover:text-mti-rose transition duration-300 ease-in-out",
isMinimized ? "w-fit" : "w-full min-w-[250px] px-8",
)}>
<RiLogoutBoxFill size={24} />
{!isMinimized && <span className="text-lg font-medium -xl:hidden">Log Out</span>}
</div>
</div> </div>
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />} {focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
</section> </section>

View File

@@ -8,10 +8,8 @@ import Button from "../Low/Button";
export default function FillBlanksSolutions({id, type, prompt, solutions, text, userSolutions, onNext, onBack}: FillBlanksExercise & CommonProps) { export default function FillBlanksSolutions({id, type, prompt, solutions, text, userSolutions, onNext, onBack}: FillBlanksExercise & CommonProps) {
const calculateScore = () => { const calculateScore = () => {
const total = text.match(/({{\d+}})/g)?.length || 0; const total = text.match(/({{\d+}})/g)?.length || 0;
const correct = userSolutions.filter( const correct = userSolutions.filter((x) => solutions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
(x) => solutions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false, const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id === y.id)).length;
).length;
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing}; return {total, correct, missing};
}; };
@@ -19,7 +17,7 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
const renderLines = (line: string) => { const renderLines = (line: string) => {
return ( return (
<span> <span>
{reactStringReplace(line, /({{\d+}})/g, (match) => { {reactStringReplace(line, /({{\d}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, ""); const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = userSolutions.find((x) => x.id === id); const userSolution = userSolutions.find((x) => x.id === id);
const solution = solutions.find((x) => x.id === id)!; const solution = solutions.find((x) => x.id === id)!;
@@ -85,13 +83,12 @@ export default function FillBlanksSolutions({id, type, prompt, solutions, text,
))} ))}
</span> </span>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6"> <span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{userSolutions && {text.split("\\n").map((line, index) => (
text.split("\\n").map((line, index) => ( <p key={index}>
<p key={index}> {renderLines(line)}
{renderLines(line)} <br />
<br /> </p>
</p> ))}
))}
</span> </span>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">

View File

@@ -1,124 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import {InteractiveSpeakingExercise} from "@/interfaces/exam";
import {CommonProps} from ".";
import {useEffect, useState} from "react";
import Button from "../Low/Button";
import dynamic from "next/dynamic";
import axios from "axios";
import {speakingReverseMarking} from "@/utils/score";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
export default function InteractiveSpeaking({
id,
type,
title,
text,
prompts,
userSolutions,
onNext,
onBack,
}: InteractiveSpeakingExercise & CommonProps) {
const [solutionsURL, setSolutionsURL] = useState<string[]>([]);
useEffect(() => {
if (userSolutions && userSolutions.length > 0) {
Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, {path: x.answer}, {responseType: "arraybuffer"}))).then(
(values) => {
setSolutionsURL(
values.map(({data}) => {
const blob = new Blob([data], {type: "audio/wav"});
const url = URL.createObjectURL(blob);
return url;
}),
);
},
);
}
}, [userSolutions]);
return (
<>
<div className="flex flex-col h-full w-full gap-8 mb-20">
<div className="flex flex-col w-full gap-8 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<div className="flex flex-col gap-3">
<span className="font-semibold">{title}</span>
</div>
<div className="flex flex-col gap-4">
<span className="font-bold">You should talk about the following things:</span>
<div className="grid grid-cols-3 gap-6 text-center">
{prompts.map((x, index) => (
<div className="italic flex flex-col gap-2 text-sm" key={index}>
<video key={index} controls className="">
<source src={x.video_url} />
</video>
<span>{x.text}</span>
</div>
))}
</div>
</div>
</div>
<div className="w-full h-full flex flex-col gap-8">
<div className="flex items-center gap-8">
{solutionsURL.map((x, index) => (
<div
key={index}
className="w-full min-w-[460px] p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<div className="flex gap-8 items-center justify-center py-8">
<Waveform audio={x} waveColor="#FCDDEC" progressColor="#EF5DA8" />
</div>
</div>
))}
</div>
{userSolutions && userSolutions.length > 0 && (
<div className="flex flex-col gap-4 w-full">
<div className="flex gap-4 px-1">
{Object.keys(userSolutions[0].evaluation!.task_response).map((key) => (
<div className="bg-ielts-speaking text-ielts-speaking-light rounded-xl px-4 py-2" key={key}>
{key}: Level {userSolutions[0].evaluation!.task_response[key]}
</div>
))}
</div>
<div className="w-full h-full min-h-fit cursor-text px-7 py-8 bg-mti-gray-smoke rounded-3xl">
{userSolutions[0].evaluation!.comment}
</div>
</div>
)}
</div>
</div>
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back
</Button>
<Button
color="purple"
onClick={() =>
onNext({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Next
</Button>
</div>
</>
);
}

View File

@@ -21,10 +21,8 @@ export default function MatchSentencesSolutions({
}: MatchSentencesExercise & CommonProps) { }: MatchSentencesExercise & CommonProps) {
const calculateScore = () => { const calculateScore = () => {
const total = sentences.length; const total = sentences.length;
const correct = userSolutions.filter( const correct = userSolutions.filter((x) => sentences.find((y) => y.id === x.question)?.solution === x.option || false).length;
(x) => sentences.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false, const missing = total - userSolutions.filter((x) => sentences.find((y) => y.id === x.question)).length;
).length;
const missing = total - userSolutions.filter((x) => sentences.find((y) => y.id.toString() === x.question.toString())).length;
return {total, correct, missing}; return {total, correct, missing};
}; };
@@ -50,9 +48,9 @@ export default function MatchSentencesSolutions({
className={clsx( className={clsx(
"w-8 h-8 rounded-full z-10 text-white", "w-8 h-8 rounded-full z-10 text-white",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
!userSolutions.find((x) => x.question.toString() === id.toString()) && "!bg-mti-red", !userSolutions.find((x) => x.question === id) && "!bg-mti-red",
userSolutions.find((x) => x.question.toString() === id.toString())?.option === solution && "bg-mti-purple", userSolutions.find((x) => x.question === id)?.option === solution && "bg-mti-purple",
userSolutions.find((x) => x.question.toString() === id.toString())?.option !== solution && "bg-mti-rose", userSolutions.find((x) => x.question === id)?.option !== solution && "bg-mti-rose",
)}> )}>
{id} {id}
</button> </button>
@@ -74,22 +72,21 @@ export default function MatchSentencesSolutions({
</div> </div>
))} ))}
</div> </div>
{userSolutions && {sentences.map((sentence, index) => (
sentences.map((sentence, index) => ( <Xarrow
<Xarrow key={index}
key={index} start={sentence.id}
start={sentence.id} end={sentence.solution}
end={sentence.solution} lineColor={
lineColor={ !userSolutions.find((x) => x.question === sentence.id)
!userSolutions.find((x) => x.question === sentence.id) ? "#CC5454"
? "#CC5454" : userSolutions.find((x) => x.question === sentence.id)?.option === sentence.solution
: userSolutions.find((x) => x.question === sentence.id)?.option === sentence.solution ? "#7872BF"
? "#7872BF" : "#CC5454"
: "#CC5454" }
} showHead={false}
showHead={false} />
/> ))}
))}
</div> </div>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">

View File

@@ -59,10 +59,8 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
const calculateScore = () => { const calculateScore = () => {
const total = questions.length; const total = questions.length;
const correct = userSolutions.filter( const correct = userSolutions.filter((x) => questions.find((y) => y.id === x.question)?.solution === x.option || false).length;
(x) => questions.find((y) => y.id.toString() === x.question.toString())?.solution === x.option || false, const missing = total - userSolutions.filter((x) => questions.find((y) => y.id === x.question)).length;
).length;
const missing = total - userSolutions.filter((x) => questions.find((y) => y.id.toString() === x.question.toString())).length;
return {total, correct, missing}; return {total, correct, missing};
}; };
@@ -88,7 +86,7 @@ export default function MultipleChoice({id, type, prompt, questions, userSolutio
<div className="flex flex-col gap-4 w-full h-full mb-20"> <div className="flex flex-col gap-4 w-full h-full mb-20">
<div className="flex flex-col gap-2 mt-4 h-full bg-mti-gray-smoke rounded-xl px-16 py-8"> <div className="flex flex-col gap-2 mt-4 h-full bg-mti-gray-smoke rounded-xl px-16 py-8">
<span className="text-xl font-semibold">{prompt}</span> <span className="text-xl font-semibold">{prompt}</span>
{userSolutions && questionIndex < questions.length && ( {questionIndex < questions.length && (
<Question <Question
{...questions[questionIndex]} {...questions[questionIndex]}
userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option} userSolution={userSolutions.find((x) => questions[questionIndex].id === x.question)?.option}

View File

@@ -5,61 +5,45 @@ import {Fragment, useEffect, useState} from "react";
import Button from "../Low/Button"; import Button from "../Low/Button";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import axios from "axios"; import axios from "axios";
import {speakingReverseMarking} from "@/utils/score";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) { export default function Speaking({id, type, title, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
const [solutionURL, setSolutionURL] = useState<string>(); const [solutionURL, setSolutionURL] = useState<string>();
useEffect(() => { useEffect(() => {
if (userSolutions && userSolutions.length > 0) { axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => {
axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => { const blob = new Blob([data], {type: "audio/wav"});
const blob = new Blob([data], {type: "audio/wav"}); const url = URL.createObjectURL(blob);
const url = URL.createObjectURL(blob);
setSolutionURL(url); setSolutionURL(url);
}); });
}
}, [userSolutions]); }, [userSolutions]);
return ( return (
<> <>
<div className="flex flex-col h-full w-full gap-8 mb-20"> <div className="flex flex-col h-full w-full gap-8 mb-20">
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16"> <div className="flex flex-col w-full gap-14 bg-mti-gray-smoke rounded-xl py-8 pb-12 px-16">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<span className="font-semibold">{title}</span> <span className="font-semibold">{title}</span>
{!video_url && ( <span className="font-regular">
<span className="font-regular"> {text.split("\\n").map((line, index) => (
{text.split("\\n").map((line, index) => ( <Fragment key={index}>
<Fragment key={index}> <span>{line}</span>
<span>{line}</span> <br />
<br /> </Fragment>
</Fragment> ))}
))} </span>
</span>
)}
</div> </div>
<div className="flex gap-6"> <div className="flex flex-col gap-4">
{video_url && ( <span className="font-bold">You should talk about the following things:</span>
<div className="flex flex-col gap-4 w-full items-center"> <div className="flex flex-col gap-1 ml-4">
<video key={id} autoPlay controls className="max-w-3xl rounded-xl"> {prompts.map((x, index) => (
<source src={video_url} /> <li className="italic" key={index}>
</video> {x}
</div> </li>
)} ))}
{prompts && prompts.length > 0 && ( </div>
<div className="flex flex-col gap-4">
<span className="font-bold">You should talk about the following things:</span>
<div className="flex flex-col gap-1 ml-4">
{prompts.map((x, index) => (
<li className="italic" key={index}>
{x}
</li>
))}
</div>
</div>
)}
</div> </div>
</div> </div>
@@ -94,7 +78,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
onBack({ onBack({
exercise: id, exercise: id,
solutions: userSolutions, solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0}, score: {correct: 1, total: 1, missing: 0},
type, type,
}) })
} }
@@ -107,7 +91,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
onNext({ onNext({
exercise: id, exercise: id,
solutions: userSolutions, solutions: userSolutions,
score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0}, score: {correct: 1, total: 1, missing: 0},
type, type,
}) })
} }

View File

@@ -10,10 +10,8 @@ type Solution = "true" | "false" | "not_given";
export default function TrueFalseSolution({prompt, type, id, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) { export default function TrueFalseSolution({prompt, type, id, questions, userSolutions, onNext, onBack}: TrueFalseExercise & CommonProps) {
const calculateScore = () => { const calculateScore = () => {
const total = questions.length || 0; const total = questions.length || 0;
const correct = userSolutions.filter( const correct = userSolutions.filter((x) => questions.find((y) => x.id === y.id)?.solution === x.solution.toLowerCase() || false).length;
(x) => questions.find((y) => x.id.toString() === y.id.toString())?.solution === x.solution.toLowerCase() || false, const missing = total - userSolutions.filter((x) => questions.find((y) => x.id === y.id)).length;
).length;
const missing = total - userSolutions.filter((x) => questions.find((y) => x.id.toString() === y.id.toString())).length;
return {total, correct, missing}; return {total, correct, missing};
}; };
@@ -64,40 +62,37 @@ export default function TrueFalseSolution({prompt, type, id, questions, userSolu
</div> </div>
<span className="text-sm w-full leading-6">You can click a selected option again to deselect it.</span> <span className="text-sm w-full leading-6">You can click a selected option again to deselect it.</span>
<div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-8"> <div className="bg-mti-gray-smoke rounded-xl px-5 py-6 flex flex-col gap-8">
{userSolutions && {questions.map((question, index) => {
questions.map((question, index) => { const userSolution = userSolutions.find((x) => x.id === question.id);
const userSolution = userSolutions.find((x) => x.id === question.id.toString());
return ( return (
<div key={question.id.toString()} className="flex flex-col gap-4"> <div key={question.id} className="flex flex-col gap-4">
<span> <span>
{index + 1}. {question.prompt} {index + 1}. {question.prompt}
</span> </span>
<div className="flex gap-4"> <div className="flex gap-4">
<Button <Button
variant={question.solution === "true" || userSolution?.solution === "true" ? "solid" : "outline"} variant={question.solution === "true" || userSolution?.solution === "true" ? "solid" : "outline"}
className="!py-2" className="!py-2"
color={getButtonColor("true", question.solution, userSolution?.solution)}> color={getButtonColor("true", question.solution, userSolution?.solution)}>
True True
</Button> </Button>
<Button <Button
variant={question.solution === "false" || userSolution?.solution === "false" ? "solid" : "outline"} variant={question.solution === "false" || userSolution?.solution === "false" ? "solid" : "outline"}
className="!py-2" className="!py-2"
color={getButtonColor("false", question.solution, userSolution?.solution)}> color={getButtonColor("false", question.solution, userSolution?.solution)}>
False False
</Button> </Button>
<Button <Button
variant={ variant={question.solution === "not_given" || userSolution?.solution === "not_given" ? "solid" : "outline"}
question.solution === "not_given" || userSolution?.solution === "not_given" ? "solid" : "outline" className="!py-2"
} color={getButtonColor("not_given", question.solution, userSolution?.solution)}>
className="!py-2" Not Given
color={getButtonColor("not_given", question.solution, userSolution?.solution)}> </Button>
Not Given
</Button>
</div>
</div> </div>
); </div>
})} );
})}
</div> </div>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">

View File

@@ -29,6 +29,7 @@ function Blank({
useEffect(() => { useEffect(() => {
const words = userInput.split(" ").filter((x) => x !== ""); const words = userInput.split(" ").filter((x) => x !== "");
if (words.length >= maxWords) { if (words.length >= maxWords) {
toast.warning(`You have reached your word limit of ${maxWords} words!`, {toastId: "word-limit"});
setUserInput(words.join(" ").trim()); setUserInput(words.join(" ").trim());
if (setUserSolution) setUserSolution(words.join(" ").trim()); if (setUserSolution) setUserSolution(words.join(" ").trim());
} }
@@ -45,21 +46,23 @@ function Blank({
}; };
return ( return (
<span className="inline-flex gap-2 ml-2"> <span className="inline-flex gap-2">
{userSolution && !isUserSolutionCorrect() && ( {userSolution && !isUserSolutionCorrect() && (
<div <input
className="py-2 px-3 rounded-2xl w-fit focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light" className="py-2 px-3 rounded-2xl w-48 focus:outline-none my-2 bg-mti-rose-ultralight text-mti-rose-light"
placeholder={id} placeholder={id}
contentEditable={disabled}> onChange={(e) => setUserInput(e.target.value)}
{userSolution} value={userSolution}
</div> contentEditable={disabled}
/>
)} )}
<div <input
className={clsx("py-2 px-3 rounded-2xl w-fit focus:outline-none my-2", getSolutionStyling())} className={clsx("py-2 px-3 rounded-2xl w-48 focus:outline-none my-2", getSolutionStyling())}
placeholder={id} placeholder={id}
contentEditable={disabled}> onChange={(e) => setUserInput(e.target.value)}
{!solutions ? userInput : solutions.join(" / ")} value={!solutions ? userInput : solutions.join(" / ")}
</div> contentEditable={disabled}
/>
</span> </span>
); );
} }
@@ -80,11 +83,11 @@ export default function WriteBlanksSolutions({
const correct = userSolutions.filter( const correct = userSolutions.filter(
(x) => (x) =>
solutions solutions
.find((y) => x.id.toString() === y.id.toString()) .find((y) => x.id === y.id)
?.solution.map((y) => y.toLowerCase().trim()) ?.solution.map((y) => y.toLowerCase())
.includes(x.solution.toLowerCase().trim()) || false, .includes(x.solution.toLowerCase()) || false,
).length; ).length;
const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id.toString() === y.id.toString())).length; const missing = total - userSolutions.filter((x) => solutions.find((y) => x.id === y.id)).length;
return {total, correct, missing}; return {total, correct, missing};
}; };
@@ -93,13 +96,11 @@ export default function WriteBlanksSolutions({
return ( return (
<span className="text-base leading-5"> <span className="text-base leading-5">
{reactStringReplace(line, /({{\d+}})/g, (match) => { {reactStringReplace(line, /({{\d+}})/g, (match) => {
const id = match.replaceAll(/[\{\}]/g, "").toString(); const id = match.replaceAll(/[\{\}]/g, "");
const userSolution = userSolutions.find((x) => x.id.toString() === id.toString()); const userSolution = userSolutions.find((x) => x.id === id);
const solution = solutions.find((x) => x.id.toString() === id.toString())!; const solution = solutions.find((x) => x.id === id)!;
return ( return <Blank userSolution={userSolution?.solution} maxWords={maxWords} id={id} solutions={solution.solution} disabled />;
<Blank userSolution={userSolution?.solution} maxWords={maxWords} id={id.toString()} solutions={solution.solution} disabled />
);
})} })}
</span> </span>
); );
@@ -117,13 +118,12 @@ export default function WriteBlanksSolutions({
))} ))}
</span> </span>
<span className="bg-mti-gray-smoke rounded-xl px-5 py-6"> <span className="bg-mti-gray-smoke rounded-xl px-5 py-6">
{userSolutions && {text.split("\\n").map((line, index) => (
text.split("\\n").map((line, index) => ( <p key={index}>
<p key={index}> {renderLines(line)}
{renderLines(line)} <br />
<br /> </p>
</p> ))}
))}
</span> </span>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">

View File

@@ -9,7 +9,6 @@ import {Fragment, useEffect, useState} from "react";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import Button from "../Low/Button"; import Button from "../Low/Button";
import {Dialog, Transition} from "@headlessui/react"; import {Dialog, Transition} from "@headlessui/react";
import {writingReverseMarking} from "@/utils/score";
export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) { export default function Writing({id, type, prompt, attachment, userSolutions, onNext, onBack}: WritingExercise & CommonProps) {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
@@ -100,27 +99,13 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on
<Button <Button
color="purple" color="purple"
variant="outline" variant="outline"
onClick={() => onClick={() => onBack({exercise: id, solutions: userSolutions, score: {correct: 1, total: 1, missing: 0}, type})}
onBack({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">
Back Back
</Button> </Button>
<Button <Button
color="purple" color="purple"
onClick={() => onClick={() => onNext({exercise: id, solutions: userSolutions, score: {correct: 1, total: 1, missing: 0}, type})}
onNext({
exercise: id,
solutions: userSolutions,
score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0},
type,
})
}
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">
Next Next
</Button> </Button>

View File

@@ -1,7 +1,6 @@
import { import {
Exercise, Exercise,
FillBlanksExercise, FillBlanksExercise,
InteractiveSpeakingExercise,
MatchSentencesExercise, MatchSentencesExercise,
MultipleChoiceExercise, MultipleChoiceExercise,
SpeakingExercise, SpeakingExercise,
@@ -12,7 +11,6 @@ import {
} from "@/interfaces/exam"; } from "@/interfaces/exam";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import FillBlanks from "./FillBlanks"; import FillBlanks from "./FillBlanks";
import InteractiveSpeaking from "./InteractiveSpeaking";
import MultipleChoice from "./MultipleChoice"; import MultipleChoice from "./MultipleChoice";
import Speaking from "./Speaking"; import Speaking from "./Speaking";
import TrueFalseSolution from "./TrueFalse"; import TrueFalseSolution from "./TrueFalse";
@@ -42,7 +40,5 @@ export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: (
return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />; return <Writing {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
case "speaking": case "speaking":
return <Speaking {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />; return <Speaking {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
case "interactiveSpeaking":
return <InteractiveSpeaking {...(exercise as InteractiveSpeakingExercise)} onNext={onNext} onBack={onBack} />;
} }
}; };

View File

@@ -1,352 +0,0 @@
import useStats from "@/hooks/useStats";
import {EMPLOYMENT_STATUS, User} from "@/interfaces/user";
import {groupBySession, averageScore} from "@/utils/stats";
import {RadioGroup} from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
import moment from "moment";
import {Divider} from "primereact/divider";
import {useState} from "react";
import ReactDatePicker from "react-datepicker";
import {BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs";
import {toast} from "react-toastify";
import Button from "./Low/Button";
import Checkbox from "./Low/Checkbox";
import CountrySelect from "./Low/CountrySelect";
import Input from "./Low/Input";
import ProfileSummary from "./ProfileSummary";
import Select from "react-select";
import useUsers from "@/hooks/useUsers";
const expirationDateColor = (date: Date) => {
const momentDate = moment(date);
const today = moment(new Date());
if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light";
if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light";
if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light";
};
interface Props {
user: User;
loggedInUser: User;
onClose: (reload?: boolean) => void;
onViewStudents?: () => void;
onViewTeachers?: () => void;
}
const UserCard = ({user, loggedInUser, onClose, onViewStudents, onViewTeachers}: Props) => {
const [expiryDate, setExpiryDate] = useState<Date | null | undefined>(user.subscriptionExpirationDate);
const [referralAgent, setReferralAgent] = useState(user.corporateInformation?.referralAgent);
const [type, setType] = useState(user.type);
const [status, setStatus] = useState(user.status);
const {stats} = useStats(user.id);
const {users} = useUsers();
const updateUser = () => {
if (!confirm(`Are you sure you want to update ${user.name}'s account?`)) return;
axios
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {...user, subscriptionExpirationDate: expiryDate, type, status})
.then(() => {
toast.success("User updated successfully!");
onClose(true);
})
.catch(() => {
toast.error("Something went wrong!", {toastId: "update-error"});
});
};
return (
<>
<ProfileSummary
user={user}
items={[
{
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: Object.keys(groupBySession(stats)).length,
label: "Exams",
},
{
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: stats.length,
label: "Exercises",
},
{
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
label: "Average Score",
},
]}
/>
<section className="flex flex-col gap-4 justify-between">
<div className="flex flex-col md:flex-row gap-8 w-full">
<Input
label="Name"
type="text"
name="name"
onChange={() => null}
placeholder="Enter your name"
defaultValue={user.name}
disabled
/>
<Input
label="E-mail Address"
type="email"
name="email"
onChange={() => null}
placeholder="Enter email address"
defaultValue={user.email}
disabled
/>
</div>
<div className="flex flex-col md:flex-row gap-8 w-full">
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Country</label>
<CountrySelect disabled value={user.demographicInformation?.country} />
</div>
<Input
type="tel"
name="phone"
label="Phone number"
onChange={() => null}
placeholder="Enter phone number"
defaultValue={user.demographicInformation?.phone}
disabled
/>
</div>
<div className="flex flex-col md:flex-row gap-8 w-full">
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Employment Status</label>
<RadioGroup
value={user.demographicInformation?.employment}
className="grid grid-cols-2 items-center gap-4 place-items-center">
{EMPLOYMENT_STATUS.map(({status, label}) => (
<RadioGroup.Option value={status} key={status}>
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-40 md:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked
? "bg-white border-mti-gray-platinum"
: "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
{label}
</span>
)}
</RadioGroup.Option>
))}
</RadioGroup>
</div>
<div className="flex flex-col gap-8 w-full">
<div className="relative flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Gender</label>
<RadioGroup value={user.demographicInformation?.gender} className="flex flex-row gap-4 justify-between">
<RadioGroup.Option value="male">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked
? "bg-white border-mti-gray-platinum"
: "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Male
</span>
)}
</RadioGroup.Option>
<RadioGroup.Option value="female">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked
? "bg-white border-mti-gray-platinum"
: "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Female
</span>
)}
</RadioGroup.Option>
<RadioGroup.Option value="other">
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-28 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!checked
? "bg-white border-mti-gray-platinum"
: "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
Other
</span>
)}
</RadioGroup.Option>
</RadioGroup>
</div>
<div className="flex flex-col gap-3">
<div className="flex justify-between items-center">
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
<Checkbox
isChecked={!!expiryDate}
onChange={(checked) => setExpiryDate(checked ? user.subscriptionExpirationDate || new Date() : undefined)}>
Enabled
</Checkbox>
</div>
{!expiryDate && (
<div
className={clsx(
"p-6 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"transition duration-300 ease-in-out",
!expiryDate ? "!bg-mti-green-ultralight !border-mti-green-light" : expirationDateColor(expiryDate),
"bg-white border-mti-gray-platinum",
)}>
{!expiryDate && "Unlimited"}
{expiryDate && moment(expiryDate).format("DD/MM/YYYY")}
</div>
)}
{expiryDate && (
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip",
expirationDateColor(expiryDate),
"transition duration-300 ease-in-out",
)}
filterDate={(date) =>
moment(date).isAfter(new Date()) &&
(loggedInUser.subscriptionExpirationDate
? moment(date).isBefore(moment(loggedInUser.subscriptionExpirationDate))
: true)
}
dateFormat="dd/MM/yyyy"
selected={moment(expiryDate).toDate()}
onChange={(date) => setExpiryDate(date)}
/>
)}
</div>
</div>
</div>
{(loggedInUser.type === "developer" || loggedInUser.type === "owner") && (
<>
<Divider className="w-full" />
<div className="flex flex-col md:flex-row gap-8 w-full">
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Status</label>
<select
defaultValue={user.status}
onChange={(e) => setStatus(e.target.value as typeof user.status)}
className="p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
<option value="active">Active</option>
<option value="disabled">Disabled</option>
<option value="paymentDue">Payment Due</option>
</select>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Type</label>
<select
defaultValue={user.type}
onChange={(e) => setType(e.target.value as typeof user.type)}
className="p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white">
<option value="student">Student</option>
<option value="teacher">Teacher</option>
<option value="corporate">Corporate</option>
<option value="agent">Country Agent</option>
<option value="owner">Owner</option>
<option value="developer">Developer</option>
</select>
</div>
</div>
</>
)}
{user.type === "corporate" && (
<>
<Divider className="w-full" />
<div className="flex flex-col md:flex-row gap-8 w-full">
<Input
label="Company Name"
type="text"
name="companyName"
onChange={() => null}
placeholder="Enter company name"
defaultValue={user.corporateInformation?.companyInformation.name}
/>
<Input
label="Amount of Users"
type="number"
name="userAmount"
onChange={() => null}
placeholder="Enter amount of users"
defaultValue={user.corporateInformation?.companyInformation.userAmount}
/>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Country Agent</label>
<Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={[
{value: "", label: "No referral"},
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
]}
defaultValue={{
value: referralAgent,
label: referralAgent ? users.find((u) => u.id === referralAgent)?.name || "" : "No agent",
}}
onChange={(value) => setReferralAgent(value?.value)}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</div>
</div>
</>
)}
</section>
<div className="flex gap-4 justify-between mt-4 w-full">
<div className="self-start flex gap-4 justify-start items-center w-full">
{onViewStudents && (
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewStudents}>
View Students
</Button>
)}
{onViewTeachers && (
<Button className="w-full max-w-[200px]" variant="outline" color="rose" onClick={onViewTeachers}>
View Teachers
</Button>
)}
</div>
<div className="self-end flex gap-4 w-full justify-end">
<Button className="w-full max-w-[200px]" variant="outline" onClick={onClose}>
Close
</Button>
<Button onClick={updateUser} className="w-full max-w-[200px]">
Update
</Button>
</div>
</div>
</>
);
};
export default UserCard;

View File

@@ -0,0 +1,31 @@
import {SEMI_TRANSPARENT} from "@/resources/colors";
import {Chart as ChartJS, RadialLinearScale, ArcElement, Tooltip, Legend} from "chart.js";
import clsx from "clsx";
import {PolarArea} from "react-chartjs-2";
import {Chart} from "primereact/chart";
interface Props {
data: {label: string; value: number}[];
label?: string;
title: string;
type: string;
colors?: string[];
}
ChartJS.register(RadialLinearScale, ArcElement, Tooltip, Legend);
export default function SingleDatasetChart({data, type, label, title, colors = Object.values(SEMI_TRANSPARENT)}: Props) {
const labels = data.map((x) => x.label);
const chartData = {
labels,
datasets: [
{
label,
data: data.map((x) => x.value),
backgroundColor: colors,
},
],
};
return <Chart type={type} data={chartData} options={{plugins: {title: {text: title, display: true}}}} />;
}

View File

@@ -7,78 +7,17 @@ export const BAND_SCORES: {[key in Module]: number[]} = {
listening: [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], writing: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
speaking: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], speaking: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
level: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
}; };
export const moduleResultText = (level: number) => { export const LEVEL_TEXT = {
if (level === 9) { excellent:
return ( "Congratulations on your exam performance! You achieved an impressive {{level}}, demonstrating excellent mastery of the assessed knowledge.\n\nIf you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of the results.\n\nPlease contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your academic journey.",
<> high: "Congratulations on your exam performance! You achieved a commendable {{level}}, demonstrating a good understanding of the assessed knowledge.\n\nIf you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of the results.\n\nPlease contact us for further information. Congratulations again on your achievement! We are here to support you on your academic journey.",
Congratulations on your exam performance! You achieved an impressive <span className="font-bold">level {level}</span>, demonstrating medium: "Congratulations on your exam performance! You achieved a {{level}}, demonstrating a satisfactory understanding of the assessed knowledge.\n\nIf you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of the results.\n\nPlease contact us for further information. Congratulations again on your achievement! We are here to support you on your academic journey.",
excellent mastery of the assessed knowledge. low: "Thank you for taking the exam. You achieved a {{level}}, but unfortunately, it did not meet the required standards.\n\nIf you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of the results.\n\nPlease contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future endeavors.",
<br /> };
<br />
If you disagree with the result, you can request a review by a qualified teacher. We are committed to the accuracy and transparency of export const levelText = (level: number) => {
the results.
<br />
<br />
Please contact us for further information. Congratulations again on your outstanding achievement! We are here to support you on your
academic journey.
</>
);
}
if (level >= 6) {
return (
<>
Congratulations on your exam performance! You achieved a commendable <span className="font-bold">level {level}</span>, demonstrating a
good understanding of the assessed knowledge.
<br />
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
journey.
</>
);
}
if (level >= 3) {
return (
<>
Congratulations on your exam performance! You achieved a <span className="font-bold">level of {level}</span>, demonstrating a
satisfactory understanding of the assessed knowledge.
<br />
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. Congratulations again on your achievement! We are here to support you on your academic
journey.
</>
);
}
return (
<>
Thank you for taking the exam. You achieved a <span className="font-bold">level {level}</span>, but unfortunately, it did not meet the
required standards.
<br />
<br />
If you have any concerns about the result, you can request a review by a qualified teacher. We are committed to the accuracy and
transparency of the results.
<br />
<br />
Please contact us for further information. We encourage you to continue your studies and wish you the best of luck in your future
endeavors.
</>
);
};
export const levelResultText = (level: number) => {
if (level === 9) { if (level === 9) {
return ( return (
<> <>

View File

@@ -1,13 +0,0 @@
{
"type": "service_account",
"project_id": "mti-ielts",
"private_key_id": "22b783a14c760d1215a8d1f5de0fa40a33a840e7",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDoNkd7s/izUBRb\nlmJYWl0xk4X9wEVJU4LKA4HPeha8RFDse4T4suVP08oCP9ODSXF5A83+IqXNMs/N\na7PtFABBAx433JrB7I4NsAUrDSjI4LeYEIqh6YzHsQvBU53HAmPChX525S4i0IBy\ncNnyXut0nmlHz5ZwCPXgqg4eN44C+m0f7sxzivcnPth/zLupnMiDAHFZrxQolWO2\n6JfozMWGw0TmCkUxngzeGBMVYmsGiKRIxEi3MWeuwjYjGO4nR1krEUlcpjCbx4UX\nxYXicJb17HOs9LTcSh9bpDWZPHKXR48hxd2cMLr+XQzw7Otwu2p8fEUOJ+CiTyNz\nlkN9p7OhAgMBAAECggEAB5DsMZdGu1X4wdazr+AK4RCG2UKkZ0wbqvgkCMX4O2xo\n7BmmtqFCmEAk+P+KJWEVW81wTu9jUl0tWOrBVzBThUrEF2seVkL+SmshsfpI6cmr\npb5lO/sTgZau1L7kGU3GQRpvKVHUl+EODFyJt2xZFOjL8qFsjAw4sbgsw1aJT6a4\nFilm6Gapi1qSKOPSlXVmi0NJ9DUtNbKaQK8/coqEJRizeXs9MORvzyKQaV8PBmWI\noEnkxahKOD48U2kmI7rT9/YsCuaP2BlGdLxvANXLjAKcrDccVZkYEH82tPtCicED\noow3i956HPdWSXQgUOU65MfGccjOmqGaGa4zUTICyQKBgQD6zLMwL9YS+n9EKZaK\nEbzRybN2d+eKbXyDJzkDi6FnSGVre2ndShsimoOtwZDLmOF/XhN79YOLJVbI124p\npAWO+WxAfe9Xy3iFEBmL4kSREA873Sd8EN5OfYS2DsN7IbjZkoaLuM8QlyXL9ZRS\nBJDVGjx+wFKRjnClcBNbVMMXiQKBgQDtBumKZS0ZCtJuBeuwLGJ1ZJtYECykIrsD\nUtQ7zxwXJzPGqZ2c5JLpHdDm/bb9nllpLsh4SpDRqxFa2H2FF8x5KWaS7JQUsS8e\ner6x5wUt6wAJqV/ZvttVrLZCa8VYn+K7bTANnkPNJZHTqBTJbxkXMDTtkwWXUN2z\nQP3N9lodWQKBgFBHiewYw9ubV3WIImnbt6cne0ymoPUMitioi3V5Epcu81fuTzrI\nZ9sxvoi19xVUwIm2oWICerLlptvvKZImsKjNajtSlHRz6wYc2zCNowkULOwqpGLw\nO1jAkOR94VDewH7UikDbTVywJSceWvXOBFZSaZ7hDQ0OnTw3ndqUTUaRAoGAd2BG\n2PPyDa28o7sJpBYGlJdSAb1LrnLre1YJHAJIZITS99hPUEhykUP6BYx80CkjYO01\n/BeZ7m9Y80cbmJ+O1Or8BT1vqyg90f0B8/mlSyYTQ8pxQupz7ydoN/WtU+BawgjQ\n7drqzPSCCHab2YPBwEMANTMZ2sbYkcJG0aekZSkCgYBbnFJm8kUy57isxHyvrci+\nR30KQl2Y9okPytF8PpLH+yNjLDoduTOHL/hZoFC0M4Gklx4wPKpsEhImIrWmG9VC\n0UrQC6TT1WoY6/S3YehVmTXo/nBPD1XTUcbF/xxUrWDjmMjnt1IlXBbIzUPD3U4P\niRXzHnXb7yi+/iRxSDts2w==\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-dyg6p@mti-ielts.iam.gserviceaccount.com",
"client_id": "104980563453519094431",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-dyg6p%40mti-ielts.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

View File

@@ -1,39 +0,0 @@
import {Type} from "@/interfaces/user";
export const PERMISSIONS = {
generateCode: {
student: ["corporate", "developer", "owner"],
teacher: ["corporate", "developer", "owner"],
corporate: ["owner", "developer"],
owner: ["developer", "owner"],
agent: ["developer", "owner"],
developer: ["developer"],
},
deleteUser: {
student: ["teacher", "corporate", "developer", "owner"],
teacher: ["corporate", "developer", "owner"],
corporate: ["owner", "developer"],
owner: ["developer", "owner"],
agent: ["developer", "owner"],
developer: ["developer"],
},
updateUser: {
student: ["teacher", "corporate", "developer", "owner"],
teacher: ["corporate", "developer", "owner"],
corporate: ["owner", "developer"],
owner: ["developer", "owner"],
agent: ["developer", "owner"],
developer: ["developer"],
},
updateExpiryDate: {
student: ["developer", "owner"],
teacher: ["developer", "owner"],
corporate: ["owner", "developer"],
owner: ["developer", "owner"],
agent: ["developer", "owner"],
developer: ["developer"],
},
examManagement: {
delete: ["developer", "owner"],
},
};

View File

@@ -1,75 +0,0 @@
import ProgressBar from "@/components/Low/ProgressBar";
import useUsers from "@/hooks/useUsers";
import {Module} from "@/interfaces";
import {Assignment} from "@/interfaces/results";
import {Stat} from "@/interfaces/user";
import {calculateBandScore} from "@/utils/score";
import clsx from "clsx";
import moment from "moment";
import {useState} from "react";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
interface Props {
onClick?: () => void;
}
export default function AssignmentCard({id, name, assigner, startDate, endDate, assignees, results, exams, onClick}: Assignment & Props) {
const {users} = useUsers();
const calculateAverageModuleScore = (module: Module) => {
const resultModuleBandScores = results.map((r) => {
const moduleStats = r.stats.filter((s) => s.module === module);
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
return calculateBandScore(correct, total, module, r.type);
});
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / results.length;
};
return (
<div
onClick={onClick}
className="w-[350px] h-fit flex flex-col gap-6 bg-white border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
<div className="flex flex-col gap-3">
<h3 className="font-semibold text-xl">{name}</h3>
<ProgressBar
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
percentage={(results.length / assignees.length) * 100}
label={`${results.length}/${assignees.length}`}
className="h-5"
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
/>
</div>
<span className="flex gap-1 justify-between">
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
<span>-</span>
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
</span>
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2">
{exams.map(({module}) => (
<div
key={module}
className={clsx(
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
module === "reading" && "bg-ielts-reading",
module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level",
)}>
{module === "reading" && <BsBook className="w-4 h-4" />}
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
{module === "writing" && <BsPen className="w-4 h-4" />}
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
{module === "level" && <BsClipboard className="w-4 h-4" />}
{calculateAverageModuleScore(module) > -1 && (
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
)}
</div>
))}
</div>
</div>
);
}

View File

@@ -1,313 +0,0 @@
import Input from "@/components/Low/Input";
import Modal from "@/components/Modal";
import {Module} from "@/interfaces";
import clsx from "clsx";
import {useState} from "react";
import {BsBook, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs";
import {generate} from "random-words";
import {capitalize} from "lodash";
import useUsers from "@/hooks/useUsers";
import {Group, User} from "@/interfaces/user";
import ProgressBar from "@/components/Low/ProgressBar";
import {calculateAverageLevel} from "@/utils/score";
import Button from "@/components/Low/Button";
import ReactDatePicker from "react-datepicker";
import moment from "moment";
import axios from "axios";
import {getExam} from "@/utils/exams";
import {toast} from "react-toastify";
import {uuidv4} from "@firebase/util";
import {Assignment} from "@/interfaces/results";
interface Props {
isCreating: boolean;
assigner: string;
users: User[];
groups: Group[];
assignment?: Assignment;
cancelCreation: () => void;
}
export default function AssignmentCreator({isCreating, assignment, assigner, groups, users, cancelCreation}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>(assignment?.exams.map((e) => e.module) || []);
const [assignees, setAssignees] = useState<string[]>(assignment?.assignees || []);
const [name, setName] = useState(assignment?.name || generate({minLength: 6, maxLength: 8, min: 2, max: 3, join: " ", formatter: capitalize}));
const [isLoading, setIsLoading] = useState(false);
const [startDate, setStartDate] = useState<Date | null>(assignment ? moment(assignment.startDate).toDate() : moment().add(1, "day").toDate());
const [endDate, setEndDate] = useState<Date | null>(assignment ? moment(assignment.endDate).toDate() : moment().add(8, "day").toDate());
const toggleModule = (module: Module) => {
const modules = selectedModules.filter((x) => x !== module);
setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module]));
};
const toggleAssignee = (user: User) => {
setAssignees((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id]));
};
const createAssignment = () => {
setIsLoading(true);
const examPromises = selectedModules.map(async (module) => getExam(module, false));
Promise.all(examPromises)
.then((exams) => {
(assignment ? axios.patch : axios.post)(`/api/assignments${assignment ? `/${assignment.id}` : ""}`, {
assigner,
assignees,
name,
startDate,
endDate,
results: [],
exams: exams.map((e) => ({module: e?.module, id: e?.id})),
})
.then(() => {
toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`);
cancelCreation();
})
.catch((e) => {
console.log(e);
toast.error("Something went wrong, please try again later!");
})
.finally(() => setIsLoading(false));
})
.catch((e) => {
console.log(e);
toast.error("Something went wrong, please try again later!");
setIsLoading(false);
});
};
const deleteAssignment = () => {
if (assignment) {
setIsLoading(true);
if (!confirm(`Are you sure you want to delete the "${assignment.name}" assignment?`)) return;
axios
.delete(`api/assignments/${assignment.id}`)
.then(() => {
toast.success(`The assignment "${name}" has been deleted successfully!`);
cancelCreation();
})
.catch((e) => {
console.log(e);
toast.error("Something went wrong, please try again later!");
})
.finally(() => setIsLoading(false));
}
};
return (
<Modal isOpen={isCreating} onClose={cancelCreation} title="New Assignment">
<div className="w-full flex flex-col gap-4">
<section className="w-full grid -md:grid-cols-1 md:grid-cols-2 place-items-center lg:grid-cols-6 -md:flex-col -md:items-center -md:gap-12 justify-between gap-8 mt-8 px-8">
<div
onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-2",
selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsBook className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Reading</span>
{!selectedModules.includes("reading") && !selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("reading") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-2",
selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsHeadphones className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Listening</span>
{!selectedModules.includes("listening") && !selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("listening") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-2",
selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsPen className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Writing</span>
{!selectedModules.includes("writing") && !selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("writing") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-3",
selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsMegaphone className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Speaking</span>
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{selectedModules.includes("level") && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("speaking") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
<div
onClick={
(!selectedModules.includes("level") && selectedModules.length === 0) || selectedModules.includes("level")
? () => toggleModule("level")
: undefined
}
className={clsx(
"w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer",
"lg:col-span-3",
selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-1/2 -translate-y-1/2 left-0 -translate-x-1/2">
<BsClipboard className="text-white w-7 h-7" />
</div>
<span className="ml-8 font-semibold">Level</span>
{!selectedModules.includes("level") && selectedModules.length === 0 && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full" />
)}
{!selectedModules.includes("level") && selectedModules.length > 0 && <BsXCircle className="text-mti-red-light w-8 h-8" />}
{selectedModules.includes("level") && <BsCheckCircle className="text-mti-purple-light w-8 h-8" />}
</div>
</section>
<Input type="text" name="name" onChange={(e) => setName(e)} defaultValue={name} label="Assignment Name" required />
<div className="w-full grid -md:grid-cols-1 md:grid-cols-2 gap-8">
<div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">Start Date *</label>
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out",
)}
popperClassName="!z-20"
filterDate={(date) => moment(date).isAfter(new Date())}
dateFormat="dd/MM/yyyy HH:mm"
selected={startDate}
showTimeSelect
onChange={(date) => setStartDate(date)}
/>
</div>
<div className="flex flex-col gap-2">
<label className="font-normal text-base text-mti-gray-dim">End Date *</label>
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip z-10",
"transition duration-300 ease-in-out",
)}
popperClassName="!z-20"
filterDate={(date) => moment(date).isAfter(startDate)}
dateFormat="dd/MM/yyyy HH:mm"
selected={endDate}
showTimeSelect
onChange={(date) => setEndDate(date)}
/>
</div>
</div>
<section className="w-full flex flex-col gap-3">
<span className="font-semibold">Assignees ({assignees.length} selected)</span>
<div className="flex gap-4 overflow-x-scroll scrollbar-hide">
{groups.map((g) => (
<button
key={g.id}
onClick={() => {
const groupStudentIds = users.filter((u) => g.participants.includes(u.id)).map((u) => u.id);
if (groupStudentIds.every((u) => assignees.includes(u))) {
setAssignees((prev) => prev.filter((a) => !groupStudentIds.includes(a)));
} else {
setAssignees((prev) => [...prev.filter((a) => !groupStudentIds.includes(a)), ...groupStudentIds]);
}
}}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
users.filter((u) => g.participants.includes(u.id)).every((u) => assignees.includes(u.id)) &&
"!bg-mti-purple-light !text-white",
)}>
{g.name}
</button>
))}
</div>
<div className="flex flex-wrap -md:justify-center gap-4">
{users.map((user) => (
<div
onClick={() => toggleAssignee(user)}
className={clsx(
"p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72",
"transition ease-in-out duration-300",
assignees.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum",
)}
key={user.id}>
<span className="flex flex-col gap-0 justify-center">
<span className="font-semibold">{user.name}</span>
<span className="text-sm opacity-80">{user.email}</span>
</span>
<ProgressBar
color="purple"
textClassName="!text-mti-black/80"
label={`Level ${calculateAverageLevel(user.levels)}`}
percentage={(calculateAverageLevel(user.levels) / 9) * 100}
className="h-6"
/>
<span className="text-mti-black/80 text-sm whitespace-pre-wrap mt-2">
Groups:{" "}
{groups
.filter((g) => g.participants.includes(user.id))
.map((g) => g.name)
.join(", ")}
</span>
</div>
))}
</div>
</section>
<div className="flex gap-4 w-full justify-end">
<Button className="w-full max-w-[200px]" variant="outline" onClick={cancelCreation} disabled={isLoading} isLoading={isLoading}>
Cancel
</Button>
{assignment && (
<Button
className="w-full max-w-[200px]"
color="red"
variant="outline"
onClick={deleteAssignment}
disabled={isLoading}
isLoading={isLoading}>
Delete
</Button>
)}
<Button
disabled={selectedModules.length === 0 || !name || !startDate || !endDate || assignees.length === 0}
className="w-full max-w-[200px]"
onClick={createAssignment}
isLoading={isLoading}>
{assignment ? "Update" : "Create"}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -1,280 +0,0 @@
import ProgressBar from "@/components/Low/ProgressBar";
import Modal from "@/components/Modal";
import useUsers from "@/hooks/useUsers";
import {Module} from "@/interfaces";
import {Assignment} from "@/interfaces/results";
import {Stat, User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams";
import {sortByModule} from "@/utils/moduleUtils";
import {calculateBandScore} from "@/utils/score";
import {convertToUserSolutions} from "@/utils/stats";
import clsx from "clsx";
import {capitalize, uniqBy} from "lodash";
import moment from "moment";
import {useRouter} from "next/router";
import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
interface Props {
isOpen: boolean;
assignment?: Assignment;
onClose: () => void;
}
export default function AssignmentView({isOpen, assignment, onClose}: Props) {
const {users} = useUsers();
const router = useRouter();
const setExams = useExamStore((state) => state.setExams);
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const formatTimestamp = (timestamp: string) => {
const date = moment(parseInt(timestamp));
const formatter = "YYYY/MM/DD - HH:mm";
return date.format(formatter);
};
const calculateAverageModuleScore = (module: Module) => {
if (!assignment) return -1;
const resultModuleBandScores = assignment.results.map((r) => {
const moduleStats = r.stats.filter((s) => s.module === module);
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
return calculateBandScore(correct, total, module, r.type);
});
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
};
const aggregateScoresByModule = (stats: Stat[]): {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,
},
level: {
total: 0,
correct: 0,
missing: 0,
},
};
stats.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 customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
const aggregatedLevels = aggregatedScores.map((x) => ({
module: x.module,
level: calculateBandScore(x.correct, x.total, x.module, focus),
}));
const timeSpent = stats[0].timeSpent;
const selectExam = () => {
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) {
setUserSolutions(convertToUserSolutions(stats));
setShowSolutions(true);
setExams(exams.map((x) => x!).sort(sortByModule));
setSelectedModules(
exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
);
router.push("/exercises");
}
});
};
const content = (
<>
<div className="w-full flex justify-between -md:items-center 2xl:items-center">
<div className="flex md:flex-col 2xl:flex-row md:gap-1 -md:gap-2 2xl:gap-2 -md:items-center 2xl:items-center">
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
{timeSpent && (
<>
<span className="md:hidden 2xl:flex"> </span>
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
</>
)}
</div>
<span
className={clsx(
correct / total >= 0.7 && "text-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
correct / total < 0.3 && "text-mti-rose",
)}>
Level{" "}
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
</span>
</div>
<div className="w-full flex flex-col gap-1">
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2">
{aggregatedLevels.map(({module, level}) => (
<div
key={module}
className={clsx(
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl",
module === "reading" && "bg-ielts-reading",
module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level",
)}>
{module === "reading" && <BsBook className="w-4 h-4" />}
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
{module === "writing" && <BsPen className="w-4 h-4" />}
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
{module === "level" && <BsClipboard className="w-4 h-4" />}
<span className="text-sm">{level.toFixed(1)}</span>
</div>
))}
</div>
</div>
</>
);
return (
<div className="flex flex-col gap-2">
<span>
{(() => {
const student = users.find((u) => u.id === user);
return `${student?.name} (${student?.email})`;
})()}
</span>
<div
key={user}
className={clsx(
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:hidden",
correct / total >= 0.7 && "hover:border-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
correct / total < 0.3 && "hover:border-mti-rose",
)}
onClick={selectExam}
role="button">
{content}
</div>
<div
key={user}
className={clsx(
"flex flex-col gap-4 border border-mti-gray-platinum p-4 cursor-pointer rounded-xl transition ease-in-out duration-300 -md:tooltip md:hidden",
correct / total >= 0.7 && "hover:border-mti-purple",
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
correct / total < 0.3 && "hover:border-mti-rose",
)}
data-tip="Your screen size is too small to view previous exams."
role="button">
{content}
</div>
</div>
);
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
<div className="mt-4 flex flex-col w-full gap-4">
<ProgressBar
color="purple"
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
className="h-6"
textClassName={
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
}
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
/>
<div className="flex gap-8 items-start">
<div className="flex flex-col gap-2">
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
</div>
<span>
Assignees:{" "}
{users
.filter((u) => assignment?.assignees.includes(u.id))
.map((u) => `${u.name} (${u.email})`)
.join(", ")}
</span>
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold">Average Scores</span>
<div className="grid grid-cols-4 gap-2 place-items-start w-full -md:mt-2">
{assignment?.exams.map(({module}) => (
<div
data-tip={capitalize(module)}
key={module}
className={clsx(
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl tooltip",
module === "reading" && "bg-ielts-reading",
module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level",
)}>
{module === "reading" && <BsBook className="w-4 h-4" />}
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
{module === "writing" && <BsPen className="w-4 h-4" />}
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
{module === "level" && <BsClipboard className="w-4 h-4" />}
{calculateAverageModuleScore(module) > -1 && (
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
)}
</div>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xl font-bold">
Results ({assignment?.results.length}/{assignment?.assignees.length})
</span>
<div>
{assignment && assignment?.results.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 w-full gap-4 xl:gap-6">
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
</div>
)}
{assignment && assignment?.results.length === 0 && <span className="font-semibold ml-1">No results yet...</span>}
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -1,274 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers";
import {Group, Stat, User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils";
import moment from "moment";
import {useEffect, useState} from "react";
import {
BsArrowLeft,
BsClipboard2Data,
BsClipboard2DataFill,
BsClock,
BsGlobeCentralSouthAsia,
BsPaperclip,
BsPerson,
BsPersonAdd,
BsPersonFill,
BsPersonFillGear,
BsPersonGear,
BsPersonLinesFill,
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {Module} from "@/interfaces";
import {groupByExam} from "@/utils/stats";
import IconCard from "./IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList";
interface Props {
user: User;
}
export default function CorporateDashboard({user}: Props) {
const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const {stats} = useStats();
const {users, reload} = useUsers();
const {groups} = useGroups(user.id);
useEffect(() => {
setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]);
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
const teacherFilter = (user: User) => user.type === "teacher" && groups.flatMap((g) => g.participants).includes(user.id);
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
const UserDisplay = (displayUser: User) => (
<div
onClick={() => setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const StudentsList = () => {
const filter = (x: User) =>
x.type === "student" &&
(!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id));
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filter={filter} />
</>
);
};
const TeachersList = () => {
const filter = (x: User) =>
x.type === "teacher" &&
(!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id));
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filter={filter} />
</>
);
};
const GroupsList = () => {
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id);
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Groups ({groups.filter(filter).length})</h2>
</div>
<GroupList user={user} />
</>
);
};
const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats
.map((s) => ({focus: users.find((u) => u.id === s.user)?.focus, score: s.score, module: s.module}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
}));
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0};
bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels);
};
const DefaultDashboard = () => (
<>
<section className="flex flex-wrap gap-2 items-center -lg:justify-center lg:justify-between text-center">
<IconCard
onClick={() => setPage("students")}
Icon={BsPersonFill}
label="Students"
value={users.filter(studentFilter).length}
color="purple"
/>
<IconCard
onClick={() => setPage("teachers")}
Icon={BsPersonLinesFill}
label="Teachers"
value={users.filter(teacherFilter).length}
color="purple"
/>
<IconCard
Icon={BsClipboard2Data}
label="Exams Performed"
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
color="purple"
/>
<IconCard
Icon={BsPaperclip}
label="Average Level"
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
color="purple"
/>
<IconCard onClick={() => setPage("groups")} Icon={BsPersonAdd} label="Groups" value={groups.length} color="purple" />
<IconCard
Icon={BsClock}
label="Expiration Date"
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
color="rose"
/>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest teachers</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(teacherFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort(
(a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</>
);
return (
<>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload) reload();
}}
onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
}
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
{page === "students" && <StudentsList />}
{page === "teachers" && <TeachersList />}
{page === "groups" && <GroupsList />}
{page === "" && <DefaultDashboard />}
</>
);
}

View File

@@ -1,30 +0,0 @@
import clsx from "clsx";
import {IconType} from "react-icons";
interface Props {
Icon: IconType;
label: string;
value: string | number;
color: "purple" | "rose" | "red";
onClick?: () => void;
}
export default function IconCard({Icon, label, value, color, onClick}: Props) {
const colorClasses: {[key in typeof color]: string} = {
purple: "text-mti-purple-light",
red: "text-mti-red-light",
rose: "text-mti-rose-light",
};
return (
<div
onClick={onClick}
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-52 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
<Icon className={clsx("text-6xl", colorClasses[color])} />
<span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">{label}</span>
<span className={clsx("font-semibold", colorClasses[color])}>{value}</span>
</span>
</div>
);
}

View File

@@ -1,358 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers";
import {User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils";
import moment from "moment";
import {useEffect, useState} from "react";
import {BsArrowLeft, BsGlobeCentralSouthAsia, BsPerson, BsPersonFill, BsPersonFillGear, BsPersonGear, BsPersonLinesFill} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import IconCard from "./IconCard";
interface Props {
user: User;
}
export default function OwnerDashboard({user}: Props) {
const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const {stats} = useStats(user.id);
const {users, reload} = useUsers();
const {groups} = useGroups();
useEffect(() => {
setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]);
const UserDisplay = (displayUser: User) => (
<div
onClick={() => setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const StudentsList = () => {
const filter = (x: User) =>
x.type === "student" &&
(!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id) || false
: true);
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filter={filter} />
</>
);
};
const TeachersList = () => {
const filter = (x: User) =>
x.type === "teacher" &&
(!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id))
.flatMap((g) => g.participants)
.includes(x.id) || false
: true);
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Teachers ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filter={filter} />
</>
);
};
const CorporateList = () => (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Corporate ({users.filter((x) => x.type === "corporate").length})</h2>
</div>
<UserList user={user} filter={(x) => x.type === "corporate"} />
</>
);
const InactiveStudentsList = () => {
const filter = (x: User) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Inactive Students ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filter={filter} />
</>
);
};
const InactiveCorporateList = () => {
const filter = (x: User) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate));
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Inactive Corporate ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filter={filter} />
</>
);
};
const DefaultDashboard = () => (
<>
<section className="w-full flex flex-wrap gap-4 items-center justify-between">
<IconCard
Icon={BsPersonFill}
label="Students"
value={users.filter((x) => x.type === "student").length}
onClick={() => setPage("students")}
color="purple"
/>
<IconCard
Icon={BsPersonLinesFill}
label="Teachers"
value={users.filter((x) => x.type === "teacher").length}
onClick={() => setPage("teachers")}
color="purple"
/>
<IconCard
Icon={BsPersonLinesFill}
label="Corporate"
value={users.filter((x) => x.type === "corporate").length}
onClick={() => setPage("corporate")}
color="purple"
/>
<IconCard
Icon={BsGlobeCentralSouthAsia}
label="Countries"
value={[...new Set(users.filter((x) => x.demographicInformation).map((x) => x.demographicInformation?.country))].length}
color="purple"
/>
<IconCard
onClick={() => setPage("inactiveStudents")}
Icon={BsPerson}
label="Inactive Students"
value={
users.filter((x) => x.type === "student" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
.length
}
color="rose"
/>
<IconCard
onClick={() => setPage("inactiveCorporate")}
Icon={BsPerson}
label="Inactive Corporate"
value={
users.filter((x) => x.type === "corporate" && (x.status === "disabled" || moment().isAfter(x.subscriptionExpirationDate)))
.length
}
color="rose"
/>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter((x) => x.type === "student")
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter((x) => x.type === "corporate")
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Unpaid Corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter((x) => x.type === "corporate" && x.status === "paymentDue")
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Students expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) =>
x.type === "student" &&
x.subscriptionExpirationDate &&
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
moment().isBefore(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Teachers expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) =>
x.type === "teacher" &&
x.subscriptionExpirationDate &&
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
moment().isBefore(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Corporate expiring in 1 month</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) =>
x.type === "corporate" &&
x.subscriptionExpirationDate &&
moment().isAfter(moment(x.subscriptionExpirationDate).subtract(30, "days")) &&
moment().isBefore(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Expired Students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) => x.type === "student" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Expired Teachers</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) => x.type === "teacher" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Expired Corporate</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(
(x) =>
x.type === "corporate" && x.subscriptionExpirationDate && moment().isAfter(moment(x.subscriptionExpirationDate)),
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</>
);
return (
<>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload) reload();
}}
onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
}
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
{page === "students" && <StudentsList />}
{page === "teachers" && <TeachersList />}
{page === "corporate" && <CorporateList />}
{page === "inactiveStudents" && <InactiveStudentsList />}
{page === "inactiveCorporate" && <InactiveCorporateList />}
{page === "" && <DefaultDashboard />}
</>
);
}

View File

@@ -1,210 +0,0 @@
import Button from "@/components/Low/Button";
import ProgressBar from "@/components/Low/ProgressBar";
import ProfileSummary from "@/components/ProfileSummary";
import useAssignments from "@/hooks/useAssignments";
import useStats from "@/hooks/useStats";
import {Assignment} from "@/interfaces/results";
import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams";
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils";
import {averageScore, groupBySession} from "@/utils/stats";
import clsx from "clsx";
import {capitalize} from "lodash";
import moment from "moment";
import Link from "next/link";
import {useRouter} from "next/router";
import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs";
interface Props {
user: User;
}
export default function StudentDashboard({user}: Props) {
const {stats} = useStats(user.id);
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id});
const router = useRouter();
const setExams = useExamStore((state) => state.setExams);
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const setAssignment = useExamStore((state) => state.setAssignment);
const startAssignment = (assignment: Assignment) => {
const examPromises = assignment.exams.map((e) => getExamById(e.module, e.id));
Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) {
setUserSolutions([]);
setShowSolutions(false);
setExams(exams.map((x) => x!).sort(sortByModule));
setSelectedModules(
exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
);
setAssignment(assignment);
router.push("/exercises");
}
});
};
return (
<>
<ProfileSummary
user={user}
items={[
{
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: Object.keys(groupBySession(stats)).length,
label: "Exams",
},
{
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: stats.length,
label: "Exercises",
},
{
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />,
value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
label: "Average Score",
},
]}
/>
<section className="flex flex-col gap-1 md:gap-3">
<span className="font-bold text-lg">Bio</span>
<span className="text-mti-gray-taupe">
{user.bio || "Your bio will appear here, you can change it by clicking on your name in the top right corner."}
</span>
</section>
<section className="flex flex-col gap-1 md:gap-3">
<div className="flex gap-4 items-center">
<div
onClick={reloadAssignments}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<span className="font-bold text-lg text-mti-black">Assignments</span>
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
</div>
</div>
<span className="text-mti-gray-taupe flex gap-8 overflow-x-scroll scrollbar-hide">
{assignments.filter((a) => moment(a.endDate).isSameOrAfter(moment())).length === 0 &&
"Assignments will appear here. It seems that for now there are no assignments for you."}
{assignments
.filter((a) => moment(a.endDate).isSameOrAfter(moment()))
.sort((a, b) => moment(a.startDate).diff(b.startDate))
.map((assignment) => (
<div
className={clsx(
"border border-mti-gray-anti-flash rounded-xl flex flex-col gap-6 p-4 min-w-[300px]",
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light",
)}
key={assignment.id}>
<div className="flex flex-col gap-1">
<h3 className="font-semibold text-xl text-mti-black/90">{assignment.name}</h3>
<span className="flex gap-1 justify-between">
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span>
<span>-</span>
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span>
</span>
</div>
<div className="flex justify-between w-full items-center">
<div className="grid grid-cols-2 gap-2 place-items-center justify-center w-fit min-w-[104px] -md:mt-2">
{assignment.exams
.map((e) => e.module)
.sort(sortByModuleName)
.map((module) => (
<div
key={module}
data-tip={capitalize(module)}
className={clsx(
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl tooltip",
module === "reading" && "bg-ielts-reading",
module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level",
)}>
{module === "reading" && <BsBook className="w-4 h-4" />}
{module === "listening" && <BsHeadphones className="w-4 h-4" />}
{module === "writing" && <BsPen className="w-4 h-4" />}
{module === "speaking" && <BsMegaphone className="w-4 h-4" />}
{module === "level" && <BsClipboard className="w-4 h-4" />}
</div>
))}
</div>
{!assignment.results.map((r) => r.user).includes(user.id) && (
<>
<div
className="tooltip w-full md:hidden h-full flex items-center justify-end pl-8"
data-tip="Your screen size is too small to perform an assignment">
<Button
disabled={moment(assignment.startDate).isAfter(moment())}
className="w-full h-full !rounded-xl"
variant="outline">
Start
</Button>
</div>
<Button
disabled={moment(assignment.startDate).isAfter(moment())}
className="w-full max-w-[50%] h-full !rounded-xl -md:hidden"
onClick={() => startAssignment(assignment)}
variant="outline">
Start
</Button>
</>
)}
{assignment.results.map((r) => r.user).includes(user.id) && (
<Button
onClick={() => router.push("/record")}
color="green"
className="w-full max-w-[50%] h-full !rounded-xl -md:hidden"
variant="outline">
Submitted
</Button>
)}
</div>
</div>
))}
</span>
</section>
<section className="flex flex-col gap-3">
<span className="font-bold text-lg">Score History</span>
<div className="grid -md:grid-rows-4 md:grid-cols-2 gap-6">
{MODULE_ARRAY.map((module) => (
<div className="border border-mti-gray-anti-flash rounded-xl flex flex-col gap-2 p-4" key={module}>
<div className="flex gap-2 md:gap-3 items-center">
<div className="w-8 h-8 md:w-12 md:h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg md:rounded-xl">
{module === "reading" && <BsBook className="text-ielts-reading w-4 h-4 md:w-5 md:h-5" />}
{module === "listening" && <BsHeadphones className="text-ielts-listening w-4 h-4 md:w-5 md:h-5" />}
{module === "writing" && <BsPen className="text-ielts-writing w-4 h-4 md:w-5 md:h-5" />}
{module === "speaking" && <BsMegaphone className="text-ielts-speaking w-4 h-4 md:w-5 md:h-5" />}
</div>
<div className="flex justify-between w-full">
<span className="font-bold md:font-extrabold text-sm">{capitalize(module)}</span>
<span className="text-sm font-normal text-mti-gray-dim">
Level {user.levels[module]} / Level {user.desiredLevels[module]}
</span>
</div>
</div>
<div className="md:pl-14">
<ProgressBar
color={module}
label=""
percentage={Math.round((user.levels[module] * 100) / user.desiredLevels[module])}
className="w-full h-2"
/>
</div>
</div>
))}
</div>
</section>
</>
);
}

View File

@@ -1,341 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import Modal from "@/components/Modal";
import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers";
import {Group, Stat, User} from "@/interfaces/user";
import UserList from "@/pages/(admin)/Lists/UserList";
import {dateSorter} from "@/utils";
import moment from "moment";
import {useEffect, useState} from "react";
import {
BsArrowLeft,
BsArrowRepeat,
BsClipboard2Data,
BsClipboard2DataFill,
BsClipboard2Heart,
BsClipboard2X,
BsClipboardPulse,
BsClock,
BsEnvelopePaper,
BsGlobeCentralSouthAsia,
BsPaperclip,
BsPerson,
BsPersonAdd,
BsPersonFill,
BsPersonFillGear,
BsPersonGear,
BsPersonLinesFill,
BsPlus,
BsRepeat,
BsRepeat1,
} from "react-icons/bs";
import UserCard from "@/components/UserCard";
import useGroups from "@/hooks/useGroups";
import {calculateAverageLevel, calculateBandScore} from "@/utils/score";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {Module} from "@/interfaces";
import {groupByExam} from "@/utils/stats";
import IconCard from "./IconCard";
import GroupList from "@/pages/(admin)/Lists/GroupList";
import useAssignments from "@/hooks/useAssignments";
import {Assignment} from "@/interfaces/results";
import AssignmentCard from "./AssignmentCard";
import Button from "@/components/Low/Button";
import clsx from "clsx";
import ProgressBar from "@/components/Low/ProgressBar";
import AssignmentCreator from "./AssignmentCreator";
import AssignmentView from "./AssignmentView";
interface Props {
user: User;
}
export default function TeacherDashboard({user}: Props) {
const [page, setPage] = useState("");
const [selectedUser, setSelectedUser] = useState<User>();
const [showModal, setShowModal] = useState(false);
const [selectedAssignment, setSelectedAssignment] = useState<Assignment>();
const [isCreatingAssignment, setIsCreatingAssignment] = useState(false);
const {stats} = useStats();
const {users, reload} = useUsers();
const {groups} = useGroups(user.id);
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id});
useEffect(() => {
setShowModal(!!selectedUser && page === "");
}, [selectedUser, page]);
const studentFilter = (user: User) => user.type === "student" && groups.flatMap((g) => g.participants).includes(user.id);
const getStatsByStudent = (user: User) => stats.filter((s) => s.user === user.id);
const UserDisplay = (displayUser: User) => (
<div
onClick={() => setSelectedUser(displayUser)}
className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300">
<img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span>
</div>
</div>
);
const StudentsList = () => {
const filter = (x: User) =>
x.type === "student" &&
(!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id));
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Students ({users.filter(filter).length})</h2>
</div>
<UserList user={user} filter={filter} />
</>
);
};
const GroupsList = () => {
const filter = (x: Group) => x.admin === user.id || x.participants.includes(user.id);
return (
<>
<div className="flex flex-col gap-4">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<h2 className="text-2xl font-semibold">Groups ({groups.filter(filter).length})</h2>
</div>
<GroupList user={user} />
</>
);
};
const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats
.map((s) => ({focus: users.find((u) => u.id === s.user)?.focus, score: s.score, module: s.module}))
.filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({
module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
}));
const levels: {[key in Module]: number} = {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0};
bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels);
};
const AssignmentsPage = () => {
const activeFilter = (a: Assignment) => moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment());
const pastFilter = (a: Assignment) => moment(a.endDate).isBefore(moment());
const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment());
return (
<>
<AssignmentView
isOpen={!!selectedAssignment && !isCreatingAssignment}
onClose={() => {
setSelectedAssignment(undefined);
setIsCreatingAssignment(false);
}}
assignment={selectedAssignment}
/>
<AssignmentCreator
assignment={selectedAssignment}
groups={groups.filter((x) => x.admin === user.id || x.participants.includes(user.id))}
users={users.filter(
(x) =>
x.type === "student" &&
(!!selectedUser
? groups
.filter((g) => g.admin === selectedUser.id)
.flatMap((g) => g.participants)
.includes(x.id) || false
: groups.flatMap((g) => g.participants).includes(x.id)),
)}
assigner={user.id}
isCreating={isCreatingAssignment}
cancelCreation={() => {
setIsCreatingAssignment(false);
setSelectedAssignment(undefined);
reloadAssignments();
}}
/>
<div className="w-full flex justify-between items-center">
<div
onClick={() => setPage("")}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<BsArrowLeft className="text-xl" />
<span>Back</span>
</div>
<div
onClick={reloadAssignments}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300">
<span>Reload</span>
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} />
</div>
</div>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Active Assignments ({assignments.filter(activeFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(activeFilter).map((a) => (
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} />
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Planned Assignments ({assignments.filter(futureFilter).length})</h2>
<div className="flex flex-wrap gap-2">
<div
onClick={() => setIsCreatingAssignment(true)}
className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300">
<BsPlus className="text-6xl" />
<span className="text-lg">New Assignment</span>
</div>
{assignments.filter(futureFilter).map((a) => (
<AssignmentCard
{...a}
onClick={() => {
setSelectedAssignment(a);
setIsCreatingAssignment(true);
}}
key={a.id}
/>
))}
</div>
</section>
<section className="flex flex-col gap-4">
<h2 className="text-2xl font-semibold">Past Assignments ({assignments.filter(pastFilter).length})</h2>
<div className="flex flex-wrap gap-2">
{assignments.filter(pastFilter).map((a) => (
<AssignmentCard {...a} onClick={() => setSelectedAssignment(a)} key={a.id} />
))}
</div>
</section>
</>
);
};
const DefaultDashboard = () => (
<>
<section className="flex -lg:flex-wrap gap-4 items-center -lg:justify-center lg:justify-start text-center">
<IconCard
onClick={() => setPage("students")}
Icon={BsPersonFill}
label="Students"
value={users.filter(studentFilter).length}
color="purple"
/>
<IconCard
Icon={BsClipboard2Data}
label="Exams Performed"
value={stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user)).length}
color="purple"
/>
<IconCard
Icon={BsPaperclip}
label="Average Level"
value={averageLevelCalculator(stats.filter((s) => groups.flatMap((g) => g.participants).includes(s.user))).toFixed(1)}
color="purple"
/>
<IconCard Icon={BsPersonAdd} label="Groups" value={groups.length} color="purple" onClick={() => setPage("groups")} />
<div
onClick={() => setPage("assignments")}
className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300">
<BsEnvelopePaper className="text-6xl text-mti-purple-light" />
<span className="flex flex-col gap-1 items-center text-xl">
<span className="text-lg">Assignments</span>
<span className="font-semibold text-mti-purple-light">{assignments.length}</span>
</span>
</div>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full justify-between">
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Latest students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest level students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
<div className="bg-white shadow flex flex-col rounded-xl w-full">
<span className="p-4">Highest exam count students</span>
<div className="flex flex-col items-start h-96 overflow-scroll scrollbar-hide">
{users
.filter(studentFilter)
.sort(
(a, b) =>
Object.keys(groupByExam(getStatsByStudent(b))).length - Object.keys(groupByExam(getStatsByStudent(a))).length,
)
.map((x) => (
<UserDisplay key={x.id} {...x} />
))}
</div>
</div>
</section>
</>
);
return (
<>
<Modal isOpen={showModal} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload) reload();
}}
onViewStudents={
selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined
}
onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
{page === "students" && <StudentsList />}
{page === "groups" && <GroupsList />}
{page === "assignments" && <AssignmentsPage />}
{page === "" && <DefaultDashboard />}
</>
);
}

View File

@@ -1,42 +0,0 @@
import nodemailer from "nodemailer";
import hbs from "nodemailer-express-handlebars";
import path from "path";
interface MailOptions {
from: string;
to: string[];
subject: string;
template: string;
context: object;
}
export function prepareMailer(template?: string): nodemailer.Transporter {
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST,
auth: {
user: process.env.MAIL_USER!,
pass: process.env.MAIL_PASS!,
},
});
const handlebarOptions: hbs.NodemailerExpressHandlebarsOptions = {
viewEngine: {
partialsDir: path.resolve("src/email/templates"),
defaultLayout: `src/email/templates/${template || "main"}`,
},
viewPath: path.resolve("src/email/templates"),
};
transport.use("compile", hbs(handlebarOptions));
return transport;
}
export function prepareMailOptions(context: object, to: string[], subject: string, template: string): MailOptions {
return {
from: process.env.MAIL_USER!,
to,
subject,
template,
context,
};
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -1,34 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
</head>
<div style="background-color: #ffffff; color: #353338;"
class="h-full min-h-screen w-full flex flex-col p-8 gap-16 text-base">
<img src="/logo_title.png" class="w-48 h-48 self-center" />
<div>
<span>Hello future {{type}} of <b>EnCoach</b>,</span><br />
<span>You have been invited to register at <a href="https://platform.encoach.com/register?code={{code}}">EnCoach</a>
to
become a
{{type}}!</span><br />
<span>Please use the following code when registering:</span>
</div>
<br />
<br />
<a href="https://platform.encoach.com/register?code={{code}}"></a>
<span class="self-center p-4 px-12 text-lg text-[#]" style="background-color: #D5D9F0; color: #353338">
<b>{{code}}</b>
</span>
</a>
<br />
<br />
<div>
<span>Thanks, <br /> Your EnCoach team</span>
</div>
</div>
</html>

View File

@@ -1,4 +0,0 @@
{
"type": "student",
"code": "123a"
}

View File

@@ -1,22 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
</head>
<div>
<p>Hello {{name}},</p>
<br />
<p>Follow this link to verify your email address.</p>
<a href="https://platform.encoach.com/action?mode=signIn&continueUrl={{email}}&oobCode={{code}}">Verify account</a>
<br />
<br />
<p>If you didnt ask to verify this address, you can ignore this email.</p>
<br />
<p>Thanks,</p>
<p>Your EnCoach team</p>
</div>
</html>

View File

@@ -1,5 +0,0 @@
{
"name": "Tiago Ribeiro",
"email": "tiago.ribeiro@ecrop.dev",
"code": "123"
}

View File

@@ -1,6 +1,6 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
import {moduleResultText} from "@/constants/ielts"; import {levelText, LEVEL_TEXT} from "@/constants/ielts";
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
@@ -9,7 +9,7 @@ import clsx from "clsx";
import Link from "next/link"; import Link from "next/link";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {Fragment, useEffect, useState} from "react"; import {Fragment, useEffect, useState} from "react";
import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs"; import {BsArrowCounterclockwise, BsBook, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs";
interface Score { interface Score {
module: Module; module: Module;
@@ -51,19 +51,6 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
progress: "text-ielts-speaking", progress: "text-ielts-speaking",
inner: "bg-ielts-speaking-light", inner: "bg-ielts-speaking-light",
}, },
level: {
progress: "text-ielts-level",
inner: "bg-ielts-level-light",
},
};
const getTotalExercises = () => {
const exam = exams.find((x) => x.module === selectedModule)!;
if (exam.module === "reading" || exam.module === "listening") {
return exam.parts.flatMap((x) => x.exercises).length;
}
return exam.exercises.length;
}; };
return ( return (
@@ -71,8 +58,8 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<div className="w-full min-h-full h-fit flex flex-col items-center justify-between gap-8"> <div className="w-full min-h-full h-fit flex flex-col items-center justify-between gap-8">
<ModuleTitle <ModuleTitle
module={selectedModule} module={selectedModule}
totalExercises={getTotalExercises()} totalExercises={exams.find((x) => x.module === selectedModule)!.exercises.length}
exerciseIndex={getTotalExercises()} exerciseIndex={exams.find((x) => x.module === selectedModule)!.exercises.length}
minTimer={exams.find((x) => x.module === selectedModule)!.minTimer} minTimer={exams.find((x) => x.module === selectedModule)!.minTimer}
disableTimer disableTimer
/> />
@@ -121,17 +108,6 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
<span className="font-semibold">Speaking</span> <span className="font-semibold">Speaking</span>
</div> </div>
)} )}
{modules.includes("level") && (
<div
onClick={() => setSelectedModule("level")}
className={clsx(
"flex gap-2 items-center rounded-xl p-4 cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:bg-ielts-level hover:text-white",
selectedModule === "level" ? "bg-ielts-level text-white" : "bg-mti-gray-smoke text-ielts-level",
)}>
<BsClipboard className="w-6 h-6" />
<span className="font-semibold">Level</span>
</div>
)}
</div> </div>
{isLoading && ( {isLoading && (
<div className="w-fit h-fit absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 animate-pulse flex flex-col gap-12 items-center"> <div className="w-fit h-fit absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 animate-pulse flex flex-col gap-12 items-center">
@@ -140,9 +116,9 @@ export default function Finish({user, scores, modules, isLoading, onViewResults}
</div> </div>
)} )}
{!isLoading && ( {!isLoading && (
<div className="w-full flex gap-9 mt-32 items-center justify-between mb-20"> <div className="w-full flex gap-9 mt-32 items-center justify-between">
<span className="max-w-3xl"> <span className="max-w-3xl">
{moduleResultText(calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus))} {levelText(calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus))}
</span> </span>
<div className="flex gap-9 px-16"> <div className="flex gap-9 px-16">
<div <div

View File

@@ -1,89 +0,0 @@
import {renderExercise} from "@/components/Exercises";
import ModuleTitle from "@/components/Medium/ModuleTitle";
import {renderSolution} from "@/components/Solutions";
import {infoButtonStyle} from "@/constants/buttonStyles";
import {LevelExam, UserSolution, WritingExam} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils";
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";
interface Props {
exam: LevelExam;
showSolutions?: boolean;
onFinish: (userSolutions: UserSolution[]) => void;
}
export default function Level({exam, showSolutions = false, onFinish}: Props) {
const [exerciseIndex, setExerciseIndex] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
const nextExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
}
if (exerciseIndex + 1 < exam.exercises.length) {
setExerciseIndex((prev) => prev + 1);
return;
}
if (exerciseIndex >= exam.exercises.length) return;
setHasExamEnded(false);
if (solution) {
onFinish(
[...userSolutions.filter((x) => x.exercise !== solution.exercise), solution].map((x) => ({...x, module: "level", exam: exam.id})),
);
} else {
onFinish(userSolutions.map((x) => ({...x, module: "level", exam: exam.id})));
}
};
const previousExercise = (solution?: UserSolution) => {
if (solution) {
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
}
if (exerciseIndex > 0) {
setExerciseIndex((prev) => prev - 1);
}
};
const getExercise = () => {
const exercise = exam.exercises[exerciseIndex];
return {
...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
};
};
return (
<>
<div className="flex flex-col h-full w-full gap-8 items-center">
<ModuleTitle
minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1}
module="level"
totalExercises={countExercises(exam.exercises)}
disableTimer={showSolutions}
/>
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&
renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
</div>
</>
);
}

View File

@@ -1,5 +1,9 @@
import {ListeningExam, UserSolution} from "@/interfaces/exam"; import {ListeningExam, UserSolution} from "@/interfaces/exam";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import Icon from "@mdi/react";
import {mdiArrowRight} from "@mdi/js";
import clsx from "clsx";
import {infoButtonStyle} from "@/constants/buttonStyles";
import {renderExercise} from "@/components/Exercises"; import {renderExercise} from "@/components/Exercises";
import {renderSolution} from "@/components/Solutions"; import {renderSolution} from "@/components/Solutions";
import ModuleTitle from "@/components/Medium/ModuleTitle"; import ModuleTitle from "@/components/Medium/ModuleTitle";
@@ -8,7 +12,6 @@ import Button from "@/components/Low/Button";
import BlankQuestionsModal from "@/components/BlankQuestionsModal"; import BlankQuestionsModal from "@/components/BlankQuestionsModal";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams"; import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils";
interface Props { interface Props {
exam: ListeningExam; exam: ListeningExam;
@@ -17,12 +20,9 @@ interface Props {
} }
export default function Listening({exam, showSolutions = false, onFinish}: Props) { export default function Listening({exam, showSolutions = false, onFinish}: Props) {
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1); const [exerciseIndex, setExerciseIndex] = useState(-1);
const [partIndex, setPartIndex] = useState(0);
const [timesListened, setTimesListened] = useState(0); const [timesListened, setTimesListened] = useState(0);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>( const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
exam.parts.flatMap((x) => x.exercises).map((x) => defaultUserSolutions(x, exam)),
);
const [showBlankModal, setShowBlankModal] = useState(false); const [showBlankModal, setShowBlankModal] = useState(false);
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]); const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
@@ -47,17 +47,11 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]); setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
} }
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) { if (exerciseIndex + 1 < exam.exercises.length && !hasExamEnded) {
setExerciseIndex((prev) => prev + 1); setExerciseIndex((prev) => prev + 1);
return; return;
} }
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
setPartIndex((prev) => prev + 1);
setExerciseIndex(showSolutions ? 0 : -1);
return;
}
if ( if (
solution && solution &&
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every( ![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
@@ -90,7 +84,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
}; };
const getExercise = () => { const getExercise = () => {
const exercise = exam.parts[partIndex].exercises[exerciseIndex]; const exercise = exam.exercises[exerciseIndex];
return { return {
...exercise, ...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [], userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
@@ -102,19 +96,17 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
<div className="flex flex-col w-full gap-2"> <div className="flex flex-col w-full gap-2">
<h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4> <h4 className="text-xl font-semibold">Please listen to the following audio attentively.</h4>
<span className="text-base"> <span className="text-base">
{exam.parts[partIndex].audio.repeatableTimes > 0 {exam.audio.repeatableTimes > 0
? `You will only be allowed to listen to the audio ${exam.parts[partIndex].audio.repeatableTimes - timesListened} time(s).` ? `You will only be allowed to listen to the audio ${exam.audio.repeatableTimes - timesListened} time(s).`
: "You may listen to the audio as many times as you would like."} : "You may listen to the audio as many times as you would like."}
</span> </span>
</div> </div>
<div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit"> <div className="rounded-xl flex flex-col gap-4 items-center w-full h-fit">
<AudioPlayer <AudioPlayer
key={partIndex} src={exam.audio.source}
src={exam.parts[partIndex].audio.source}
color="listening" color="listening"
onEnd={() => setTimesListened((prev) => prev + 1)} onEnd={() => setTimesListened((prev) => prev + 1)}
disabled={timesListened === exam.parts[partIndex].audio.repeatableTimes} disabled={timesListened === exam.audio.repeatableTimes}
disablePause
/> />
</div> </div>
</div> </div>
@@ -125,46 +117,23 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} /> <BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
<div className="flex flex-col h-full w-full gap-8 justify-between"> <div className="flex flex-col h-full w-full gap-8 justify-between">
<ModuleTitle <ModuleTitle
exerciseIndex={ exerciseIndex={exerciseIndex + 1}
(exam.parts
.flatMap((x) => x.exercises)
.findIndex(
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
) || 0) + (exerciseIndex === -1 ? 0 : 1)
}
minTimer={exam.minTimer} minTimer={exam.minTimer}
module="listening" module="listening"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))} totalExercises={exam.exercises.length}
disableTimer={showSolutions} disableTimer={showSolutions}
/> />
{renderAudioPlayer()} {exerciseIndex === -1 && renderAudioPlayer()}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.exercises.length &&
!showSolutions && !showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise)} renderExercise(getExercise(), nextExercise, previousExercise)}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length && exerciseIndex < exam.exercises.length &&
showSolutions && showSolutions &&
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)} renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
</div> </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((prev) => prev - 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 && ( {exerciseIndex === -1 && (
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end"> <Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full justify-self-end">
Start now Start now

View File

@@ -18,7 +18,6 @@ import Button from "@/components/Low/Button";
import BlankQuestionsModal from "@/components/BlankQuestionsModal"; import BlankQuestionsModal from "@/components/BlankQuestionsModal";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams"; import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils";
interface Props { interface Props {
exam: ReadingExam; exam: ReadingExam;
@@ -81,30 +80,13 @@ function TextModal({isOpen, title, content, onClose}: {isOpen: boolean; title: s
} }
export default function Reading({exam, showSolutions = false, onFinish}: Props) { export default function Reading({exam, showSolutions = false, onFinish}: Props) {
const [exerciseIndex, setExerciseIndex] = useState(showSolutions ? 0 : -1); const [exerciseIndex, setExerciseIndex] = useState(-1);
const [partIndex, setPartIndex] = useState(0);
const [showTextModal, setShowTextModal] = useState(false); const [showTextModal, setShowTextModal] = useState(false);
const [userSolutions, setUserSolutions] = useState<UserSolution[]>( const [userSolutions, setUserSolutions] = useState<UserSolution[]>(exam.exercises.map((x) => defaultUserSolutions(x, exam)));
exam.parts.flatMap((x) => x.exercises).map((x) => defaultUserSolutions(x, exam)),
);
const [showBlankModal, setShowBlankModal] = useState(false); const [showBlankModal, setShowBlankModal] = useState(false);
const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]); const [hasExamEnded, setHasExamEnded] = useExamStore((state) => [state.hasExamEnded, state.setHasExamEnded]);
useEffect(() => {
const listener = (e: KeyboardEvent) => {
if (e.key === "F3" || ((e.ctrlKey || e.metaKey) && e.key === "f")) {
e.preventDefault();
}
};
document.addEventListener("keydown", listener);
return () => {
document.removeEventListener("keydown", listener);
};
}, []);
useEffect(() => { useEffect(() => {
if (hasExamEnded && exerciseIndex === -1) { if (hasExamEnded && exerciseIndex === -1) {
setExerciseIndex((prev) => prev + 1); setExerciseIndex((prev) => prev + 1);
@@ -125,17 +107,11 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]); setUserSolutions((prev) => [...prev.filter((x) => x.exercise !== solution.exercise), solution]);
} }
if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) { if (exerciseIndex + 1 < exam.exercises.length && !hasExamEnded) {
setExerciseIndex((prev) => prev + 1); setExerciseIndex((prev) => prev + 1);
return; return;
} }
if (partIndex + 1 < exam.parts.length && !hasExamEnded) {
setPartIndex((prev) => prev + 1);
setExerciseIndex(showSolutions ? 0 : -1);
return;
}
if ( if (
solution && solution &&
![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every( ![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every(
@@ -168,7 +144,7 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
}; };
const getExercise = () => { const getExercise = () => {
const exercise = exam.parts[partIndex].exercises[exerciseIndex]; const exercise = exam.exercises[exerciseIndex];
return { return {
...exercise, ...exercise,
userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [], userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [],
@@ -176,7 +152,7 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
}; };
const renderText = () => ( const renderText = () => (
<div className="flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16 mt-4"> <div className="flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl py-8 px-16">
<div className="flex flex-col w-full gap-2"> <div className="flex flex-col w-full gap-2">
<h4 className="text-xl font-semibold"> <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. Please read the following excerpt attentively, you will then be asked questions about the text you&apos;ve read.
@@ -184,10 +160,10 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
<span className="text-base">You will be allowed to read the text while doing the exercises</span> <span className="text-base">You will be allowed to read the text while doing the exercises</span>
</div> </div>
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
<h3 className="text-xl font-semibold">{exam.parts[partIndex].text.title}</h3> <h3 className="text-xl font-semibold">{exam.text.title}</h3>
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" /> <div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
<span className="overflow-auto"> <span className="overflow-auto">
{exam.parts[partIndex].text.content.split("\\n").map((line, index) => ( {exam.text.content.split("\\n").map((line, index) => (
<p key={index}>{line}</p> <p key={index}>{line}</p>
))} ))}
</span> </span>
@@ -199,33 +175,25 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
<> <>
<div className="flex flex-col h-full w-full gap-8"> <div className="flex flex-col h-full w-full gap-8">
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} /> <BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
<TextModal {...exam.parts[partIndex].text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} /> <TextModal {...exam.text} isOpen={showTextModal} onClose={() => setShowTextModal(false)} />
<ModuleTitle <ModuleTitle
minTimer={exam.minTimer} minTimer={exam.minTimer}
exerciseIndex={ exerciseIndex={exerciseIndex + 1}
(exam.parts
.flatMap((x) => x.exercises)
.findIndex(
(x) => x.id === exam.parts[partIndex].exercises[exerciseIndex === -1 ? exerciseIndex + 1 : exerciseIndex]?.id,
) || 0) + (exerciseIndex === -1 ? 0 : 1)
}
module="reading" module="reading"
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))} totalExercises={exam.exercises.length}
disableTimer={showSolutions} disableTimer={showSolutions}
label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.parts[partIndex].exercises[exerciseIndex].type)} label={exerciseIndex === -1 ? undefined : convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
/> />
<div className={clsx("mb-20 w-full", exerciseIndex > -1 && "grid grid-cols-2 gap-4")}> {exerciseIndex === -1 && renderText()}
{renderText()} {exerciseIndex > -1 &&
{exerciseIndex > -1 && exerciseIndex < exam.exercises.length &&
exerciseIndex < exam.parts[partIndex].exercises.length && !showSolutions &&
!showSolutions && renderExercise(getExercise(), nextExercise, previousExercise)}
renderExercise(getExercise(), nextExercise, previousExercise)} {exerciseIndex > -1 &&
{exerciseIndex > -1 && exerciseIndex < exam.exercises.length &&
exerciseIndex < exam.parts[partIndex].exercises.length && showSolutions &&
showSolutions && renderSolution(exam.exercises[exerciseIndex], nextExercise, previousExercise)}
renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)} {exerciseIndex > -1 && exerciseIndex < exam.exercises.length && (
</div>
{exerciseIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (
<Button <Button
color="purple" color="purple"
variant="outline" variant="outline"
@@ -235,25 +203,7 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
</Button> </Button>
)} )}
</div> </div>
{exerciseIndex === -1 && partIndex > 0 && ( {exerciseIndex === -1 && (
<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((prev) => prev - 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"> <Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
Start now Start now
</Button> </Button>

View File

@@ -4,25 +4,21 @@ import {Module} from "@/interfaces";
import clsx from "clsx"; import clsx from "clsx";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import ProgressBar from "@/components/Low/ProgressBar"; import ProgressBar from "@/components/Low/ProgressBar";
import {BsBook, BsCheck, BsCheckCircle, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs"; import {BsBook, BsCheckCircle, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs";
import {totalExamsByModule} from "@/utils/stats"; import {totalExamsByModule} from "@/utils/stats";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import {calculateAverageLevel} from "@/utils/score"; import {calculateAverageLevel} from "@/utils/score";
import {sortByModuleName} from "@/utils/moduleUtils"; import {sortByModuleName} from "@/utils/moduleUtils";
import {capitalize} from "lodash";
import ProfileSummary from "@/components/ProfileSummary";
interface Props { interface Props {
user: User; user: User;
page: "exercises" | "exams"; onStart: (modules: Module[]) => void;
onStart: (modules: Module[], avoidRepeated: boolean) => void;
disableSelection?: boolean; disableSelection?: boolean;
} }
export default function Selection({user, page, onStart, disableSelection = false}: Props) { export default function Selection({user, onStart, disableSelection = false}: Props) {
const [selectedModules, setSelectedModules] = useState<Module[]>([]); const [selectedModules, setSelectedModules] = useState<Module[]>([]);
const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true);
const {stats} = useStats(user?.id); const {stats} = useStats(user?.id);
const toggleModule = (module: Module) => { const toggleModule = (module: Module) => {
@@ -32,71 +28,82 @@ export default function Selection({user, page, onStart, disableSelection = false
return ( return (
<> <>
<div className="w-full h-full relative flex flex-col gap-8 md:gap-16"> <div className="w-full h-full relative flex flex-col gap-16">
{user && ( <section className="w-full flex gap-8">
<ProfileSummary <img src={user.profilePicture} alt={user.name} className="aspect-square h-64 rounded-3xl drop-shadow-xl object-cover" />
user={user} <div className="flex flex-col gap-4 py-4 w-full">
items={[ <div className="flex justify-between w-full gap-8">
{ <div className="flex flex-col gap-2 py-2">
icon: <BsBook className="text-ielts-reading w-6 h-6 md:w-8 md:h-8" />, <h1 className="font-bold text-4xl">{user.name}</h1>
label: "Reading", <h6 className="font-normal text-base text-mti-gray-taupe capitalize">{user.type}</h6>
value: totalExamsByModule(stats, "reading"), </div>
}, <ProgressBar
{ label={`Level ${calculateAverageLevel(user.levels).toFixed(1)}`}
icon: <BsHeadphones className="text-ielts-listening w-6 h-6 md:w-8 md:h-8" />, percentage={100}
label: "Listening", color="purple"
value: totalExamsByModule(stats, "listening"), className="max-w-xs w-32 self-end h-10"
}, />
{ </div>
icon: <BsPen className="text-ielts-writing w-6 h-6 md:w-8 md:h-8" />, <ProgressBar
label: "Writing", label=""
value: totalExamsByModule(stats, "writing"), percentage={Math.round((calculateAverageLevel(user.levels) * 100) / calculateAverageLevel(user.desiredLevels))}
}, color="red"
{ className="w-full h-3 drop-shadow-lg"
icon: <BsMegaphone className="text-ielts-speaking w-6 h-6 md:w-8 md:h-8" />, />
label: "Speaking", <div className="flex justify-between w-full mt-8">
value: totalExamsByModule(stats, "speaking"), <div className="flex gap-4 items-center">
}, <div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
{ <BsBook className="text-ielts-reading w-8 h-8" />
icon: <BsClipboard className="text-ielts-level w-6 h-6 md:w-8 md:h-8" />, </div>
label: "Level", <div className="flex flex-col">
value: totalExamsByModule(stats, "level"), <span className="font-bold text-xl">{totalExamsByModule(stats, "reading")}</span>
}, <span className="font-normal text-base text-mti-gray-dim">Reading</span>
]} </div>
/> </div>
)} <div className="flex gap-4 items-center">
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
<BsHeadphones className="text-ielts-listening w-8 h-8" />
</div>
<div className="flex flex-col">
<span className="font-bold text-xl">{totalExamsByModule(stats, "listening")}</span>
<span className="font-normal text-base text-mti-gray-dim">Listening</span>
</div>
</div>
<div className="flex gap-4 items-center">
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
<BsPen className="text-ielts-writing w-8 h-8" />
</div>
<div className="flex flex-col">
<span className="font-bold text-xl">{totalExamsByModule(stats, "writing")}</span>
<span className="font-normal text-base text-mti-gray-dim">Writing</span>
</div>
</div>
<div className="flex gap-4 items-center">
<div className="w-16 h-16 border border-mti-gray-platinum bg-mti-gray-smoke flex items-center justify-center rounded-xl">
<BsMegaphone className="text-ielts-speaking w-8 h-8" />
</div>
<div className="flex flex-col">
<span className="font-bold text-xl">{totalExamsByModule(stats, "speaking")}</span>
<span className="font-normal text-base text-mti-gray-dim">Speaking</span>
</div>
</div>
</div>
</div>
</section>
<section className="flex flex-col gap-3"> <section className="flex flex-col gap-3">
<span className="font-bold text-lg">About {capitalize(page)}</span> <span className="font-bold text-lg">About Exams</span>
<span className="text-mti-gray-taupe"> <span className="text-mti-gray-taupe">
{page === "exercises" && ( This comprehensive test will assess your proficiency in reading, listening, writing, and speaking English. Be prepared to dive
<> into a variety of interesting and challenging topics while showcasing your ability to communicate effectively in English.
In the realm of language acquisition, practice makes perfect, and our exercises are the key to unlocking your full Master the vocabulary, grammar, and interpretation skills required to succeed in this high-level exam. Are you ready to
potential. Dive into a world of interactive and engaging exercises that cater to diverse learning styles. From grammar demonstrate your mastery of the English language to the world?
drills that build a strong foundation to vocabulary challenges that broaden your lexicon, our exercises are carefully
designed to make learning English both enjoyable and effective. Whether you&apos;re looking to reinforce specific
skills or embark on a holistic language journey, our exercises are your companions in the pursuit of excellence.
Embrace the joy of learning as you navigate through a variety of activities that cater to every facet of language
acquisition. Your linguistic adventure starts here!
</>
)}
{page === "exams" && (
<>
Welcome to the heart of success on your English language journey! Our exams are crafted with precision to assess and
enhance your language skills. Each test is a passport to your linguistic prowess, designed to challenge and elevate
your abilities. Whether you&apos;re a beginner or a seasoned learner, our exams cater to all levels, providing a
comprehensive evaluation of your reading, writing, speaking, and listening skills. Prepare to embark on a journey of
self-discovery and language mastery as you navigate through our thoughtfully curated exams. Your success is not just a
destination; it&apos;s a testament to your dedication and our commitment to empowering you with the English language.
</>
)}
</span> </span>
</section> </section>
<section className="w-full flex -lg:flex-col -lg:items-center -lg:gap-12 justify-between gap-8 mt-8"> <section className="w-full flex justify-between gap-8 mt-8">
<div <div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("reading") : undefined} onClick={!disableSelection ? () => toggleModule("reading") : undefined}
className={clsx( className={clsx(
"relative w-64 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", "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("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}> )}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-0 -translate-y-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-reading top-0 -translate-y-1/2">
@@ -106,18 +113,17 @@ export default function Selection({user, page, onStart, disableSelection = false
<p className="text-center text-xs"> <p className="text-center text-xs">
Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English. Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.
</p> </p>
{!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && ( {!selectedModules.includes("reading") && !disableSelection && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)} )}
{(selectedModules.includes("reading") || disableSelection) && ( {(selectedModules.includes("reading") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" /> <BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
</div> </div>
<div <div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("listening") : undefined} onClick={!disableSelection ? () => toggleModule("listening") : undefined}
className={clsx( className={clsx(
"relative w-64 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", "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") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}> )}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-0 -translate-y-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-listening top-0 -translate-y-1/2">
@@ -127,18 +133,17 @@ export default function Selection({user, page, onStart, disableSelection = false
<p className="text-center text-xs"> <p className="text-center text-xs">
Improve your ability to follow conversations in English and your ability to understand different accents and intonations. Improve your ability to follow conversations in English and your ability to understand different accents and intonations.
</p> </p>
{!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && ( {!selectedModules.includes("listening") && !disableSelection && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)} )}
{(selectedModules.includes("listening") || disableSelection) && ( {(selectedModules.includes("listening") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" /> <BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
</div> </div>
<div <div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("writing") : undefined} onClick={!disableSelection ? () => toggleModule("writing") : undefined}
className={clsx( className={clsx(
"relative w-64 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", "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") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}> )}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-0 -translate-y-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-writing top-0 -translate-y-1/2">
@@ -148,18 +153,17 @@ export default function Selection({user, page, onStart, disableSelection = false
<p className="text-center text-xs"> <p className="text-center text-xs">
Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays. Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.
</p> </p>
{!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && ( {!selectedModules.includes("writing") && !disableSelection && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)} )}
{(selectedModules.includes("writing") || disableSelection) && ( {(selectedModules.includes("writing") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" /> <BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
</div> </div>
<div <div
onClick={!disableSelection && !selectedModules.includes("level") ? () => toggleModule("speaking") : undefined} onClick={!disableSelection ? () => toggleModule("speaking") : undefined}
className={clsx( className={clsx(
"relative w-64 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", "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") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum", selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}> )}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-0 -translate-y-1/2"> <div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-speaking top-0 -translate-y-1/2">
@@ -169,72 +173,23 @@ export default function Selection({user, page, onStart, disableSelection = false
<p className="text-center text-xs"> <p className="text-center text-xs">
You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings. You&apos;ll have access to interactive dialogs, pronunciation exercises and speech recordings.
</p> </p>
{!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && ( {!selectedModules.includes("speaking") && !disableSelection && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" /> <div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)} )}
{(selectedModules.includes("speaking") || disableSelection) && ( {(selectedModules.includes("speaking") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" /> <BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
)} )}
{selectedModules.includes("level") && <BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />}
</div> </div>
{!disableSelection && (
<div
onClick={selectedModules.length === 0 || selectedModules.includes("level") ? () => toggleModule("level") : undefined}
className={clsx(
"relative w-64 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("level") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum",
)}>
<div className="absolute w-16 h-16 flex items-center justify-center rounded-full bg-ielts-level top-0 -translate-y-1/2">
<BsClipboard className="text-white w-7 h-7" />
</div>
<span className="font-semibold">Level:</span>
<p className="text-center text-xs">You&apos;ll be able to test your english level with multiple choice questions.</p>
{!selectedModules.includes("level") && selectedModules.length === 0 && !disableSelection && (
<div className="border border-mti-gray-platinum w-8 h-8 rounded-full mt-4" />
)}
{(selectedModules.includes("level") || disableSelection) && (
<BsCheckCircle className="mt-4 text-mti-purple-light w-8 h-8" />
)}
{!selectedModules.includes("level") && selectedModules.length > 0 && (
<BsXCircle className="mt-4 text-mti-red-light w-8 h-8" />
)}
</div>
)}
</section> </section>
<div className="flex w-full -md:flex-col -md:gap-4 -md:justify-center md:justify-between items-center"> <Button
<div onClick={() =>
className="flex gap-3 items-center text-mti-gray-dim text-sm cursor-pointer tooltip w-full -md:justify-center" onStart(!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"])
data-tip="If possible, the platform will choose exams not yet done" }
onClick={() => setAvoidRepeatedExams((prev) => !prev)}> color="purple"
<input type="checkbox" className="hidden" /> className="px-12 w-full max-w-xs self-end"
<div disabled={selectedModules.length === 0 && !disableSelection}>
className={clsx( Start Exam
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white", </Button>
"transition duration-300 ease-in-out",
avoidRepeatedExams && "!bg-mti-purple-light ",
)}>
<BsCheck color="white" className="w-full h-full" />
</div>
<span>Avoid Repeated Questions</span>
</div>
<div className="tooltip w-full" data-tip={`Your screen size is too small to do ${page}`}>
<Button color="purple" className="px-12 w-full max-w-xs md:hidden" disabled>
Start Exam
</Button>
</div>
<Button
onClick={() =>
onStart(
!disableSelection ? selectedModules.sort(sortByModuleName) : ["reading", "listening", "writing", "speaking"],
avoidRepeatedExams,
)
}
color="purple"
className="px-12 w-full max-w-xs md:self-end -md:hidden"
disabled={selectedModules.length === 0 && !disableSelection}>
Start Exam
</Button>
</div>
</div> </div>
</> </>
); );

View File

@@ -5,8 +5,6 @@ import {infoButtonStyle} from "@/constants/buttonStyles";
import {UserSolution, SpeakingExam} from "@/interfaces/exam"; import {UserSolution, SpeakingExam} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams"; import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils";
import {convertCamelCaseToReadable} from "@/utils/string";
import {mdiArrowRight} from "@mdi/js"; import {mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import clsx from "clsx"; import clsx from "clsx";
@@ -69,11 +67,10 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
<> <>
<div className="flex flex-col h-full w-full gap-8 items-center"> <div className="flex flex-col h-full w-full gap-8 items-center">
<ModuleTitle <ModuleTitle
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
minTimer={exam.minTimer} minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1} exerciseIndex={exerciseIndex + 1}
module="speaking" module="speaking"
totalExercises={countExercises(exam.exercises)} totalExercises={exam.exercises.length}
disableTimer={showSolutions} disableTimer={showSolutions}
/> />
{exerciseIndex > -1 && {exerciseIndex > -1 &&

View File

@@ -5,7 +5,6 @@ import {infoButtonStyle} from "@/constants/buttonStyles";
import {UserSolution, WritingExam} from "@/interfaces/exam"; import {UserSolution, WritingExam} from "@/interfaces/exam";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {defaultUserSolutions} from "@/utils/exams"; import {defaultUserSolutions} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils";
import {mdiArrowRight} from "@mdi/js"; import {mdiArrowRight} from "@mdi/js";
import Icon from "@mdi/react"; import Icon from "@mdi/react";
import clsx from "clsx"; import clsx from "clsx";
@@ -72,7 +71,7 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
minTimer={exam.minTimer} minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1} exerciseIndex={exerciseIndex + 1}
module="writing" module="writing"
totalExercises={countExercises(exam.exercises)} totalExercises={exam.exercises.length}
disableTimer={showSolutions} disableTimer={showSolutions}
/> />
{exerciseIndex > -1 && {exerciseIndex > -1 &&

View File

@@ -1,7 +1,5 @@
import {initializeApp} from "firebase/app"; import {initializeApp} from "firebase/app";
import * as admin from "firebase-admin/app"; import {getFirestore} from "firebase/firestore";
const serviceAccount = require("@/constants/serviceAccountKey.json");
const firebaseConfig = { const firebaseConfig = {
apiKey: process.env.FIREBASE_PUBLIC_API_KEY || "", apiKey: process.env.FIREBASE_PUBLIC_API_KEY || "",
@@ -13,10 +11,4 @@ const firebaseConfig = {
measurementId: process.env.FIREBASE_MEASUREMENT_ID || "", measurementId: process.env.FIREBASE_MEASUREMENT_ID || "",
}; };
export const app = initializeApp(firebaseConfig, Math.random().toString()); export const app = initializeApp(firebaseConfig);
export const adminApp = admin.initializeApp(
{
credential: admin.cert(serviceAccount),
},
Math.random().toString(),
);

View File

@@ -1,32 +0,0 @@
import {Assignment} from "@/interfaces/results";
import axios from "axios";
import {useEffect, useState} from "react";
export default function useAssignments({assigner, assignees}: {assigner?: string; assignees?: string}) {
const [assignments, setAssignments] = useState<Assignment[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<Assignment[]>("/api/assignments")
.then((response) => {
if (assigner) {
setAssignments(response.data.filter((a) => a.assigner === assigner));
return;
}
if (assignees) {
setAssignments(response.data.filter((a) => a.assignees.filter((x) => assignees.includes(x)).length > 0));
return;
}
setAssignments(response.data);
})
.finally(() => setIsLoading(false));
};
useEffect(getData, [assignees, assigner]);
return {assignments, isLoading, isError, reload: getData};
}

View File

@@ -1,21 +0,0 @@
import {Exam} from "@/interfaces/exam";
import axios from "axios";
import {useEffect, useState} from "react";
export default function useExams() {
const [exams, setExams] = useState<Exam[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<Exam[]>("/api/exam")
.then((response) => setExams(response.data))
.finally(() => setIsLoading(false));
};
useEffect(getData, []);
return {exams, isLoading, isError, reload: getData};
}

View File

@@ -1,26 +0,0 @@
import {Group, User} from "@/interfaces/user";
import axios from "axios";
import {useEffect, useState} from "react";
export default function useGroups(admin?: string) {
const [groups, setGroups] = useState<Group[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const getData = () => {
setIsLoading(true);
axios
.get<Group[]>("/api/groups")
.then((response) => {
const filter = (g: Group) => g.admin === admin || g.participants.includes(admin || "");
const filteredGroups = admin ? response.data.filter(filter) : response.data;
return setGroups(admin ? filteredGroups.map((g) => ({...g, disableEditing: g.disableEditing || g.admin !== admin})) : filteredGroups);
})
.finally(() => setIsLoading(false));
};
useEffect(getData, [admin]);
return {groups, isLoading, isError, reload: getData};
}

View File

@@ -16,9 +16,9 @@ export default function useUser({redirectTo = "", redirectIfFound = false} = {})
if ( if (
// If redirectTo is set, redirect if the user was not found. // If redirectTo is set, redirect if the user was not found.
(redirectTo && !redirectIfFound && (!user || (user && !user.isVerified))) || (redirectTo && !redirectIfFound && !user) ||
// If redirectIfFound is also set, redirect if the user was found // If redirectIfFound is also set, redirect if the user was found
(redirectIfFound && user && user.isVerified) (redirectIfFound && user)
) { ) {
Router.push(redirectTo); Router.push(redirectTo);
} }

View File

@@ -7,15 +7,13 @@ export default function useUsers() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
const getData = () => { useEffect(() => {
setIsLoading(true); setIsLoading(true);
axios axios
.get<User[]>("/api/users/list", {headers: {page: "register"}}) .get<User[]>("/api/users/list")
.then((response) => setUsers(response.data)) .then((response) => setUsers(response.data))
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; }, []);
useEffect(getData, []); return {users, isLoading, isError};
return {users, isLoading, isError, reload: getData};
} }

View File

@@ -1,39 +1,27 @@
import {Module} from "."; import {Module} from ".";
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam; export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam;
export interface ReadingExam { export interface ReadingExam {
parts: { text: {
text: { title: string;
title: string; content: string;
content: string; };
};
exercises: Exercise[];
}[];
id: string; id: string;
exercises: Exercise[];
module: "reading"; module: "reading";
minTimer: number; minTimer: number;
type: "academic" | "general"; type: "academic" | "general";
isDiagnostic: boolean; isDiagnostic: boolean;
} }
export interface LevelExam { export interface ListeningExam {
module: "level"; audio: {
source: string;
repeatableTimes: number; // *The amount of times the user is allowed to repeat the audio, 0 for unlimited
};
id: string; id: string;
exercises: Exercise[]; exercises: Exercise[];
minTimer: number;
isDiagnostic: boolean;
}
export interface ListeningExam {
parts: {
audio: {
source: string;
repeatableTimes: number; // *The amount of times the user is allowed to repeat the audio, 0 for unlimited
};
exercises: Exercise[];
}[];
id: string;
module: "listening"; module: "listening";
minTimer: number; minTimer: number;
isDiagnostic: boolean; isDiagnostic: boolean;
@@ -80,8 +68,7 @@ export type Exercise =
| MultipleChoiceExercise | MultipleChoiceExercise
| WriteBlanksExercise | WriteBlanksExercise
| WritingExercise | WritingExercise
| SpeakingExercise | SpeakingExercise;
| InteractiveSpeakingExercise;
export interface Evaluation { export interface Evaluation {
comment: string; comment: string;
@@ -112,7 +99,6 @@ export interface SpeakingExercise {
title: string; title: string;
text: string; text: string;
prompts: string[]; prompts: string[];
video_url: string;
userSolutions: { userSolutions: {
id: string; id: string;
solution: string; solution: string;
@@ -120,19 +106,6 @@ export interface SpeakingExercise {
}[]; }[];
} }
export interface InteractiveSpeakingExercise {
id: string;
type: "interactiveSpeaking";
title: string;
text: string;
prompts: {text: string; video_url: string}[];
userSolutions: {
id: string;
solution: {question: string; answer: string}[];
evaluation?: Evaluation;
}[];
}
export interface FillBlanksExercise { export interface FillBlanksExercise {
prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it." prompt: string; // *EXAMPLE: "Complete the summary below. Click a blank to select the corresponding word for it."
type: "fillBlanks"; type: "fillBlanks";

View File

@@ -1 +1 @@
export type Module = "reading" | "listening" | "writing" | "speaking" | "level"; export type Module = "reading" | "listening" | "writing" | "speaking";

View File

@@ -1,5 +1,4 @@
import {Module} from "@/interfaces"; import {Module} from "@/interfaces";
import {Stat} from "./user";
export type UserResults = {[key in Module]: ModuleResult}; export type UserResults = {[key in Module]: ModuleResult};
@@ -8,18 +7,3 @@ interface ModuleResult {
score: number; score: number;
total: number; total: number;
} }
export interface Assignment {
id: string;
name: string;
assigner: string;
assignees: string[];
results: {
user: string;
type: "academic" | "general";
stats: Stat[];
}[];
exams: {id: string; module: Module}[];
startDate: Date;
endDate: Date;
}

View File

@@ -12,48 +12,8 @@ export interface User {
desiredLevels: {[key in Module]: number}; desiredLevels: {[key in Module]: number};
type: Type; type: Type;
bio: string; bio: string;
isVerified: boolean;
demographicInformation?: DemographicInformation;
corporateInformation?: CorporateInformation;
subscriptionExpirationDate?: null | Date;
registrationDate?: Date;
status: "active" | "disabled" | "paymentDue";
} }
export interface CorporateInformation {
companyInformation: CompanyInformation;
payment?: {
value: number;
currency: string;
};
monthlyDuration: number;
referralAgent?: string;
allowedUserAmount?: number;
}
export interface CompanyInformation {
name: string;
userAmount: number;
}
export interface DemographicInformation {
country: string;
phone: string;
gender: Gender;
employment: EmploymentStatus;
}
export type Gender = "male" | "female" | "other";
export type EmploymentStatus = "employed" | "student" | "self-employed" | "unemployed" | "retired" | "other";
export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [
{status: "student", label: "Student"},
{status: "employed", label: "Employed"},
{status: "unemployed", label: "Unemployed"},
{status: "self-employed", label: "Self-employed"},
{status: "retired", label: "Retired"},
{status: "other", label: "Other"},
];
export interface Stat { export interface Stat {
user: string; user: string;
exam: string; exam: string;
@@ -63,8 +23,6 @@ export interface Stat {
module: Module; module: Module;
solutions: any[]; solutions: any[];
type: string; type: string;
timeSpent?: number;
assignment?: string;
score: { score: {
correct: number; correct: number;
total: number; total: number;
@@ -72,13 +30,5 @@ export interface Stat {
}; };
} }
export interface Group { export type Type = "student" | "teacher" | "admin" | "owner" | "developer";
admin: string; export const userTypes: Type[] = ["student", "teacher", "admin", "owner", "developer"];
name: string;
participants: string[];
id: string;
disableEditing?: boolean;
}
export type Type = "student" | "teacher" | "corporate" | "owner" | "developer" | "agent";
export const userTypes: Type[] = ["student", "teacher", "corporate", "owner", "developer", "agent"];

View File

@@ -1,144 +0,0 @@
import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox";
import {PERMISSIONS} from "@/constants/userPermissions";
import {Type, User} from "@/interfaces/user";
import axios from "axios";
import clsx from "clsx";
import {capitalize} from "lodash";
import moment from "moment";
import {useEffect, useState} from "react";
import ReactDatePicker from "react-datepicker";
import {toast} from "react-toastify";
import ShortUniqueId from "short-unique-id";
import {useFilePicker} from "use-file-picker";
export default function BatchCodeGenerator({user}: {user: User}) {
const [emails, setEmails] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
const {openFilePicker, filesContent} = useFilePicker({
accept: ".txt",
multiple: false,
});
useEffect(() => {
if (user && (user.type === "corporate" || user.type === "teacher")) {
setExpiryDate(user.subscriptionExpirationDate || null);
}
}, [user]);
useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]);
useEffect(() => {
if (filesContent.length > 0) {
const file = filesContent[0];
const emails = file.content
.split("\n")
.filter((x) => new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/).test(x));
if (emails.length === 0) {
toast.error("Please upload a .txt file containing e-mails, one per line!");
return;
}
setEmails([...new Set(emails)]);
}
}, [filesContent]);
const generateCode = (type: Type) => {
const uid = new ShortUniqueId();
const codes = emails.map(() => uid.randomUUID(6));
setIsLoading(true);
axios
.post("/api/code", {type, codes, emails, expiryDate})
.then(({data, status}) => {
if (data.ok) {
toast.success(`Successfully generated ${capitalize(type)} codes and they have been notified by e-mail!`, {toastId: "success"});
return;
}
if (status === 403) {
toast.error(data.reason, {toastId: "forbidden"});
}
})
.catch(({response: {status, data}}) => {
if (status === 403) {
toast.error(data.reason, {toastId: "forbidden"});
return;
}
toast.error(`Something went wrong, please try again later!`, {toastId: "error"});
})
.finally(() => setIsLoading(false));
};
return (
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<label className="font-normal text-base text-mti-gray-dim">Choose a .txt file containing e-mails</label>
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}>
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
</Button>
{user && (user.type === "developer" || user.type === "owner") && (
<>
<div className="flex -md:flex-row md:flex-col -md:items-center 2xl:flex-row 2xl:items-center justify-between gap-2">
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled}>
Enabled
</Checkbox>
</div>
{isExpiryDateEnabled && (
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out",
)}
filterDate={(date) => moment(date).isAfter(new Date())}
dateFormat="dd/MM/yyyy"
selected={expiryDate}
onChange={(date) => setExpiryDate(date)}
/>
)}
</>
)}
<label className="font-normal text-base text-mti-gray-dim">Select the type of user they should be</label>
{user && (
<div className="grid -md:grid-cols-2 md:grid-cols-1 xl:grid-cols-2 gap-4 place-items-center">
<Button
className="w-44 2xl:w-48"
variant="outline"
onClick={() => generateCode("student")}
disabled={emails.length === 0 || isLoading || !PERMISSIONS.generateCode.student.includes(user.type)}>
Student
</Button>
<Button
className="w-44 2xl:w-48"
variant="outline"
onClick={() => generateCode("teacher")}
disabled={emails.length === 0 || isLoading || !PERMISSIONS.generateCode.teacher.includes(user.type)}>
Teacher
</Button>
<Button
className="w-44 2xl:w-48"
variant="outline"
onClick={() => generateCode("corporate")}
disabled={emails.length === 0 || isLoading || !PERMISSIONS.generateCode.corporate.includes(user.type)}>
Corporate
</Button>
<Button
className="w-44 2xl:w-48"
variant="outline"
onClick={() => generateCode("owner")}
disabled={emails.length === 0 || isLoading || !PERMISSIONS.generateCode.owner.includes(user.type)}>
Owner
</Button>
</div>
)}
</div>
);
}

View File

@@ -1,130 +0,0 @@
import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox";
import {PERMISSIONS} from "@/constants/userPermissions";
import {Type, User} from "@/interfaces/user";
import axios from "axios";
import clsx from "clsx";
import {capitalize} from "lodash";
import moment from "moment";
import {useEffect, useState} from "react";
import ReactDatePicker from "react-datepicker";
import {toast} from "react-toastify";
import ShortUniqueId from "short-unique-id";
export default function CodeGenerator({user}: {user: User}) {
const [generatedCode, setGeneratedCode] = useState<string>();
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
useEffect(() => {
if (user && (user.type === "corporate" || user.type === "teacher")) {
setExpiryDate(user.subscriptionExpirationDate || null);
}
}, [user]);
useEffect(() => {
if (!isExpiryDateEnabled) setExpiryDate(null);
}, [isExpiryDateEnabled]);
const generateCode = (type: Type) => {
const uid = new ShortUniqueId();
const code = uid.randomUUID(6);
axios
.post("/api/code", {type, codes: [code], expiryDate})
.then(({data, status}) => {
if (data.ok) {
toast.success(`Successfully generated a ${capitalize(type)} code!`, {toastId: "success"});
setGeneratedCode(code);
return;
}
if (status === 403) {
toast.error(data.reason, {toastId: "forbidden"});
}
})
.catch(({response: {status, data}}) => {
if (status === 403) {
toast.error(data.reason, {toastId: "forbidden"});
return;
}
toast.error(`Something went wrong, please try again later!`, {toastId: "error"});
});
};
return (
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<label className="font-normal text-base text-mti-gray-dim">User Code Generator</label>
{user && (
<div className="grid -md:grid-cols-2 md:grid-cols-1 place-items-center 2xl:grid-cols-2 gap-4">
<Button
className="w-44 md:w-48"
variant="outline"
onClick={() => generateCode("student")}
disabled={!PERMISSIONS.generateCode.student.includes(user.type) || (isExpiryDateEnabled && expiryDate === null)}>
Student
</Button>
<Button
className="w-44 md:w-48"
variant="outline"
onClick={() => generateCode("teacher")}
disabled={!PERMISSIONS.generateCode.teacher.includes(user.type) || (isExpiryDateEnabled && expiryDate === null)}>
Teacher
</Button>
<Button
className="w-44 md:w-48"
variant="outline"
onClick={() => generateCode("corporate")}
disabled={!PERMISSIONS.generateCode.corporate.includes(user.type) || (isExpiryDateEnabled && expiryDate === null)}>
Corporate
</Button>
<Button
className="w-44 md:w-48"
variant="outline"
onClick={() => generateCode("owner")}
disabled={!PERMISSIONS.generateCode.owner.includes(user.type) || (isExpiryDateEnabled && expiryDate === null)}>
Owner
</Button>
</div>
)}
{user && (user.type === "developer" || user.type === "owner") && (
<>
<div className="flex -md:flex-row md:flex-col -md:items-center 2xl:flex-row 2xl:items-center justify-between gap-2">
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label>
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled}>
Enabled
</Checkbox>
</div>
{isExpiryDateEnabled && (
<ReactDatePicker
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out",
)}
filterDate={(date) => moment(date).isAfter(new Date())}
dateFormat="dd/MM/yyyy"
selected={expiryDate}
onChange={(date) => setExpiryDate(date)}
/>
)}
</>
)}
<label className="font-normal text-base text-mti-gray-dim">Generated Code:</label>
<div
className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out",
)}
data-tip="Click to copy"
onClick={() => {
if (generatedCode) navigator.clipboard.writeText(generatedCode);
}}>
{generatedCode}
</div>
{generatedCode && <span className="text-sm text-mti-gray-dim font-light">Give this code to the user to complete their registration</span>}
</div>
);
}

View File

@@ -1,50 +0,0 @@
import Button from "@/components/Low/Button";
import Input from "@/components/Low/Input";
import {Module} from "@/interfaces";
import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {RadioGroup} from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
import {capitalize} from "lodash";
import {useRouter} from "next/router";
import {FormEvent, useState} from "react";
import {toast} from "react-toastify";
export default function ExamGenerator() {
const [selectedModule, setSelectedModule] = useState<Module>();
const [examId, setExamId] = useState<string>();
const [isLoading, setIsLoading] = useState(false);
const setExams = useExamStore((state) => state.setExams);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const router = useRouter();
const generateExam = (module: Module) => {
axios.get(`/api/exam/${module}/generate`).then((result) => console.log(result.data));
};
return (
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<label className="font-normal text-base text-mti-gray-dim">Exam Generator</label>
<div className="w-full grid grid-cols-2 gap-2">
{MODULE_ARRAY.map((module) => (
<Button
onClick={() => generateExam(module)}
key={module}
className={clsx(
"w-full min-w-[200px]",
module === "reading" && "!bg-ielts-reading/80 !border-ielts-reading hover:!bg-ielts-reading",
module === "listening" && "!bg-ielts-listening/80 !border-ielts-listening hover:!bg-ielts-listening",
module === "writing" && "!bg-ielts-writing/80 !border-ielts-writing hover:!bg-ielts-writing",
module === "speaking" && "!bg-ielts-speaking/80 !border-ielts-speaking hover:!bg-ielts-speaking",
)}>
{capitalize(module)}
</Button>
))}
</div>
</div>
);
}

View File

@@ -1,79 +0,0 @@
import Button from "@/components/Low/Button";
import Input from "@/components/Low/Input";
import {Module} from "@/interfaces";
import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {RadioGroup} from "@headlessui/react";
import clsx from "clsx";
import {capitalize} from "lodash";
import {useRouter} from "next/router";
import {FormEvent, useState} from "react";
import {toast} from "react-toastify";
export default function ExamLoader() {
const [selectedModule, setSelectedModule] = useState<Module>();
const [examId, setExamId] = useState<string>();
const [isLoading, setIsLoading] = useState(false);
const setExams = useExamStore((state) => state.setExams);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const router = useRouter();
const loadExam = async (e?: FormEvent) => {
if (e) e.preventDefault();
setIsLoading(true);
if (selectedModule && examId) {
const exam = await getExamById(selectedModule, examId.trim());
if (!exam) {
toast.error("Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID", {
toastId: "invalid-exam-id",
});
setIsLoading(false);
return;
}
setExams([exam]);
setSelectedModules([selectedModule]);
router.push("/exercises");
}
setIsLoading(false);
};
return (
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl">
<label className="font-normal text-base text-mti-gray-dim">Exam Loader</label>
<form className="flex flex-col gap-4 w-full" onSubmit={loadExam}>
<RadioGroup
value={selectedModule}
onChange={setSelectedModule}
className="grid -md:grid-cols-2 md:grid-cols-1 xl:grid-cols-2 items-center gap-4 place-items-center">
{MODULE_ARRAY.map((module) => (
<RadioGroup.Option value={module} key={module}>
{({checked}) => (
<span
className={clsx(
"px-6 py-4 w-44 2xl:w-48 flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
"hover:bg-mti-purple-light hover:border-mti-purple-dark hover:text-white",
"transition duration-300 ease-in-out",
!checked ? "bg-white border-mti-gray-platinum" : "bg-mti-purple-light border-mti-purple-dark text-white",
)}>
{capitalize(module)}
</span>
)}
</RadioGroup.Option>
))}
</RadioGroup>
<Input type="text" name="examId" onChange={setExamId} placeholder="Exam ID" className="w-full" />
<Button disabled={!selectedModule || !examId} isLoading={isLoading} className="w-full">
Load Exam
</Button>
</form>
</div>
);
}

View File

@@ -1,154 +0,0 @@
import {PERMISSIONS} from "@/constants/userPermissions";
import useExams from "@/hooks/useExams";
import useUsers from "@/hooks/useUsers";
import {Module} from "@/interfaces";
import {Exam} from "@/interfaces/exam";
import {Type, User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
import {getExamById} from "@/utils/exams";
import {countExercises} from "@/utils/moduleUtils";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
import axios from "axios";
import clsx from "clsx";
import {capitalize} from "lodash";
import {useRouter} from "next/router";
import {BsCheck, BsTrash, BsUpload} from "react-icons/bs";
import {toast} from "react-toastify";
const CLASSES: {[key in Module]: string} = {
reading: "text-ielts-reading",
listening: "text-ielts-listening",
speaking: "text-ielts-speaking",
writing: "text-ielts-writing",
level: "text-ielts-level",
};
const columnHelper = createColumnHelper<Exam>();
export default function ExamList({user}: {user: User}) {
const {exams, reload} = useExams();
const setExams = useExamStore((state) => state.setExams);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const router = useRouter();
const loadExam = async (module: Module, examId: string) => {
const exam = await getExamById(module, examId.trim());
if (!exam) {
toast.error("Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID", {
toastId: "invalid-exam-id",
});
return;
}
setExams([exam]);
setSelectedModules([module]);
router.push("/exercises");
};
const deleteExam = async (exam: Exam) => {
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return;
axios
.delete(`/api/exam/${exam.module}/${exam.id}`)
.then(() => toast.success(`Deleted the "${exam.id}" exam`))
.catch((reason) => {
if (reason.response.status === 404) {
toast.error("Exam not found!");
return;
}
if (reason.response.status === 403) {
toast.error("You do not have permission to delete this exam!");
return;
}
toast.error("Something went wrong, please try again later.");
})
.finally(reload);
};
const getTotalExercises = (exam: Exam) => {
if (exam.module === "reading" || exam.module === "listening") {
return countExercises(exam.parts.flatMap((x) => x.exercises));
}
return countExercises(exam.exercises);
};
const defaultColumns = [
columnHelper.accessor("id", {
header: "ID",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("module", {
header: "Module",
cell: (info) => <span className={CLASSES[info.getValue()]}>{capitalize(info.getValue())}</span>,
}),
columnHelper.accessor((x) => getTotalExercises(x), {
header: "Exercises",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("minTimer", {
header: "Timer",
cell: (info) => <>{info.getValue()} minute(s)</>,
}),
{
header: "",
id: "actions",
cell: ({row}: {row: {original: Exam}}) => {
return (
<div className="flex gap-4">
<div
data-tip="Load exam"
className="cursor-pointer tooltip"
onClick={async () => await loadExam(row.original.module, row.original.id)}>
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
{PERMISSIONS.examManagement.delete.includes(user.type) && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteExam(row.original)}>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
);
},
},
];
const table = useReactTable({
data: exams,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
return (
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="py-4" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -1,321 +0,0 @@
import Button from "@/components/Low/Button";
import Checkbox from "@/components/Low/Checkbox";
import Input from "@/components/Low/Input";
import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers";
import {Module} from "@/interfaces";
import {Group, User} from "@/interfaces/user";
import {Disclosure, Transition} from "@headlessui/react";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
import axios from "axios";
import clsx from "clsx";
import {capitalize} from "lodash";
import {useEffect, useRef, useState} from "react";
import {BsCheck, BsDash, BsPencil, BsPlus, BsTrash} from "react-icons/bs";
import {toast} from "react-toastify";
import Select from "react-select";
import {uuidv4} from "@firebase/util";
import {useFilePicker} from "use-file-picker";
const columnHelper = createColumnHelper<Group>();
interface CreateDialogProps {
user: User;
users: User[];
group?: Group;
onCreate: (group: Group) => void;
}
const CreatePanel = ({user, users, group, onCreate}: CreateDialogProps) => {
const [name, setName] = useState<string | undefined>(group?.name || undefined);
const [admin, setAdmin] = useState<string>(group?.admin || user.id);
const [participants, setParticipants] = useState<string[]>(group?.participants || []);
const {openFilePicker, filesContent} = useFilePicker({
accept: ".txt",
multiple: false,
});
useEffect(() => {
if (filesContent.length > 0) {
const file = filesContent[0];
const emails = file.content
.toLowerCase()
.split("\n")
.filter((x) => new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/).test(x));
if (emails.length === 0) {
toast.error("Please upload a .txt file containing e-mails, one per line!");
return;
}
const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined);
const filteredUsers = emailUsers.filter(
(x) =>
((user.type === "developer" || user.type === "owner" || user.type === "corporate") &&
(x?.type === "student" || x?.type === "teacher")) ||
(user.type === "teacher" && x?.type === "student"),
);
setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id));
toast.success(
user.type !== "teacher"
? "Added all teachers and students found in the file you've provided!"
: "Added all students found in the file you've provided!",
{toastId: "upload-success"},
);
}
}, [filesContent, user.type, users]);
return (
<div className="flex flex-col gap-12 mt-4 w-full px-4 py-2">
<div className="flex flex-col gap-8">
<Input name="name" type="text" label="Name" defaultValue={name} onChange={setName} required disabled={group?.disableEditing} />
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Participants</label>
<div className="flex gap-8 w-full">
<Select
className="w-full"
value={participants.map((x) => ({
value: x,
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
}))}
placeholder="Participants..."
defaultValue={participants.map((x) => ({
value: x,
label: `${users.find((y) => y.id === x)?.email} - ${users.find((y) => y.id === x)?.name}`,
}))}
options={users
.filter((x) => (user.type === "teacher" ? x.type === "student" : x.type === "student" || x.type === "teacher"))
.map((x) => ({value: x.id, label: `${x.email} - ${x.name}`}))}
onChange={(value) => setParticipants(value.map((x) => x.value))}
isMulti
isSearchable
styles={{
control: (styles) => ({
...styles,
backgroundColor: "white",
borderRadius: "999px",
padding: "1rem 1.5rem",
zIndex: "40",
}),
}}
/>
<Button className="w-full max-w-[300px]" onClick={openFilePicker} variant="outline">
{filesContent.length === 0 ? "Upload participants .txt file" : filesContent[0].name}
</Button>
</div>
</div>
</div>
<Button
className="w-full max-w-[200px] self-end"
disabled={!name}
onClick={() => {
if (name !== group?.name && (name === "Students" || name === "Teachers")) {
toast.error("That group name is reserved and cannot be used, please enter another one.");
return;
}
onCreate({name: name!, admin, participants, id: group?.id || uuidv4()});
}}>
{!group ? "Create" : "Update"}
</Button>
</div>
);
};
const filterTypes = ["corporate", "teacher"];
export default function GroupList({user}: {user: User}) {
const [editingID, setEditingID] = useState<string>();
const [showDisclosure, setShowDisclosure] = useState(false);
const [filterByUser, setFilterByUser] = useState(false);
const {users} = useUsers();
const {groups, reload} = useGroups(user && filterTypes.includes(user?.type) ? user.id : undefined);
useEffect(() => {
if (editingID) setShowDisclosure(true);
}, [editingID]);
useEffect(() => {
if (showDisclosure) document.getElementById("disclosure")?.scrollTo();
if (!showDisclosure) setEditingID(undefined);
}, [showDisclosure]);
useEffect(() => {
if (user && (user.type === "corporate" || user.type === "teacher")) {
setFilterByUser(true);
}
}, [user]);
const createGroup = (group: Group) => {
return axios
.post<{ok: boolean}>("/api/groups", group)
.then(() => {
toast.success(`Group "${group.name}" created successfully`);
return true;
})
.catch(() => {
toast.error("Something went wrong, please try again later!");
return false;
})
.finally(reload);
};
const updateGroup = (group: Group) => {
return axios
.patch<{ok: boolean}>(`/api/groups/${group.id}`, group)
.then(() => {
toast.success(`Group "${group.name}" created successfully`);
return true;
})
.catch(() => {
toast.error("Something went wrong, please try again later!");
return false;
})
.finally(reload);
};
const deleteGroup = (group: Group) => {
if (!confirm(`Are you sure you want to delete "${group.name}"?`)) return;
axios
.delete<{ok: boolean}>(`/api/groups/${group.id}`)
.then(() => toast.success(`Group "${group.name}" deleted successfully`))
.catch(() => toast.error("Something went wrong, please try again later!"))
.finally(reload);
};
const defaultColumns = [
columnHelper.accessor("id", {
header: "ID",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("name", {
header: "Name",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("admin", {
header: "Admin",
cell: (info) => (
<div className="tooltip" data-tip={capitalize(users.find((x) => x.id === info.getValue())?.type)}>
{users.find((x) => x.id === info.getValue())?.name}
</div>
),
}),
columnHelper.accessor("participants", {
header: "Participants",
cell: (info) =>
info
.getValue()
.map((x) => users.find((y) => y.id === x)?.name)
.join(", "),
}),
{
header: "",
id: "actions",
cell: ({row}: {row: {original: Group}}) => {
return (
<>
{(user?.type === "developer" || user?.type === "owner" || user.id === row.original.admin) && (
<div className="flex gap-2">
{editingID !== row.original.id && (
<div data-tip="Edit" className="cursor-pointer tooltip" onClick={() => setEditingID(row.original.id)}>
<BsPencil className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
{!row.original.disableEditing && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteGroup(row.original)}>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
)}
</>
);
},
},
];
const table = useReactTable({
data: groups,
columns: defaultColumns,
getCoreRowModel: getCoreRowModel(),
});
return (
<>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="py-4" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
<>
<div
className={clsx(
"w-full px-4 py-2 bg-mti-purple-ultralight/40 flex gap-2 items-center justify-center rounded-lg",
"transition duration-300 ease-in-out",
"hover:bg-mti-purple-ultralight cursor-pointer",
)}
onClick={() => setShowDisclosure((prev) => !prev)}>
{!showDisclosure ? <BsPlus className="w-6 h-6" /> : <BsDash className="w-6 h-6" />}
<span>{!showDisclosure ? "Create group" : "Cancel"}</span>
</div>
<Transition
show={showDisclosure}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0">
<div id="#disclosure">
<CreatePanel
group={editingID ? groups.find((x) => x.id === editingID) : undefined}
user={user}
users={
user?.type === "corporate" || user?.type === "teacher"
? users.filter(
(u) =>
groups
.filter((g) => g.admin === user.id)
.flatMap((g) => g.participants)
.includes(u.id) || groups.flatMap((g) => g.participants).includes(u.id),
)
: users
}
onCreate={(group) => {
(!editingID ? createGroup : updateGroup)(group).then((result) => {
if (result) {
setShowDisclosure(false);
setEditingID(undefined);
reload();
}
});
}}
/>
</div>
</Transition>
</>
</>
);
}

View File

@@ -1,493 +0,0 @@
import Button from "@/components/Low/Button";
import {PERMISSIONS} from "@/constants/userPermissions";
import useGroups from "@/hooks/useGroups";
import useUsers from "@/hooks/useUsers";
import {Type, User, userTypes} from "@/interfaces/user";
import {Popover, Transition} from "@headlessui/react";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table";
import axios from "axios";
import clsx from "clsx";
import {capitalize, reverse} from "lodash";
import moment from "moment";
import {Fragment, useEffect, useState} from "react";
import {BsArrowDown, BsArrowDownUp, BsArrowUp, BsCheck, BsCheckCircle, BsEye, BsFillExclamationOctagonFill, BsPerson, BsTrash} from "react-icons/bs";
import {toast} from "react-toastify";
import {countries, TCountries} from "countries-list";
import countryCodes from "country-codes-list";
import Modal from "@/components/Modal";
import UserCard from "@/components/UserCard";
const columnHelper = createColumnHelper<User>();
export default function UserList({user, filter}: {user: User; filter?: (user: User) => boolean}) {
const [showDemographicInformation, setShowDemographicInformation] = useState(false);
const [sorter, setSorter] = useState<string>();
const [displayUsers, setDisplayUsers] = useState<User[]>([]);
const [selectedUser, setSelectedUser] = useState<User>();
const {users, reload} = useUsers();
const {groups} = useGroups(user ? user.id : undefined);
const expirationDateColor = (date: Date) => {
const momentDate = moment(date);
const today = moment(new Date());
if (today.isAfter(momentDate)) return "!text-mti-red-light font-bold line-through";
if (today.add(1, "weeks").isAfter(momentDate)) return "!text-mti-red-light";
if (today.add(2, "weeks").isAfter(momentDate)) return "!text-mti-rose-light";
if (today.add(1, "months").isAfter(momentDate)) return "!text-mti-orange-light";
};
useEffect(() => {
if (user && users) {
const filterUsers =
user.type === "corporate" || user.type === "student"
? users.filter((u) => groups.flatMap((g) => g.participants).includes(u.id))
: users;
const filteredUsers = filter ? filterUsers.filter(filter) : filterUsers;
setDisplayUsers([...filteredUsers.sort(sortFunction)]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, users, sorter, groups]);
const deleteAccount = (user: User) => {
if (!confirm(`Are you sure you want to delete ${user.name}'s account?`)) return;
axios
.delete<{ok: boolean}>(`/api/user?id=${user.id}`)
.then(() => {
toast.success("User deleted successfully!");
reload();
})
.catch(() => {
toast.error("Something went wrong!", {toastId: "delete-error"});
});
};
const updateAccountType = (user: User, type: Type) => {
if (!confirm(`Are you sure you want to update ${user.name}'s account from ${capitalize(user.type)} to ${capitalize(type)}?`)) return;
axios
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {...user, type})
.then(() => {
toast.success("User type updated successfully!");
reload();
})
.catch(() => {
toast.error("Something went wrong!", {toastId: "update-error"});
});
};
const verifyAccount = (user: User) => {
axios
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {...user, isVerified: true})
.then(() => {
toast.success("User verified successfully!");
reload();
})
.catch(() => {
toast.error("Something went wrong!", {toastId: "update-error"});
});
};
const toggleDisableAccount = (user: User) => {
if (
!confirm(
`Are you sure you want to ${user.status === "disabled" ? "enable" : "disable"} ${
user.name
}'s account? This change is usually related to their payment state.`,
)
)
return;
axios
.post<{user?: User; ok?: boolean}>(`/api/users/update?id=${user.id}`, {
...user,
status: user.status === "disabled" ? "active" : "disabled",
})
.then(() => {
toast.success(`User ${user.status === "disabled" ? "enabled" : "disabled"} successfully!`);
reload();
})
.catch(() => {
toast.error("Something went wrong!", {toastId: "update-error"});
});
};
const SorterArrow = ({name}: {name: string}) => {
if (sorter === name) return <BsArrowUp />;
if (sorter === reverseString(name)) return <BsArrowDown />;
return <BsArrowDownUp />;
};
const actionColumn = ({row}: {row: {original: User}}) => {
return (
<div className="flex gap-4">
{PERMISSIONS.updateUser[row.original.type].includes(user.type) && (
<Popover className="relative">
<Popover.Button>
<div data-tip="Change Type" className="cursor-pointer tooltip">
<BsPerson className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1">
<Popover.Panel className="absolute z-10 w-screen right-1/2 translate-x-1/3 max-w-sm">
<div className="bg-white p-4 rounded-lg grid grid-cols-2 gap-2 w-full drop-shadow-xl">
<Button
onClick={() => updateAccountType(row.original, "student")}
className="text-sm !py-2 !px-4"
disabled={row.original.type === "student" || !PERMISSIONS.generateCode["student"].includes(user.type)}>
Student
</Button>
<Button
onClick={() => updateAccountType(row.original, "teacher")}
className="text-sm !py-2 !px-4"
disabled={row.original.type === "teacher" || !PERMISSIONS.generateCode["teacher"].includes(user.type)}>
Teacher
</Button>
<Button
onClick={() => updateAccountType(row.original, "corporate")}
className="text-sm !py-2 !px-4"
disabled={row.original.type === "corporate" || !PERMISSIONS.generateCode["corporate"].includes(user.type)}>
Admin
</Button>
<Button
onClick={() => updateAccountType(row.original, "owner")}
className="text-sm !py-2 !px-4"
disabled={row.original.type === "owner" || !PERMISSIONS.generateCode["owner"].includes(user.type)}>
Owner
</Button>
</div>
</Popover.Panel>
</Transition>
</Popover>
)}
{!row.original.isVerified && PERMISSIONS.updateUser[row.original.type].includes(user.type) && (
<div data-tip="Verify User" className="cursor-pointer tooltip" onClick={() => verifyAccount(row.original)}>
<BsCheck className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
{PERMISSIONS.updateUser[row.original.type].includes(user.type) && (
<div
data-tip={row.original.status === "disabled" ? "Enable User" : "Disable User"}
className="cursor-pointer tooltip"
onClick={() => toggleDisableAccount(row.original)}>
{row.original.status === "disabled" ? (
<BsCheckCircle className="hover:text-mti-purple-light transition ease-in-out duration-300" />
) : (
<BsFillExclamationOctagonFill className="hover:text-mti-purple-light transition ease-in-out duration-300" />
)}
</div>
)}
{PERMISSIONS.deleteUser[row.original.type].includes(user.type) && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteAccount(row.original)}>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div>
)}
</div>
);
};
const demographicColumns = [
columnHelper.accessor("name", {
header: (
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "name"))}>
<span>Name</span>
<SorterArrow name="name" />
</button>
) as any,
cell: ({row, getValue}) => (
<div
className={clsx(
PERMISSIONS.updateExpiryDate[row.original.type].includes(user.type) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
)}
onClick={() => (PERMISSIONS.updateExpiryDate[row.original.type].includes(user.type) ? setSelectedUser(row.original) : null)}>
{getValue()}
</div>
),
}),
columnHelper.accessor("demographicInformation.country", {
header: (
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "country"))}>
<span>Country</span>
<SorterArrow name="country" />
</button>
) as any,
cell: (info) =>
info.getValue()
? `${countryCodes.findOne("countryCode" as any, info.getValue()).flag} ${
countries[info.getValue() as unknown as keyof TCountries].name
} (+${countryCodes.findOne("countryCode" as any, info.getValue()).countryCallingCode})`
: "Not available",
}),
columnHelper.accessor("demographicInformation.phone", {
header: (
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "phone"))}>
<span>Phone</span>
<SorterArrow name="phone" />
</button>
) as any,
cell: (info) => info.getValue() || "Not available",
enableSorting: true,
}),
columnHelper.accessor("demographicInformation.employment", {
header: (
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "employment"))}>
<span>Employment</span>
<SorterArrow name="employment" />
</button>
) as any,
cell: (info) => capitalize(info.getValue()) || "Not available",
enableSorting: true,
}),
columnHelper.accessor("demographicInformation.gender", {
header: (
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "gender"))}>
<span>Gender</span>
<SorterArrow name="gender" />
</button>
) as any,
cell: (info) => capitalize(info.getValue()) || "Not available",
enableSorting: true,
}),
{
header: (
<span className="cursor-pointer" onClick={() => setShowDemographicInformation((prev) => !prev)}>
Switch
</span>
),
id: "actions",
cell: actionColumn,
},
];
const defaultColumns = [
columnHelper.accessor("name", {
header: (
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "name"))}>
<span>Name</span>
<SorterArrow name="name" />
</button>
) as any,
cell: ({row, getValue}) => (
<div
className={clsx(
PERMISSIONS.updateExpiryDate[row.original.type].includes(user.type) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
)}
onClick={() => (PERMISSIONS.updateExpiryDate[row.original.type].includes(user.type) ? setSelectedUser(row.original) : null)}>
{getValue()}
</div>
),
}),
columnHelper.accessor("email", {
header: (
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "email"))}>
<span>E-mail</span>
<SorterArrow name="email" />
</button>
) as any,
cell: ({row, getValue}) => (
<div
className={clsx(
PERMISSIONS.updateExpiryDate[row.original.type].includes(user.type) &&
"underline text-mti-purple-light hover:text-mti-purple-dark transition ease-in-out duration-300 cursor-pointer",
)}
onClick={() => (PERMISSIONS.updateExpiryDate[row.original.type].includes(user.type) ? setSelectedUser(row.original) : null)}>
{getValue()}
</div>
),
}),
columnHelper.accessor("type", {
header: (
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "type"))}>
<span>Type</span>
<SorterArrow name="type" />
</button>
) as any,
cell: (info) => capitalize(info.getValue()),
}),
columnHelper.accessor("subscriptionExpirationDate", {
header: (
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "expiryDate"))}>
<span>Expiry Date</span>
<SorterArrow name="expiryDate" />
</button>
) as any,
cell: (info) => (
<span className={clsx(info.getValue() ? expirationDateColor(moment(info.getValue()).toDate()) : "")}>
{!info.getValue() ? "No expiry date" : moment(info.getValue()).format("DD/MM/YYYY")}
</span>
),
}),
columnHelper.accessor("isVerified", {
header: (
<button className="flex gap-2 items-center" onClick={() => setSorter((prev) => selectSorter(prev, "verification"))}>
<span>Verification</span>
<SorterArrow name="verification" />
</button>
) as any,
cell: (info) => (
<div className="flex gap-3 items-center text-mti-gray-dim text-sm self-center">
<div
className={clsx(
"w-6 h-6 rounded-md flex items-center justify-center border border-mti-purple-light bg-white",
"transition duration-300 ease-in-out",
info.getValue() && "!bg-mti-purple-light ",
)}>
<BsCheck color="white" className="w-full h-full" />
</div>
</div>
),
}),
{
header: (
<span className="cursor-pointer" onClick={() => setShowDemographicInformation((prev) => !prev)}>
Switch
</span>
),
id: "actions",
cell: actionColumn,
},
];
const reverseString = (str: string) => reverse(str.split("")).join("");
const selectSorter = (previous: string | undefined, name: string) => {
if (!previous) return name;
if (previous === name) return reverseString(name);
return undefined;
};
const sortFunction = (a: User, b: User) => {
if (sorter === "name" || sorter === reverseString("name"))
return sorter === "name" ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name);
if (sorter === "email" || sorter === reverseString("email"))
return sorter === "email" ? a.email.localeCompare(b.email) : b.email.localeCompare(a.email);
if (sorter === "type" || sorter === reverseString("type"))
return sorter === "type"
? userTypes.findIndex((t) => a.type === t) - userTypes.findIndex((t) => b.type === t)
: userTypes.findIndex((t) => b.type === t) - userTypes.findIndex((t) => a.type === t);
if (sorter === "verification" || sorter === reverseString("verification"))
return sorter === "verification"
? a.isVerified.toString().localeCompare(b.isVerified.toString())
: b.isVerified.toString().localeCompare(a.isVerified.toString());
if (sorter === "expiryDate" || sorter === reverseString("expiryDate")) {
if (!a.subscriptionExpirationDate && b.subscriptionExpirationDate) return sorter === "expiryDate" ? -1 : 1;
if (a.subscriptionExpirationDate && !b.subscriptionExpirationDate) return sorter === "expiryDate" ? 1 : -1;
if (!a.subscriptionExpirationDate && !b.subscriptionExpirationDate) return 0;
if (moment(a.subscriptionExpirationDate).isAfter(b.subscriptionExpirationDate)) return sorter === "expiryDate" ? -1 : 1;
if (moment(b.subscriptionExpirationDate).isAfter(a.subscriptionExpirationDate)) return sorter === "expiryDate" ? 1 : -1;
return 0;
}
if (sorter === "country" || sorter === reverseString("country")) {
if (!a.demographicInformation?.country && b.demographicInformation?.country) return sorter === "country" ? -1 : 1;
if (a.demographicInformation?.country && !b.demographicInformation?.country) return sorter === "country" ? 1 : -1;
if (!a.demographicInformation?.country && !b.demographicInformation?.country) return 0;
return sorter === "country"
? a.demographicInformation!.country.localeCompare(b.demographicInformation!.country)
: b.demographicInformation!.country.localeCompare(a.demographicInformation!.country);
}
if (sorter === "phone" || sorter === reverseString("phone")) {
if (!a.demographicInformation?.phone && b.demographicInformation?.phone) return sorter === "phone" ? -1 : 1;
if (a.demographicInformation?.phone && !b.demographicInformation?.phone) return sorter === "phone" ? 1 : -1;
if (!a.demographicInformation?.phone && !b.demographicInformation?.phone) return 0;
return sorter === "phone"
? a.demographicInformation!.phone.localeCompare(b.demographicInformation!.phone)
: b.demographicInformation!.phone.localeCompare(a.demographicInformation!.phone);
}
if (sorter === "employment" || sorter === reverseString("employment")) {
if (!a.demographicInformation?.employment && b.demographicInformation?.employment) return sorter === "employment" ? -1 : 1;
if (a.demographicInformation?.employment && !b.demographicInformation?.employment) return sorter === "employment" ? 1 : -1;
if (!a.demographicInformation?.employment && !b.demographicInformation?.employment) return 0;
return sorter === "employment"
? a.demographicInformation!.employment.localeCompare(b.demographicInformation!.employment)
: b.demographicInformation!.employment.localeCompare(a.demographicInformation!.employment);
}
if (sorter === "gender" || sorter === reverseString("gender")) {
if (!a.demographicInformation?.gender && b.demographicInformation?.gender) return sorter === "employment" ? -1 : 1;
if (a.demographicInformation?.gender && !b.demographicInformation?.gender) return sorter === "employment" ? 1 : -1;
if (!a.demographicInformation?.gender && !b.demographicInformation?.gender) return 0;
return sorter === "gender"
? a.demographicInformation!.gender.localeCompare(b.demographicInformation!.gender)
: b.demographicInformation!.gender.localeCompare(a.demographicInformation!.gender);
}
return a.id.localeCompare(b.id);
};
const table = useReactTable({
data: displayUsers,
columns: (!showDemographicInformation ? defaultColumns : demographicColumns) as any,
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="w-full">
<Modal isOpen={!!selectedUser} onClose={() => setSelectedUser(undefined)}>
<>
{selectedUser && (
<div className="w-full flex flex-col gap-8">
<UserCard
loggedInUser={user}
onClose={(shouldReload) => {
setSelectedUser(undefined);
if (shouldReload) reload();
}}
user={selectedUser}
/>
</div>
)}
</>
</Modal>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th className="py-4 px-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="px-2">
{table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => (
<td className="px-4 py-2 items-center w-fit" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -1,63 +0,0 @@
import {User} from "@/interfaces/user";
import {Tab} from "@headlessui/react";
import clsx from "clsx";
import ExamList from "./ExamList";
import GroupList from "./GroupList";
import UserList from "./UserList";
export default function Lists({user}: {user: User}) {
return (
<Tab.Group>
<Tab.List className="flex space-x-1 rounded-xl bg-mti-purple-ultralight/40 p-1">
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
)
}>
User List
</Tab>
{user?.type === "developer" && (
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
)
}>
Exam List
</Tab>
)}
<Tab
className={({selected}) =>
clsx(
"w-full rounded-lg py-2.5 text-sm font-medium leading-5 text-mti-purple-light",
"ring-white ring-opacity-60 ring-offset-2 ring-offset-mti-purple-light focus:outline-none focus:ring-2",
"transition duration-300 ease-in-out",
selected ? "bg-white shadow" : "text-blue-100 hover:bg-white/[0.12] hover:text-mti-purple-dark",
)
}>
Group List
</Tab>
</Tab.List>
<Tab.Panels className="mt-2">
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<UserList user={user} />
</Tab.Panel>
{user?.type === "developer" && (
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<ExamList user={user} />
</Tab.Panel>
)}
<Tab.Panel className="overflow-y-scroll max-h-[600px] rounded-xl scrollbar-hide">
<GroupList user={user} />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
);
}

View File

@@ -1,56 +0,0 @@
import Button from "@/components/Low/Button";
import {User} from "@/interfaces/user";
import {sendEmailVerification} from "@/utils/email";
import axios from "axios";
import {useRouter} from "next/router";
import {Divider} from "primereact/divider";
import {toast} from "react-toastify";
interface Props {
user: User;
isLoading: boolean;
setIsLoading: (isLoading: boolean) => void;
}
export default function EmailVerification({user, isLoading, setIsLoading}: Props) {
const router = useRouter();
const onSuccess = () => toast.success("An e-mail has been sent, please make sure to check your spam folder!");
const onError = (e: Error) => {
console.error(e);
toast.error("Something went wrong, please logout and re-login.", {toastId: "send-verify-error"});
};
const logout = async () => {
axios.post("/api/logout").finally(() => {
setTimeout(() => router.reload(), 500);
});
};
return (
<>
<div className="flex flex-col items-center gap-6 w-full -lg:px-8 lg:w-1/2 relative">
<h4 className="font-semibold text-2xl text-mti-purple-light">Please confirm your account!</h4>
<span className="text-center">
An e-mail has been sent to <span className="italic text-mti-purple-light">{user?.email}</span>, please click the link in it to
confirm your account to be able to use the application. <br /> <br />
Please refresh this page once it has been verified.
</span>
<Button
className="mt-8 w-full"
color="purple"
disabled={isLoading}
onClick={() => sendEmailVerification(setIsLoading, onSuccess, onError)}>
Resend e-mail
</Button>
</div>
<Divider className="max-w-xs lg:max-w-md" />
<span className="text-mti-gray-cool text-sm font-normal">
<button className="text-mti-purple-light" onClick={logout}>
Log out instead
</button>
</span>
</>
);
}

View File

@@ -1,295 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import {useEffect, useState} from "react";
import {Module} from "@/interfaces";
import Selection from "@/exams/Selection";
import Reading from "@/exams/Reading";
import {Exam, InteractiveSpeakingExercise, SpeakingExercise, UserSolution, 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 {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 Layout from "@/components/High/Layout";
import AbandonPopup from "@/components/AbandonPopup";
import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation";
import {useRouter} from "next/router";
import {getExam} from "@/utils/exams";
import {capitalize} from "lodash";
import Level from "@/exams/Level";
interface Props {
page: "exams" | "exercises";
}
export default function ExamPage({page}: Props) {
const [hasBeenUploaded, setHasBeenUploaded] = useState(false);
const [moduleIndex, setModuleIndex] = useState(0);
const [sessionId, setSessionId] = useState("");
const [exam, setExam] = useState<Exam>();
const [isEvaluationLoading, setIsEvaluationLoading] = useState(false);
const [showAbandonPopup, setShowAbandonPopup] = useState(false);
const [avoidRepeated, setAvoidRepeated] = useState(false);
const [timeSpent, setTimeSpent] = useState(0);
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 assignment = useExamStore((state) => state.assignment);
const {user} = useUser({redirectTo: "/login"});
const router = useRouter();
useEffect(() => setSessionId(uuidv4()), []);
useEffect(() => {
selectedModules.length > 0 && timeSpent === 0 && !showSolutions;
if (selectedModules.length > 0 && timeSpent === 0 && !showSolutions) {
const timerInterval = setInterval(() => {
setTimeSpent((prev) => prev + 1);
}, 1000);
return () => {
clearInterval(timerInterval);
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules.length]);
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((module) => getExam(module, avoidRepeated));
Promise.all(examPromises).then((values) => {
if (values.every((x) => !!x)) {
setExams(values.map((x) => x!));
} else {
toast.error("Something went wrong, please try again");
setTimeout(router.reload, 500);
}
});
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedModules, setExams, exams]);
useEffect(() => {
if (selectedModules.length > 0 && exams.length !== 0 && moduleIndex >= selectedModules.length && !hasBeenUploaded && !showSolutions) {
const newStats: Stat[] = userSolutions.map((solution) => ({
...solution,
timeSpent,
session: sessionId,
exam: solution.exam!,
module: solution.module!,
user: user?.id || "",
date: new Date().getTime(),
...(assignment ? {assignment: assignment.id} : {}),
}));
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 updateExamWithUserSolutions = (exam: Exam): Exam => {
if (exam.module === "reading" || exam.module === "listening") {
const parts = exam.parts.map((p) =>
Object.assign(p, {
exercises: p.exercises.map((x) => Object.assign(x, {userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions})),
}),
);
return Object.assign(exam, {parts});
}
const exercises = exam.exercises.map((x) => Object.assign(x, {userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions}));
return Object.assign(exam, {exercises});
};
const onFinish = (solutions: UserSolution[]) => {
const solutionIds = solutions.map((x) => x.exercise);
const solutionExams = solutions.map((x) => x.exam);
if (exam && !solutionExams.includes(exam.id)) return;
if (exam && (exam.module === "writing" || exam.module === "speaking") && solutions.length > 0 && !showSolutions) {
setHasBeenUploaded(true);
setIsEvaluationLoading(true);
Promise.all(
exam.exercises.map(async (exercise) => {
if (exercise.type === "writing") {
return await evaluateWritingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!);
}
if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking") {
return await evaluateSpeakingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!);
}
}),
)
.then((responses) => {
setUserSolutions([...userSolutions, ...responses.filter((x) => !!x)] as any);
})
.finally(() => {
setIsEvaluationLoading(false);
setHasBeenUploaded(false);
});
}
axios.get("/api/stats/update");
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,
},
level: {
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 (
<Selection
page={page}
user={user!}
disableSelection={page === "exams"}
onStart={(modules, avoid) => {
setSelectedModules(modules);
setAvoidRepeated(avoid);
}}
/>
);
}
if (moduleIndex >= selectedModules.length) {
return (
<Finish
isLoading={isEvaluationLoading}
user={user!}
modules={selectedModules}
onViewResults={() => {
setShowSolutions(true);
setModuleIndex(0);
setExam(exams[0]);
}}
scores={aggregateScoresByModule(userSolutions)}
/>
);
}
if (exam && exam.module === "reading") {
return <Reading exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
if (exam && exam.module === "listening") {
return <Listening exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
if (exam && exam.module === "writing") {
return <Writing exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
if (exam && exam.module === "speaking") {
return <Speaking exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
if (exam && exam.module === "level") {
return <Level exam={exam} onFinish={onFinish} showSolutions={showSolutions} />;
}
return <>Loading...</>;
};
return (
<>
<Head>
<title>{capitalize(page).toString()} | EnCoach</title>
<meta
name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<ToastContainer />
{user && (
<Layout
user={user}
className="justify-between"
focusMode={selectedModules.length !== 0 && !showSolutions && moduleIndex < selectedModules.length}
onFocusLayerMouseEnter={() => setShowAbandonPopup(true)}>
<>
{renderScreen()}
{!showSolutions && moduleIndex < selectedModules.length && (
<AbandonPopup
isOpen={showAbandonPopup}
abandonPopupTitle="Leave Exercise"
abandonPopupDescription="Are you sure you want to leave the exercise? You will lose all your progress."
abandonConfirmButtonText="Confirm"
onAbandon={() => router.reload()}
onCancel={() => setShowAbandonPopup(false)}
/>
)}
</>
</Layout>
)}
</>
);
}

View File

@@ -1,216 +0,0 @@
import Button from "@/components/Low/Button";
import Input from "@/components/Low/Input";
import useUsers from "@/hooks/useUsers";
import {User} from "@/interfaces/user";
import {sendEmailVerification} from "@/utils/email";
import axios from "axios";
import {Divider} from "primereact/divider";
import {useState} from "react";
import {toast} from "react-toastify";
import {KeyedMutator} from "swr";
import Select from "react-select";
import moment from "moment";
interface Props {
isLoading: boolean;
setIsLoading: (isLoading: boolean) => void;
mutateUser: KeyedMutator<User>;
sendEmailVerification: typeof sendEmailVerification;
}
const availableDurations = {
"1_month": {label: "1 Month", number: 1},
"3_months": {label: "3 Months", number: 3},
"6_months": {label: "6 Months", number: 6},
"12_months": {label: "12 Months", number: 12},
};
export default function RegisterCorporate({isLoading, setIsLoading, mutateUser, sendEmailVerification}: Props) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [referralAgent, setReferralAgent] = useState<string | undefined>();
const [companyName, setCompanyName] = useState("");
const [companyUsers, setCompanyUsers] = useState(0);
const [subscriptionDuration, setSubscriptionDuration] = useState(1);
const {users} = useUsers();
const onSuccess = () => toast.success("An e-mail has been sent, please make sure to check your spam folder!");
const onError = (e: Error) => {
console.error(e);
toast.error("Something went wrong, please logout and re-login.", {toastId: "send-verify-error"});
};
const register = (e: any) => {
e.preventDefault();
if (confirmPassword !== password) {
toast.error("Your passwords do not match!", {toastId: "password-not-match"});
return;
}
setIsLoading(true);
axios
.post("/api/register", {
name,
email,
password,
type: "corporate",
profilePicture: "/defaultAvatar.png",
subscriptionExpirationDate: moment().add(1, "days").add(subscriptionDuration, "months").toISOString(),
corporateInformation: {
companyInformation: {
name: companyName,
userAmount: companyUsers,
},
referralAgent,
allowedUserAmount: companyUsers,
monthlyDuration: subscriptionDuration,
},
})
.then((response) => {
mutateUser(response.data.user).then(() => sendEmailVerification(setIsLoading, onSuccess, onError));
})
.catch((error) => {
console.log(error.response.data);
if (error.response.status === 401) {
toast.error("There is already a user with that e-mail!");
return;
}
if (error.response.status === 400) {
toast.error("The provided code is invalid!");
return;
}
toast.error("There was something wrong, please try again!");
})
.finally(() => setIsLoading(false));
};
return (
<form className="flex flex-col items-center gap-4 w-full" onSubmit={register}>
<div className="w-full flex gap-4">
<Input type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" defaultValue={name} required />
<Input type="email" name="email" onChange={(e) => setEmail(e)} placeholder="Enter email address" defaultValue={email} required />
</div>
<div className="w-full flex gap-4">
<Input
type="password"
name="password"
onChange={(e) => setPassword(e)}
placeholder="Enter your password"
defaultValue={password}
required
/>
<Input
type="password"
name="confirmPassword"
onChange={(e) => setConfirmPassword(e)}
placeholder="Confirm your password"
defaultValue={confirmPassword}
required
/>
</div>
<Divider className="w-full !my-2" />
<div className="w-full flex gap-4">
<Input
type="text"
name="companyName"
onChange={(e) => setCompanyName(e)}
placeholder="Institution name"
label="Institution name"
defaultValue={companyName}
required
/>
<Input
type="number"
name="companyUsers"
onChange={(e) => setCompanyUsers(parseInt(e))}
label="Amount of users"
defaultValue={companyUsers}
required
/>
</div>
<div className="w-full flex gap-4">
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Referral</label>
<Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={[
{value: "", label: "No referral"},
...users.filter((u) => u.type === "agent").map((x) => ({value: x.id, label: `${x.name} - ${x.email}`})),
]}
defaultValue={{value: "", label: "No referral"}}
onChange={(value) => setReferralAgent(value?.value)}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</div>
<div className="flex flex-col gap-3 w-full">
<label className="font-normal text-base text-mti-gray-dim">Subscription Duration</label>
<Select
className="px-4 py-4 w-full text-sm font-normal placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed bg-white rounded-full border border-mti-gray-platinum focus:outline-none"
options={Object.keys(availableDurations).map((value) => ({
value,
label: availableDurations[value as keyof typeof availableDurations].label,
}))}
defaultValue={{value: "1_month", label: availableDurations["1_month"].label}}
onChange={(value) =>
setSubscriptionDuration(value ? availableDurations[value.value as keyof typeof availableDurations].number : 1)
}
styles={{
control: (styles) => ({
...styles,
paddingLeft: "4px",
border: "none",
outline: "none",
":focus": {
outline: "none",
},
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
/>
</div>
</div>
<Button
className="lg:mt-8 w-full"
color="purple"
disabled={
isLoading || !email || !name || !password || !confirmPassword || password !== confirmPassword || !companyName || companyUsers <= 0
}>
Create account
</Button>
</form>
);
}

View File

@@ -1,101 +0,0 @@
import Button from "@/components/Low/Button";
import Input from "@/components/Low/Input";
import {User} from "@/interfaces/user";
import {sendEmailVerification} from "@/utils/email";
import axios from "axios";
import {useState} from "react";
import {toast} from "react-toastify";
import {KeyedMutator} from "swr";
interface Props {
queryCode?: string;
isLoading: boolean;
setIsLoading: (isLoading: boolean) => void;
mutateUser: KeyedMutator<User>;
sendEmailVerification: typeof sendEmailVerification;
}
export default function RegisterIndividual({queryCode, isLoading, setIsLoading, mutateUser, sendEmailVerification}: Props) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [code, setCode] = useState(queryCode || "");
const onSuccess = () => toast.success("An e-mail has been sent, please make sure to check your spam folder!");
const onError = (e: Error) => {
console.error(e);
toast.error("Something went wrong, please logout and re-login.", {toastId: "send-verify-error"});
};
const register = (e: any) => {
e.preventDefault();
if (confirmPassword !== password) {
toast.error("Your passwords do not match!", {toastId: "password-not-match"});
return;
}
setIsLoading(true);
axios
.post("/api/register", {
name,
email,
password,
type: "individual",
code,
profilePicture: "/defaultAvatar.png",
})
.then((response) => {
mutateUser(response.data.user).then(() => sendEmailVerification(setIsLoading, onSuccess, onError));
})
.catch((error) => {
console.log(error.response.data);
if (error.response.status === 401) {
toast.error("There is already a user with that e-mail!");
return;
}
if (error.response.status === 400) {
toast.error("The provided code is invalid!");
return;
}
toast.error("There was something wrong, please try again!");
})
.finally(() => setIsLoading(false));
};
return (
<form className="flex flex-col items-center gap-6 w-full" onSubmit={register}>
<Input type="text" name="name" onChange={(e) => setName(e)} placeholder="Enter your name" defaultValue={name} required />
<Input type="email" name="email" onChange={(e) => setEmail(e)} placeholder="Enter email address" defaultValue={email} required />
<Input
type="password"
name="password"
onChange={(e) => setPassword(e)}
placeholder="Enter your password"
defaultValue={password}
required
/>
<Input
type="password"
name="confirmPassword"
onChange={(e) => setConfirmPassword(e)}
placeholder="Confirm your password"
defaultValue={confirmPassword}
required
/>
<Input type="text" name="code" onChange={(e) => setCode(e)} placeholder="Enter your registration code" defaultValue={code} required />
<Button
className="lg:mt-8 w-full"
color="purple"
disabled={isLoading || !email || !name || !password || !confirmPassword || password !== confirmPassword || !code}>
Create account
</Button>
</form>
);
}

View File

@@ -5,15 +5,12 @@ import type {AppProps} from "next/app";
import "primereact/resources/themes/lara-light-indigo/theme.css"; import "primereact/resources/themes/lara-light-indigo/theme.css";
import "primereact/resources/primereact.min.css"; import "primereact/resources/primereact.min.css";
import "primeicons/primeicons.css"; import "primeicons/primeicons.css";
import "react-datepicker/dist/react-datepicker.css";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useEffect} from "react"; import {useEffect} from "react";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import usePreferencesStore from "@/stores/preferencesStore";
export default function App({Component, pageProps}: AppProps) { export default function App({Component, pageProps}: AppProps) {
const reset = useExamStore((state) => state.reset); const reset = useExamStore((state) => state.reset);
const setIsSidebarMinimized = usePreferencesStore((state) => state.setSidebarMinimized);
const router = useRouter(); const router = useRouter();
@@ -21,15 +18,5 @@ export default function App({Component, pageProps}: AppProps) {
if (router.pathname !== "/exercises") reset(); if (router.pathname !== "/exercises") reset();
}, [router.pathname, reset]); }, [router.pathname, reset]);
useEffect(() => {
if (localStorage.getItem("isSidebarMinimized")) {
if (localStorage.getItem("isSidebarMinimized") === "true") {
setIsSidebarMinimized(true);
} else {
setIsSidebarMinimized(false);
}
}
}, [setIsSidebarMinimized]);
return <Component {...pageProps} />; return <Component {...pageProps} />;
} }

View File

@@ -1,157 +0,0 @@
/* eslint-disable @next/next/no-img-element */
import {toast, ToastContainer} from "react-toastify";
import axios from "axios";
import {FormEvent, useEffect, useState} from "react";
import Head from "next/head";
import useUser from "@/hooks/useUser";
import {Divider} from "primereact/divider";
import Button from "@/components/Low/Button";
import {BsArrowRepeat} from "react-icons/bs";
import Link from "next/link";
import Input from "@/components/Low/Input";
import {useRouter} from "next/router";
export function getServerSideProps({query, res}: {query: {oobCode: string; mode: string; apiKey?: string; continueUrl?: string}; res: any}) {
if (!query || !query.oobCode || !query.mode) {
res.setHeader("location", "/login");
res.statusCode = 302;
res.end();
return {
props: {},
};
}
return {
props: {
code: query.oobCode,
mode: query.mode,
apiKey: query.apiKey,
continueUrl: query.continueUrl,
},
};
}
export default function Reset({code, mode, apiKey, continueUrl}: {code: string; mode: string; apiKey?: string; continueUrl?: string}) {
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
useUser({
redirectTo: "/",
redirectIfFound: true,
});
useEffect(() => {
if (mode === "signIn") {
axios
.post<{ok: boolean}>("/api/reset/verify", {
email: continueUrl?.replace("https://platform.encoach.com/", ""),
})
.then((response) => {
if (response.data.ok) {
toast.success("Your account has been verified!", {toastId: "verify-successful"});
setTimeout(() => {
router.reload();
}, 1000);
return;
}
toast.error("Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!", {
toastId: "verify-error",
});
})
.catch(() => {
toast.error("Something went wrong! Please make sure to click the link in your e-mail again and input the correct e-mail!", {
toastId: "verify-error",
});
setIsLoading(false);
});
}
});
const login = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
axios
.post<{ok: boolean}>("/api/reset/confirm", {code, password})
.then((response) => {
if (response.data.ok) {
toast.success("Your password has been reset!", {toastId: "reset-successful"});
setTimeout(() => {
router.push("/login");
}, 1000);
return;
}
toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "reset-error"});
})
.catch(() => {
toast.error("Something went wrong! Please make sure to click the link in your e-mail again!", {toastId: "reset-error"});
})
.finally(() => setIsLoading(false));
};
return (
<>
<Head>
<title>Reset | EnCoach</title>
<meta name="description" content="Generated by create next app" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="w-full h-[100vh] flex bg-white text-black">
<ToastContainer />
<section className="h-full w-fit min-w-fit relative hidden lg:flex">
<div className="absolute h-full w-full bg-mti-rose-light z-10 bg-opacity-50" />
<img src="/people-talking-tablet.png" alt="People smiling looking at a tablet" className="h-full aspect-auto" />
</section>
{mode === "resetPassword" && (
<section className="h-full w-full flex flex-col items-center justify-center gap-2">
<div className="flex flex-col gap-2 items-center relative">
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-36 lg:w-64 absolute -top-36 lg:-top-64" />
<h1 className="font-bold text-2xl lg:text-4xl">Reset your password</h1>
<p className="self-start text-sm lg:text-base font-normal text-mti-gray-cool">to your registered Email Address</p>
</div>
<Divider className="max-w-xs lg:max-w-md" />
<form className="flex flex-col items-center gap-6 w-full -lg:px-8 lg:w-1/2" onSubmit={login}>
<Input type="password" name="password" onChange={(e) => setPassword(e)} placeholder="Password" />
<Button className="mt-8 w-full" color="purple" disabled={isLoading}>
{!isLoading && "Reset"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="text-white animate-spin" size={25} />
</div>
)}
</Button>
</form>
<span className="text-mti-gray-cool text-sm font-normal mt-8">
Don&apos;t have an account?{" "}
<Link className="text-mti-purple-light" href="/register">
Sign up
</Link>
</span>
</section>
)}
{mode === "signIn" && (
<section className="h-full w-full flex flex-col items-center justify-center gap-2">
<div className="flex flex-col gap-2 items-center relative">
<img src="/logo_title.png" alt="EnCoach's Logo" className="w-36 lg:w-64 absolute -top-36 lg:-top-64" />
<h1 className="font-bold text-2xl lg:text-4xl">Confirm your account</h1>
<p className="self-start text-sm lg:text-base font-normal text-mti-gray-cool">to your registered Email Address</p>
</div>
<Divider className="max-w-xs lg:max-w-md" />
<div className="flex flex-col items-center gap-6 w-full -lg:px-8 lg:w-1/2">
<span className="text-center">
Your e-mail is currently being verified, please wait a second. <br /> <br />
Once it has been verified, you will be redirected to the home page.
</span>
</div>
</section>
)}
</main>
</>
);
}

View File

@@ -1,76 +1,61 @@
/* eslint-disable @next/next/no-img-element */
import Head from "next/head";
import {withIronSessionSsr} from "iron-session/next"; import {withIronSessionSsr} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import useUser from "@/hooks/useUser"; import Head from "next/head";
import {ToastContainer} from "react-toastify"; import {ToastContainer} from "react-toastify";
import useUser from "@/hooks/useUser";
import Layout from "@/components/High/Layout"; import Layout from "@/components/High/Layout";
import CodeGenerator from "./(admin)/CodeGenerator"; import Button from "@/components/Low/Button";
import ExamLoader from "./(admin)/ExamLoader"; import {useRouter} from "next/router";
import {Tab} from "@headlessui/react";
import clsx from "clsx";
import Lists from "./(admin)/Lists";
import BatchCodeGenerator from "./(admin)/BatchCodeGenerator";
import {shouldRedirectHome} from "@/utils/navigation.disabled";
import ExamGenerator from "./(admin)/ExamGenerator";
export const getServerSideProps = withIronSessionSsr(({req, res}) => { export const getServerSideProps = withIronSessionSsr(({req, res}) => {
const user = req.session.user; const user = req.session.user;
if (!user || !user.isVerified) { if (!user) {
res.setHeader("location", "/login"); res.setHeader("location", "/login");
res.statusCode = 302; res.statusCode = 302;
res.end(); res.end();
return { return {
props: { props: {
user: null, user: null,
}, },
}; };
} }
if (shouldRedirectHome(user) || user.type !== "developer") { return {
res.setHeader("location", "/"); props: {user: req.session.user},
res.statusCode = 302; };
res.end();
return {
props: {
user: null,
},
};
}
return {
props: {user: req.session.user},
};
}, sessionOptions); }, sessionOptions);
export default function Admin() { export default function Page() {
const {user} = useUser({redirectTo: "/login"}); const {user} = useUser({redirectTo: "/login"});
const router = useRouter();
return ( const handleManageQuestionsClick = () => {
<> router.push('/question-management');
<Head> };
<title>Admin Panel | EnCoach</title> return (
<meta <>
name="description" <Head>
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." <title>IELTS GPT | Muscat Training Institute</title>
/> <meta
<meta name="viewport" content="width=device-width, initial-scale=1" /> name="description"
<link rel="icon" href="/favicon.ico" /> content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
</Head> />
<ToastContainer /> <meta name="viewport" content="width=device-width, initial-scale=1"/>
{user && ( <link rel="icon" href="/favicon.ico"/>
<Layout user={user} className="gap-6"> </Head>
<section className="w-full flex -md:flex-col -xl:gap-2 gap-8 justify-between"> <ToastContainer/>
{user.email === "tiago.ribeiro@ecrop.dev" ? <ExamGenerator /> : <ExamLoader />} {user && (
<CodeGenerator user={user} /> <Layout user={user}>
<BatchCodeGenerator user={user} /> <div>
</section> <Button
<section className="w-full"> onClick={handleManageQuestionsClick}
<Lists user={user} /> color="purple"
</section> className="px-12 w-full max-w-xs self-end">
</Layout> Manage Questions
)} </Button>
</> </div>
); </Layout>
} )}
</>
);
}

View File

@@ -1,48 +0,0 @@
// 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, query, where, setDoc, doc, getDoc, deleteDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {uuidv4} from "@firebase/util";
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;
}
if (req.method === "GET") return GET(req, res);
if (req.method === "PATCH") return PATCH(req, res);
if (req.method === "DELETE") return DELETE(req, res);
res.status(404).json({ok: false});
}
async function GET(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query;
const snapshot = await getDoc(doc(db, "assignments", id as string));
res.status(200).json({...snapshot.data(), id: snapshot.id});
}
async function DELETE(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query;
await deleteDoc(doc(db, "assignments", id as string));
res.status(200).json({ok: true});
}
async function PATCH(req: NextApiRequest, res: NextApiResponse) {
const {id} = req.query;
await setDoc(doc(db, "assignments", id as string), {assigner: req.session.user?.id, ...req.body}, {merge: true});
res.status(200).json({ok: true});
}

View File

@@ -1,41 +0,0 @@
// 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, query, where, setDoc, doc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {uuidv4} from "@firebase/util";
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;
}
if (req.method === "GET") return GET(req, res);
if (req.method === "POST") return POST(req, res);
res.status(404).json({ok: false});
}
async function GET(req: NextApiRequest, res: NextApiResponse) {
const q = query(collection(db, "assignments"));
const snapshot = await getDocs(q);
const docs = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
}));
res.status(200).json(docs);
}
async function POST(req: NextApiRequest, res: NextApiResponse) {
await setDoc(doc(db, "assignments", uuidv4()), {assigner: req.session.user?.id, ...req.body});
res.status(200).json({ok: true});
}

View File

@@ -1,69 +0,0 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, setDoc, doc, query, collection, where, getDocs} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Type} from "@/interfaces/user";
import {PERMISSIONS} from "@/constants/userPermissions";
import {uuidv4} from "@firebase/util";
import {prepareMailer, prepareMailOptions} from "@/email";
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, reason: "You must be logged in to generate a code!"});
return;
}
const {type, codes, emails, expiryDate} = req.body as {type: Type; codes: string[]; emails?: string[]; expiryDate: null | Date};
const permission = PERMISSIONS.generateCode[type];
if (!permission.includes(req.session.user.type)) {
res.status(403).json({ok: false, reason: "Your account type does not have permissions to generate a code for that type of user!"});
return;
}
if (req.session.user.type === "corporate") {
const codesGeneratedByUserSnapshot = await getDocs(query(collection(db, "codes"), where("creator", "==", req.session.user.id)));
const totalCodes = codesGeneratedByUserSnapshot.docs.length + codes.length;
const allowedCodes = req.session.user.corporateInformation?.companyInformation.userAmount || 0;
if (totalCodes > allowedCodes) {
res.status(403).json({
ok: false,
reason: `You have or would have exceeded your amount of allowed codes, you currently are allowed to generate ${
allowedCodes - codesGeneratedByUserSnapshot.docs.length
} codes.`,
});
return;
}
}
const codePromises = codes.map(async (code, index) => {
const codeRef = doc(db, "codes", code);
await setDoc(codeRef, {type, code, creator: req.session.user!.id, expiryDate});
if (emails && emails.length > index) {
const transport = prepareMailer();
const mailOptions = prepareMailOptions(
{
type,
code,
},
[emails[index]],
"EnCoach Registration",
"main",
);
await transport.sendMail(mailOptions);
}
});
Promise.all(codePromises).then(() => {
res.status(200).json({ok: true});
});
}

View File

@@ -1,59 +0,0 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import axios from "axios";
import formidable from "formidable-serverless";
import {getStorage, ref, uploadBytes} from "firebase/storage";
import fs from "fs";
import {app} from "@/firebase";
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const storage = getStorage(app);
const form = formidable({keepExtensions: true});
await form.parse(req, async (err: any, fields: any, files: any) => {
if (err) console.log(err);
const uploadingAudios = await Promise.all(
Object.keys(files).map(async (fileID: string) => {
const audioFile = files[fileID];
const questionID = fileID.replace("answer_", "question_");
const audioFileRef = ref(storage, `speaking_recordings/${(audioFile as any).path.replace("upload_", "")}`);
const binary = fs.readFileSync((audioFile as any).path).buffer;
const snapshot = await uploadBytes(audioFileRef, binary);
fs.rmSync((audioFile as any).path);
return {question: fields[questionID], answer: snapshot.metadata.fullPath};
}),
);
const backendRequest = await axios.post(
`${process.env.BACKEND_URL}/speaking_task_3`,
{answers: uploadingAudios},
{
headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`,
},
},
);
res.status(200).json({...backendRequest.data, answer: uploadingAudios});
});
}
export const config = {
api: {
bodyParser: false,
},
};

View File

@@ -18,10 +18,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const storage = getStorage(app); const storage = getStorage(app);
const form = formidable({keepExtensions: true}); const form = formidable({keepExtensions: true, uploadDir: "./"});
await form.parse(req, async (err: any, fields: any, files: any) => { await form.parse(req, async (err: any, fields: any, files: any) => {
if (err) console.log(err);
const audioFile = files.audio; const audioFile = files.audio;
const audioFileRef = ref(storage, `speaking_recordings/${(audioFile as any).path.replace("upload_", "")}`); const audioFileRef = ref(storage, `speaking_recordings/${(audioFile as any).path.replace("upload_", "")}`);
@@ -29,8 +27,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
const snapshot = await uploadBytes(audioFileRef, binary); const snapshot = await uploadBytes(audioFileRef, binary);
const backendRequest = await axios.post( const backendRequest = await axios.post(
`${process.env.BACKEND_URL}/speaking_task_3`, `${process.env.BACKEND_URL}/speaking_task_1`,
{answers: [{question: fields.question, answer: snapshot.metadata.fullPath}]}, {question: fields.question, answer: snapshot.metadata.fullPath},
{ {
headers: { headers: {
Authorization: `Bearer ${process.env.BACKEND_JWT}`, Authorization: `Bearer ${process.env.BACKEND_JWT}`,

View File

@@ -1,21 +1,15 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next"; import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase"; import {app} from "@/firebase";
import {getFirestore, doc, getDoc, deleteDoc} from "firebase/firestore"; import {getFirestore, doc, getDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {PERMISSIONS} from "@/constants/userPermissions";
const db = getFirestore(app); const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) { async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res);
if (req.method === "DELETE") return del(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ok: false}); res.status(401).json({ok: false});
return; return;
@@ -30,34 +24,8 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
res.status(200).json({ res.status(200).json({
id: docSnap.id, id: docSnap.id,
...docSnap.data(), ...docSnap.data(),
module,
}); });
} else { } else {
res.status(404).json(undefined); res.status(404).json(undefined);
} }
} }
async function del(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {module, id} = req.query as {module: string; id: string};
const docRef = doc(db, module, id);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
if (!PERMISSIONS.examManagement.delete.includes(req.session.user.type)) {
res.status(403).json({ok: false});
return;
}
await deleteDoc(docRef);
res.status(200).json({ok: true});
} else {
res.status(404).json({ok: false});
}
}

View File

@@ -1,41 +0,0 @@
// 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, query, where} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {shuffle} from "lodash";
import {Exam} from "@/interfaces/exam";
import {Stat} from "@/interfaces/user";
import {Module} from "@/interfaces";
import axios from "axios";
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;
}
if (req.session.user.type !== "developer") {
res.status(403).json({ok: false});
return;
}
const {module} = req.query as {module: Module};
switch (module) {
case "reading":
const result = await axios.get(
`${process.env.BACKEND_URL}/reading_passage_1?topic=football manager video game&exercises=multipleChoice&exercises=trueFalse&exercises=fillBlanks&exercises=writeBlanks`,
{headers: {Authorization: `Bearer ${process.env.BACKEND_JWT}`}},
);
res.status(200).json(result.data);
return;
}
res.status(200).json({ok: true});
}

Some files were not shown because too many files have changed in this diff Show More